(f2)Karp-Rabin算法:散列

官官方信息技术老师·16 页·深入(追求细节与边界)·0 次浏览·38 天前
字符串匹配散列滚动哈希

Karp-Rabin:散列匹配

看完你能说清指纹比对如何让串匹配免于逐字符比较

按 空格/→ 演示下一步

问一句
1 / 16 页

全部页面点击任意一页,跳回舞台从这页播放

字符串匹配散列滚动哈希

Karp-Rabin:散列匹配

看完你能说清指纹比对如何让串匹配免于逐字符比较

1第 1 页 · Karp-Rabin:散列匹配

朴素匹配的瓶颈

上一页看到朴素匹配逐格挪动、每次都整段比对。模式串稍长,主串又大,这种「回头重来」的重复劳动会迅速堆出天文数字级的比较次数。

重复比较
相邻两次对齐只挪一格,模式串几乎被完整重扫一遍
最坏代价
主串长 n、模式串长 m 时,最坏需 O(n·m) 次字符比较
突破思路
若能快速判断「这一段像不像」,就不必逐字符比对
指纹核对对应 →散列窗口

先比指纹(散列值)快速排除,指纹不同就无需逐项比对

T=∑i=0n−mm⏟朴素比较=O(nm)T=\sum_{i=0}^{n-m}\underbrace{m}_{\text{朴素比较}}=O(nm)
2第 2 页 · 朴素匹配的瓶颈

指纹思想

上一页看到朴素匹配每轮都逐字比较,太慢。生活中验钞不用逐张摸水印——先看编号对不对,编号不同直接淘汰。字符串也能这样:给每个子串算一个短"指纹",先比指纹。

指纹 = 散列值
把长度为 m 的子串压缩成一个数,如对各字符求和取模
指纹不等必不匹配
指纹不同则两串必然不同,可直接跳过,不必逐字比
指纹相等需复核
不同串可能撞出同一指纹,相等时仍要逐字确认
验钞看编号对应 →指纹比较

编号不同必是假钞(不匹配),编号相同才需仔细验真

3第 3 页 · 指纹思想

串也是数

上一页说要给每个窗口算一个指纹。指纹从哪来?其实不用发明新东西——字符本来就是数字,那一段串就可以直接当成一个整数来读。

字符即数字
如 ASCII 里 'a'=97,每个字符天然对应一个整数
串即 Q 进制数
把窗口串按位权拼成整数,如 "ab"=97×Q+98
为压缩铺垫
长串会对应一个大整数,后续用取模压缩它
电话号码对应 →串的整数化

号码每位是数字,整串连读就是一个大数

H(ab)=97⋅Q+98H(\text{ab})=97\cdot Q+98
4第 4 页 · 串也是数

散列压缩:取模

上一页我们把字符串当成大数,可这个数可能有上千位,没法直接比较。电话号码太长记不住,我们只记尾号四位——尾号相同再去核对全号,一下就把范围缩小了。

取模压缩
大数值 mod M,把结果限制在 0 到 M-1 之间
指纹
取模后的余数就是串的指纹,位数固定、便于比较
碰撞
不同串可能同余,指纹相等后须回原串核对
电话尾号对应 →哈希指纹

记尾号四位对应取模存指纹,尾号同再核对全号对应碰撞后验证

f(s)=Ns mod M,f(s)∈[0, M−1]f(s)=N_s \bmod M,\quad f(s)\in[0,\,M-1]
5第 5 页 · 散列压缩:取模

数位溢出问题

上一页我们用取模把串压成了指纹,但有个新麻烦:计算机的整数宽度是有限的,比如 64 位。m 位 Q 进制数动辄上百位,直接乘一次就『爆表』,指纹反而算不出来。

溢出从哪来
m 位 Q 进制数远超字长,一次乘加就丢高位
模运算可分配
加、乘、减都能先取模再运算,结果不变
边算边取模
每步乘加后立即 %q,中间值始终小于 q²
温度计刻度对应 →取模运算

温度计的 40 度对应取模后的小数值,转一圈归零不影响读数

h=((h⋅Q+si) mod q)h = ((h\cdot Q + s_i) \bmod q)
6第 6 页 · 数位溢出问题

边算边取模的实现

python

看边滚动边取模的写法:中间值始终小于 m²,乘法不溢出。

代码高亮加载中…

每步先乘 B 加新位再取模,值不超过 m²;减旧位用预取模的 B^(k-1),全程不出现长度级大数。

7第 7 页 · 边算边取模的实现

冲突:同指纹不同串

前面我们把长数字「边算边取模」,指纹变得又小又快。但省事是有代价的:好几个不同的数除以同一个 M,余数可能完全相同——指纹一样,串未必一样。

