Función prefijo. Algoritmo de Knuth–Morris–Pratt
Definición de la función prefijo
Se nos da un string de longitud . La función prefijo (prefix function) de este string se define como un arreglo de longitud , donde es la longitud del prefijo propio más largo de la subcadena que también es un sufijo de esa subcadena. Un prefijo propio de un string es un prefijo que no es igual al string mismo. Por definición, .
Matemáticamente, la definición de la función prefijo se puede escribir así:
Por ejemplo, la función prefijo del string “abcabcd” es , y la función prefijo del string “aabaaab” es .
Algoritmo trivial
Un algoritmo que sigue exactamente la definición de la función prefijo es el siguiente:
vector<int> prefix_function(string s) {
int n = (int)s.length();
vector<int> pi(n);
for (int i = 0; i < n; i++)
for (int k = 0; k <= i; k++)
if (s.substr(0, k) == s.substr(i-k+1, k))
pi[i] = k;
return pi;
}Es fácil ver que su complejidad es , así que hay margen de mejora.
Algoritmo eficiente
Este algoritmo fue propuesto por Knuth y Pratt y, de forma independiente, por Morris en 1977. Se usó como la función principal de un algoritmo de búsqueda de subcadenas.
Primera optimización
La primera observación importante es que los valores de la función prefijo solo pueden aumentar a lo sumo en uno.
En efecto, de lo contrario, si , podemos tomar este sufijo que termina en la posición con longitud y quitarle el último carácter. Terminamos con un sufijo que termina en la posición con longitud , que es mejor que , es decir, obtenemos una contradicción.
La siguiente ilustración muestra esta contradicción. El sufijo propio más largo en la posición que también es un prefijo tiene longitud , y en la posición tiene longitud . Por lo tanto el string es igual al string , lo que significa que también los strings y son iguales; por lo tanto tiene que ser .
{\pi[i+1] = 4} ~ \dots ~ \underbrace{s{i-2} ~ \overbrace{s_{i-1} ~ s_{i}}^{\pi[i] = 2} ~ s_{i+1}}_{\pi[i+1] = 4}
Así, al pasar a la siguiente posición, el valor de la función prefijo puede aumentar en uno, permanecer igual o disminuir en cierta cantidad. Este hecho ya nos permite reducir la complejidad del algoritmo a , porque en un paso la función prefijo puede crecer a lo sumo en uno. En total la función puede crecer a lo sumo pasos, y por lo tanto también solo puede disminuir un total de pasos. Esto significa que solo tenemos que realizar comparaciones de strings, y alcanzamos la complejidad .
Segunda optimización
Vayamos más lejos: queremos deshacernos de las comparaciones de strings. Para lograrlo, tenemos que usar toda la información calculada en los pasos anteriores.
Calculemos entonces el valor de la función prefijo para . Si , entonces podemos afirmar con certeza que , ya que ya sabemos que el sufijo en la posición de longitud es igual al prefijo de longitud . Esto se ilustra de nuevo con un ejemplo.
{\pi[i+1] = \pi[i] + 1} ~ \dots ~ \underbrace{\overbrace{s{i-2} ~ s_{i-1} ~ s_{i}}^{\pi[i]} ~ \overbrace{s_{i+1}}^{s_3 = s_{i + 1}}}_{\pi[i+1] = \pi[i] + 1}
Si no es el caso, , entonces tenemos que probar un string más corto. Para acelerar las cosas, nos gustaría pasar de inmediato a la mayor longitud tal que la propiedad de prefijo se cumple en la posición , es decir, :
j ~ s_2 ~ s_3}^{\pi[i]} ~ \dots ~ \overbrace{s{i-3} ~ s_{i-2} ~ \underbrace{s_{i-1} ~ s_{i}}j}^{\pi[i]} ~ s{i+1}
En efecto, si encontramos tal longitud , entonces de nuevo solo necesitamos comparar los caracteres y . Si son iguales, podemos asignar . En caso contrario, tendremos que encontrar el mayor valor menor que para el cual se cumple la propiedad de prefijo, y así sucesivamente. Puede ocurrir que esto continúe hasta . Si entonces , asignamos , y en caso contrario.
Así que ya tenemos un esquema general del algoritmo. La única pregunta que queda es cómo encontrar de forma efectiva las longitudes para . Recapitulemos: para la longitud actual en la posición para la cual se cumple la propiedad de prefijo, es decir, , queremos encontrar el mayor para el cual se cumple la propiedad de prefijo.
k ~ s_2 ~ s_3}^j ~ \dots ~ \overbrace{s{i-3} ~ s_{i-2} ~ \underbrace{s_{i-1} ~ s_{i}}k}^j ~s{i+1}
La ilustración muestra que este tiene que ser el valor de , que ya calculamos antes.
Algoritmo final
Por fin podemos construir un algoritmo que no realiza ninguna comparación de strings y solo realiza acciones.
Este es el procedimiento final:
- Calculamos los valores de prefijo en un bucle iterando desde hasta (a simplemente se le asigna ).
- Para calcular el valor actual fijamos la variable que denota la longitud del mejor sufijo para . Inicialmente .
- Comprobamos si el sufijo de longitud también es un prefijo comparando y . Si son iguales, asignamos ; en caso contrario reducimos a y repetimos este paso.
- Si llegamos a la longitud y todavía no hay coincidencia, entonces asignamos y pasamos al siguiente índice .
Implementación
La implementación termina siendo sorprendentemente corta y expresiva.
vector<int> prefix_function(string s) {
int n = (int)s.length();
vector<int> pi(n);
for (int i = 1; i < n; i++) {
int j = pi[i-1];
while (j > 0 && s[i] != s[j])
j = pi[j-1];
if (s[i] == s[j])
j++;
pi[i] = j;
}
return pi;
}Este es un algoritmo online, es decir, procesa los datos a medida que llegan: por ejemplo, se pueden leer los caracteres del string uno por uno y procesarlos de inmediato, hallando el valor de la función prefijo para cada siguiente carácter. El algoritmo aún requiere almacenar el string mismo y los valores de la función prefijo calculados previamente, pero si conocemos de antemano el valor máximo que puede tomar la función prefijo sobre el string, podemos almacenar solo los primeros caracteres del string y la misma cantidad de valores de la función prefijo.
Aplicaciones
Búsqueda de una subcadena en un string. El algoritmo de Knuth-Morris-Pratt
Esta tarea es la aplicación clásica de la función prefijo.
Dados un texto y un string , queremos encontrar y mostrar las posiciones de todas las ocurrencias del string en el texto .
Por comodidad denotamos con la longitud del string y con la longitud del texto .
Generamos el string , donde es un separador que no aparece ni en ni en . Calculemos la función prefijo de este string. Ahora pensemos en el significado de los valores de la función prefijo, excepto las primeras entradas (que pertenecen al string y al separador). Por definición, el valor muestra la mayor longitud de una subcadena que termina en la posición y que coincide con el prefijo. Pero en nuestro caso esto no es más que el bloque más grande que coincide con y termina en la posición . Esta longitud no puede ser mayor que debido al separador. Pero si se alcanza la igualdad , entonces significa que el string aparece por completo en esta posición, es decir, termina en la posición . Solo no hay que olvidar que las posiciones están indexadas en el string .
Así, si en alguna posición tenemos , entonces en la posición del string aparece el string .
Como ya se mencionó en la descripción del cálculo de la función prefijo, si sabemos que los valores de prefijo nunca superan cierto valor, entonces no necesitamos almacenar el string entero ni la función entera, sino solo su comienzo. En nuestro caso esto significa que solo necesitamos almacenar el string y los valores de la función prefijo para él. Podemos leer de a un carácter del string y calcular el valor actual de la función prefijo.
Así, el algoritmo de Knuth-Morris-Pratt resuelve el problema en tiempo y memoria .
Contar el número de ocurrencias de cada prefijo
Aquí discutimos dos problemas a la vez. Dado un string de longitud . En la primera variante del problema queremos contar el número de apariciones de cada prefijo en el mismo string. En la segunda variante se da otro string y queremos contar el número de apariciones de cada prefijo en .
Primero resolvemos el primer problema. Consideremos el valor de la función prefijo en una posición . Por definición, significa que el prefijo de longitud del string ocurre y termina en la posición , y no hay un prefijo más largo que cumpla esta definición. Al mismo tiempo, prefijos más cortos pueden terminar en esta posición. No es difícil ver que tenemos la misma pregunta que ya respondimos cuando calculamos la función prefijo misma: Dado un prefijo de longitud que es un sufijo que termina en la posición , ¿cuál es el siguiente prefijo más pequeño que también es un sufijo que termina en la posición ? Así, en la posición termina el prefijo de longitud , el prefijo de longitud , el prefijo , y así sucesivamente, hasta que el índice se vuelve cero. Así podemos calcular la respuesta de la siguiente forma.
vector<int> ans(n + 1);
for (int i = 0; i < n; i++)
ans[pi[i]]++;
for (int i = n-1; i > 0; i--)
ans[pi[i-1]] += ans[i];
for (int i = 0; i <= n; i++)
ans[i]++;Aquí, para cada valor de la función prefijo primero contamos cuántas veces ocurre en el arreglo , y luego calculamos las respuestas finales: si sabemos que el prefijo de longitud aparece exactamente veces, entonces este número hay que sumarlo al número de ocurrencias de su sufijo más largo que también es un prefijo. Al final hay que sumar a cada resultado, ya que también hay que contar los prefijos originales.
Ahora consideremos el segundo problema. Aplicamos el truco de Knuth-Morris-Pratt: creamos el string y calculamos su función prefijo. Las únicas diferencias con la primera tarea son que solo nos interesan los valores de prefijo que se relacionan con el string , es decir, para . Con esos valores podemos realizar exactamente los mismos cálculos que en la primera tarea.
El número de subcadenas distintas en un string
Dado un string de longitud . Queremos calcular el número de subcadenas distintas que aparecen en él.
Resolveremos este problema de forma iterativa. A saber, aprenderemos, conociendo el número actual de subcadenas distintas, cómo recalcular este conteo al agregar un carácter al final.
Sea el número actual de subcadenas distintas en , y agregamos el carácter al final de . Obviamente aparecerán algunas subcadenas nuevas que terminan en . Queremos contar estas subcadenas nuevas que no aparecieron antes.
Tomamos el string y lo invertimos. Ahora la tarea se transforma en calcular cuántos prefijos hay que no aparecen en ningún otro lugar. Si calculamos el valor máximo de la función prefijo del string invertido , entonces el prefijo más largo que aparece en tiene longitud . Claramente, también aparecen todos los prefijos de menor longitud.
Por lo tanto, el número de subcadenas nuevas que aparecen cuando agregamos un nuevo carácter es .
Así, por cada carácter agregado podemos calcular el número de subcadenas nuevas en tiempo , lo que da una complejidad temporal de en total.
Vale la pena notar que también podemos calcular el número de subcadenas distintas agregando los caracteres al principio, o eliminando caracteres del principio o del final.
Comprimir un string
Dado un string de longitud . Queremos encontrar la representación “comprimida” más corta del string, es decir, queremos encontrar un string de menor longitud tal que se pueda representar como concatenación de una o más copias de .
Está claro que solo necesitamos encontrar la longitud de . Conociendo la longitud, la respuesta al problema será el prefijo de con esa longitud.
Calculemos la función prefijo de . Usando su último valor definimos el valor . Mostraremos que si divide a , entonces será la respuesta; en caso contrario no hay una compresión efectiva y la respuesta es .
Sea divisible por . Entonces el string se puede particionar en bloques de longitud . Por definición de la función prefijo, el prefijo de longitud será igual a su sufijo. Pero esto significa que el último bloque es igual al bloque anterior. Y el bloque anterior tiene que ser igual al bloque que lo precede. Y así sucesivamente. Como resultado, resulta que todos los bloques son iguales, por lo tanto podemos comprimir el string a longitud .
Por supuesto, todavía hay que mostrar que esto es realmente el óptimo. En efecto, si hubiera una compresión menor que , entonces la función prefijo al final sería mayor que . Por lo tanto es realmente la respuesta.
Ahora supongamos que no es divisible por . Mostramos que esto implica que la longitud de la respuesta es . Lo demostramos por contradicción. Suponiendo que existe una respuesta, y que la compresión tiene longitud ( divide a ). Entonces el último valor de la función prefijo tiene que ser mayor que , es decir, el sufijo cubrirá parcialmente el primer bloque. Ahora consideremos el segundo bloque del string. Como el prefijo es igual al sufijo, y tanto el prefijo como el sufijo cubren este bloque y su desplazamiento relativo no divide la longitud del bloque (de lo contrario dividiría a ), entonces todos los caracteres del bloque tienen que ser idénticos. Pero entonces el string consiste en un solo carácter repetido una y otra vez, por lo tanto podemos comprimirlo a un string de tamaño , lo que da , y divide a . Contradicción.
Construir un autómata según la función prefijo
Volvamos a la concatenación de los dos strings a través de un separador, es decir, para los strings y calculamos la función prefijo del string . Obviamente, como es un separador, el valor de la función prefijo nunca superará . Se sigue que basta con almacenar solo el string y los valores de la función prefijo para él, y podemos calcular la función prefijo para todo carácter posterior sobre la marcha:
{\text{need to store}} ~ \underbrace{t_0 ~ t_1 ~ \dots ~ t{m-1}}_{\text{do not need to store}}
En efecto, en tal situación, conocer el siguiente carácter y el valor de la función prefijo de la posición anterior es información suficiente para calcular el siguiente valor de la función prefijo, sin usar ningún carácter anterior del string ni el valor de la función prefijo en ellos.
En otras palabras, podemos construir un autómata (una máquina de estados finitos): el estado en él es el valor actual de la función prefijo, y la transición de un estado a otro se realizará mediante el siguiente carácter.
Así, incluso sin tener el string , podemos construir una tabla de transiciones \pi, c) \rightarrow \text{new}\pi usando el mismo algoritmo que para calcular la tabla de transiciones:
void compute_automaton(string s, vector<vector<int>>& aut) {
s += '#';
int n = s.size();
vector<int> pi = prefix_function(s);
aut.assign(n, vector<int>(26));
for (int i = 0; i < n; i++) {
for (int c = 0; c < 26; c++) {
int j = i;
while (j > 0 && 'a' + c != s[j])
j = pi[j-1];
if ('a' + c == s[j])
j++;
aut[i][c] = j;
}
}
}Sin embargo, en esta forma el algoritmo corre en tiempo para las letras minúsculas del alfabeto. Nótese que podemos aplicar programación dinámica y usar las partes de la tabla ya calculadas. Cada vez que vamos del valor al valor , en realidad queremos decir que la transición lleva al mismo estado que la transición , y esta respuesta ya está calculada con precisión.
void compute_automaton(string s, vector<vector<int>>& aut) {
s += '#';
int n = s.size();
vector<int> pi = prefix_function(s);
aut.assign(n, vector<int>(26));
for (int i = 0; i < n; i++) {
for (int c = 0; c < 26; c++) {
if (i > 0 && 'a' + c != s[i])
aut[i][c] = aut[pi[i-1]][c];
else
aut[i][c] = i + ('a' + c == s[i]);
}
}
}Como resultado construimos el autómata en tiempo .
¿Cuándo es útil un autómata así? Para empezar, recordemos que usamos la función prefijo del string y sus valores principalmente para un solo propósito: encontrar todas las ocurrencias del string en el string .
Por lo tanto, el beneficio más obvio de este autómata es la aceleración del cálculo de la función prefijo para el string . Al construir el autómata para , ya no necesitamos almacenar el string ni los valores de la función prefijo en él. Todas las transiciones ya están calculadas en la tabla.
Pero hay una segunda aplicación, menos obvia. Podemos usar el autómata cuando el string es un string gigantesco construido usando algunas reglas. Esto puede ser, por ejemplo, los strings de Gray, o un string formado por una combinación recursiva de varios strings cortos de la entrada.
Para completar resolveremos un problema de este tipo: dados un número y un string de longitud . Tenemos que calcular el número de ocurrencias de en el -ésimo string de Gray. Recordemos que los strings de Gray se definen de la siguiente forma:
\begin{align} g_1 &= \text{"a"}\ g_2 &= \text{"aba"}\ g_3 &= \text{"abacaba"}\ g_4 &= \text{"abacabadabacaba"} \end{align}
En tales casos, incluso construir el string será imposible, por su longitud astronómica. El -ésimo string de Gray tiene caracteres de longitud. Sin embargo, podemos calcular el valor de la función prefijo al final del string de forma efectiva, conociendo solo el valor de la función prefijo al inicio.
Además del autómata mismo, también calculamos valores : el valor del autómata después de procesar el string partiendo del estado . Y adicionalmente calculamos valores : el número de ocurrencias de en durante el procesamiento de partiendo del estado . En realidad es el número de veces que la función prefijo tomó el valor al realizar las operaciones. La respuesta al problema será entonces .
¿Cómo podemos calcular estos valores? Primero, los valores básicos son y . Y todos los valores posteriores se pueden calcular a partir de los valores anteriores y usando el autómata. Para calcular el valor para algún recordamos que el string consiste en , el -ésimo carácter del alfabeto, y . Así el autómata irá al estado:
Los valores de también se pueden contar fácilmente.
Así podemos resolver el problema para strings de Gray, y de forma similar también una enorme cantidad de otros problemas parecidos. Por ejemplo, el mismo método también resuelve el siguiente problema: se nos da un string y algunos patrones , cada uno de los cuales se especifica así: es un string de caracteres ordinarios, y puede haber algunas inserciones recursivas de los strings anteriores de la forma , lo que significa que en ese lugar hay que insertar el string veces. Un ejemplo de tales patrones:
\begin{align} t_1 &= \text{"abdeca"}\ t_2 &= \text{"abc"} + t_1^{30} + \text{"abd"}\ t_3 &= t_2^{50} + t_1^{100}\ t_4 &= t_2^{10} + t_3^{100} \end{align}
Las sustituciones recursivas inflan el string, de modo que sus longitudes pueden alcanzar el orden de .
Tenemos que encontrar el número de veces que el string aparece en cada uno de los strings.
El problema se puede resolver de la misma forma construyendo el autómata de la función prefijo, y luego calculamos las transiciones para cada patrón usando los resultados anteriores.
Problemas de práctica
- UVA # 455 “Periodic Strings”
- UVA # 11022 “String Factoring”
- UVA # 11452 “Dancing the Cheeky-Cheeky”
- UVA 12604 - Caesar Cipher
- UVA 12467 - Secret Word
- UVA 11019 - Matrix Matcher
- SPOJ - Pattern Find
- SPOJ - A Needle in the Haystack
- Codeforces - Anthem of Berland
- Codeforces - MUH and Cube Walls
- Codeforces - Prefixes and Suffixes