Evanalysis
1.2預計閱讀時間: 15 分鐘

1.2 Hash table 與 collision 策略

用 dictionary 操作、hash function、collision 與 chaining 去理解 hash table 為何以有序結構換取平均情況下的快速存取。

課程目錄

當 ADT、pointer 同基本 node 結構已經建立好之後,下一個問題就係:如何高效 支援 dictionary 類操作?Hash table 係其中一個標準答案。佢唔保留排序結構, 而係先用 hash function 將 key 跳到某個 bucket。

呢個跳轉好有力,但永遠唔會完美。整章內容都係用嚟解釋:呢個跳轉可以保證 乜嘢、collision 點樣出現,以及當多個 key 撞入同一位置時,實作點樣補救。

動機

先列出 hash table 需要支援的 dictionary 操作:

  • 為某個 key 插入 value;
  • 按 key 找返 value;
  • 刪除已存在嘅 key-value entry。

所以邏輯對象首先係 dictionary 或 symbol table。Hash table 只係實作呢個 ADT 的其中一種方法。

定義

Hashtable 作為 dictionary ADT

Hashtable 儲存 key-value pair,並用 bucket index 來支援 insert、lookup、 delete 等 dictionary 操作。

呢個定義已經暗示兩條設計線:

  • key 點樣被轉成 bucket index;
  • 如果兩個 key 去到同一個 index,實作會點做。

Hash function 應該做乜

定義

Hash function

Hash function 會將 key 映射成固定範圍內的整數 index,通常係 [0, H_SIZE)。

一個好 hash function 應具備四點:

  • 永遠回傳合法 bucket index;
  • 盡量減少 collision;
  • 盡量平均分散 key;
  • 計算本身要夠快。

最後一點唔可以忽略,因為如果 hash 規則本身太慢,就算分散效果好,lookup 都未必值得。

例題

一個簡單的字串 hash 例子

對字串 key,一種簡單的入門寫法可以係:

int Hash(char *s, int nBuckets) {
   int h = 0;
   while (*s != '\0') {
      h += *s;
      s++;
   }
   return h % nBuckets;
}

呢類規則容易計,但亦都容易撞。字元總和相等,或者除以 nBuckets 後餘數 相同的字串一定會 collision;互為字元重排的字串就係最直接的例子。呢段程式 亦假設 nBuckets > 0、輸入字元按非負值(例如 ASCII byte)解讀,而且累加 結果唔會超出 int 範圍。因此佢適合追蹤計算過程,但實戰實作應該使用定義清楚的 unsigned hash state 同更強的混合規則。

邊讀邊試

測試一個簡單的 hash-and-bucket 模型

這個 lab 用一條簡單 hash 規則把 key 映射到 bucket,讓你直接看到 collision 與 chaining。

Collision 數量: 0

KeysBucket 編號
cat4
dog6
cow0
cod2

Bucket 編號 0

cow

Bucket 編號 1

∅

Bucket 編號 2

cod

Bucket 編號 3

∅

Bucket 編號 4

cat

Bucket 編號 5

∅

Bucket 編號 6

dog

Collision 不是例外,而是常態

定義

Collision

若兩個不同 key 被映射到同一個 bucket index,便稱為 collision。

只要 bucket 數量有限,而 key 空間夠大,collision 就幾乎無可避免。所以 真正要問的唔係「如何完全避免 collision」,而係「有 collision 時仍然如何 保持 dictionary 正確」。

觀看 collision handling 如何保持 dictionary 正確

下面的短圖解會保留 ADT contract,同時比較兩種主要補救方式。Hashing 只決定由邊度開始搜尋;chaining 同 probing 則解釋 table 如何在 collision 之後仍不混淆不同 key。

Hash table collision 與正確性

看清 hash table 在兩個不同 key 發生 collision 時,如何仍保持 dictionary 操作正確。

Hashing 只是第一步。正確 lookup 和 update 仍取決於 collision strategy 是否沿 chain 或 probe sequence 檢查 key。

Chaining:同一個 bucket 下面掛一條鏈

Chaining 將碰撞的 entry 保留喺 bucket 旁邊的 linked list。

