Función Z y su cálculo
Supongamos que se nos da un string de longitud . La función Z (Z-function) de este string es un arreglo de longitud donde el -ésimo elemento es igual a la mayor cantidad de caracteres a partir de la posición que coinciden con los primeros caracteres de .
En otras palabras, es la longitud del string más largo que es, al mismo tiempo, un prefijo de y un prefijo del sufijo de que empieza en .
Nota. En este artículo, para evitar ambigüedad, asumimos índices basados en ; es decir: el primer carácter de tiene índice y el último tiene índice .
El primer elemento de la función Z, , en general no está bien definido. En este artículo asumiremos que es cero (aunque no cambia nada en la implementación del algoritmo).
Este artículo presenta un algoritmo para calcular la función Z en tiempo , además de varias de sus aplicaciones.
Ejemplos
Por ejemplo, estos son los valores de la función Z calculados para distintos strings:
- “aaaaa” -
- “aaabaab” -
- “abacaba” -
Algoritmo trivial
La definición formal se puede representar con la siguiente implementación elemental .
vector<int> z_function_trivial(string s) {
int n = s.size();
vector<int> z(n);
for (int i = 1; i < n; i++) {
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
z[i]++;
}
}
return z;
}Simplemente iteramos por cada posición y actualizamos para cada una, partiendo de e incrementándolo mientras no encontremos una discrepancia (y mientras no lleguemos al final del string).
Por supuesto, esta no es una implementación eficiente. Ahora mostraremos la construcción de una implementación eficiente.
Algoritmo eficiente para calcular la función Z
Para obtener un algoritmo eficiente calcularemos los valores de uno tras otro desde hasta , pero al mismo tiempo, al calcular un valor nuevo, intentaremos aprovechar al máximo los valores ya calculados.
Por brevedad, llamemos coincidencias de segmento a aquellas subcadenas que coinciden con un prefijo de . Por ejemplo, el valor de la función Z buscada es la longitud de la coincidencia de segmento que empieza en la posición (y que termina en la posición ).
Para hacer esto, mantendremos los índices de la coincidencia de segmento más a la derecha. Es decir, entre todos los segmentos detectados nos quedaremos con el que termina más a la derecha. En cierto modo, el índice se puede ver como la “frontera” hasta la cual el algoritmo ya recorrió el string ; todo lo que está más allá de ese punto todavía no se conoce.
Entonces, si el índice actual (para el cual hay que calcular el siguiente valor de la función Z) es , tenemos una de dos opciones:
-
— la posición actual está fuera de lo que ya procesamos.
Calcularemos entonces con el algoritmo trivial (es decir, comparando los valores uno por uno). Nótese que al final, si , habrá que actualizar los índices del segmento más a la derecha, porque está garantizado que el nuevo es mejor que el anterior.
-
— la posición actual está dentro de la coincidencia de segmento actual .
Entonces podemos usar los valores Z ya calculados para “inicializar” el valor de a algo (seguro que es mejor que “empezar desde cero”), tal vez incluso un número grande.
Para esto, observamos que las subcadenas y coinciden. Esto significa que como aproximación inicial de podemos tomar el valor ya calculado para el segmento correspondiente , y ese es .
Sin embargo, el valor podría ser demasiado grande: al aplicarlo a la posición podría superar el índice . Esto no está permitido porque no sabemos nada de los caracteres a la derecha de : pueden diferir de los requeridos.
Aquí hay un ejemplo de un escenario similar:
Cuando llegamos a la última posición (), el segmento de coincidencia actual será . La posición coincidirá entonces con la posición , para la cual el valor de la función Z es . Obviamente, no podemos inicializar en , sería completamente incorrecto. El valor máximo al que podríamos inicializarlo es — porque es el mayor valor que no nos lleva más allá del índice del segmento de coincidencia .
Así, como aproximación inicial de podemos tomar de forma segura:
Después de haber inicializado en , intentamos incrementar ejecutando el algoritmo trivial — porque en general, después de la frontera , no podemos saber si el segmento seguirá coincidiendo o no.
Así, el algoritmo completo se parte en dos casos, que solo difieren en el valor inicial de : en el primer caso se asume que es cero, en el segundo se determina a partir de los valores ya calculados (usando la fórmula de arriba). Después de eso, ambas ramas de este algoritmo se pueden reducir a la implementación del algoritmo trivial, que empieza inmediatamente después de especificar el valor inicial.
El algoritmo resulta ser muy simple. A pesar de que en cada iteración se ejecuta el algoritmo trivial, hemos avanzado de forma significativa, y tenemos un algoritmo que corre en tiempo lineal. Más adelante demostraremos que el tiempo de ejecución es lineal.
Implementación
La implementación resulta bastante concisa:
vector<int> z_function(string s) {
int n = s.size();
vector<int> z(n);
int l = 0, r = 0;
for(int i = 1; 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;
}Comentarios sobre esta implementación
Toda la solución se da como una función que devuelve un arreglo de longitud — la función Z de .
El arreglo se inicializa con ceros. El segmento de coincidencia más a la derecha actual se asume (es decir, un segmento deliberadamente pequeño que no contiene ningún ).
Dentro del bucle para primero determinamos el valor inicial — o bien permanecerá en cero, o se calculará con la fórmula de arriba.
A continuación, el algoritmo trivial intenta aumentar el valor de tanto como sea posible.
Al final, si hace falta (es decir, si ), actualizamos el segmento de coincidencia más a la derecha .
Comportamiento asintótico del algoritmo
Demostraremos que el algoritmo de arriba tiene un tiempo de ejecución lineal en la longitud del string — es decir, es .
La demostración es muy simple.
Nos interesa el bucle while anidado, ya que todo lo demás es solo un conjunto de operaciones constantes que suman .
Mostraremos que cada iteración del bucle while aumentará el borde derecho del segmento de coincidencia.
Para eso, consideraremos ambas ramas del algoritmo:
-
En este caso, o bien el bucle
whileno hará ninguna iteración (si ), o tomará algunas iteraciones, empezando en la posición , cada vez moviéndose un carácter a la derecha. Después de eso, el borde derecho se actualizará necesariamente.Así, hemos encontrado que, cuando , cada iteración del bucle
whileaumenta el valor del nuevo índice . -
En este caso, inicializamos en cierto valor dado por la fórmula de arriba. Comparemos este valor inicial con el valor . Tendremos tres casos:
-
Demostramos que en este caso no tendrá lugar ninguna iteración del bucle
while.Es fácil de demostrar, por ejemplo, por contradicción: si el bucle
whilehiciera al menos una iteración, significaría que la aproximación inicial era inexacta (menor que la longitud real de la coincidencia). Pero como y son iguales, esto implicaría que tiene el valor incorrecto (menor de lo que debería).Así, como es correcto y es menor que , se sigue que este valor coincide con el valor requerido .
-
En este caso, el bucle
whilepuede hacer algunas iteraciones, pero cada una de ellas llevará a un aumento del valor del índice porque empezaremos a comparar desde , lo que subirá más allá del intervalo . -
Esta opción es imposible, por definición de .
-
Así, hemos demostrado que cada iteración del bucle interno hace que el puntero avance hacia la derecha. Como no puede ser mayor que , esto significa que el bucle interno no hará más de iteraciones.
Como el resto del algoritmo obviamente trabaja en , hemos demostrado que el algoritmo completo para calcular funciones Z corre en tiempo lineal.
Aplicaciones
Ahora consideraremos algunos usos de las funciones Z para tareas específicas.
Estas aplicaciones serán en gran medida similares a las aplicaciones de la función prefijo.
Búsqueda de la subcadena
Para evitar confusión, llamamos al string de texto, y al patrón. El problema es: encontrar todas las ocurrencias del patrón dentro del texto .
Para resolver este problema, creamos un nuevo string , es decir, concatenamos y pero además colocamos un carácter separador en el medio (elegiremos de modo que con certeza no esté presente en ninguna parte de los strings o ).
Calculamos la función Z de . Luego, para cualquier en el intervalo , consideraremos el valor correspondiente . Si es igual a entonces sabemos que hay una ocurrencia de en la -ésima posición de ; en caso contrario no hay ocurrencia de en la -ésima posición de .
El tiempo de ejecución (y el consumo de memoria) es .
Número de subcadenas distintas en un string
Dado un string de longitud , contar el número de subcadenas distintas de .
Resolveremos este problema de forma iterativa. Es decir: conociendo el número actual de subcadenas distintas, recalcular esta cantidad después de agregar al final de un carácter.
Sea el número actual de subcadenas distintas de . Agregamos un nuevo carácter a . Obviamente, puede haber algunas subcadenas nuevas que terminan en este nuevo carácter (a saber, todos aquellos strings que terminan con este símbolo y que todavía no habíamos encontrado).
Tomamos un string y lo invertimos (escribimos sus caracteres en orden inverso). Nuestra tarea ahora es contar cuántos prefijos de no se encuentran en ninguna otra parte de . Calculemos la función Z de y encontremos su valor máximo . Obviamente, el prefijo de de longitud también ocurre en algún lugar del medio de . Claramente, los prefijos más cortos también ocurren.
Así, hemos encontrado que el número de subcadenas nuevas que aparecen cuando se agrega el símbolo a es igual a .
En consecuencia, el tiempo de ejecución de esta solución es para un string de longitud .
Vale la pena notar que exactamente de la misma forma podemos recalcular, todavía en tiempo , el número de subcadenas distintas al agregar un carácter al principio del string, y también al eliminarlo (del final o del principio).
Compresión de un string
Dado un string de longitud . Encontrar su representación “comprimida” más corta, es decir: encontrar un string de menor longitud tal que se pueda representar como concatenación de una o más copias de .
Una solución es: calcular la función Z de , recorrer todos los tales que divide a . Detenerse en el primer tal que . Entonces, el string se puede comprimir a la longitud .
La demostración de este hecho es la misma que la de la solución que usa la función prefijo.
Problemas de práctica
- CSES - Finding Borders
- eolymp - Blocks of string
- Codeforces - Password [Difficulty: Easy]
- UVA # 455 “Periodic Strings” [Difficulty: Medium]
- UVA # 11022 “String Factoring” [Difficulty: Medium]
- UVa 11475 - Extend to Palindrome
- LA 6439 - Pasti Pas!
- Codechef - Chef and Strings
- Codeforces - Prefixes and Suffixes
- Codeforces - “a” String Problem