天天看點

九章算法基礎班 Python+Java

\/ ----- 》》  yofank

哈希

哈希(Hash)也稱為散列,就是把任意長度的輸入,通過雜湊演算法,變換成固定長度的輸出,這個輸出值就是散列值。

哈希表

哈希表(Hash table,也叫散清單),是根據關鍵碼值(Key value)而直接進行通路的資料結構。也就是說,它通過把關鍵碼值映射到表中一個位置來通路記錄,以加快查找的速度。這個映射函數叫做散列函數,存放記錄的數組叫做散清單。

散清單是算法在時間和空間上作出權衡的經典例子

如果沒有記憶體限制,我們可以直接将鍵作為(可能是一個超大的)數組的索引,那麼所有查找操作隻需要通路記憶體一次即可完成。但這種理想情況不會經常出現,因為當鍵很多時需要的記憶體太大。另一方面,如果沒有時間限制,我們可以使用無序數組并進行順序查找,這樣就隻需要很少的記憶體。而散清單則使用了适度的空間和時間并在這兩個極端之間找到了一種平衡。事實上,我們不必重寫代碼,隻需要調整雜湊演算法的參數就可以在空間和時間之間作出取舍。我們會使用機率論的經典結論來幫組我們選擇适當的參數。

使用Hash的查詢算法分為兩步:

① 用Hash函數将被查找的鍵轉化為數組的一個索引。

理想情況下,不同的鍵都能轉化為不同的索引值。當然,這隻是理想情況,是以我們需要面對兩個或者多個鍵都會散列到相同的索引值的情況。

② 處理碰撞沖突的過程

Hash函數

一個好的Hash函數應滿足下列兩個要求:
  • 一緻性 —— 等價(equal)的key必然産生相等的hash code
  • 高效性 —— 高效的計算
  • 均勻性 —— 均勻地散列所有的key

幾種常見的Hash算法:

① 除法哈希法

公式:​

​hash(key) = key mod M​

注意:M 通常為“素數”
② 乘法哈希法

​hash(key) = floor( M/W * ( a * key mod W) )​

其中 floor 表示對表達式進行下取整

注意:
  1. 通常設定 M 為 2 的幂次方。
  2. W 為計算機字長大小(也為2的幂次方)。
  3. a 為一個非常接近于W的數。

其實,“乘法哈希”的思想就是:提取關鍵字 key 中間 k 位數字。

③ 斐波那契(Fibonacci)哈希法

也就是當 “乘法哈希法” 的 ​

​a ≈ W/φ​

​,​

​1/φ ≈ (√5-1)/2 = 0.618 033 988​

​ 時情況。而,​

​1/φ ≈ (√5-1)/2 = 0.618 033 988​

​,可稱為黃金分割點。

Q:那,為什麼“斐波那契(Fibonacci)哈希法”能夠更好的将關鍵字 key 進行散列了?

A:Why is 'a ≈ W/φ' special? It has to do with what happens to consecutive keys when they are hashed using the multiplicative method. As shown in Figure ‘Fibonacci Hashing’ , consecutive keys are spread out quite nicely. In fact, when we use 'a ≈ W/φ' to hash consecutive keys, the hash value for each subsequent key falls in between the two widest spaced hash values already computed. Furthermore, it is a property of the golden ratio, φ , that each subsequent hash value divides the interval into which it falls according to the golden ratio!

九章算法基礎班 Python+Java

常見的兩種解決碰撞的方法

① 拉鍊法(separate chaining)

一個Hash函數能夠将鍵轉換為數組索引,Hash算法的第二部是碰撞處理,也就是處理兩個或多個鍵的Hash值相同的情況。一種直接的辦法是将大小為 M 的數組中的每個元素指向一條連結清單,連結清單中的每個結點都存儲了Hash值為該元素的索引的鍵值對。這種方法被稱為“拉鍊法”,是以發生沖突的元素都被存儲在一個連結清單中。

九章算法基礎班 Python+Java
  • 基本思想

    這種方法的基本思想就是選擇足夠大的M,使得所有連結清單都盡可能短以保證高效的查找。查找分兩步:首先根據Hash值找到對應的連結清單,然後沿着連結清單順序查找對應的鍵。

    當你能夠預知所需要的符号表的大小時,該方法能夠得到不錯的性能。一種更可靠的方案是動态調整連結清單數組的大小,這樣無論在符号表中有多少鍵值對都能保證連結清單較短。

  • 基于拉鍊法的符号表(實作)
