Algoritmo de Manacher - Encontrar todos los sub-palíndromos en
Enunciado
Se da un string de longitud . Encontrar todos los pares tales que la subcadena es un palíndromo. Un string es un palíndromo cuando ( es el string invertido de ).
Enunciado más preciso
En el peor caso un string puede tener hasta subcadenas palindrómicas, y a primera vista parece que no hay un algoritmo lineal para este problema.
Pero la información sobre los palíndromos se puede guardar de forma compacta: para cada posición encontraremos el número de palíndromos no vacíos centrados en esa posición.
Los palíndromos con un centro común forman una cadena contigua: es decir, si tenemos un palíndromo de longitud centrado en , también tenemos palíndromos de longitudes , y así sucesivamente, también centrados en . Por lo tanto, recolectaremos la información de todas las subcadenas palindrómicas de esta manera.
Los palíndromos de longitud impar y par se contabilizan por separado como y . Para los palíndromos de longitud par asumimos que están centrados en la posición si sus dos caracteres centrales son y .
Por ejemplo, el string tiene tres palíndromos de longitud impar con centros en la posición , es decir, :
{s_3}\ a\ b}^{d{odd}[3]=3} c
Y el string tiene dos palíndromos de longitud par con centros en la posición , es decir, :
{s_3}\ b}^{d{even}[3]=2} d
Es un hecho sorprendente que existe un algoritmo, suficientemente simple, que calcula estos “arreglos de palindromicidad” y en tiempo lineal. El algoritmo se describe en este artículo.
Solución
En general, este problema tiene muchas soluciones: con hashing de strings se puede resolver en , y con árboles de sufijos y LCA rápido este problema se puede resolver en .
Pero el método descrito aquí es suficientemente más simple y tiene una constante oculta menor en complejidad temporal y de memoria. Este algoritmo fue descubierto por Glenn K. Manacher en 1975.
Otra forma moderna de resolver este problema y de tratar con palíndromos en general es mediante el llamado árbol palindrómico, o eertree.
Algoritmo trivial
Para evitar ambigüedades en la descripción posterior denotamos qué es el “algoritmo trivial”.
Es el algoritmo que hace lo siguiente. Para cada posición de centro intenta aumentar la respuesta en uno mientras sea posible, comparando un par de caracteres correspondientes cada vez.
Tal algoritmo es lento: solo puede calcular la respuesta en .
La implementación del algoritmo trivial es:
vector<int> manacher_odd_trivial(string s) {
int n = s.size();
s = "$" + s + "^";
vector<int> p(n + 2);
for(int i = 1; i <= n; i++) {
while(s[i - p[i]] == s[i + p[i]]) {
p[i]++;
}
}
return vector<int>(begin(p) + 1, end(p) - 1);
}Se usaron los caracteres terminales $ y ^ para evitar tratar los extremos del string por separado.
Algoritmo de Manacher
Describimos el algoritmo para encontrar todos los sub-palíndromos de longitud impar, es decir, para calcular .
Para un cálculo rápido mantendremos los bordes exclusivos del (sub-)palíndromo más a la derecha encontrado (es decir, el (sub-)palíndromo más a la derecha actual es ). Inicialmente ponemos , lo que corresponde al string vacío.
Así, queremos calcular para el siguiente , y todos los valores anteriores en ya se han calculado. Hacemos lo siguiente:
-
Si está fuera del sub-palíndromo actual, es decir, , simplemente lanzamos el algoritmo trivial.
Así incrementaremos de forma consecutiva y comprobaremos cada vez si la subcadena más a la derecha actual es un palíndromo. Cuando encontremos el primer desajuste o lleguemos a los bordes de , nos detendremos. En este caso ya habremos calculado . Después de esto, no debemos olvidar actualizar . debe actualizarse de forma que represente el último índice del sub-palíndromo más a la derecha actual.
-
Ahora consideremos el caso en que . Intentaremos extraer alguna información de los valores ya calculados en . Así, encontremos la posición “espejo” de en el sub-palíndromo , es decir, obtendremos la posición , y comprobamos el valor de . Como es la posición simétrica a respecto de , casi siempre podemos asignar . Ilustración de esto (el palíndromo alrededor de se “copia” de hecho al palíndromo alrededor de ):
\text{palindrome}\ \ldots\ \underbrace{ s{i-d_{odd}[j]+1}\ \ldots\ s_i\ \ldots\ s_{i+d_{odd}[j]-1}\ }\text{palindrome}\ \ldots\ s{r-1}\ }^\text{palindrome}\ \ldots
Pero hay un caso delicado que hay que manejar correctamente: cuando el palíndromo “interno” alcanza los bordes del “externo”, es decir, (o, lo que es lo mismo, ). Como la simetría fuera del palíndromo “externo” no está garantizada, asignar simplemente será incorrecto: no tenemos datos suficientes para afirmar que el palíndromo en la posición tiene la misma longitud.
En realidad, debemos restringir por ahora la longitud de nuestro palíndromo, es decir, asignar , para manejar correctamente esas situaciones. Después de esto ejecutaremos el algoritmo trivial, que intentará aumentar mientras sea posible.
Ilustración de este caso (el palíndromo con centro se restringe para caber en el palíndromo “externo”):
\text{palindrome}\ \ldots\ \underbrace{ s{i-(r-i)+1}\ \ldots\ s_i\ \ldots\ s_{r-1} }\text{palindrome}\ }^\text{palindrome}\ \underbrace{ \ldots \ldots \ldots \ldots \ldots }\text{try moving here}
En la ilustración se muestra que el palíndromo con centro podría ser más grande y salir del palíndromo “externo”, pero, con como centro, solo podemos usar la parte que cabe por completo en el palíndromo “externo”. Pero la respuesta para la posición () puede ser mucho mayor que esta parte, así que a continuación ejecutaremos nuestro algoritmo trivial, que intentará hacerlo crecer fuera de nuestro palíndromo “externo”, es decir, hacia la región “try moving here”.
De nuevo, no debemos olvidar actualizar los valores después de calcular cada .
Complejidad del algoritmo de Manacher
A primera vista no es obvio que este algoritmo tenga complejidad lineal, porque a menudo ejecutamos el algoritmo naive al buscar la respuesta para una posición particular.
Sin embargo, un análisis más cuidadoso muestra que el algoritmo es lineal. De hecho, el algoritmo de construcción de la función Z, que se parece a este algoritmo, también trabaja en tiempo lineal.
Podemos notar que cada iteración del algoritmo trivial incrementa en uno. Además no puede disminuir durante el algoritmo. Así, el algoritmo trivial hará iteraciones en total.
Las demás partes del algoritmo de Manacher trabajan obviamente en tiempo lineal. Así, obtenemos complejidad .
Implementación del algoritmo de Manacher
Para calcular , obtenemos el siguiente código. Cosas a notar:
- es el índice de la letra central del palíndromo actual.
- Si supera , se inicializa en 0.
- Si no supera , se inicializa o bien con , donde es la posición espejo de en , o bien se restringe al tamaño del palíndromo “externo”.
- El bucle while denota el algoritmo trivial. Lo lanzamos independientemente del valor de .
- Si el tamaño del palíndromo centrado en es , entonces almacena .
vector<int> manacher_odd(string s) {
int n = s.size();
s = "$" + s + "^";
vector<int> p(n + 2);
int l = 0, r = 1;
for(int i = 1; i <= n; i++) {
if(i <= r) {
p[i] = min(r - i, p[l + (r - i)]);
}
while(s[i - p[i]] == s[i + p[i]]) {
p[i]++;
}
if(i + p[i] > r) {
l = i - p[i], r = i + p[i];
}
}
return vector<int>(begin(p) + 1, end(p) - 1);
}Trabajar con paridades
Aunque es posible implementar el algoritmo de Manacher para longitudes impares y pares por separado, la implementación de la versión para longitudes pares a menudo se considera más difícil, porque es menos natural y fácilmente conduce a errores por uno.
Para mitigar esto, es posible reducir todo el problema al caso en que solo tratamos palíndromos de longitud impar. Para ello, podemos poner un carácter adicional # entre cada letra del string y también al principio y al final del string:
Como se puede ver, y , donde denota el arreglo de Manacher para palíndromos de longitud impar en el string unido con #, mientras que y corresponden a los arreglos definidos arriba en el string inicial.
En efecto, los caracteres # no afectan a los palíndromos de longitud impar, que siguen centrados en los caracteres del string inicial, pero ahora los palíndromos de longitud par del string inicial son palíndromos de longitud impar del nuevo string centrados en caracteres #.
Nótese que y son esencialmente las longitudes incrementadas en de los palíndromos más grandes de longitud par e impar centrados en , respectivamente.
La reducción se implementa de la siguiente manera:
vector<int> manacher(string s) {
string t;
for(auto c: s) {
t += string("#") + c;
}
auto res = manacher_odd(t + "#");
return vector<int>(begin(res) + 1, end(res) - 1);
}Por simplicidad, se omite partir el arreglo en y así como su cálculo explícito.