假设你需要在一段百万字的文本中搜索一个关键词。最直观的方法是:将关键词与文本的每个位置逐一比较——时间复杂度 $O(nm)$,其中n是文本长度,m是关键词长度。当文本达到百万级别时,这个算法可能需要数秒甚至更长。

但如果告诉你,有一种方法能在 $O(n+m)$ 时间内完成同样的任务呢?这就是字符串匹配算法的魅力:通过预处理模式串,利用已匹配的信息避免重复比较,将二次复杂度降为线性复杂度。

在文本编辑器的查找功能、搜索引擎的索引构建、DNA序列比对、抄袭检测系统背后,字符串匹配算法都在默默工作。掌握KMP、Z函数、Rabin-Karp等核心算法,不仅能让你在面试中游刃有余,更能深刻理解计算机处理文本信息的底层逻辑。

朴素匹配:直观但低效的起点

在深入高效算法之前,先理解朴素匹配方法的问题所在。假设要在文本 haystack = "ABABABC" 中搜索模式 needle = "ABABC"

朴素匹配从文本的第一个位置开始,逐字符比较。当匹配到第五个字符时发现不匹配(A vs C),于是将模式串向右移动一位,重新从头开始比较。问题在于:即使前四个字符已经匹配成功,我们仍然丢弃了这些信息,从零开始。

// 朴素匹配 - O(nm) 时间复杂度
public int strStrNaive(String haystack, String needle) {
    int n = haystack.length(), m = needle.length();
    for (int i = 0; i <= n - m; i++) {
        int j;
        for (j = 0; j < m; j++) {
            if (haystack.charAt(i + j) != needle.charAt(j)) break;
        }
        if (j == m) return i;
    }
    return -1;
}

这种方法的致命缺陷在于:当不匹配发生时,已匹配的部分信息被完全浪费。高效算法的核心思想正是:如何利用这些已匹配信息,让模式串"聪明地"跳跃到下一个可能匹配的位置。

KMP算法:利用模式串自身的结构

1977年,Knuth、Morris、Pratt三人共同发表了线性时间字符串匹配算法。KMP算法的核心洞察是:当匹配失败时,模式串中已匹配的部分本身包含着关于"应该跳到哪个位置继续匹配"的信息。

前缀函数(LPS数组)的本质

考虑模式串 P = "ABABAC"。当我们在文本中匹配到第六个字符时发生失败,此时前五个字符 ABABA 已经匹配。观察这个已匹配部分:

  • 前缀 AB 等于后缀 AB
  • 这意味着如果模式串向右移动三位,前两个字符 AB 仍然能与文本匹配

这就是LPS(Longest Proper Prefix which is also Suffix)数组的核心思想:lps[i] 表示模式串 P[0...i] 的最长相等真前缀和真后缀的长度。“真"意味着不能是整个字符串本身。

P = "ABABAC" 为例:

  • lps[0] = 0:单个字符没有真前缀和真后缀
  • lps[1] = 0"AB" 没有相等的前后缀
  • lps[2] = 1"ABA" 的前缀 "A" 等于后缀 "A"
  • lps[3] = 2"ABAB" 的前缀 "AB" 等于后缀 "AB"
  • lps[4] = 3"ABABA" 的前缀 "ABA" 等于后缀 "ABA"
  • lps[5] = 0"ABABAC" 没有相等的前后缀

KMP算法部分匹配表

图片来源: 阮一峰 - 字符串匹配的KMP算法

LPS数组的构建

构建LPS数组使用双指针技巧:len 指向当前最长匹配前缀的末尾,i 遍历模式串。

private int[] buildLPS(String pattern) {
    int m = pattern.length();
    int[] lps = new int[m];
    int len = 0;  // 当前最长匹配前后缀的长度
    
    for (int i = 1; i < m; i++) {
        // 不匹配时,回退到上一个可能的匹配位置
        while (len > 0 && pattern.charAt(i) != pattern.charAt(len)) {
            len = lps[len - 1];
        }
        // 匹配时,扩展匹配长度
        if (pattern.charAt(i) == pattern.charAt(len)) {
            len++;
            lps[i] = len;
        }
    }
    return lps;
}