public class SeparateChainingHashST<Key, Value>
{
private int N;                              // number of key-value pairs
private int M;                              // hash table size
private SequentialSearchST<Key, Value>[] st;    // array of ST objects
public SeparateChainingHashST()
    {
this(997);
    }
public SeparateChainingHashST(int M)
    {
// Create M linked lists.
this.M = M;
st = (SequentialSearchST<Key, Value>[])new SequentialSearchST[M];
for (int i = 0; i < M; i++)
        {
st[i] = new SequentialSearchST();
        }
    }
private int hash(Key key)
    {
return (key.hashCode() & 0x7fffffff) % M;
    }
public Value get(Key key)
    {
return (Value)st[hash(key)].get(key);
    }
public void put(Key key, Value val)
    {
st[hash(key)].put(key, val);
    }
public Iterable<Key> keys()     // See Exercise 3.4.19
}
      
public class SequentialSearchST<Key, Value>
{
private Node first;         // first node in the linked list
private class Node
    {
// linked-list node
Key key;
Value val;
Node next;
public Node(Key key, Value val, Node next)
        {
this.key = key;
this.val = val;
this.next = next;
        }
    }
public Value get(Key key)
    {
// Search for key, return associated value.
for (Node x = first; x != null; x = x.next)
        {
if (key.equals(x.key))
            {
return x.val;   // search hit
            }
        }
return null;            // search miss
    }
public void put(Key key, Value val)
    {
// Search for key. Update value if found; grow table if new.
for (Node x = first; x != null; x = x.next)
        {
if (key.equals(x.key))
            {
x.val = val;
return;                     // Search hit : update val.
            }
        }
first = new Node(key, val, first);  // Search miss: add new node.
    }
}
      
  • 有序性相關的操作

    Hash 最主要的目的在于均勻地将鍵散布開來,是以在計算 Hash 後鍵的順序資訊就丢失了。

    對于需要快速找到最大或者最小的鍵,或是查找某個範圍内的鍵,哈希表都不是合适的選擇,因為這些操作的運作時間都将會是線性的。

② 線性探測法(linear probing)
  • “開放位址”哈希表

    實作哈希表的另一種方式就是用大小為 M 的數組儲存 N 個鍵值對,其中 M > N。我們需要依靠數組中的空位解決碰撞沖突。基于這種政策的所有方法被統稱為“開放位址”哈希表

  • 線性探測法(“開放位址”哈希表的一種實作方式)

    開放位址哈希表中最簡單的方法叫做“線性探測”法:當碰撞發生時(當一個鍵的Hash值已經被另一個不同的鍵占用),我們直接檢測哈希表中的下一個位置(将索引值加 1)。這樣的線性探測可能會産生三種結果:

    a)命中,該位置的鍵和被查找的鍵相同;

    b)未命中,鍵為空(該位置沒有鍵)

    c)繼續查找,該位置的鍵和被查找的鍵不同。

    我們用Hash函數找到鍵在數組中的索引,檢查其中的鍵和被查找的鍵是否相同。如果不同則繼續查找(将索引增大,到達數組結尾時折回數組的開頭),直到找到該鍵或者遇到一個空元素。

    我們習慣将檢查一個數組位置是否含有被查找的鍵的操作稱作探測。在這裡它可以等價于我們一直使用的比較,不過有些探測實際上是在測試鍵是否為空。

  • 核心思想

    “開放位址”哈希表的核心思想是與其将記憶體用于連結清單,不如将它們作為哈希表的空元素。這些空元素可以作為查找結束的标志。

  • 基于線性探測的符号表(實作)
public class LinearProbingHashST<Key, Value>
{
private int N;          // number of key-value pairs in the table
private int M = 16;     // size of linear-probing table
private Key[] keys;     // the keys
private Value[] vals;   // the values
public LinearProbingHashST()
    {
keys = (Key[])new Object[M];
vals = (Value[])new Object[M];
    }
private int hash(Key key)
    {
return (key.hashCode() & 0x7fffffff) % M;
    }
private void resize()   // see page 474
public void put(Key key, Value val)
    {
if (N >= M/2)
        {
resize(2*M);    // double M (see text)
        }
int i;
for (i = hash(key); keys[i] != null; i = (i+1) % M)
        {
if (keys[i].equals(key))
            {
vals[i] = val;
return;
            }
        }
keys[i] = key;
vals[i] = val;
N++;
    }
public Value get(Key key)
    {
for (int i = hash(key); keys[i] != null; i = (i+1) % M)
        {
if (keys[i].equals(key))
            {
return vals[i];
            }
        }
return null;
    }
}
      

