数据结构——串与 KMP 算法:字符串匹配从暴力到优化

字符串匹配是日常开发中最常见的操作------搜索关键词、查找替换、文本比对。暴力匹配虽然简单但效率低,KMP 算法是考研和面试的常考题,也是理解"空间换时间"思想的好例子。

一、串的基本概念

串(String) 是由零个或多个字符组成的有限序列。

复制代码
S = "abc123"
  ↑    ↑
 串名 串值

术语:

  • 子串:串中任意连续字符组成的子序列。如 "abc" 是 "abc123" 的子串
  • 主串:包含子串的串
  • 位置:字符在串中的序号(通常从 1 开始)

二、暴力匹配(Brute-Force)

1. 算法思想

复制代码
主串 S: "ababcabcacbab"
模式串 T: "abcac"

从 S[1] 开始与 T[1] 比较:
第一趟:a b a b c ...
        a b c a c      ← S[3]=a ≠ T[3]=c,匹配失败
        ↑

第二趟:a b a b c ...
          a b c a c    ← S[2]=b ≠ T[1]=a,失败

第三趟:a b a b c a b c a c b a b
          a b c a c     ← 匹配成功!
          ↑

2. 代码实现

java 复制代码
public class BruteForce {

    /**
     * 暴力匹配
     * @param s 主串
     * @param t 模式串
     * @return 匹配到的起始位置,-1 表示未找到
     */
    public static int indexOf(String s, String t) {
        int n = s.length();
        int m = t.length();

        // i 为主串指针,j 为模式串指针
        for (int i = 0; i <= n - m; i++) {
            int j;
            for (j = 0; j < m; j++) {
                if (s.charAt(i + j) != t.charAt(j)) {
                    break;  // 字符不相等,跳出内循环
                }
            }
            if (j == m) {
                return i;  // 完全匹配,返回起始位置
            }
        }
        return -1;
    }
}

3. 时间复杂度

复制代码
最好情况:每次第一个字符就不匹配 → O(n + m)
最坏情况:每次匹配到最后一个字符才失败 → O(n × m)

例子:S = "aaaaaaaaaaaab", T = "aaab"
每一趟都要比到最后才发现不匹配 → 效率极低

暴力匹配的问题: 匹配失败后主串回溯到 i+1,模式串回到 0,之前比过的信息全部丢弃。

三、KMP 算法

1. 核心思想

KMP 算法(Knuth-Morris-Pratt)的核心是:匹配失败时,主串指针不回溯,模式串根据 next 数组向右滑动到合适位置继续匹配。

复制代码
主串: "ababcabcacbab"
模式串: "abcac"

当匹配到第 3 个字符失败时:
  a b a b c ...
  a b c a c
  ↑ ↑     ← S[3]≠T[3]
   i=2    ← 且 S[0:2]="ab" = T[0:2]="ab"

暴力做法:i 回退到 1,j 回退到 0 → 重新比
KMP 做法:i 不动(停在 2),j 跳到 1 → 因为 T[0:1]="a" = T[1:2]="b"? 不相等,所以 j=0

关键:模式串 T 中已经匹配的部分 "ab" 有没有重复的前后缀?
"ab" 的前缀 "a" ≠ 后缀 "b" → 没有公共前后缀 → j 回到 0

2. next 数组

nextj 的含义: 当模式串 Tj 与主串匹配失败时,j 应该回退到的位置。

计算规则:

复制代码
next[0] = -1(模式串第一个字符就失败)
next[1] = 0(第二个字符失败,模式串回到开头)

对于 j > 1:
next[j] = T[0:j-1] 中最长相等前后缀的长度

前后缀概念:

复制代码
字符串 "abcab"
前缀:{"a", "ab", "abc", "abca"}
后缀:{"b", "ab", "cab", "bcab"}
最长相等前后缀 = "ab" → 长度 = 2

3. 手算 next 数组

复制代码
模式串 T = "ababa"

j=0: next[0] = -1

j=1: T[0:0]="" → 没有子串 → next[1] = 0

j=2: T[0:1]="ab"
    前缀: {"a"}  后缀: {"b"} → 无相等 → next[2] = 0

j=3: T[0:2]="aba"
    前缀: {"a", "ab"}  后缀: {"a", "ba"}
    相等: "a" → 长度1 → next[3] = 1

j=4: T[0:3]="abab"
    前缀: {"a", "ab", "aba"}  后缀: {"b", "ab", "bab"}
    相等: "ab" → 长度2 → next[4] = 2

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

4. KMP 匹配过程

复制代码
用上面计算好的 next 数组匹配:

S = "abcabababa"
T = "ababa"
next = [-1, 0, 0, 1, 2]

第一趟:a b c a b a b a b c
        a b a b a
        ↑
    i=2: S[2]=c ≠ T[2]=a,失败
    j=2 → next[2]=0 → j 跳到 0,i 不变

第二趟:a b c a b a b a b c
            a b a b a
            ↑
    i=2: S[2]=c ≠ T[0]=a,失败
    j=0 → next[0]=-1 → i 后移一位,j=0

