← 返回博客列表

一、字符串算法概述

字符串是编程中最常见的数据类型之一。围绕字符串,有几类经典问题:模式匹配(在长文本中找短串出现位置)、回文判定(找最长回文子串)、子串哈希(快速比较子串是否相等)。

本文聚焦三种最常考、最实用的字符串算法:

算法 解决的问题 暴力时间 优化后时间
KMP 单模式串匹配 O(nm) O(n+m)
Manacher 最长回文子串 O(n²) O(n)
Rabin-Karp 多模式匹配 / 子串比较 O(nm) O(n+m) 平均
学习建议
1. 先理解"暴力为什么慢",再体会算法是怎么跳过不必要比较的
2. 核心是利用已匹配过的信息,避免回头——KMP 用 next 数组、Manacher 用回文对称性、Rabin-Karp 用哈希值复用

二、暴力匹配与 KMP 原理

2.1 暴力匹配为什么慢

设文本串 T 长 n,模式串 P 长 m。暴力匹配中,每当 T[i] 与 P[j] 不匹配时,i 要回退到 i-j+1,j 从 0 重新开始。这就导致已经匹配过的字符被反复比较。

暴力匹配的"回退"导致重复比较 T = a b a b a c P₁ = a b a b a b ✗ 第5位失配! P₂ = a b a → 暴力回退,从头再比
图 1:暴力匹配在失配时完全回退,已匹配的"ababa"信息被浪费

2.2 KMP 核心思想

KMP 的灵魂是:当失配发生时,利用模式串已匹配部分的"最长相等前后缀"信息,决定模式串可以跳过多少位,而文本串指针 i 从不回退。

这就需要一个 next 数组,其中 next[j] 表示:模式串第 j 位失配时,j 应该跳回到哪个下标继续比较。

2.3 next 数组构造图解

以模式串 P = "ababaca" 为例,求每个位置的最长相等前后缀长度:

next 数组构造:P = "ababaca"(最长相等前后缀长度) 下标 j: 0 1 2 3 4 5 6 P[j]: a b a b a c a j=0 (a): 无前缀后缀 → next[0] = 0 j=1 (ab): 前缀"a"≠后缀"b" → next[1] = 0 j=2 (aba): a = a 最长=1 → next[2] = 1 j=3 (abab): a b = a b 最长=2 → next[3] = 2 j=4 (ababa): a b a = a b a 最长=3 → next[4] = 3 j=5 (ababac): 尝试 3 位→失败,跳 next[2]=1→失败,跳 next[0]=0→失败 → next[5]=0 j=6 (ababaca): a = a 最长=1 → next[6] = 1 next 数组 = 0 0 1 2 3 0 1
图 2:next 数组逐位构造——找每个子串的"最长相等前后缀"长度

2.4 KMP 逐字符匹配过程

有了 next 数组后,匹配时 i 永不回退。当 T[i] 与 P[j] 失配,j 跳到 next[j-1](因为 j-1 之前是匹配好的)。

KMP 匹配:T="abababcab",P="ababaca"(next=[0,0,1,2,3,0,1]) 步骤1:i=0~4 全匹配,j=5 时 T[5]='b' vs P[5]='c' ✗ 失配 T: a b a b a b P: a b a b a c ✗ j 跳 next[4]=3 步骤2:j=3, T[5]='b' vs P[3]='b' ✓ 匹配,继续 T: a b a b P: a b a b 步骤3:i=5~7 继续匹配,j=3→4→5→6 全匹配,找到! T: a b a b c a P: a b a b c a ✓ 匹配成功!起点 = i-m+1 = 2 关键:整个过程 i 始终前进不回退,j 通过 next 数组"跳跃式"回溯
图 3:KMP 逐字符匹配——i 不回退,j 靠 next 数组智能跳跃

三、KMP 模板 + 示例:LeetCode 28 实现 strStr()