要查找一個鍵,我們從它的Hash值開始順序查找,如果找到則命中,如果遇到空元素則未命中。

  • 删除操作

    如何從基于線性探測的哈希表中删除一個鍵?仔細想一想,你會發現直接将該鍵所在的位置設為null是不行的,因為這會使得在此位置之後的元素無法被查找。

    是以,我們需要将簇中被删除鍵的右側的所有鍵重新插入哈希表。

public void delete(Key key)
{
if (!contains(key))
    {
return;
    }
int i = hash(key);
while (!key.equals(keys[i]))
    {
i = (i+1) % M;
    }
keys[i] = null;
vals[i] = null;
i = (i+1) % M;
while (keys[i] != null)
    {
Key keyToRedo = keys[i];
Value valToRedo = vals[i];
keys[i] = null;
vals[i] = null;
N--;
put(keyToRedo, valToRedo);
i = (i+1) % M;
    }
N--;
if (N > 0 && N == M/8)
    {
resize(M/2);
    }
}
      
  • 鍵簇

    線性探測的平均成本取決于元素在插入數組後聚內建的一組連續的條目,也叫做鍵簇。

    如圖????所示,例如,在示例中插入鍵 C 會産生一個長度為 3 的鍵簇( A C S )。這意味着插入 H 需要探測 4 次,因為 H 的Hash值為該鍵簇的第一個位置。

    九章算法基礎班 Python+Java
    顯然,短小的鍵簇才能保證較高的效率。随着插入的鍵越來越多,這個要求很難滿足,較長的鍵簇也會越來越多。另外因為(基于均勻性假設)數組的每個位置都有相同的可能性被插入一個新鍵,長鍵簇被選中的可能被短鍵簇更大,同時因為新鍵的Hash值無論落在簇中的任何位置都會使簇的長度加 1(甚至更多,如果這個簇和相鄰的簇之間隻有一個空元素相隔的話)
  • 線性探測法的性能分析

    開放位址哈希表的性能依賴于 α = N/M 的比值。我們将 α 稱為哈希表的使用率。對于基于拉鍊法的哈希表,α 是每條拉連結清單的長度,是以一般大于 1 ;對于基于線性探測的哈希表,α 是表中已被占用的空間的比例,它是不可能大于 1 的。事實上,在 LinearProbingHashST 中我們不允許 α 達到 1 (清單被占滿),因為此時未命中的查找會導緻無限循環(因為,在元素不存在的情況下,空元素作為查找結束的标志)。為了保證性能,我們會動态調整數組的大小來保證使用率在 1/8 到 1/2 之間。

假設J(均勻哈希假設)。我們使用的Hash函數能夠均勻并獨立地将所有的鍵散布于 0 到 M-1 之間。

讨論。我們在實作Hash函數時随意指定了很多參數,這顯然無法實作一個能夠在數學意義上均勻并獨立地散布所有鍵的Hash函數。事實上,深入的理論研究報告告訴我們想要找到一個計算簡單但又擁有一緻性和均勻性的Hash函數是不太可能的。在實際應用中,和使用 Math.random() 生成随機數一樣,大多數程式員都會滿足于随機數生成器類的Hash函數。很少人會去檢驗獨立性,而這個性質一般都不會滿足。

命題 M :在一張大小為 M 并含有 N = α * M 個鍵的基于線性探測的哈希表中,基于假設 J ,命中和未命中的查找所需的探測次數分别為:

九章算法基礎班 Python+Java

特别是當 α 約為 1/2 時,查找命中所需要的探測次數約為 3/2,未命中所需要的約為 5/2。當 α 趨于 1 時,這些估計值的精确度會下降,但不需要擔心這些情況,因為我們會保證哈希表的使用率小于 1/2。

當哈希表快滿的時候查找所需的探測次數是巨大的(α 越趨近于1,由公式可知探測的次數也越來越大),但當使用率 α 小于 1/2 時探測的預計次數隻在 1.5 到 2.5 之間。

繼續閱讀