從 dictionary 查詢選擇儲存方式
Dictionary 把每個 key 對應到一個 value。若以未排序的 list 儲存,搜尋可能 需要檢查每個 entry;若保持陣列有序,可以使用 binary search,但插入新 key 時可能需要移動大量 entry。直接以 key 作為陣列索引可以省去搜尋,卻要求 key 屬於合適的整數範圍;任意字串並不提供這樣的索引。
Hash table 從 key 計算 index,只搜尋分配到該位置的候選項。它不維持排序 關係,以減少 lookup 與 update 的期望工作量。不過,有限 index 無法區分 所有可能的 key,因此即使發生 collision,表示方式仍須保留各個不同 entry。
Dictionary 契約
定義
Dictionary semantics of the table
Hash table 是 dictionary 的一種表示。對每個 key,表支援 insert、lookup 與 delete。若同一個 key 再次插入,新的 value 會取代舊 association, 不會建立第二份相同的 key。
例題
Enter 會更新已存在的 key
假設 table 已經存有:
"cat" -> 3"dog" -> 8
若再執行 Enter(table, "cat", 9),結果不應是兩份 "cat",而是
"cat" 對應的 association 被更新,之後 Lookup(table, "cat") 會回傳 9。
Enter 為指定的 key 插入 value;如果 key 已經存在,就會 overwrite 舊 value。
以 opaque pointer 表示 value
下面的 C interface 以 void * 表示 value,使介面不依賴 value 的具體型別。
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 決定。
從 key 計算合法 index
定義
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
| Keys | Bucket 編號 |
|---|---|
| cat | 4 |
| dog | 6 |
| cow | 0 |
| cod | 2 |
Bucket 編號 0
cow
Bucket 編號 1
∅
Bucket 編號 2
cod
Bucket 編號 3
∅
Bucket 編號 4
cat
Bucket 編號 5
∅
Bucket 編號 6
dog
不同 key 為何會共用 bucket
定義
Collision
兩個不同 key 被映射到同一個 bucket index,就發生了 collision。
若 key 的取值範圍包含的 key 多於 bucket 數量,就至少有兩個可能的 key 對應同一 index。這並不表示每個小規模輸入已經發生 collision,而是表示 任何映射到該有限範圍的 hash function 都無法對所有合法輸入排除 collision。 Hashing 只確定候選項,仍須比較 key,才能確認某個候選項正是查詢目標。
Chaining 把 collision 的 entry 存入 bucket 所連接的 list;open addressing 則把它們存入同一個 table 的其他 slot,並透過可重複的 probe sequence 找回。 兩者都必須找到 key 相同的 entry,不能直接採用遇到的第一個非空位置。
看清 hash table 在兩個不同 key 發生 collision 時,如何仍保持 dictionary 操作正確。
Hashing 只是第一步。正確 lookup 和 update 仍取決於 collision strategy 是否沿 chain 或 probe sequence 檢查 key。
Chaining:以鏈結保存同 bucket 的 entry
Chaining 把碰撞的 entry 存在 bucket 所連接的 linked list。
定義
Chaining
Chaining 會令每個 bucket 指向一條 linked list,把所有 hash 到同一個 bucket 的 key-value entry 串在一起。
Bucket chain 使用的仍是 pointer 連接的 node:
typedef struct cellT {
char *key;
void *value;
struct cellT *next;
} cellT;
因此 lookup 可以分成兩層:
- 先 hash key,選出 bucket;
- 再只掃描該 bucket 底下的 linked list。
例題
Chaining 下的 lookup
假設 Hash("cat") = 3,而 bucket 3 的 chain 是
("cow", value1) -> ("cat", value2) -> ("cod", value3)。
當執行 Lookup(table, "cat") 時,程式會:
- 先算到 bucket
3; - 沿 bucket
3的 linked list 掃描; - 逐個比較 key;
- 找到
"cat"時回傳value2。
Collision 不會改變 lookup 的正確結果,但會增加這個 bucket 所需的 key 比較。
Bucket 內的次序不屬於 dictionary 契約。實作可以在鏈首、鏈尾或按其他一致 規則插入,只要 lookup 與 overwrite 仍能找到正確的 key。這個選擇改變的是 表示方式,不是 key-value 對應的意義。
例題
Chaining 下的插入與 lookup
假設 table 只有五個 bucket,而 hash rule 會令 "ape" 與 "ant" 撞入同一個
bucket。
- 先插入
"ape"。 - 再插入
"ant"。 - 再插入
"apple"。 - lookup
"ant"。
若 "ape" 與 "ant" 碰撞,它們會出現在同一條 bucket chain。Lookup "ant"
時,程式先 hash 一次,再只掃描該 chain,逐個比較 key,直到找到精確對應的
entry。
Occupancy 與期望搜尋成本
即使 hash function 的分布合適,table 的 occupancy 仍會影響搜尋成本。
設 table 有 n 個 entry、m 個 bucket,load factor 通常寫成
對 chaining 而言,n 可以大於 m,所以 可以大於 1。對 open
addressing 而言,每個 slot 最多存一個 live entry,因此
;要插入新的 key,除了要有 ,probe sequence
亦必須能到達空 slot。
定理
Chaining 的計數恆等式
設 為 bucket 中 chain 的長度,其中 。則有
在 chaining 中,各條 bucket chain 把 n 個 entry 分開:每個 entry 恰好屬於
一條 chain。因此,將所有 chain length 相加會將每個 entry 計一次,得到
;再除以 m,就得到平均 occupancy 。
這是確定性的計數恆等式,本身不保證 lookup 是常數時間。要得到 這類期望界,還需要合適的 hash function 或 key distribution 假設。沒有這項假設,即使 load factor 很小,仍可能出現一條很長的 chain。
當 table 的 load 增加時:
- chaining 的平均 chain length 嚴格等於 ,但每條 chain 的實際長度 取決於 key 的分布;
- 當 接近
1時,open addressing 的 probe sequence 通常會變長, 而且仍然要滿足 probe coverage 條件; - 若分布或 load 未受控制,平均 lookup 會偏離理想的常數時間。
因此,resize 也是維持 hash table 預期效能的必要考量。
Open addressing:在同一 table 內 probing
Open addressing 把 entry 存在 table 內;首選 slot 已被佔用時,便按 probe rule 繼續檢查其他位置。
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 以不同的 offset 減少 primary clustering。
定義
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
,其中 :
89去 bucket918去 bucket849hash 去9,碰撞後 probe 到058hash 去8,先撞9,再 probe 到269hash 去9,先撞0,再 probe 到3
所以最後 occupied buckets 是:
0 -> 492 -> 583 -> 698 -> 189 -> 89
最終位置取決於初始 hash、probe rule,以及搜尋過程中遇到的已佔用位置。
常見錯誤
Quadratic probes 未必能到達所有 slot
減少 primary clustering 並不保證 probe sequence 覆蓋所有 slot。
例如,當 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
這個 sequence 只到達四個不同位置。若它們已被佔用,即使另外三個位置 仍然空置,probe 也找不到可用 slot。因此,load factor 與 probe coverage 是兩項不同的條件。
定義
Double hashing
對 size 為 的 table,double hashing 用第二個 hash function 產生 step ,並按
進行 probing。只有當 時,probe sequence 才保證覆蓋全部 個 slot。只要求 step 非零,或者只要求 ,仍不足夠。
設 table size 為 10、h(k) = k % 10,第二個 hash 為
。對 key 23,step 為
。由 bucket 3 開始時,因為
,probe 只會在 3 與 8 之間循環;如果兩格都已經被佔用,
它就不能到達其他空 slot。合適的第二個 hash 可以令 probe 分布更好,但 table
size 與 step rule 必須一併設計。
Deletion 還必須保留搜尋路徑,讓其後插入到同一 probe sequence 的 entry 仍然可達。直接把刪除位置標成從未使用的空位,可能中斷這條路徑。這裏的 probe 例子討論 insertion 與 lookup,沒有訂明 deletion 的具體實作。
Bucket 數量改變後需要 rehash
Bucket 數量參與 index 的計算。即使底層 hash code 不變,改變 bucket 數量 也可能把已有 entry 分配到新位置。因此,每個 entry 都須按新規則重新放置; 這個過程稱為 rehashing。
例題
模數改變,bucket 也可能改變
採用 h(k) = k % m 時,key 18 在 m = 10 的表中屬於 bucket 8,
在 m = 20 的表中卻屬於 bucket 18。直接把舊 cell 複製到較大陣列,會使
該 entry 留在新 lookup 不會預期的位置。Rehashing 重新計算位置;若採用
open addressing,還須重新建立符合新 probe rule 的存放關係。
Collision 策略的取捨
不同方案適合不同條件,選擇時須比較各自的 tradeoff。
- Chaining 容易理解,而且用 linked list 就可以處理 collision。
- Linear probing 簡單,但容易出現 primary clustering。
- Quadratic probing 可以減少 clustering,但對 load factor 更敏感。
- Double hashing 通常 probe 分散得更好,但因為要第二個 hash function,所以更 貴、更複雜。
選擇取決於 table size、預期 load、記憶體佈局及 update pattern。Probe rule 必須先滿足 coverage 條件,才有比較其速度的意義。
常見錯誤
常見錯誤
Hash 相同不代表 key 相同
若 Hash(key1) == Hash(key2),只代表兩個 key 去到同一個 bucket,不代表
兩者本身相等。
常見錯誤
合法的 hash 範圍不保證均勻分布
一個 function 即使永遠回傳合法 bucket index,仍然可能因為分布太集中而很差。
常見錯誤
平均常數時間需要模型假設
Hash table lookup 的期望 O(1) 界需要合適的 hash function 或 key distribution
及受控的 load factor,不能省略這些條件。
總結
Dictionary 契約規定每個 key 對應的 value,表示方式則決定這個關聯存在哪裏。 Hashing 選擇候選項,key comparison 確認目標,collision handling 保持每個 已存 key 可達。期望效率依賴 key/hash 的模型與 occupancy;改變表的大小後, 必須透過 rehashing 使存放位置與 lookup 使用的規則繼續一致。
快速檢查
思考檢查
Hashing 中的 collision 是甚麼?
以 key 與 bucket index 說明。
解答 · 答案
Collision 是兩個不同 key 被映射到同一個 bucket index。
思考檢查
在 chaining 中,算出 bucket 後,下一步應做甚麼?
想想程式接著會在哪裡繼續查找。
解答 · 答案
它會沿該 bucket 的 linked list 繼續掃描,直至找到 matching key 或到達鏈尾。
練習
思考檢查
為何 hash table 在 hashing 之後仍須比較 key?
請從 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 設計。
思考檢查
為何 Enter 在 key 已存在時要覆寫舊 value?
根據 dictionary 契約說明。
解答 · 引導解答
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 ,其中 。49 與 69 最後分別存入哪個 bucket?
列出相關 probe sequence。
解答 · 引導解答
49 先 hash 去 9,碰撞後去 0。69 先 hash 去 9,再撞 0,最後去
3。
思考檢查
Double hashing 如何改善 probe 分布,又需要甚麼額外計算?
指出新增的計算步驟。
解答 · 引導解答
Double hashing 通常可以令 probe path 較均勻地分散,但需要計算第二個 hash function,並使 step 與 table size 滿足互質條件。額外 hash 計算是其成本。