散列表是最常用的資料結構之一,特別是 ruby js等動態語言在文法層次上對它進行了支援。只是在java中,有那麼點點繞(每次使用的時候,心裡會疙瘩一下,不知道你們有沒有這種感覺)。
private int hash(int hashCode) { int capacity = table.length; return hashCode % capacity; } capacity 不應該是 2 的冪, 否則的話值為hashCode的低 k 位, 高位就會浪費掉,可能會造成很多碰撞
可以選擇2的整數冪不大接近的質數。
現在運行測試,是通過滴:)
但是等等, 有時候我們需要這樣:
Java代碼 public void test2() { HashTable ht = new HashTable(); Object o1 = new Object(); ht.put("o1", o1); Object anotherO1 = new Object(); ht.put("o1", anotherO1); // 更新 assertEquals(anotherO1, ht.get("o1")); } 我們需要重構代碼,把key也給儲存起來。
首先添加一個結構, 儲存key 和value
Java代碼 public class HashTable { public static class Entry { private String key; private Object value; public Entry(String key, Object value) { this.key = key; this.value = value; } public String getKey() { return key; } public Object getValue() { return value; } } private Entry[] table = new Entry[1000]; // 原來的Object[] 改成 Entry[] 重構put
Java代碼 public void put(String key, Object value) { int hash = hash(key.hashCode()); if (table[hash] == null || table[hash].getKey().equals(key)) { table[hash] = new Entry(key, value); } else{ throw new RuntimeException("撞車啦,怎麼辦?"); } } 重構get
Java代碼 public Object get(String key) { int hash = hash(key.hashCode()); Entry entry = table[hash]; return entry == null ? null : entry.getValue(); } 可以看到,測試又通過了:)
再看乘法散列
Java代碼 private int hash(int hashCode) { int capacity = table.length; double a = 0.6180334; // 萬能的黃金分割 return (int) (((hashCode * a) % 1) * capacity); } 用常數(A) 乘hashCode 取小數 再乘capacity
Knuth認為 黃金分割數 是比較理想的值((根號5 - 1) / 2 ~ 0.618), 股民朋友們一定認識
乘法散列 的優點是:
對 capacity 沒有什麼特別的要求, 一般選擇它為 2 的整數冪。
因為這樣可以使用移位代替乘法計算。
然後黃金分割數 A 如果可以表示成 2654435769 / (2 ^32)
那就可以簡化成://
假設電腦字長是w位,這裡w為32位,hashCode * 2654435769,是一個2w位的值,前32位為高位,後32位為低位,((1 << 32) - 1) 為32個1,與hashCode * 2654435769相與(&)即可保留低32位的值,然後低32位中的前p位即為散列值,也就是低32位右移(32-p)
((hashCode * 2654435769) & ((1 << 32) - 1) ) // 只保留 32 位 >> (32 - p)
重購代碼試試看:
首先,數組空間大小為 2 ^ p
Java代碼 private int p = 10; private Entry[] table = new Entry[1 << p];
然後:
Java代碼 private int hash(int hashCode) { long k = 2654435769L; return (int)(((k * hashCode) & ((1L << 32) - 1)) >> (32 - p)); } 測試還是通過滴。
下面, 讓我們加多一點元素,搞壞它。
Java代碼 @Test public void test3() { HashTable ht = new HashTable(); for (int i = 0; i < 1000; i++) { Object o = new Object(); ht.put("key" + i, o); assertEquals(o, ht.get("key" + i)); System.out.println("Ok: " + i); } } 運行測試,失敗, 可以看到控制台只輸出到 108
RuntimeException, 撞車了怎麼辦?
可以採用連結法,開放定址法搞定
先來 連結法
首先重構Entry, 把自己串起來
Java代碼 public static class Entry { private String key; private Object value; private Entry next; public Entry(String key, Object value) { this(key, value, null); } public Entry(String key, Object value, Entry next) { this.key = key; this.value = value; this.next = next; } public String getKey() { return key; } public Object getValue() { return value; } public void setValue(Object value) { this.value = value; } public Entry getNext() { return next; } } 同時也添加了一個 setValue 方法, 這樣更容易在鏈表中“更新元素
然後重構put
Java代碼 public void put(String key, Object value) { int hash = hash(key.hashCode()); Entry entry = table[hash]; if (entry == null) { // 位置沒被使用過, 直接用 table[hash] = new Entry(key, value); return; } for (Entry o = entry; o != null; o = o.getNext()) { if (o.getKey().equals(key)) { // 看看key節點是否存在, 如果是,就更新它 o.setValue(value); return; } } table[hash] = new Entry(key, value, entry); // 這裡我們串起來 } 可以看到,測試正常運行:)
但是隨著散列表中的元素越來越多,碰撞機率也越來越大,最好當元素數量達到一定量時,自動擴充容量,這樣才能保證其優異的尋找效能。
但是我們先看看,現在的散列表, 運行test3時,碰撞幾率是多少。
為此,我們重構, 發生碰撞時, 統計次數。
Java代碼 private int size = 0; // 統計表中元素個數 private int collideCount = 0; // 統計碰撞次數 public int getSize() { return size; } public float getCollideRate() { return size > 0 ? ((float) collideCount) / size : 0; } Java代碼 public void put(String key, Object value) { int hash = hash(key.hashCode()); Entry entry = table[hash]; if (entry == null) { table[hash] = new Entry(key, value); size++; // 這裡 return; } collideCount++; // 這裡 for (Entry o = entry; o != null; o = o.getNext()) { if (o.getKey().equals(key)) { o.setValue(value); return; } } table[hash] = new Entry(key, value, entry); size++; // 還有這 } 測試:
Java代碼 @Test public void test4() { HashTable ht = new HashTable(); for (int i = 0; i < 1000; i++) { ht.put("key" + i, new Object()); } System.out.println(ht.getCollideRate()); } 輸出:0.309
總的容量為 1024, 有1000個元素, 其中309個是發生碰撞。事故挺嚴重的。
下面我們重構HashTable類, 讓其每次達到容量的 0.75(裝載因子) 就擴充容量:)
Java代碼 private int p = 4; private Entry[] table = new Entry[1 << p]; private float loadFactor = 0.75f;
首先, 我們的初始化容量為 16個(1 << 4), 然後 load factor 為0.75
Java代碼 public void put(String key, Object value) { if (table.length * loadFactor < size) { resize(); } 然後在put 前檢查一下, 如有必要 resize
Java代碼 private void resize() { Entry[] old = table; p += 1; table = new Entry[1 << p]; size = 0; collideCount = 0; for (int i = 0; i < old.length; i++) { Entry entry = old[i]; while (entry != null) { put(entry.getKey(), entry.getValue()); entry = entry.getNext(); } } } 寫個測試:
Java代碼 @Test public void test5() { HashTable ht = new HashTable(); for (int i = 0; i < 1000; i++) { Object o = new Object(); ht.put("key" + i, o); assertEquals(o, ht.get("key" + i)); } System.out.println(ht.getSize()); assertTrue(ht.getSize() == 1000); System.out.println(ht.getCollideRate()); } 這個時候,同樣是添加到1000個, loadFactor 此時為 0.08
我們的散列表初始大小為16, 添加到1000個,要進行若干次 resize, resize開銷比較大。
我們可以重構代碼, 建構函式中指定容量大小,以避免不必要的resize開銷。
但這裡不做了,因為現在只是為了說明演算法, 但是使用 java.util.HashMap時,就曉得了。
解決碰撞還有開放定址法
也是灰常容易滴, 我們添加兩個方法, put2, 和 get2, 實現看看。
使用最簡單的 線性探查
Java代碼 public void put2(String key, Object value) { if (table.length * loadFactor < size) { resize(); } int hash = hash(key.hashCode()); Entry entry = table[hash]; int nextHash = hash; while (entry != null) { if (entry.getKey().equals(key)) { entry.setValue(value); return; } nextHash = (nextHash + 1) % table.length; // 看看下一個位置 entry = table[nextHash]; } table[nextHash] = new Entry(key, value); size++; if (hash != nextHash) { collideCount++; } } Java代碼 public Object get2(String key) { int hash = hash(key.hashCode()); Entry entry = table[hash]; while (entry != null) { if (entry.getKey().equals(key)) { return entry.getValue(); } hash = (hash + 1) % table.length; entry = table[hash]; } return null; } 同樣,寫一個測試
Java代碼 @Test public void test6() { HashTable ht = new HashTable(); for (int i = 0; i < 1000; i++) { Object o = new Object(); ht.put2("key" + i, o); assertEquals(o, ht.get2("key" + i)); } System.out.println(ht.getSize()); assertTrue(ht.getSize() == 1000); System.out.println(ht.getCollideRate()); } 線性探查比較容易實現, 但是容易造成“堆在一起”的問題, 書中稱為:一次群集
可以採用二次探查, 或雙重散列,更好地避免這種現象。
//----------
下面看看java.util.HashMap的實現,更好地瞭解散列表。
先看 put:
Java代碼 public V put(K key, V value) { if (key == null) // null 也可以為key return putForNullKey(value); int hash = hash(key.hashCode()); // 關心的地方 int i = indexFor(hash, table.length); // 關心的地方 for (Entry<K, V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); // 關心的地方 return null; } 代碼中 hash 和 indexFor addEntry 是我們關心的地方。
此外: HashMap 允許使用值為 null 的key
有一個 if 語句:
Java代碼
- if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
先看看 hash值是否相等, 再判斷equals
這也給出了我們重寫equals和 hash的原則: 如果你重寫了equals, 那麼你一定要重寫 hashCode, 如果兩個對象equals,那麼hashCode也一定要相等, 否則在HashMap等容器中將不能正確工作。參看《Effective Java》
再來看看 hash 和 indexFor (中文注釋是我加的)
Java代碼 /** * Applies a supplemental hash function to a given hashCode, which * defends against poor quality hash functions. This is critical * <strong>because HashMap uses power-of-two length hash tables</strong>, that * otherwise encounter collisions for hashCodes that do not differ * in lower bits. Note: Null keys always map to hash 0, thus index 0. */ static int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } /** * Returns index for hash code h. */ static int indexFor(int h, int length) { return h & (length-1); } hash 根據 原hashCode產生更好的散列值, 因為table的容量大小剛好為2的整數冪, 所以必須這樣做,否則hash code的高位將浪費(模數時) --- 見上面除法散列
indexFor: 等於 h % length,
所以,HashMap 採用 改進的除法散列
再看看 addEntry
Java代碼 void addEntry(int hash, K key, V value, int bucketIndex) { Entry<K, V> e = table[bucketIndex]; table[bucketIndex] = new Entry<K, V>(hash, key, value, e); if (size++ >= threshold) resize(2 * table.length); } table 也成倍擴充的