KMP字符串匹配:从O(n×m)到O(n+m)的神奇算法

前言

KMP是字符串匹配的经典算法,很多人觉得它很难,特别是next数组的构建。其实KMP的本质就是:不要傻乎乎地从头开始匹配,利用已经匹配的信息

我并没有能力让你看完就精通所有字符串算法,我只是想让你理解KMP的核心思想、next数组的含义、以及为什么它能把暴力的O(n×m)优化到O(n+m)。

摘要

从"字符串匹配暴力超时"问题出发,剖析KMP算法的核心思想与next数组构建。通过暴力匹配的重复计算分析、最长公共前后缀的图解演示、以及KMP匹配过程的详细推导,揭秘为什么KMP能避免回退。配合LeetCode高频题目与完整代码,给出KMP算法的实现套路。


一、从暴力匹配说起

周四早上,哈吉米遇到一道题:

LeetCode 28 - 找出字符串中第一个匹配项的下标

给你两个字符串 haystack 和 needle ,
请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。
如果 needle 不是 haystack 的一部分,则返回 -1 。

示例:
输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 0 和 6 处匹配。第一个匹配项的下标是 0 。

输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 。

哈吉米的暴力代码:

Java版本

public int strStr(String haystack, String needle) {
    int n = haystack.length();
    int m = needle.length();
    
    // 枚举起点
    for (int i = 0; i <= n - m; i++) {
        boolean match = true;
        
        // 从起点i开始匹配
        for (int j = 0; j < m; j++) {
            if (haystack.charAt(i + j) != needle.charAt(j)) {
                match = false;
                break;
            }
        }
        
        if (match) {
            return i;
        }
    }
    
    return -1;
}

南北绿豆走过来:"数据量多大?"

哈吉米:"haystack最多10万,needle最多1万,O(n×m)=10亿次,可能超时。"

南北绿豆:"这是经典的字符串匹配问题,KMP算法秒了。"


二、暴力匹配的问题

阿西噶阿西画了个图:

示例haystack = "aaaaaab", needle = "aaab"

暴力匹配过程

1次匹配:
haystack: a a a a a a b
needle:   a a a b
          ✓ ✓ ✓ ✗(第4个字符不匹配)

第2次匹配:
haystack: a a a a a a b
needle:     a a a b
            ✓ ✓ ✓ ✗

第3次匹配:
haystack: a a a a a a b
needle:       a a a b
              ✓ ✓ ✓ ✓(匹配成功)

问题在哪?

南北绿豆:"第1次匹配失败后,needle直接回退到起点,浪费了已经匹配的信息。"

已经知道:haystack[0..2]='aaa',needle[0..2]='aaa'
失败原因:haystack[3]='a',needle[3]='b'

关键:haystack[1..3]='aaa',needle[0..2]='aaa'
  → haystack[1..3]的前3个字符和needle的前3个字符相同
  → 不需要从haystack[1]重新匹配,可以直接从needle[3]继续

哈吉米:"所以可以利用已经匹配的部分?"

阿西噶阿西:"对,这就是KMP的核心思想。"


三、KMP的核心思想

南北绿豆:"KMP的关键:当匹配失败时,needle不从头开始,而是跳到某个位置继续匹配。"

3.1 生活化场景

场景:你在读一本书,要找"algorithm"这个单词。

暴力方法

从第1页开始,一个个字母匹配
  a-l-g-o-r-i... 不对,不是这个词
  
从第2页重新开始
  a-l-g... 又不对
  
效率太低

KMP方法

匹配到"algo"时发现不对
但你记得"algo"的前缀"al"和后缀"al"不匹配...
(这个例子不太好)

换个例子:匹配"ababc"
已匹配:"abab"
失败位置:'c'

观察:needle="ababc"
  前缀"ab"和后缀"ab"相同
  
所以:不用从头开始,直接从前缀"ab"后面继续

哈吉米:"有点绕..."

南北绿豆:"举个更清楚的例子。"


四、最长公共前后缀(next数组)

南北绿豆:"KMP的核心是next数组,记录每个位置的最长公共前后缀。"

4.1 什么是公共前后缀

