OSDN Git Service

refactor: rewrite Manacher page
authorLeoJacob <lzy4575@126.com>
Tue, 6 Nov 2018 14:20:31 +0000 (22:20 +0800)
committerLeoJacob <lzy4575@126.com>
Tue, 6 Nov 2018 14:20:31 +0000 (22:20 +0800)
Rewrite Manacher page.

Most of its content comes from English version of "Finding all sub-palindromes in O(N)" page originated from e-maxx.

Some content and problems in the old page are rewrited and kept in the new page.

docs/string/manacher.md

index bcb05ff..1dd07ba 100644 (file)
-## 前言
+## 描述
 
-马拉车算法(Manacher's algorithm)是一个求一个字符串中最长回文连续子序列的算法
+给定一个长度为 $n$ 的字符串 $s$,请找到所有对 $(i, j)$ 使得子串 $s[i \dots j]$ 为一个回文串。当 $t = t_{\text{rev}}$ 时,字符串 $t$ 是一个回文串($t_{\text{rev}}$ 是 $t$ 的反转字符串)。
 
-## æ­£æ\96\87
+## æ\9b´è¿\9bä¸\80æ­¥ç\9a\84æ\8f\8fè¿°
 
-[【模板】manacher 算法](https://www.luogu.org/problemnew/show/P3805)
+显然在最坏情况下可能有 $O(n^2)$ 个回文串,因此似乎一眼看过去该问题并没有线性算法。
 
-题意是求 S 中的最长回文串
+但是关于回文串的信息可用**一种更紧凑的方式**表达:对于每个位置 $i = 0 \dots n - 1$,我们找出值 $d_1[i]$ 和 $d_2[i]$。二者分别表示以位置 $i$ 为中心的长度为奇数和长度为偶数的回文串个数。
 
-最暴力的做法当然是枚举 l 和 r,对于每个 l 和 r 求遍历一遍判断是否为回文
+举例来说,字符串 $s = \mathtt{abababc}$ 以 $s[3] = b$ 为中心有三个奇数长度的回文串,也即 $d_1[3] = 3$:
 
-时间复杂度达到$O(n^3)$,显然做不了这题
+$$
+a\ \overbrace{b\ a\ \underset{s_3}{b}\ a\ b}^{d_1[3]=3}\ c
+$$
 
\9c¨è¿\99个å\9fºç¡\80ä¸\8aç¨\8då¾®ä¼\98å\8c\96ä¸\80ä¸\8bï¼\8cä¹\9fæ\98¯å¾\88æ\98¾ç\84¶ç\9a\84å\81\9aæ³\95ï¼\9aé\95¿åº¦ä¸ºå¥\87æ\95°å\9b\9eæ\96\87串以æ\9c\80中é\97´å­\97符ç\9a\84ä½\8d置为对称轴左å\8f³å¯¹ç§°ï¼\8cè\80\8cé\95¿åº¦ä¸ºå\81¶æ\95°ç\9a\84å\9b\9eæ\96\87串ç\9a\84对称轴å\9c¨ä¸­é\97´ä¸¤ä¸ªå­\97符ä¹\8bé\97´ç\9a\84空é\9a\99ã\80\82å\8f¯ä»¥é\81\8då\8e\86è¿\99äº\9b对称轴ï¼\8cå\9c¨æ¯\8f个对称轴ä¸\8aå\90\8cæ\97¶å\90\91å·¦å\92\8cå\90\91å\8f³æ\89©å±\95ï¼\8cç\9b´å\88°å·¦å\8f³ä¸¤è¾¹ç\9a\84å­\97符ä¸\8då\90\8cæ\88\96è\80\85å\88°è¾¾è¾¹ç\95\8cã\80\82
­\97符串 $s = \mathtt{cbaabd}$ ä»¥ $s[3] = a$ ä¸ºä¸­å¿\83æ\9c\89两个å\81¶æ\95°é\95¿åº¦ç\9a\84å\9b\9eæ\96\87串ï¼\8cä¹\9få\8d³ $d_2[3] = 2$ï¼\9a
 
-这样的复杂度是$O(n^2)$,还是过不了
+$$
+c\ \overbrace{b\ a\ \underset{s_3}{a}\ b}^{d_2[3]=2}\ d
+$$
 
-观察数据范围,$S.length()\leq11000000$,应该需要一个$O(n)$及以下的算法
+因此关键思路是,如果以某个位置 $i$ 为中心,我们有一个长度为 $l$ 的回文串,那么我们有以 $i$ 为中心的长度为 $l - 2$,$l - 4$,等等的回文串。所以 $d_1[i]$ 和 $d_2[i]$ 两个数组已经足够表示字符串中所有子回文串的信息。
 
-dalao 云
+一个令人惊讶的事实是,存在一个复杂度为线性并且足够简单的算法计算上述两个“回文性质数组” $d_1[]$ 和 $d_2[]$。在这篇文章中我们将详细的描述该算法。
 
-> 暴力算法的优化是信息的利用和对重复搜索的去重
+## 解法
 
\88\91们è\80\83è\99\91å¦\82ä½\95å\88©ç\94¨è¿\99é\87\8cç\9a\84é\87\8då¤\8dä¿¡æ\81¯
\80»ç\9a\84æ\9d¥è¯´ï¼\8c该é\97®é¢\98å\85·æ\9c\89å¤\9aç§\8d解æ³\95ï¼\9aåº\94ç\94¨å­\97符串å\93\88å¸\8cï¼\8c该é\97®é¢\98å\8f¯å\9c¨ $O(n \log n)$ æ\97¶é\97´å\86\85解å\86³ï¼\8cè\80\8c使ç\94¨å\90\8eç¼\80æ\95°ç»\84å\92\8cå¿«é\80\9f LCA è¯¥é\97®é¢\98å\8f¯å\9c¨ $O(n)$ æ\97¶é\97´å\86\85解å\86³ã\80\82
 
-第一种解法,是直接暴力计算
+但是这里描述的算法**压倒性**的简单,并且在时间和空间复杂度上具有更小的常数。该算法由**Glenn K. Manacher**在 1975 年提出。
 
-而第二种解法,是利用字符串对称的性质,把枚举端点变成枚举中点,少了一个循环,优化掉$O(n)$的复杂度
+## 朴素算法
 
-我们所求的$O(n)$算法,是不是能由第二种解法再利用一次字符串对称的性质得来呢?
+为了避免在之后的叙述中出现歧义,这里我们指出什么是“朴素算法”。
 
§\82å¯\9fä¸\8bé\9d¢ç\9a\84å­\97符串:
¯¥ç®\97æ³\95é\80\9aè¿\87ä¸\8bè¿°æ\96¹å¼\8få·¥ä½\9cï¼\9a对æ¯\8f个中å¿\83ä½\8dç½® $i$ï¼\8cå\9c¨æ¯\94è¾\83ä¸\80对对åº\94å­\97符å\90\8eï¼\8cå\8fªè¦\81å\8f¯è\83½ï¼\8c该ç®\97æ³\95便å°\9dè¯\95å°\86ç­\94æ¡\88å\8a  $1$ã\80\82
 
-$O A K A B A K A B A O A K$
+该算法是比较慢的:它只能在 $O(n^2)$ 的时间内计算答案。
 
-其中的最长回文串为$ABAKABA$,若使用第二种解法,有什么可以不计算的呢?
+该朴素算法的实现如下:
 
-相信很容易猜到,一个回文串的左半边有一个回文串,那它的右半边也有一个,那么我们对这个回文串的计算显然可以略去
+```c++
+vector<int> d1(n),  d2(n);
+for (int i = 0; i < n; i++) {
+  d1[i] = 1;
+  while (0 <= i - d1[i] && i + d1[i] < n && s[i - d1[i]] == s[i + d1[i]]) {
+    d1[i]++;
+  }
 
-扩展到一般性质,若一个回文串里包含着另一个回文串,那这个回文串的另一边必然存在另一个与它一模一样的回文串!
+  d2[i] = 0;
+  while (0 <= i - d2[i] - 1 && i + d2[i] < n && s[i - d2[i] - 1] == s[i + d2[i]]) {
+    d2[i]++;
+  }
+}
+```
 
-由此我们来改进第二种算法
+## Manacher 算法
 
-这个$O(n^2)$算法有什么缺点呢?
+这里我们将只描述算法中寻找所有奇数长度子回文串的情况,即只计算 $d_1[]$;寻找所有偶数长度子回文串的算法(即计算数组 $d_2[]$)将只需对奇数情况下的算法进行一些小修改。
 
-1. 回文串长度的奇偶性造成了对称轴的位置可能在某字符上,也可能在两个字符之间的空隙处,要对两种情况分别处理
+为了快速计算,我们维护已找到的子回文串的最靠右的**边界 $(l, r)$**(即具有最大 $r$ 值的回文串)。初始时,我们置 $l = 0$ 和 $r = -1$。
 
-如何解决?我们可以强行在原字符串中插入其他本字符串不会出现的字符,如 "#"
+现在假设我们要对下一个 $i$ 计算 $d_1[i]$,而之前所有 $d_1[]$ 中的值已计算完毕。我们将通过下列方式计算:
 
-也就是说,若原来的字符串是这样
+-   如果 $i$ 位于当前子回文串之外,即 $i > r$,那么我们调用朴素算法。
 
-![](./images/manacher1.png)
+    因此我们将连续的增加 $d_1[i]$,同时在每一步中检查当前的子串 $[i - d_1[i] \dots i + d_1[i]]$ 是否为一个回文串。如果我们找到了第一处对应字符不同,又或者碰到了 $s$ 的边界,则算法停止。在两种情况下我们均已计算完 $d_1[i]$。此后,仍需记得更新 $(l, r)$。
 
-那么我们把它改成这样
+-   现在考虑 $i \le r$ 的情况。我们将尝试从已计算过的 $d_1[]$ 的值中获取一些信息。首先在子回文串 $(l, r)$ 中反转位置 $i$,即我们得到 $j = l + (r - i)$。现在来考察值 $d_1[j]$。因为位置 $j$ 同位置 $i$ 对称,我们**几乎总是**可以置 $d_1[i] = d_1[j]$。该想法的图示如下(可认为以 $j$ 为中心的回文串被“拷贝”至以 $i$ 为中心的位置上):
 
-![](./images/manacher2.png)
+    $$
+    \ldots\ 
+    \overbrace{
+        s_l\ \ldots\ 
+        \underbrace{
+            s_{j-d_1[j]+1}\ \ldots\ s_j\ \ldots\ s_{j+d_1[j]-1}
+        }_\text{palindrome}\ 
+        \ldots\ 
+        \underbrace{
+            s_{i-d_1[j]+1}\ \ldots\ s_i\ \ldots\ s_{i+d_1[j]-1}
+        }_\text{palindrome}\ 
+        \ldots\ s_r
+    }^\text{palindrome}\ 
+    \ldots
+    $$
+  
+    然而有一个**棘手的情况**需要被正确处理:当“内部”的回文串到达“外部”回文串的边界时,即 $j - d_1[j] + 1 \le l$(或者等价的说,$i + d_1[j] - 1 \ge r$)。因为在“外部”回文串范围以外的对称性没有保证,因此直接置 $d_1[i] = d_1[j]$ 将是不正确的:我们没有足够的信息来断言在位置 $i$ 的回文串具有同样的长度。
 
-关于这部分的代码:
+    实际上,为了正确处理这种情况,我们应该“截断”回文串的长度,即置 $d_1[i] = r - i$。之后我们将运行朴素算法以尝试尽可能增加 $d_1[i]$ 的值。
 
-```cpp
-inline void change() {
-  s[0] = s[1] = '#';
-  for (int i = 0; i < n; i++) {
-    s[i * 2 + 2] = a[i];
-    s[i * 2 + 3] = '#';
-  }
-  n = n * 2 + 2;
-  s[n] = 0;
-}
-```
+    该种情况的图示如下(以 $j$ 为中心的回文串已经被截断以落在“外部”回文串内):
 
-这样我们就可以直接以每个字符为对称轴进行扩展了
+    $$
+    \ldots\ 
+    \overbrace{
+        \underbrace{
+            s_l\ \ldots\ s_j\ \ldots\ s_{j+(j-l)}
+        }_\text{palindrome}\ 
+        \ldots\ 
+        \underbrace{
+            s_{i-(r-i)}\ \ldots\ s_i\ \ldots\ s_r
+        }_\text{palindrome}
+    }^\text{palindrome}\ 
+    \underbrace{
+        \ldots \ldots \ldots \ldots \ldots
+    }_\text{try moving here}
+    $$
 
-2. 会出现很多子串被重复多次访问,时间效率大幅降低
+    该图示显示出,尽管以 $j$ 为中心的回文串可能更长,以致于超出“外部”回文串,但在位置 $i$,我们只能利用其完全落在“外部”回文串内的部分。然而位置 $i$ 的答案可能比这个值更大,因此接下来我们将运行朴素算法来尝试将其扩展至“外部”回文串之外,也即标识为 "try moving here" 的区域
 
-这个就是我们刚刚提出的优化了
+最后,仍有必要提醒的是,我们应当记得在计算完每个 $d_1[i]$ 后更新值 $(l, r)$
 
-我们用一个辅助数组$hw_i$表示$i$点能够扩展出的回文长度
+同时,再让我们重复一遍:计算偶数长度回文串数组 $d_2[]$ 的算法同上述计算奇数长度回文串数组 $d_1[]$ 的算法十分类似。
 
-æ\88\91们å\85\88设置ä¸\80个è¾\85å\8a©å\8f\98é\87\8f$maxright$ï¼\8c表示已ç»\8f触å\8f\8aå\88°ç\9a\84æ\9c\80å\8f³è¾¹ç\9a\84å­\97符
+## Manacher ç®\97æ³\95ç\9a\84å¤\8dæ\9d\82度
 
-以及一个辅助变量$mid$,表示包含$maxright$的回文串的对称轴所在的位置
+因为在计算一个特定位置的答案时我们总会运行朴素算法,所以一眼看去该算法的时间复杂度为线性的事实并不显然。
 
-也就是这样:
+然而更仔细的分析显示出该算法具有线性复杂度。此处我们需要指出,[计算 Z 函数的算法](z-function.md)和该算法较为类似,并同样具有线性时间复杂度。
 
-![](./images/manacher3.png)
+实际上,注意到朴素算法的每次迭代均会使 $r$ 增加 $1$,以及 $r$ 在算法运行过程中从不减小。这两个观察告诉我们朴素算法总共会进行 $O(n)$ 次迭代。
 
-当 i 在 maxright 左边且在 mid 右边时:
+Manacher 算法的另一部分显然也是线性的,因此总复杂度为 $O(n)$。
 
-设 i 关于 mid 的对称点为 j,显然$hw_i$一定不会小于$hw_j$。
+## Manacher 算法的实现
 
-我们没必要保存 j,j 可以通过计算得出,为$mid+(mid-i)=(mid\times2)-i$
+### 分类讨论
 
-那么我们就将$hw_i$设为$hw_j$,从$i+hw_i$开始扩展(利用已知信息),这样就可以较快地求出 hw[i],然后重新 maxright 和 mid
+为了计算 $d_1[]$,我们有以下代码:
 
-当$i$在$maxright$右边时,我们无法得知关于$hw_i$的信息,只好从 1 开始遍历,然后更新$maxright$和$mid$
+```c++
+vector<int> d1(n);
+for (int i = 0, l = 0, r = -1; i < n; i++) {
+  int k = (i > r) ? 1 : min(d1[l + r - i], r - i);
+  while (0 <= i - k && i + k < n && s[i - k] == s[i + k]) {
+    k++;
+  }
+  d1[i] = k--;
+  if (i + k > r) {
+    l = i - k;
+    r = i + k;
+  }
+}
+```
 
¿\99é\83¨å\88\86ç\9a\84代ç \81ä¹\9fæ\98¯é\9d\9e常ç®\80ç\9f­ç\9a\84:
®¡ç®\97 $d_2[]$ ç\9a\84代ç \81å\8d\81å\88\86类似ï¼\8cä½\86æ\98¯å\9c¨ç®\97æ\9c¯è¡¨è¾¾å¼\8fä¸\8aæ\9c\89äº\9b许ä¸\8då\90\8cï¼\9a
 
-```cpp
-inline void manacher() {
-  int maxright = 0, mid;
-  for (int i = 1; i < n; i++) {
-    if (i < maxright)
-      hw[i] = min(hw[(mid << 1) - i], hw[mid] + mid - i);
-    else
-      hw[i] = 1;
-    while (s[i + hw[i]] == s[i - hw[i]]) ++hw[i];
-    if (hw[i] + i > maxright) {
-      maxright = hw[i] + i;
-      mid = i;
-    }
+```c++
+vector<int> d2(n);
+for (int i = 0, l = 0, r = -1; i < n; i++) {
+  int k = (i > r) ? 0 : min(d2[l + r - i + 1], r - i + 1);
+  while (0 <= i - k - 1 && i + k < n && s[i - k - 1] == s[i + k]) {
+    k++;
+  }
+  d2[i] = k--;
+  if (i + k > r) {
+    l = i - k - 1;
+    r = i + k ;
   }
 }
 ```
 
-虽然看起来优化不了多少,但它的时间复杂度确实是$O(n)$的
-
-## 习题
+### 统一处理
 
-[P4555 \[国家集训队\] 最长双回文串](https://www.luogu.org/problemnew/show/P4555)
+虽然在讲解过程及上述实现中我们将 $d_1[]$ 和 $d_2[]$ 的计算分开考虑,但实际上可以通过一个技巧将二者的计算统一为 $d_1[]$ 的计算。
 
-一道几乎是裸题的题
+给定一个长度为 $n$ 的字符串 $s$,我们在其 $n + 1$ 个空中插入分隔符 $\#$,从而构造一个长度为 $2n + 1$ 的字符串 $s'$。举例来说,对于字符串 $s = \mathtt{abababc}$,其对应的 $s' = \mathtt{\#a\#b\#a\#b\#a\#b\#c\#}$。
 
-看到回文串可以想想马拉车,于是我们就用马拉车写
+对于字母间的 $\#$,其实际意义为 $s$ 中对应的“空”。而两端的 $\#$ 则是为了实现的方便。
 
-在朴素的马拉车基础上求出 l 和 r 数组,$l_i$表示 i 所在回文串中的最右端的下标,$r_i$代表 i 所在回文串中的最左端的下标
+注意到,在对 $s'$ 计算 $d_1[]$ 后,对于一个位置 $i$,$d_1[i]$ 所描述的最长的子回文串必定以 $\#$ 结尾(若以字母结尾,由于字母两侧必定各有一个 $\#$,因此可向外扩展一个得到一个更长的)。因此,对于 $s$ 中一个以字母为中心的极大子回文串,设其长度为 $m + 1$,则其在 $s'$ 中对应一个以相应字母为中心,长度为 $2m + 3$ 的极大子回文串;而对于 $s$ 中一个以空为中心的极大子回文串,设其长度为 $m$,则其在 $s'$ 中对应一个以相应表示空的 $\#$ 为中心,长度为 $2m + 1$ 的极大子回文串(上述两种情况下的 $m$ 均为偶数,但该性质成立与否并不影响结论)。综合以上观察及少许计算后易得,在 $s'$ 中,$d_1[i]$ 表示在 $s​$ 中以对应位置为中心的极大子回文串的**总长度加一**。
 
-然后拼接一下即可:
+上述结论建立了 $s'$ 的 $d_1[]$ 同 $s$ 的 $d_1[]$ 和 $d_2[]$ 间的关系。
 
-```cpp
-#include <cstdio>
-#include <cstring>
-#include <iostream>
+由于该统一处理本质上即求 $s'$ 的 $d_1[]$,因此在得到 $s'$ 后,代码同上节计算 $d_1[]$ 的一样。
 
-using namespace std;
+## 练习题目
 
-const int maxn = 200010;
-char a[maxn], s[maxn << 1];
-int l[maxn << 1], r[maxn << 1];
-int n, hw[maxn], ans;
-inline void manacher() {
-  int maxright = 0, mid;
-  for (int i = 1; i < n; ++i) {
-    if (i < maxright)
-      hw[i] = min(hw[(mid << 1) - i], hw[mid] + mid - i);
-    else
-      hw[i] = 1;
-    while (s[i + hw[i]] == s[i - hw[i]]) ++hw[i];
-    if (hw[i] + i > maxright) {
-      maxright = hw[i] + i;
-      mid = i;
-    }
-  }
-}
+- [UVA #11475 "Extend to Palindrome"](https://uva.onlinejudge.org/index.php?option=com_onlinejudge&Itemid=8&page=show_problem&problem=2470)
+- [P4555 \[国家集训队\] 最长双回文串](https://www.luogu.org/problemnew/show/P4555)
 
-void change() {
-  s[0] = s[1] = '#';
-  for (int i = 0; i < n; ++i) {
-    s[i * 2 + 2] = a[i];
-    s[i * 2 + 3] = '#';
-  }
-  n = n * 2 + 2;
-  s[n] = 0;
-}
+***
 
-inline int maxx(int a, int b) { return a > b ? a : b; }
-
-int main() {
-  scanf("%s", a);
-  n = strlen(a);
-  change();
-  manacher();
-  int now = 0;
-  for (int i = 0; i < n; ++i)
-    while (now <= i + hw[i] - 1) l[now++] = i;
-  now = n;
-  for (int i = n - 1; i >= 0; --i) {
-    while (now >= i - hw[i] + 1) r[now--] = i;
-  }
-  int ans = 0;
-  for (int i = 0; i < n; ++i) ans = maxx(ans, r[i] - l[i]);
-  printf("%d", ans);
-}
-```
+**本页面主要译自博文 [Нахождение всех подпалиндромов](http://e-maxx.ru/algo/palindromes_count) 与其英文翻译版 [Finding all sub-palindromes in $O(N)$](https://cp-algorithms.com/string/manacher.html) 。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。**