题目:给你两个字符串 haystack 和 needle,找出 needle 在 haystack 中第一次出现的位置下标,不存在返回 -1。

#include <vector>
#include <string>
using namespace std;

// 构造 next 数组:next[j] = P[0..j] 的最长相等前后缀长度
vector<int> buildNext(const string& P) {
    int m = P.size();
    vector<int> next(m, 0);
    int j = 0;  // j 指向当前已匹配的前缀长度

    for (int i = 1; i < m; ++i) {
        // 当 P[i] != P[j],j 回退到 next[j-1],直到 j=0 或匹配
        while (j > 0 && P[i] != P[j]) {
            j = next[j - 1];
        }
        if (P[i] == P[j]) {
            j++;
        }
        next[i] = j;  // 记录最长相等前后缀长度
    }
    return next;
}

// KMP 匹配:返回 needle 在 haystack 中第一次出现的下标
int strStr(const string& haystack, const string& needle) {
    int n = haystack.size(), m = needle.size();
    if (m == 0) return 0;

    vector<int> next = buildNext(needle);
    int j = 0;  // needle 的当前指针

    for (int i = 0; i < n; ++i) {
        // 失配时 j 回退,i 从不回退
        while (j > 0 && haystack[i] != needle[j]) {
            j = next[j - 1];
        }
        if (haystack[i] == needle[j]) {
            j++;
        }
        // needle 全部匹配完成
        if (j == m) {
            return i - m + 1;  // 返回匹配起点下标
        }
    }
    return -1;
}
⏱ 复杂度
构造 next 数组 O(m),匹配 O(n),总体 O(n+m)。双 while 看似嵌套,但 j 每步最多 +1,回退次数不会超过 +1 次数,均摊 O(1)。
LeetCode 28 示例:haystack="ABABDABACD", needle="ABABCABAB" T: A B A B D A B A C D P: A B A B C A B A B → 返回 5
图 4:LeetCode 28 示例——needle 在 haystack 下标 5 处首次匹配

四、Manacher 原理

4.1 问题背景

回文串正读反读一样。暴力中心扩展法要分别处理奇数长度和偶数长度回文,时间 O(n²)。Manacher(马拉车)算法通过巧妙预处理,统一奇偶回文并利用回文对称性实现 O(n)。

4.2 第一步:奇偶统一处理

