CC++ & Algorithm

KMP算法与next数组:字符串匹配的加速器

极难2
语言版本:通用
概述:利用模式串自身的信息(next数组)避免回溯,让匹配过程线性提速。

KMP算法:让字符串匹配不再“从头再来”

你有没有在语文课本里玩过“找相同字”的游戏?比如在一大段文字里找出所有“学习”两个字。如果从头一个个对比,遇到不匹配就退回去重新开始,效率非常低。KMP算法就是帮你“聪明”地跳过已经比对过的部分,像记笔记一样记住哪些地方不需要再比较。

KMP是三位计算机科学家Knuth、Morris、Pratt名字的缩写。它的核心是一个叫“next数组”(也叫部分匹配表)的辅助工具。有了它,当匹配失败时,模式串不会只向右移动1位,而是能一下子跳过多位,大大加快速度。


1. 核心思想:利用模式串自己的“对称性”

什么是前后缀?

假设你的书包里有几本同样的作业本,封面都是“张三”。你想在书架上找一本叫《张三日记》的书,但书只有“张三”两个字。你每次拿着“张三”这两个字去比对书架上的书名。

  • 前缀:字符串从开头截取的一段(不包括最后一个字符本身)。
    比如“ABAB”的前缀有:""(空串)、"A"、"AB"、"ABA"(不包括最后一个'B')。
  • 后缀:字符串从结尾往前截取的一段(不包括第一个字符本身)。
    “ABAB”的后缀有:""、"B"、"AB"、"BAB"。
  • 最长公共前后缀:既是前缀又是后缀的最长的那一段。
    “ABAB”中,公共的有""和"AB",最长是"AB",长度2。

next数组具体存什么?

对于模式串(你要找的字符串)的每个位置 i(从0开始),next[i] 表示:
模式串中从开头到位置 i 的这个子串,它的最长公共前后缀的长度(注意整个子串本身不算,所以长度一定小于 i+1)。

例如模式串 "ABAB":

  • next[0](子串"A"):没有非空前缀后缀,长度0。
  • next[1](子串"AB"):前缀"A"、后缀"B",无公共,长度0。
  • next[2](子串"ABA"):前缀有"A"、"AB";后缀有"A"、"BA",公共" A"长度1。
  • next[3](子串"ABAB"):前缀" A、AB、ABA";后缀" B、AB、BAB",公共"AB"长度2。

所以 next = [0, 0, 1, 2]。

为什么它能加速匹配?

想象你正在玩“拼图”:文本串是长跑道,模式串是一段轨道。暴力匹配就像每次轨道对不准就退后一格重新对齐;而KMP利用轨道自身的对称性(前后缀相同),当失配时,直接让轨道向右滑动,使已匹配部分的后缀与模式串的前缀对齐。

我们画个示意图:

文本 T:A B A B A B A B
模式 P:A B A B C

第一次比对到位置4(下标从0开始):

T: A B A B A B A B
P: A B A B C
   ↑       ↑  ↑
   i=0    i=4 失配

此时P[4]=C与T[4]=A不同。但前面已匹配了"ABAB",它的最长公共前后缀是"AB"(长度2)。所以我们可以将模式串向右移动,使得P[2](即next[4]=2)与T[4]对齐,然后继续比:

T: A B A B A B A B
P:     A B A B C
       ↑ (P[2] = A 与 T[4] = A 匹配)

省去了从P[0]重新开始比较的2步。


2. 如何构建next数组(递推思想)

构建next数组就像在模式串内部玩一次“自我匹配”。我们用两个指针:i 遍历模式串的每个位置(从1开始),j 记录当前已经匹配的前缀长度。

步骤(经典实现,next[i]表示最长公共前后缀长度,失配时用next[i-1]回退):

  1. 初始化 next[0]=0,i=1,j=0。
  2. 如果 P[i] == P[j],说明可以延长公共前后缀,则 next[i]=j+1,i++,j++。
  3. 否则,如果 j>0,就让 j 回退到 next[j-1](因为前面可能有更短的公共前后缀),然后重新比较 P[i] 与 P[j]。
  4. 如果 j==0,说明没有公共前后缀,next[i]=0,i++。

