KMP算法讲解

一、从暴力匹配到KMP:一个思考的演进

想象你有一个文本串(比如一篇文章)和一个模式串(一个你想查找的单词)。最直观的查找方式(暴力匹配,BF算法)是这样的:

  1. 将模式串与文本串的开头对齐,然后从左到右逐个比较字符。
  2. 如果某个字符不匹配,就将模式串整体向右移动一位,再从模式串的开头开始比较。
  3. 重复这个过程,直到找到匹配或文本串结束。

这个方法的缺点是,它丢弃了之前所有的比较信息。每当匹配失败,模式串的指针(j)就不得不退回到起点,文本串的指针(i)也要回溯。这在很多场景下做了大量的重复工作。

KMP算法的核心思想正是“利用已匹配的信息,让模式串的指针不需要完全回溯”。它通过一个预处理过的数组(通常称为next数组或部分匹配表),在匹配失败时,知道应该将模式串的指针j回退到哪个位置,而文本串的指针i永远不回溯,继续向前走。

二、KMP算法的核心:部分匹配表(Next数组)

这个“已匹配的信息”就是模式串自身的对称性,即真前缀与真后缀的相等长度next数组就是用来存储这些信息的。

约定:为了后面代码编写的方便,通常将next[j]定义为:当模式串第j位(从0开始)字符与文本串失配时,模式串指针j应该跳转到的位置。一个经典且巧妙的初始化是将next[0]设为 -1

1. Next数组的递推计算

next数组的求解过程,本质上也是一个“自我匹配”的过程。我们用两个“指针”j(当前要求解next值的位置)和kj之前已经匹配的前缀长度,也就是next[j]的候选值)。

  • 初始状态j = 0k = -1next[0] = -1-1是一个特殊标志,表示整个模式串都无前缀可匹配,需要将模式串整体右移一位。
  • 递推规则
    • 如果 k == -1 或者 p[j] == p[k],说明找到了更长的相同前后缀,则 j++k++,并且令 next[j] = k
    • 如果 p[j] != p[k],说明当前长度k的前缀不能作为j位置的后缀,我们需要寻找更短的可能相同前后缀。这正是 k = next[k] 这句代码的精髓,它利用了已计算好的next信息进行递归,保证了查找的效率。

下面是求解next数组的C++代码模板,包含了详细注释:

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

using namespace std;

/**
 * 构建模式串 p 的部分匹配表(next数组)
 * @param p 模式串
 * @return next数组,next[i] 表示当模式串第 i 位失配时,j 应该跳转到的位置
 */
vector<int> buildNext(const string &p) {
    int m = p.size();
    // next[0] = -1 是一个巧妙的设置,使得当第一个字符就失配时,j 能变为 -1
    // 这样在匹配函数中,j == -1 就代表要从模式串的第一个字符重新开始比较
    vector<int> next(m, -1); 
    int j = 0;      // j 是当前正在计算 next 值的索引
    int k = -1;     // k 是当前已知的、j 之前的最长相同前后缀的长度,也是 next[j] 的候选值

    while (j < m - 1) {
        if (k == -1 || p[j] == p[k]) {
            // 情况1: k 为 -1,说明已无更短前后缀,next[j+1] 只能为 0 (k+1)
            // 情况2: p[j] == p[k],说明找到了一个长度为 k+1 的相同前后缀
            j++;
            k++;
            // 优化:如果 p[j] 和 p[k] 相等,那么当 p[j] 失配时,跳转到 p[k] 也会失配
            // 所以可以直接再跳一次,即 next[j] = next[k];
            // 这里为了清晰展示基础版本,先不写优化,后续题目中再使用
            next[j] = k;
        } else {
            // 不匹配,则尝试寻找更短的相同前后缀
            // 这正是 KMP 算法的核心递归:k = next[k]
            // 因为 p[0...k-1] 是 p[0...j-1] 的后缀,我们要在这个后缀中寻找更短的前缀
            k = next[k];
        }
    }
    return next;
}

图解 k = next[k]:假设在计算next数组时,对于模式串p,我们已经有 p[0..k-1] == p[j-k..j-1],但 p[j] != p[k]。我们需要为j寻找一个更短的相同前后缀。因为p[0..k-1]本身就是p[0..j-1]的一个后缀,它的相同前后缀信息就存储在next[k]中。所以,我们将k更新为next[k],继续尝试匹配p[j]与新的p[k]。这个过程一直持续,直到找到匹配或k回退到-1

2. KMP匹配过程

有了next数组,匹配过程就非常清晰了。

/**
 * KMP 字符串匹配算法
 * @param s 文本串
 * @param p 模式串
 * @return 模式串在文本串中所有出现位置的起始下标 (从0开始)
 */
