4325 字
22 分钟
算法导论(3) 哈希

封面画师: 元元勺, pixiv id: 112941829, 侵删

目录
  1. 字典
  2. 直接寻址表
  3. 哈希碰撞
  4. 动态哈希表
  5. 字符串匹配
  6. 开放寻址法
  7. 加密哈希
待补充

代码实现

字典(Dictionary)#

一种抽象数据类型. 其维护某个集合, 集合中的每个元素item都含有键key. 拥有如下操作:

  1. 插入: insert(item)
  2. 删除: delete(item)
  3. 查找: search(key)

python的内置类字典实现了上述功能, 每个元素都为一个键-值对.

我们知道, 平衡BST能在O(logn)O(log n)内解决如上问题.

能不能更快?

直接寻址表(Direct Access Table)#

最简单的想法就是建立一个数组, 由于数组的(指定索引)随机访问的效率是O(1)O(1), 可以再设置一个状态保存空元素. 于是:

  1. insert(key, value): arr[key] = value
  2. delete(key, value): arr[key] = EMPTY
  3. search(key): return arr[key]

我们就完成了上述目标. 然而聪明的同学可能已经发现, 我们这个实现有很多问题:

  1. 最主要的问题是key只能是非负整数, 而我们往往需要以字符串或者其他类型作为key.
  2. 假如key的分布比较稀疏(e.g. key[0] = 1, key[1] = 100000000000), 那么即使我们只需要存两个元素, 也得开100000000000以上的空间!

问题1的解决: prehash#

将任意类型的键,确定性地转换成一个整数. 我们暂时不讲这个转换方法, 后面会讲到. 目前只需要知道是可以做到把任意类型的键映射到一个整数的就行. python内置了这个方法, 即hash()函数. 对于自定义对象可以使用__hash__方法.

问题2的解决: hashing#

我们现在有了一个(可能非常大的)整数键. 我们需要将这个巨大的整数域(称为全域U), 映射到一个大小合理的, 可以作为数组索引的小范围 [0,1,...,m1][0, 1, ..., m-1] 中. mm就是我们哈希表(数组)的大小. 这个映射叫做哈希函数(hash function). 这里同样不讲哈希函数的具体实现.

通过预哈希和哈希函数可以将n个不同元素映射到m个不同的桶(或槽位, slot)中. 在理想情况下m = n, 并且每个桶恰好储存一个元素. 这样既不会浪费空间, 也能精确查找.

然而我们发现, 这是难以做到的.

哈希碰撞/哈希冲突#

我们将一个巨大的整数域映射到了一个小范围里, 由于所有可能的键的数量远大于m, 根据抽屉原理, 必然会有多个不同的键k1,k2,...ksk_1, k_2, ... k_s被映射到同一个桶中. 假如说我们的元素恰好被prehash到了k1,k2,...ksk_1, k_2, ... k_s的某些键当中, 就必然出现同一个桶里储存了不同的元素. 这就叫做哈希碰撞(或哈希冲突, Collisions).

如何处理冲突,是整个哈希技术的核心和灵魂. 我们课程里会介绍两种主流的冲突解决方式. 这里介绍拉链法, 后续课程中会介绍开放寻址法.

拉链法(Chaining)#

让哈希表的每个桶不止储存一个元素, 而是储存一个链表:

  1. insert(key, value): arr[key].append(value)
  2. delete(key, value): arr[key].remove(value)
  3. search(key): return arr[key].find(value)

好结局: 所有数据均匀分配到各个桶中, 我们只需要花费常数复杂度的代价即可解决哈希冲突.

坏结局: 我们的运气极差, 所有 nn 个键都被哈希到了同一个桶中. 这时, 哈希表退化成了一个长链表, 所有操作的时间复杂度都变成了O(n)O(n), 我们的 O(1)O(1) 梦想破灭了. (建议买彩票)

为了(在大多数情况下)避免坏结局, 我们必须让键均匀地分配到各个桶中. 让每个链表都尽可能短.

简单均匀哈希假设(Simple Uniform Hashing Assumption)#

一个理想化的哈希函数, 所有数据分配到各个桶中的概率均等(为1/m)并且互相独立.

这时, 每个桶中的链表长度期望值为n / m, 称为负载因子(Load Factor), 记作

α=nm\alpha = \frac{n}{m}

.