定義

Chaining

Chaining 會令每個 bucket 指向一條 linked list,將所有 hash 去同一個 bucket 的 key-value entry 串在一起。

呢個做法同上一節的 pointer / node 模型完全配合:

typedef struct cellT {
   char *key;
   void *value;
   struct cellT *next;
} cellT;

因此 lookup 可以分成兩層:

  1. 先 hash key,選出 bucket;
  2. 再只掃描該 bucket 底下的 linked list。

例題

Chaining 下的 lookup

假設 Hash("cat") = 3,而 bucket 3 的 chain 係

("cow", value1) -> ("cat", value2) -> ("cod", value3)。

當執行 Lookup(table, "cat") 時,程式會:

  1. 先算到 bucket 3;
  2. 沿 bucket 3 的 linked list 掃描;
  3. 逐個比較 key;
  4. 找到 "cat" 時回傳 value2。

Collision 並冇破壞正確性,只係令該 bucket 的搜尋稍為慢咗。

Open addressing:唔用鏈,而係繼續 probing

Open addressing 將所有 entry 留喺 table 內。呢種方法唔係喺 bucket 下面掛 linked list, 而係當首選 slot 被佔用時,繼續去 table 內其他位置 probing。

常見 probing 方式包括:

  • linear probing;
  • quadratic probing;
  • double hashing。

同 chaining 最大分別係:額外工作喺邊度發生。

  • chaining 將 collision 放去 bucket side 的 linked structure;
  • open addressing 將 collision 變成 table 內部的續搜。

所以 clustering 同 probe sequence 設計就變得非常重要。Deletion 另外需要規則 去保留 probe path;本節將 delete 列為 ADT 操作,但唔展開呢個獨立 invariant。

定理

Collision handling 係正確性的一部分

如果 collision 策略設計錯,即使 hash function 本身合法,table 仍然可以 回傳錯 value,甚至丟失 entry。

Load factor 點解重要

即使 hash function 本身唔差,只要 table 太滿,效能都會變壞。

設 table 有 n 個 entry、m 個 bucket,load factor 通常寫成

α=nm\alpha = \frac{n}{m}

對 chaining 來講,n 可以大過 m,所以 α\alpha 可以大過 1。對 open addressing 來講,每個 slot 最多存一個 live entry,因此 0≤α≤10 \le \alpha \le 1;要插入新的 key,除咗要有 α<1\alpha < 1,probe sequence 亦必須真係去得到空 slot。

定理 / 命題

定理

Chaining 的計數恆等式

設 ℓj\ell_j 係 bucket jj 入面 chain 的長度,其中 j=0,…,m−1j=0,\ldots,m-1。咁就有

∑j=0m−1ℓj=n以及1m∑j=0m−1ℓj=nm=α.\sum_{j=0}^{m-1}\ell_j=n \qquad\text{以及}\qquad \frac{1}{m}\sum_{j=0}^{m-1}\ell_j=\frac{n}{m}=\alpha.

證明思路

喺 chaining 入面,各條 bucket chain 將 n 個 entry 分開:每個 entry 恰好屬於 一條 chain。因此,將所有 chain length 相加會將每個 entry 計一次,得到 ∑jℓj=n\sum_j\ell_j=n;再除以 m,就得到平均 occupancy α\alpha。

呢個係確定性的計數恆等式,本身並唔保證 lookup 係常數時間。要得到 O(1+α)O(1+\alpha) 呢類期望界,仲需要合適的 hash function 或 key distribution 假設。冇呢個假設時,即使 load factor 好細,仍然可以出現一條好長的 chain。

當 table 的 load 增加時:

  • chaining 的平均 chain length 嚴格等於 α\alpha,但每條 chain 的實際長度 取決於 key 的分布;
  • 當 α\alpha 接近 1 時,open addressing 的 probe sequence 通常會變長, 而且仍然要滿足 probe coverage 條件;
  • 如果分布或者 load 冇受到控制,平均 lookup 會偏離理想的常數時間。

所以 resize 唔只係工程細節,亦係保護 hashtable 效能模型的一部分。

Generic pointer 令 ADT 可重用

