📖 第 9.4 章:Manacher 与 Z 函数 ⏱️ 约 30 分钟 🥇 Gold

📖 第 9.4 章:Manacher 与 Z 函数


🎯 学习目标

学完本章后,你将能够:

  1. 用 Manacher 算法 O(N) 求最长回文子串并统计回文个数
  2. 理解 Z 函数(扩展 KMP)的定义与线性求法
  3. 掌握两者共享的「维护最右已知区间 + 镜像加速」思想

9.4.0 问题引入

本章解决两个看似不同、实则同源的问题:

问题 A(最长回文): 给定字符串 s(长度 ≤ 10⁶),求最长回文子串的长度。

s = "abababa"  → 7
s = "abababc"  → 5("ababa")

问题 B(前缀匹配): 给定模式串 P 和文本 T,统计有多少个位置 i,使得 P 与 T 从 i 开始的后缀的前 |P| 个字符完全匹配。

P = "aba", T = "ababa"   → 2(i = 0 和 i = 2)

问题 A 的朴素做法(中心扩展)是 O(N²),问题 B 的朴素做法(逐位置比较)也是 O(N²)。有趣的是,两者的线性解法——Manacher 与 Z 函数——用了完全相同的策略:维护一个「当前已知的最靠右的区间」,新位置的答案先从区间内的镜像位置照搬,不够再暴力扩展。学完一个,另一个就是换了个定义照抄。


9.4.1 Manacher 算法——O(N) 最长回文

从中心扩展说起:O(N²) 为什么超时

「求字符串 s 的最长回文子串」有一个经典做法:枚举每个中心,向两边扩展。回文串有两种中心——某个字符(奇数长度,如 "aba" 中心是 b)或某两个字符之间的空隙(偶数长度,如 "abba" 中心是两个 b 之间)。每种中心最坏扩展 O(N),总共 O(N) 个中心,复杂度 O(N²)。

当 N = 10⁶ 时(很多题就是这个量级),O(N²) 会达到 10¹² 次操作,必然超时。1975 年 Manacher 提出的算法把它降到了 O(N)。

技巧一:插入 '#' 统一奇偶

奇偶两种中心要写两份几乎相同的代码,容易出错。解决办法:在每两个字符之间以及首尾都插入分隔符 #。

原串:  a  b  a  b  a  b  c
新串:# a # b # a # b # a # b # c #

新串长度变成 2N+1(一定是奇数),所有回文的中心都变成了一个字符:

  • 原串的奇回文 "aba" → 新串 #a#b#a#,中心是 b
  • 原串的偶回文 "abba" → 新串 #a#b#b#a#,中心是 #

从此只需要处理「奇数长度」这一种情况。

p[i] 的定义与关键性质

设新串为 t,定义 p[i] = 以 t[i] 为中心的最长回文的半径(回文串为 t[i-p[i]+1 .. i+p[i]-1],即 p[i] 是「包括中心在内往右延伸的字符数」)。

一个重要结论:在原串中,以某位置为中心的最长回文长度 = 新串中对应位置的 p[i] - 1。例如 "abababc" 中新串中心在 t[7]=a(原串下标 3)处 p=6,对应原串回文 "ababa",长度 5 = 6-1。

朴素做法对每个 i 从半径 1 开始扩展,浪费了大量已经算出的信息。Manacher 的核心是利用已知回文的镜像加速。

维护当前右端点最靠右的回文串,记其中心为 id、右边界为 mx。当计算 p[i] 时,若 i < mx,说明 i 在这个大回文内部。设 j = 2*id-i 是 i 关于 id 的镜像位置,则:

p[i] ≥ min(p[j], mx - i)

为什么?

  • 若以 j 为中心的回文完全落在大回文内部(p[j] < mx-i),由对称性,以 i 为中心的回文至少和它一样长,直接 p[i] = p[j],一步都不用扩展;
  • 若以 j 为中心的回文顶到了大回文的边界(p[j] ≥ mx-i),超出部分没有对称性保证,只能确定 p[i] ≥ mx-i,之后从 mx-i 开始继续中心扩展。

若 i ≥ mx,没有任何信息可用,老老实实从半径 1 开始扩展。