这个过程有点像你在做“找相同”游戏:从开头开始,一边走一边记下哪些部分已经匹配过。

举例:模式串 "ABCDABD"

i子串最长公共前后缀next[i]
0A0
1AB0
2ABC0
3ABCD0
4ABCDA"A"长度11
5ABCDAB"AB"长度22
6ABCDABD无(因为前缀"ABCDAB"与后缀"BCDABD"不同)0

注意:当 i=6 时,P[6]='D' ≠ P[j]=P[2]='C',j 从2回退到 next[1]=0,发现仍不同,所以 next[6]=0。


3. 匹配过程:文本指针永不回溯

有了next数组,匹配过程与构建next非常相似:

  • 用 i 指向文本,j 指向模式串。
  • 若 text[i] == pattern[j],则 i++,j++。
  • 若不等,则 j = next[j-1](如果 j>0);否则 i++。
  • 当 j 等于模式串长度时,说明找到一个匹配位置,记录 i-m+1,然后 j = next[j-1] 继续找下一个。

关键点:文本指针 i 只会向右移动,从不回头。这就是KMP线性时间的原因。


4. 新手容易犯的错误

  1. next数组下标混淆:有的教材把next[i]定义为“失配时应跳到的位置”,而这里我们定义为“最长公共前后缀长度”,使用时需要写成next[j-1]。一定要清楚自己用的是哪种定义。
  2. 边界条件:模式串长度为0或1时,需要特殊处理。例如,长度为1时next[0]=0。
  3. while循环的顺序:在构建next时,一定要先处理不相等的情况(while循环),再处理相等的情况,否则可能漏掉匹配。
  4. 忘记检查空模式串:KMP搜索函数开头应该判断pattern是否为空,否则会造成数组越界。
  5. 找所有匹配时:匹配成功后,不能直接让j=0,而要利用next[j-1]继续,否则会漏掉重叠的匹配(比如在"AAAA"中找"AA")。

5. 完整可运行的代码示例

C++版(含详细注释)

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

using namespace std;

// 构建next数组,next[i]表示子串P[0..i]的最长公共前后缀长度
vector<int> buildNext(const string &pattern) {
    int m = pattern.size();
    vector<int> next(m, 0); // 初始化全部为0
    int j = 0; // 当前匹配的前缀长度
    for (int i = 1; i < m; i++) {
        // 当j>0且当前字符不匹配,回退j
        while (j > 0 && pattern[i] != pattern[j]) {
            j = next[j-1];
        }
        if (pattern[i] == pattern[j]) {
            j++;
        }
        next[i] = j;
    }
    return next;
}

// KMP匹配,返回所有匹配的起始下标
vector<int> kmpSearch(const string &text, const string &pattern) {
    vector<int> positions; // 存储匹配位置
    if (pattern.empty()) return positions; // 空模式串直接返回
    vector<int> next = buildNext(pattern);
    int n = text.size(), m = pattern.size();
    int j = 0; // 模式串当前比较位置
    for (int i = 0; i < n; i++) {
        // 字符不匹配,利用next调整j
        while (j > 0 && text[i] != pattern[j]) {
            j = next[j-1];
        }
        if (text[i] == pattern[j]) {
            j++;
        }
        if (j == m) {
            positions.push_back(i - m + 1); // 记录匹配起始位置
            j = next[j-1]; // 继续寻找下一个匹配
        }
    }
    return positions;
}

int main() {
    string text = "ABABABAB";
    string pattern = "ABAB";
    vector<int> pos = kmpSearch(text, pattern);
    cout << "模式串出现位置: ";
    for (int p : pos) cout << p << " ";
    cout << endl;
    // 显示next数组
    vector<int> nxt = buildNext(pattern);
    cout << "next数组: ";
    for (int x : nxt) cout << x << " ";
    cout << endl;
    return 0;
}