指纹会撞车
不同的串对 M 取模,可能得到相同的余数
边界在哪
指纹相同≠串相同,只说明「可能是匹配」
验证兜底
指纹相等时,仍需逐字符比对一次才能确认
撞得多吗
M 取大质数、串随机时,冲突概率约为 1/M
身份证号中的出生日期对应 →取模指纹

同一天出生的人生日相同,但不能说他们是同一个人

串A≠串B, 但 A mod M=B mod M\text{串}A\neq \text{串}B,\ \text{但 } A \bmod M = B \bmod M
8第 8 页 · 冲突:同指纹不同串

指纹匹配的完整流程

从左到右看:每个窗口先比指纹,只有相等才进逐字核对,否则立即滑到下一个窗口。

图解渲染中…
d1:指纹相等仍可能冲突,须逐字确认才算真匹配f1:假指纹即冲突情形,滑过继续下一窗口
9第 9 页 · 指纹匹配的完整流程

指纹匹配 vs 逐字匹配

指纹相同就等于匹配吗?两种判定方式的误判风险和代价差异,正是容易混淆的地方。

逐字匹配
  • 逐字符比较,绝不误判
  • 最坏 O(m·n),慢在重复比较
  • 适合需要绝对确认的场景
指纹匹配
  • 比较哈希值,可能误判
  • 平均 O(n),坏散列才退化
  • 适合先筛后验的流水线
指纹相同必回退逐字验证;哈希不等可直接判不匹配,效率来自排除而非确认。
10第 10 页 · 指纹匹配 vs 逐字匹配

窗口滑动时什么变了

前面算好了一个窗口的指纹,但逐个重算整窗就又回到朴素匹配了。好在相邻窗口只差两个字符:走一格,最左边出去一个,右边进来一个。其实指纹不必重算,只需局部修补。

减去离场字符
最左字符的贡献是它×进制^(m-1),先把它整个扣掉
整体乘进制
窗口右移一位,所有剩余字符的位权各升一级
加进新字符
新字符位权为1,直接加上,再取模得新指纹
商场排队闸机对应 →滑动窗口更新

队首一人出闸、队尾一人进闸,队伍人数无需重新清点

h′=((h−cold⋅bm−1)⋅b+cnew) mod ph' = \big((h - c_{\text{old}}\cdot b^{m-1}) \cdot b + c_{\text{new}}\big) \bmod p
11第 11 页 · 窗口滑动时什么变了

指纹更新公式

窗口右移一格,指纹不必重算——用旧指纹增量更新。

1
减去左字符
旧指纹减去最左字符乘以其最高位权重,把它从窗口中移除
2
左移一位
整体乘进制的位数次幂,相当于窗口数字左移一格
3
加入右字符
加上新进入窗口的字符值,补全新窗口的数
4
全程取模
每步运算后都取模,防止大数溢出,保证 O(1) 更新
12第 12 页 · 指纹更新公式

Karp-Rabin 主循环

python

完整主循环:预计算首窗指纹,滑动时 O(1) 更新,命中再逐字验证。

代码高亮加载中…

循环前 O(m) 算好两个指纹;主循环每步先比对指纹,相等才做 O(m) 逐字验证;否则用更新公式 O(1) 滑到下一窗。

13第 13 页 · Karp-Rabin 主循环

自测:指纹相等意味着什么

点击作答

Karp-Rabin 中两个窗口的指纹相等,能得出什么结论?

14第 14 页 · 自测:指纹相等意味着什么

要点回顾

  • ✓指纹相等只是候选,逐字核对才算数
  • ✓取模压住溢出,代价是引入碰撞风险
  • ✓滑动窗口只需 O(1) 更新指纹
  • ✓四环节闭环:压缩→溢出→冲突→更新
延伸主题:期望时间分析与碰撞概率上界选什么模数与进制更安全多模式匹配与滚动哈希变体
15第 15 页 · 要点回顾

课后思考

先自己想几分钟,再对照参考答案——三问都有回旋余地,不求唯一解。

1为什么 Karp-Rabin 要把比较字符串改成比较数字?直接逐字比较到底慢在哪?

参考答案逐字比较最坏每窗 O(m),窗数 n 个就是 O(nm)。指纹思想把一整窗压成一个数,一次比较 O(1),均摊下整个匹配近似 O(n+m)。核心是「用一个数代表一串」。

2如果要在文本流里实时匹配、文本长度未知,指纹更新公式还怎么用?

参考答案恰好成立:更新公式只依赖旧指纹、新进字符、滑出字符和基数幂,不需要预知全文。边读边滚动更新指纹即可,这也是 Karp-Rabin 适合流式处理的原因。

3指纹相等不等于字符串相等——冲突概率能压到多低?如果不做二次验证会怎样?

参考答案取大素数模后,两不同串偶然同指纹的概率约为 1/p,可近似忽略,但不能当零。不验证就会把冲突误报为匹配;实践里要么指纹相等后逐字复核,要么接受极小误报率换速度。

16第 16 页 · 课后思考