第三趟:a b c a b a b a b c
              a b a b a
              ↑↑↑
    S[3]=a=T[0], S[4]=b=T[1], S[5]=a=T[2] → 匹配继续...
    
    ... 最终匹配成功

5. 代码实现

java 复制代码
public class KMP {

    /**
     * 计算 next 数组
     */
    public static int[] getNext(String pattern) {
        int m = pattern.length();
        int[] next = new int[m];
        next[0] = -1;
        int i = 0;   // 当前计算的 next 位置
        int j = -1;  // 最长相等前后缀长度

        while (i < m - 1) {
            if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
                i++;
                j++;
                next[i] = j;
            } else {
                j = next[j];  // 不相等,回退
            }
        }
        return next;
    }

    /**
     * KMP 匹配
     */
    public static int indexOf(String text, String pattern) {
        int n = text.length();
        int m = pattern.length();
        if (m == 0) return 0;

        int[] next = getNext(pattern);
        int i = 0;  // 主串指针
        int j = 0;  // 模式串指针

        while (i < n && j < m) {
            if (j == -1 || text.charAt(i) == pattern.charAt(j)) {
                i++;
                j++;
            } else {
                j = next[j];  // 主串不回溯,只动 j
            }
        }

        if (j == m) {
            return i - j;  // 匹配成功
        }
        return -1;
    }
}

6. 时间复杂度

复制代码
计算 next 数组:O(m)
KMP 匹配过程:O(n)
总时间复杂度:O(n + m)

对比:

复制代码
暴力:O(n × m)
KMP:O(n + m)

当 n=1000000, m=10000 时:
暴力:100 亿次比较
KMP:101 万次比较 → 快 10000 倍

四、KMP 的优化(nextval 数组)

KMP 有一个改进版,使用 nextval 数组进一步减少不必要的比较:

java 复制代码
public static int[] getNextVal(String pattern) {
    int m = pattern.length();
    int[] nextval = new int[m];
    nextval[0] = -1;
    int i = 0;
    int j = -1;

    while (i < m - 1) {
        if (j == -1 || pattern.charAt(i) == pattern.charAt(j)) {
            i++;
            j++;
            // 改进:如果 pattern[i] == pattern[j]
            // 那么即使跳到 j 也会匹配失败,所以继续向前跳
            if (pattern.charAt(i) != pattern.charAt(j)) {
                nextval[i] = j;
            } else {
                nextval[i] = nextval[j];
            }
        } else {
            j = nextval[j];
        }
    }
    return nextval;
}

五、408 考研常见考题

题1:手算 next 数组

复制代码
求模式串 "abaabcac" 的 next 数组

答案:[-1, 0, 0, 1, 1, 2, 2, 3]

过程:
j=0: next[0] = -1
j=1: next[1] = 0
j=2: "ab" → 无相等 → next[2]=0
j=3: "aba" → "a"=1 → next[3]=1
j=4: "abaa" → "a"=1 → next[4]=1
j=5: "abaab" → "ab"=2 → next[5]=2
j=6: "abaabc" → 无相等 → next[6]=0→next[6]=2?
j=7: "abaabca" → "a"=1→next[7]=1→... 

题2:next 与 nextval 的区别

复制代码
模式串 "aaaaaab"
next 数组: [-1, 0, 1, 2, 3, 4, 5]
nextval 数组: [-1, -1, -1, -1, -1, -1, 5]

优化效果:当遇到 "aaaaaab" 时,
遇到 'b' 之前的所有字符都是 'a',
如果 S[i] ≠ 'a',那么跳转到哪里都一样,
nextval 直接跳到第一个字符

六、KMP 算法的应用

java 复制代码
// 1. 文本搜索(Ctrl+F)
int pos = KMP.indexOf(document, keyword);

// 2. 查找所有出现位置
List<Integer> findAll(String text, String pattern) {
    List<Integer> positions = new ArrayList<>();
    int pos = 0;
    while ((pos = KMP.indexOf(text.substring(pos), pattern)) != -1) {
        positions.add(pos);
        pos++;
    }
    return positions;
}

// 3. 字符串替换
String replace(String text, String oldStr, String newStr) {
    StringBuilder sb = new StringBuilder();
    int last = 0;
    int pos = KMP.indexOf(text, oldStr);
    while (pos != -1) {
        sb.append(text, last, pos);
        sb.append(newStr);
        last = pos + oldStr.length();
        pos = KMP.indexOf(text.substring(last), oldStr);
        if (pos != -1) pos += last;
    }
    sb.append(text.substring(last));
    return sb.toString();
}

七、知识点对比

复制代码
暴力匹配:主串回溯,简单但慢
KMP:主串不回溯,利用 next 数组快速跳转
next 数组:记录最长相等前后缀长度
nextval 数组:next 的改进版,进一步优化

暴力  O(n×m) → 理解思想,面试基础
KMP   O(n+m) → 考研重点,要能手算 next 数组

💡 觉得有用的话,点赞 + 关注【张老师技术栈】吧!每周更新 Java/Python/爬虫 实战干货,不让你白来。