Manacher 算法

利用回文串的「镜像」特点减少计算。

引理 0

\(S\) 是一个长度为 \(n+1\) 回文串,下标从 \(0\) 开始;\(T = S[l, r]\)\(S\) 的子串。\(T\) 是回文串当且仅当 \(S[n-r, n-l]\) 是回文串。

先考虑长度为奇数的回文子串(简称为「奇回文子串」),可以求出以每个下标为中心的最长奇回文子串的长度。

\(P_i\) 表示以下标 \(i\) 为中心的最长回文子串,用 \(f(i)\) 表示 \(P_i\) 的长度,即 \(f(i) = |P_i|\)

\(L_i, R_i\) 分别表示 \(P_i\) 的左半部分和右半部分。用 \(l_i,r_i\) 分别表示 \(P_i\) 的左右端点的下标。

引理 1

\(i\)\(j\) 是两个下标,若 $ i < j $ 且 $r_j \le r_i $ 则 $ P_j \subseteq P_i $;


分割线以上的内容现在读已不知所云;读者请忽略。

字符串下标从 0 开始,\(S[0, n)\)
假设当前以下标 \(i\) 为中心,考虑在 \(i\) 之前是否有「镜子」\(j\) 使得 \(i\) 对着 \(j\) 能照出自己。换言之是否存在 \(j<i\) 使得以 \(j\) 为中心的回文子串能够「波及」\(i\) 。用式子表示就是 $f(j) \ge 2(i-j) + 1 $ 或者 \(j + f(j)/2 \ge i\)\(i\) 关于 \(j\) 的镜像为 \(2j -i\)

我们希望 \(j\) 不仅能「照出」\(i\) 还要能「照出」\(i\) 右边尽量多的字符,换言之「照得尽量远」。用式子表示就是 $ j + f(j)/2$ 尽可能大。

回文串在镜像操作下保持不变。
\(j + f(j)/2 \ge i\) 那么区间 \([j-f(j)/2, j+f(j)/2]\) 中以 \(i\) 为中心的最长回文子串区间\([j - f(j)/2 , j + f(j)/2]\) 中以 \(2j-i\) 为中心的最长回文子串

理解这个性质是理解 Manacher 算法的关键,我一直绕不过来。


对字符串进行镜像操作

示意图

字符串的镜像操作相当于序列反转(reverse),因此回文串在镜像操作下保持不变。

\(S[j-f(j)/2, j+f(j)/2]\) 中的任意子串都有一个关于 \(j\) 的镜像串(即反转串)。

Manacher 算法原理示意图

注解