为什么是 O(N)?注意每次中心扩展成功(while 循环多走一步),mx 都会随之向右移动至少 1,而 mx 从不减小、最多移动 N 次。所以所有扩展的总步数是 O(N)。

完整实现(含哨兵)

#include <bits/stdc++.h>
using namespace std;

// Manacher:O(N) 求最长回文子串长度
// 原串中以某位置为中心的最长回文长度 = 新串对应位置的 p[i] - 1
pair<string, vector<int>> manacher(const string& s) {
    // 1. 构造 "$#a#b#...#c#":开头 '$' 哨兵防越界,'#' 统一奇偶
    string t = "$#";
    for (char c : s) { t += c; t += '#'; }
    t += '^';  // 结尾哨兵
    int n = t.size();
    vector<int> p(n, 0);

    // 2. 维护当前右边界最靠右的回文串(中心 id,右端 mx)
    for (int i = 1, id = 0, mx = 0; i < n - 1; i++) {
        // 镜像加速:i 在大回文内部时,借用镜像位置 2*id-i 的信息
        p[i] = (mx > i) ? min(p[2 * id - i], mx - i) : 1;
        // 中心扩展(哨兵保证不越界:'$' 和 '^' 必不相等且不等于其它字符)
        while (t[i + p[i]] == t[i - p[i]]) p[i]++;
        // 更新最右回文
        if (i + p[i] > mx) { mx = i + p[i]; id = i; }
    }
    return {t, p};
}

int longest_palindrome(const string& s) {
    auto [t, p] = manacher(s);
    int best = 0;
    for (int i = 1; i < (int)t.size() - 1; i++) best = max(best, p[i] - 1);
    return best;
}

int main() {
    cout << longest_palindrome("abababa") << endl;  // 输出 7
    cout << longest_palindrome("abababc") << endl;  // 输出 5("ababa")
    cout << longest_palindrome("cbbd") << endl;     // 输出 2("bb")
    return 0;
}

逐步追踪:"abababc"

新串 t = $#a#b#a#b#a#b#c#^(下标 1..15 为有效部分):

i :   1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
t[i]: $  #  a  #  b  #  a  #  b  #  a  #  b  #  c  #  ^(1..15 为有效部分)
p[i]: 1  2  1  4  1  6  1  6  1  4  1  2  1  2  1

几个关键位置:

  • i=4(字符 b):扩展得 #a#b#a#,p=4,回文右端到 i+p-1=7。更新 mx=8? 不——右边界是 i+p=8,即 mx=8,id=4;
  • i=7(字符 a):在 [0,8] 内,镜像 j=1,min(p[1], 8-7)=1,从 1 扩展得 #b#a#b#a#b#,p=6,mx 更新为 13,id=7;
  • i=9(字符 b):在 [1,13] 内,镜像 j=5,min(p[5]=1, 13-9)=1,扩展得 #a#b#a#b#a#,p=6;
  • i=11(字符 a):镜像 j=3,min(p[3]=1, ...) =1,扩展得 p=4;
  • i=13(字符 b):镜像 j=1,但 13 已顶到 mx,从 min(p[1]=2, 13-13+...) 开始,扩展得 #a#b#,p=2。

最大 p 值为 6,最长回文长度 = 6-1 = 5,即 "ababa"。

⚠️ 边界哨兵

开头放 '$'、结尾放 '^'(两个都不属于原字符集、且互不相同)不是装饰:while (t[i+p[i]] == t[i-p[i]]) 故意不写下标检查。若没有哨兵,i 靠近边界时 i-p[i] 会越界到 -1、i+p[i] 会越界到 n,产生未定义行为;有了哨兵,扩展到哨兵位置必然失配停下。这是 Manacher 代码既短又快的关键——但前提是哨兵字符绝不能出现在原串中。

例题:回文子串计数

问题:统计字符串 s 中回文子串的个数(子串按位置区分,如 "aaa" 有 6 个:3 个 "a"、2 个 "aa"、1 个 "aaa")。