为什么这能在线性时间内完成?关键在于 len 指针的行为:它要么增加(匹配时),要么减少(不匹配回退时)。由于 len 最多增加m次,总操作次数是 $O(m)$。

KMP匹配过程

有了LPS数组,匹配过程变得直观:当不匹配发生时,查询LPS数组决定模式串应该移动到哪个位置。

public int strStrKMP(String haystack, String needle) {
    int n = haystack.length(), m = needle.length();
    if (m == 0) return 0;
    
    int[] lps = buildLPS(needle);
    int j = 0;  // 模式串中的位置
    
    for (int i = 0; i < n; i++) {
        // 不匹配时,根据LPS数组跳转
        while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
            j = lps[j - 1];
        }
        // 匹配时,推进模式串指针
        if (haystack.charAt(i) == needle.charAt(j)) {
            j++;
        }
        // 完全匹配
        if (j == m) {
            return i - m + 1;
        }
    }
    return -1;
}

时间复杂度分析:构建LPS数组 $O(m)$,匹配过程 $O(n)$,总计 $O(n+m)$。空间复杂度 $O(m)$ 用于存储LPS数组。

Z算法:更直观的线性匹配

Z算法提供了另一种线性时间的字符串匹配方法,其核心是Z函数。与KMP的前缀函数不同,Z函数关注的是"从每个位置开始,有多少字符与前缀匹配”。

Z函数的定义

对于字符串 SZ[i] 定义为:从位置 i 开始的子串 S[i...] 与字符串前缀 S[0...] 的最长公共前缀长度。

例如 S = "aabxaab"

  • Z[0] 未定义(或视为0,因为整个字符串与自己的前缀比较没有意义)
  • Z[1] = 1"abxaab""aabxaab" 的最长公共前缀是 "a"
  • Z[4] = 3"aab""aabxaab" 的最长公共前缀是 "aab"

Z-box与线性构建

Z算法的精妙之处在于使用"Z-box"——一个区间 [l, r],表示已知的与前缀匹配的最右边界。当处理位置 i 时:

  1. 如果 i > r:从头开始比较,计算新的Z值
  2. 如果 i <= r:利用已知信息,Z[i] 至少是 min(Z[i-l], r-i+1)
private int[] zFunction(String s) {
    int n = s.length();
    int[] z = new int[n];
    int l = 0, r = 0;  // Z-box边界
    
    for (int i = 1; i < n; i++) {
        // 利用已知信息
        if (i <= r) {
            z[i] = Math.min(r - i + 1, z[i - l]);
        }
        // 尝试扩展
        while (i + z[i] < n && s.charAt(z[i]) == s.charAt(i + z[i])) {
            z[i]++;
        }
        // 更新Z-box
        if (i + z[i] - 1 > r) {
            l = i;
            r = i + z[i] - 1;
        }
    }
    return z;
}

用Z函数进行模式匹配

将模式串和文本拼接:pattern + "$" + text$是不出现在两者中的分隔符)。计算Z数组后,任何 Z[i] == pattern.length 的位置 i 都对应一个匹配。

public List<Integer> strStrZ(String text, String pattern) {
    String combined = pattern + "$" + text;
    int[] z = zFunction(combined);
    List<Integer> matches = new ArrayList<>();
    int m = pattern.length();
    
    for (int i = m + 1; i < combined.length(); i++) {
        if (z[i] == m) {
            matches.add(i - m - 1);
        }
    }
    return matches;
}

Rabin-Karp:哈希加速的匹配

Rabin-Karp算法采用完全不同的思路:将字符串转换为数值,用数值比较代替字符比较。

滚动哈希的原理

将字符串视为一个进制数。对于字符串 "abc",选择基数 d = 256,哈希值为:

$$h = a \times d^2 + b \times d^1 + c \times d^0$$

关键是:当窗口向右滑动一位时,新哈希值可以通过旧哈希值快速计算:

