哈希表——快速找到数据:冲突、删除与扩容的实现

文章目录

    • [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),支持有序遍历与范围查询
相关推荐
Code_Solitude1 小时前
c语言:关于qsoet函数知识点
c语言·数据结构·算法
Nil2082 小时前
leetcode 49字母异位分组
算法·leetcode·哈希算法
音草2 小时前
C++ 继承详解:从概念到菱形继承
c++
weixin_307779132 小时前
C++代码实现MATLAB中的spectralcluster函数功能
开发语言·c++·算法·matlab
Pniubi2 小时前
快速排序(力扣基础)(递归)
java·数据结构·算法
xiaoye-duck3 小时前
《C++ 项目:手写大模型接入 SDK》Trae IDE 开发环境搭建与依赖管理
c++·ide·语言模型
沫璃染墨3 小时前
《Qt从零入门系列(十四):QThread多线程详解——从线程创建到客户端阻塞处理》
开发语言·c++·笔记·qt·ui·交互·线程
草莓熊Lotso3 小时前
【C++ 基于微服务的即时通讯系统】Elasticsearch 从安装到 C++ 客户端二次封装全落地
linux·网络·数据库·c++·elasticsearch·微服务
CHPCWWHSU3 小时前
FreeCAD 源码解读:从 Property 到 Command,看一套 CAD 内核的完整闭环
c++·cad