思路:以每个位置为中心的回文串个数恰好是 p[i] / 2(新串中半径 p[i] 的回文,对应原串中 p[i]/2 个回文:从最长的开始每次长度减 2)。把所有中心加起来即可,复杂度 O(N)。

#include <bits/stdc++.h>
using namespace std;

long long count_palindromes(const string& s) {
    string t = "$#";
    for (char c : s) { t += c; t += '#'; }
    t += '^';
    int n = t.size();
    vector<int> p(n, 0);
    for (int i = 1, id = 0, mx = 0; i < n - 1; i++) {
        p[i] = (mx > i) ? min(p[2 * id - i], mx - i) : 1;
        while (t[i + p[i]] == t[i - p[i]]) p[i]++;
        if (i + p[i] > mx) { mx = i + p[i]; id = i; }
    }
    long long ans = 0;
    for (int i = 1; i < n - 1; i++) ans += p[i] / 2;
    return ans;
}

int main() {
    cout << count_palindromes("abababc") << endl;  // 输出 13
    cout << count_palindromes("aaa") << endl;      // 输出 6
    cout << count_palindromes("abc") << endl;      // 输出 3
    return 0;
}

验证 "abababc":单字符 7 个 + "aba"/"bab" 共 4 个 + "ababa"/"babab" 共 2 个 = 13 ✓。


9.4.2 Z 函数(扩展 KMP)

引入:另一种「自我匹配」

回忆第 9.2 章的前缀函数:π[i] 描述的是**「以 i 结尾的后缀」与「整个串的前缀」**的最长匹配。现在换一个视角——我们不看「以 i 结尾的」,而看「以 i 开头的」:

z[i] = 后缀 s[i..n-1] 与整个串 s 的最长公共前缀(LCP)的长度,规定 z[0] = 0(或 n,看实现约定,本书取 0)。

对比一下:

  • π[i]:s 的前缀 vs s[0..i] 的后缀——「前缀 vs 后缀」
  • z[i]:s 的前缀 vs s[i..] 的前缀——「前缀 vs 前缀」

两者信息等价(可以 O(N) 互相推导),但 Z 函数的定义更直观:z[i] 就是「从 i 开始往后,能和串开头匹配多长」。

几个例子(下标从 0 开始):

z("aaaaa")  = [0, 4, 3, 2, 1]
z("aaabaab") = [0, 2, 1, 0, 2, 1, 0]
z("abacaba") = [0, 0, 1, 0, 3, 0, 1]

比如 "abacaba":z[4]=3,因为从下标 4 开始的后缀 "aba" 恰好与原串前缀 "aba" 匹配 3 个字符。

Z-box 与线性算法

朴素做法对每个 i 从 0 开始逐字符比较,最坏 O(N²)(如 "aaaaa")。加速的思路和 Manacher 惊人地相似:维护已经匹配到的最右端,利用已算出的信息。

称区间 [i, i+z[i]-1] 为 i 的匹配段(Z-box)。算法过程中维护右端点最靠右的 Z-box,记为 [l, r]——它的含义是:s[l..r] 与 s 的前缀完全相同。

计算 z[i] 时(i 从 1 到 n-1):

  • 若 i < r:i 落在 Z-box 内。由 s[l..r] = s[0..r-l],位置 i 在 box 内对应于前缀中的位置 i-l,因此

    z[i] ≥ min(z[i-l], r - i)
    
    • 若 z[i-l] < r-i:以 i-l 为中心的匹配段没有顶到 box 右端,整个信息可以照搬,z[i] = z[i-l],一步不用比较;
    • 否则只能确定 z[i] ≥ r-i,从 r-i 开始暴力扩展。
  • 若 i ≥ r:没有信息,从 0 开始暴力扩展。

算出 z[i] 后,若 i + z[i] > r,更新 l = i, r = i + z[i]。

复杂度:暴力扩展每成功一步,r 就向右移至少 1,而 r 只增不减且不超过 n,所以总扩展次数 O(N),整体 O(N)。

完整实现

#include <bits/stdc++.h>
using namespace std;