vector<int> kmpSearch(const string &s, const string &p) {
    vector<int> next = buildNext(p);
    vector<int> positions;
    int n = s.size();
    int m = p.size();
    int i = 0; // 文本串 s 的指针
    int j = 0; // 模式串 p 的指针

    while (i < n) {
        if (j == -1 || s[i] == p[j]) {
            // j == -1 表示模式串的第一个字符都不匹配,需要 i 和 j 都前进
            // 或者字符匹配成功,指针同时前进
            i++;
            j++;
        } else {
            // 字符失配,j 根据 next 数组跳转,i 不变
            j = next[j];
        }

        // 当 j 移动到模式串末尾,说明找到了一个完整匹配
        if (j == m) {
            // 匹配的起始位置为 i - m
            positions.push_back(i - m);
            // 找到匹配后,我们继续寻找下一个匹配。j 需要按照 next 数组回退一位,
            // 因为模式串的最后一个字符已经匹配成功,我们需要利用它的 next 信息继续匹配。
            // 回退到 next[j] 的位置,然后循环继续。
            j = next[j]; 
        }
    }
    return positions;
}

三、洛谷题目实战

下面我们通过三道洛谷上的经典题目,来巩固KMP算法的应用。

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

这是KMP算法最直接的模板题,要求输出所有出现位置以及模式串每个前缀的最长border(即next数组的值)。

题目分析

直接套用上述KMP算法即可。需要注意的是,题目中的next数组(题目中称为前缀数组)定义略有不同,通常是从1开始索引,且next[i]表示前i个字符组成的子串的最长相同前后缀长度。这与我们代码中从0开始、next[0]=-1的实现需要做一个映射。但核心思想完全一致。我们只需将求出的next数组按题目要求的格式输出即可。

AC代码

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

using namespace std;

int main() {
    string s1, s2;
    cin >> s1 >> s2;

    int n = s1.length();
    int m = s2.length();

    // ----- 构建模式串 s2 的 next 数组 -----
    vector<int> next(m, -1);
    int j = 0, k = -1;
    while (j < m - 1) {
        if (k == -1 || s2[j] == s2[k]) {
            j++;
            k++;
            // 这里使用了一个常见的优化:如果 s2[j] == s2[k],则 next[j] 应该等于 next[k]
            // 避免在匹配时进行无意义的跳转(因为跳转后还是相等的字符,依然会失配)
            if (s2[j] == s2[k]) {
                next[j] = next[k];
            } else {
                next[j] = k;
            }
        } else {
            k = next[k];
        }
    }

    // ----- KMP 匹配过程 -----
    j = 0;
    for (int i = 0; i < n; i++) {
        while (j > 0 && s1[i] != s2[j]) {
            j = next[j]; // 失配时,j 根据 next 数组跳转
        }
        if (s1[i] == s2[j]) {
            j++;
        }
        if (j == m) {
            // 题目要求输出位置从 1 开始计数
            cout << i - m + 2 << endl;
            j = next[j]; // 找到一个匹配后,继续寻找下一个
        }
    }

    // ----- 输出 next 数组,题目要求格式(从 1 开始,无 -1)-----
    // 注意:我们求的 next[0] 是 -1,但题目第一个输出对应的是长度为 1 的前缀的 border 长度,应该是 0
    // 所以从索引 1 开始输出,并且值为我们 next 数组的值 +1? 需要仔细验证。
    // 更稳妥的方法是,根据题目定义的 next 数组重新生成一份。
    // 但观察我们的 next 数组:对于模式串 "ABA",
    // j (代码索引):  0  1  2
    // next[j]:      -1 0  0  (经过优化后)
    // 长度为1的前缀 "A" 的border长应为0 -> 对应 next[1]? 0
    // 长度为2的前缀 "AB" border长0 -> next[2]? 0
    // 长度为3的前缀 "ABA" border长1 -> 但我们的next[2]是0,不对应。
    // 这说明我们的 next 数组定义与题目不同。题目要求的 next[i] (i从1开始) 表示前 i 个字符最长 border 长度。
    // 它可以通过我们构建的未优化的 next 数组得到,或者我们重新构建一个。
    // 为了不引入过多复杂性,这里采用更直观的方式:再求一次题目要求的 next 数组。
    vector<int> pmt(m, 0);
    j = 0;
    for (int i = 1; i < m; i++) {
        while (j > 0 && s2[i] != s2[j]) {
            j = pmt[j - 1];
        }
        if (s2[i] == s2[j]) {
            j++;
        }
        pmt[i] = j;
    }

    for (int i = 0; i < m; i++) {
        cout << pmt[i] << " ";
    }
    cout << endl;

    return 0;
}

