Disculpas si mi respuesta parece redundante, pero implementé el algoritmo de Ukkonen recientemente, y me encontré luchando con él durante días; Tuve que leer varios documentos sobre el tema para comprender el por qué y cómo de algunos aspectos centrales del algoritmo.
Encontré que el enfoque de 'reglas' de las respuestas anteriores no era útil para comprender las razones subyacentes , así que he escrito todo a continuación centrándose únicamente en la pragmática. Si ha tenido problemas para seguir otras explicaciones, tal como lo hice yo, tal vez mi explicación complementaria lo haga "hacer clic" para usted.
Publiqué mi implementación de C # aquí: https://github.com/baratgabor/SuffixTree
Tenga en cuenta que no soy un experto en este tema, por lo que las siguientes secciones pueden contener imprecisiones (o peor). Si encuentra alguno, no dude en editar.
Prerrequisitos
El punto de partida de la siguiente explicación supone que está familiarizado con el contenido y el uso de los árboles de sufijos, y las características del algoritmo de Ukkonen, por ejemplo, cómo está extendiendo el árbol de sufijos carácter por carácter, de principio a fin. Básicamente, supongo que ya has leído algunas de las otras explicaciones.
(Sin embargo, tuve que agregar una narrativa básica para el flujo, por lo que el comienzo podría parecer redundante).
La parte más interesante es la explicación sobre la diferencia entre usar enlaces de sufijo y volver a escanear desde la raíz . Esto es lo que me dio muchos errores y dolores de cabeza en mi implementación.
Nodos de hoja abiertos y sus limitaciones.
Estoy seguro de que ya sabe que el 'truco' más fundamental es darse cuenta de que podemos dejar el final de los sufijos 'abierto', es decir, hacer referencia a la longitud actual de la cadena en lugar de establecer el final en un valor estático. De esta manera, cuando agreguemos caracteres adicionales, esos caracteres se agregarán implícitamente a todas las etiquetas de sufijo, sin tener que visitarlos y actualizarlos.
Pero este final abierto de sufijos, por razones obvias, funciona solo para los nodos que representan el final de la cadena, es decir, los nodos de hoja en la estructura de árbol. Las operaciones de bifurcación que ejecutamos en el árbol (la adición de nuevos nodos de ramificación y nodos de hoja) no se propagarán automáticamente a donde sea necesario.
Probablemente sea elemental, y no requeriría mención, que las subcadenas repetidas no aparecen explícitamente en el árbol, ya que el árbol ya las contiene en virtud de que son repeticiones; sin embargo, cuando la subcadena repetitiva termina encontrando un carácter que no se repite, necesitamos crear una ramificación en ese punto para representar la divergencia desde ese punto en adelante.
Por ejemplo, en el caso de la cadena 'ABCXABCY' (ver más abajo), se debe agregar una ramificación a X e Y a tres sufijos diferentes, ABC , BC y C ; de lo contrario no sería un árbol de sufijos válido, y no podríamos encontrar todas las subcadenas de la cadena haciendo coincidir los caracteres desde la raíz hacia abajo.
Una vez más, para enfatizar: cualquier operación que ejecutemos en un sufijo en el árbol también debe reflejarse en sus sufijos consecutivos (por ejemplo, ABC> BC> C), de lo contrario, simplemente dejarán de ser sufijos válidos.
Pero incluso si aceptamos que tenemos que hacer estas actualizaciones manuales, ¿cómo sabemos cuántos sufijos deben actualizarse? Como, cuando agregamos el carácter repetido A (y el resto de los caracteres repetidos en sucesión), aún no tenemos idea de cuándo / dónde necesitamos dividir el sufijo en dos ramas. La necesidad de dividir se determina solo cuando encontramos el primer carácter no repetitivo, en este caso Y (en lugar de la X que ya existe en el árbol).
Lo que podemos hacer es hacer coincidir la cadena repetida más larga que podamos y contar cuántos de sus sufijos necesitamos actualizar más tarde. Esto es lo que significa "resto" .
El concepto de 'resto' y 'reescaneo'
La variable remainder
nos dice cuántos caracteres repetidos agregamos implícitamente, sin ramificación; es decir, cuántos sufijos necesitamos visitar para repetir la operación de bifurcación una vez que encontramos el primer carácter que no podemos igualar. Esto esencialmente equivale a cuántos caracteres 'profundos' estamos en el árbol desde su raíz.
Entonces, siguiendo el ejemplo anterior de la cadena ABCXABCY , hacemos coincidir la parte ABC repetida 'implícitamente', incrementando remainder
cada vez, lo que resulta en el resto de 3. Luego encontramos el carácter no repetido 'Y' . Aquí nos dividimos el agregado previamente ABCX en ABC -> X y ABC -> Y . Luego disminuimos remainder
de 3 a 2, porque ya nos ocupamos de la ramificación ABC . Ahora repetimos la operación haciendo coincidir los últimos 2 caracteres, BC , desde la raíz para llegar al punto donde necesitamos dividirnos, y también dividimos BCX en BC-> X y BC -> Y . Nuevamente, disminuimos remainder
a 1 y repetimos la operación; hasta que remainder
sea 0. Por último, también necesitamos agregar el carácter actual ( Y ) a la raíz.
Esta operación, siguiendo los sufijos consecutivos desde la raíz simplemente para llegar al punto donde necesitamos hacer una operación, es lo que se llama 'reescaneo' en el algoritmo de Ukkonen, y típicamente esta es la parte más costosa del algoritmo. Imagine una cadena más larga donde necesita 'volver a escanear' subcadenas largas, en muchas docenas de nodos (discutiremos esto más adelante), potencialmente miles de veces.
Como solución, presentamos lo que llamamos 'enlaces de sufijo' .
El concepto de 'enlaces de sufijo'
Los enlaces de sufijo básicamente apuntan a las posiciones a las que normalmente tendríamos que 'volver a escanear' , por lo que en lugar de la costosa operación de reescaneo, simplemente podemos saltar a la posición vinculada, hacer nuestro trabajo, saltar a la siguiente posición vinculada y repetir, hasta que haya No hay más puestos para actualizar.
Por supuesto, una gran pregunta es cómo agregar estos enlaces. La respuesta existente es que podemos agregar los enlaces cuando insertamos nuevos nodos de ramificación, utilizando el hecho de que, en cada extensión del árbol, los nodos de ramificación se crean naturalmente uno tras otro en el orden exacto en que tendremos que vincularlos. . Sin embargo, tenemos que vincular desde el último nodo de rama creado (el sufijo más largo) al creado anteriormente, por lo que debemos almacenar en caché lo último que creamos, vincularlo al siguiente que creamos y almacenar en caché el recién creado.
Una consecuencia es que, en realidad, a menudo no tenemos enlaces de sufijo para seguir, porque el nodo de rama dado se acaba de crear. En estos casos, todavía tenemos que recurrir al mencionado 'reescaneo' desde la raíz. Es por eso que, después de una inserción, se le indica que use el enlace de sufijo o salte a la raíz.
(O, alternativamente, si está almacenando punteros principales en los nodos, puede intentar seguir a los padres, verificar si tienen un enlace y usarlo. Descubrí que esto rara vez se menciona, pero el uso del enlace de sufijo no es establecido en piedras. Existen múltiples enfoques posibles, y si comprende el mecanismo subyacente, puede implementar uno que se adapte mejor a sus necesidades.)
El concepto de "punto activo"
Hasta ahora hemos discutido múltiples herramientas eficientes para construir el árbol, y nos referimos vagamente a atravesar múltiples bordes y nodos, pero aún no hemos explorado las consecuencias y complejidades correspondientes.
El concepto explicado anteriormente de "resto" es útil para realizar un seguimiento de dónde estamos en el árbol, pero debemos darnos cuenta de que no almacena suficiente información.
En primer lugar, siempre residimos en un borde específico de un nodo, por lo que debemos almacenar la información del borde. Llamaremos a esto 'borde activo' .
En segundo lugar, incluso después de agregar la información de borde, todavía no tenemos forma de identificar una posición que esté más abajo en el árbol y que no esté directamente conectada al nodo raíz . Por lo tanto, también necesitamos almacenar el nodo. Llamemos a esto 'nodo activo' .
Por último, podemos notar que el "resto" es inadecuado para identificar una posición en un borde que no está directamente conectado a la raíz, porque "resto" es la longitud de toda la ruta; y probablemente no queremos molestarnos en recordar y restar la longitud de los bordes anteriores. Por lo tanto, necesitamos una representación que sea esencialmente el resto en el borde actual . Esto es lo que llamamos 'longitud activa' .
Esto lleva a lo que llamamos 'punto activo' : un paquete de tres variables que contiene toda la información que necesitamos para mantener nuestra posición en el árbol:
Active Point = (Active Node, Active Edge, Active Length)
Puede observar en la siguiente imagen cómo la ruta coincidente de ABCABD consta de 2 caracteres en el borde AB (desde la raíz ), más 4 caracteres en el borde CABDABCABD (desde el nodo 4), lo que resulta en un 'resto' de 6 caracteres. Por lo tanto, nuestra posición actual se puede identificar como Nodo activo 4, Borde activo C, Longitud activa 4 .
Otra función importante del 'punto activo' es que proporciona una capa de abstracción para nuestro algoritmo, lo que significa que partes de nuestro algoritmo pueden hacer su trabajo en el 'punto activo' , independientemente de si ese punto activo está en la raíz o en cualquier otro lugar . Esto facilita la implementación del uso de enlaces de sufijo en nuestro algoritmo de una manera limpia y directa.
Diferencias de reescaneo versus uso de enlaces de sufijo
Ahora, la parte difícil, algo que, en mi experiencia, puede causar muchos errores y dolores de cabeza, y está mal explicado en la mayoría de las fuentes, es la diferencia en el procesamiento de los casos de enlaces de sufijo frente a los casos de reescaneo.
Considere el siguiente ejemplo de la cadena 'AAAABAAAABAAC' :
Puede observar arriba cómo el 'resto' de 7 corresponde a la suma total de caracteres de la raíz, mientras que la 'longitud activa' de 4 corresponde a la suma de caracteres coincidentes del borde activo del nodo activo.
Ahora, después de ejecutar una operación de bifurcación en el punto activo, nuestro nodo activo puede contener o no un enlace de sufijo.
Si hay un enlace de sufijo: solo necesitamos procesar la parte de 'longitud activa' . El "resto" es irrelevante, porque el nodo al que saltamos a través del enlace de sufijo ya codifica implícitamente el "resto" correcto , simplemente en virtud de estar en el árbol donde está.
Si NO hay un enlace de sufijo: necesitamos 'volver a escanear' desde cero / raíz, lo que significa procesar todo el sufijo desde el principio. Para este fin, tenemos que usar todo el 'resto' como base para volver a escanear.
Ejemplo de comparación de procesamiento con y sin un enlace de sufijo
Considere lo que sucede en el siguiente paso del ejemplo anterior. Comparemos cómo lograr el mismo resultado, es decir, pasar al siguiente sufijo para procesar, con y sin un enlace de sufijo.
Usando 'enlace de sufijo'
Tenga en cuenta que si usamos un enlace de sufijo, estamos automáticamente 'en el lugar correcto'. Lo que a menudo no es estrictamente cierto debido al hecho de que la "longitud activa" puede ser "incompatible" con la nueva posición.
En el caso anterior, dado que la 'longitud activa' es 4, estamos trabajando con el sufijo ' ABAA' , comenzando en el Nodo 4 vinculado. Pero después de encontrar el borde que corresponde al primer carácter del sufijo ( 'A' ), notamos que nuestra 'longitud activa' desborda este borde en 3 caracteres. Entonces saltamos sobre el borde completo, al siguiente nodo, y disminuimos la 'longitud activa' por los caracteres que consumimos con el salto.
Luego, después de encontrar el siguiente borde 'B' , correspondiente al sufijo decrementado 'BAA ', finalmente notamos que la longitud del borde es mayor que la 'longitud activa' restante de 3, lo que significa que encontramos el lugar correcto.
Tenga en cuenta que parece que esta operación generalmente no se conoce como 'reescaneo', aunque para mí parece que es el equivalente directo de reescaneo, solo con una longitud acortada y un punto de partida no raíz.
Usando 'reescanear'
Tenga en cuenta que si usamos una operación tradicional de 'reescaneo' (aquí pretendiendo que no teníamos un enlace de sufijo), comenzamos en la parte superior del árbol, en la raíz, y tenemos que bajar nuevamente al lugar correcto, siguiendo a lo largo de todo el sufijo actual.
La longitud de este sufijo es el "resto" que discutimos antes. Tenemos que consumir la totalidad de este resto, hasta que llegue a cero. Esto podría (y a menudo lo hace) incluir saltar a través de múltiples nodos, en cada salto disminuyendo el resto por la longitud del borde por el que saltamos. Luego, finalmente, llegamos a un borde que es más largo que nuestro "resto" restante ; aquí establecemos el borde activo en el borde dado, establecemos la 'longitud activa' en el 'resto ' restante y listo.
Sin embargo, tenga en cuenta que la variable real 'resto' necesita ser preservada, y solo decrementada después de cada inserción de nodo. Entonces, lo que describí anteriormente asumió el uso de una variable separada inicializada en 'resto' .
Notas sobre sufijos enlaces y rescans
1) Tenga en cuenta que ambos métodos conducen al mismo resultado. Sin embargo, el salto de enlace de sufijo es significativamente más rápido en la mayoría de los casos; Esa es toda la razón detrás de los enlaces de sufijo.
2) Las implementaciones algorítmicas reales no necesitan diferir. Como mencioné anteriormente, incluso en el caso de usar el enlace de sufijo, la 'longitud activa' a menudo no es compatible con la posición vinculada, ya que esa rama del árbol puede contener ramificaciones adicionales. Entonces, esencialmente solo tiene que usar 'longitud activa' en lugar de 'resto' , y ejecutar la misma lógica de escaneo hasta que encuentre un borde que sea más corto que la longitud restante del sufijo.
3) Una observación importante relacionada con el rendimiento es que no es necesario verificar todos y cada uno de los personajes durante el reescaneo. Debido a la forma en que se construye un árbol de sufijos válido, podemos asumir con seguridad que los caracteres coinciden. Por lo tanto, está contando principalmente las longitudes, y la única necesidad de verificación de equivalencia de caracteres surge cuando saltamos a un nuevo borde, ya que los bordes se identifican por su primer carácter (que siempre es único en el contexto de un nodo dado). Esto significa que la lógica de 'reescaneo' es diferente de la lógica de coincidencia de cadena completa (es decir, la búsqueda de una subcadena en el árbol).
4) El enlace de sufijo original descrito aquí es solo uno de los posibles enfoques . Por ejemplo, NJ Larsson et al. nombra este enfoque como de arriba hacia abajo orientado a los nodos y lo compara con las de abajo hacia arriba orientadas a los nodos y dos variedades orientadas a los bordes . Los diferentes enfoques tienen diferentes desempeños, requisitos, limitaciones, etc. típicos y en el peor de los casos, pero en general parece que los enfoques orientados al borde son una mejora general del original.