简单均匀哈希假设下, 所有操作的期望平均时间复杂度为Θ(1+α)\Theta (1 + \alpha). 那么我们如果能够把负载因子保持在一个常数, 这个复杂度就是Θ(1)\Theta(1).

由于α=nm\alpha = \frac{n}{m}, 也就是说, 我们需要让nnmm成正比.

这是一个理想化的假设, 我们的目标是设计出尽可能接近这个假设的哈希函数.

除法哈希#

除法哈希, 或者叫模哈希. hashing(k)=k%mhashing(k) = k \% m. 优点在于简单快速. 关键在于m的选择, 为了避开数据可能的分布规律, m不应被选择为2的整数次幂或10的整数次幂.

一般来说, 我们选择m为一个远离2的幂次的质数.

乘法哈希#

核心思想:

  1. 将键k乘以某个数a. 乘法这个操作, 特别是当数字很大并发生溢出时, 具有非常好的“雪崩效应”: 键k的微小变化, 都会导致乘积结果的巨大变化, 所有位数都会被充分地打乱.
  2. 从这个被打乱的结果中, 我们不像除法那样取低位, 而是提取中间或高位的某一部分作为最终的哈希值. 因为乘积的中间部分比特, 是受输入键k的所有位共同影响的结果, 因此它包含了最”混乱”, 分布最均匀的信息.

讲义上其实提到了两种形式的乘法哈希.

理论数学形式
  1. 挑选一个数0<A<10<A<1, 比如可以取黄金分割比0.618. 将键kk乘以AA.
  2. kAkA的小数部分, 记为cc, 这个小数部分对k的变化非常敏感.
  3. 这里知道0<c<10<c<1, 我们将其乘mm, 0<cm<m0<cm<m
  4. cmcm向下取整, 即得到了我们要的hash(k)hash(k)

想象一个周长为1的圆. 乘以AA, 就像在圆盘上旋转一个固定距离AA. kAkA就是旋转kk次. cc就是旋转kk次后的最终位置, 这个位置看起来是相当随机的. 然后我们把圆盘分成mm个扇区,看它落在了哪个扇区, 这就是哈希值.

这个方法优美而且易于理解, 然而其涉及浮点数的精确运算, 在计算机里不容易实现. 于是我们有:

计算机实现形式

假设k不会超过w位. w一般取32位(kuint32_t)或64位(kuint64_t) m为哈希表的大小. 为了方便, 可以设置为2的r次幂, 其中r为整数, 即m = 1 << r

  1. 挑选一个w位的数a, 通常是一个较大的, 远离2的整数次幂的奇数. 将键k乘以a. 结果会是一个2w位的整数.
  2. 由于我们的w取的刚好是uint32_t类型或者uint64_t类型的上限, 这个乘法会自然溢出, 只剩下后w位, 记作c.
  3. 我们要取中间位, 由于高位已经被我们舍弃掉了, 所以只需要舍弃一些低位即可. 具体来说我们要保留高r位, 只需将c右移到只剩r位即可. 即hash(k) = c >> (w - r)

乘法哈希的优点:

  1. 速度快: 只利用CPU中最为高效的指令, 左右移和乘法, 比取模运算优秀.
  2. 对m的选择友好: m可以取2的整数次幂, 在计算机中非常方便. 反观除法哈希需要寻找一个大质数, 这通常是非常慢的.
  3. 打乱效果好, 几乎不会受数据内在一般规律的影响. 有效避免冲突.

全域哈希#

以上的哈希函数都是确定性的. 如果攻击者知道了我们的哈希函数, 他/她可以精心构造出一组数据, 使它们全部发生哈希碰撞, 从而使我们的系统性能降低至O(n)O(n). (也就是算法竞赛选手熟知的卡哈希)

全域哈希的思想: 我们不再使用一个固定的哈希函数, 而是使用一整个哈希函数族HH. 程序开始时, 我们在函数族HH中随机选择一个哈希函数hh作为本次运行时的哈希函数. 这意味着即使攻击者知道了整个哈希函数族HH, 他/她也无法预测我们这次会用哪个函数, 从而无法构造出对于特定函数的最坏情况. 因此对于任何输入我们都能有良好的平均性能.

例: hasha,b(k)=[(ak+b)%p]%mhash_{a,b}(k) = [(ak+b) \%p]\%m. 其中a,ba, b是随机选择的, pp是一个大质数, 如998244353998244353.

