Lecture 14: 散列与哈希表(Hashing & Hash Tables)(对应课程真实讲座 L24)
Lecture 14: 散列与哈希表(Hashing & Hash Tables)(对应课程真实讲座 L24)
概述
本讲以“高效存储并检索 17 000 名学生记录(每名学生的学号唯一、8 位数字)”为驱动问题,逐一权衡了巨型直接索引数组、排序 + 二分、平衡树等方案后,隆重推出本季“最惊艳的数据结构”——哈希表(hash table):一个普通数组加上一个哈希函数(hash function),就能在平均 O(1) 时间内完成插入、查找、删除,却只占用 O(n) 空间。全讲围绕两条主线展开:一是两种冲突消解策略——线性探测(linear probing)与分离链(separate chaining)——的机制、运行时间与工程细节(装填因子、聚类、墓碑);二是好哈希函数应具备的性质,以及 std::unordered_set / unordered_map 的用法。官方对应:L24(2026 年 8 月 4 日,周二,Hashing;另附 Stanford HashSet / HashMap 文档供参考)。
核心概念与算法原理
问题定义:按学号存取学生记录。 假设要为 17 000 名在校生维护记录,学号是 00000000–99999999 之间的 8 位数字,要求“查得快、存得省”。官方课上对比了四类方案,暴露了一个贯穿全季的经典权衡——用空间换时间,或用时间换空间:
- 巨型直接索引数组:开一个长度 1 亿的数组,把学号直接当下标。插入/查找/删除都是铁打的 O(1),但 17 000 条记录要占 1 亿个槽位,浪费到离谱。
- 合理大小的有序数组 + 二分查找:只存 17 000 个元素,排序后二分。空间省了,但建表要先花 O(n log n) 排序,查找退化为 O(log n),中途插入新学生还要 O(n) 挪动。
- 平衡二叉搜索树:插入/查找最坏 O(log n)、空间 O(n)。比有序数组灵活(随时插入),但每个节点带指针,常数更大,且永远够不到 O(1)。
- 哈希表:数组 + 哈希函数。平均 O(1) 增删查、O(n) 空间——“两条方案各自最好的部分”被拼到了一起。
官方还顺带提了一句“每个节点有 10 个孩子、沿学号逐位下行”的数字树,这正是 trie(字典树)的思想;本季不展开(见 Lecture 17 延伸专题)。
哈希表是什么? 哈希表 = 一个定长数组(称桶数组 / bucket array),外加一个哈希函数 h。插入键 key 时,先算 h(key) 得到一个很大的整数(哈希码 hash code),再对它取模 % 表长 得到合法下标,把键存进那个槽位:
键(key) ──哈希函数──▶ 哈希码(整数,可能很大) ──% 表长──▶ 桶下标
"apple" h("apple")=… … % 8 = 3 → 存到下标 3
取模这一步必须由使用者自己完成——课堂明确提醒:哈希函数通常返回很大的数,任何拿它当数组下标的用法都要先对表长取模。不同键可能算到同一个下标,这就叫冲突(collision);冲突是不可避免的(键的个数远超下标个数,鸽笼原理),于是整讲都在讨论“冲突发生后怎么办”。
策略一:线性探测(linear probing,开放寻址族)。 冲突时不去抢同一个槽,而是沿着数组向后一个槽一个槽地找空位;走到末尾就绕回开头(用 (下标 + i) % 表长 实现环形扫描)。下图以表长 7、四个“哈希值都对 7 取模等于 5”的键为例:
键 下标 = 键 % 7 插入过程(下标:0 1 2 3 4 5 6)
5 5 插 5 → [ ][ ][ ][ ][ ][5][ ]
12 5 插 12 → [ ][ ][ ][ ][ ][5][12] 5 被占,探测到 6
19 5 插 19 → [19][ ][ ][ ][ ][5][12] 5、6 都占,绕回 0
26 5 插 26 → [19][26][ ][ ][ ][5][12] 5→6→0→1,最终落到 1
由此引出两个重要现象:
- 聚类(primary clustering):冲突键挤成一串连续占用的块,块越长,后面任何键探测时越容易被“路过”而放得更远,块又更大——恶性循环。缓解手段:① 把表维持得“比较空”(官方给出的经验是占用 25%–50%,宁可浪费一点空间);② 表长取质数,减少取模后下标分布的周期性规律。
- 删除要用“墓碑”(tombstone),不能直接清空:查找是沿着探测序列走到空位才停的。若把被删槽直接标成 EMPTY,就会把探测链拦腰截断,使排在它后面的键再也找不到(见下图)。正确做法是把槽标记为“已删但曾经用过”(墓碑 T),查找时跳过墓碑继续走,插入时则优先复用最靠前的墓碑槽:
插入 5、12、19 后(表长 7):下标 5 放 5,6 放 12,0 放 19
删除 12 时——
若把下标 6 置 EMPTY:找 19 从 5 出发,到 6 遇 EMPTY 即停 → 误判“不存在” ✗
若把下标 6 置墓碑 T :找 19 从 5 出发,跳过 6 继续 → 在 0 处找到 ✓
[19][ ][ ][ ][ ][ 5][T]
策略二:分离链(separate chaining)。 让数组的每个槽不是直接放元素,而是放一条链表(桶 bucket)的头指针;冲突的键全部挂进同一条链表,根本不需要探测:
桶数组(每个槽是链表头)
[0] → nullptr
[1] → "apple" → nullptr
[2] → nullptr
[3] → "grape" → "peach" → "plum" → nullptr ← 三个键冲突,串成一条链
[4] → nullptr
新元素通常头插,官方给出两个理由:头插无需维护尾指针、天然 O(1);且“最近访问的元素更可能再次被访问”,把它放链表头部对后续查询友好。分离链的性能由装填因子(load factor)= n / b(元素总数 ÷ 桶数,即每条链的平均长度)决定:负载太高链就长,太低则空桶过多浪费空间。只要负载保持小常数,期望每次查询只需看常数个元素——平均 O(1) 由此而来。
好哈希函数四条性质(官方课末总结,设计细节超纲但性质必须懂):
- 确定性(deterministic):同一输入必须永远得到同一哈希码,否则插入时算到下标 3、查找时却算出下标 8,记录就“人间蒸发”了。
- 输入均匀时输出也要均匀:若 70% 的键都落进同一个桶,插入 n 个键的总代价会退化到 O(n²);反之,均匀散开时各桶都很短。
- 输出范围要大:若哈希函数只产出 0–9,配一个 10 000 长的表也只会用前 10 个槽,等于人为制造海量冲突。
- 相似输入要给出差异大的哈希码:真实数据常常成堆出现(如连续学号),把它们打散能避免在表里制造聚集。
复杂度表述要谨慎(官方特别提醒)。 不加修饰地宣称“哈希表是 O(1)”是业内常见惯例,但它隐含两个前提:好的哈希函数 + 均匀分布的输入(平均情形),以及哈希函数本身是 O(1)。字符串哈希通常要遍历全部 k 个字符,代价是 O(k)——所以对字符串键而言,一次“O(1)”操作的完整成本其实是 O(k)。最坏情形(所有键撞进同一桶 / 全部聚成一团)下,插入、查找、删除都是 O(n)。
标准库对应物。 std::unordered_set / std::unordered_map 正是“平均 O(1) 增删查、迭代无序”的哈希容器(标准库具体用哪种冲突策略是实现细节,常见实现仍是链式分桶)。它要求键类型可哈希(提供 std::hash<T> 特化或自定义哈希函数对象)且可判等(operator==);作为交换,遍历时元素不保证有序——这与第 3 讲基于平衡树的 std::set / std::map(有序、O(log n))形成鲜明对照。
代码示例与实现详解
示例 1:分离链 HashSet<string>(含 rehash)。
#include <iostream>
#include <list>
#include <string>
#include <utility>
#include <vector>
using namespace std;
class ChainedHashSet {
public:
explicit ChainedHashSet(size_t bucketCount = 8)
: buckets_(bucketCount), size_(0) {}
// 插入:重复键返回 false;否则头插并视负载因子决定是否扩容
bool insert(const string& key) {
size_t idx = hashIndex(key);
for (const string& s : buckets_[idx]) {
if (s == key) return false; // 哈希集合不允许重复
}
buckets_[idx].push_front(key); // 头插:O(1)
++size_;
if (size_ > buckets_.size() * 0.75) rehash(); // 负载 > 0.75 → 扩容
return true;
}
bool contains(const string& key) const {
for (const string& s : buckets_[hashIndex(key)]) {
if (s == key) return true; // 链很短时近似 O(1)
}
return false;
}
bool erase(const string& key) {
auto& chain = buckets_[hashIndex(key)];
for (auto it = chain.begin(); it != chain.end(); ++it) {
if (*it == key) { chain.erase(it); --size_; return true; }
}
return false;
}
size_t size() const { return size_; }
void dump() const {
for (size_t i = 0; i < buckets_.size(); ++i) {
cout << "桶[" << i << "]:";
for (const string& s : buckets_[i]) cout << " -> " << s;
cout << "\n";
}
}
private:
// 经典的“乘 31 累加”字符串哈希(类似 Java String.hashCode 的思路)
size_t hashIndex(const string& key) const {
size_t h = 0;
for (char c : key) h = h * 31 + static_cast<unsigned char>(c);
return h % buckets_.size(); // 取模落桶
}
void rehash() {
vector<list<string>> old = std::move(buckets_);
buckets_.assign(old.size() * 2, list<string>()); // 桶数翻倍
size_ = 0; // 计数清零后整体重插
for (auto& chain : old)
for (auto& s : chain) insert(s);
}
vector<list<string>> buckets_; // 桶数组:每桶一条链表
size_t size_; // 元素总数
};
int main() {
ChainedHashSet s;
for (const string& w : {"apple", "banana", "pear", "grape",
"plum", "peach", "kiwi", "melon",
"fig", "date"}) { // 第 9 个元素会触发 rehash
cout << "插入 " << w << (s.insert(w) ? " 成功" : " 重复") << "\n";
}
cout << "再次插入 apple -> " << (s.insert("apple") ? "成功" : "拒绝重复") << "\n";
cout << "contains(pear) = " << s.contains("pear") << "\n";
cout << "contains(kiwi) = " << s.contains("kiwi") << "\n";
cout << "erase(pear) = " << s.erase("pear") << "\n";
cout << "erase(pear) = " << s.erase("pear") << "(第二次已删不到)\n";
cout << "当前 size = " << s.size() << "\n\n";
s.dump();
return 0;
}
【代码做什么】 main 依次插入 10 个单词(初始 8 桶、rehash 阈值 0.75:前 6 次插入后 size=6 未超限,第 7 次插入 kiwi 时 size=7 > 8×0.75,触发 rehash、桶数翻到 16),随后演示查重拒绝、contains、erase(含删除不存在的键返回 false),最后 dump 出每个桶的链表内容,直观看到元素如何被“打散”进不同桶。真实输出片段:
插入 apple 成功
...
插入 kiwi 成功 ← 此处发生 rehash(8 桶 → 16 桶)
插入 melon 成功
...
再次插入 apple -> 拒绝重复
contains(pear) = 1
erase(pear) = 1
当前 size = 9
桶[0]: -> kiwi
桶[1]: -> peach
桶[4]: -> fig -> plum ← 冲突的两个键共享一条链
桶[10]: -> apple
【实现机制解说】 ① hashIndex 用“乘 31 累加”把任意长字符串压成一个 size_t,再对 buckets_.size() 取模——无论表多大都得到合法下标;static_cast<unsigned char> 保证负 char 值不捣乱。② 冲突键全部进同一条 std::list,链表天然支持 O(1) 头插与任意位置删除(erase 前先线性扫一遍查重,符合集合语义)。③ rehash 是哈希表唯一的“重活”:桶数翻倍后,原来的取模结果几乎全部失效,必须把每个元素重新哈希、重新入桶,代价 O(n);但因为每次扩容都翻倍,摊到 n 次插入上平均仍是 O(1)(与第 1 讲 vector 的扩容如出一辙)。④ 扩容后负载约为原来一半,保证负载因子长期在阈值以下。
示例 2:线性探测版(整数集合,含墓碑处理)。
#include <iostream>
#include <string>
#include <vector>
using namespace std;
// 槽位三种状态:EMPTY 从未用过 / USED 在用 / TOMBSTONE 已删但曾用过
enum class Slot { EMPTY, USED, TOMBSTONE };
class LinearHashSet {
public:
explicit LinearHashSet(size_t cap = 7)
: status_(cap, Slot::EMPTY), values_(cap, 0), size_(0) {}
bool insert(int key) {
if (contains(key)) return false; // 先查重
int tomb = -1; // 探测途中遇到的最靠前墓碑
size_t start = hashIndex(key);
for (size_t step = 0; step < status_.size(); ++step) {
size_t pos = (start + step) % status_.size(); // 环形探测
if (status_[pos] == Slot::TOMBSTONE && tomb < 0) tomb = (int)pos;
if (status_[pos] == Slot::EMPTY) { // 探测链终点
size_t target = tomb < 0 ? pos : (size_t)tomb; // 优先复用墓碑
status_[target] = Slot::USED; values_[target] = key;
++size_;
return true;
}
}
return false; // 表满(本例容量大于元素数)
}
bool contains(int key) const {
size_t start = hashIndex(key);
for (size_t step = 0; step < status_.size(); ++step) {
size_t pos = (start + step) % status_.size();
if (status_[pos] == Slot::EMPTY) return false; // 空位 = 探测链尽头
if (status_[pos] == Slot::USED && values_[pos] == key) return true;
// TOMBSTONE:绝不能停,必须继续向后探测
}
return false;
}
bool erase(int key) {
size_t start = hashIndex(key);
for (size_t step = 0; step < status_.size(); ++step) {
size_t pos = (start + step) % status_.size();
if (status_[pos] == Slot::EMPTY) return false;
if (status_[pos] == Slot::USED && values_[pos] == key) {
status_[pos] = Slot::TOMBSTONE; // 打墓碑,而不是置 EMPTY!
--size_;
return true;
}
}
return false;
}
size_t size() const { return size_; }
void dump() const {
for (size_t i = 0; i < status_.size(); ++i) {
char tag = status_[i] == Slot::USED ? 'U'
: status_[i] == Slot::TOMBSTONE ? 'T' : 'E';
cout << "下标 " << i << " [" << tag << "]";
if (status_[i] == Slot::USED) cout << " 值=" << values_[i];
cout << "\n";
}
}
private:
size_t hashIndex(int key) const {
size_t h = static_cast<size_t>(key); // 先转无符号再取模,杜绝负下标
return h % status_.size();
}
vector<Slot> status_;
vector<int> values_;
size_t size_;
};
int main() {
LinearHashSet t(7);
// 5、12、19、26 对 7 取模都等于 5 —— 故意制造连环冲突
for (int k : {5, 12, 19, 26, 3, 0})
cout << "插入 " << k << (t.insert(k) ? " 成功" : " 失败") << "\n";
cout << "\n删除 12 后:\n";
t.erase(12);
t.dump();
cout << "\ncontains(19) = " << t.contains(19)
<< " ← 跨过墓碑 T 仍能找到\n";
cout << "contains(12) = " << t.contains(12)
<< " ← 已删,找不到\n";
return 0;
}
【代码做什么】 表长固定 7,前四个键全“撞”在下标 5,被迫依次探到 6、绕回 0、1(与核心概念部分的图示完全对应);随后 dump 打印每个槽的状态,接着删除 12(位于探测链中段),再验证 19 依然能被找到——直观演示“墓碑必须被跳过而非终止探测”。
【实现机制解说】 ① 用独立的 status_ 数组与 values_ 数组并行存储,状态与数据分离,EMPTY/TOMBSTONE 槽无需“假值”占位。② 删除只改状态不改值:置 TOMBSTONE 后,查找照常把它当“占用过的位置”跨过去,直到遇见真正的 EMPTY 才判定不存在;插入时记录探测路上第一个墓碑,把新键复用到那个槽,避免表里墓碑越积越多。③ 三个操作统一用 (start + step) % 容量 的环形下标,天然实现“绕回表头”。④ 对比示例 1 可见:分离链的删除是“从链表摘下节点、空间即刻回收”,而开放寻址的删除是“打标记、空间延迟复用”——这是两类策略最本质的工程差异。
示例 3:std::unordered_set 实战——twoSum(官方第 24 讲的“超级重要练习”)。
#include <iostream>
#include <unordered_set>
#include <vector>
using namespace std;
// 判断数组中是否存在两个不同位置的数,其和等于 target
bool twoSum(const vector<int>& v, int target) {
unordered_set<int> seen; // 记录已扫描过的值
for (int x : v) {
if (seen.count(target - x)) return true; // 补数已在前面出现过?
seen.insert(x); // 先查后插:避免把 x 自己当配对
}
return false;
}
int main() {
vector<int> v = {5, 1, 3, 1, 9};
cout << "target=8 -> " << twoSum(v, 8) << " (5+3)\n";
cout << "target=2 -> " << twoSum(v, 2) << " (两个 1)\n";
cout << "target=9 -> " << twoSum(v, 9) << " (不能只用单个 9)\n";
cout << "target=18 -> " << twoSum(v, 18) << " (只有一个 9)\n";
return 0;
}
【代码做什么】 一次线性扫描,边走边把见过的数存进 unordered_set;对当前数 x,只查“补数 target − x 是否已在集合里”。官方把它列为第 24 讲的“超级重要练习”:朴素双层循环是 O(n²),而哈希版是 O(n)——正好复习“哈希表 = 快速查重”这一核心用途。
【实现机制解说】 ① 为什么“先查后插”而不是“先插后查”?若先把 x 插进去再查补数,当 target == 2x 时会拿同一个元素自己配自己(如单元素 {9}、target 18 会被误判成功)。② 只关心“是否出现”时用 unordered_set 就够;若还要返回下标,换 unordered_map<int,int>(值→下标)即可。③ 若想给自定义结构体(如学生记录)当键,需特化 std::hash<T> 或给容器传自定义哈希函数对象,并保证 operator== 与哈希一致——这是 STL 哈希容器的“入场券”。
复杂度分析
设 n 为元素总数、b 为桶数(分离链)或表长(线性探测)。以下“平均”均基于好哈希函数 + 均匀输入 + 负载保持小常数的前提。
| 操作 | 分离链 平均 | 分离链 最坏 | 线性探测 平均 | 线性探测 最坏 |
|---|---|---|---|---|
| 插入 | O(1) | O(n)* | O(1) | O(n) |
| 查找 | O(1) | O(n) | O(1) | O(n) |
| 删除 | O(1) | O(n) | O(1) | O(n) |
| 空间 | O(n) | O(n) | O(n) | O(n) |
*最坏插入 O(n) 是因为集合要查重:若 n 个键全落进同一条链,插入前的查重就要扫整条链;若允许重复则最坏可降到 O(1)。线性探测的最坏情形是表几乎满、全部元素聚成一个大簇时,要探遍全表才找到空位。
原因简述:平均情形下每条链 / 每次探测的期望长度是负载因子(小常数),故平均 O(1);最坏情形(糟糕的哈希函数 + 恶意输入)所有键挤进同一桶或同一大簇,操作退化为 O(n)。两个补充点:① 字符串键的哈希本身 O(k)(k = 串长),实际开销应写作 O(k);② 单次 rehash 代价 O(n),但桶数翻倍使 n 次插入的总代价仍为 O(n),均摊 O(1)。官方还给出经验值:线性探测把表维持在 25%–50% 占用可显著压低昂贵探测的概率;分离链则靠“负载超阈值就 rehash”把链长钉在小常数上。
关键要点
- 哈希表 = 数组 + 哈希函数 + 冲突消解策略,平均 O(1) 增删查、O(n) 空间,是“用一点空间换回常数时间”的典范。
- 冲突不可避免:线性探测靠向后找空位(删除须用墓碑)、分离链靠同桶挂链表(头插 O(1)),两策各有取舍。
- 一切平均 O(1) 的承诺都建立在“好哈希函数 + 负载不过高”之上:哈希要确定性、均匀、范围大、相似输入差异大。
- 负载因子是哈希表的“健康指标”:链式结构超阈值就 rehash 翻倍(均摊 O(1)),开放寻址则宜让表保持 25%–50% 空闲。
- std::unordered_set/map 给出平均 O(1) 操作但遍历无序;要有序遍历请回到第 3 讲的 std::set/map。
常见陷阱与注意事项
- 哈希函数不确定(例如掺入随机数):同一键插入、查找算出的下标不同,记录“失踪”。规避:哈希必须纯函数。
- 取模得负下标:C++ 对负数取模结果为负(Python 不会),哈希码溢出变负后
% 表长仍是负值,越界访问直接崩。规避:先转成无符号类型(如static_cast<size_t>)再取模;也不要迷信abs()(INT_MIN的绝对值仍是负数)。 - 线性探测删除后直接置空:探测链被截断,后续键查不到。规避:一律打墓碑,且插入优先复用墓碑槽。
- 把“平均 O(1)”当成“绝对 O(1)”:坏哈希 + 全撞一桶时是 O(n),插 n 个键可到 O(n²);字符串哈希本身也是 O(k)。规避:理解最坏情形存在,并选均匀的哈希。
- 表塞得太满才想起扩容:开放寻址表接近满时,单次插入要探过几乎整张表。规避:像 vector 一样提前翻倍扩容,让占用率长期低于阈值。
- 负载因子过低也不健康:几百个元素配几百万个桶,空桶链头指针本身也是内存。规避:扩容/缩容策略兼顾空间与时间。
- unordered 容器用于自定义类型不写哈希与相等:编译报错或行为错误。规避:特化
std::hash或传入哈希函数对象,并保证operator==与哈希逻辑自洽。
思考题(带答案)
问题 1:分离链的插入最坏为什么是 O(n)?如果允许集合里出现重复元素,最坏会变成多少? 答案:因为集合不允许重复,插入前必须先在目标桶的链表里查重;若 n 个键全撞进同一桶,查重就要扫整条 O(n) 的链。若允许重复、直接头插不做查重,最坏可降回 O(1)。这说明“集合语义的查重”本身是插入代价的一部分。
问题 2:线性探测查找时遇到 TOMBSTONE 为什么必须继续走、遇到 EMPTY 却可以立刻停? 答案:插入采用“沿探测序列找第一个空位”的规则,因此任何“曾经被占用过的位置”(USED 或 TOMBSTONE)都可能是某键探测链的中间站;只有 EMPTY 才是所有探测链的公认终点,遇到它即可断定“后面不可能再有该键”。墓碑若被当作终点,排在它后面的键就永远查不到了。
问题 3:为什么 rehash 时桶数通常翻倍而不是只加一两个? 答案:rehash 要把全部 n 个元素重新哈希入桶,代价 O(n);若每次只小幅扩容,插入 n 个元素可能触发 O(n) 次 rehash,总代价退化到 O(n²)。翻倍扩容使 rehash 次数只有 O(log n) 次,n 次插入总代价仍为 O(n),均摊 O(1)——和 vector 扩容是同一个道理。
