【数据结构与算法】第17篇:串(String)的高级模式匹配:KMP算法

一、BF算法的问题

回顾BF算法,当匹配失败时:

text

复制代码
主串: a b c a b d
模式: a b d
       ↑ ↑ ✗ (c != d)

此时i回溯到2,j重置为1,重新开始比较。问题是,我们已经知道S2=b,而T1=a,肯定不匹配。BF算法没有利用这个信息,做了很多无用的比较。

KMP的核心思想:当匹配失败时,主串指针i不回溯,只移动模式串指针j,利用已匹配部分的前缀和后缀信息,跳过不必要的比较。


二、前缀、后缀和部分匹配值

2.1 定义

  • 前缀:除最后一个字符外,字符串的所有头部子串

  • 后缀:除第一个字符外,字符串的所有尾部子串

  • 部分匹配值:前缀和后缀的最长相等长度

例如 "ababa"

长度 前缀 后缀 是否相等
1 a a
2 ab ba
3 aba aba
4 abab baba

最长相等长度为3,所以部分匹配值为3。

2.2 计算模式串的部分匹配值

T = "abab" 为例:

子串 前缀 后缀 最长相等长度
"a" 0
"ab" {a} {b} 0
"aba" {a,ab} {a,ba} 1 (a)
"abab" {a,ab,aba} {b,ab,bab} 2 (ab)

得到部分匹配值数组:[0, 0, 1, 2]


三、next数组的推导

3.1 next数组的含义

next[j] 表示:当模式串的第j个字符与主串不匹配时,模式串指针j应该跳到的位置(即下一次从哪个位置开始比较)。

重要约定 :为了方便,我们通常让数组下标从1开始,next[1] = 0 表示特殊情况。

3.2 递推公式

text

复制代码
next[1] = 0
next[2] = 1

当 j > 2 时:
令 k = next[j-1]
如果 T[j-1] == T[k],则 next[j] = k + 1
否则,递归地让 k = next[k],继续比较,直到 k=0

这个递推关系比较抽象,我们通过具体例子来理解。

3.3 手算next数组

例1:T = "abab"

text

复制代码
j=1: next[1] = 0

j=2: next[2] = 1

j=3: 看前一个字符 T[2]=b
     取 k = next[2]=1
     比较 T[2] 和 T[1]: 'b' != 'a'
     让 k = next[1]=0,k=0 停止
     next[3] = k+1 = 1

j=4: 看前一个字符 T[3]=a
     取 k = next[3]=1
     比较 T[3] 和 T[1]: 'a' == 'a',匹配
     next[4] = k+1 = 2

结果:next = [0, 1, 1, 2]

例2:T = "abcabc"

text

复制代码
j=1: next[1]=0
j=2: next[2]=1
j=3: T[2]=b, k=1, T[1]=a, b!=a, k=0 → next[3]=1
j=4: T[3]=c, k=1, T[1]=a, c!=a, k=0 → next[4]=1
j=5: T[4]=a, k=1, T[1]=a, a==a → next[5]=2
j=6: T[5]=b, k=2, T[2]=b, b==b → next[6]=3

结果:next = [0, 1, 1, 1, 2, 3]

3.4 代码实现next数组

c

复制代码
void getNext(const char *T, int *next, int len) {
    int i = 1, j = 0;  // i是模式串当前下标,j是已匹配的前缀长度
    next[1] = 0;
    
    while (i < len) {
        if (j == 0 || T[i-1] == T[j-1]) {  // 注意字符串下标从0开始
            i++;
            j++;
            next[i] = j;
        } else {
            j = next[j];
        }
    }
}

四、KMP匹配算法

4.1 算法流程

有了next数组,KMP匹配就很简洁了:

text

复制代码
i = 1, j = 1
while (i <= S.len && j <= T.len) {
    if (j == 0 || S[i] == T[j]) {
        i++;
        j++;
    } else {
        j = next[j];  // i不回溯,j跳转
    }
}
if (j > T.len) 返回 i - T.len
else 返回 0

4.2 动画演示

S = "ababcabc", T = "abcabc" 为例,next = [0,1,1,1,2,3]

text

复制代码
第1轮:i=1,j=1
S: a b a b c a b c
T: a b c a b c
   ↑ ↑ ✗ (a==a, b==b, a!=c)
此时 j=3,根据next[3]=1,j跳到1
i保持=3

第2轮:i=3,j=1
S: a b a b c a b c
T:     a b c a b c
       ↑ ✗ (a!=a? 等等,S[3]=a, T[1]=a,相等!)
等等,仔细看:S[3]=a, T[1]=a,匹配!

实际上,KMP的关键是当不匹配时,j跳转到nextj,继续比较。

4.3 完整代码

c

复制代码
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#define MAXLEN 100

// 获取next数组(下标从1开始)
void getNext(const char *T, int *next, int len) {
    int i = 1, j = 0;
    next[1] = 0;
    
    while (i < len) {
        if (j == 0 || T[i-1] == T[j-1]) {
            i++;
            j++;
            next[i] = j;
        } else {
            j = next[j];
        }
    }
}

// KMP算法
int kmp(const char *S, const char *T) {
    int lenS = strlen(S);
    int lenT = strlen(T);
    
    if (lenT == 0) return 1;
    if (lenS < lenT) return 0;
    
    int *next = (int*)malloc((lenT + 1) * sizeof(int));
    getNext(T, next, lenT);
    
    int i = 1, j = 1;  // 从1开始
    while (i <= lenS && j <= lenT) {
        if (j == 0 || S[i-1] == T[j-1]) {
            i++;
            j++;
        } else {
            j = next[j];
        }
    }
    
    free(next);
    
    if (j > lenT) {
        return i - lenT;  // 返回位置(从1开始)
    }
    return 0;
}

