每日算法 — 使用java实现字符串算法:KMP与Manacher的高效模式匹配

引言

字符串模式匹配是算法竞赛、信息检索和生物信息学中的核心问题。从简单的暴力匹配到高效的线性时间算法,字符串算法的演进体现了算法设计的精妙之处。本文将用 Java 深入实现 KMP(Knuth-Morris-Pratt)算法和 Manacher 算法,分别解决”子串搜索”和”最长回文子串”两大经典问题,讲解前缀函数与中心扩展的核心思想。

暴力匹配的局限

在介绍高效算法之前,先看暴力匹配的问题所在:

/**
 * 暴力字符串匹配
 * 时间复杂度:O(m * n),其中 m 为文本长度,n 为模式串长度
 */
public int bruteForce(String text, String pattern) {
    int m = text.length();
    int n = pattern.length();

    for (int i = 0; i <= m - n; i++) {
        int j = 0;
        while (j < n && text.charAt(i + j) == pattern.charAt(j)) {
            j++;
        }
        if (j == n) {
            return i;  // 在位置 i 找到匹配
        }
    }
    return -1;  // 未找到匹配
}

暴力匹配的问题在于:每次匹配失败后,文本指针只回溯一位,模式串指针归零。这意味着已经比较过的字符可能被重复比较多次。KMP 算法的核心思想就是避免这种不必要的回溯。

KMP 算法:线性时间模式匹配

核心思想

KMP 算法的关键在于前缀函数(Prefix Function),也称失配函数(Failure Function)。对于模式串的每个位置 i,pi[i] 表示子串 pattern[0..i] 的最长相等真前缀与真后缀的长度。

当在位置 i 发生失配时,利用前缀函数可以直接将模式串向右移动到一个合理的位置,而无需回溯文本指针。

前缀函数的计算

/**
 * 计算前缀函数(失配函数)
 * pi[i] 表示 pattern[0..i] 的最长相等真前缀与真后缀的长度
 * 时间复杂度:O(n)
 *
 * @param pattern 模式串
 * @return 前缀函数数组
 */
public int[] computePrefixFunction(String pattern) {
    int n = pattern.length();
    int[] pi = new int[n];

    // pi[0] 恒为 0,因为长度为1的字符串没有真前缀/真后缀
    for (int i = 1; i < n; i++) {
        int j = pi[i - 1];  // j 表示前一个位置的最长前缀后缀长度

        // 如果当前字符不匹配,回退到更短的前缀
        while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
            j = pi[j - 1];
        }

        // 如果当前字符匹配,前缀后缀长度加1
        if (pattern.charAt(i) == pattern.charAt(j)) {
            j++;
        }

        pi[i] = j;
    }

    return pi;
}

前缀函数计算示例

以模式串 "ABABC" 为例:

i 子串 真前缀 真后缀 最长匹配 pi[i]
0 A – – – 0
1 AB A B 无 0
2 ABA A, AB A, BA A 1
3 ABAB A, AB, ABA B, AB, BAB AB 2
4 ABABC A, AB, ABA, ABAB C, BC, ABC, BABC 无 0

KMP 搜索过程

/**
 * KMP 字符串匹配算法
 * 时间复杂度:O(m + n),线性时间
 * 空间复杂度:O(n),用于存储前缀函数
 *
 * @param text    文本串
 * @param pattern 模式串
 * @return 模式串在文本串中首次出现的位置,未找到返回 -1
 */
public int kmpSearch(String text, String pattern) {
    if (pattern.isEmpty()) return 0;
    if (text.length() < pattern.length()) return -1;

    int[] pi = computePrefixFunction(pattern);

    int j = 0;  // 模式串当前匹配位置
    for (int i = 0; i < text.length(); i++) {
        // 当发生失配时,利用前缀函数回退模式串指针
        while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
            j = pi[j - 1];
        }

        // 当前字符匹配
        if (text.charAt(i) == pattern.charAt(j)) {
            j++;
        }

        // 模式串完全匹配
        if (j == pattern.length()) {
            return i - pattern.length() + 1;  // 返回匹配起始位置
        }
    }

    return -1;
}

/**
 * KMP 查找所有匹配位置
 */
