字符串算法最容易被误会成“记数组模板”。这节先做两件很人类的事:匹配失败时利用已经看过的文字;找回文时利用已经量过的对称。以后数组名忘了,也能把路重新走出来。
学完后,你能:
前置:字符串下标、前缀/后缀、循环。尝试:
AAAA 在文本 AAAAA 中出现几次?ABABA 的最长真前后缀相同部分是什么,长度多少?准备两条纸带:文本 ABABABACABA 与模式 ABABAC。每次比较都在文本和模式下方画一条竖线,并记录 文本位置 i、已连续匹配长度 j、下一次从模式哪一格继续。遇到回文时,改画一条字符串纸带,记录 中心、已确认左右边界、还能免费借到多少对称信息。
本节的互动约束是:每次跳过比较之前,都要写出一份已经成立的证据。若说不出证据,就不能跳。
把模式 ABABAC 从文本第 0 位对齐,逐字符比较。前五位 ABABA 相同,第六位文本是 B、模式是 C,失配。朴素法会把模式右移一格,再从模式第 0 位比起。
停顿 0:失配前已经确认的
ABABA中,列出所有既是前缀又是后缀的非空字符串。不要只写最长一个。
先不写数组。令 j 表示“文本当前位置之前,连续匹配了模式前缀的多少字符”。请在这段扫描中填表:
| 读到的文本字符 | 读前 j |
比较的模式字符 | 成功/失配 | 读后 j |
|---|---|---|---|---|
| A | 0 | |||
| B | 1 | |||
| A | 2 | |||
| B(第一次失配处) | 5 |
停顿 1:失配后如果保留长度 3,下一次为什么应把当前文本字符再与模式第 3 位比较,而不是直接跳到下一个文本字符?
pi,不要先背下标对模式 A B A B A C,每写到一个新位置,就问:“当前整个前缀的最长真前缀,也是它的后缀吗?”先写字符串,再写长度:
| 前缀 | 所有可用 border | 最长长度 pi |
|---|---|---|
A |
||
AB |
||
ABA |
||
ABAB |
||
ABABA |
||
ABABAC |
停顿 2:在给
ABABAC的最后一个 C 求pi时,为什么先从长度 3 尝试,而不是从长度 4、2、1 乱猜?
写 abacaba,对每个位置当中心,左右同时扩。请先填写中心 3(字符 c)能扩到多远,再填写中心 2(字符 a)能扩到多远。然后把中心 3 已经确认的回文区间画成 [0,6]。
停顿 3:中心 4 关于中心 3 的镜像是谁?镜像中心已有半径为什么能先给中心 4 一段免费下界?
| 算法 | 当前状态 | 可复用的证据 | 何时必须做新比较 |
|---|---|---|---|
| KMP | |||
| Manacher |
朴素匹配把模式串放在文本每个起点:一旦第 $j$ 位失配,就把模式整体右移一格,再从第 0 位重新比较。若模式是 ABABAC,已经匹配了 ABABA 后失败,前面的 ABA 其实仍可能是下一次匹配的开头;全部扔掉等于让字符白读一遍。
回文也有同一种浪费:逐个中心向两边扩展时,已知一个长回文内部的镜像位置,仍会重新对比很多字符。我们需要给“已经确认的对称”开收据。
设已经匹配模式前缀 pattern[0..j-1]。如果下一个字符失配,仍可能成为新开头的部分必须同时:
这正是 border。令 pi[i] 是 pattern[0..i] 的最长 border 长度。失配时不试 j-1,j-2,...,而把候选直接跳到 pi[j-1];若仍失配,再沿 border 链继续跳。
j = 0
for i in 1..len(pattern)-1:
while j > 0 and pattern[i] != pattern[j]:
j = pi[j - 1] # 更短、但仍可能的 border
if pattern[i] == pattern[j]:
j += 1
pi[i] = j
# 扫描文本时用同一段 while;j == len(pattern) 时记录一次匹配
每次 j 回退都严格变小,而每个文本字符只会推动它前进有限次,因此匹配是 $O(n+m)$。KMP 不是“不回退”,而是只回退到有证据的位置。
先只看奇长度回文。radius[i] 表示以 i 为中心、向一侧能扩多远(包含中心,最小为 1)。维护目前最靠右的已知回文区间 $[L,R]$。当新中心 $i\le R$ 时,它关于旧中心的镜像 mirror=L+R-i 已经有半径;radius[i] 至少能取 min(radius[mirror], R-i+1),然后只检查可能越过 $R$ 的新字符。
L = 0; R = -1
for center in 0..n-1:
radius[center] = 1 if center > R else min(radius[L + R - center], R - center + 1)
while characters just outside this radius are equal:
radius[center] += 1
if center + radius[center] - 1 > R:
L = center - radius[center] + 1
R = center + radius[center] - 1
对 KMP,按下表从 ABABA 的失配开始填:
当前 j |
已匹配后缀 | 它是否也是模式前缀 | 下一候选 j |
当前文本字符是否重读 |
|---|---|---|---|---|
| 5 | ABABA |
— | ||
| 3 | ABA |
— | ||
| 1 | A |
— |
对 Manacher,给 abacaba 写出奇半径数组,并圈出哪几个中心位于已有最右区间内。请特别写出中心 5 为什么一开始可借中心 1 的半径,却仍可能需要截断。
偶长度回文只需把中心放在两个字符间、调整边界;正式课会统一下标约定。Manacher 快的理由同样不是跳过真相,而是每次额外比较若成功都会把全局右边界推得更远,总推进不超过 $n$。
KMP 复用的是“前缀和后缀已经相同”的事实;Manacher 复用的是“一个区间已经左右对称”的事实。两者都维护可证明的已知区域,而不是凭感觉跳过字符。
| 名称 | 它描述什么 | 典型下标错误 |
|---|---|---|
pi[i] |
前缀 0..i 的最长真 border 长度 |
误把整个串算成 border |
j |
当前已匹配模式前缀长度 | 失配后访问 pi[j] 而非 pi[j-1] |
radius[i] |
中心向外覆盖的半径 | 把“含中心”与“不含中心”混用 |
j=0 时不能访问 pi[-1]。j=pi[j-1] 才能保留重叠匹配。运行 不从头再看的字符串.py。选择 k 可以看前缀函数构建、失配回跳和朴素比较次数;选择 m 可以看每个中心如何借镜像半径再向外扩。
先故意把模式换成 AAAA,观察“成功后保留 border”为什么会找到重叠出现。字符没有忘性,只有朴素循环比较健忘。
Border 链还能找周期、统计前缀出现次数;回文半径还能统计回文子串和回答中心覆盖问题。两者都只适用于比较关系稳定的线性序列;若字符会被中途修改,通常需要不同的数据结构。
pi[j-1] 是合理的下一候选?ABABA 的所有 border。ABABAC 做一次 pi。