\(i\) 是当前考虑的下标,\(j\)\(i\) 之前的某个下标,满足:\(j + f(j) \ge i\)\(j + f(j)\) 最大。
要求出以 \(i\) 为中心的最长奇回文子串,我们考虑 \(i\) 关于 \(j\) 的对称点(或称「镜像点」)\(i'\),显然 \(i' < j < i\) 。以 \(i'\) 为中心的最长奇回文子串已经求出了,是 \(S[l_{i'}, r_{i'}]\),即图中黄色的部分(包括最左侧浅黄色那一段和右侧深黄色那一段)。注意到,必然有 \(r_{i'} \le r_j\),这是因为 \(j\)\(i\) 之前的下标中使得以其为中心的最长奇回文字串的右端点下标最大的那个点,于是很容易看出:以 \(i'\) 为中心的最长奇回文字串 \(S[l_{i'}, r_{i'}]\) 的右半部分 \(S[i', r_{i'}]\) 完全落在区间 \([l_j, r_j]\) 上,因此 \(S[i', r_{i'}]\) 关于 \(j\) 的镜像也完全落在区间 \([l_j, r_j]\) 上。

\(S[l_{i'}, r_{i'}]\)\(S[l_j, r_j]\) 的交集(即图中深黄色那一段)关于点 \(j\) 做镜像操作,所得的字符串就是图中橙色的那一段(或者说橙色的那一段是深黄色的那一段关于 \(j\) 的镜像串)。

橙色那一段的右端点即 \(\min(r_j, 2j - l_{i'})\)
\(\min(r_j, 2j - l_{i'}) + 1\) 这个位置开始检查,看以 \(i\) 为中心的奇回文串能否扩展。

\(i \ne j\) 是两下标,用 \(\mathsf{Mirror}(i, j)\) 表示 \(i\) 关于 \(j\) 的对称位置,即 \(\mathsf{Mirror}(i, j) := 2j - i\) 。于是有 $ 2j - l_{i'} = \mathsf{Mirror}(l_{i'}, j)$ 。

性质 1

令 $ r = \min(r_j, \mathsf{Mirror}(l_{i'}, j)) \(,有 \)S[\mathsf{Mirror}(r, j), r] = P_i \bigcap P_j$

性质 2

长为 \(n\) 的串至多有 \(n\) 个不同的回文子串。

复杂度

观察上图。

\(O(1)\) 地计算出 \(|P_i \bigcap P_j|\) ;若 \(P_i \bigcap P_j\) 能向两侧扩展,那么右边界(border, frontier)将增大;右边界是单调不减的,因此扩展的总复杂度为 \(O(n)\) 。于是 Manacher 算法的复杂度为 \(O(n)\)

实现

void manacher (char str [], int h[], int n) {
    int m = 0;
    static char buf[M]; // M是字符串最大长度的两倍。
    //以'#'开头
    for (int i = 0; i < n; ++i){
        buf[m++] = '#', buf[m++] = str[i];
    }
    buf[m++] = '#'; // 以'#'结尾
    buf[m] = '\0'; // 必须补零!

    // r:右边界,mid:与 r 对应的中点;    
    // 这里的 r 总比上面分析中的 r 大 1;也可理解为采用“左闭右开”的区间表示
    for (int i = 0, r = 0, mid = 0; i < m; ++i) { // mid 不必初始化,可随意赋初值
        h[i] = i < r ? std::min(r - i, h[2 * mid - i]) : 0;
        while (h[i] <= i && buf[i - h[i]] == buf[i + h[i]])
            ++h[i];
        if (r < i + h[i]) r = i + h[i], mid = i;
    }
}

加了一个也许有点用的小小的优化

void manacher (char str [], int h[], int n) {
    int m = 0;
    static char buf[M]; // M是字符串最大长度的两倍。
    //以'#'开头
    for (int i = 0; i < n; ++i){
        buf[m++] = '#', buf[m++] = str[i];
    }
    buf[m++] = '#'; // 以'#'结尾
    buf[m] = '\0'; // 必须补零!
    
    // r:右边界,mid:与 r 对应的中点;    
    // 这里的 r 总比上面分析中的 r 大 1;也可理解为采用“左闭右开”的区间表示
    for (int i = 0, r = 0, mid = 0; i < m; ++i) { // mid 不必初始化,可随意赋初值
        h[i] = i < r ? std::min(r - i, h[2*mid - i]) : 0;
        if(h[i] == r - i) { // 一个小小的优化,若不满足此条件则必然无法再扩展
            while (h[i] <= i && buf[i - h[i]] == buf[i + h[i]])
                ++h[i];
            if (r < i + h[i]) r = i + h[i], mid = i;
        }
    }
}

数组 \(h\) 的性质

将原字符串长度记为 \(n\),则数组 \(h\) 长为 \(2n+1\)

原字符串的最长回文子串的长度即 \(h\) 的最大值减 \(1\)

对于 $ 0 \le i \le 2n $,当 \(i\) 为偶数时 \(h[i] - 1\) 表示原字符串中「右半边起点的下标为 \(i/2\)」的最长回文串的长度;当 \(i\) 为奇数时 \(h[i] - 1\) 表示原字符串中「中心点下标为 \(\lfloor i/2 \rfloor\)」的最长回文串的长度。

Resource

Manacher 算法的不需要补特殊字符的实现

CP-Algorithms 上关于 Manacher 算法的条目

posted @ 2018-08-07 18:34  Pat  阅读(218)  评论(0编辑  收藏  举报