动态哈希表#

上一节我们提到, 哈希表的规模m要与数据规模n成正比.那么随着数据规模的变化(插入, 删除), 我们哈希表的规模也要发生变化.

采用类似std::vector的倍增形式即可, 可以达到均摊O(1)O(1). 当n>mαn > m\alpha时, m倍增, 当n<mα/4n < m\alpha / 4(避免在临界点时反复地倍增, 缩小)时, m缩小一半. α\alpha的取值因需求而定, 一般设定α<0.7\alpha < 0.7.

但是这里需要注意, 每次改变m的时候我们的哈希函数都会随之改变. 也就是说每次扩容或者缩小, 我们都要对原哈希表的所有元素重新进行一次哈希(rehash), 复杂度O(m)O(m), 均摊后为O(1)O(1).

由于这个均摊复杂度分析非常简单(指的是复杂度分析的过程, 而这个想法本身其实非常天才), 与线性表的扩张如出一辙, 这里不再赘述.

字符串匹配#

字符串匹配问题: 给定一个长文本(text)t, 模式串(pattern)s, 判断s是否是t的一个子串.

朴素算法: 比较t中的每一个与s等长的子串是否等于s. 需要O(st)O(|s|\cdot |t|).

我们知道, 比较字符串需要O(s)O(|s|), 而比较哈希值只需要O(1)O(1). 所以我们只需要比较t中的每一个与s等长的子串的哈希值是否等于s的哈希值即可. 但是这里有一个新的问题. 我们必须要在O(1)O(1)内计算出单个子串的哈希值, 如果每次重新计算的话, 则每次依然需要O(s)O(|s|)用于计算哈希值.

容易想到, 相邻两个子串的区别仅在于首字符和末字符, 如果相邻两个子串的哈希值可以只根据首末字符的变化而递推的话即可达到O(1)O(1)递推, 仅第一个子串需要O(s)O(|s|).

Karp-Rabin算法
  1. 将字符串视作一个a进制的数. 比如如果是纯小写字母的字符串可以取a = 26, 纯英文(文本, 符号)字符串可以取a = 128并用ascii编码代替每个字符.
  2. 这里我们使用除法哈希, 将这个大数对某个大质数p取模.
  3. 假如我们已经知道了上一个子串c1c2...cnc_1c_2...c_n的哈希值hnh_n, 想知道下一个子串c2c3...cncn+1c_2c_3...c_nc_{n+1}的哈希值hn+1h_{n + 1}, 容易注意到我们只需要下面几个步骤(以下操作全部省略%p\% p):
    1. 消除c1c_1对哈希值的影响, 即hnc1an1h_n - c_1 \cdot a^{n - 1}
    2. 哈希值整体左移一位, 即hnah_n \cdot a
    3. 加入后一位的哈希值, 即hn+1=hn+cn+1h_{n + 1} = h_n + c_{n + 1}

我们就根据上一个子串的哈希值O(1)O(1)地递推出了下一个子串的哈希值.

注意到无冲突的情况下, Karp-Rabin算法的时间复杂度为O(s+t)O(|s| + |t|). 但每次冲突需要O(s)O(|s|)的验证成本.

开放寻址法(open addressing)#

核心思想: 不再使用链表等外部数据结构, 所有数据储存在哈希表(数组)内部.

这意味着哈希表的每一个桶要么为空, 要么恰好储存一个元素, 也就是说负载因子α1\alpha \le 1.

如果某个键k对应的储存位置已经被占用, 那么就通过某个特定的探查序列(probe sequence), 寻找下一个空位. 下一个空位被占用就再寻找下一个空位… 直到找到空位为止.

我们可以定义一个寻址路线.

开放寻址法的哈希函数的形式化定义: h:U×{0,1,...,m1}{0,1,...,m1}h: U × \set{0, 1, ..., m-1} → \set{0, 1, ..., m-1}

对于任意一个键k, 其探查序列为<h(k, 0), h(k, 1), h(k, 2), ...>, 一个好的探查序列必须是m的一个排列, 以保证只要表没满, 我们必然能找到一个空位.

