字符串 / 进阶
KMP:前缀函数与线性字符串匹配
从最长相等真前后缀推导前缀函数,在失配时复用已知信息,并在线性时间内找出模式串的全部出现位置。
- 复杂度
- 预处理 O(m),匹配 O(n),空间 O(m)
- 阅读
- 4 分钟
- 前置知识
- 字符串、循环不变量
- 字符串
- KMP
- 前缀函数
问题#
给定长度为 的文本串 text 和长度为 的模式串 pattern,找出模式串在文本中的所有出现位置,允许出现位置相互重叠。
朴素匹配从每个文本位置重新比较,最坏需要 。KMP 的目标是:失配时不让文本指针回退,而是利用已经匹配部分的前后缀信息调整模式串位置。
核心思想与不变量#
前缀函数#
对字符串 ,定义:
也就是说,pi[i] 是前缀 s[0..i] 的最长相等真前缀与后缀的长度。例如 s = "ababaca" 的前缀函数为:
s: a b a b a c a
pi: 0 0 1 2 3 0 1
计算 pi[i] 时,先令 j = pi[i-1]。如果 s[i] != s[j],长度为 j 的候选边界失败;下一个仍可能成立的最长候选,正是这个边界自身的最长边界 pi[j-1]。不断跳转,直到字符相等或 j = 0。
循环不变量是:每次比较前,s[0,j) 已经等于 s[i-j,i),而 j 是尚未排除的最长候选边界。
匹配过程#
扫描文本时令 j 表示当前已经匹配的模式串前缀长度。若 text[i] 与 pattern[j] 失配,同样令 j = pi[j-1];若匹配则 j++。
当 j == m,一次匹配结束于位置 i,起点是 i-m+1。记录答案后不能直接清零,而要令 j = pi[j-1],这样才能发现重叠匹配。
文本指针始终只向右移动;模式长度 j 虽会回退,但每次增长和回退的总次数都是线性的。
复杂度#
构造长度为 的前缀函数需要 时间和 空间;扫描长度为 的文本需要 时间。因此总时间为 ,额外空间为 。
虽然代码含有嵌套 while,但 j 每增长一次才有可能在之后回退一次,所有回退次数之和不超过所有增长次数之和,因此不是平方复杂度。
C++17 模板#
模板不依赖拼接分隔符,因此文本与模式串可以包含任意普通字符。这里约定空模式串不执行匹配,返回空位置集合。
#include <iostream>
#include <string>
#include <vector>
using namespace std;
vector<int> prefix_function(const string& pattern) {
vector<int> pi(pattern.size(), 0);
for (int i = 1; i < static_cast<int>(pattern.size()); ++i) {
int matched = pi[i - 1];
while (matched > 0 && pattern[i] != pattern[matched]) {
matched = pi[matched - 1];
}
if (pattern[i] == pattern[matched]) ++matched;
pi[i] = matched;
}
return pi;
}
vector<int> kmp_find_all(const string& text, const string& pattern) {
vector<int> positions;
if (pattern.empty()) return positions;
vector<int> pi = prefix_function(pattern);
int matched = 0;
for (int i = 0; i < static_cast<int>(text.size()); ++i) {
while (matched > 0 && text[i] != pattern[matched]) {
matched = pi[matched - 1];
}
if (text[i] == pattern[matched]) ++matched;
if (matched == static_cast<int>(pattern.size())) {
positions.push_back(i - static_cast<int>(pattern.size()) + 1);
matched = pi[matched - 1]; // 保留边界,继续寻找重叠匹配
}
}
return positions;
}
// 返回能够完整平铺字符串的最短重复单元长度;不存在更短单元时返回 n。
int smallest_repeating_period(const string& text) {
if (text.empty()) return 0;
vector<int> pi = prefix_function(text);
int candidate = static_cast<int>(text.size()) - pi.back();
return text.size() % candidate == 0 ? candidate : static_cast<int>(text.size());
}
int main() {
string text = "ababcabcabababd";
string pattern = "ababd";
vector<int> positions = kmp_find_all(text, pattern);
cout << "matches (0-based):";
for (int position : positions) cout << ' ' << position;
cout << '\n';
cout << "period of abcabcabc = "
<< smallest_repeating_period("abcabcabc") << '\n';
return 0;
}
示例#
模板输出:
matches (0-based): 10
period of abcabcabc = 3
当模式串已经匹配了 abab,却遇到不等于 d 的字符时,KMP 不会把文本位置退回开头,而是沿前缀函数跳到模式串中仍与已读文本后缀相等的最长前缀,再继续比较。
重叠匹配同样能被找到:文本 aaaaa、模式 aaa 的答案为 0, 1, 2。
易错点#
pi[i]记录的是长度,不是下标;回退写成pi[matched - 1],不能访问pi[matched]。- 只有
matched > 0时才能继续回退,否则会访问负下标。 - 完整匹配后要先记录位置,再回退到
pi[m-1];不回退会在下一字符访问pattern[m],清零则会漏掉重叠匹配。 next数组有多种下标和初值约定。使用别人的模板前,应先确认它表示长度还是失配后的下标;本文统一使用前缀函数pi。- 最短边界周期 不一定能完整平铺字符串;求重复单元时还要检查
n % candidate == 0。 - 通过
pattern + '#' + text计算时,必须保证分隔符不会出现在任一字符串中;流式模板没有这个限制。
课程内应用与延伸阅读#
课程内的前缀信息应用#
- 沿
pi[n-1]、pi[pi[n-1]-1]...跳转,可以枚举一个字符串的所有 border(相等真前后缀)。 - 最短重复单元可由
n - pi[n-1]得到候选长度,再检查它能否整除字符串长度;正文模板已经实现这一应用。
延伸阅读(非课程主线)#
- 对前缀函数做计数累加,可以求每个前缀在字符串中出现多少次。
- KMP 自动机(非课程主线):把字符转移预处理后,适合 DP 中反复读取字符并维护匹配状态。
- 若只判断是否出现,第一次得到完整匹配即可返回;若处理字符流,可保留
matched状态跨批次继续扫描。 - 通配符或大小写忽略匹配会改变“相等关系”的性质,修改比较函数后需要重新确认前缀回退仍然成立。