Evanalysis
1.2嵌入式互動預計閱讀時間: 13 分鐘

1.2 Hash table 與 collision 策略

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

課程目錄

CSCI2520:資料結構

針對 CSCI2520 資料結構基礎的結構化筆記,重視操作層級推理與選擇性互動示範。

章節 0程式基礎1
章節 2List 與 recursion1
章節 4Trees 與 BST1

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

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

仍然要先由 ADT 角度開始

Hashtable tutorial 仍然先由操作出發,而唔係先由 bucket 出發:

  • 為某個 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,HSIZE)[0, H_SIZE)

本地 tutorial 特別強調一個好 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;
}

呢類規則容易計,但亦都容易撞。若兩個 key 的字元總和接近,collision 便可能頻繁出現。所以它適合教學,但未必係最穩健的實戰方案。

邊讀邊試

測試一個簡單的 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 正確」。

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

Lecture material 用 chaining 作為最直觀的補救方法。

定義

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")=3Hash("cat") = 3,而 bucket 3 的 chain 係

("cow",value1)>("cat",value2)>("cod",value3)("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

Tutorial 亦介紹 open addressing。呢種方法唔係喺 bucket 下面掛 linked list, 而係當首選 slot 被佔用時,繼續去 table 內其他位置 probing。

常見 probing 方式包括:

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

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

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

所以 deletion、clustering 同 probe sequence 設計就變得非常重要。

定理

Collision handling 係正確性的一部分

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

Load factor 點解重要

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

Load factor 通常寫成

α=儲存中的 entry 數量bucket 數量\alpha = \frac{\text{儲存中的 entry 數量}}{\text{bucket 數量}}

α\alpha 上升時:

  • chaining 的鏈會變長;
  • open addressing 的 probe sequence 會變長;
  • 平均情況下的 lookup 會慢慢偏離理想的常數時間。

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

Generic pointer 令 ADT 可重用

本地 hashtable lecture 用 voidvoid * 去表示 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)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 去答。

解答

答案

快速檢查

Chaining 下,算完 bucket 之後下一步要做乜?

想想程式會去哪裡繼續找。

解答

答案

練習

快速檢查

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

請從 collision 角度回答。

解答

引導解答

快速檢查

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

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

解答

引導解答

更仔細咁讀 implementation

Lecture 其實唔係先講 bucket,而係先講 ADT contract。呢個次序好重要,因為 hash table 首先係 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"cat" -> 3
  • "dog">8"dog" -> 8

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

Lecture 入面講 Enter inserts a particular value for a specified key, 而且如果原本已經有舊 value,就會 overwrite 舊 value,講嘅就係呢件事。

Hashing 之後點解仍然要比較 key

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

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

例題

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

假設 bucket 3 入面有:

("cow",value1)>("cat",value2)>("cod",value3)("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

Tutorial 同 lecture 都有講 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。

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

Quadratic probing 就係針對呢個問題。

定義

Quadratic probing

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

例題

Quadratic probing 的具體插入

Tutorial 俾咗一個 size 10 的例子,hash rule 係 h(k)=kh(k) = k % 10。插入 {89, 18, 49, 58, 69} 時,用 quadratic probing 會咁樣落位:

  • 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>490 -> 49
  • 2>582 -> 58
  • 3>693 -> 69
  • 8>188 -> 18
  • 9>899 -> 89

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

常見錯誤

quadratic probing 唔等於完全冇限制

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

例題

點解 probe sequence 會漏格

Lecture 的例子係 Nbuckets=7Nbuckets = 7h0=0h0 = 0F(i)=i2F(i) = i^2,probe sequence 會係

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

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

定義

Double hashing

Double hashing 會用第二個 hash function 來決定 probe step。一般而言,佢比 linear probing 同 quadratic probing 更好分散 probe path,但代價係你要有兩個 hash function。

呢個就係 CSCI2520 好典型的 tradeoff:規則更精細,可以換來更好的分散;但 實作亦會更複雜。

Rehashing 係改 table 形狀的代價

當 bucket 數目改變,hash function 都要跟住改。原本已經存入去的 entry,就要 用新 rule 再入一次。呢個過程叫 rehashing。

Lecture 亦提醒過,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

Source material 唔係叫你揀一個「永遠最好」的方案,而係叫你睇明 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 時先問呢幾件事

  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 行為。

解答

引導解答

快速檢查

Quadratic probing 相比 linear probing,Lecture 說減少咗乜嘢?

請直接講出 clustering 名稱。

解答

答案

快速檢查

用 tutorial 的 size 10 例子,4969 用 quadratic probing 最後落喺邊?

請用 probe sequence 去答。

解答

引導解答

快速檢查

點解 lecture 話 double hashing 一般較好,但又唔算免費?

請講出佢需要額外乜嘢。

解答

引導解答

本節掌握 checkpoint

要完成這一節 checkpoint,需要把每一題答對。 答對進度: 0%.

技能點: hashtable, collision, hash-function

Hashing 裡的 collision 是甚麼?

已用嘗試次數: 0

剩餘嘗試次數: 不限嘗試次數

預覽不會消耗嘗試次數。

提交會記錄一次正式評分嘗試。

技能點: hashtable, chaining, lookup

填空:在 chaining 下,選定 bucket 之後,lookup 會沿着 bucket 的 ____ 繼續掃描。

已用嘗試次數: 0

剩餘嘗試次數: 不限嘗試次數

預覽不會消耗嘗試次數。

提交會記錄一次正式評分嘗試。

輸入格式提示: 輸入如 `linked list` 或 `chain` 這樣的短語即可。