开放寻址法的哈希操作流程
  1. 插入insert(item):
    1. i = 0
    2. 计算探查位置slot = h(k, i)
    3. 检查其是否为空
      1. 若不为空, i++, 重复步骤2
      2. 若为空, 元素放入T[slot], 操作完成.
      3. i >= m, 表已满, 操作失败
  2. 查找search(key):
    1. i = 0
    2. 计算探查位置slot = h(k, i)
    3. 检查其T[slot].key是否等于key
      1. 若不相等, i++, 重复步骤2
      2. 若相等, 查找成功
      3. 若为空, 查找失败
      4. i >= m, 查找失败
  3. 删除delete(item): 这里出现了问题. 假如我们只是简单找到元素并将该位置设置为空, 那么假设另一个键k的探查路径经过这个位置时, 可能会提前遇到空位置并判定查找失败(而不探查后面的探查路径), 删除操作破坏了其他键的探查链.
    1. 解决办法: 可以设置一个特殊标记作为”已删除”状态, 删除某个元素时并不将其设定为空, 而是设定为”已删除”状态.
    2. 若插入操作遇到”已删除”状态, 可以直接当做空位使用, 也就是将”已删除”替换为需要插入的元素
    3. 若查找操作遇到”已删除”状态, 则认为该位置并非空位, 继续探查下一个位置.

探查策略#

线性探查(Linear Probing)#

最容易想到的一个, 如果位置slot被占用, 就访问slot+1,slot+2,...,slot+islot+1, slot+2, ..., slot+i

线性探查有一个致命问题:

聚集(clustering): 由于探查序列最初的重叠部分太多, 一旦出现一小块被连续占用的桶, 任何哈希到这段区间内的桶都必须连续探查到区间末尾才能插入. 这会导致这个区间像滚雪球一样越来越大, 最终哈希表退化为线性表, 操作的时间复杂度退化为O(n)O(n)!

二次探查(Quadratic Probing)#

如果位置slot被占用, 探查slot+1,slot+4,...,slot+i2slot+1, slot+4, ..., slot+i^2

可以有效避免探查序列的重叠. 会产生一种二次聚集, 但更为温和.

双重哈希(Double Hashing)#

利用两个哈希函数h(k),hstep(k)h(k), h_{step}(k), 第一个决定初始位置(slot=h(k)slot = h(k)), 第二个决定步长step=hstep(k)step = h_step(k). 如果位置slot被占用, 探查slot+step,slot+2step,...,slot+istepslot + step, slot + 2step, ..., slot + i\cdot step, 注意stepstep必须与m互质才能保证遍历表中所有位置. 一个常见的做法是, 令mm为2的整数次幂, 并且令hstep(k)h_{step}(k)永远返回奇数.

每个键k不仅有自己的起始点, 还有自己独特的探查步长.

优点: 性能最好的开放寻址策略之一, 能有效地避免探查序列重叠, 从而有效避免了各种聚集问题.

性能分析#

均匀哈希假设(Uniform Hashing Assumption)是分析开放寻址法的理想化假设: 每个键的探查序列, 都等可能地是 m! 种排列中的任意一种.

这在现实中是不可能实现的, 但双重哈希被认为是最接近这个理想状态的.

在均匀哈希假设下, 一次不成功查找(或一次插入)的期望探查次数是: E[probes]11αE[probes]\le \frac{1}{1-\alpha}

所以开放寻址法的性能与1α1 - \alpha成反比, 对于负载因子α\alpha非常敏感. 在负载因子比较大的时候, 进行扩容能取得很好的收效.

加密哈希(Cryptographic Hashing)#

我们上面所讲的哈希算法追求的是快速(O(1)O(1)操作), 而加密哈希追求的是安全性.

加密哈希的属性
  1. 单向性: 从哈希值h(x)反推出原始输入x是计算上不可行的.
  2. 抗碰撞性: 找到任意两个不同的输入x和y, 使得h(x) = h(y)是计算上不可行的.
  3. 抗目标碰撞性: 给定某个输入x, 找到一个x’使得h(x) = h(x’)是计算上不可行的.
加密哈希的应用
  1. 密码储存: 储存密码的哈希值, 而非原文, 防止数据泄露
  2. 文件完整性校验: 计算文件哈希值如MD5, SHA-256等, 如果文件被篡改, 哈希值就会改变.
  3. 数字签名: 对消息的哈希值签名, 而非对整个长消息签名.
算法导论(3) 哈希
https://fuwari.vercel.app/posts/算法导论3-哈希/
作者
ykindred
发布于
2025-09-17
许可协议
CC BY-NC-SA 4.0