Z 函数:扩展 KMP
基于通用算法套路 · 核于 2026-07
速查
- 目标:对串
s(长度n),O(n) 构造 Z 数组z[0..n-1],其中z[i] = s与s[i:]的最长公共前缀长度;z[0]通常约定为0(或n,本站按 0 处理)。 - 示例:
s = "aabaa"→z = [0, 1, 0, 2, 1];s = "ababa"→z = [0, 0, 3, 0, 1]。 - 核心思想(Z-box 加速):维护当前已触及最右的「匹配段」
[l, r](满足s[l..r-1] === s[0..r-l-1],即这段是串的前缀),当前下标i落在r内时,从镜像i-l的已知 Z 值起步扩展。 - 借初值公式:
i < r时z[i] = min(z[i-l], r-i)起步;i >= r时z[i] = 0起步;然后while (s[i+z[i]] === s[z[i]]) z[i]++。 - 更新 l、r:若
i + z[i] > r,则l = i, r = i + z[i](向右扩展了 Z-box)。 - O(n) 证明:
r单调右移、总推进 ≤ n;每次「成功的字符比较」都让r右移至少 1,失败比较每个i最多 1 次,总比较 ≤ 2n。 - 应用①字符串匹配:构造
pat + '#' + txt的 Z 数组,z[i] == |pat|(i在txt部分)处即一个匹配点,整体 O(n+m)。 - 应用②最小周期:最小的
p满足z[p] == n - p,则串是长度p的循环节重复;若还要n % p == 0则是完全周期。 - 应用③重复子串/后缀关系:
z[i] == n - i表示后缀s[i:]恰是整串前缀(border 关系)。 - 对比 KMP:KMP 的
next[i]是「s[0..i]的最长相等前后缀」;Z 函数直接给「s[i:]与整串的公共前缀」——后者更直观,求匹配/周期更顺手,故称「扩展 KMP」。 - 易错:
z[0]约定要统一;min(z[i-l], r-i)的r-i是「Z-box 余量」不可漏;匹配应用里分隔符必须不在字符集内。
一、定义与示例
Z 函数(Z-function)又称 扩展 KMP(extended KMP)。对长度为 n 的串 s,定义:
z[i] = s[0..] 与 s[i..] 的最长公共前缀长度(即从 i 起的后缀,与整串前缀最多匹配多少个字符)约定 z[0] = 0(整串与自身的 LCP 是整串长度 n,但这个值无用且会干扰循环,故按 0 处理;也有教材约定 z[0] = n,仅差一处特判)。几个例子:
s = "aabaa" → z = [0, 1, 0, 2, 1]
^ ^ ^ ^
| | | s[4:]="a",与 s 公共前缀 "a" 长 1
| | s[3:]="aa",与 s 公共前缀 "aa" 长 2
| s[1:]="abaa",与 s 公共前缀 "a" 长 1
z[0] 约定 0
s = "ababa" → z = [0, 0, 3, 0, 1]
s[2:]="aba",与 s="ababa" 公共前缀 "aba" 长 3二、Z-box 加速:核心三步
朴素构造是 O(n²)(每个 i 从 0 扩展)。Z 函数用「维护最右匹配段」做到 O(n)。维护两个量:
l:当前已触及最右的「匹配段」(满足s[l..r-1] === s[0..r-l-1])的左端。r:该匹配段的右端(开区间,即第一个还没确认匹配的位置)。
这个 [l, r) 段叫 Z-box——它本身就是串的一个前缀,所以 i 落在 r 内时,i 关于 l 的镜像 i - l 处的 Z 值可以借用。对每个 i(从 1 开始):
- 借初值:
i < r时,z[i] = min(z[i - l], r - i)。z[i-l]是镜像位置的已知值(s[i..]与s[i-l..]同构于s[0..]与s[i-l..]的已算关系);r - i是「Z-box 余量」(超过r的部分没法保证,要老老实实扩展)。i >= r时z[i] = 0。 - 向右扩展:
while (i + z[i] < n && s[z[i]] === s[i + z[i]]) z[i]++。 - 更新 l、r:若
i + z[i] > r,则l = i, r = i + z[i](Z-box 右扩)。
js
// Z 函数:O(n) 构造 Z 数组
function zFunction(s) {
const n = s.length;
const z = new Array(n).fill(0);
let l = 0, r = 0; // Z-box:[l, r) 是已知的匹配段(等于 s 前缀)
for (let i = 1; i < n; i++) {
if (i < r) z[i] = Math.min(z[i - l], r - i); // 借镜像初值
while (i + z[i] < n && s[z[i]] === s[i + z[i]]) z[i]++; // 扩展
if (i + z[i] > r) { l = i; r = i + z[i]; } // 更新 Z-box
}
return z; // z[0] 保持 0
}三、O(n) 摊还分析
与 Manacher 完全同构的证明:r 单调右移、总推进 ≤ n。
- 每次
while成功匹配一次(s[z[i]] === s[i+z[i]]成立),i + z[i]至少 +1,更新后r也至少 +1——而r ≤ n,所以成功比较 ≤ n 次。 - 借初值保证了「
r以内的比较不必重做」;失败的比较(while退出那一次)每个i最多 1 次,共 ≤ n 次。
总计字符比较 ≤ 2n,O(n)。这是「单调右端点 + 镜像借用 + 摊还」模板的又一实例。
四、应用一:字符串匹配
把模式串 pat(长 m)和主串 txt(长 k)用字符集外的分隔符 # 拼起来,对拼接串 pat + '#' + txt 求 Z 数组:
- 在
txt部分(下标i >= m + 1),若z[i] === m,则说明txt中对应位置起、长度m的子串与pat完全相同——找到一个匹配点。 - 整体 O(n + m)(拼接串长
m + 1 + k),与 KMP 同阶,但代码更短、推导更直观。
js
// 字符串匹配:返回 txt 中所有匹配 pat 的起始下标
function zMatch(txt, pat) {
const concat = pat + '#' + txt;
const z = zFunction(concat);
const m = pat.length, res = [];
for (let i = m + 1; i < concat.length; i++) {
if (z[i] === m) res.push(i - (m + 1)); // 还原回 txt 的下标
}
return res;
}五、应用二:最小周期与重复子串
- 最小循环节:最小的正整数
p,使得s可由其前p个字符重复若干次得到。用 Z 函数判定:若z[p] === n - p(即s[p..]与s的公共前缀正好覆盖到串尾),则p是一个「弱周期」(s是s[0..p-1]重复后可能截断的串);若还满足n % p === 0,则是「完全周期」(整数次重复)。最小的这种p即最小周期。 - 后缀是前缀(border):
z[i] === n - i表示后缀s[i..]恰好等于整串前缀s[0..n-i-1]——这给出串的所有 border(相等的前后缀)长度,与 KMP 的next数组等价但视角不同。 - 判重复子串:如「
s是否由某个子串重复多次构成」——找最小p使z[p] === n - p且n % p === 0,若存在则s = s[0..p-1]重复n/p次。
交互演示
- Z 函数可视化演示 —— Z-box 区间的复用与扩展
下一步
Manacher 与 Z 函数都讲完了,两者共用「单调右端点 + 镜像借用」的线性算法精髓。若要查阅复杂度表、完整代码、应用清单与易错点速查,见参考。