在每个字符中间插入一个特殊分隔符(如 #),再在首尾加哨兵(避免越界判断):

奇偶回文统一处理:插入分隔符 原串 (奇): a b a → ^ # a # b # a # $ 长度 7(奇) 原串 (偶): a b b a → ^ # a # b # b # a # $ 长度 9(奇) 统一技巧:原回文长度 L = 新回文半径 P[i](因为每2个新字符对应原1个字符)
图 5:插入分隔符 ^#a#b#a#$ 和 ^#a#b#b#a#$,将奇偶回文统一为"以某个字符为中心的奇数长度回文"

4.3 第二步:中心扩展与回文半径递推

Manacher 维护两个关键变量:center(当前最右回文的中心)和 R(当前最右回文的右边界)。利用回文对称性,新中心 i 的初始半径可以从它关于 center 的镜像点 mirror = 2*center - i 直接继承。

Manacher 核心:利用对称性,P[i] 初始值从镜像 P[mirror] 继承 以 center 为中心的已知最大回文(右边界 R) center R mirror i mirror = 2*center - i(对称) 情况 1:i ≥ R → 无对称性可利用,P[i] = 1 开始暴力扩展 情况 2:i < R 且 P[mirror] < R-i → P[i] = P[mirror](完全对称) 小 镜像半径落在回文内 同 i 的半径 = 镜像半径 情况 3:i < R 且 P[mirror] ≥ R-i → P[i] 先取 R-i,再尝试扩展 大 镜像半径超出边界 R-i 从 R-i 起向外扩展
图 6:Manacher 三情况——根据 i 与 R 的关系、镜像半径大小决定 P[i] 初始值

五、Manacher 模板 + 示例:LeetCode 5 最长回文子串

题目:给你一个字符串 s,找到 s 中最长的回文子串。

#include <string>
#include <vector>
#include <algorithm>
using namespace std;

string longestPalindrome(const string& s) {
    if (s.empty()) return "";

    // 步骤1:预处理,插入分隔符统一奇偶回文
    // 例如 "abc" → "^#a#b#c#$"(首尾加哨兵 ^$ 防越界)
    string t = "^";
    for (char c : s) {
        t += '#';
        t += c;
    }
    t += "#$";

    int n = t.size();
    vector<int> P(n, 0);  // P[i] = 以 i 为中心的回文半径(含中心本身)
    int center = 0, R = 0;  // 当前最右回文的中心与右边界

    // 步骤2:线性计算 P 数组
    for (int i = 1; i < n - 1; ++i) {
        int mirror = 2 * center - i;  // i 关于 center 的对称点

        // 情况2&3:若 i 在 R 内,利用对称性给 P[i] 一个初始值
        if (i < R) {
            P[i] = min(R - i, P[mirror]);
        }

        // 从初始半径开始,尝试向两边扩展
        while (t[i + 1 + P[i]] == t[i - 1 - P[i]]) {
            P[i]++;
        }

        // 若 i 扩展出的右边界超过了 R,更新 center 和 R
        if (i + P[i] > R) {
            center = i;
            R = i + P[i];
        }
    }

    // 步骤3:找 P 数组最大值,还原回原串位置
    int maxLen = 0, maxCenter = 0;
    for (int i = 1; i < n - 1; ++i) {
        if (P[i] > maxLen) {
            maxLen = P[i];
            maxCenter = i;
        }
    }

    // 原串起点 = (maxCenter - maxLen) / 2
    // 推导:预处理串中位置 i,对应原串下标 i/2(向下取整)
    int start = (maxCenter - maxLen) / 2;
    return s.substr(start, maxLen);
}
示例:s = "babad",预处理后 t = "^#b#a#b#a#d#$" i: 12345678910 t[i]: # b # a # b # a # d P[i]: 0103030101 maxLen=3, maxCenter=7 → start=(7-3)/2=2, s.substr(2,3)="bad" (同样 P[4]=3 得到 "bab",两者都是最长回文) 长度还原: 预处理串中回文半径 P[i] = 原串回文长度 L
图 7:Manacher 处理 "babad" —— P[i] 最大值对应最长回文 "bab" 或 "bad"

六、Rabin-Karp 滚动哈希原理

6.1 核心思想

把字符串看作一个 B 进制数(B 通常取 26, 128, 256 或更大的质数),用这个数对大质数 M 取模作为哈希值。比较两个子串是否相等时,先比较哈希值——哈希不等必不相等,哈希相等再做精确比较(处理冲突)。

6.2 滚动哈希公式

Rabin-Karp 滚动哈希公式(B=10进制类比:数字 "1234") 初始化:H("abcd") = (a·B³ + b·B² + c·B¹ + d·B⁰) mod M a b c d = a·B³ + b·B² + c·B + d 滚动一步:H("bcde") 由 H("abcd") 推导 a b c d e → 去掉 a·B³ 其余 ×B 升位 + e (新字符) 滚动公式:H[i+1] = ( (H[i] - s[i] · Bᵐ⁻¹) · B + s[i+m] ) mod M 模运算下的减法要加 M 后再取模,避免负数 冲突处理(双重保险): ① 哈希相等时,逐字符精确比较(叫"朴素校验") ② 双哈希:用两组 (B₁,M₁) 和 (B₂,M₂) 同时哈希,同时等才认为等
图 8:Rabin-Karp 滚动哈希公式与冲突处理策略

七、Rabin-Karp 示例:LeetCode 187 重复 DNA 序列

题目:DNA 序列由 'A','C','G','T' 4 种字符构成。给定 DNA 字符串 s,找出所有长度为 10 且出现超过 1 次的子串。

这个题非常适合滚动哈希:4 种字符用 2 位二进制编码(A=0, C=1, G=2, T=3),长度 10 正好 20 位,B=4, M=4¹⁰=1048576,哈希值天然不溢出且无冲突。

#include <vector>
#include <string>
#include <unordered_map>
using namespace std;

vector<string> findRepeatedDnaSequences(const string& s) {
    vector<string> ans;
    int n = s.size();
    if (n <= 10) return ans;

    // 字符 → 2位编码:A=00, C=01, G=10, T=11
    unordered_map<char, int> mp = {{'A',0}, {'C',1}, {'G',2}, {'T',3}};

    const int L = 10;          // 目标子串长度
    const int B = 4;           // 4 进制
    const int BL = 1 << (L * 2); // B^L = 4^10 = 2^20,用于哈希滚动时"去掉最高位"
                                          // 注意:实际代码中 BL = B^(L-1) 更标准,这里用位运算简化
    const int MASK = BL - 1;   // 20 位掩码,等价于 mod BL

    // 第一步:计算前 L 个字符的哈希值
    int h = 0;
    for (int i = 0; i < L; ++i) {
        h = (h << 2) | mp[s[i]];  // h = h*B + mp[s[i]],*4 等价于左移 2 位
    }

    // 记录每个哈希值出现次数
    unordered_map<int, int> cnt;
    cnt[h] = 1;

    // 第二步:滚动哈希,O(n) 遍历
    for (int i = 1; i <= n - L; ++i) {
        // 滚动公式:
        //   1. (h << 2) :整体升一位(*B)
        //   2. & MASK  :去掉溢出的最高位(等价于 - s[i-1]·B^L mod BL)
        //   3. | mp[s[i+L-1]] :加上新字符
        h = ((h << 2) & MASK) | mp[s[i + L - 1]];

        cnt[h]++;
        // 第二次出现时才加入答案,避免重复输出
        if (cnt[h] == 2) {
            ans.push_back(s.substr(i, L));
        }
    }

    return ans;
}
⚠ 注意:MASK 的作用
本题 B=4,每字符 2 位,10 长度共 20 位。MASK = 4¹⁰ - 1 = 0xFFFFF。
左移 2 位后会有 21 位,和 MASK 做按位与就能去掉最高的两位(即滚出窗口的那个字符的贡献),等价于 mod 4¹⁰。这是"无模数、位运算优化"的经典场景。

八、总结

三种字符串算法对比

维度 KMP Manacher Rabin-Karp
核心问题 单模式串匹配(P 在 T 中位置) 最长回文子串 子串哈希 / 多模式匹配
关键数据结构 next 数组(最长相等前后缀) P 数组(回文半径)+ center/R 滚动哈希 h + B^L 预处理
核心思想 失配时 j 跳 next,i 不回退 利用回文对称性,P[i] 从镜像继承 哈希值滚动更新,O(1) 滑窗
时间复杂度 O(n+m) 最坏 O(n) 最坏 O(n+m) 平均,最坏 O(nm)(冲突多时)
空间复杂度 O(m)(next 数组) O(n)(P 数组 + 预处理串) O(1) 额外(若不需计数)
典型题目 LeetCode 28, 459(重复子串) LeetCode 5, 647(回文子串数) LeetCode 187, 1044(最长重复子串)
编码难度 中等(next 数组易写错) 中等偏难(中心/边界/还原三处易错) 简单~中等(注意取模与溢出)
面试频率 ★★★★★ ★★★★☆ ★★★★★

复习建议

🎯 一句话记忆
KMP 靠 next 不回头,Manacher 靠对称省扩展,Rabin-Karp 靠哈希 O(1) 比。
三者的共同灵魂:复用已有的计算结果,避免重复劳动——这也是所有优秀算法的底层逻辑。