// 打印next数组
void printNext(const char *T) {
    int len = strlen(T);
    int *next = (int*)malloc((len + 1) * sizeof(int));
    getNext(T, next, len);
    
    printf("T = \"%s\"\n", T);
    printf("j:  ");
    for (int i = 1; i <= len; i++) {
        printf("%2d ", i);
    }
    printf("\nnext: ");
    for (int i = 1; i <= len; i++) {
        printf("%2d ", next[i]);
    }
    printf("\n\n");
    
    free(next);
}

int main() {
    // 测试next数组
    printNext("abab");
    printNext("abcabc");
    printNext("aaaaa");
    
    // 测试匹配
    printf("--- 匹配测试 ---\n");
    printf("'abcabd' 找 'abd': %d\n", kmp("abcabd", "abd"));
    printf("'hello world' 找 'world': %d\n", kmp("hello world", "world"));
    printf("'aaaaa' 找 'aaa': %d\n", kmp("aaaaa", "aaa"));
    printf("'abc' 找 'def': %d\n", kmp("abc", "def"));
    
    return 0;
}

运行结果:

text

复制代码
T = "abab"
j:   1  2  3  4 
next: 0  1  1  2 

T = "abcabc"
j:   1  2  3  4  5  6 
next: 0  1  1  1  2  3 

T = "aaaaa"
j:   1  2  3  4  5 
next: 0  1  2  3  4 

--- 匹配测试 ---
'abcabd' 找 'abd': 4
'hello world' 找 'world': 7
'aaaaa' 找 'aaa': 1
'abc' 找 'def': 0

五、nextval数组(优化)

5.1 next数组的问题

T[j] == T[next[j]] 时,匹配失败后跳转到nextj,但该位置的字符和当前字符相同,仍然会失败,造成额外比较。

例如 T = "aaaab"

text

复制代码
j=4, T[4]=a, next[4]=3, T[3]=a,相同
匹配失败后:j=4 → j=3 → j=2 → j=1,连续跳转多次

5.2 nextval优化

递归地优化:如果 T[j] == T[next[j]],则 nextval[j] = nextval[next[j]]

计算nextval

text

复制代码
nextval[1] = 0
for j = 2 to len:
    if T[j] == T[next[j]]:
        nextval[j] = nextval[next[j]]
    else:
        nextval[j] = next[j]

5.3 代码实现

c

复制代码
void getNextval(const char *T, int *nextval, int len) {
    int i = 1, j = 0;
    nextval[1] = 0;
    
    while (i < len) {
        if (j == 0 || T[i-1] == T[j-1]) {
            i++;
            j++;
            if (T[i-1] != T[j-1]) {
                nextval[i] = j;
            } else {
                nextval[i] = nextval[j];
            }
        } else {
            j = nextval[j];
        }
    }
}

六、复杂度分析

算法 时间复杂度 空间复杂度
BF O(n×m) O(1)
KMP O(n+m) O(m)(next数组)

KMP的优越性在大规模文本匹配中非常明显。例如:

  • 主串长度 n=1,000,000

  • 模式串长度 m=1,000

  • BF最坏需要 10^9 次比较

  • KMP只需要约 1,001,000 次比较


七、KMP与BF的对比

对比项 BF算法 KMP算法
主串指针 会回溯 不回溯
模式串指针 重置为1 跳转到nextj
预处理 计算next数组
时间复杂度 O(n×m) O(n+m)
空间复杂度 O(1) O(m)
适用场景 小规模、简单匹配 大规模、多次匹配

八、小结

这一篇我们学习了KMP算法:

要点 说明
核心思想 主串指针不回溯,利用部分匹配信息移动模式串
next数组 表示匹配失败时j应该跳到的位置
next推导 递推公式,基于前缀和后缀的相等关系
nextval优化 避免连续跳转到相同字符
时间复杂度 O(n+m),比BF的O(n×m)高效很多

KMP的精髓:当匹配失败时,我们已经知道前面哪些字符是匹配的,利用这个信息跳过不必要的比较。

下一篇我们讲数组的压缩存储。


九、思考题

  1. 模式串 "abcaabca" 的next数组是多少?手动推导一下。

  2. KMP算法中,主串指针为什么不回溯?这样会不会漏掉可能的匹配?

  3. 如果模式串是 "abc",它的next数组是多少?匹配 "abcabc" 时,KMP和BF的比较次数分别是多少?

  4. 什么时候用KMP比BF好?什么时候BF其实就够了?

相关推荐
复杂网络23 分钟前
论最小 Agent 计算机的形态
算法
kisshyshy16 小时前
🍦 雪糕、食堂、火车厢:三幅漫画吃透栈、队列与链表
javascript·算法
猿人谷1 天前
不只是 CPU 阈值:STAR 如何用 GAT + Transformer 做容器级自动扩缩容?
人工智能·算法
复杂网络1 天前
Stable Diffusion 视觉大模型微调技术深度调研
算法
Flynt1 天前
装上TypeScript 7.0 RC之后,最让我意外不是10倍提速
typescript·visual studio code
复杂网络1 天前
基于 Stable Diffusion 架构的视觉大模型代表性工作与原理深度解析
算法
MrZhao4001 天前
Agent Loop 如何用 Hook 扩展:权限、日志与工具拦截
算法
MrZhao4001 天前
Agent 为什么需要 Skills:别把所有知识都塞进 system prompt
算法
JieE2122 天前
LeetCode 101. 对称二叉树|JS 递归 + 迭代双解法,彻底搞懂镜像判断
javascript·算法
JieE2123 天前
LeetCode 56. 合并区间|超清晰 JS 图解思路,面试高频区间题
javascript·算法·面试