public List<Integer> kmpSearchAll(String text, String pattern) {
    List<Integer> result = new ArrayList<>();
    if (pattern.isEmpty()) return result;

    int[] pi = computePrefixFunction(pattern);
    int j = 0;

    for (int i = 0; i < text.length(); i++) {
        while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
            j = pi[j - 1];
        }

        if (text.charAt(i) == pattern.charAt(j)) {
            j++;
        }

        if (j == pattern.length()) {
            result.add(i - pattern.length() + 1);
            // 继续搜索:允许重叠匹配
            j = pi[j - 1];
        }
    }

    return result;
}

KMP 算法图解

以 text = "ABABDABACDABABCABAB",pattern = "ABABC" 为例:

文本:  A B A B D A B A C D A B A B C A B A B
模式:  A B A B C
        ↑ 在位置4失配(D != C)

利用 pi[3] = 2,模式串向右移动,保持文本指针不动:
文本:  A B A B D A B A C D A B A B C A B A B
模式:      A B A B C
            ↑ 在位置4再次失配(D != C)

利用 pi[1] = 0,模式串继续向右移动:
文本:  A B A B D A B A C D A B A B C A B A B
模式:          A B A B C
                ↑ 继续匹配...

Manacher 算法:线性时间最长回文子串

核心思想

回文串分为奇数长度(如 "aba")和偶数长度(如 "abba")两种。传统中心扩展法需要分别以每个字符和每对字符为中心向两边扩展,时间复杂度为 O(n²)。

Manacher 算法的核心技巧是:插入分隔符,将所有回文串统一为奇数长度。例如 "abba" 变为 "#a#b#b#a#",此时回文中心就是某个 # 字符。

算法实现

/**
 * Manacher 算法:线性时间求解最长回文子串
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
public class ManacherAlgorithm {

    /**
     * 预处理:插入分隔符,统一回文串长度为奇数
     * 例如 "abba" -> "#a#b#b#a#"
     *
     * @param s 原始字符串
     * @return 处理后的字符数组
     */
    private char[] preprocess(String s) {
        int n = s.length();
        char[] t = new char[2 * n + 1];
        for (int i = 0; i < n; i++) {
            t[2 * i] = '#';
            t[2 * i + 1] = s.charAt(i);
        }
        t[2 * n] = '#';
        return t;
    }

    /**
     * Manacher 算法主函数
     *
     * @param s 原始字符串
     * @return 最长回文子串
     */
    public String longestPalindrome(String s) {
        if (s == null || s.length() < 2) {
            return s;
        }

        char[] t = preprocess(s);
        int n = t.length;
        int[] p = new int[n];  // p[i] 表示以 t[i] 为中心的回文半径

        int center = 0;   // 当前最右回文串的中心
        int right = 0;    // 当前最右回文串的右边界(不含)
        int maxLen = 0;   // 最长回文串的长度
        int start = 0;    // 最长回文串在原始字符串中的起始位置

        for (int i = 0; i < n; i++) {
            // 利用对称性初始化 p[i]
            // 如果 i 在当前最右回文串内,p[i] 至少等于其关于 center 对称点的 p 值
            // 但不能超过右边界
            if (i < right) {
                int mirror = 2 * center - i;  // i 关于 center 的对称点
                p[i] = Math.min(right - i, p[mirror]);
            }

            // 中心扩展:尝试继续扩大以 i 为中心的回文串
            int leftBound = i - (p[i] + 1);
            int rightBound = i + (p[i] + 1);
            while (leftBound >= 0 && rightBound < n && t[leftBound] == t[rightBound]) {
                p[i]++;
                leftBound--;
                rightBound++;
            }

            // 更新最右回文串信息
            if (i + p[i] > right) {
                center = i;
                right = i + p[i];
            }

            // 更新最长回文串信息
            // 原始回文串长度 = p[i](因为每隔一个字符是 #,有效字符数为 p[i])
            if (p[i] > maxLen) {
                maxLen = p[i];
                // 计算在原始字符串中的起始位置
                start = (i - p[i]) / 2;
            }
        }

        return s.substring(start, start + maxLen);
    }

    /**
     * 获取所有回文子串的数量
     */
    public int countPalindromicSubstrings(String s) {
        if (s == null || s.isEmpty()) return 0;

        char[] t = preprocess(s);
        int n = t.length;
        int[] p = new int[n];

        int center = 0, right = 0;
        int count = 0;

        for (int i = 0; i < n; i++) {
            if (i < right) {
                p[i] = Math.min(right - i, p[2 * center - i]);
            }

            int l = i - (p[i] + 1);
            int r = i + (p[i] + 1);
            while (l >= 0 && r < n && t[l] == t[r]) {
                p[i]++;
                l--;
                r++;
            }

            if (i + p[i] > right) {
                center = i;
                right = i + p[i];
            }

            // 以 i 为中心的回文子串数量 = p[i]
            // (因为半径为1对应一个字符,半径为2对应三个字符...)
            count += (p[i] + 1) / 2;
        }

        return count;
    }
}