2. P4391 [BOI2009]Radio Transmission 无线传输

这道题要求我们找到一个字符串的最小周期。它巧妙地利用了KMP算法中next数组的性质。

题目分析

假设原始字符串是s,它的一个周期是t,意思是s可以由若干个t重复拼接而成(最后一段可能不完整)。例如,s = "abcabcab",周期可以是"abc"

KMP算法中的next数组(指未优化的基础版本)给出了字符串的最长相同前后缀长度。对于长度为L的字符串,其最小周期长度等于 L - next[L](这里next数组索引从1开始,next[L]表示整个字符串的最长相同前后缀长度)。

证明:如果字符串存在长度为p的周期,那么字符串的前L-p个字符必然等于后L-p个字符。即 s[0..L-p-1] == s[p..L-1],这正是长度为L-p的相同前后缀。我们要找最小周期,就要找最大的L-p,也就是最长的相同前后缀。因此 min_period = L - max_border

AC代码

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

using namespace std;

int main() {
    int n;
    string s;
    cin >> n >> s;

    // 构建 next 数组(基础版,未优化)
    vector<int> next(n + 1, 0); // 为了方便理解,我们让索引从1开始,next[1]=0
    int j = 0;
    for (int i = 2; i <= n; i++) {
        while (j > 0 && s[i - 1] != s[j]) {
            j = next[j];
        }
        if (s[i - 1] == s[j]) {
            j++;
        }
        next[i] = j;
    }

    // 最小周期长度 = n - 最长相同前后缀长度
    int min_period = n - next[n];
    cout << min_period << endl;

    return 0;
}

3. P4824 [USACO15FEB]Censoring S

这道题要求我们从字符串中反复删除一个指定的模式串,直到不再包含该模式串。直接使用KMP配合栈可以高效解决。

题目分析

  1. 对模式串t构建next数组。
  2. 遍历文本串s的每个字符,将其压入栈中。
  3. 在遍历每个字符时,就像KMP匹配一样,维护一个j(当前已匹配的模式串长度)。
  4. 如果当前字符c与模式串的t[j]匹配,则j++;否则j = next[j](回溯)。
  5. 如果j达到了模式串的长度m,说明在栈顶的m个字符恰好构成了一个模式串。这时,我们就将这m个字符从栈中弹出,同时,j需要更新为弹出前栈顶元素所对应的已匹配长度。因此,我们还需要一个辅助数组pos[i]来记录栈中第i个元素在匹配过程中对应的j值。

AC代码

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

using namespace std;

int main() {
    string s, t;
    cin >> s >> t;

    int n = s.length();
    int m = t.length();

    // 1. 构建模式串 t 的 next 数组
    vector<int> next(m, -1);
    int j = 0, k = -1;
    while (j < m - 1) {
        if (k == -1 || t[j] == t[k]) {
            j++; k++;
            next[j] = k;
        } else {
            k = next[k];
        }
    }

    // 2. 遍历 s,使用栈和 KMP 进行匹配和删除
    stack<char> stk;          // 存储最终结果的栈
    vector<int> pos(n + 1, 0); // pos[i] 记录栈中第 i 个字符时,匹配到模式串的位置 j

    j = 0;
    for (int i = 0; i < n; i++) {
        char c = s[i];
        // KMP 匹配逻辑
        while (j > 0 && c != t[j]) {
            j = next[j];
        }
        if (c == t[j]) {
            j++;
        }
        // 将当前字符和其匹配长度 j 入栈
        stk.push(c);
        pos[stk.size()] = j;

        // 如果匹配长度 j 等于模式串长度,说明找到了一个完整的模式串
        if (j == m) {
            // 从栈中弹出 m 个字符
            for (int cnt = 0; cnt < m; cnt++) {
                stk.pop();
            }
            // 更新 j 为栈顶元素对应的匹配长度。如果栈为空,则 j = 0
            j = stk.empty() ? 0 : pos[stk.size()];
        }
    }

    // 3. 输出栈中剩余的字符
    string result;
    while (!stk.empty()) {
        result.push_back(stk.top());
        stk.pop();
    }
    // 由于栈是后进先出,需要反转
    for (int i = result.size() - 1; i >= 0; i--) {
        cout << result[i];
    }
    cout << endl;

    return 0;
}

希望这份讲解能帮助你理解和掌握KMP算法。从模板题到思维题,逐步深入,是学习算法的有效路径。祝你刷题愉快!

posted @ 2026-02-27 20:36  Co_led  阅读(0)  评论(0)    收藏  举报