NeFut Logo NeFut
EN 管理员登录

高效字符串匹配:KMP算法详解与实现

发布于:2026-05-27 09:17 最后更新:2026-06-10 09:00
#algorithm #String #KMP

核心逻辑与数学原理

KMP 算法的核心任务是在 $O(N + M)$ 时间复杂度内实现单模式串在文本串中的匹配,打破暴力匹配 $O(NM)$ 的固有上限。其底层数学基石是前缀函数(Prefix Function),在竞赛源码中常定义为 $next$ 数组。

1. 前缀函数的数学定义

对于长度为 $M$ 的字符串 $P$,其前缀函数 $next[i]$ 表示子串 $P[0 \dots i]$ 的最长相等真前缀与真后缀的长度。集合表述如下:

$$next[i] = \max \big\{ k \big| 0 \le k < i+1 \text{ and } P[0 \dots k-1] == P[i-k+1 \dots i] \big\}$$

若该集合为空,则 $next[i] = 0$。

2. 失配状态转移的收敛性证明

当文本串字符 $S[i]$ 与模式串字符 $P[j]$ 发生失配时,传统算法将 $i$ 回溯。KMP 保持 $i$ 不动,将 $j$ 转移至 $next[j-1]$。

证明:已知 $P[0 \dots j-1] == S[i-j \dots i-1]$。若存在一个更短的匹配滑块长度 $k$,使得新的前缀能够对接当前后缀,则必须满足 $P[0 \dots k-1] == P[j-k \dots j-1]$。根据前缀函数定义,最大的 $k$ 严格等于 $next[j-1]$。由于 $next[j-1] < j$,转移状态单调递减,迭代必然在有限步内收敛至 $0$。


状态设计与算法推导

1. 前缀函数 $next$ 数组的递推

假设已知 $next[0 \dots i-1]$。现推导 $next[i]$ 的值。令 $j = next[i-1]$。

$$next[i] = j + 1$$

2. 模式匹配状态机

将模式串 $P$ 视为一个有限状态自动机,状态值为当前成功匹配的字符数 $j$。对于文本串的每个字符 $S[i]$:

  1. 若 $S[i] \neq P[j]$ 且 $j > 0$,状态沿失配指针连续回溯:j = next[j - 1]
  2. 若 $S[i] == P[j]$,状态向高位推进:j++
  3. 若 $j == M$,说明成功命中完整模式串,记录答案位置 $i - M + 1$,随后状态强制转移至 j = next[j - 1] 以继续匹配后续重叠子串。

C++ 标准源码(NOIP风格)

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>

using namespace std;

// 高效输入函数,处理常规字符串读入
void fast_io() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
}

const int MAXM = 1000005;
int nxt[MAXM]; // 避开 C++ 某些头文件中的全局关键字 next,硬核重命名为 nxt

// 严格基于 0 下标递推计算前缀函数
void get_next(const string& p, int m) {
    nxt[0] = 0; // 长度为 1 的子串无真前后缀,必然为 0
    int j = 0;  // j 始终代表当前待比较的前缀字符下标,同时也是前一位置的 nxt 值
    for (int i = 1; i < m; ++i) {
        while (j > 0 && p[i] != p[j]) {
            j = nxt[j - 1]; // 致命踩坑点:失配时必须回溯到 nxt[j-1] 而不是 nxt[j]
        }
        if (p[i] == p[j]) {
            j++;
        }
        nxt[i] = j;
    }
}

// KMP 主匹配过程
void kmp_search(const string& s, const string& p) {
    int n = s.length();
    int m = p.length();

    get_next(p, m);

    int j = 0; // 模式串的匹配指针
    for (int i = 0; i < n; ++i) {
        while (j > 0 && s[i] != p[j]) {
            j = nxt[j - 1]; // 文本串与模式串失配,自适应转移状态,i 指针绝不回溯
        }
        if (s[i] == p[j]) {
            j++;
        }
        if (j == m) {
            // 成功在文本串中匹配到模式串,输出起始标准下标(1-based 风格常见于 NOIP 评测)
            cout << i - m + 2 << "\n"; 
            j = nxt[j - 1]; // 致命踩坑点:匹配成功后为了寻找重叠的下一个目标,必须强制转移状态
        }
    }
}

int main() {
    fast_io();
    string s, p;
    if (cin >> s >> p) {
        kmp_search(s, p);
        int m = p.length();
        for (int i = 0; i < m; ++i) {
            cout << nxt[i] << (i == m - 1 ? "" : " ");
        }
        cout << "\n";
    }
    return 0;
}

NOIP 实战避坑指南

1. 全局变量命名冲突与编译流产

在线下 Linux 环境下使用 g++ -O2 编译时,若引入了某些标准库,全局声明 int next[MAXN]; 会与 <algorithm> 或内建命名空间中的标准迭代器算子 std::next 发生代数命名冲突。这会导致编译器抛出难以辨识的符号重载错误。必须在竞赛中将数组严格命名为 nxtnxt_arr

2. 失配索引越界与死循环

while (j > 0 && p[i] != p[j]) 循环内部,跳转语句若写为 j = nxt[j]; 将直接引发逻辑灾难。因为当 j 处的字符失配时,应当查询的是已经匹配成功的子串 $P[0 \dots j-1]$ 的最长公共前后缀。若写成 nxt[j],不仅丢失了数学正确性,更可能由于 nxt[j] == j 的恶性数据陷入无限死循环,或在 $j = M$ 时越界触发 RE

3. 下标 0-based 与 1-based 混用导致状态断层

KMP 算法的实现极度依赖下标边界。若在读取 string 时使用 0 下标,而 next 数组的转移逻辑借鉴了某些教科书上的 1 下标算法(如 nxt[j] = nxt[nxt[j]] 且初始置 nxt[0] = -1),会导致边界索引出现 $\pm 1$ 的物理偏离,状态机将彻底失去高位拦截作用,在评测机上表现为大面积 WA


经典 NOIP/洛谷 真题

1. 洛谷 P3375 【模板】KMP 字符串匹配

2. 洛谷 P2375 [NOI2014] 动物园

原文链接: local://8.1

[h] 返回首页