
文章目录
-
- [1. 已经有搜索树了,为什么还需要哈希表?](#1. 已经有搜索树了,为什么还需要哈希表?)
- [2. 哈希函数怎样选择,负载因子又说明什么?](#2. 哈希函数怎样选择,负载因子又说明什么?)
-
- [2.1 整数可以取模,其他键先产生哈希值](#2.1 整数可以取模,其他键先产生哈希值)
- [2.2 负载因子衡量拥挤程度](#2.2 负载因子衡量拥挤程度)
- [3. 线性探测:位置被占后,按同一条路径继续找](#3. 线性探测:位置被占后,按同一条路径继续找)
- [4. 删除的难点:空位置为什么还要分两种?](#4. 删除的难点:空位置为什么还要分两种?)
-
- [4.1 直接清空,会切断查找路径](#4.1 直接清空,会切断查找路径)
- [4.2 墓碑能复用,但不能让重复键混进来](#4.2 墓碑能复用,但不能让重复键混进来)
- [5. 扩容改变了位置规则,必须重新安置元素](#5. 扩容改变了位置规则,必须重新安置元素)
- [6. 链地址法:让同一个桶里的元素连起来](#6. 链地址法:让同一个桶里的元素连起来)
- [7. 复杂度什么时候是 O(1),什么时候会慢下来?](#7. 复杂度什么时候是 O(1),什么时候会慢下来?)
- [8. 总结](#8. 总结)
1. 已经有搜索树了,为什么还需要哈希表?
红黑树能把查找控制在 O(log N),同时保留键之间的大小关系。但如果需求只是"给定学号找成绩""给定单词统计次数",我们并不需要每次都沿着大小关系寻找。
回想数组:知道下标,便能直接访问对应位置。如果键本身就能告诉我们该去哪里,就可能省掉逐层比较。
假设只统计小写英文字母,count[ch - 'a'] 就把字符映射到 0~25。这里要求输入确实属于 'a'~'z';对于任意字符直接这样访问,会有越界风险。这种直接定址适合键的范围小且集中:一个位置对应一个可能的键。
但只有一千名学生,学号却可能跨越数亿个整数时,按学号开数组就很浪费。我们希望只开有限的位置,再把大范围的键映射进去。这就是哈希表的出发点。
通常可以把定位过程拆成两步:
text
键 key ──Hash(key)──> 无符号整数哈希值 ──% M──> 初始位置 / 桶下标
M 是当前槽位数或桶数,必须大于 0。哈希值不是数组下标,只有经过范围压缩,才能作为当前表的下标。

不同键定位到同一位置,称为哈希冲突。键的可能取值远多于位置时,不可能让所有不同键都占有独立位置。所以,哈希表必须同时解决两个问题:尽量分散数据,以及发生冲突后仍能正确找到数据。
因此查找的完整思路是:用哈希确定查找范围,再比较键来确认目标。
2. 哈希函数怎样选择,负载因子又说明什么?
2.1 整数可以取模,其他键先产生哈希值
对于非负整数,key % M 很直观。但在 C++ 中,-1 % 11 是 -1,不能把这个有符号结果直接当数组下标。
字符串也不能直接当作整数 % M,但可以让每个字节参与累积,最终仍然形成一个无符号正整数。
cpp
struct StringHash
{
std::size_t operator()(const std::string& key) const
{
std::size_t value = 0;
for (unsigned char ch : key)
value = value * 131 + ch;
return value;
}
};
以常见字符编码中的 "ab" 为例,先得到 97,再得到 97 * 131 + 98 = 12805。"ba" 的结果是 98 * 131 + 97 = 12935。与简单相加相比,字符的位置参与了运算,因而这两个字符串不再必然冲突。
std::size_t是无符号整数,算术超出范围时按无符号规则回绕,不是有符号溢出。
无论选哪种算法,必须满足:
- 如果两次比较认为键相等,它们的哈希值必须相同;不同键的哈希值允许相同。
- 键存放在表内期间,其哈希结果与相等判断应保持稳定。不能插入时用一种规则,查找时随意换另一种。
2.2 负载因子衡量拥挤程度
设有效元素数为 N,槽位数或桶数为 M:
text
负载因子 α = N / M
代码中若要计算小数,需先转成浮点数;N / M 的整数除法会截断。
开放定址法让元素直接占槽位,因此
N ≤ M。
链地址法中的M是链表头指针的数量,一个桶可以挂多个结点,因此α可以超过 1,表示每桶平均元素数,不是数组占用率。
3. 线性探测:位置被占后,按同一条路径继续找
开放定址法把元素放在同一个槽位数组内。线性探测最容易理解:从初始位置出发,冲突就往后走一步,到末尾后回到 0。
text
初始位置 h = Hash(key) % M
探测位置依次为 h, (h + 1) % M, (h + 2) % M, ...
以这组整数键为例,暂时固定 M = 11 来观察位置变化,不在图中触发自动扩容:
text
19, 30, 5, 36, 13, 20, 21, 12
19 放到 8;30 也从 8 开始,发现 19 后放到 9。20 从 9 开始,绕过 30 后放到 10。21 从 10 开始,被 20 挡住,回到 0。12 从 1 开始,恰好可直接放入。
最终数组中的键为:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 键 | 21 | 12 | 13 | 36 | 空 | 5 | 空 | 空 | 19 | 30 | 20 |

查找也必须沿相同路径前进。例如找 20,从 9 出发,看到 30 不相等,继续到 10 才确认命中。
如果查找一个不存在的键,遇到从未使用过的空槽就可以停止:若该键曾经沿这条路径插入,就本应在遇到这个空槽时就存下来,不会越过它。
线性探测的代价是堆积。连续的一段槽位被占据后,起点不同的键也可能汇入同一段,进一步拉长连续区域。
| 其他探测策略 | 思路 | 使用时的关键条件 |
|---|---|---|
| 二次探测 | 用平方相关的偏移拉开探测位置 | 公式、容量和负载需要配合;不能认定任意公式都能走遍所有槽位 |
| 双重散列 | 用另一个哈希函数决定步长 | 步长非零,且与容量互质,才能遍历整张表 |
例如容量 12、步长 3,从 1 出发只能访问 1 → 4 → 7 → 10 → 1,其他位置再空也找不到。
4. 删除的难点:空位置为什么还要分两种?
4.1 直接清空,会切断查找路径
仍看上面的表。删除 30 后,如果把下标 9 设成 EMPTY,再找 20:起点就是 9,程序遇到空槽立即报告不存在。实际上 20 仍在下标 10。
解决方法是增加删除标记,也叫墓碑:
| 状态 | 含义 | 查找时 | 插入时 |
|---|---|---|---|
EMPTY |
在本次建表或重建后从未被占用 | 可以报告未找到 | 可以存放 |
EXIST |
当前有有效元素 | 比较键,不同就继续 | 被占用 |
DELETED |
曾经被占用,现在逻辑删除 | 必须继续探测 | 完成查重后可以复用 |

4.2 墓碑能复用,但不能让重复键混进来
删除 30 后,再插入 20,如果看到下标 9 是墓碑就立刻放进去,表中就会出现两个 20。可用位置不等于已经证明键不存在。
有两种清楚的写法:一次探测中记住第一个墓碑,继续查重;或者先完整查重,再寻找可用位置。
若插入 41,它和 19 一样从 8 开始。查重确认 41 不存在后,可以复用下标 9 的墓碑。有效元素数加一,墓碑数减一。

5. 扩容改变了位置规则,必须重新安置元素
容量从 11 改成 23,19 的初始位置从 8 变为 19,30 的初始位置从 8 变为 7。只把数组扩大并保留原下标,新查找却按 23 取模,双方使用了不同的定位规则,这就会导致前后逻辑不一样而错乱。
正确步骤是:申请新槽位数组,遍历旧表,只对 EXIST 元素按新容量重新定位和探测,全部完成后交换新旧数组。墓碑不迁移,有效元素总数不变。

这个过程称为重新散列(rehash)。它不一定意味着扩容:同容量重建也可以用来清理墓碑。
6. 链地址法:让同一个桶里的元素连起来
开放定址让冲突元素继续占其他槽位;而链地址法则让每个桶保存一个链表头指针,冲突的键都挂在同一条链上。
例如容量 11 时,19、30、41 都在桶 8。采用头插,依次插入后链为:
text
buckets[8] → 41 → 30 → 19 → nullptr
找 30 时只需遍历桶 8 的链,不必去其他桶找。删除时可以真正断链并释放结点,不需要墓碑,因为查找结束条件是这条链走到空指针。

这里要记住两个主体:桶数组存的是头指针 ,键值对存在结点里。因此数组容量和元素数量可以不同,负载因子也能超过 1。
扩容时无需再为每个旧元素 new 一次。先创建更大的空桶数组,再把原结点重新链接过去就可以了。关键顺序是:
text
保存旧 next → 计算新桶下标 → 头插到新桶 → 沿保存的 next 继续旧链

另一个不容易注意的问题是本次待插入元素的下标 :如果扩容前算过
hash % 旧容量,扩容后一定要重新按新容量计算,不能继续使用旧下标。比如键 22 在容量 11 时定位到 0,容量 23 时应定位到 22。放错桶后,查找就会像这个元素不存在一样。
7. 复杂度什么时候是 O(1),什么时候会慢下来?
讨论复杂度前,先假设哈希与比较键的成本是 O(1),分布较好,并把负载控制在了合适范围内。
| 操作或场景 | 复杂度与条件 |
|---|---|
| 链地址查找、删除 | 均匀分布时,预期 O(1 + α);α 有界时通常写为平均 O(1) |
| 线性探测查找、删除 | 分布适当且负载与墓碑受控时,预期 O(1);α 接近 1 时探测成本明显上升 |
| 普通插入 | 不含重建时,在上述条件下预期 O(1) |
| 扩容重建 | 至少扫描旧表并初始化新表;链地址迁移为 O(M旧 + M新 + N),不计复杂类型的操作成本 |
| 开放定址重建 | 包含扫描、初始化和重新探测;良好分布时约 O(M旧 + M新 + N),严重冲突时迁入过程可能 O(N²) |
| 任一表的单次查找最坏情况 | 可退化为 O(N) 或扫描 O(M) 个槽位;不能保证每次常数时间 |
链地址中,所有键都进入一个桶时,链就退化成线性查找。开放定址中,连续占用区域很长时,查找也可能检查很多槽位。
在分布良好、容量按几何方式增长等条件下,扩容总成本可分摊到多次插入,插入通常称为均摊预期 O(1)。
空间上,开放定址需要 O(M) 个槽位,本实现每个槽位都构造键值对象;链地址需要 O(M + N) 空间,还多出每个结点的指针、缓存值和单独分配成本。
开放定址连续访问通常有较好的局部性,链地址修改链接和保留结点地址更方便。
红黑树则提供 O(log N) 的查找上界和有序遍历。需要范围查询、有序输出时,树结构的能力仍有价值;只是按键定位、去重、计数时,哈希容器通常会更贴近需求。
8. 总结
- 哈希表核心思想:通过哈希函数将 key 映射到数组下标,实现快速访问
- 时间特性:平均期望 O (1);极端哈希冲突下最坏退化为 O (N)
- 两种实现
- 链地址法:桶内挂链表,负载因子 α 可大于 1;删除简单,缓存局部性差
- 开放寻址法(线性探测等):元素直接存入槽位;α 一般控制在 0.7 左右,空间局部性好,负载高时探测成本飙升
- 扩容:容量几何倍增,扩容代价分摊到多次插入,插入操作均摊 O (1);扩容需要重哈希迁移全部元素
- 对比红黑树:哈希表无序,适合单点查找、去重计数;红黑树稳定 O (logN),支持有序遍历与范围查询
