Skip to Content

Función Z y su cálculo

Supongamos que se nos da un string ss de longitud nn. La función Z (Z-function) de este string es un arreglo de longitud nn donde el ii-ésimo elemento es igual a la mayor cantidad de caracteres a partir de la posición ii que coinciden con los primeros caracteres de ss.

En otras palabras, z[i]z[i] es la longitud del string más largo que es, al mismo tiempo, un prefijo de ss y un prefijo del sufijo de ss que empieza en ii.

Nota. En este artículo, para evitar ambigüedad, asumimos índices basados en 00; es decir: el primer carácter de ss tiene índice 00 y el último tiene índice n1n-1.

El primer elemento de la función Z, z[0]z[0], 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 O(n)O(n), además de varias de sus aplicaciones.

Ejemplos

Por ejemplo, estos son los valores de la función Z calculados para distintos strings:

  • “aaaaa” - [0,4,3,2,1][0, 4, 3, 2, 1]
  • “aaabaab” - [0,2,1,0,2,1,0][0, 2, 1, 0, 2, 1, 0]
  • “abacaba” - [0,0,1,0,3,0,1][0, 0, 1, 0, 3, 0, 1]

Algoritmo trivial

La definición formal se puede representar con la siguiente implementación elemental O(n2)O(n^2).

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 ii y actualizamos z[i]z[i] para cada una, partiendo de z[i]=0z[i] = 0 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 z[i]z[i] uno tras otro desde i=1i = 1 hasta n1n - 1, 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 ss. Por ejemplo, el valor de la función Z buscada z[i]z[i] es la longitud de la coincidencia de segmento que empieza en la posición ii (y que termina en la posición i+z[i]1i + z[i] - 1).

Para hacer esto, mantendremos los índices [l,r)[l, r) 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 rr se puede ver como la “frontera” hasta la cual el algoritmo ya recorrió el string ss; 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 ii, tenemos una de dos opciones:

  • iri \geq r — la posición actual está fuera de lo que ya procesamos.

    Calcularemos entonces z[i]z[i] con el algoritmo trivial (es decir, comparando los valores uno por uno). Nótese que al final, si z[i]>0z[i] > 0, habrá que actualizar los índices del segmento más a la derecha, porque está garantizado que el nuevo r=i+z[i]r = i + z[i] es mejor que el rr anterior.

  • i<ri < r — la posición actual está dentro de la coincidencia de segmento actual [l,r)[l, r).

    Entonces podemos usar los valores Z ya calculados para “inicializar” el valor de z[i]z[i] a algo (seguro que es mejor que “empezar desde cero”), tal vez incluso un número grande.

    Para esto, observamos que las subcadenas s[lr)s[l \dots r) y s[0rl)s[0 \dots r-l) coinciden. Esto significa que como aproximación inicial de z[i]z[i] podemos tomar el valor ya calculado para el segmento correspondiente s[0rl)s[0 \dots r-l), y ese es z[il]z[i-l].

    Sin embargo, el valor z[il]z[i-l] podría ser demasiado grande: al aplicarlo a la posición ii podría superar el índice rr. Esto no está permitido porque no sabemos nada de los caracteres a la derecha de rr: pueden diferir de los requeridos.

    Aquí hay un ejemplo de un escenario similar:

    s=aaaabaa s = “aaaabaa”

    Cuando llegamos a la última posición (i=6i = 6), el segmento de coincidencia actual será [5,7)[5, 7). La posición 66 coincidirá entonces con la posición 65=16 - 5 = 1, para la cual el valor de la función Z es z[1]=3z[1] = 3. Obviamente, no podemos inicializar z[6]z[6] en 33, sería completamente incorrecto. El valor máximo al que podríamos inicializarlo es 11 — porque es el mayor valor que no nos lleva más allá del índice rr del segmento de coincidencia [l,r)[l, r).

    Así, como aproximación inicial de z[i]z[i] podemos tomar de forma segura:

    z0[i]=min(ri,  z[il]) z_0[i] = \min(r - i,; z[i-l])

    Después de haber inicializado z[i]z[i] en z0[i]z_0[i], intentamos incrementar z[i]z[i] ejecutando el algoritmo trivial — porque en general, después de la frontera rr, 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 z[i]z[i]: 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 nn — la función Z de ss.

El arreglo zz se inicializa con ceros. El segmento de coincidencia más a la derecha actual se asume [0;0)[0; 0) (es decir, un segmento deliberadamente pequeño que no contiene ningún ii).

Dentro del bucle para i=1n1i = 1 \dots n - 1 primero determinamos el valor inicial z[i]z[i] — 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 z[i]z[i] tanto como sea posible.

Al final, si hace falta (es decir, si i+z[i]>ri + z[i] > r), actualizamos el segmento de coincidencia más a la derecha [l,r)[l, r).

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 O(n)O(n).

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 O(n)O(n).

