当 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
| 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
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 在两个不同 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 可以分成两层:
- 先 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 并没有破坏正确性,只是让该 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 旁边的 linked structure;
- open addressing 把 collision 变成 table 内部的续搜。
所以 clustering 与 probe sequence 设计就变得很重要。Deletion 还需要额外规则 来保持 probe path;本节把 delete 列为 ADT 操作,但不展开这个独立 invariant。
定理
Collision handling 是正确性的一部分
如果 collision 策略设计错,即使 hash function 本身合法,table 仍然可能 返回错误结果,甚至丢失 entry。
Load factor 为什么重要
即使 hash function 本身不差,只要 table 太满,效能都会变坏。
设 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 不只是工程细节,也是保护 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。
- 先插入
"ape"。 - 再插入
"ant"。 - 再插入
"apple"。 - 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
,其中 :
89去 bucket918去 bucket849hash 到9,撞了之后 probe 到058hash 到8,先撞9,再 probe 到269hash 到9,先撞0,再 probe 到3
所以最后 occupied buckets 是:
0 -> 492 -> 583 -> 698 -> 189 -> 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 为 的 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 必须一起设计。
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:
- key 是什么,value 是什么?
Enter遇到已经存在的 key 时会怎样?- lookup 之后有没有再比较 stored key?
- collision 是由 chain 处理,还是由 probe sequence 处理,或者两者都有?
- table 太满时会发生什么?
如果这五个答案清楚,hash table implementation 就会容易得多。
更多练习
思考检查
为什么 Enter 在 key 已经存在时要覆盖旧 value?
请用 dictionary contract 来答,而不要只讲 code 行为。
解答 · 引导解答
因为 ADT 是按 key 定义唯一 association 的。相同 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 分散得更好,但代价是你要再提供一个 second hash function。多出来的 hash 公式就是它的成本。