// Z 函数(扩展 KMP):z[i] = s 与 s[i..] 的最长公共前缀长度,z[0] = 0
vector<int> z_function(const string& s) {
    int n = s.size();
    vector<int> z(n, 0);
    for (int i = 1, l = 0, r = 0; i < n; i++) {
        // i 在 Z-box [l,r) 内:借用镜像位置 i-l 的 z 值
        if (i < r) z[i] = min(r - i, z[i - l]);
        // 暴力扩展
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        // 更新最右 Z-box
        if (i + z[i] > r) { l = i; r = i + z[i]; }
    }
    return z;
}

int main() {
    for (const string& s : {"aaaaa", "aaabaab", "abacaba"}) {
        auto z = z_function(s);
        cout << s << ": ";
        for (int x : z) cout << x << " ";
        cout << endl;
    }
    return 0;
}

输出:

aaaaa: 0 4 3 2 1
aaabaab: 0 2 1 0 2 1 0
abacaba: 0 0 1 0 3 0 1

逐步追踪:"aaaaa"

i=1: i>r(r=0),暴力扩展:s[0..3] vs s[1..4] 全匹配,z[1]=4。
     更新 l=1, r=5。
i=2: i<r,z[2] = min(5-2, z[1]=4) = 3。i+z[2]=5 不大于 r=5,不再扩展。
i=3: i<r,z[3] = min(5-3, z[2]=3) = 2。
i=4: i<r,z[4] = min(5-4, z[3]=2) = 1。

结果 [0,4,3,2,1]——注意 i≥2 之后一次比较都没做,全部靠镜像直接照搬。

与 KMP 对照

前缀函数(KMP,第 9.2 章)Z 函数(扩展 KMP)
定义π[i] = s[0..i] 的最长 border(前缀 = 后缀)z[i] = s[i..] 与 s 的最长公共前缀
视角以 i 结尾的匹配以 i 开头的匹配
维护的区间「失配指针」链 π[π[...]]最右 Z-box [l, r]
求模式串出现拼接 P#T,找 π[i] = |P|拼接 P#T,找 z[i] = |P|
复杂度O(N)O(N)
适用偏好失配跳转、border 树上做 DP「每个后缀 vs 前缀」的直观统计

经验法则:题目若问「后缀与前缀的关系」(如最短周期 i + z[i] == n、后缀匹配计数),Z 函数往往写起来更直白;若要在 border 链上做递推 DP,KMP 的 π 数组更顺手。

例题:border 式匹配计数

问题:给定模式串 P 和文本 T(|P|, |T| ≤ 10⁶),求 T 中有多少个位置 i,使得 T 从 i 开始的后缀以 P 为前缀(即 P 与 T 的该后缀的前 |P| 个字符完全匹配)。

思路:和 KMP 匹配一样用拼接技巧。构造 s = P + '#' + T,求 Z 函数。对 T 的每个起点 i,对应 s 中下标 i + |P| + 1,若 z[i+|P|+1] == |P|,则该位置匹配。分隔符 '#' 保证 z 值不会「穿过」边界(P 和 T 中都不含 '#')。复杂度 O(|P| + |T|)。

#include <bits/stdc++.h>
using namespace std;

vector<int> z_function(const string& s) {
    int n = s.size();
    vector<int> z(n, 0);
    for (int i = 1, l = 0, r = 0; i < n; i++) {
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        if (i + z[i] > r) { l = i; r = i + z[i]; }
    }
    return z;
}

long long count_matches(const string& P, const string& T) {
    auto z = z_function(P + '#' + T);
    int m = P.size();
    long long cnt = 0;
    for (int i = m + 1; i < (int)z.size(); i++)
        if (z[i] == m) cnt++;
    return cnt;
}

int main() {
    cout << count_matches("ab", "ababab") << endl;   // 输出 3(下标 0、2、4)
    cout << count_matches("aba", "ababa") << endl;   // 输出 2(下标 0、2)
    cout << count_matches("xyz", "abcabc") << endl;  // 输出 0
    return 0;
}

⚠️ 常见错误