下面的 C interface 用 void * 去表示 value,呢個其實係 ADT 的重要 設計選擇。

typedef struct hashtableCDT *hashtableADT;

hashtableADT EmptyHashtable();
void Enter(hashtableADT table, char *key, void *value);
void *Lookup(hashtableADT table, char *key);

Table 本身唔需要知道 value 的具體型別。它只需要維持 key 與某個 opaque pointer 之間的關聯,而真正如何解讀那個 pointer,則由 client 決定。

常見錯誤

常見錯誤

Hash 相同不代表 key 相同

若 Hash(key1) == Hash(key2),只代表兩個 key 去到同一個 bucket,唔代表 兩者本身相等。

常見錯誤

Hash 範圍合法,不代表 hash function 已經好

一個 function 即使永遠回傳合法 bucket index,仍然可能因為分布太集中而很差。

常見錯誤

O(1) 平均情況並不是免除 collision 的藉口

講 hash table lookup 係 O(1),背後其實隱含了合理的 hash function 和受控 的 load factor。唔可以因此忽視 collision。

快速檢查

思考檢查

Hashing 入面的 collision 係乜?

用 key 同 bucket index 去答。

解答 · 答案

Collision 就係兩個不同 key 被映射到同一個 bucket index。

思考檢查

在 chaining 中,算出 bucket 後,下一步應做甚麼?

想想程式接著會在哪裡繼續查找。

解答 · 答案

它會沿該 bucket 的 linked list 繼續掃描,直至找到 matching key 或到達鏈尾。

練習

思考檢查

點解 hashtable 在 hashing 之後,仍然要做 key comparison?

請從 collision 角度回答。

解答 · 引導解答

Hashing 只係將搜尋範圍縮到某個 bucket。因為不同 key 仍然可能 collision, 所以實作仍然要逐個比較 bucket 內的實際 key,才能確認找到的是正確 entry。

思考檢查

解釋 chaining 同 open addressing 其中一個取捨。

用額外工作發生在哪裡來解釋。

解答 · 引導解答

Chaining 會把 collision 放到 bucket 旁邊的 linked list;open addressing 則把 collision 變成 table 內部的 probing。前者較依賴 pointer structure, 後者較依賴 occupancy 與 probe sequence 設計。

更仔細咁讀 implementation

讀 implementation 時,先明確 ADT contract,再檢查 bucket 佈局。Contract 規定咗每種存放方式都必須實現的 dictionary 操作。

定義

Dictionary semantics of the table

Hash table 係一種 dictionary。對每個 key,表應該支援 insert、lookup 同 delete。如果同一個 key 再次插入,新的 value 應該取代舊的 association,而 唔係複製多一份 key。

呢個 overwrite 行為好容易忽略,但正正就係點解呢個 ADT 係 dictionary, 而唔係 multiset。

例題

Enter 會更新已存在的 key

假設 table 已經存住:

  • "cat" -> 3
  • "dog" -> 8

如果再做 Enter(table, "cat", 9),結果唔應該係兩份 "cat"。正確結果係 "cat" 對應的 association 被更新,之後 Lookup(table, "cat") 會回傳 9。

Enter 為指定的 key 插入 value;如果 key 已經存在,就會 overwrite 舊 value。

Hashing 之後點解仍然要比較 key

Hashing 只係縮窄搜尋範圍,唔係完成搜尋。呢點係 collision 理論入面最核心 嘅直覺之一。

如果兩個 key 落咗同一個 bucket,table 仍然要比較儲存入面嘅 key 同查詢 key 係咪真係一樣。否則就分唔到係真正命中,定係只係碰巧撞到同一個 bucket。

例題

一個 bucket 可以放幾個完全唔同的 key

假設 bucket 3 入面有:

("cow", value1) -> ("cat", value2) -> ("cod", value3)

如果 lookup 的 key 係 "cat",hash 只會話你應該去 bucket 3。之後仍然要 逐個比較 "cow"、"cat",先可以確認回傳正確 value。

Chaining 的好處係 collision structure 分開存

最直觀的 collision strategy 就係 chaining。每個 bucket 指向一條 linked list, 而所有 hash 去同一個 bucket 的 entry 都掛喺同一條 chain 上。

