Me siento un poco espeso en este punto. He pasado días tratando de comprender completamente la construcción del árbol de sufijos, pero debido a que no tengo antecedentes matemáticos, muchas de las explicaciones me eluden a medida que comienzan a hacer un uso excesivo de la simbología matemática. La explicación más cercana a una buena que he encontrado es Búsqueda de cadena rápida con árboles de sufijos , pero pasa por alto varios puntos y algunos aspectos del algoritmo siguen sin estar claros.
Una explicación paso a paso de este algoritmo aquí en Stack Overflow sería invaluable para muchos otros además de mí, estoy seguro.
Como referencia, aquí está el artículo de Ukkonen sobre el algoritmo: http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf
Mi comprensión básica, hasta ahora:
- Necesito iterar a través de cada prefijo P de una cadena dada T
- Necesito recorrer cada sufijo S en el prefijo P y agregarlo al árbol
- Para agregar el sufijo S al árbol, necesito iterar a través de cada carácter en S, con las iteraciones que consisten en caminar hacia abajo de una rama existente que comienza con el mismo conjunto de caracteres C en S y potencialmente dividir un borde en nodos descendientes cuando yo alcanzar un carácter diferente en el sufijo, O si no hubiera un borde coincidente para caminar hacia abajo. Cuando no se encuentra ningún borde coincidente para caminar hacia C, se crea un nuevo borde de hoja para C.
El algoritmo básico parece ser O (n 2 ), como se señala en la mayoría de las explicaciones, ya que necesitamos recorrer todos los prefijos, luego debemos recorrer cada uno de los sufijos para cada prefijo. El algoritmo de Ukkonen es aparentemente único debido a la técnica de puntero de sufijo que usa, aunque creo que eso es lo que me cuesta entender.
También tengo problemas para entender:
- exactamente cuándo y cómo se asigna, usa y cambia el "punto activo"
- ¿Qué está pasando con el aspecto de canonización del algoritmo?
- ¿Por qué las implementaciones que he visto necesitan "arreglar" las variables de límite que están usando?
Aquí está el código fuente completo de C # . No solo funciona correctamente, sino que también es compatible con la canonización automática y genera un gráfico de texto más bonito de la salida. El código fuente y la salida de muestra están en:
Actualizar 2017-11-04
Después de muchos años, he encontrado un nuevo uso para los árboles de sufijos y he implementado el algoritmo en JavaScript . Gist está debajo. Debe estar libre de errores. Volcarlo en un archivo js, npm install chalk
desde la misma ubicación, y luego ejecutar con node.js para ver una salida colorida. Hay una versión simplificada en el mismo Gist, sin ninguno de los códigos de depuración.
https://gist.github.com/axefrog/c347bf0f5e0723cbd09b1aaed6ec6fc6
fuente
Respuestas:
El siguiente es un intento de describir el algoritmo Ukkonen mostrando primero lo que hace cuando la cadena es simple (es decir, no contiene caracteres repetidos) y luego extendiéndola al algoritmo completo.
Primero, algunas declaraciones preliminares.
Lo que estamos construyendo es básicamente como un trie de búsqueda. Entonces, hay un nodo raíz, los bordes salen de él y conducen a nuevos nodos, y más bordes salen de ellos, y así sucesivamente
Pero : a diferencia de un trie de búsqueda, las etiquetas de borde no son caracteres individuales. En cambio, cada borde se etiqueta con un par de enteros
[from,to]
. Estos son punteros en el texto. En este sentido, cada borde lleva una etiqueta de cadena de longitud arbitraria, pero solo ocupa O (1) espacio (dos punteros).Principio básico
Primero me gustaría demostrar cómo crear el árbol de sufijos de una cadena particularmente simple, una cadena sin caracteres repetidos:
El algoritmo funciona en pasos, de izquierda a derecha . Hay un paso para cada carácter de la cadena . Cada paso puede involucrar más de una operación individual, pero veremos (ver las observaciones finales al final) que el número total de operaciones es O (n).
Entonces, comenzamos desde la izquierda , y primero insertamos solo el carácter
a
creando una arista desde el nodo raíz (a la izquierda) a una hoja, y etiquetándola como[0,#]
, lo que significa que la arista representa la subcadena que comienza en la posición 0 y termina en el extremo corriente . Utilizo el símbolo#
para referirme al final actual , que está en la posición 1 (justo despuésa
).Entonces tenemos un árbol inicial, que se ve así:
Y lo que significa es esto:
Ahora avanzamos a la posición 2 (justo después
b
). Nuestro objetivo en cada paso es insertar todos los sufijos hasta la posición actual . Hacemos esto pora
borde existente aab
b
En nuestra representación esto parece
Y lo que significa es:
Observamos dos cosas:
ab
es el mismo como lo que solía ser en el árbol inicial:[0,#]
. Su significado ha cambiado automáticamente porque actualizamos la posición actual#
de 1 a 2.A continuación, incrementamos la posición nuevamente y actualizamos el árbol agregando un
c
a cada borde existente e insertando un nuevo borde para el nuevo sufijoc
.En nuestra representación esto parece
Y lo que significa es:
Observamos:
#
, y la inserción del nuevo borde para el carácter final se puede hacer en tiempo O (1). Por lo tanto, para una cadena de longitud n, solo se requiere tiempo O (n).Primera extensión: repeticiones simples
Por supuesto, esto funciona muy bien solo porque nuestra cadena no contiene repeticiones. Ahora miramos una cadena más realista:
Comienza
abc
como en el ejemplo anterior, luegoab
se repite y siguex
, y luegoabc
se repite seguido ded
.Pasos 1 a 3: después de los primeros 3 pasos tenemos el árbol del ejemplo anterior:
Paso 4: Pasamos
#
a la posición 4. Esto actualiza implícitamente todos los bordes existentes para esto:y necesitamos insertar el sufijo final del paso actual,,
a
en la raíz.Antes de hacer esto, presentamos dos variables más (además de
#
), que por supuesto han estado allí todo el tiempo pero no las hemos usado hasta ahora:(active_node,active_edge,active_length)
remainder
, que es un número entero que indica cuántos sufijos nuevos necesitamos insertarEl significado exacto de estos dos se aclarará pronto, pero por ahora solo digamos:
abc
ejemplo simple , el punto activo siempre fue(root,'\0x',0)
, es decir,active_node
el nodo raíz,active_edge
se especificó como el carácter nulo'\0x'
yactive_length
fue cero. El efecto de esto fue que el nuevo borde que insertamos en cada paso se insertó en el nodo raíz como un borde recién creado. Pronto veremos por qué es necesario un triple para representar esta información.remainder
siempre se estableció en 1 al comienzo de cada paso. El significado de esto era que el número de sufijos que teníamos que insertar activamente al final de cada paso era 1 (siempre solo el carácter final).Ahora esto va a cambiar. Cuando insertamos el carácter final actual
a
en la raíz, nos damos cuenta de que ya existe un arco de salida a partir dea
, en concreto:abca
. Esto es lo que hacemos en tal caso:[4,#]
en el nodo raíz. En cambio, simplemente notamos que el sufijoa
ya está en nuestro árbol. Termina en el medio de un borde más largo, pero eso no nos molesta. Simplemente dejamos las cosas como están.(root,'a',1)
. Eso significa que el punto activo ahora está en algún lugar en el medio del borde saliente del nodo raíz que comienzaa
, específicamente, después de la posición 1 en ese borde. Notamos que el borde se especifica simplemente por su primer caráctera
. Eso es suficiente porque solo puede haber un borde que comience con cualquier carácter en particular (confirme que esto es cierto después de leer la descripción completa).remainder
, por lo que al comienzo del siguiente paso será 2.Observación: Cuando se encuentra que el sufijo final que necesitamos insertar ya existe en el árbol , el árbol en sí no cambia en absoluto (solo actualizamos el punto activo y
remainder
). El árbol ya no es una representación precisa del árbol de sufijos hasta la posición actual , pero contiene todos los sufijos (porque el sufijo finala
está contenido implícitamente ). Por lo tanto, aparte de actualizar las variables (que son todas de longitud fija, por lo que esto es O (1)), no se realizó ningún trabajo en este paso.Paso 5: Actualizamos la posición actual
#
a 5. Esto actualiza automáticamente el árbol a esto:Y debido a que
remainder
es 2 , necesitamos insertar dos sufijos finales de la posición actual:ab
yb
. Esto es básicamente porque:a
sufijo del paso anterior nunca se ha insertado correctamente. Por lo tanto, se ha mantenido , y dado que hemos avanzado un paso, ahora ha crecido dea
aab
.b
.En la práctica, esto significa que vamos al punto activo (que apunta detrás
a
de lo que ahora es elabcab
borde) e insertamos el carácter final actualb
. Pero: una vez más, resulta queb
también está presente en ese mismo borde.Entonces, nuevamente, no cambiamos el árbol. Nosotros simplemente:
(root,'a',2)
(mismo nodo y borde que antes, pero ahora señalamos detrás deb
)remainder
a 3 porque todavía no hemos insertado correctamente el borde final del paso anterior, y tampoco insertamos el borde final actual.Para ser claros: tuvimos que insertar
ab
yb
en el paso actual, pero comoab
ya se encontró, actualizamos el punto activo y ni siquiera intentamos insertarlob
. ¿Por qué? Porque siab
está en el árbol, todos sus sufijos (incluidosb
) también deben estar en el árbol. Quizás solo implícitamente , pero debe estar allí, debido a la forma en que hemos construido el árbol hasta ahora.Procedemos al paso 6 incrementando
#
. El árbol se actualiza automáticamente para:Porque
remainder
es 3 , tenemos que insertarabx
,bx
yx
. El punto activo nos dice dóndeab
termina, por lo que solo necesitamos saltar allí e insertar elx
. De hecho,x
todavía no está allí, así que dividimos elabcabx
borde e insertamos un nodo interno:Las representaciones de borde siguen siendo punteros en el texto, por lo que la división e inserción de un nodo interno se puede hacer en O (1) tiempo.
Por lo que hemos tratado
abx
y decrementoremainder
a 2. Ahora necesitamos que introduzca el siguiente sufijo restante,bx
. Pero antes de hacerlo, necesitamos actualizar el punto activo. La regla para esto, después de dividir e insertar un borde, se llamará Regla 1 a continuación, y se aplica siempre queactive_node
sea raíz (aprenderemos la regla 3 para otros casos más abajo). Aquí está la regla 1:Por lo tanto, el nuevo triple de punto activo
(root,'b',1)
indica que la siguiente inserción debe hacerse en elbcabx
borde, detrás de 1 carácter, es decir, detrásb
. Podemos identificar el punto de inserción en el tiempo O (1) y verificar six
ya está presente o no. Si estuviera presente, finalizaríamos el paso actual y dejaríamos todo como está. Perox
no está presente, así que lo insertamos dividiendo el borde:Nuevamente, esto tomó tiempo O (1) y actualizamos
remainder
a 1 y el punto activo a(root,'x',0)
como lo establece la regla 1.Pero hay una cosa más que debemos hacer. Llamaremos a esta Regla 2:
Todavía tenemos que insertar el sufijo final del paso actual,
x
. Como elactive_length
componente del nodo activo ha caído a 0, la inserción final se realiza directamente en la raíz. Como no hay un borde saliente en el nodo raíz que comiencex
, insertamos un nuevo borde:Como podemos ver, en el paso actual se hicieron todas las inserciones restantes.
Procedemos al paso 7 estableciendo
#
= 7, que agrega automáticamente el siguiente caráctera
a todos los bordes de las hojas, como siempre. Luego intentamos insertar el nuevo carácter final en el punto activo (la raíz), y encontramos que ya está allí. Así que finalizamos el paso actual sin insertar nada y actualizamos el punto activo(root,'a',1)
.En el paso 8 ,
#
= 8, agregamosb
, y como se vio antes, esto solo significa que actualizamos el punto activo(root,'a',2)
e incrementamosremainder
sin hacer nada más, porqueb
ya está presente. Sin embargo, notamos (en tiempo O (1)) que el punto activo está ahora al final de un borde. Reflejamos esto volviéndolo a configurar(node1,'\0x',0)
. Aquí, solíanode1
referirme al nodo interno en el queab
termina el borde.Luego, en el paso
#
= 9 , necesitamos insertar 'c' y esto nos ayudará a entender el truco final:Segunda extensión: uso de enlaces de sufijo
Como siempre, la
#
actualización se agregac
automáticamente a los bordes de las hojas y vamos al punto activo para ver si podemos insertar 'c'. Resulta que 'c' ya existe en ese borde, por lo que establecemos el punto activo en(node1,'c',1)
, incrementarremainder
y no hacer nada más.Ahora en el paso
#
= 10 ,remainder
es 4, por lo que primero debemos insertarabcd
(que queda de hace 3 pasos) insertandod
en el punto activo.Intentar insertar
d
en el punto activo provoca una división del borde en el tiempo O (1):El
active_node
, desde el que se inició la división, está marcado en rojo arriba. Aquí está la regla final, la Regla 3:Entonces el punto activo es ahora
(node2,'c',1)
, ynode2
está marcado en rojo a continuación:Desde la introducción de
abcd
es completa, que decrementamosremainder
a 3 y consideremos el siguiente sufijo restante del paso actual,bcd
. La regla 3 ha establecido el punto activo en el nodo y el borde correctos, por lo que la inserciónbcd
se puede realizar simplemente insertando su carácter finald
en el punto activo.Hacer esto causa otra división de borde, y debido a la regla 2 , debemos crear un enlace de sufijo desde el nodo previamente insertado al nuevo:
Observamos: los enlaces de sufijo nos permiten restablecer el punto activo para que podamos realizar la siguiente inserción restante en O (1) esfuerzo. Mire el gráfico de arriba para confirmar que efectivamente el nodo en la etiqueta
ab
está vinculado al nodo enb
(su sufijo), y el nodo enabc
está vinculadobc
.El paso actual aún no está terminado.
remainder
ahora es 2, y debemos seguir la regla 3 para restablecer el punto activo nuevamente. Como el actualactive_node
(rojo arriba) no tiene un enlace de sufijo, restablecemos la raíz. El punto activo es ahora(root,'c',1)
.Por lo tanto, la siguiente inserción se produce en el borde saliente del nodo raíz cuya etiqueta comienza con
c
:,cabxabcd
detrás del primer carácter, es decir, detrásc
. Esto causa otra división:Y dado que esto implica la creación de un nuevo nodo interno, seguimos la regla 2 y establecemos un nuevo enlace de sufijo desde el nodo interno creado anteriormente:
(Estoy usando Graphviz Dot para estos pequeños gráficos. El nuevo enlace de sufijo hizo que el punto reorganizara los bordes existentes, así que verifique cuidadosamente para confirmar que lo único que se insertó arriba es un nuevo enlace de sufijo).
Con esto,
remainder
se puede establecer en 1 y, dado queactive_node
es root, utilizamos la regla 1 para actualizar el punto activo(root,'d',0)
. Esto significa que la inserción final del paso actual es insertar una solad
en la raíz:Ese fue el paso final y hemos terminado. Sin embargo, hay varias observaciones finales :
En cada paso avanzamos
#
1 posición. Esto actualiza automáticamente todos los nodos hoja en tiempo O (1).Pero no trata con a) los sufijos restantes de los pasos anteriores, yb) con el único carácter final del paso actual.
remainder
nos dice cuántas inserciones adicionales necesitamos hacer. Estos insertos corresponden uno a uno con los sufijos finales de la cadena que termina en la posición actual#
. Consideramos uno tras otro y hacemos el inserto. Importante: Cada inserción se realiza en tiempo O (1) ya que el punto activo nos dice exactamente a dónde ir, y necesitamos agregar solo un carácter en el punto activo. ¿Por qué? Porque los otros caracteres están contenidos implícitamente (de lo contrario, el punto activo no estaría donde está).Después de cada inserción, disminuimos
remainder
y seguimos el enlace de sufijo si hay alguno. Si no, vamos a la raíz (regla 3). Si ya estamos en la raíz, modificamos el punto activo usando la regla 1. En cualquier caso, solo toma O (1) tiempo.Si, durante una de estas inserciones, encontramos que el carácter que queremos insertar ya está allí, no hacemos nada y finalizamos el paso actual, incluso si
remainder
> 0. La razón es que cualquier inserción que quede será sufijo del que acabamos de intentar hacer. Por lo tanto, todos están implícitos en el árbol actual. El hecho de queremainder
> 0 se asegura de tratar los sufijos restantes más adelante.¿Qué pasa si al final del algoritmo
remainder
> 0? Este será el caso siempre que el final del texto sea una subcadena que ocurrió antes. En ese caso, debemos agregar un carácter adicional al final de la cadena que no haya ocurrido antes. En la literatura, generalmente el signo de dólar$
se usa como un símbolo para eso. ¿Por que importa? -> Si luego usamos el árbol de sufijos completo para buscar sufijos, debemos aceptar coincidencias solo si terminan en una hoja . De lo contrario, obtendríamos muchas coincidencias espurias, porque hay muchas cadenas implícitamente contenidas en el árbol que no son sufijos reales de la cadena principal. Forzandoremainder
ser 0 al final es esencialmente una forma de asegurar que todos los sufijos terminen en un nodo hoja. Sin embargo, si queremos usar el árbol para buscar subcadenas generales , no solo sufijos de la cadena principal, este paso final no es necesario, como sugiere el comentario del OP a continuación.Entonces, ¿cuál es la complejidad de todo el algoritmo? Si el texto tiene n caracteres de longitud, obviamente hay n pasos (o n + 1 si agregamos el signo de dólar). En cada paso no hacemos nada (aparte de actualizar las variables), o hacemos
remainder
inserciones, cada una de las cuales toma tiempo O (1). Comoremainder
indica cuántas veces no hemos hecho nada en los pasos anteriores, y se reduce para cada inserción que hacemos ahora, el número total de veces que hacemos algo es exactamente n (o n + 1). Por lo tanto, la complejidad total es O (n).Sin embargo, hay una pequeña cosa que no expliqué correctamente: puede suceder que sigamos un enlace de sufijo, actualicemos el punto activo y luego descubramos que su
active_length
componente no funciona bien con el nuevoactive_node
. Por ejemplo, considere una situación como esta:(Las líneas discontinuas indican el resto del árbol. La línea punteada es un enlace de sufijo).
Ahora deje que el punto activo sea
(red,'d',3)
, para que apunte al lugar detrásf
deldefg
borde. Ahora suponga que hicimos las actualizaciones necesarias y ahora siga el enlace de sufijo para actualizar el punto activo de acuerdo con la regla 3. El nuevo punto activo es(green,'d',3)
. Sin embargo, eld
borde que sale del nodo verde esde
, por lo que tiene solo 2 caracteres. Para encontrar el punto activo correcto, obviamente debemos seguir ese borde hasta el nodo azul y restablecerlo(blue,'f',1)
.En un caso particularmente malo,
active_length
podría ser tan grande comoremainder
, que puede ser tan grande como n. Y bien podría suceder que para encontrar el punto activo correcto, no solo necesitamos saltar sobre un nodo interno, sino quizás muchos, hasta n en el peor de los casos. ¿Eso significa que el algoritmo tiene una complejidad oculta de O (n 2 ), porque en cada pasoremainder
es generalmente O (n), y los ajustes posteriores al nodo activo después de seguir un enlace de sufijo también podrían ser O (n)?No. La razón es que si de hecho tenemos que ajustar el punto activo (por ejemplo, de verde a azul como arriba), eso nos lleva a un nuevo nodo que tiene su propio enlace de sufijo y
active_length
se reducirá. A medida que seguimos la cadena de enlaces de sufijo, hacemos las inserciones restantes,active_length
solo puede disminuir, y el número de ajustes de puntos activos que podemos hacer en el camino no puede ser mayor queactive_length
en cualquier momento dado. Comoactive_length
nunca puede ser mayor queremainder
, yremainder
es O (n) no solo en cada paso, sino que la suma total de los incrementos realizadosremainder
en el transcurso de todo el proceso también es O (n), el número de ajustes de puntos activos es también limitado por O (n).fuente
abcdefabxybcdmnabcdex
. La parte inicial deabcd
se repite enabxy
(esto crea un nodo interno despuésab
) y nuevamente enabcdex
, y termina enbcd
, que aparece no solo en elbcdex
contexto, sino también en elbcdmn
contexto. Después de queabcdex
se inserta, seguimos el enlace del sufijo para insertarbcdex
, y allí se activará canonicize.Traté de implementar el Suffix Tree con el enfoque dado en la respuesta de jogojapan, pero no funcionó en algunos casos debido a la redacción utilizada para las reglas. Además, he mencionado que nadie logró implementar un árbol de sufijos absolutamente correcto utilizando este enfoque. A continuación escribiré una "descripción general" de la respuesta de jogojapan con algunas modificaciones a las reglas. También describiré el caso cuando olvidemos crear enlaces de sufijo importantes .
Variables adicionales utilizadas
Usemos un concepto de nodo interno : todos los nodos, excepto la raíz y las hojas, son nodos internos .
Observación 1
Cuando se encuentra que el sufijo final que necesitamos insertar ya existe en el árbol, el árbol en sí no cambia en absoluto (solo actualizamos el
active point
yremainder
).Observación 2
Si en algún momento
active_length
es mayor o igual a la longitud del borde actual (edge_length
), movemos nuestra flechaactive point
hacia abajo hasta queedge_length
sea estrictamente mayor queactive_length
.Ahora, redefinamos las reglas:
Regla 1
Regla 2
Esta definición de la
Rule 2
es diferente de jogojapan ', ya que aquí tenemos en cuenta no solo los nodos internos recién creados , sino también los nodos internos, desde los cuales hacemos una inserción.Regla 3
En esta definición de
Rule 3
también consideramos las inserciones de los nodos hoja (no solo los nodos divididos).Y finalmente, Observación 3:
Cuando el símbolo que queremos agregar al árbol ya está en el borde, nosotros, según
Observation 1
, solo actualizamosactive point
yremainder
, dejando el árbol sin cambios. PERO si hay un nodo interno marcado como que necesita un enlace de sufijo , debemos conectar ese nodo con nuestra corriente aactive node
través de un enlace de sufijo.Veamos el ejemplo de un árbol de sufijos para cdddcdc si agregamos un enlace de sufijo en ese caso y si no lo hacemos:
Si NO conectamos los nodos a través de un enlace de sufijo:
Si nos HACEMOS conectar los nodos a través de un enlace de sufijo:
Parece que no hay una diferencia significativa: en el segundo caso hay dos enlaces de sufijo más. Pero estos enlaces de sufijo son correctos , y uno de ellos, desde el nodo azul hasta el rojo, es muy importante para nuestro enfoque con el punto activo . El problema es que si no ponemos un enlace de sufijo aquí, más adelante, cuando agreguemos algunas letras nuevas al árbol, podríamos omitir agregar algunos nodos al árbol debido a
Rule 3
que, de acuerdo con esto, si no hay un enlace sufijo, entonces debemos poner elactive_node
a la raíz.Cuando estábamos agregando la última letra al árbol, el nodo rojo ya existía antes de que hiciéramos una inserción desde el nodo azul (el borde con la etiqueta 'c' ). Como había un inserto desde el nodo azul, lo marcamos como que necesita un enlace de sufijo . Luego, confiando en el enfoque de punto activo ,
active node
se configuró en el nodo rojo. Pero no hacemos una inserción desde el nodo rojo, ya que la letra 'c' ya está en el borde. ¿Significa que el nodo azul debe dejarse sin un enlace de sufijo? No, debemos conectar el nodo azul con el rojo a través de un enlace de sufijo. ¿Por qué es correcto? Porque el punto activoEl enfoque garantiza que lleguemos al lugar correcto, es decir, al siguiente lugar donde debemos procesar una inserción de un sufijo más corto .Finalmente, aquí están mis implementaciones del árbol de sufijos:
Espero que esta "descripción" combinada con la respuesta detallada de jogojapan ayude a alguien a implementar su propio árbol de sufijos.
fuente
aabaacaad
Es uno de los casos que muestra que agregar un enlace de sufijo adicional puede reducir los tiempos de actualización del triple. La conclusión en los últimos dos párrafos del post de jogojapan es incorrecta. Si no agregamos los enlaces de sufijo mencionados en esta publicación, la complejidad de tiempo promedio debería ser O (nlong (n)) o más. Porque lleva más tiempo caminar por el árbol para encontrar el correctoactive_node
.Gracias por el tutorial bien explicado por @jogojapan , implementé el algoritmo en Python.
Un par de problemas menores mencionados por @jogojapan resultan ser más sofisticados de lo que esperaba y necesitan ser tratados con mucho cuidado. Me costó varios días lograr que mi implementación sea lo suficientemente robusta (supongo). Los problemas y las soluciones se enumeran a continuación:
Termina con
Remainder > 0
resulta que esta situación también puede ocurrir durante el paso de despliegue , no solo el final de todo el algoritmo. Cuando eso suceda, podemos dejar el resto, actnode, actedge y actlength sin cambios , finalizar el paso de despliegue actual y comenzar otro paso ya sea continuando o desplegando dependiendo de si el siguiente carácter en la cadena original está en la ruta actual o no.Salto sobre nodos: cuando seguimos un enlace de sufijo, actualice el punto activo y luego descubra que su componente active_length no funciona bien con el nuevo active_node. Tenemos que avanzar hacia el lugar correcto para dividir o insertar una hoja. Es posible que este proceso no sea tan sencillo porque durante el movimiento la longitud de acción y la velocidad continua cambian todo el tiempo, cuando tiene que volver al nodo raíz , la longitud de acción y la acción podrían estar equivocadas debido a esos movimientos. Necesitamos variables adicionales para mantener esa información.
Los otros dos problemas han sido señalados de alguna manera por @managonov
La división podría degenerar Cuando intente dividir un borde, en algún momento encontrará que la operación de división está justo en un nodo. En ese caso, solo necesitamos agregar una nueva hoja a ese nodo, tomarlo como una operación de división de borde estándar, lo que significa que los enlaces de sufijo, si los hay, deben mantenerse correspondientemente.
Enlaces de sufijos ocultos Existe otro caso especial en el que incurre el problema 1 y el problema 2 . A veces necesitamos saltar varios nodos al punto correcto para dividirnos, podríamos superar el punto correcto si nos movemos comparando la cadena restante y las etiquetas de ruta. En ese caso, el enlace del sufijo se descuidará involuntariamente, si hubiera alguno. Esto podría evitarse recordando el punto correcto al avanzar. El enlace de sufijo debe mantenerse si el nodo dividido ya existe, o incluso si el problema 1 ocurre durante un paso de despliegue.
Finalmente, mi implementación en Python es la siguiente:
fuente
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 disminuimosremainder
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, disminuimosremainder
a 1 y repetimos la operación; hasta queremainder
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.
fuente
@jogojapan trajiste una increíble explicación y visualización. Pero como @makagonov mencionó, faltan algunas reglas con respecto a la configuración de enlaces de sufijo. Es visible de manera agradable cuando va paso a paso en http://brenden.github.io/ukkonen-animation/ a través de la palabra 'aabaaabb'. Cuando va del paso 10 al paso 11, no hay un enlace de sufijo del nodo 5 al nodo 2, pero el punto activo se mueve repentinamente allí.
@makagonov, dado que vivo en el mundo Java, también traté de seguir tu implementación para comprender el flujo de trabajo del edificio ST, pero fue difícil para mí debido a:
Así que terminé con tal implementación en Java que espero refleje todos los pasos de una manera más clara y reduzca el tiempo de aprendizaje para otras personas de Java:
fuente
Mi intuición es la siguiente:
Después de k iteraciones del bucle principal, ha construido un árbol de sufijos que contiene todos los sufijos de la cadena completa que comienzan en los primeros k caracteres.
Al principio, esto significa que el árbol de sufijos contiene un solo nodo raíz que representa la cadena completa (este es el único sufijo que comienza en 0).
Después de las iteraciones de len (cadena), tiene un árbol de sufijos que contiene todos los sufijos.
Durante el ciclo, la clave es el punto activo. Supongo que esto representa el punto más profundo en el árbol de sufijos que corresponde a un sufijo adecuado de los primeros k caracteres de la cadena. (Creo que apropiado significa que el sufijo no puede ser la cadena completa).
Por ejemplo, suponga que ha visto los caracteres 'abcabc'. El punto activo representaría el punto en el árbol correspondiente al sufijo 'abc'.
El punto activo está representado por (origen, primero, último). Esto significa que actualmente se encuentra en el punto del árbol al que llega al comenzar en el origen del nodo y luego introducir los caracteres en la cadena [first: last]
Cuando agrega un nuevo personaje, mira para ver si el punto activo todavía está en el árbol existente. Si es así, ya está. De lo contrario, debe agregar un nuevo nodo al árbol de sufijos en el punto activo, recurrir a la siguiente coincidencia más corta y verificar nuevamente.
Nota 1: Los punteros de sufijo dan un enlace a la siguiente coincidencia más corta para cada nodo.
Nota 2: cuando agrega un nuevo nodo y una alternativa, agrega un nuevo puntero de sufijo para el nuevo nodo. El destino para este puntero sufijo será el nodo en el punto activo acortado. Este nodo ya existirá o se creará en la próxima iteración de este bucle de reserva.
Nota 3: La parte de canonización simplemente ahorra tiempo al verificar el punto activo. Por ejemplo, suponga que siempre usó origin = 0, y solo cambió primero y último. Para verificar el punto activo, debería seguir el árbol de sufijos cada vez a lo largo de todos los nodos intermedios. Tiene sentido almacenar en caché el resultado de seguir esta ruta registrando solo la distancia desde el último nodo.
¿Puede dar un código de ejemplo de lo que quiere decir con "corregir" las variables de límite?
Advertencia de salud: también encontré este algoritmo particularmente difícil de entender, así que tenga en cuenta que es probable que esta intuición sea incorrecta en todos los detalles importantes ...
fuente
Hola, he intentado implementar la implementación explicada anteriormente en ruby, compruébalo. Parece que funciona bien.
La única diferencia en la implementación es que he tratado de usar el objeto de borde en lugar de solo usar símbolos.
también está presente en https://gist.github.com/suchitpuri/9304856
fuente