定义

  • 前缀:不包含最后一个字符的所有头部子串
  • 后缀:不包含第一个字符的所有尾部子串
  • 公共前后缀:既是前缀又是后缀的子串

示例str = "ababa"

前缀:a, ab, aba, abab
后缀:a, ba, aba, baba

公共前后缀:a, aba

最长公共前后缀:aba(长度3

阿西噶阿西:"next[i]就是记录needle[0..i]的最长公共前后缀的长度。"

4.2 next数组示例

needle = "ababc"

ineedle[0..i]前缀后缀公共next[i]
0a---0
1abab0
2abaa, aba, baa1
3ababa, ab, abab, ab, babab2
4ababca, ab, aba, ababc, bc, abc, babc0

next数组[0, 0, 1, 2, 0]

为什么需要next数组?

南北绿豆:"当匹配失败时,next[j]告诉我们needle应该跳到哪个位置继续匹配。"

匹配到needle[4]='c'时失败
前面已经匹配了needle[0..3]='abab'

next[3]=2,说明'abab'的前2个字符='ab'和后2个字符='ab'相同

所以:haystack中刚匹配失败的位置前面2个字符是'ab'
  → 等于needle的前2个字符'ab'
  → needle可以跳到位置2继续匹配(跳过前面的'ab'

五、next数组的构建

阿西噶阿西:"next数组的构建是KMP最难的部分。"

5.1 构建思路

核心:next数组的构建过程,本身就是一个KMP匹配过程(自己匹配自己)。

双指针

  • i:当前位置
  • j:前缀末尾位置(也是next[i-1])

5.2 构建过程演示

示例needle = "ababc"

ijneedle[i]needle[j]操作next[i]
00--初始化0
10ba不匹配,j=00
20aa匹配,j++1
31bb匹配,j++2
42ca不匹配,j=next[j-1]=0-
40ca不匹配,j=00

最终next数组[0, 0, 1, 2, 0]

图示

flowchart TB
    A["比较needle[i]和needle[j]"]
    B["相等?"]
    C["j++<br/>next[i]=j"]
    D["j==0?"]
    E["next[i]=0"]
    F["j=next[j-1]<br/>继续比较"]
    
    A --> B
    B -->|是| C
    B -->|否| D
    D -->|是| E
    D -->|否| F
    F --> A
    
    style C fill:#e1ffe1
    style E fill:#fff4e1

5.3 构建next数组代码

Java版本

private int[] buildNext(String needle) {
    int m = needle.length();
    int[] next = new int[m];
    next[0] = 0; // 第一个字符没有前后缀
    
    int j = 0; // 前缀末尾位置
    
    for (int i = 1; i < m; i++) {
        // 如果不匹配,j回退
        while (j > 0 && needle.charAt(i) != needle.charAt(j)) {
            j = next[j - 1];
        }
        
        // 如果匹配,j前进
        if (needle.charAt(i) == needle.charAt(j)) {
            j++;
        }
        
        next[i] = j;
    }
    
    return next;
}

C++版本

vector<int> buildNext(string needle) {
    int m = needle.size();
    vector<int> next(m, 0);
    
    int j = 0;
    
    for (int i = 1; i < m; i++) {
        while (j > 0 && needle[i] != needle[j]) {
            j = next[j - 1];
        }
        
        if (needle[i] == needle[j]) {
            j++;
        }
        
        next[i] = j;
    }
    
    return next;
}

Python版本

def buildNext(needle):
    m = len(needle)
    next = [0] * m
    
    j = 0
    
    for i in range(1, m):
        while j > 0 and needle[i] != needle[j]:
            j = next[j - 1]
        
        if needle[i] == needle[j]:
            j += 1
        
        next[i] = j
    
    return next

六、KMP匹配过程

南北绿豆:"有了next数组,匹配过程就简单了。"

6.1 匹配过程演示

示例haystack = "ababcaababc", needle = "ababc"

next数组[0, 0, 1, 2, 0]

匹配过程

ijhaystack[i]needle[j]操作说明
00aa匹配,i++, j++-
11bb匹配,i++, j++-
22aa匹配,i++, j++-
33bb匹配,i++, j++-
44cc匹配,i++, j++找到!

返回:i - j = 4 - 5 = -1?不对,应该是i - needle.length() = 0

重新演示(更复杂的例子)

haystack = "aabaacaab", needle = "aabaac"

next数组[0, 1, 0, 1, 2, 0]

i(haystack)j(needle)字符匹配操作说明
00a==a匹配i++, j++
11a==a匹配i++, j++
22b==b匹配i++, j++
33a==a匹配i++, j++
44a==a匹配i++, j++
55c==c匹配j==m,找到!

返回:起始位置 = i - m = 6 - 6 = 0

哈吉米:"这个例子一次就匹配成功了,看不出KMP的优势啊。"

南北绿豆:"换个会失败的例子。"


七、KMP的威力:失配时的跳转

示例haystack = "aabaabaac", needle = "aabaac"

next数组[0, 1, 0, 1, 2, 0]

匹配过程

ijhaystack[i]needle[j]操作说明
00aa匹配i++, j++
11aa匹配i++, j++
22bb匹配i++, j++
33aa匹配i++, j++
44aa匹配i++, j++
55bc不匹配j=next[j-1]=next[4]=2
52bb匹配i++, j++
63aa匹配i++, j++
74aa匹配i++, j++
85cc匹配找到!

关键步骤解析

失配位置:i=5, j=5
haystack[5]='b', needle[5]='c'

暴力做法:
  needle回到起点,从haystack[1]重新匹配

KMP做法:
  j跳到next[4]=2
  为什么跳到2?
  
  因为已匹配的部分needle[0..4]='aabaa'
  next[4]=2,说明前2个字符='aa'和后2个字符='aa'相同
  
  所以haystack[3..4]='aa' = needle[0..1]='aa'
  不需要重新匹配,直接从needle[2]继续

对比图示

flowchart TB
    subgraph 暴力匹配
        A1["失配后<br/>needle回到起点"]
        B1["i回退<br/>浪费已匹配信息"]
    end
    
    subgraph KMP匹配
        A2["失配后<br/>j=next[j-1]"]
        B2["i不回退<br/>利用已匹配信息"]
    end
    
    style A1 fill:#ffe6e6
    style A2 fill:#e1ffe1

哈吉米:"KMP的i指针不回退,只有j指针跳转,所以快!"


八、KMP完整代码

8.1 完整实现

Java版本

public int strStr(String haystack, String needle) {
    if (needle.length() == 0) return 0;
    
    int n = haystack.length();
    int m = needle.length();
    
    // 构建next数组
    int[] next = buildNext(needle);
    
    int j = 0; // needle的指针
    
    for (int i = 0; i < n; i++) {
        // 失配时,j跳转
        while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
            j = next[j - 1];
        }
        
        // 匹配,j前进
        if (haystack.charAt(i) == needle.charAt(j)) {
            j++;
        }
        
        // 匹配成功
        if (j == m) {
            return i - m + 1;
        }
    }
    
    return -1;
}

private int[] buildNext(String needle) {
    int m = needle.length();
    int[] next = new int[m];
    
    int j = 0;
    for (int i = 1; i < m; i++) {
        while (j > 0 && needle.charAt(i) != needle.charAt(j)) {
            j = next[j - 1];
        }
        
        if (needle.charAt(i) == needle.charAt(j)) {
            j++;
        }
        
        next[i] = j;
    }
    
    return next;
}

C++版本

int strStr(string haystack, string needle) {
    if (needle.empty()) return 0;
    
    int n = haystack.size();
    int m = needle.size();
    
    vector<int> next = buildNext(needle);
    
    int j = 0;
    
    for (int i = 0; i < n; i++) {
        while (j > 0 && haystack[i] != needle[j]) {
            j = next[j - 1];
        }
        
        if (haystack[i] == needle[j]) {
            j++;
        }
        
        if (j == m) {
            return i - m + 1;
        }
    }
    
    return -1;
}

vector<int> buildNext(string needle) {
    int m = needle.size();
    vector<int> next(m, 0);
    
    int j = 0;
    for (int i = 1; i < m; i++) {
        while (j > 0 && needle[i] != needle[j]) {
            j = next[j - 1];
        }
        
        if (needle[i] == needle[j]) {
            j++;
        }
        
        next[i] = j;
    }
    
    return next;
}

Python版本

def strStr(haystack, needle):
    if not needle:
        return 0
    
    n, m = len(haystack), len(needle)
    
    # 构建next数组
    next_arr = buildNext(needle)
    
    j = 0
    
    for i in range(n):
        while j > 0 and haystack[i] != needle[j]:
            j = next_arr[j - 1]
        
        if haystack[i] == needle[j]:
            j += 1
        
        if j == m:
            return i - m + 1
    
    return -1

def buildNext(needle):
    m = len(needle)
    next_arr = [0] * m
    
    j = 0
    for i in range(1, m):
        while j > 0 and needle[i] != needle[j]:
            j = next_arr[j - 1]
        
        if needle[i] == needle[j]:
            j += 1
        
        next_arr[i] = j
    
    return next_arr

时间复杂度:O(n+m)

  • 构建next数组:O(m)
  • 匹配过程:O(n)

九、例题2:重复的子字符串

9.1 题目

LeetCode 459 - 重复的子字符串

给定一个非空的字符串 s ,检查是否可以通过由它的一个子串重复多次构成。

示例:
输入:s = "abab"
输出:true
解释:可由子串 "ab" 重复两次构成。

输入:s = "aba"
输出:false

输入:s = "abcabcabcabc"
输出:true
解释:可由子串 "abc" 重复四次构成。

9.2 思路分析

南北绿豆:"这题可以用KMP的next数组巧妙解决。"

关键性质:如果字符串s由子串重复构成,那么s的长度-next[n-1]就是最小重复子串的长度

示例s = "abab"

next数组:[0, 0, 1, 2]

最长公共前后缀长度:next[3] = 2
  → 前缀"ab"和后缀"ab"相同
  
s长度 - next[3] = 4 - 2 = 2
  → 最小重复单元长度是2

如果4能被2整除,说明可以重复构成 ✓

9.3 代码实现

Java版本

public boolean repeatedSubstringPattern(String s) {
    int n = s.length();
    int[] next = buildNext(s);
    
    // 最长公共前后缀长度
    int len = next[n - 1];
    
    // len > 0 说明有公共前后缀
    // n % (n - len) == 0 说明能整除
    return len > 0 && n % (n - len) == 0;
}

C++版本

bool repeatedSubstringPattern(string s) {
    int n = s.size();
    vector<int> next = buildNext(s);
    
    int len = next[n - 1];
    
    return len > 0 && n % (n - len) == 0;
}

Python版本

def repeatedSubstringPattern(s):
    n = len(s)
    next_arr = buildNext(s)
    
    length = next_arr[n - 1]
    
    return length > 0 and n % (n - length) == 0

十、KMP总结

10.1 核心要点

南北绿豆总结:

  1. 核心思想:利用已匹配信息,避免重复匹配
  2. next数组:记录最长公共前后缀长度
  3. 时间复杂度:O(n+m),i指针不回退
  4. 关键:失配时j跳到next[j-1]

10.2 KMP vs 暴力

对比项暴力匹配KMP
i指针会回退不回退
j指针失配后回到0失配后跳到next[j-1]
时间复杂度O(n×m)O(n+m)
空间复杂度O(1)O(m)

10.3 识别技巧

阿西噶阿西

  • 看到字符串匹配、查找子串,想KMP
  • 看到重复模式、周期性,想KMP的next数组
  • 看到大数据量字符串匹配,想KMP

10.4 为什么next数组这么构建

南北绿豆:"很多人问:为什么j = next[j-1]?"

解释

当needle[i] != needle[j]时:

说明needle[0..i-1]中,
  前j个字符 != 后j个字符(从i-j+1i)
  
但我们知道:
  needle[0..j-1]的最长公共前后缀长度是next[j-1]
  
所以:
  可以跳到next[j-1]位置继续比较
  相当于用更短的前缀去匹配

哈吉米:"这部分确实难理解,需要多想想。"


参考资料

  • 《算法导论》- Thomas H. Cormen
  • 《算法竞赛进阶指南》- 李煜东
  • Donald Knuth - KMP算法论文