当 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,通常是 。
本地 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
| 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 正确”。
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 可以分成两层:
- 先 hash key,选出 bucket;
- 再只扫描该 bucket 下面的 linked list。
例题
Chaining 下的 lookup
假设 ,而 bucket 3 的 chain 是
。
当执行 Lookup(table, "cat") 时,程序会:
- 先算出 bucket
3; - 沿 bucket
3的 linked list 扫描; - 逐个比较 key;
- 找到
"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 旁边的 linked structure;
- open addressing 把 collision 变成 table 内部的续搜。
所以 deletion、clustering 与 probe sequence 设计就变得很重要。
定理
Collision handling 是正确性的一部分
如果 collision 策略设计错,即使 hash function 本身合法,table 仍然可能 返回错误结果,甚至丢失 entry。
Load factor 为什么重要
即使 hash function 本身不差,只要 table 太满,效能都会变坏。
Load factor 通常写成
当 上升时:
- chaining 的链会变长;
- open addressing 的 probe sequence 会变长;
- 平均情况下的 lookup 会慢慢偏离理想的常数时间。
所以 resize 不只是工程细节,也是保护 hashtable 效能模型的一部分。
Generic pointer 让 ADT 可复用
本地 hashtable lecture 使用 作为 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 相同
若 ,只代表两个 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 已经存着:
如果再执行 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 里有:
如果 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
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,例如 。因为步长增长较快,它 可以减少 primary clustering。
例题
Quadratic probing 的具体插入
Tutorial 给了一个 size 10 的例子,hash rule 是 。插入
{89, 18, 49, 58, 69} 时,用 quadratic probing 会这样落位:
89去 bucket918去 bucket849hash 到9,撞了之后 probe 到058hash 到8,先撞9,再 probe 到269hash 到9,先撞0,再 probe 到3
所以最后 occupied buckets 是:
这个例子很有用,因为它清楚显示:最终位置不只是看 raw hash value,还要看 probe rule。
常见错误
quadratic probing 不是完全没有限制
Quadratic probing 可以减轻 primary clustering,但不代表 table 可以无限填满。 Lecture 的 load-factor 例子显示,当 、 时,probe sequence 会重复,而且有些 slot 甚至完全不会被 probe 到。
例题
为什么 probe sequence 会漏格
Lecture 的例子是 、、,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 时先问这几件事
- key 是什么,value 是什么?
Enter遇到已经存在的 key 时会怎样?- lookup 之后有没有再比较 stored key?
- collision 是由 chain 处理,还是由 probe sequence 处理,或者两者都有?
- table 太满时会发生什么?
如果这五个答案清楚,hash table implementation 就会容易得多。
更多练习
快速检查
为什么 Enter 在 key 已经存在时要覆盖旧 value?
请用 dictionary contract 来答,而不要只讲 code 行为。
解答
引导解答
快速检查
Quadratic probing 相比 linear probing,Lecture 说减少了什么?
请直接说出 clustering 的名称。
解答
答案
快速检查
用 tutorial 的 size 10 例子,49 和 69 用 quadratic probing 最后落在哪?
请用 probe sequence 来答。
解答
引导解答
快速检查
为什么 lecture 说 double hashing 一般较好,但又不算免费?
请说明它额外需要什么。
解答