呢個做法有一個實際好處:chain 入面嘅順序唔係 dictionary contract 的一部分。 你可以將新 entry 放前、放後,甚至按其他一致規則插入,只要 lookup 同 overwrite 行為正確,ADT 就仍然成立。

常見錯誤

bucket 內的順序唔係 dictionary contract

有啲同學會以為 chain 內部順序本身就係語義的一部分。其實唔係。正確的 contract 係搵到正確的 key-value pair;chain 的次序只係 implementation detail。

例題

Chaining 下的插入同 lookup

假設 table 只有五個 bucket,而 hash rule 會令 "ape" 同 "ant" 撞入同一個 bucket。

  1. 先插入 "ape"。
  2. 再插入 "ant"。
  3. 再插入 "apple"。
  4. lookup "ant"。

如果 "ape" 同 "ant" 碰撞,佢哋會出現喺同一條 bucket chain。lookup "ant" 時,程式先 hash 一次,再只掃描該 chain,逐個比較 key,直到搵到精確對應的 entry。

Open addressing:唔掛鏈,而係繼續 probing

Open addressing 唔係喺 bucket 底下掛 linked list,而係當首選 slot 已經被 佔用時,繼續喺 table 入面搵下一個位置。

Collision 的成本因此轉移到 probing rule 本身。

  • linear probing 會檢查下一格,再下一格,一路向前;
  • quadratic probing 會跳平方步;
  • double hashing 會用第二個 hash function 產生 step size。

定義

Linear probing

Linear probing 係指如果首選 slot 已經滿咗,就按序檢查下一格,然後再下一格, 直到搵到空位或者搵到相同 key。

Linear probing 容易形成一長串連續 filled buckets,呢種現象 叫 primary clustering。cluster 一長,之後 probe 就更容易撞到同一段區域,效能會 慢慢跌落去。

Quadratic probing 就係針對呢個問題。

定義

Quadratic probing

Quadratic probing 會用平方形式的 offset,例如 i^2。因為步長增長較快, 佢可以減少 primary clustering。

例題

Quadratic probing 的具體插入

由一個 size 為 10 的空表開始,hash rule 係 h(k) = k % 10。依次插入 89, 18, 49, 58, 69,使用 quadratic probes hi=(h(k)+i2) mod 10h_i=(h(k)+i^2)\bmod 10,其中 i=0,1,2,…i=0,1,2,\ldots:

  • 89 去 bucket 9
  • 18 去 bucket 8
  • 49 hash 去 9,撞咗之後 probe 到 0
  • 58 hash 去 8,先撞 9,再 probe 到 2
  • 69 hash 去 9,先撞 0,再 probe 到 3

所以最後 occupied buckets 係:

  • 0 -> 49
  • 2 -> 58
  • 3 -> 69
  • 8 -> 18
  • 9 -> 89

呢個例子好有用,因為佢清楚顯示:最終位置唔係只靠 raw hash value,仲要靠 probe rule。

常見錯誤

quadratic probing 唔等於完全冇限制

Quadratic probing 可以減少 primary clustering,但唔代表 table 可以無限填滿。 例如,當 Nbuckets = 7、F(i) = i^2 時,probe sequence 會重複,而且有啲 slot 甚至完全唔會被 probe 到。

例題

點解 probe sequence 會漏格

設 Nbuckets = 7、h0 = 0,使用 probes (h0 + i^2) % 7; 令 i = 0, 1, ..., 6,得到 probe sequence

0, 1, 4, 2, 2, 4, 1

重點唔係計數技巧,而係你要見到:probe rule 本身可以唔覆蓋所有 bucket。 所以 open addressing 一定要有 load-factor discipline,而唔係盲目相信一個公式就 可以解決所有 collision。

定義

Double hashing

對 size 為 mm 的 table,double hashing 用第二個 hash function 產生 step s(k)s(k),並按

hi=(h0+i s(k)) mod mh_i=(h_0+i\,s(k))\bmod m