错误原因修复方案
Manacher 哨兵缺失或与原字符冲突while 循环不检查下标,越界为未定义行为开头放 '$'、结尾放 '^',且保证不在原串中出现
Manacher 忘记回文长度 = p[i] - 1新串长度 2N+1,半径换算回来要减 1记住结论:原串回文长度 = p[i] - 1
Z 函数边界用 i <= r 且照搬时多扩展z[i-l] 顶到 box 右端时照搬结果错误用 i < r(r 为开区间端点)或在 z[i-l] < r-i 时才直接赋值
z[0] 约定混乱有的实现取 z[0]=n,有的取 0全书统一取 z[0] = 0,拼接匹配从下标 m+1 开始找
拼接时分隔符出现在 P 或 T 中z 值可能穿过边界导致错误匹配选字符集中不存在的分隔符

💪 练习题(本章 2 道,全部含完整解答)

🟡 进阶练习

题目 1:最长回文子串
给定字符串 s(长度 ≤ 10⁶),求最长回文子串的长度。要求 O(N)。

示例:

s = "abababa"  → 7
s = "abababc"  → 5("ababa")

思路: Manacher 算法。插入 # 统一奇偶后求半径数组 p,答案为 max(p[i]) - 1。

#include <bits/stdc++.h>
using namespace std;

int main() {
    string s; cin >> s;
    string t = "$#";
    for (char c : s) { t += c; t += '#'; }
    t += '^';
    int n = t.size();
    vector<int> p(n, 0);
    int best = 0;
    for (int i = 1, id = 0, mx = 0; i < n - 1; i++) {
        p[i] = (mx > i) ? min(p[2 * id - i], mx - i) : 1;
        while (t[i + p[i]] == t[i - p[i]]) p[i]++;
        if (i + p[i] > mx) { mx = i + p[i]; id = i; }
        best = max(best, p[i] - 1);
    }
    cout << best << "\n";
    return 0;
}

🔴 挑战练习

题目 2:两字符串 border 计数
给定字符串 P 和 T(长度 ≤ 10⁶),统计有多少个位置 i(0 ≤ i < |T|),使得 P 恰好是「T 从 i 开始的后缀」的前缀(即 P 与 T[i..i+|P|-1] 完全匹配)。要求 O(|P| + |T|)。

示例:

P = "aba", T = "ababa"   → 2(i = 0 和 i = 2)
P = "aa",  T = "aaaa"    → 3(i = 0、1、2)

思路: 拼接 P + '#' + T 求 Z 函数,统计拼接串中 z[i] == |P| 的个数。分隔符防止 z 值跨越 P 与 T 的边界。

#include <bits/stdc++.h>
using namespace std;

vector<int> z_function(const string& s) {
    int n = s.size();
    vector<int> z(n, 0);
    for (int i = 1, l = 0, r = 0; i < n; i++) {
        if (i < r) z[i] = min(r - i, z[i - l]);
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        if (i + z[i] > r) { l = i; r = i + z[i]; }
    }
    return z;
}

int main() {
    string P, T;
    cin >> P >> T;
    auto z = z_function(P + '#' + T);
    int m = P.size();
    long long cnt = 0;
    for (int i = m + 1; i < (int)z.size(); i++)
        if (z[i] == m) cnt++;
    cout << cnt << "\n";
    return 0;
}

注: 若改成统计「T 的每个前缀中有多少个 border 等于 P」之类的变体,则更适合同样在拼接串上做 KMP / Z 函数后附加计数数组,思路相同。


📋 本章总结

  • Manacher:插 # 统一奇偶 + 哨兵防越界 + 镜像加速,O(N) 求最长回文
  • Z 函数:z[i] = 后缀与前缀的最长匹配,最右 Z-box 镜像照搬,O(N)
  • 两者共享同一个思想:维护最靠右的已知区间,能照搬就照搬,不够再暴力扩展
  • 「拼接 P#T」技巧在 KMP(第 9.2 章)和 Z 函数中通用

💡 章节联系: Z 函数是 第 9.2 章 KMP的镜像兄弟——一个看「以 i 结尾」的匹配,一个看「以 i 开始」的匹配。想同时匹配多个模式串时,单模式的 KMP / Z 函数都不够用,请前往第 9.5 章:AC 自动机。若需要 O(1) 判断任意子串是否回文,Manacher 的 p 数组还可以与第 3.7 章的字符串哈希配合使用。