Skip to Content

Autómata de sufijos

Un autómata de sufijos (suffix automaton) es una estructura de datos poderosa que permite resolver muchos problemas relacionados con strings.

Por ejemplo, se puede buscar todas las ocurrencias de un string en otro, o contar la cantidad de subcadenas distintas de un string dado. Ambas tareas se pueden resolver en tiempo lineal con ayuda de un autómata de sufijos.

Intuitivamente, un autómata de sufijos se puede entender como una forma comprimida de todas las subcadenas de un string dado. Un hecho impresionante es que el autómata de sufijos contiene toda esta información en una forma altamente comprimida. Para un string de longitud nn solo requiere memoria O(n)O(n). Además, también se puede construir en tiempo O(n)O(n) (si consideramos el tamaño kk del alfabeto como una constante); en caso contrario tanto la memoria como la complejidad temporal serán O(nlogk)O(n \log k).

La linealidad del tamaño del autómata de sufijos fue descubierta primero en 1983 por Blumer et al., y en 1985 se presentaron los primeros algoritmos lineales de construcción por Crochemore y Blumer.

Definición de un autómata de sufijos

Un autómata de sufijos para un string dado ss es un DFA mínimo (autómata finito determinista / máquina de estados finitos determinista) que acepta todos los sufijos del string ss.

En otras palabras:

  • Un autómata de sufijos es un grafo acíclico orientado. Los vértices se llaman estados, y las aristas se llaman transiciones entre estados.
  • Uno de los estados t0t_0 es el estado inicial, y debe ser la fuente del grafo (todos los demás estados son alcanzables desde t0t_0).
  • Cada transición está etiquetada con algún carácter. Todas las transiciones que salen de un estado deben tener etiquetas distintas.
  • Uno o varios estados se marcan como estados terminales. Si empezamos desde el estado inicial t0t_0 y nos movemos a lo largo de transiciones hasta un estado terminal, entonces las etiquetas de las transiciones recorridas deben deletrear uno de los sufijos del string ss. Cada uno de los sufijos de ss debe poder deletrearse usando un camino de t0t_0 a un estado terminal.
  • El autómata de sufijos contiene el mínimo número de vértices entre todos los autómatas que satisfacen las condiciones descritas arriba.

Propiedad de las subcadenas

La propiedad más simple e importante de un autómata de sufijos es que contiene información sobre todas las subcadenas del string ss. Cualquier camino que empieza en el estado inicial t0t_0, si escribimos las etiquetas de las transiciones, forma una subcadena de ss. Y recíprocamente, cada subcadena de ss corresponde a un cierto camino que empieza en t0t_0.

Para simplificar las explicaciones, diremos que la subcadena corresponde a ese camino (empezando en t0t_0 y las etiquetas deletrean la subcadena). Y recíprocamente decimos que cualquier camino corresponde al string deletreado por sus etiquetas.

Uno o varios caminos pueden llevar a un estado. Así, diremos que un estado corresponde al conjunto de strings que corresponden a estos caminos.

Ejemplos de autómatas de sufijos construidos

Aquí mostraremos algunos ejemplos de autómatas de sufijos para varios strings simples.

Denotaremos el estado inicial con azul y los estados terminales con verde.

Para el string s= ""s =~ \text{""}:

Autómata de sufijos para ""

Para el string s= “a”s =~ \text{“a”}:

Autómata de sufijos para "a"

Para el string s= “aa”s =~ \text{“aa”}:

Autómata de sufijos para "aa"

Para el string s= “ab”s =~ \text{“ab”}:

Autómata de sufijos para "ab"

Para el string s= “aba”s =~ \text{“aba”}:

Autómata de sufijos para "aba"

Para el string s= “abb”s =~ \text{“abb”}:

Autómata de sufijos para "abb"

Para el string s= “abbb”s =~ \text{“abbb”}:

Autómata de sufijos para "abbb"

Construcción en tiempo lineal

Antes de describir el algoritmo para construir un autómata de sufijos en tiempo lineal, necesitamos introducir varios conceptos nuevos y demostraciones simples, que serán muy importantes para entender la construcción.

Posiciones finales endposendpos {data-toc-label=“Posiciones finales”}

Consideremos cualquier subcadena no vacía tt del string ss. Denotaremos con endpos(t)endpos(t) el conjunto de todas las posiciones en el string ss en las que terminan las ocurrencias de tt. Por ejemplo, tenemos endpos(“bc”)={2,4}endpos(\text{“bc”}) = {2, 4} para el string “abcbc”\text{“abcbc”}.

Llamaremos a dos subcadenas t1t_1 y t2t_2 endposendpos-equivalentes si sus conjuntos de finales coinciden: endpos(t1)=endpos(t2)endpos(t_1) = endpos(t_2). Así, todas las subcadenas no vacías del string ss se pueden descomponer en varias clases de equivalencia según sus conjuntos endposendpos.

Resulta que en una máquina de sufijos las subcadenas endposendpos-equivalentes corresponden al mismo estado. En otras palabras, el número de estados en un autómata de sufijos es igual al número de clases de equivalencia entre todas las subcadenas, más el estado inicial. Cada estado de un autómata de sufijos corresponde a una o más subcadenas que tienen el mismo valor endposendpos.

Más adelante describiremos el algoritmo de construcción usando esta hipótesis. Luego veremos que se cumplen todas las propiedades requeridas de un autómata de sufijos, excepto la minimalidad. Y la minimalidad se sigue del teorema de Nerode (que no se demostrará en este artículo).

Podemos hacer algunas observaciones importantes respecto de los valores endposendpos:

Lema 1: Dos subcadenas no vacías uu y ww (con length(u)length(w)length(u) \le length(w)) son endposendpos-equivalentes si y solo si el string uu ocurre en ss solo en forma de sufijo de ww.

La demostración es obvia. Si uu y ww tienen los mismos valores endposendpos, entonces uu es un sufijo de ww y aparece solo en forma de sufijo de ww en ss. Y si uu es un sufijo de ww y aparece solo en forma de sufijo en ss, entonces los valores endposendpos son iguales por definición.