Manacher 算法的关键理解

  1. 分隔符技巧:"abba" 变为 "#a#b#b#a#",中心在索引4的#处,半径为4,对应原串长度4
  2. 对称性利用:如果位置 i 在当前最右回文串内,其关于中心 center 的对称点 mirror = 2*center - i 的回文半径已知,可以直接利用
  3. 边界限制:对称点提供的初始值不能超过当前最右回文串的右边界,超出部分需要中心扩展验证

Manacher 算法图解

以 s = "babad" 为例:

预处理:  # b # a # b # a # d #
索引:    0 1 2 3 4 5 6 7 8 9 10

i=0: p[0]=0, center=0, right=0
i=1: 中心扩展得到 p[1]=1 ("#b#"), 更新 center=1, right=2
i=2: i<right, mirror=0, p[2]=min(0,0)=0, 扩展得 p[2]=0
i=3: i>right, 扩展得 p[3]=3 ("#b#a#b#"), 更新 center=3, right=6
i=4: i<right, mirror=2, p[4]=min(2,0)=0, 扩展得 p[4]=0
i=5: i<right, mirror=1, p[5]=min(1,1)=1, 扩展得 p[5]=1
...以此类推

最长回文:p[3]=3,原串起始位置=(3-3)/2=0,长度=3,结果为 "bab"

完整测试代码

public class StringAlgorithmDemo {
    public static void main(String[] args) {
        // KMP 测试
        KMPAlgorithm kmp = new KMPAlgorithm();
        String text = "ABABDABACDABABCABAB";
        String pattern = "ABABC";

        System.out.println("文本: " + text);
        System.out.println("模式: " + pattern);
        System.out.println("KMP首次匹配位置: " + kmp.kmpSearch(text, pattern));
        System.out.println("KMP所有匹配位置: " + kmp.kmpSearchAll(text, pattern));

        // 前缀函数测试
        int[] pi = kmp.computePrefixFunction("ABABC");
        System.out.println("前缀函数 pi: " + java.util.Arrays.toString(pi));

        // Manacher 测试
        ManacherAlgorithm manacher = new ManacherAlgorithm();
        String[] testCases = {"babad", "cbbd", "a", "ac", "bananas", "abacdfgdcaba"};

        System.out.println("\nManacher 最长回文子串测试:");
        for (String test : testCases) {
            String result = manacher.longestPalindrome(test);
            int count = manacher.countPalindromicSubstrings(test);
            System.out.println("\"" + test + "\" -> 最长回文: \"" + result + "\", 回文子串数: " + count);
        }

        // 性能对比:长文本匹配
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 100000; i++) {
            sb.append((char)('a' + (i % 26)));
        }
        String longText = sb.toString();
        String longPattern = longText.substring(50000, 50010);

        long start = System.currentTimeMillis();
        int pos = kmp.kmpSearch(longText, longPattern);
        long end = System.currentTimeMillis();
        System.out.println("\nKMP在10万字符中搜索: 位置=" + pos + ", 耗时=" + (end - start) + "ms");
    }
}

复杂度分析

算法 问题 时间复杂度 空间复杂度 关键优化
暴力匹配 子串搜索 O(m × n) O(1) 无
KMP 子串搜索 O(m + n) O(n) 前缀函数避免回溯
中心扩展 最长回文 O(n²) O(1) 无
Manacher 最长回文 O(n) O(n) 对称性 + 最右回文边界

总结

KMP 算法和 Manacher 算法分别代表了字符串模式匹配和回文检测领域的高效解法。KMP 通过前缀函数将模式串的自身结构信息编码为失配时的跳转表,实现了文本指针永不回溯的线性时间匹配。Manacher 通过分隔符技巧统一回文长度,再利用对称性和最右回文边界信息避免重复扩展,同样达到了线性时间复杂度。

这两种算法的共同启示是:预处理阶段投入 O(n) 的时间和空间,可以换来查询阶段 O(n) 甚至 O(1) 的性能提升。这种”以空间换时间”的思想在算法设计中具有普适性,也是理解更高级字符串数据结构(如后缀数组、后缀自动机)的基础。