Arreglo de sufijos
Definición
Sea un string de longitud . El -ésimo sufijo de es la subcadena .
Un arreglo de sufijos (suffix array) contendrá enteros que representan los índices de inicio de todos los sufijos de un string dado, después de que dichos sufijos se hayan ordenado.
Como ejemplo, miremos el string . Todos los sufijos son los siguientes
- & baab \
- & aab \
- & ab \
- & b \end{array}
Después de ordenar estos strings:
- & baab \end{array}
Por lo tanto el arreglo de sufijos de será .
Como estructura de datos se usa ampliamente en áreas como compresión de datos, bioinformática y, en general, en cualquier área que trate con strings y problemas de matching de strings.
Construcción
Enfoque {data-toc-label=“Enfoque O(n^2 log n)”}
Este es el enfoque más naive. Obtener todos los sufijos y ordenarlos usando quicksort o mergesort, reteniendo simultáneamente sus índices originales. El ordenamiento usa comparaciones, y como comparar dos strings tomará adicionalmente de tiempo, obtenemos la complejidad final .
Enfoque {data-toc-label=“Enfoque O(n log n)”}
Estrictamente hablando, el siguiente algoritmo no ordenará los sufijos, sino más bien los desplazamientos cíclicos de un string. Sin embargo, de él podemos derivar muy fácilmente un algoritmo para ordenar sufijos: basta con agregar al final del string un carácter arbitrario que sea más pequeño que cualquier carácter del string. Es común usar el símbolo <span class=“katex-display”>dabbb$.
- & abbb<span class=“katex-error” title=“ParseError: KaTeX parse error: Expected ‘EOF’, got ’&’ at position 3: d &̲amp; abbb \ 4.…” style=“color:#cc0000”>d & abbb \
- & b</span>dabb & b \
- & bb<span class=“katex-error” title=“ParseError: KaTeX parse error: Expected ‘EOF’, got ’&’ at position 5: dab &̲amp; bb \
- &…” style=“color:#cc0000”>dab & bb \
- & bbb</span>da & bbb \
- & dabbb$ & dabbb \end{array}
Como vamos a ordenar desplazamientos cíclicos, consideraremos subcadenas cíclicas. Usaremos la notación para la subcadena de incluso si . En este caso en realidad nos referimos al string . Además tomaremos todos los índices módulo la longitud de , y omitiremos la operación módulo por simplicidad.
El algoritmo que discutimos realizará iteraciones. En la -ésima iteración () ordenamos las subcadenas cíclicas de de longitud . Después de la -ésima iteración las subcadenas de longitud estarán ordenadas, así que esto es equivalente a ordenar los desplazamientos cíclicos por completo.
En cada iteración del algoritmo, además de la permutación , donde es el índice de la -ésima subcadena (que empieza en y tiene longitud ) en el orden ordenado, también mantendremos un arreglo , donde corresponde a la clase de equivalencia a la que pertenece la subcadena. Porque algunas de las subcadenas serán idénticas, y el algoritmo necesita tratarlas de igual forma. Por conveniencia las clases se etiquetarán con números empezando desde cero. Además los números se asignarán de forma que preserven información sobre el orden: si una subcadena es más pequeña que la otra, entonces también debería tener una etiqueta de clase más pequeña. El número de clases de equivalencia se almacenará en una variable .
Miremos un ejemplo. Consideremos el string . Las subcadenas cíclicas y los arreglos correspondientes y se dan para cada iteración:
Cabe notar que los valores de pueden ser distintos. Por ejemplo en la iteración -ésima el arreglo también podría ser o . Todas estas opciones permutan las subcadenas a un orden ordenado. Así que todas son válidas. Al mismo tiempo el arreglo es fijo: no puede haber ambigüedades.
Enfoquémonos ahora en la implementación del algoritmo. Escribiremos una función que toma un string y devuelve las permutaciones de los desplazamientos cíclicos ordenados.
vector<int> sort_cyclic_shifts(string const& s) {
int n = s.size();
const int alphabet = 256;Al comienzo (en la iteración -ésima) debemos ordenar las subcadenas cíclicas de longitud , es decir, tenemos que ordenar todos los caracteres del string y dividirlos en clases de equivalencia (los mismos símbolos se asignan a la misma clase). Esto se puede hacer de forma trivial, por ejemplo, usando ordenamiento por conteo. Para cada carácter contamos cuántas veces aparece en el string, y luego usamos esta información para crear el arreglo . Después recorremos el arreglo y construimos comparando caracteres adyacentes.
vector<int> p(n), c(n), cnt(max(alphabet, n), 0);
for (int i = 0; i < n; i++)
cnt[s[i]]++;
for (int i = 1; i < alphabet; i++)
cnt[i] += cnt[i-1];
for (int i = 0; i < n; i++)
p[--cnt[s[i]]] = i;
c[p[0]] = 0;
int classes = 1;
for (int i = 1; i < n; i++) {
if (s[p[i]] != s[p[i-1]])
classes++;
c[p[i]] = classes - 1;
}Ahora tenemos que hablar del paso de iteración. Asumamos que ya realizamos el paso -ésimo y calculamos los valores de los arreglos y para él. Queremos calcular los valores del paso -ésimo en tiempo . Como realizamos este paso veces, el algoritmo completo tendrá una complejidad temporal de .
Para ello, nótese que las subcadenas cíclicas de longitud consisten en dos subcadenas de longitud que podemos comparar entre sí en usando la información de la fase anterior — los valores de las clases de equivalencia . Así, para dos subcadenas de longitud que empiezan en las posiciones y , toda la información necesaria para compararlas está contenida en los pares y .
{\text{length} = 2^{k-1},~ \text{class} = c[i]} \quad \underbrace{s{i+2^{k-1}} \dots s_{i+2^k-1}}{\text{length} = 2^{k-1},~ \text{class} = c[i + 2^{k-1}]} }^{\text{length} = 2^k} \dots \overbrace{ \underbrace{s_j \dots s{j+2^{k-1}-1}}{\text{length} = 2^{k-1},~ \text{class} = c[j]} \quad \underbrace{s{j+2^{k-1}} \dots s_{j+2^k-1}}_{\text{length} = 2^{k-1},~ \text{class} = c[j + 2^{k-1}]} }^{\text{length} = 2^k} \dots
Esto nos da una solución muy simple: ordenar las subcadenas de longitud por estos pares de números. Esto nos dará el orden requerido . Sin embargo un ordenamiento normal corre en tiempo , con lo cual no estamos satisfechos. Esto solo nos daría un algoritmo para construir un arreglo de sufijos en tiempo .
¿Cómo realizamos rápidamente tal ordenamiento de los pares? Como los elementos de los pares no superan , podemos usar de nuevo ordenamiento por conteo. Sin embargo ordenar pares con ordenamiento por conteo no es lo más eficiente. Para lograr una mejor constante oculta en la complejidad, usaremos otro truco.
Usamos aquí la técnica en la que se basa el radix sort: para ordenar los pares primero los ordenamos por el segundo elemento, y luego por el primer elemento (con un ordenamiento estable, es decir, un ordenamiento que no rompe el orden relativo de elementos iguales). Sin embargo los segundos elementos ya estaban ordenados en la iteración anterior. Así, para ordenar los pares por los segundos elementos, solo necesitamos restar a los índices en (p. ej. si la subcadena más pequeña de longitud empieza en la posición , entonces la subcadena de longitud con la segunda mitad más pequeña empieza en ).
Así, solo con restas simples podemos ordenar los segundos elementos de los pares en . Ahora necesitamos realizar un ordenamiento estable por los primeros elementos. Como ya se mencionó, esto se puede lograr con ordenamiento por conteo.
Lo único que queda es calcular las clases de equivalencia , pero como antes esto se puede hacer simplemente iterando sobre la permutación ordenada y comparando pares vecinos.
Aquí está el resto de la implementación. Usamos arreglos temporales y para almacenar la permutación por los segundos elementos y los nuevos índices de clase de equivalencia.
vector<int> pn(n), cn(n);
for (int h = 0; (1 << h) < n; ++h) {
for (int i = 0; i < n; i++) {
pn[i] = p[i] - (1 << h);
if (pn[i] < 0)
pn[i] += n;
}
fill(cnt.begin(), cnt.begin() + classes, 0);
for (int i = 0; i < n; i++)
cnt[c[pn[i]]]++;
for (int i = 1; i < classes; i++)
cnt[i] += cnt[i-1];
for (int i = n-1; i >= 0; i--)
p[--cnt[c[pn[i]]]] = pn[i];
cn[p[0]] = 0;
classes = 1;
for (int i = 1; i < n; i++) {
pair<int, int> cur = {c[p[i]], c[(p[i] + (1 << h)) % n]};
pair<int, int> prev = {c[p[i-1]], c[(p[i-1] + (1 << h)) % n]};
if (cur != prev)
++classes;
cn[p[i]] = classes - 1;
}
c.swap(cn);
}
return p;
}El algoritmo requiere de tiempo y de memoria. Por simplicidad usamos el rango ASCII completo como alfabeto.
Si se sabe que el string solo contiene un subconjunto de caracteres, p. ej. solo letras minúsculas, entonces la implementación se puede optimizar, pero el factor de optimización probablemente sería insignificante, ya que el tamaño del alfabeto solo importa en la primera iteración. Toda otra iteración depende del número de clases de equivalencia, que puede alcanzar rápidamente incluso si inicialmente era un string sobre un alfabeto de tamaño .
Nótese también que este algoritmo solo ordena los desplazamientos cíclicos. Como se mencionó al comienzo de esta sección, podemos generar el orden ordenado de los sufijos agregando un carácter más pequeño que todos los demás caracteres del string, y ordenando el string resultante por desplazamientos cíclicos, p. ej. ordenando los desplazamientos cíclicos de s + </span>s|s|$.
vector<int> suffix_array_construction(string s) {
s += "$";
vector<int> sorted_shifts = sort_cyclic_shifts(s);
sorted_shifts.erase(sorted_shifts.begin());
return sorted_shifts;
}Aplicaciones
Encontrar el desplazamiento cíclico más pequeño
El algoritmo de arriba ordena todos los desplazamientos cíclicos (sin agregar un carácter al string), y por lo tanto da la posición del desplazamiento cíclico más pequeño.
Encontrar una subcadena en un string
La tarea es encontrar un string dentro de algún texto de forma online: conocemos el texto de antemano, pero no el string . Podemos crear el arreglo de sufijos del texto en tiempo . Ahora podemos buscar la subcadena de la siguiente manera. La ocurrencia de debe ser un prefijo de algún sufijo de . Como ordenamos todos los sufijos, podemos realizar una búsqueda binaria de en . Comparar el sufijo actual y la subcadena dentro de la búsqueda binaria se puede hacer en tiempo , por lo tanto la complejidad para encontrar la subcadena es . Nótese también que si la subcadena ocurre múltiples veces en , entonces todas las ocurrencias estarán unas al lado de otras en . Por lo tanto el número de ocurrencias se puede encontrar con una segunda búsqueda binaria, y todas las ocurrencias se pueden imprimir fácilmente.
Comparar dos subcadenas de un string
Queremos poder comparar dos subcadenas de la misma longitud de un string dado en tiempo , es decir, comprobar si la primera subcadena es más pequeña que la segunda.
Para ello construimos el arreglo de sufijos en tiempo y almacenamos todos los resultados intermedios de las clases de equivalencia .
Usando esta información podemos comparar cualesquiera dos subcadenas cuya longitud sea una potencia de dos en O(1): para ello basta con comparar las clases de equivalencia de ambas subcadenas. Ahora queremos generalizar este método a subcadenas de longitud arbitraria.
Comparemos dos subcadenas de longitud con índices de inicio y . Encontramos la mayor longitud de un bloque que cabe dentro de una subcadena de esta longitud: el mayor tal que . Entonces comparar las dos subcadenas se puede reemplazar por comparar dos bloques superpuestos de longitud : primero hay que comparar los dos bloques que empiezan en y , y si estos son iguales entonces comparar los dos bloques que terminan en las posiciones y :
{2^k} \dots s{i+l-1}}^{\text{first}} \dots \overbrace{\underbrace{s_j \dots s_{j+l-2^k} \dots s_{j+2^k-1}}{2^k} \dots s{j+l-1}}^{\text{second}} \dots
{2^k}}^{\text{first}} \dots \overbrace{s_j \dots \underbrace{s{j+l-2^k} \dots s_{j+2^k-1} \dots s_{j+l-1}}_{2^k}}^{\text{second}} \dots
Aquí está la implementación de la comparación. Nótese que se asume que la función se llama con el ya calculado. se puede computar con , pero es más eficiente precomputar todos los valores de para cada . Véase por ejemplo el artículo sobre la Tabla Dispersa, que usa una idea similar y computa todos los valores de .
int compare(int i, int j, int l, int k) {
pair<int, int> a = {c[k][i], c[k][(i+l-(1 << k))%n]};
pair<int, int> b = {c[k][j], c[k][(j+l-(1 << k))%n]};
return a == b ? 0 : a < b ? -1 : 1;
}Prefijo común más largo de dos subcadenas con memoria adicional
Para un string dado queremos computar el prefijo común más largo (LCP) de dos sufijos arbitrarios con posiciones y .
El método descrito aquí usa de memoria adicional. Un enfoque completamente distinto que solo usará una cantidad lineal de memoria se describe en la siguiente sección.
Construimos el arreglo de sufijos en tiempo , y recordamos los resultados intermedios de los arreglos de cada iteración.
Calculemos el LCP de dos sufijos que empiezan en y . Podemos comparar cualesquiera dos subcadenas con una longitud igual a una potencia de dos en . Para ello, comparamos los strings por potencias de dos (de la potencia más alta a la más baja) y si las subcadenas de esta longitud son iguales, entonces agregamos la longitud igual a la respuesta y continuamos comprobando el LCP a la derecha de la parte igual, es decir, a y se les suma la potencia de dos actual.
int lcp(int i, int j) {
int ans = 0;
for (int k = log_n; k >= 0; k--) {
if (c[k][i % n] == c[k][j % n]) {
ans += 1 << k;
i += 1 << k;
j += 1 << k;
}
}
return ans;
}Aquí log_n denota una constante igual al logaritmo de en base redondeado hacia abajo.
Prefijo común más largo de dos subcadenas sin memoria adicional
Tenemos la misma tarea que en la sección anterior. Tenemos que computar el prefijo común más largo (LCP) de dos sufijos de un string .
A diferencia del método anterior, este solo usará de memoria. El resultado del preprocesamiento será un arreglo (que a su vez es una fuente importante de información sobre el string, y por lo tanto también se usa para resolver otras tareas). Las consultas LCP se pueden responder realizando consultas RMQ (consultas de mínimo en un rango) en este arreglo, así que para distintas implementaciones es posible alcanzar tiempo logarítmico e incluso constante por consulta.
La base de este algoritmo es la siguiente idea: computaremos el prefijo común más largo para cada par de sufijos adyacentes en el orden ordenado. En otras palabras construimos un arreglo , donde es igual a la longitud del prefijo común más largo de los sufijos que empiezan en y . Este arreglo nos dará una respuesta para cualesquiera dos sufijos adyacentes del string. Luego la respuesta para dos sufijos arbitrarios, no necesariamente vecinos, se puede obtener de este arreglo. De hecho, sea la solicitud computar el LCP de los sufijos y . Entonces la respuesta a esta consulta será .
Así, si tenemos tal arreglo , entonces el problema se reduce al RMQ, que tiene un gran número de soluciones distintas con distintas complejidades.
Así que la tarea principal es construir este arreglo . Usaremos el algoritmo de Kasai, que puede computar este arreglo en tiempo .
Miremos dos sufijos adyacentes en el orden ordenado (orden del arreglo de sufijos). Sean sus posiciones de inicio y y su igual a . Si quitamos la primera letra de ambos sufijos — es decir, tomamos los sufijos y — entonces debería ser obvio que el de estos dos es . Sin embargo no podemos usar este valor y escribirlo en el arreglo , porque estos dos sufijos podrían no estar uno al lado del otro en el orden ordenado. El sufijo será por supuesto más pequeño que el sufijo , pero podría haber algunos sufijos entre ellos. Sin embargo, como sabemos que el LCP entre dos sufijos es el valor mínimo de todas las transiciones, también sabemos que el LCP entre cualquier par en ese intervalo tiene que ser al menos , especialmente también entre y el siguiente sufijo. Y posiblemente puede ser más grande.
Ahora ya podemos implementar el algoritmo. Iteraremos sobre los sufijos en orden de su longitud. De esta forma podemos reutilizar el último valor , ya que pasar del sufijo al sufijo es exactamente lo mismo que quitar la primera letra. Necesitaremos un arreglo adicional , que nos dará la posición de un sufijo en la lista ordenada de sufijos.
vector<int> lcp_construction(string const& s, vector<int> const& p) {
int n = s.size();
vector<int> rank(n, 0);
for (int i = 0; i < n; i++)
rank[p[i]] = i;
int k = 0;
vector<int> lcp(n-1, 0);
for (int i = 0; i < n; i++) {
if (rank[i] == n - 1) {
k = 0;
continue;
}
int j = p[rank[i] + 1];
while (i + k < n && j + k < n && s[i+k] == s[j+k])
k++;
lcp[rank[i]] = k;
if (k)
k--;
}
return lcp;
}Es fácil ver que disminuimos a lo sumo veces (en cada iteración a lo sumo una vez, excepto para , donde lo reseteamos directamente a ), y el LCP entre dos strings es a lo sumo , también incrementaremos solo veces. Por lo tanto el algoritmo corre en tiempo .
Número de subcadenas distintas
Preprocesamos el string computando el arreglo de sufijos y el arreglo LCP. Usando esta información podemos computar el número de subcadenas distintas en el string.
Para ello, pensaremos en qué nuevas subcadenas empiezan en la posición , luego en , etc. De hecho tomamos los sufijos en orden ordenado y vemos qué prefijos dan nuevas subcadenas. Así no pasaremos por alto ninguna por accidente.
Como los sufijos están ordenados, es claro que el sufijo actual dará nuevas subcadenas para todos sus prefijos, excepto para los prefijos que coinciden con el sufijo . Así, todos sus prefijos excepto los primeros . Como la longitud del sufijo actual es , prefijos nuevos empiezan en . Sumando sobre todos los sufijos, obtenemos la respuesta final:
Problemas de práctica
- Uva 760 - DNA Sequencing
- Uva 1223 - Editor
- Codechef - Tandem
- Codechef - Substrings and Repetitions
- Codechef - Entangled Strings
- Codeforces - Martian Strings
- Codeforces - Little Elephant and Strings
- SPOJ - Ada and Terramorphing
- SPOJ - Ada and Substring
- UVA - 1227 - The longest constant gene
- SPOJ - Longest Common Substring
- UVA 11512 - GATTACA
- LA 7502 - Suffixes and Palindromes
- GYM - Por Costel and the Censorship Committee
- UVA 1254 - Top 10
- UVA 12191 - File Recover
- UVA 12206 - Stammering Aliens
- Codechef - Jarvis and LCP
- LA 3943 - Liking’s Letter
- UVA 11107 - Life Forms
- UVA 12974 - Exquisite Strings
- UVA 10526 - Intellectual Property
- UVA 12338 - Anti-Rhyme Pairs
- UVA 12191 - File Recover
- SPOJ - Suffix Array
- LA 4513 - Stammering Aliens
- SPOJ - LCS2
- Codeforces - Fake News (hard)
- SPOJ - Longest Commong Substring
- SPOJ - Lexicographical Substring Search
- Codeforces - Forbidden Indices
- Codeforces - Tricky and Clever Password
- LA 6856 - Circle of digits