$$h_{new} = (h_{old} - oldChar \times d^{m-1}) \times d + newChar$$
public int strStrRabinKarp(String haystack, String needle) {
    int n = haystack.length(), m = needle.length();
    if (m == 0) return 0;
    if (m > n) return -1;
    
    // 大质数用于取模,避免溢出
    long MOD = 1_000_000_007L;
    long base = 256L;
    long baseM = 1;  // base^m mod MOD
    
    // 预计算 base^m
    for (int i = 0; i < m; i++) baseM = (baseM * base) % MOD;
    
    // 计算模式串和文本第一个窗口的哈希
    long needleHash = 0, windowHash = 0;
    for (int i = 0; i < m; i++) {
        needleHash = (needleHash * base + needle.charAt(i)) % MOD;
        windowHash = (windowHash * base + haystack.charAt(i)) % MOD;
    }
    
    for (int i = 0; i <= n - m; i++) {
        // 哈希匹配时,进行实际比较(避免哈希碰撞)
        if (needleHash == windowHash) {
            if (haystack.substring(i, i + m).equals(needle)) {
                return i;
            }
        }
        // 滚动计算下一个窗口的哈希
        if (i < n - m) {
            windowHash = (windowHash * base - haystack.charAt(i) * baseM % MOD + MOD) % MOD;
            windowHash = (windowHash + haystack.charAt(i + m)) % MOD;
        }
    }
    return -1;
}

Rabin-Karp特别适合多模式匹配:预处理所有模式串的哈希,然后在一次文本扫描中查找所有模式。

LeetCode实战:从理论到代码

LeetCode 28. Find the Index of the First Occurrence in a String

这是最基础的字符串匹配问题,KMP是最优解。

class Solution {
    public int strStr(String haystack, String needle) {
        int n = haystack.length(), m = needle.length();
        if (m == 0) return 0;
        
        int[] lps = buildLPS(needle);
        int j = 0;
        
        for (int i = 0; i < n; i++) {
            while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
                j = lps[j - 1];
            }
            if (haystack.charAt(i) == needle.charAt(j)) {
                j++;
            }
            if (j == m) {
                return i - m + 1;
            }
        }
        return -1;
    }
    
    private int[] buildLPS(String pattern) {
        int m = pattern.length();
        int[] lps = new int[m];
        int len = 0;
        for (int i = 1; i < m; i++) {
            while (len > 0 && pattern.charAt(i) != pattern.charAt(len)) {
                len = lps[len - 1];
            }
            if (pattern.charAt(i) == pattern.charAt(len)) {
                lps[i] = ++len;
            }
        }
        return lps;
    }
}

复杂度:时间 $O(n+m)$,空间 $O(m)$。

LeetCode 459. Repeated Substring Pattern

判断字符串是否由某个子串重复构成。一个巧妙的解法:将字符串翻倍后去掉首尾,如果原串仍能找到,说明存在重复模式。

class Solution {
    public boolean repeatedSubstringPattern(String s) {
        String doubled = (s + s).substring(1, s.length() * 2 - 1);
        return doubled.contains(s);
    }
}

也可以使用KMP的LPS数组:如果 lps[n-1] > 0n % (n - lps[n-1]) == 0,则存在重复模式。

class Solution {
    public boolean repeatedSubstringPattern(String s) {
        int n = s.length();
        int[] lps = new int[n];
        int len = 0;
        
        for (int i = 1; i < n; i++) {
            while (len > 0 && s.charAt(i) != s.charAt(len)) {
                len = lps[len - 1];
            }
            if (s.charAt(i) == s.charAt(len)) {
                lps[i] = ++len;
            }
        }
        
        int lastLPS = lps[n - 1];
        return lastLPS > 0 && n % (n - lastLPS) == 0;
    }
}

原理lps[n-1] 表示整个字符串的最长相等前后缀长度。如果这个长度能整除字符串长度,说明字符串可以由长度为 n - lps[n-1] 的子串重复构成。

LeetCode 1392. Longest Happy Prefix

