文章目录

KMP 算法

KMP 算法

KMP 算法(Knuth-Morris-Pratt 算法)是高效的字符串匹配算法。其核心思想是利用已经匹配过的有效信息,保持主串(文本串)指针不回退,通过预处理模式串的前缀函数,将字符串匹配的时间复杂度稳定优化至线性。

一、核心前置概念

1. 暴力匹配的瓶颈

在主串 \(S\) 中寻找模式串 \(P\) 时,如果使用暴力算法,一旦在某个位置失配(即 \(S[i] \neq P[j]\)),通常会将主串的指针 \(i\) 回退到 \(i-j+2\) 的位置,并将模式串指针 \(j\) 归零,重新开始匹配。这使得最坏时间复杂度达到 \(O(n \times m)\)

2. 真前缀与真后缀的定义

在 KMP 中,经常用到真前缀真后缀的概念(注意是真前缀 / 后缀,即不能等于字符串本身):

  • 真前缀:除去字符串本身,其开头的一部分构成的子串。

  • 真后缀:除去字符串本身,其结尾的一部分构成的子串。

二、前缀函数(nxt 数组 / \(\pi\) 数组)

前缀函数是 KMP 的灵魂,它负责对模式串进行 \\“自我匹配”\\

1. 定义

对于模式串 \(P\),令 \(nxt[i]\), 表示子串 \(P[1 \dots i]\) 的最长相等真前缀与真后缀的长度

举例说明(下标从 1 开始):

  • \(P[1 \dots 4] = \text{"abab"}\):最长相等真前后缀为 "ab",长度为 2,所以 \(nxt[4] = 2\)

  • \(P[1 \dots 4] = \text{"aaaa"}\):最长相等真前后缀为 "aaa",长度为 3,所以 \(nxt[4] = 3\)

  • \(P[1 \dots 4] = \text{"abcd"}\):没有相等的真前后缀,长度为 0,所以 \(nxt[4] = 0\)

2. 线性求法(推导)

计算 \(nxt\) 数组采用了 DP 的思想。假设我们已经计算出了 \(nxt[1 \dots i-1]\),现在要计算 \(nxt[i]\)

  1. 设当前已知的匹配长度 \(j = nxt[i-1]\)。这意味着 \(P[1 \dots j] = P[i-j \dots i-1]\)

  2. 尝试扩展:

    • 匹配成功:如果 \(P[j + 1] == P[i]\),则当前的前后缀可以继续延长,因此 \(nxt[i] = j + 1\)

    • 匹配失败(失配):如果 \(P[i] \neq P[j+1]\),说明当前长度 \(j\) 行不通。根据 “Border 链”(即所有公共前后缀的长度集合),我们需要寻找次长的公共前后缀。对于 \(P[1 \dots j]\),其次长的公共前后缀长度恰好就是 \(nxt[j]\)。因此,令 \(j = nxt[j]\),然后重复上述判断,直到 \(j = 0\) 或者匹配成功。

3. 标准代码实现

const int N = 1e5 + 5;
char p[N];              // 模式串,下标从 1 开始
int nxt[N];             // 前缀函数数组
int m = strlen(p + 1);  // 模式串长度

void get_next() {
    nxt[1] = 0;                 // 长度为 1 的子串没有真前后缀
    for (int i = 2, j = 0; i <= m; i++) {
        // 如果失配,不断回溯寻找次短的公共前后缀
        while (j > 0 && p[i] != p[j + 1]) {
            j = nxt[j];
        }
        // 如果当前字符匹配,匹配长度加 1
        if (p[i] == p[j + 1]) {
            j++;
        }
        nxt[i] = j;
    }
}

三、KMP 匹配主串

nxt 数组预处理完成后,就可以在主串 \(S\) 上进行高效的匹配

1. 匹配规则

我们在主串 \(S\) 上维护一个指针 \(i\),在模式串 \(P\) 上维护一个指针 \(j\)(表示当前 \(P\) 已经成功匹配了长度为 \(j\) 的前缀,即 \(P[1 \dots j] = S[i-j+1 \dots i]\))。

  1. 主串指针永不后退:对于 \(i = 1 \to n\)依次扫描主串。

  2. 当前字符比较:比较 \(S[i]\)\(P[j+1]\)

    • 成功:如果相等,j ++。若 j == m,说明在主串位置 i - m + 1 处发现了完整的模式串,记录答案;如果要寻找多个匹配,令 j = nxt[j] 继续向下寻找。

    • 失败:如果不相等,按照与预处理相同的方式,进行 j = nxt[j]回退。注意,此时 \(i\) 指针保持不变,继续判断新的 \(P[j+1]\) 是否与 \(S[i]\) 相等。

2. 标准实现代码

char s[N];              // 主串,下标从 1 开始
int n = strlen(s + 1);  // 主串长度

void kmp() {
    for (int i = 1, j = 0; i <= n; i++) {
        // 如果主串字符 s[i] 与模式串的下一个字符 p[j+1] 不匹配,通过 nxt 数组回退 j
        while (j > 0 && s[i] != p[j + 1]) {
            j = nxt[j];
        }
        // 字符匹配成功,模式串指针向前推进
        if (s[i] == p[j + 1]) {
            j++;
        }
        // 匹配成功找到一个完整模式串
        if (j == m) {
            // 输出匹配开始位置 (i - m + 1)
            // 如果允许重叠匹配,继续 j = nxt[j]
            j = nxt[j];
        }
    }
}

四、时间复杂度分析

标准 KMP 算法的时间复杂度为 \(O(n + m)\),证明如下:

  1. 预处理阶段:指针 \(j\)for 循环内最多增加 \(m\) 次。每次 while 循环失配时,\(j\) 都会根据 nxt[j] 严格减小,因此回退的总次数也不会超过匹配成功的总次数(即 \(m\) 次)。所以预处理是 \(O(m)\)

  2. 匹配阶段:主串指针 \(i\) 在循环中每次增加 1,总共增加 \(n\) 次。模式串指针 \(j\) 增加的总次数受限于 \(i\) 扫描的次数(最多 \(n\) 次),而 while 回退的总次数也一定小于等于 \(j\) 增加的次数(即 \(O(n)\))。

  3. 结论:不考虑常数情况下,整体算法复杂度严格为 \(O(n + m)\)

注:复杂度上界是理论严格线性

注:部分内容由 AI 润色

posted @ 2026-09-04 18:57  lvwangshu  阅读(8)  评论(0)    收藏  举报