Mostraremos que cada iteración del bucle while aumentará el borde derecho rr del segmento de coincidencia.

Para eso, consideraremos ambas ramas del algoritmo:

  • iri \geq r

    En este caso, o bien el bucle while no hará ninguna iteración (si s[0]s[i]s[0] \ne s[i]), o tomará algunas iteraciones, empezando en la posición ii, cada vez moviéndose un carácter a la derecha. Después de eso, el borde derecho rr se actualizará necesariamente.

    Así, hemos encontrado que, cuando iri \geq r, cada iteración del bucle while aumenta el valor del nuevo índice rr.

  • i<ri < r

    En este caso, inicializamos z[i]z[i] en cierto valor z0z_0 dado por la fórmula de arriba. Comparemos este valor inicial z0z_0 con el valor rir - i. Tendremos tres casos:

    • z0<riz_0 < r - i

      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 while hiciera al menos una iteración, significaría que la aproximación inicial z[i]=z0z[i] = z_0 era inexacta (menor que la longitud real de la coincidencia). Pero como s[lr)s[l \dots r) y s[0rl)s[0 \dots r-l) son iguales, esto implicaría que z[il]z[i-l] tiene el valor incorrecto (menor de lo que debería).

      Así, como z[il]z[i-l] es correcto y es menor que rir - i, se sigue que este valor coincide con el valor requerido z[i]z[i].

    • z0=riz_0 = r - i

      En este caso, el bucle while puede hacer algunas iteraciones, pero cada una de ellas llevará a un aumento del valor del índice rr porque empezaremos a comparar desde s[r]s[r], lo que subirá más allá del intervalo [l,r)[l, r).

    • z0>riz_0 > r - i

      Esta opción es imposible, por definición de z0z_0.

Así, hemos demostrado que cada iteración del bucle interno hace que el puntero rr avance hacia la derecha. Como rr no puede ser mayor que n1n-1, esto significa que el bucle interno no hará más de n1n-1 iteraciones.

Como el resto del algoritmo obviamente trabaja en O(n)O(n), 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 tt al string de texto, y pp al patrón. El problema es: encontrar todas las ocurrencias del patrón pp dentro del texto tt.

Para resolver este problema, creamos un nuevo string s=p++ts = p + \diamond + t, es decir, concatenamos pp y tt pero además colocamos un carácter separador \diamond en el medio (elegiremos \diamond de modo que con certeza no esté presente en ninguna parte de los strings pp o tt).

Calculamos la función Z de ss. Luego, para cualquier ii en el intervalo [0;  length(t)1][0; ; \operatorname{length}(t) - 1], consideraremos el valor correspondiente k=z[i+length(p)+1]k = z[i + \operatorname{length}(p) + 1]. Si kk es igual a length(p)\operatorname{length}(p) entonces sabemos que hay una ocurrencia de pp en la ii-ésima posición de tt; en caso contrario no hay ocurrencia de pp en la ii-ésima posición de tt.

El tiempo de ejecución (y el consumo de memoria) es O(length(t)+length(p))O(\operatorname{length}(t) + \operatorname{length}(p)).

Número de subcadenas distintas en un string

Dado un string ss de longitud nn, contar el número de subcadenas distintas de ss.

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 ss un carácter.

Sea kk el número actual de subcadenas distintas de ss. Agregamos un nuevo carácter cc a ss. Obviamente, puede haber algunas subcadenas nuevas que terminan en este nuevo carácter cc (a saber, todos aquellos strings que terminan con este símbolo y que todavía no habíamos encontrado).

Tomamos un string t=s+ct = s + c y lo invertimos (escribimos sus caracteres en orden inverso). Nuestra tarea ahora es contar cuántos prefijos de tt no se encuentran en ninguna otra parte de tt. Calculemos la función Z de tt y encontremos su valor máximo zmaxz_{max}. Obviamente, el prefijo de tt de longitud zmaxz_{max} también ocurre en algún lugar del medio de tt. 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 cc a ss es igual a length(t)zmax\operatorname{length}(t) - z_{max}.

En consecuencia, el tiempo de ejecución de esta solución es O(n2)O(n^2) para un string de longitud nn.

Vale la pena notar que exactamente de la misma forma podemos recalcular, todavía en tiempo O(n)O(n), 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 ss de longitud nn. Encontrar su representación “comprimida” más corta, es decir: encontrar un string tt de menor longitud tal que ss se pueda representar como concatenación de una o más copias de tt.

Una solución es: calcular la función Z de ss, recorrer todos los ii tales que ii divide a nn. Detenerse en el primer ii tal que i+z[i]=ni + z[i] = n. Entonces, el string ss se puede comprimir a la longitud ii.

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