進行 probing。只有當 gcd⁡(s(k),m)=1\gcd(s(k),m)=1 時,probe sequence 先保證覆蓋全部 mm 個 slot。只要求 step 非零,或者只要求 1≤s(k)≤m−11\le s(k)\le m-1,都唔足夠。

設 table size 為 10、h(k) = k % 10,第二個 hash 為 s(k)=7−(k mod 7)s(k)=7-(k\bmod 7)。對 key 23,step 為 s(23)=7−(23 mod 7)=5s(23)=7-(23\bmod 7)=5。由 bucket 3 開始時,因為 gcd⁡(5,10)=5\gcd(5,10)=5,probe 只會喺 3 同 8 之間循環;如果兩格都已經被佔用, 佢就去唔到其他空 slot。合適的第二個 hash 可以令 probe 分布更好,但 table size 同 step rule 必須一齊設計。

Rehashing 係改 table 形狀的代價

當 bucket 數目改變,由 hash code 縮減到 bucket index 的規則亦會改變。 即使底層 hash-code computation 保持不變,原本已經存入去的 entry 仍然要按新的 bucket rule 重新放置。呢個過程叫 rehashing。

喺固定容量的實作入面,rehashing 可能唔常發生;但概念上一定要理解, 因為佢說明會增長的 hash table 並唔係一個固定唔變的 memory picture。

例題

點解 resize 之後一定要 rehash

如果 table 由一個 bucket 數改去另一個 bucket 數,舊的 bucket index 未必對新 table 仍然有效。所有 entry 都要重新 hash 一次,計出新位置,再重新放回去。

所以 resize 唔係 copy bytes 咁簡單,而係改變咗 bucket index 的意義。

點樣揀 chaining、linear probing、quadratic probing、double hashing

冇一個方案永遠最好;選擇時需要比較各自的 tradeoff。

  • Chaining 容易理解,而且用 linked list 就可以處理 collision。
  • Linear probing 簡單,但容易出現 primary clustering。
  • Quadratic probing 可以減少 clustering,但對 load factor 更敏感。
  • Double hashing 通常 probe 分散得更好,但因為要第二個 hash function,所以更 貴、更複雜。

如果你喺讀 C code,最安全的問題唔係「理論上邊個最快?」而係「呢個 table 的 size、load、同 update pattern,究竟同邊種策略最夾?」

總結

讀 code 時,可以先用下面呢份簡短 checklist:

  1. key 係咩,value 係咩?
  2. Enter 遇到已存在的 key 時會點?
  3. lookup 之後有冇再比較 stored key?
  4. collision 係由 chain 處理,定 probe sequence 處理,抑或兩者都有?
  5. table 太滿時會發生咩事?

如果呢五個答案清楚,hash table implementation 就會容易得多。

更多練習

思考檢查

點解 Enter 在 key 已經存在時要覆寫舊 value?

請用 dictionary contract 去答,而唔係只講 code 行為。

解答 · 引導解答

因為 ADT 係以 key 為唯一識別。相同 key 再插入時,應該更新原本的 association, 否則 table 就唔再有一個清楚的 key-to-value 對應。

思考檢查

Quadratic probing 相比 linear probing,減少咗邊種 clustering?

請直接講出 clustering 名稱。

解答 · 答案

Quadratic probing 減少 primary clustering,即係 linear probing 容易形成的長串 連續 filled buckets。

思考檢查

由 size 為 10 的空表開始,依次插入 89、18、49、58、69,使用 probes hi=(k+i2) mod 10h_i=(k+i^2)\bmod 10,其中 i=0,1,2,…i=0,1,2,\ldots。49 同 69 最後落喺邊個 bucket?

請用 probe sequence 去答。

解答 · 引導解答

49 先 hash 去 9,撞咗之後去 0。69 先 hash 去 9,再撞 0,最後去 3。

思考檢查

Double hashing 點樣改善 probe 分布,又需要乜額外計算?

請講出佢需要額外乜嘢。

解答 · 引導解答

Double hashing 通常可以令 probe path 分散得更好,但代價係你要再提供一個 second hash function。多出來的 hash 公式就係佢的成本。

練習

先自行作答,再檢查答案。你可以修改後重試。

載入中…