哈希表
一、簡介
如果所有的鍵都是小整數,那麽我們可以用一個數組來實現無序的符號表,將鍵作為數組的索引i而數組中i(鍵)處儲存的就是對應的值。
這樣就可以快速地訪問任意鍵的值,哈希表是這種簡易方法的拓展並能夠處理更加復雜類型的鍵。
哈希表需要用算術操作將鍵轉換為數組的索引來訪問數組中的鍵值對。
哈希表的查找算法主要分為兩步:
第一步是用哈希函數將鍵轉換為數組的一個索引,理想情況下不同的鍵都能轉換為不同的索引值,但是實際上會有多個鍵哈希到到相同索引值上。
因此,第二步就是處理碰撞沖突的過程。這裏有兩種處理碰撞沖突的方法:separate chaining(拉鏈法)和linear probing(線性探測法)。
哈希表是算法在時間和空間上做出權衡的經典例子。
如果沒有內存限制,算法可以直接將鍵作為數組(可能是超級大)的索引,那麽所有的查找操作只需要訪問一次內存即可完成。但這種理想情況一般不會出現,因為當鍵很多時,需要的內存太大。
如果沒有時間限制,算法可以使用無序數組並進行順序查找,這樣只需要很少的內存。
哈希表使用了適度的空間和時間並在這兩種極端之間找到了一種平衡。
算法只需要調整某些參數就可以在時間和空間之間做出取舍,這裏需要使用概率論的經典結論來選擇適當的參數。
概率論是數學分析中的重大成果,哈希表利用了這些知識。使用哈希表可以使應用擁有常數級別的查找和插入操作的符號表。這使得其在很多情況下成為實現簡單符號表的最佳選擇。
二、哈希函數
如果算法用一個大小為M的數組來儲存鍵值對,那麽需要一個能夠將任意鍵轉為該數組範圍內的索引(0-M-1)的哈希函數。
這個哈希函數應該易於計算並且能夠均勻分布所有的鍵。即對於任意鍵,0到M-1之間的每個整數都有相等可能性與之對應。
1、正整數
將整數哈希最常用的方法是除留余數法。這個方法選擇大小為素數M的數組,對於任意正整數k,計算k除以M的余數。
如果M不是素數的話,將不能有效利用鍵中所包含的所有信息,導致算法不能均勻地分布所有鍵。
比如,鍵值是十進制而M為10k,則哈希函數只能利用最後k位(10001%100=01,只利用了後2位)。
2、浮點數
四舍五入的方法比較容易理解,但有缺陷,因為這種情況高位起的作用更大,地位對哈希的結果沒什麽影響。
更好的方法是先將浮點數轉為為二進制數,再用除留余數法。
3、字符串
用除留余數法也能處理較長的鍵,如字符串。只是將字符串當做一個大整數。
int hash = 0; for(int i = 0; i < s.length(); i++) hash = (R * hash + s.charAt(i)) % M;
這種計算把字符串當做一個N位的R進制值,並將其除以M並取余。
這裏要註意字符串表示的數字,高位在前,即s.charAt(0)為最高位。
R取值要足夠小,使之不造成溢出,這裏選31。
Java的String默認實現采用了類似的方法:
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
只是這裏的M值默認為int的最大值加1。
4、組合鍵
如果鍵的類型含有多個整型變量,算法可以向String一樣,將其混合起來。
5、Java的約定
每種數據類型都需要相應的哈希函數,Java中每個數據類型都繼承了hashCode方法。
每個數據類型的hashCode方法都必須和equas方法一致。
也就是說,如果a.equals(b)方法返回true,那麽a.hashCode和b.hashCode的返回值相同。
相反兩個對象的哈希值不一樣,那這兩個對象是不同的。如果兩個對象的哈希值一樣,那這兩個對象不一定是相同的。
總:hashCode是equals的必要不充分條件。
6、將hashCode的返回值轉換為數組索引
因為實際應用需要的是數組的索引而不是一個32位的整數,所以在實際實現中會將默認的hashCode方法和除留余數法結合起來產生一個0大M-1的整數。
private int hash(Key x) { return (x.hashCode() & 0x7fffffff) % M; }
7、自定義hashCode方法
自定義hashCode方法必須將鍵平均分布到32位整數中去。
8、緩存
如果哈希值計算很麻煩,那麽可以將每個鍵的哈希值緩存起來(儲存到變量hash),第一次調用時計算,之後都是直接返回哈希值。
一個優秀的哈希方法需要滿足三個條件:
一致性——等價的鍵必須產生相等的哈希值
高效性——計算簡便
均勻性——均勻地哈希所有鍵
保證均勻性的最好辦法是保證鍵的每一位都在哈希值的計算中起到了相同的作用。
9、假設
假設:算法使用的哈希函數能夠將所有鍵均勻並獨立地分布到0和M-1之間。
這個假設是實際上無法達到的理想模型,但是是算法實現哈希函數的指導思想。
三、基於拉鏈法的哈希表
1、簡介
哈希函數將鍵轉化為數組索引,第二步就是需要進行碰撞處理。
一種最直接的方法是將大小為M的數組的每個元素指向一條鏈表,鏈表的每個節點都儲存了哈希值為該位置數組下標的鍵值對。
這種方法稱為拉鏈法(separate chaining)。因為沖突的元素都放在同一個鏈表上。
這種方法的思想就是:選擇足夠大的M使得鏈表都盡可能短以保證高效查找。
查找順序:先根據哈希值找到相應的鏈表,然後遍歷鏈表查找相應的鍵。
2、實現
這裏可以用兩種方式實現,第一種是用原始的鏈表數據類型,第二種是用之前已經實現了的SequentialSearchST。
算法使用了M條鏈表來保存N個鍵,所以鏈表的平均長度是N/M。
官方實現:
public class SeparateChainingHashST<Key, Value> { private static final int INIT_CAPACITY = 4; private int n; // number of key-value pairs private int m; // hash table size private SequentialSearchST<Key, Value>[] st; // array of linked-list symbol tables /** * Initializes an empty symbol table. */ public SeparateChainingHashST() { this(INIT_CAPACITY); } /** * Initializes an empty symbol table with {@code m} chains. * @param m the initial number of chains */ public SeparateChainingHashST(int m) { this.m = m; st = (SequentialSearchST<Key, Value>[]) new SequentialSearchST[m]; for (int i = 0; i < m; i++) st[i] = new SequentialSearchST<Key, Value>(); } // resize the hash table to have the given number of chains, // rehashing all of the keys private void resize(int chains) { SeparateChainingHashST<Key, Value> temp = new SeparateChainingHashST<Key, Value>(chains); for (int i = 0; i < m; i++) { for (Key key : st[i].keys()) { temp.put(key, st[i].get(key)); } } this.m = temp.m; this.n = temp.n; this.st = temp.st; } // hash value between 0 and m-1 private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % m; } /** * Returns the number of key-value pairs in this symbol table. * * @return the number of key-value pairs in this symbol table */ public int size() { return n; } /** * Returns true if this symbol table is empty. * * @return {@code true} if this symbol table is empty; * {@code false} otherwise */ public boolean isEmpty() { return size() == 0; } /** * Returns true if this symbol table contains the specified key. * * @param key the key * @return {@code true} if this symbol table contains {@code key}; * {@code false} otherwise * @throws IllegalArgumentException if {@code key} is {@code null} */ public boolean contains(Key key) { if (key == null) throw new IllegalArgumentException("argument to contains() is null"); return get(key) != null; } /** * Returns the value associated with the specified key in this symbol table. * * @param key the key * @return the value associated with {@code key} in the symbol table; * {@code null} if no such value * @throws IllegalArgumentException if {@code key} is {@code null} */ public Value get(Key key) { if (key == null) throw new IllegalArgumentException("argument to get() is null"); int i = hash(key); return st[i].get(key); } /** * Inserts the specified key-value pair into the symbol table, overwriting the old * value with the new value if the symbol table already contains the specified key. * Deletes the specified key (and its associated value) from this symbol table * if the specified value is {@code null}. * * @param key the key * @param val the value * @throws IllegalArgumentException if {@code key} is {@code null} */ public void put(Key key, Value val) { if (key == null) throw new IllegalArgumentException("first argument to put() is null"); if (val == null) { delete(key); return; } // double table size if average length of list >= 10 if (n >= 10*m) resize(2*m); int i = hash(key); if (!st[i].contains(key)) n++; st[i].put(key, val); } /** * Removes the specified key and its associated value from this symbol table * (if the key is in this symbol table). * * @param key the key * @throws IllegalArgumentException if {@code key} is {@code null} */ public void delete(Key key) { if (key == null) throw new IllegalArgumentException("argument to delete() is null"); int i = hash(key); if (st[i].contains(key)) n--; st[i].delete(key); // halve table size if average length of list <= 2 if (m > INIT_CAPACITY && n <= 2*m) resize(m/2); } // return keys in symbol table as an Iterable public Iterable<Key> keys() { Queue<Key> queue = new Queue<Key>(); for (int i = 0; i < m; i++) { for (Key key : st[i].keys()) queue.enqueue(key); } return queue; } /** * Unit tests the {@code SeparateChainingHashST} data type. * * @param args the command-line arguments */ public static void main(String[] args) { SeparateChainingHashST<String, Integer> st = new SeparateChainingHashST<String, Integer>(); for (int i = 0; !StdIn.isEmpty(); i++) { String key = StdIn.readString(); st.put(key, i); } // print keys for (String s : st.keys()) StdOut.println(s + " " + st.get(s)); } }
註:這個實現動態調整了鏈表數組的大小,而且動態調整鏈表數組大小需要重新哈希所有鍵,因為M的大小發生了變化。
3、性能分析
結論1:在一張含有M條鏈表和N個鍵的哈希表中,任意一條鏈表的長度均在N/M的常數因子範圍內的概率無限趨向於1。
這個結論完全依賴於這個假設:算法使用的哈希函數能夠將所有鍵均勻並獨立地分布到0和M-1之間。
結論2:在一張含有M條鏈表和N個鍵的哈希表中,未命中查找和插入操作所需的比較次數為~N/M。
4、有序性相關操作
哈希表不是合適的選擇,這回導致線性級別的運行時間。
四、基於線性探測法的哈希表
1、簡介
實現哈希表的另一種方法是用大小為M的數組保存N個鍵值對,其中M>N。這種方法依靠數組中的空位解決碰撞沖突。
當發生碰撞時,算法檢查下一個位置(將索引加1)。
這樣線性探測可能會產生三種結果:
命中,該位置的鍵和要查找的鍵相同。
未命中,該鍵為空。
繼續查找,該位置的鍵和要查找的鍵不相同。
查找的思路:先用哈希函數找到鍵在數組中的索引,檢查其中的鍵和被查找的鍵是否相同,如果不同則繼續查找,直到找到該鍵或者遇到一個空元素。
2、實現
官方實現:
使用了兩個數組,一個用於保存鍵,一個用於保存值。
public class LinearProbingHashST<Key, Value> { private static final int INIT_CAPACITY = 4; private int n; // number of key-value pairs in the symbol table private int m; // size of linear probing table private Key[] keys; // the keys private Value[] vals; // the values /** * Initializes an empty symbol table. */ public LinearProbingHashST() { this(INIT_CAPACITY); } /** * Initializes an empty symbol table with the specified initial capacity. * * @param capacity the initial capacity */ public LinearProbingHashST(int capacity) { m = capacity; n = 0; keys = (Key[]) new Object[m]; vals = (Value[]) new Object[m]; } /** * Returns the number of key-value pairs in this symbol table. * * @return the number of key-value pairs in this symbol table */ public int size() { return n; } /** * Returns true if this symbol table is empty. * * @return {@code true} if this symbol table is empty; * {@code false} otherwise */ public boolean isEmpty() { return size() == 0; } /** * Returns true if this symbol table contains the specified key. * * @param key the key * @return {@code true} if this symbol table contains {@code key}; * {@code false} otherwise * @throws IllegalArgumentException if {@code key} is {@code null} */ public boolean contains(Key key) { if (key == null) throw new IllegalArgumentException("argument to contains() is null"); return get(key) != null; } // hash function for keys - returns value between 0 and M-1 private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % m; } // resizes the hash table to the given capacity by re-hashing all of the keys private void resize(int capacity) { LinearProbingHashST<Key, Value> temp = new LinearProbingHashST<Key, Value>(capacity); for (int i = 0; i < m; i++) { if (keys[i] != null) { temp.put(keys[i], vals[i]); } } keys = temp.keys; vals = temp.vals; m = temp.m; } /** * Inserts the specified key-value pair into the symbol table, overwriting the old * value with the new value if the symbol table already contains the specified key. * Deletes the specified key (and its associated value) from this symbol table * if the specified value is {@code null}. * * @param key the key * @param val the value * @throws IllegalArgumentException if {@code key} is {@code null} */ public void put(Key key, Value val) { if (key == null) throw new IllegalArgumentException("first argument to put() is null"); if (val == null) { delete(key); return; } // double table size if 50% full if (n >= m/2) resize(2*m); 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++; } /** * Returns the value associated with the specified key. * @param key the key * @return the value associated with {@code key}; * {@code null} if no such value * @throws IllegalArgumentException if {@code key} is {@code null} */ public Value get(Key key) { if (key == null) throw new IllegalArgumentException("argument to get() is null"); for (int i = hash(key); keys[i] != null; i = (i + 1) % m) if (keys[i].equals(key)) return vals[i]; return null; } /** * Removes the specified key and its associated value from this symbol table * (if the key is in this symbol table). * * @param key the key * @throws IllegalArgumentException if {@code key} is {@code null} */ public void delete(Key key) { if (key == null) throw new IllegalArgumentException("argument to delete() is null"); if (!contains(key)) return; // find position i of key int i = hash(key); while (!key.equals(keys[i])) { i = (i + 1) % m; } // delete key and associated value keys[i] = null; vals[i] = null; // rehash all keys in same cluster i = (i + 1) % m; while (keys[i] != null) { // delete keys[i] an vals[i] and reinsert Key keyToRehash = keys[i]; Value valToRehash = vals[i]; keys[i] = null; vals[i] = null; n--; put(keyToRehash, valToRehash); i = (i + 1) % m; } n--; // halves size of array if it‘s 12.5% full or less if (n > 0 && n <= m/8) resize(m/2); assert check(); } /** * Returns all keys in this symbol table as an {@code Iterable}. * To iterate over all of the keys in the symbol table named {@code st}, * use the foreach notation: {@code for (Key key : st.keys())}. * * @return all keys in this symbol table */ public Iterable<Key> keys() { Queue<Key> queue = new Queue<Key>(); for (int i = 0; i < m; i++) if (keys[i] != null) queue.enqueue(keys[i]); return queue; } // integrity check - don‘t check after each put() because // integrity not maintained during a delete() private boolean check() { // check that hash table is at most 50% full if (m < 2*n) { System.err.println("Hash table size m = " + m + "; array size n = " + n); return false; } // check that each key in table can be found by get() for (int i = 0; i < m; i++) { if (keys[i] == null) continue; else if (get(keys[i]) != vals[i]) { System.err.println("get[" + keys[i] + "] = " + get(keys[i]) + "; vals[i] = " + vals[i]); return false; } } return true; } /** * Unit tests the {@code LinearProbingHashST} data type. * * @param args the command-line arguments */ public static void main(String[] args) { LinearProbingHashST<String, Integer> st = new LinearProbingHashST<String, Integer>(); for (int i = 0; !StdIn.isEmpty(); i++) { String key = StdIn.readString(); st.put(key, i); } // print keys for (String s : st.keys()) StdOut.println(s + " " + st.get(s)); } }
註:NULL表示一簇鍵的結束。
delete方法比較復雜,需要在下圖的哈希表中刪除C,直接在該鍵所在的位置設為null是不行的。這樣會導致其後邊的S和H無法被找到,所以需要將S和H重新插入,即要刪除的鍵同個簇中右側的鍵。
和拉鏈法一樣,線性探測法哈希表的性能也依賴於N/M的比值。
只不過意義不一樣,這裏是N/M是哈希表中被占用的空間比例,算法使用動態調整數組大小方法來保證使用率在1/8到1/2之間。
和拉鏈法一樣,動態調整數組大小需要重新哈希所有鍵。
3、數學分析
線性探測的性能取決於元素在插入數組後聚集成的一組連續的條目,也叫鍵簇。
鍵簇短小才能保證高的效率。隨著插入的鍵越來越多,這個要求越來越難滿足,較長的鍵簇會越來越多。
結論:在一張大小為M,且含有N=αM個鍵的基於線性探測哈希表中,基於假設(算法使用的哈希函數能夠將所有鍵均勻並獨立地分布到0和M-1之間),命中和未命中查找查找所需的探測次數為分別為:
特別當α=1/2時,這兩個值分別為3/2,、5/2。單α趨於1時,這些估計值精確度會下降。
不過我們不用擔心這個情況,算法會保證α小於1/2。
當使用率小於1/2時,探測的預計次數在1.5和2.5之間。
在put函數和delete函數,可以看到有相應的動態調整數組大小的語句。保證α小於1/2,並大於某個值。
哈希表