Lema 2: Consideremos dos subcadenas no vacías uu y ww (con length(u)length(w)length(u) \le length(w)). Entonces sus conjuntos endposendpos o bien no se intersectan en absoluto, o endpos(w)endpos(w) es un subconjunto de endpos(u)endpos(u). Y depende de si uu es un sufijo de ww o no.

{endpos(w)endpos(u)if u is a suffix of wendpos(w)endpos(u)=otherwise{endpos(w)endpos(u)amp;if u is a suffix of wendpos(w)endpos(u)=amp;otherwise\begin{cases} endpos(w) \subseteq endpos(u) & \text{if } u \text{ is a suffix of } w \\ endpos(w) \cap endpos(u) = \emptyset & \text{otherwise} \end{cases}

Demostración: Si los conjuntos endpos(u)endpos(u) y endpos(w)endpos(w) tienen al menos un elemento en común, entonces los strings uu y ww ambos terminan en esa posición, es decir, uu es un sufijo de ww. Pero entonces en cada ocurrencia de ww también aparece la subcadena uu, lo que significa que endpos(w)endpos(w) es un subconjunto de endpos(u)endpos(u).

Lema 3: Consideremos una clase de endposendpos-equivalencia. Ordenemos todas las subcadenas de esta clase por longitud decreciente. Entonces en la secuencia resultante cada subcadena será una más corta que la anterior, y al mismo tiempo será un sufijo de la anterior. En otras palabras, en una misma clase de equivalencia, las subcadenas más cortas son de hecho sufijos de las subcadenas más largas, y toman todas las longitudes posibles en un cierto intervalo [x;y][x; y].

Demostración: Fijemos alguna clase de endposendpos-equivalencia. Si solo contiene un string, entonces el lema es obviamente cierto. Ahora digamos que el número de strings en la clase es mayor que uno.

Según el Lema 1, dos strings endposendpos-equivalentes distintos están siempre de tal forma que el más corto es un sufijo propio del más largo. En consecuencia, no puede haber dos strings de la misma longitud en la clase de equivalencia.

Denotemos por ww el más largo, y por uu el string más corto de la clase de equivalencia. Según el Lema 1, el string uu es un sufijo propio del string ww. Consideremos ahora cualquier sufijo de ww con una longitud en el intervalo [length(u);length(w)][length(u); length(w)]. Es fácil ver que este sufijo también está contenido en la misma clase de equivalencia. Porque este sufijo solo puede aparecer en forma de sufijo de ww en el string ss (ya que también el sufijo más corto uu ocurre en ss solo en forma de sufijo de ww). En consecuencia, según el Lema 1, este sufijo es endposendpos-equivalente al string ww.

Enlaces de sufijo linklink {data-toc-label=“Enlaces de sufijo”}

Consideremos algún estado vt0v \ne t_0 en el autómata. Como sabemos, el estado vv corresponde a la clase de strings con los mismos valores endposendpos. Y si denotamos por ww el más largo de estos strings, entonces todos los demás strings son sufijos de ww.

También sabemos que los primeros sufijos de un string ww (si consideramos sufijos en orden descendente de su longitud) están todos contenidos en esta clase de equivalencia, y todos los demás sufijos (al menos uno más: el sufijo vacío) están en algunas otras clases. Denotamos por tt el mayor de esos sufijos, y hacemos un enlace de sufijo hacia él.

En otras palabras, un enlace de sufijo (suffix link) link(v)link(v) lleva al estado que corresponde al sufijo más largo de ww que está en otra clase de endposendpos-equivalencia.

Aquí asumimos que el estado inicial t0t_0 corresponde a su propia clase de equivalencia (que contiene solo el string vacío), y por conveniencia ponemos endpos(t0)={1,0,,length(s)1}endpos(t_0) = {-1, 0, \dots, length(s)-1}.

Lema 4: Los enlaces de sufijo forman un árbol con raíz t0t_0.

Demostración: Consideremos un estado arbitrario vt0v \ne t_0. Un enlace de sufijo link(v)link(v) lleva a un estado que corresponde a strings con longitud estrictamente menor (esto se sigue de la definición de los enlaces de sufijo y del Lema 3). Por lo tanto, al movernos a lo largo de los enlaces de sufijo, tarde o temprano llegaremos al estado inicial t0t_0, que corresponde al string vacío.

Lema 5: Si construimos un árbol usando los conjuntos endposendpos (por la regla de que el conjunto de un nodo padre contiene los conjuntos de todos los hijos como subconjuntos), entonces la estructura coincidirá con el árbol de enlaces de sufijo.

Demostración: El hecho de que podemos construir un árbol usando los conjuntos endposendpos se sigue directamente del Lema 2 (que cualesquiera dos conjuntos o bien no se intersectan o uno está contenido en el otro).

Consideremos ahora un estado arbitrario vt0v \ne t_0, y su enlace de sufijo link(v)link(v). De la definición del enlace de sufijo y del Lema 2 se sigue que

endpos(v)endpos(link(v)),endpos(v) \subseteq endpos(link(v)),

lo que junto con el lema anterior demuestra la afirmación: el árbol de enlaces de sufijo es esencialmente un árbol de conjuntos endposendpos.

Aquí hay un ejemplo de un árbol de enlaces de sufijo en el autómata de sufijos construido para el string “abcbc”\text{“abcbc”}. Los nodos están etiquetados con la subcadena más larga de la clase de equivalencia correspondiente.

Autómata de sufijos para "abcbc" con enlaces de sufijo

Recapitulación

Antes de proceder al algoritmo en sí, recapitulamos el conocimiento acumulado e introducimos unas notaciones auxiliares.

  • Las subcadenas del string ss se pueden descomponer en clases de equivalencia según sus posiciones finales endposendpos.
  • El autómata de sufijos consiste del estado inicial t0t_0, así como de un estado por cada clase de endposendpos-equivalencia.
  • Para cada estado vv coinciden una o varias subcadenas. Denotamos por longest(v)longest(v) el string más largo de ese tipo, y por len(v)len(v) su longitud. Denotamos por shortest(v)shortest(v) la subcadena más corta de ese tipo, y su longitud con minlen(v)minlen(v). Entonces todos los strings correspondientes a este estado son sufijos distintos del string longest(v)longest(v) y tienen todas las longitudes posibles en el intervalo [minlen(v);len(v)][minlen(v); len(v)].
  • Para cada estado vt0v \ne t_0 se define un enlace de sufijo como un enlace que lleva a un estado que corresponde al sufijo del string longest(v)longest(v) de longitud minlen(v)1minlen(v) - 1. Los enlaces de sufijo forman un árbol con raíz en t0t_0, y al mismo tiempo este árbol forma una relación de inclusión entre los conjuntos endposendpos.
  • Podemos expresar minlen(v)minlen(v) para vt0v \ne t_0 usando el enlace de sufijo link(v)link(v) como:

minlen(v)=len(link(v))+1minlen(v) = len(link(v)) + 1

  • Si empezamos desde un estado arbitrario v0v_0 y seguimos los enlaces de sufijo, entonces tarde o temprano llegaremos al estado inicial t0t_0. En este caso obtenemos una secuencia de intervalos disjuntos [minlen(vi);len(vi)][minlen(v_i); len(v_i)], que en unión forma el intervalo continuo [0;len(v0)][0; len(v_0)].

Algoritmo

Ahora podemos proceder al algoritmo en sí. El algoritmo será online, es decir, agregaremos los caracteres del string uno por uno, y modificaremos el autómata en consecuencia en cada paso.

Para alcanzar consumo de memoria lineal, solo guardaremos los valores lenlen, linklink y una lista de transiciones en cada estado. No etiquetaremos estados terminales (pero más adelante mostraremos cómo disponer estas etiquetas después de construir el autómata de sufijos).

Inicialmente el autómata consiste de un solo estado t0t_0, que será el índice 00 (los estados restantes recibirán los índices 1,2,1, 2, \dots). Le asignamos len=0len = 0 y link=1link = -1 por conveniencia (1-1 será un estado ficticio, inexistente).

Ahora toda la tarea se reduce a implementar el proceso de agregar un carácter cc al final del string actual. Describamos este proceso:

  • Sea lastlast el estado correspondiente a todo el string antes de agregar el carácter cc. (Inicialmente ponemos last=0last = 0, y cambiaremos lastlast en el último paso del algoritmo en consecuencia.)

  • Creamos un estado nuevo curcur, y le asignamos len(cur)=len(last)+1len(cur) = len(last) + 1. El valor link(cur)link(cur) no se conoce en ese momento.

  • Ahora hacemos el siguiente procedimiento: Empezamos en el estado lastlast. Mientras no haya una transición por la letra cc, agregaremos una transición al estado curcur, y seguiremos el enlace de sufijo. Si en algún punto ya existe una transición por la letra cc, entonces nos detendremos y denotaremos este estado con pp.

  • Si no encontramos tal estado pp, entonces llegamos al estado ficticio 1-1, entonces podemos simplemente asignar link(cur)=0link(cur) = 0 y salir.

  • Supongamos ahora que encontramos un estado pp, desde el cual existe una transición por la letra cc. Denotaremos el estado al que lleva la transición con qq.

  • Ahora tenemos dos casos. O bien len(p)+1=len(q)len(p) + 1 = len(q), o no.

  • Si len(p)+1=len(q)len(p) + 1 = len(q), entonces podemos simplemente asignar link(cur)=qlink(cur) = q y salir.

  • En caso contrario es un poco más complicado. Es necesario clonar el estado qq: creamos un estado nuevo cloneclone, copiamos todos los datos de qq (enlace de sufijo y transición) excepto el valor lenlen. Asignaremos len(clone)=len(p)+1len(clone) = len(p) + 1.

    Después de clonar dirigimos el enlace de sufijo de curcur a cloneclone, y también de qq a clone.

    Finalmente necesitamos caminar desde el estado pp hacia atrás usando enlaces de sufijo mientras haya una transición por cc al estado qq, y redirigir todas esas al estado cloneclone.

  • En cualquiera de los tres casos, después de completar el procedimiento, actualizamos el valor lastlast con el estado curcur.

Si también queremos saber qué estados son terminales y cuáles no, podemos encontrar todos los estados terminales después de construir el autómata de sufijos completo para todo el string ss. Para ello, tomamos el estado correspondiente a todo el string (guardado en la variable lastlast), y seguimos sus enlaces de sufijo hasta que llegamos al estado inicial. Marcaremos todos los estados visitados como terminales. Es fácil entender que al hacer eso marcaremos exactamente los estados correspondientes a todos los sufijos del string ss, que son exactamente los estados terminales.

En la siguiente sección veremos en detalle cada paso y mostraremos su corrección.

Aquí solo notamos que, como solo creamos uno o dos estados nuevos por cada carácter de ss, el autómata de sufijos contiene un número lineal de estados.

La linealidad del número de transiciones, y en general la linealidad del tiempo de ejecución del algoritmo es menos clara, y se demostrarán después de que hayamos demostrado la corrección.

Corrección

  • Llamaremos a una transición (p,q)(p, q) continua si len(p)+1=len(q)len(p) + 1 = len(q). En caso contrario, es decir, cuando len(p)+1<len(q)len(p) + 1 < len(q), la transición se llamará no continua.

    Como podemos ver de la descripción del algoritmo, las transiciones continuas y no continuas llevarán a distintos casos del algoritmo. Las transiciones continuas están fijas, y nunca cambiarán otra vez. En contraste, una transición no continua puede cambiar cuando se agregan letras nuevas al string (el extremo de la arista de transición puede cambiar).

  • Para evitar ambigüedad denotaremos el string para el cual se construyó el autómata de sufijos antes de agregar el carácter actual cc con ss.

  • El algoritmo empieza creando un estado nuevo curcur, que corresponderá a todo el string s+cs + c. Está claro por qué tenemos que crear un estado nuevo. Junto con el carácter nuevo se crea una clase de equivalencia nueva.

  • Después de crear un estado nuevo recorremos por enlaces de sufijo empezando desde el estado correspondiente a todo el string ss. Para cada estado intentamos agregar una transición con el carácter cc al estado nuevo curcur. Así agregamos a cada sufijo de ss el carácter cc. Sin embargo solo podemos agregar estas transiciones nuevas si no entran en conflicto con una ya existente. Por lo tanto, en cuanto encontramos una transición ya existente con cc tenemos que detenernos.

  • En el caso más simple llegamos al estado ficticio 1-1. Esto significa que agregamos la transición con cc a todos los sufijos de ss. Esto también significa que el carácter cc no había formado parte del string ss antes. Por lo tanto el enlace de sufijo de curcur tiene que llevar al estado 00.

  • En el segundo caso nos encontramos con una transición existente (p,q)(p, q). Esto significa que intentamos agregar un string x+cx + c (donde xx es un sufijo de ss) a la máquina que ya existe en la máquina (el string x+cx + c ya aparece como subcadena de ss). Como asumimos que el autómata para el string ss está construido correctamente, no deberíamos agregar una transición nueva aquí.

    Sin embargo hay una dificultad. ¿A qué estado debería llevar el enlace de sufijo del estado curcur? Tenemos que hacer un enlace de sufijo a un estado en el que el string más largo es exactamente x+cx + c, es decir, el lenlen de este estado debería ser len(p)+1len(p) + 1. Sin embargo es posible que tal estado aún no exista, es decir, len(q)>len(p)+1len(q) > len(p) + 1. En este caso tenemos que crear tal estado, partiendo el estado qq.

  • Si la transición (p,q)(p, q) resulta ser continua, entonces len(q)=len(p)+1len(q) = len(p) + 1. En este caso todo es simple. Dirigimos el enlace de sufijo de curcur al estado qq.

  • En caso contrario la transición es no continua, es decir, len(q)>len(p)+1len(q) > len(p) + 1. Esto significa que el estado qq corresponde no solo al sufijo de s+cs + c con longitud len(p)+1len(p) + 1, sino también a subcadenas más largas de ss. No podemos hacer otra cosa que partir el estado qq en dos subestados, de modo que el primero tenga longitud len(p)+1len(p) + 1.

    ¿Cómo podemos partir un estado? Clonamos el estado qq, lo que nos da el estado cloneclone, y ponemos len(clone)=len(p)+1len(clone) = len(p) + 1. Copiamos todas las transiciones de qq a cloneclone, porque no queremos cambiar los caminos que recorren qq. También ponemos el enlace de sufijo de cloneclone al destino del enlace de sufijo de qq, y ponemos el enlace de sufijo de qq a cloneclone.

    Y después de partir el estado, ponemos el enlace de sufijo de curcur a cloneclone.

    En el último paso cambiamos algunas de las transiciones a qq, las redirigimos a cloneclone. ¿Qué transiciones tenemos que cambiar? Basta con redirigir solo las transiciones correspondientes a todos los sufijos del string w+cw + c (donde ww es el string más largo de pp), es decir, necesitamos continuar moviéndonos a lo largo de los enlaces de sufijo, empezando desde el vértice pp hasta que llegamos al estado ficticio 1-1 o a una transición que lleva a un estado distinto de qq.

Número lineal de operaciones

Primero hacemos inmediatamente la hipótesis de que el tamaño del alfabeto es constante. Si no es el caso, entonces no será posible hablar de la complejidad temporal lineal. La lista de transiciones desde un vértice se guardará en un árbol balanceado, que permite realizar rápido operaciones de búsqueda de clave y agregar claves. Por lo tanto, si denotamos con kk el tamaño del alfabeto, entonces el comportamiento asintótico del algoritmo será O(nlogk)O(n \log k) con memoria O(n)O(n). Sin embargo, si el alfabeto es lo bastante pequeño, entonces se puede sacrificar memoria evitando árboles balanceados, y guardar las transiciones en cada vértice como un arreglo de longitud kk (para búsqueda rápida por clave) y una lista dinámica (para recorrer rápido todas las claves disponibles). Así alcanzamos la complejidad temporal O(n)O(n) para el algoritmo, pero a costa de complejidad de memoria O(nk)O(n k).

Así que consideraremos el tamaño del alfabeto constante, es decir, cada operación de buscar una transición por un carácter, agregar una transición, buscar la siguiente transición — todas estas operaciones se pueden hacer en O(1)O(1).

Si consideramos todas las partes del algoritmo, entonces contiene tres lugares en el algoritmo en los que la complejidad lineal no es obvia:

  • El primer lugar es el recorrido a través de los enlaces de sufijo desde el estado lastlast, agregando transiciones con el carácter cc.
  • El segundo lugar es la copia de transiciones cuando el estado qq se clona en un estado nuevo cloneclone.
  • El tercer lugar es cambiar la transición que lleva a qq, redirigiéndolas a cloneclone.

Usamos el hecho de que el tamaño del autómata de sufijos (tanto en el número de estados como en el número de transiciones) es lineal. (La demostración de la linealidad del número de estados es el algoritmo mismo, y la demostración de linealidad del número de estados se da abajo, después de la implementación del algoritmo).

Así, la complejidad total del primer y segundo lugar es obvia, después de todo cada operación agrega solo una transición nueva amortizada al autómata.

Resta estimar la complejidad total del tercer lugar, en el que redirigimos transiciones que originalmente apuntaban a qq, a cloneclone. Denotamos v=longest(p)v = longest(p). Este es un sufijo del string ss, y con cada iteración su longitud disminuye — y por tanto la posición vv como sufijo del string ss aumenta de forma monótona con cada iteración. En este caso, si antes de la primera iteración del bucle, el string correspondiente vv estaba a profundidad kk (k2k \ge 2) de lastlast (contando la profundidad como el número de enlaces de sufijo), entonces después de la última iteración el string v+cv + c será un 2-ésimo enlace de sufijo en el camino desde curcur (que se convertirá en el valor nuevo lastlast).

Así, cada iteración de este bucle lleva al hecho de que la posición del string longest(link(link(last))longest(link(link(last)) como sufijo del string actual aumentará de forma monótona. Por lo tanto este ciclo no puede ejecutarse más de nn iteraciones, que era lo que se requería demostrar.

Implementación

Primero describimos una estructura de datos que guardará toda la información sobre una transición específica (lenlen, linklink y la lista de transiciones). Si es necesario se puede agregar un flag terminal aquí, así como otra información. Guardaremos la lista de transiciones en forma de un mapmap, que nos permite alcanzar memoria total O(n)O(n) y tiempo O(nlogk)O(n \log k) para procesar todo el string.

struct state { int len, link; map<char, int> next; };

El autómata de sufijos mismo se guardará en un arreglo de estas estructuras statestate. Guardamos el tamaño actual szsz y también la variable lastlast, el estado correspondiente a todo el string en el momento.

const int MAXLEN = 100000; state st[MAXLEN * 2]; int sz, last;

Damos una función que inicializa un autómata de sufijos (creando un autómata de sufijos con un solo estado).

void sa_init() { st[0].len = 0; st[0].link = -1; sz++; last = 0; }

Y finalmente damos la implementación de la función principal — que agrega el siguiente carácter al final de la línea actual, reconstruyendo la máquina en consecuencia.

void sa_extend(char c) { int cur = sz++; st[cur].len = st[last].len + 1; int p = last; while (p != -1 && !st[p].next.count(c)) { st[p].next[c] = cur; p = st[p].link; } if (p == -1) { st[cur].link = 0; } else { int q = st[p].next[c]; if (st[p].len + 1 == st[q].len) { st[cur].link = q; } else { int clone = sz++; st[clone].len = st[p].len + 1; st[clone].next = st[q].next; st[clone].link = st[q].link; while (p != -1 && st[p].next[c] == q) { st[p].next[c] = clone; p = st[p].link; } st[q].link = st[cur].link = clone; } } last = cur; }

Como se mencionó arriba, si se sacrifica memoria (O(nk)O(n k), donde kk es el tamaño del alfabeto), entonces se puede alcanzar el tiempo de construcción de la máquina en O(n)O(n), incluso para cualquier tamaño de alfabeto kk. Pero para esto habrá que guardar un arreglo de tamaño kk en cada estado (para saltar rápido a la transición de la letra), y adicionalmente una lista de todas las transiciones (para iterar rápido sobre las transiciones).

Propiedades adicionales

Número de estados

El número de estados en un autómata de sufijos del string ss de longitud nn no excede 2n12n - 1 (para n2n \ge 2).

La demostración es el algoritmo de construcción mismo, ya que inicialmente el autómata consiste de un estado, y en la primera y segunda iteración solo se creará un solo estado, y en los n2n-2 pasos restantes se crearán a lo sumo 22 estados cada uno.

Sin embargo también podemos mostrar esta estimación sin conocer el algoritmo. Recordemos que el número de estados es igual al número de conjuntos endposendpos distintos. Además estos conjuntos endposendpos forman un árbol (un vértice padre contiene todos los conjuntos hijos en su conjunto). Consideremos este árbol y transformémoslo un poco: mientras tenga un vértice interno con solo un hijo (lo que significa que el conjunto del hijo omite al menos una posición del conjunto padre), creamos un hijo nuevo con el conjunto de las posiciones faltantes. Al final tenemos un árbol en el que cada vértice interno tiene grado mayor que uno, y el número de hojas no excede nn. Por lo tanto no hay más de 2n12n - 1 vértices en tal árbol.

Esta cota del número de estados se puede de hecho alcanzar para cada nn. Un string posible es:

“abbbbbb”\text{“abbb}\dots \text{bbb”}

En cada iteración, empezando en la tercera, el algoritmo partirá un estado, resultando en exactamente 2n12n - 1 estados.

Número de transiciones

El número de transiciones en un autómata de sufijos de un string ss de longitud nn no excede 3n43n - 4 (para n3n \ge 3).

Demostremos esto:

Primero estimemos el número de transiciones continuas. Consideremos un árbol de expansión de los caminos más largos en el autómata empezando en el estado t0t_0. Este esqueleto consistirá solo de las aristas continuas, y por tanto su número es menor que el número de estados, es decir, no excede 2n22n - 2.

Ahora estimemos el número de transiciones no continuas. Sea la transición no continua actual (p,q)(p, q) con el carácter cc. Tomamos el string correspondiente u+c+wu + c + w, donde el string uu corresponde al camino más largo del estado inicial a pp, y ww al camino más largo de qq a cualquier estado terminal. Por un lado, cada tal string u+c+wu + c + w para cada string incompleto será distinto (ya que los strings uu y ww se forman solo por transiciones completas). Por otro lado cada tal string u+c+wu + c + w, por la definición de los estados terminales, será un sufijo de todo el string ss. Como hay solo nn sufijos no vacíos de ss, y ninguno de los strings u+c+wu + c + w puede contener ss (porque todo el string solo contiene transiciones completas), el número total de transiciones incompletas no excede n1n - 1.

Combinando estas dos estimaciones nos da la cota 3n33n - 3. Sin embargo, como el número máximo de estados solo se puede alcanzar con el caso de prueba “abbbbbb”\text{“abbb\dots bbb”} y este caso claramente tiene menos de 3n33n - 3 transiciones, obtenemos la cota más ajustada de 3n43n - 4 para el número de transiciones en un autómata de sufijos.

Esta cota también se puede alcanzar con el string:

“abbbbbbc”\text{“abbb}\dots \text{bbbc”}

Aplicaciones

Aquí vemos algunas tareas que se pueden resolver usando el autómata de sufijos. Por simplicidad asumimos que el tamaño del alfabeto kk es constante, lo que nos permite considerar la complejidad de agregar un carácter y el recorrido como constantes.

Comprobar ocurrencia

Dado un texto TT, y varios patrones PP. Tenemos que comprobar si los strings PP aparecen o no como subcadena de TT.

Construimos un autómata de sufijos del texto TT en tiempo O(length(T))O(length(T)). Para comprobar si un patrón PP aparece en TT, seguimos las transiciones, empezando desde t0t_0, según los caracteres de PP. Si en algún punto no existe una transición, entonces el patrón PP no aparece como subcadena de TT. Si podemos procesar todo el string PP de esta forma, entonces el string aparece en TT.

Es claro que esto tomará tiempo O(length(P))O(length(P)) por cada string PP. Además el algoritmo de hecho encuentra la longitud del prefijo más largo de PP que aparece en el texto.

Número de subcadenas distintas

Dado un string SS. Se quiere calcular el número de subcadenas distintas.

Construyamos un autómata de sufijos para el string SS.

Cada subcadena de SS corresponde a algún camino en el autómata. Por lo tanto el número de subcadenas distintas es igual al número de caminos distintos en el autómata empezando en t0t_0.

Dado que el autómata de sufijos es un grafo dirigido acíclico, el número de formas distintas se puede calcular usando programación dinámica.

A saber, sea d[v]d[v] el número de formas, empezando en el estado vv (incluyendo el camino de longitud cero). Entonces tenemos la recursión:

d[v]=1+w:(v,w,c)DAWGd[w]d[v] = 1 + \sum_{w : (v, w, c) \in DAWG} d[w]

Es decir, d[v]d[v] se puede expresar como la suma de respuestas para todos los extremos de las transiciones de vv.

El número de subcadenas distintas es el valor d[t0]1d[t_0] - 1 (ya que no contamos la subcadena vacía).

Complejidad temporal total: O(length(S))O(length(S))

Como alternativa, podemos aprovechar el hecho de que cada estado vv coincide con subcadenas de longitud [minlen(v),len(v)][minlen(v),len(v)]. Por lo tanto, dado minlen(v)=1+len(link(v))minlen(v) = 1 + len(link(v)), tenemos el total de subcadenas distintas en el estado vv siendo len(v)minlen(v)+1=len(v)(1+len(link(v)))+1=len(v)len(link(v))len(v) - minlen(v) + 1 = len(v) - (1 + len(link(v))) + 1 = len(v) - len(link(v)).

Esto se demuestra de forma sucinta abajo:

long long get_diff_strings(){ long long tot = 0; for(int i = 1; i < sz; i++) { tot += st[i].len - st[st[i].link].len; } return tot; }

Aunque esto también es O(length(S))O(length(S)), no requiere espacio extra ni llamadas recursivas, en consecuencia corre más rápido en la práctica.

Longitud total de todas las subcadenas distintas

Dado un string SS. Queremos calcular la longitud total de todas sus subcadenas distintas.

La solución es similar a la anterior, solo que ahora es necesario considerar dos cantidades para la parte de programación dinámica: el número de subcadenas distintas d[v]d[v] y su longitud total ans[v]ans[v].

Ya describimos cómo calcular d[v]d[v] en la tarea anterior. El valor ans[v]ans[v] se puede calcular usando la recursión:

ans[v]=w:(v,w,c)DAWGd[w]+ans[w]ans[v] = \sum_{w : (v, w, c) \in DAWG} d[w] + ans[w]

Tomamos la respuesta de cada vértice adyacente ww, y le sumamos d[w]d[w] (ya que cada subcadena es un carácter más larga cuando se empieza desde el estado vv).

Otra vez esta tarea se puede calcular en tiempo O(length(S))O(length(S)).

Como alternativa, podemos, otra vez, aprovechar el hecho de que cada estado vv coincide con subcadenas de longitud [minlen(v),len(v)][minlen(v),len(v)]. Como minlen(v)=1+len(link(v))minlen(v) = 1 + len(link(v)) y la fórmula de la serie aritmética Sn=na1+an2S_n = n \cdot \frac{a_1+a_n}{2} (donde SnS_n denota la suma de nn términos, a1a_1 representando el primer término, y ana_n representando el último), podemos calcular la longitud de las subcadenas en un estado en tiempo constante. Luego sumamos estos totales para cada estado vt0v \neq t_0 en el autómata. Esto se muestra en el código de abajo:

long long get_tot_len_diff_substings() { long long tot = 0; for(int i = 1; i < sz; i++) { long long shortest = st[st[i].link].len + 1; long long longest = st[i].len; long long num_strings = longest - shortest + 1; long long cur = num_strings * (longest + shortest) / 2; tot += cur; } return tot; }

Este enfoque corre en tiempo O(length(S))O(length(S)), pero experimentalmente corre 20x más rápido que la versión de programación dinámica con memoización en strings aleatorizados. No requiere espacio extra ni recursión.

kk-ésima subcadena lexicográfica {data-toc-label=“k-ésima subcadena lexicográfica”}

Dado un string SS. Tenemos que responder múltiples consultas. Para cada número dado KiK_i tenemos que encontrar el KiK_i-ésimo string en la lista ordenada lexicográficamente de todas las subcadenas.

La solución a este problema se basa en la idea de los dos problemas anteriores. La kk-ésima subcadena lexicográfica corresponde al kk-ésimo camino lexicográfico en el autómata de sufijos. Por lo tanto, después de contar el número de caminos desde cada estado, podemos buscar fácilmente el kk-ésimo camino empezando desde la raíz del autómata.

Esto toma tiempo O(length(S))O(length(S)) para el preprocesamiento y luego O(length(ans)k)O(length(ans) \cdot k) por cada consulta (donde ansans es la respuesta a la consulta y kk es el tamaño del alfabeto).

Menor desplazamiento cíclico

Dado un string SS. Queremos encontrar el desplazamiento cíclico lexicográficamente más pequeño.

Construimos un autómata de sufijos para el string S+SS + S. Entonces el autómata contendrá en sí mismo como caminos todos los desplazamientos cíclicos del string SS.

En consecuencia el problema se reduce a encontrar el camino lexicográficamente más pequeño de longitud length(S)length(S), lo que se puede hacer de forma trivial: empezamos en el estado inicial y pasamos de forma voraz por las transiciones con el carácter mínimo.

La complejidad temporal total es O(length(S))O(length(S)).

Número de ocurrencias

Para un texto dado TT. Tenemos que responder múltiples consultas. Para cada patrón dado PP tenemos que averiguar cuántas veces aparece el string PP en el string TT como subcadena.

Construimos el autómata de sufijos para el texto TT.

Luego hacemos el siguiente preprocesamiento: para cada estado vv en el autómata calculamos el número cnt[v]cnt[v] que es igual al tamaño del conjunto endpos(v)endpos(v). De hecho todos los strings correspondientes al mismo estado vv aparecen en el texto TT una cantidad igual de veces, que es igual al número de posiciones en el conjunto endposendpos.

Sin embargo no podemos construir los conjuntos endposendpos de forma explícita, por lo tanto solo consideramos sus tamaños cntcnt.

Para calcularlos procedemos de la siguiente forma. Para cada estado, si no fue creado por clonación (y si no es el estado inicial t0t_0), lo inicializamos con cnt=1cnt = 1. Luego recorreremos todos los estados en orden decreciente de su longitud lenlen, y sumaremos el valor actual cnt[v]cnt[v] a los enlaces de sufijo:

cnt[link(v)] += cnt[v]cnt[link(v)] \text{ += } cnt[v]

Esto da el valor correcto para cada estado.

¿Por qué es correcto? El número total de estados obtenidos no vía clonación es exactamente length(T)length(T), y los primeros ii de ellos aparecieron cuando agregamos los primeros ii caracteres. En consecuencia para cada uno de estos estados contamos la posición correspondiente en la que se procesó. Por lo tanto inicialmente tenemos cnt=1cnt = 1 para cada estado de ese tipo, y cnt=0cnt = 0 para todos los demás.

Luego aplicamos la siguiente operación para cada vv: cnt[link(v)] += cnt[v]cnt[link(v)] \text{ += } cnt[v]. El significado detrás de esto es que si un string vv aparece cnt[v]cnt[v] veces, entonces también todos sus sufijos aparecen en las mismas posiciones finales exactas, por tanto también cnt[v]cnt[v] veces.

¿Por qué no sobrecontamos en este procedimiento (es decir, no contamos algunas posiciones dos veces)? Porque agregamos las posiciones de un estado a solo un otro estado, así que no puede ocurrir que un estado dirija sus posiciones a otro estado dos veces de dos formas distintas.

Así podemos calcular las cantidades cntcnt para todos los estados del autómata en tiempo O(length(T))O(length(T)).

Después de eso responder una consulta es simplemente consultar el valor cnt[t]cnt[t], donde tt es el estado correspondiente al patrón, si tal estado existe. En caso contrario responder con 00. Responder una consulta toma tiempo O(length(P))O(length(P)).

Posición de la primera ocurrencia

Dado un texto TT y múltiples consultas. Para cada string de consulta PP queremos encontrar la posición de la primera ocurrencia de PP en el string TT (la posición del inicio de PP).

Otra vez construimos un autómata de sufijos. Además precomputamos la posición firstposfirstpos para todos los estados del autómata, es decir, para cada estado vv queremos encontrar la posición firstpos[v]firstpos[v] del final de la primera ocurrencia. En otras palabras, queremos encontrar de antemano el elemento mínimo de cada conjunto endposendpos (ya que obviamente no podemos mantener todos los conjuntos endposendpos de forma explícita).

Para mantener estas posiciones firstposfirstpos extendemos la función sa_extend(). Cuando creamos un estado nuevo curcur, ponemos:

firstpos(cur)=len(cur)1firstpos(cur) = len(cur) - 1

Y cuando clonamos un vértice qq como cloneclone, ponemos:

firstpos(clone)=firstpos(q)firstpos(clone) = firstpos(q)

(ya que la única otra opción para un valor sería firstpos(cur)firstpos(cur) que es definitivamente demasiado grande)

Así la respuesta a una consulta es simplemente firstpos(t)length(P)+1firstpos(t) - length(P) + 1, donde tt es el estado correspondiente al string PP. Responder una consulta otra vez toma solo tiempo O(length(P))O(length(P)).

Todas las posiciones de ocurrencia

Esta vez tenemos que mostrar todas las posiciones de las ocurrencias en el string TT.

Otra vez construimos un autómata de sufijos para el texto TT. Similar a la tarea anterior computamos la posición firstposfirstpos para todos los estados.

Claramente firstpos(t)firstpos(t) es parte de la respuesta, si tt es el estado correspondiente a un string de consulta PP. Así tomamos en cuenta el estado del autómata que contiene PP. ¿Qué otros estados necesitamos tomar en cuenta? Todos los estados que corresponden a strings para los cuales PP es un sufijo. En otras palabras necesitamos encontrar todos los estados que pueden alcanzar el estado tt vía enlaces de sufijo.

Por lo tanto para resolver el problema necesitamos guardar para cada estado una lista de referencias de sufijo que llevan a él. La respuesta a la consulta entonces contendrá todos los firstposfirstpos para cada estado que podemos encontrar en un DFS / BFS empezando desde el estado tt usando solo las referencias de sufijo.

En general, esto requiere O(length(T))O(length (T)) para el preprocesamiento y O(length(P)+answer(P))O(length(P) + answer(P)) por cada petición, donde answer(P)answer(P) — este es el tamaño de la respuesta.

Primero, bajamos por el autómata por cada carácter del patrón para encontrar nuestro nodo de inicio requiriendo O(length(P))O(length(P)). Luego, usamos nuestro workaround que funcionará en tiempo O(answer(P))O(answer(P)), porque no visitaremos un estado dos veces (porque solo un enlace de sufijo sale de cada estado, así que no puede haber dos caminos distintos que lleven al mismo estado).

Solo debemos tener en cuenta que dos estados distintos pueden tener el mismo valor firstposfirstpos. Esto ocurre si un estado se obtuvo clonando otro. Sin embargo, esto no arruina la complejidad, ya que cada estado solo puede tener a lo sumo un clon.

Además, también podemos deshacernos de las posiciones duplicadas, si no emitimos las posiciones de los estados clonados. De hecho un estado, al que un estado clonado puede llegar, también es alcanzable desde el estado original. Así si recordamos el flag is_cloned para cada estado, podemos simplemente ignorar los estados clonados y solo emitir firstposfirstpos para todos los demás estados.

Aquí hay algunos esbozos de implementación:

struct state { ... bool is_clone; int first_pos; vector<int> inv_link; }; // after constructing the automaton for (int v = 1; v < sz; v++) { st[st[v].link].inv_link.push_back(v); } // output all positions of occurrences void output_all_occurrences(int v, int P_length) { if (!st[v].is_clone) cout << st[v].first_pos - P_length + 1 << endl; for (int u : st[v].inv_link) output_all_occurrences(u, P_length); }

String más corto que no aparece

Dado un string SS y un cierto alfabeto. Tenemos que encontrar un string de la menor longitud, que no aparece en SS.

Aplicaremos programación dinámica sobre el autómata de sufijos construido para el string SS.

Sea d[v]d[v] la respuesta para el nodo vv, es decir, ya procesamos parte de la subcadena, estamos actualmente en el estado vv, y queremos encontrar el menor número de caracteres que hay que agregar para encontrar una transición inexistente. Calcular d[v]d[v] es muy simple. Si no hay transición usando al menos un carácter del alfabeto, entonces d[v]=1d[v] = 1. En caso contrario un carácter no es suficiente, y así necesitamos tomar el mínimo de todas las respuestas de todas las transiciones:

d[v]=1+minw:(v,w,c)SAd[w].d[v] = 1 + \min_{w:(v,w,c) \in SA} d[w].

La respuesta al problema será d[t0]d[t_0], y el string real se puede restaurar usando el arreglo calculado d[]d[].

Subcadena común más larga de dos strings

Dados dos strings SS y TT. Tenemos que encontrar la subcadena común más larga, es decir, un string XX que aparece como subcadena en SS y también en TT.

Construimos un autómata de sufijos para el string SS.

Ahora tomaremos el string TT, y para cada prefijo buscaremos el sufijo más largo de este prefijo en SS. En otras palabras, para cada posición en el string TT, queremos encontrar la subcadena común más larga de SS y TT que termina en esa posición.

Para ello usaremos dos variables, el estado actual vv, y la longitud actual ll. Estas dos variables describirán la parte coincidente actual: su longitud y el estado que le corresponde.

Inicialmente v=t0v = t_0 y l=0l = 0, es decir, la coincidencia está vacía.

Ahora describamos cómo podemos agregar un carácter T[i]T[i] y recalcular la respuesta para él.

  • Si hay una transición desde vv con el carácter T[i]T[i], entonces simplemente seguimos la transición y aumentamos ll en uno.
  • Si no hay tal transición, tenemos que acortar la parte coincidente actual, lo que significa que necesitamos seguir el enlace de sufijo: v=link(v)v = link(v). Al mismo tiempo, la longitud actual tiene que acortarse. Obviamente necesitamos asignar l=len(v)l = len(v), ya que después de pasar por el enlace de sufijo terminamos en un estado cuyo string más largo correspondiente es una subcadena.
  • Si todavía no hay transición usando el carácter requerido, repetimos y otra vez vamos por el enlace de sufijo y disminuimos ll, hasta que encontramos una transición o llegamos al estado ficticio 1-1 (lo que significa que el símbolo T[i]T[i] no aparece en absoluto en SS, así que asignamos v=l=0v = l = 0).

La respuesta a la tarea será el máximo de todos los valores ll.

La complejidad de esta parte es O(length(T))O(length(T)), ya que en un movimiento podemos o bien aumentar ll en uno, o hacer varios pases a través de los enlaces de sufijo, cada uno termina reduciendo el valor ll.

Implementación:

string lcs (string S, string T) { sa_init(); for (int i = 0; i < S.size(); i++) sa_extend(S[i]); int v = 0, l = 0, best = 0, bestpos = 0; for (int i = 0; i < T.size(); i++) { while (v && !st[v].next.count(T[i])) { v = st[v].link ; l = st[v].len; } if (st[v].next.count(T[i])) { v = st [v].next[T[i]]; l++; } if (l > best) { best = l; bestpos = i; } } return T.substr(bestpos - best + 1, best); }

Subcadena común más larga de múltiples strings

Hay kk strings SiS_i dados. Tenemos que encontrar la subcadena común más larga, es decir, un string XX que aparece como subcadena en cada string SiS_i.

Unimos todos los strings en un string grande TT, separando los strings por caracteres especiales DiD_i (uno por cada string):

T=S1+D1+S2+D2++Sk+Dk.T = S_1 + D_1 + S_2 + D_2 + \dots + S_k + D_k.

Luego construimos el autómata de sufijos para el string TT.

Ahora necesitamos encontrar un string en la máquina, que está contenido en todos los strings SiS_i, y esto se puede hacer usando los caracteres especiales agregados. Nótese que si una subcadena está incluida en algún string SjS_j, entonces en el autómata de sufijos existe un camino empezando desde esta subcadena que contiene el carácter DjD_j y no contiene los otros caracteres D1,,Dj1,Dj+1,,DkD_1, \dots, D_{j-1}, D_{j+1}, \dots, D_k.

Así necesitamos calcular la alcanzabilidad, que nos dice para cada estado de la máquina y cada símbolo DiD_i si existe tal camino. Esto se puede computar fácilmente con DFS o BFS y programación dinámica. Después de eso, la respuesta al problema será el string longest(v)longest(v) para el estado vv, desde el cual existían los caminos para todos los caracteres especiales.

Problemas de práctica