寻找最长的"快乐前缀"——既是前缀又是后缀的最长子串。这正是LPS数组最后元素的含义。

class Solution {
    public String longestPrefix(String s) {
        int n = s.length();
        int[] lps = new int[n];
        int len = 0;
        
        for (int i = 1; i < n; i++) {
            while (len > 0 && s.charAt(i) != s.charAt(len)) {
                len = lps[len - 1];
            }
            if (s.charAt(i) == s.charAt(len)) {
                lps[i] = ++len;
            }
        }
        
        return s.substring(0, lps[n - 1]);
    }
}

复杂度:时间 $O(n)$,空间 $O(n)$。

LeetCode 214. Shortest Palindrome

通过在字符串前面添加字符,使其成为最短回文。核心是找到最长回文前缀。

KMP解法:将原串和其逆序拼接,计算LPS数组。

class Solution {
    public String shortestPalindrome(String s) {
        String rev = new StringBuilder(s).reverse().toString();
        String combined = s + "#" + rev;
        
        int[] lps = new int[combined.length()];
        int len = 0;
        for (int i = 1; i < combined.length(); i++) {
            while (len > 0 && combined.charAt(i) != combined.charAt(len)) {
                len = lps[len - 1];
            }
            if (combined.charAt(i) == combined.charAt(len)) {
                lps[i] = ++len;
            }
        }
        
        int longestPalinPrefix = lps[combined.length() - 1];
        String toAdd = new StringBuilder(s.substring(longestPalinPrefix)).reverse().toString();
        return toAdd + s;
    }
}

原理s + "#" + rev 的LPS数组最后元素,给出了原串与逆序串的最长公共前缀长度,这正是最长回文前缀的长度。

算法选择指南

算法 预处理时间 匹配时间 空间复杂度 适用场景
朴素匹配 $O(1)$ $O(nm)$ $O(1)$ 模式串极短时
KMP $O(m)$ $O(n)$ $O(m)$ 通用字符串匹配
Z算法 $O(m)$ $O(n)$ $O(m)$ 需要所有匹配位置时
Rabin-Karp $O(m)$ $O(n)$ 平均 $O(1)$ 多模式匹配、扩展性好
Boyer-Moore $O(m+k)$ $O(n/m)$ 最优 $O(k)$ 字符集较大时

其中 $k$ 是字符集大小。

现实应用

字符串匹配算法远不止是面试题:

文本编辑器 的查找功能需要实时响应,KMP或Boyer-Moore确保毫秒级响应。

DNA序列分析 中,人类基因组约30亿碱基对,需要在其中定位特定基因序列。高效的字符串匹配是生物信息学的基础工具。

搜索引擎 的倒排索引构建过程中,需要从海量文档中提取词项,字符串匹配是核心操作。

抄袭检测系统 通过比较文档间的相似片段来识别抄袭,本质上是多模式字符串匹配问题。

算法演进的启示

从朴素匹配的 $O(nm)$ 到KMP的 $O(n+m)$,字符串匹配算法的演进揭示了计算机科学的一个深刻道理:预处理换取运行时效率。通过预先分析模式串的结构(LPS数组、Z数组),我们在匹配阶段能够做出更明智的决策。

更重要的是,KMP算法教会我们一个通用的问题解决策略:不要浪费已获得的信息。当匹配失败时,已匹配的部分不是毫无价值的废料,而是蕴含着"下一步该怎么做"的线索。这种"信息复用"的思想,在动态规划、后缀自动机等更复杂的算法中都有体现。


参考文献

  • Knuth, D. E., Morris, J. H., & Pratt, V. R. (1977). Fast pattern matching in strings. SIAM Journal on Computing, 6(2), 323-350.
  • Boyer, R. S., & Moore, J. S. (1977). A fast string searching algorithm. Communications of the ACM, 20(10), 762-772.
  • Karp, R. M., & Rabin, M. O. (1987). Efficient randomized pattern-matching algorithms. IBM Journal of Research and Development, 31(2), 249-260.