Python版(同样易懂)

def build_next(pattern):
    """构建next数组,返回列表"""
    m = len(pattern)
    next_arr = [0] * m  # 全部初始化为0
    j = 0  # 当前匹配的前缀长度
    for i in range(1, m):
        # 当j>0且字符不相等,回退j
        while j > 0 and pattern[i] != pattern[j]:
            j = next_arr[j-1]
        if pattern[i] == pattern[j]:
            j += 1
        next_arr[i] = j
    return next_arr

def kmp_search(text, pattern):
    """返回模式串在文本中所有出现起始位置"""
    if not pattern:
        return []
    next_arr = build_next(pattern)
    n, m = len(text), len(pattern)
    j = 0  # 模式串当前比较位置
    positions = []  # 存储匹配位置
    for i in range(n):
        # 字符不匹配,回退j
        while j > 0 and text[i] != pattern[j]:
            j = next_arr[j-1]
        if text[i] == pattern[j]:
            j += 1
        if j == m:
            positions.append(i - m + 1)
            j = next_arr[j-1]  # 继续找下一个
    return positions

# 测试
text = "ABABABAB"
pattern = "ABAB"
pos = kmp_search(text, pattern)
print("模式串出现位置:", pos)
print("next数组:", build_next(pattern))

6. 总结与拓展

  • KMP把匹配时间从O(n×m)降到O(n+m),n是文本长度,m是模式串长度。文本指针永不回溯,模式串跳跃移动。
  • 生活例子:就像你查字典找“苹果”这个词,你不需要每次翻到第一页;KMP会记住前面已经看过的字母位置,直接跳到可能匹配的地方。
  • 常见场景:文本编辑器中的查找功能、DNA序列比对、垃圾邮件过滤中的关键词检测等。
  • 进阶知识点
    • 字符串最小周期:利用next数组的最后一个值可以求字符串的最小周期。例如模式串"ABABAB",next[5]=4,最小周期长度 = m - next[m-1] = 2。
    • Z算法:另一种线性时间字符串匹配算法,思想类似但更直观。
    • 扩展KMP(Z函数):可以求出文本串每个位置与模式串的最长公共前缀长度。

想要更深入理解,可以亲手画几遍匹配过程,或尝试用KMP解决一道oj题目(如“剪花布条”)。记住,先掌握朴素匹配,再理解KMP,你会发现它就像一把“剪刀”,能精准地剪掉不必要的比较。

例题精讲

1单选题

对于模式串 "ababaa",若采用从0开始编号且next[0] = -1的定义,其next数组为?

A[-1, 0, 0, 1, 2, 3]
B[-1, 0, 1, 2, 3, 4]
C[-1, 0, 0, 1, 1, 2]
D[0, 0, 0, 1, 2, 3]
2判断题

KMP算法在匹配过程中,主串的指针i始终不会回溯,只会向前移动。

3填空题
以下是一个KMP匹配函数的实现,请补全失配时的处理语句。

int KMP(string text, string pattern, int next[]) {
    int i = 0, j = 0;
    while (i < text.length() && j < pattern.length()) {
        if (j == -1 || text[i] == pattern[j]) {
            i++;
            j++;
        } else {
            ___;
        }
    }
    if (j == pattern.length()) return i - j;
    else return -1;
}
4单选题

关于KMP算法的时间复杂度和空间复杂度,下列描述正确的是?

A时间复杂度O(n*m),空间复杂度O(m)
B时间复杂度O(n+m),空间复杂度O(m)
C时间复杂度O(n+m),空间复杂度O(n)
D时间复杂度O(n),空间复杂度O(m)
5判断题

对于模式串 "aaaaa",其next数组(从0开始,next[0] = -1)为 [-1, 0, 1, 2, 3]。