Algoritmo del árbol de sufijos de Ukkonen en inglés simple


1102

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:

https://gist.github.com/2373868


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 chalkdesde 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


2
¿Le echó un vistazo a la descripción dada en el libro de Dan Gusfield ? Encontré eso útil.
jogojapan

44
La esencia no especifica la licencia: ¿puedo cambiar su código y volver a publicar bajo MIT (obviamente con atribuciones)?
Yurik

2
Sí, ve por tu vida. Considéralo dominio público. Como se menciona en otra respuesta en esta página, hay un error que necesita ser reparado de todos modos.
Nathan Ridley

1
tal vez esta implementación ayude a otros, vaya a code.google.com/p/text-indexing
cos

2
"Considerarlo dominio público" es, quizás sorprendentemente, una respuesta muy inútil. La razón es que en realidad es imposible que coloque el trabajo en el dominio público. Por lo tanto, su comentario "considérelo ..." subraya el hecho de que la licencia no está clara y le da al lector razones para dudar de que el estado del trabajo sea realmente claro para usted . Si desea que las personas puedan usar su código, especifique una licencia para él, elija la licencia que desee (pero, a menos que sea un abogado, ¡elija una licencia preexistente!)
James Youngman

Respuestas:


2380

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.

  1. 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

  2. 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:

abc

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 acreando 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és a).

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 por

  • expandir el aborde existente aab
  • insertando un nuevo borde para b

En nuestra representación esto parece

ingrese la descripción de la imagen aquí

Y lo que significa es:

Observamos dos cosas:

  • La representación de borde para abes 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.
  • Cada borde consume O (1) espacio, ya que consta de solo dos punteros en el texto, independientemente de cuántos caracteres represente.

A continuación, incrementamos la posición nuevamente y actualizamos el árbol agregando un ca cada borde existente e insertando un nuevo borde para el nuevo sufijo c.

En nuestra representación esto parece

Y lo que significa es:

Observamos:

  • El árbol es el árbol de sufijos correcto hasta la posición actual después de cada paso
  • Hay tantos pasos como caracteres en el texto.
  • La cantidad de trabajo en cada paso es O (1), porque todos los bordes existentes se actualizan automáticamente mediante incrementos #, 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:

abcabxabcd

Comienza abccomo en el ejemplo anterior, luego abse repite y sigue x, y luego abcse repite seguido de d.

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,, aen 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:

  • El punto activo , que es un triple (active_node,active_edge,active_length)
  • El remainder, que es un número entero que indica cuántos sufijos nuevos necesitamos insertar

El significado exacto de estos dos se aclarará pronto, pero por ahora solo digamos:

  • En el abcejemplo simple , el punto activo siempre fue (root,'\0x',0), es decir, active_nodeel nodo raíz, active_edgese especificó como el carácter nulo '\0x'y active_lengthfue 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.
  • El remaindersiempre 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 aen la raíz, nos damos cuenta de que ya existe un arco de salida a partir de a, en concreto: abca. Esto es lo que hacemos en tal caso:

  • Nosotros no insertamos un borde fresco [4,#]en el nodo raíz. En cambio, simplemente notamos que el sufijo aya 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.
  • Nos fijamos el punto activo a (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 comienza a, específicamente, después de la posición 1 en ese borde. Notamos que el borde se especifica simplemente por su primer carácter a. 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).
  • También incrementamos 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 final aestá 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 remainderes 2 , necesitamos insertar dos sufijos finales de la posición actual: aby b. Esto es básicamente porque:

  • El asufijo del paso anterior nunca se ha insertado correctamente. Por lo tanto, se ha mantenido , y dado que hemos avanzado un paso, ahora ha crecido de aa ab.
  • Y necesitamos insertar el nuevo borde final b.

En la práctica, esto significa que vamos al punto activo (que apunta detrás ade lo que ahora es el abcabborde) e insertamos el carácter final actual b. Pero: una vez más, resulta que btambién está presente en ese mismo borde.

Entonces, nuevamente, no cambiamos el árbol. Nosotros simplemente:

  • Actualice el punto activo a (root,'a',2)(mismo nodo y borde que antes, pero ahora señalamos detrás de b)
  • Incremente el valor remaindera 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 aby ben el paso actual, pero como abya se encontró, actualizamos el punto activo y ni siquiera intentamos insertarlo b. ¿Por qué? Porque si abestá en el árbol, todos sus sufijos (incluidos b) 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 remainderes 3 , tenemos que insertar abx, bxy x. El punto activo nos dice dónde abtermina, por lo que solo necesitamos saltar allí e insertar el x. De hecho, xtodavía no está allí, así que dividimos el abcabxborde 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 abxy decremento remaindera 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 que active_nodesea ​​raíz (aprenderemos la regla 3 para otros casos más abajo). Aquí está la regla 1:

Después de una inserción desde la raíz,

  • active_node sigue siendo raíz
  • active_edge se establece en el primer carácter del nuevo sufijo que necesitamos insertar, es decir b
  • active_length se reduce en 1

Por lo tanto, el nuevo triple de punto activo (root,'b',1)indica que la siguiente inserción debe hacerse en el bcabxborde, detrás de 1 carácter, es decir, detrás b. Podemos identificar el punto de inserción en el tiempo O (1) y verificar si xya está presente o no. Si estuviera presente, finalizaríamos el paso actual y dejaríamos todo como está. Pero x no está presente, así que lo insertamos dividiendo el borde:

Nuevamente, esto tomó tiempo O (1) y actualizamos remaindera 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:

Si dividimos un borde e insertamos un nuevo nodo, y si ese no es el primer nodo creado durante el paso actual, conectamos el nodo previamente insertado y el nuevo nodo a través de un puntero especial, un enlace de sufijo . Más adelante veremos por qué eso es útil. Esto es lo que obtenemos, el enlace de sufijo se representa como un borde punteado:

Todavía tenemos que insertar el sufijo final del paso actual, x. Como el active_lengthcomponente 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 comience x, 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ácter aa 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, agregamos b, y como se vio antes, esto solo significa que actualizamos el punto activo (root,'a',2)e incrementamos remaindersin hacer nada más, porque bya 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ía node1referirme al nodo interno en el que abtermina 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 agrega cautomá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), incrementar remaindery no hacer nada más.

Ahora en el paso #= 10 , remainderes 4, por lo que primero debemos insertar abcd(que queda de hace 3 pasos) insertando den el punto activo.

Intentar insertar den 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:

Después de dividir un borde de un active_nodeque no es el nodo raíz, seguimos el enlace de sufijo que sale de ese nodo, si lo hay, y restablecemos active_nodeel nodo al que apunta. Si no hay un enlace de sufijo, lo establecemos active_nodeen la raíz. active_edge y active_lengthpermanecer sin cambios.

Entonces el punto activo es ahora (node2,'c',1), y node2está marcado en rojo a continuación:

Desde la introducción de abcdes completa, que decrementamos remaindera 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ón bcdse puede realizar simplemente insertando su carácter final den 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 abestá vinculado al nodo en b(su sufijo), y el nodo en abcestá vinculado bc.

El paso actual aún no está terminado. remainderahora es 2, y debemos seguir la regla 3 para restablecer el punto activo nuevamente. Como el actual active_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:, cabxabcddetrás del primer carácter, es decir, detrás c. 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, remainderse puede establecer en 1 y, dado que active_nodees 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 sola d 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.

  • remaindernos 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 remaindery 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 que remainder> 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. Forzandoremainderser 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 remainderinserciones, cada una de las cuales toma tiempo O (1). Como remainderindica 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_lengthcomponente no funciona bien con el nuevo active_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ás fdel defgborde. 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, el dborde que sale del nodo verde es de, 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_lengthpodría ser tan grande como remainder, 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 paso remainderes 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_lengthse reducirá. A medida que seguimos la cadena de enlaces de sufijo, hacemos las inserciones restantes, active_lengthsolo puede disminuir, y el número de ajustes de puntos activos que podemos hacer en el camino no puede ser mayor que active_lengthen cualquier momento dado. Como active_lengthnunca puede ser mayor que remainder, y remainder es O (n) no solo en cada paso, sino que la suma total de los incrementos realizados remainderen 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).


74
Lo siento, esto terminó un poco más de lo que esperaba. Y me doy cuenta de que explica una serie de cosas triviales que todos sabemos, mientras que las partes difíciles aún podrían no estar perfectamente claras. Vamos a editarlo en forma juntos.
jogojapan

69
Y debo agregar que esto no se basa en la descripción que se encuentra en el libro de Dan Gusfield. Es un nuevo intento de describir el algoritmo considerando primero una cadena sin repeticiones y luego discutiendo cómo se manejan las repeticiones. Esperaba que fuera más intuitivo.
jogojapan

8
Gracias @jogojapan, pude escribir un ejemplo completamente funcional gracias a tu explicación. He publicado la fuente, así que espero que alguien más pueda encontrarla útil: gist.github.com/2373868
Nathan Ridley

44
@NathanRidley Sí (por cierto, ese bit final es lo que Ukkonen llama canonicize). Una forma de activarlo es asegurarse de que hay una subcadena que aparece tres veces y termina en una cadena que aparece una vez más en un contexto diferente. Por ej abcdefabxybcdmnabcdex. La parte inicial de abcdse repite en abxy(esto crea un nodo interno después ab) y nuevamente en abcdex, y termina en bcd, que aparece no solo en el bcdexcontexto, sino también en el bcdmncontexto. Después de que abcdexse inserta, seguimos el enlace del sufijo para insertar bcdex, y allí se activará canonicize.
jogojapan

66
Ok, mi código ha sido completamente reescrito y ahora funciona correctamente para todos los casos, incluida la canonización automática, además tiene una salida gráfica de texto mucho mejor. gist.github.com/2373868
Nathan Ridley

132

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

  1. punto activo : un triple (active_node; active_edge; active_length), que muestra desde dónde debemos comenzar a insertar un nuevo sufijo.
  2. resto : muestra el número de sufijos que debemos agregar explícitamente . Por ejemplo, si nuestra palabra es 'abcaabca' y resto = 3, significa que debemos procesar 3 últimos sufijos: bca , ca y a .

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 pointy remainder).

Observación 2

Si en algún momento active_lengthes mayor o igual a la longitud del borde actual ( edge_length), movemos nuestra flecha active pointhacia abajo hasta que edge_lengthsea ​​estrictamente mayor que active_length.

Ahora, redefinamos las reglas:

Regla 1

Si después de una inserción desde el nodo activo = raíz , la longitud activa es mayor que 0, entonces:

  1. el nodo activo no cambia
  2. la longitud activa se disminuye
  3. el borde activo se desplaza hacia la derecha (al primer carácter del siguiente sufijo debemos insertar)

Regla 2

Si creamos un nuevo nodo interno O hacemos un insertador desde un nodo interno , y este no es el primer nodo interno TAL en el paso actual, entonces vinculamos el nodo SUCH anterior con ESTE a través de un enlace de sufijo .

Esta definición de la Rule 2es 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

Después de una inserción desde el nodo activo que no es el nodo raíz , debemos seguir el enlace del sufijo y establecer el nodo activo en el nodo al que apunta. Si no hay un enlace de sufijo, establezca el nodo activo en el nodo raíz . De cualquier manera, el borde activo y la longitud activa permanecen sin cambios.

En esta definición de Rule 3tambié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 actualizamos active pointy remainder, 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 a active nodetravé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:

  1. Si NO conectamos los nodos a través de un enlace de sufijo:

    • antes de agregar la última letra c :

    • después de agregar la última letra c :

  2. Si nos HACEMOS conectar los nodos a través de un enlace de sufijo:

    • antes de agregar la última letra c :

    • después de agregar la última letra c :

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 3que, de acuerdo con esto, si no hay un enlace sufijo, entonces debemos poner el active_nodea 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 nodese 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:

  1. Java
  2. C ++

Espero que esta "descripción" combinada con la respuesta detallada de jogojapan ayude a alguien a implementar su propio árbol de sufijos.


3
Muchas gracias y +1 por tu esfuerzo. Estoy seguro de que tiene razón ... aunque no tengo tiempo para pensar en los detalles de inmediato. Comprobaré más tarde y posiblemente también modifique mi respuesta.
jogojapan

Muchas gracias, realmente ayudó. Sin embargo, ¿podría ser más específico en la Observación 3? Por ejemplo, dando los diagramas de los 2 pasos que introducen el nuevo enlace de sufijo. ¿Está el nodo vinculado al nodo activo? (ya que en realidad no insertamos el segundo nodo)
dyesdyes

@makagonov Oye, ¿puedes ayudarme a construir un árbol de sufijos para tu cadena "cdddcdc"? Estoy un poco confundido al hacerlo (los pasos iniciales).
tariq zafar

3
En cuanto a la regla 3, una forma inteligente es establecer el enlace de sufijo de raíz a raíz, y (por defecto) establecer el enlace de sufijo de cada nodo a raíz. Por lo tanto, podemos evitar el condicionamiento y simplemente seguir el enlace del sufijo.
sqd

1
aabaacaadEs 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 correcto active_node.
IvanaGyro

10

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:

  1. Termina conRemainder > 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.

  2. 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.

    ingrese la descripción de la imagen aquí

Los otros dos problemas han sido señalados de alguna manera por @managonov

  1. 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.

  2. 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:

Consejos: Incluye una función de impresión de árbol ingenuo en el código anterior, que es muy importante durante la depuración . Me ahorró mucho tiempo y es conveniente para localizar casos especiales.


10

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.

Repetición de ramificaciones en sufijos

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 remaindernos 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 remaindercada 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 remainderde 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 remaindera 1 y repetimos la operación; hasta que remaindersea ​​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 .

Resto y punto activo

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' :

Resto a través de múltiples bordes

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'

Alcanzar sufijos consecutivos a través de enlaces 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'

Alcanzar sufijos consecutivos a través del reescaneo

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.


8

@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:

  • combinando aristas con nodos
  • utilizando punteros de índice en lugar de referencias
  • rompe declaraciones;
  • continuar declaraciones;

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:

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

public class ST {

  public class Node {
    private final int id;
    private final Map<Character, Edge> edges;
    private Node slink;

    public Node(final int id) {
        this.id = id;
        this.edges = new HashMap<>();
    }

    public void setSlink(final Node slink) {
        this.slink = slink;
    }

    public Map<Character, Edge> getEdges() {
        return this.edges;
    }

    public Node getSlink() {
        return this.slink;
    }

    public String toString(final String word) {
        return new StringBuilder()
                .append("{")
                .append("\"id\"")
                .append(":")
                .append(this.id)
                .append(",")
                .append("\"slink\"")
                .append(":")
                .append(this.slink != null ? this.slink.id : null)
                .append(",")
                .append("\"edges\"")
                .append(":")
                .append(edgesToString(word))
                .append("}")
                .toString();
    }

    private StringBuilder edgesToString(final String word) {
        final StringBuilder edgesStringBuilder = new StringBuilder();
        edgesStringBuilder.append("{");
        for(final Map.Entry<Character, Edge> entry : this.edges.entrySet()) {
            edgesStringBuilder.append("\"")
                    .append(entry.getKey())
                    .append("\"")
                    .append(":")
                    .append(entry.getValue().toString(word))
                    .append(",");
        }
        if(!this.edges.isEmpty()) {
            edgesStringBuilder.deleteCharAt(edgesStringBuilder.length() - 1);
        }
        edgesStringBuilder.append("}");
        return edgesStringBuilder;
    }

    public boolean contains(final String word, final String suffix) {
        return !suffix.isEmpty()
                && this.edges.containsKey(suffix.charAt(0))
                && this.edges.get(suffix.charAt(0)).contains(word, suffix);
    }
  }

  public class Edge {
    private final int from;
    private final int to;
    private final Node next;

    public Edge(final int from, final int to, final Node next) {
        this.from = from;
        this.to = to;
        this.next = next;
    }

    public int getFrom() {
        return this.from;
    }

    public int getTo() {
        return this.to;
    }

    public Node getNext() {
        return this.next;
    }

    public int getLength() {
        return this.to - this.from;
    }

    public String toString(final String word) {
        return new StringBuilder()
                .append("{")
                .append("\"content\"")
                .append(":")
                .append("\"")
                .append(word.substring(this.from, this.to))
                .append("\"")
                .append(",")
                .append("\"next\"")
                .append(":")
                .append(this.next != null ? this.next.toString(word) : null)
                .append("}")
                .toString();
    }

    public boolean contains(final String word, final String suffix) {
        if(this.next == null) {
            return word.substring(this.from, this.to).equals(suffix);
        }
        return suffix.startsWith(word.substring(this.from,
                this.to)) && this.next.contains(word, suffix.substring(this.to - this.from));
    }
  }

  public class ActivePoint {
    private final Node activeNode;
    private final Character activeEdgeFirstCharacter;
    private final int activeLength;

    public ActivePoint(final Node activeNode,
                       final Character activeEdgeFirstCharacter,
                       final int activeLength) {
        this.activeNode = activeNode;
        this.activeEdgeFirstCharacter = activeEdgeFirstCharacter;
        this.activeLength = activeLength;
    }

    private Edge getActiveEdge() {
        return this.activeNode.getEdges().get(this.activeEdgeFirstCharacter);
    }

    public boolean pointsToActiveNode() {
        return this.activeLength == 0;
    }

    public boolean activeNodeIs(final Node node) {
        return this.activeNode == node;
    }

    public boolean activeNodeHasEdgeStartingWith(final char character) {
        return this.activeNode.getEdges().containsKey(character);
    }

    public boolean activeNodeHasSlink() {
        return this.activeNode.getSlink() != null;
    }

    public boolean pointsToOnActiveEdge(final String word, final char character) {
        return word.charAt(this.getActiveEdge().getFrom() + this.activeLength) == character;
    }

    public boolean pointsToTheEndOfActiveEdge() {
        return this.getActiveEdge().getLength() == this.activeLength;
    }

    public boolean pointsAfterTheEndOfActiveEdge() {
        return this.getActiveEdge().getLength() < this.activeLength;
    }

    public ActivePoint moveToEdgeStartingWithAndByOne(final char character) {
        return new ActivePoint(this.activeNode, character, 1);
    }

    public ActivePoint moveToNextNodeOfActiveEdge() {
        return new ActivePoint(this.getActiveEdge().getNext(), null, 0);
    }

    public ActivePoint moveToSlink() {
        return new ActivePoint(this.activeNode.getSlink(),
                this.activeEdgeFirstCharacter,
                this.activeLength);
    }

    public ActivePoint moveTo(final Node node) {
        return new ActivePoint(node, this.activeEdgeFirstCharacter, this.activeLength);
    }

    public ActivePoint moveByOneCharacter() {
        return new ActivePoint(this.activeNode,
                this.activeEdgeFirstCharacter,
                this.activeLength + 1);
    }

    public ActivePoint moveToEdgeStartingWithAndByActiveLengthMinusOne(final Node node,
                                                                       final char character) {
        return new ActivePoint(node, character, this.activeLength - 1);
    }

    public ActivePoint moveToNextNodeOfActiveEdge(final String word, final int index) {
        return new ActivePoint(this.getActiveEdge().getNext(),
                word.charAt(index - this.activeLength + this.getActiveEdge().getLength()),
                this.activeLength - this.getActiveEdge().getLength());
    }

    public void addEdgeToActiveNode(final char character, final Edge edge) {
        this.activeNode.getEdges().put(character, edge);
    }

    public void splitActiveEdge(final String word,
                                final Node nodeToAdd,
                                final int index,
                                final char character) {
        final Edge activeEdgeToSplit = this.getActiveEdge();
        final Edge splittedEdge = new Edge(activeEdgeToSplit.getFrom(),
                activeEdgeToSplit.getFrom() + this.activeLength,
                nodeToAdd);
        nodeToAdd.getEdges().put(word.charAt(activeEdgeToSplit.getFrom() + this.activeLength),
                new Edge(activeEdgeToSplit.getFrom() + this.activeLength,
                        activeEdgeToSplit.getTo(),
                        activeEdgeToSplit.getNext()));
        nodeToAdd.getEdges().put(character, new Edge(index, word.length(), null));
        this.activeNode.getEdges().put(this.activeEdgeFirstCharacter, splittedEdge);
    }

    public Node setSlinkTo(final Node previouslyAddedNodeOrAddedEdgeNode,
                           final Node node) {
        if(previouslyAddedNodeOrAddedEdgeNode != null) {
            previouslyAddedNodeOrAddedEdgeNode.setSlink(node);
        }
        return node;
    }

    public Node setSlinkToActiveNode(final Node previouslyAddedNodeOrAddedEdgeNode) {
        return setSlinkTo(previouslyAddedNodeOrAddedEdgeNode, this.activeNode);
    }
  }

  private static int idGenerator;

  private final String word;
  private final Node root;
  private ActivePoint activePoint;
  private int remainder;

  public ST(final String word) {
    this.word = word;
    this.root = new Node(idGenerator++);
    this.activePoint = new ActivePoint(this.root, null, 0);
    this.remainder = 0;
    build();
  }

  private void build() {
    for(int i = 0; i < this.word.length(); i++) {
        add(i, this.word.charAt(i));
    }
  }

  private void add(final int index, final char character) {
    this.remainder++;
    boolean characterFoundInTheTree = false;
    Node previouslyAddedNodeOrAddedEdgeNode = null;
    while(!characterFoundInTheTree && this.remainder > 0) {
        if(this.activePoint.pointsToActiveNode()) {
            if(this.activePoint.activeNodeHasEdgeStartingWith(character)) {
                activeNodeHasEdgeStartingWithCharacter(character, previouslyAddedNodeOrAddedEdgeNode);
                characterFoundInTheTree = true;
            }
            else {
                if(this.activePoint.activeNodeIs(this.root)) {
                    rootNodeHasNotEdgeStartingWithCharacter(index, character);
                }
                else {
                    previouslyAddedNodeOrAddedEdgeNode = internalNodeHasNotEdgeStartingWithCharacter(index,
                            character, previouslyAddedNodeOrAddedEdgeNode);
                }
            }
        }
        else {
            if(this.activePoint.pointsToOnActiveEdge(this.word, character)) {
                activeEdgeHasCharacter();
                characterFoundInTheTree = true;
            }
            else {
                if(this.activePoint.activeNodeIs(this.root)) {
                    previouslyAddedNodeOrAddedEdgeNode = edgeFromRootNodeHasNotCharacter(index,
                            character,
                            previouslyAddedNodeOrAddedEdgeNode);
                }
                else {
                    previouslyAddedNodeOrAddedEdgeNode = edgeFromInternalNodeHasNotCharacter(index,
                            character,
                            previouslyAddedNodeOrAddedEdgeNode);
                }
            }
        }
    }
  }

  private void activeNodeHasEdgeStartingWithCharacter(final char character,
                                                    final Node previouslyAddedNodeOrAddedEdgeNode) {
    this.activePoint.setSlinkToActiveNode(previouslyAddedNodeOrAddedEdgeNode);
    this.activePoint = this.activePoint.moveToEdgeStartingWithAndByOne(character);
    if(this.activePoint.pointsToTheEndOfActiveEdge()) {
        this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge();
    }
  }

  private void rootNodeHasNotEdgeStartingWithCharacter(final int index, final char character) {
    this.activePoint.addEdgeToActiveNode(character, new Edge(index, this.word.length(), null));
    this.activePoint = this.activePoint.moveTo(this.root);
    this.remainder--;
    assert this.remainder == 0;
  }

  private Node internalNodeHasNotEdgeStartingWithCharacter(final int index,
                                                         final char character,
                                                         Node previouslyAddedNodeOrAddedEdgeNode) {
    this.activePoint.addEdgeToActiveNode(character, new Edge(index, this.word.length(), null));
    previouslyAddedNodeOrAddedEdgeNode = this.activePoint.setSlinkToActiveNode(previouslyAddedNodeOrAddedEdgeNode);
    if(this.activePoint.activeNodeHasSlink()) {
        this.activePoint = this.activePoint.moveToSlink();
    }
    else {
        this.activePoint = this.activePoint.moveTo(this.root);
    }
    this.remainder--;
    return previouslyAddedNodeOrAddedEdgeNode;
  }

  private void activeEdgeHasCharacter() {
    this.activePoint = this.activePoint.moveByOneCharacter();
    if(this.activePoint.pointsToTheEndOfActiveEdge()) {
        this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge();
    }
  }

  private Node edgeFromRootNodeHasNotCharacter(final int index,
                                             final char character,
                                             Node previouslyAddedNodeOrAddedEdgeNode) {
    final Node newNode = new Node(idGenerator++);
    this.activePoint.splitActiveEdge(this.word, newNode, index, character);
    previouslyAddedNodeOrAddedEdgeNode = this.activePoint.setSlinkTo(previouslyAddedNodeOrAddedEdgeNode, newNode);
    this.activePoint = this.activePoint.moveToEdgeStartingWithAndByActiveLengthMinusOne(this.root,
            this.word.charAt(index - this.remainder + 2));
    this.activePoint = walkDown(index);
    this.remainder--;
    return previouslyAddedNodeOrAddedEdgeNode;
  }

  private Node edgeFromInternalNodeHasNotCharacter(final int index,
                                                 final char character,
                                                 Node previouslyAddedNodeOrAddedEdgeNode) {
    final Node newNode = new Node(idGenerator++);
    this.activePoint.splitActiveEdge(this.word, newNode, index, character);
    previouslyAddedNodeOrAddedEdgeNode = this.activePoint.setSlinkTo(previouslyAddedNodeOrAddedEdgeNode, newNode);
    if(this.activePoint.activeNodeHasSlink()) {
        this.activePoint = this.activePoint.moveToSlink();
    }
    else {
        this.activePoint = this.activePoint.moveTo(this.root);
    }
    this.activePoint = walkDown(index);
    this.remainder--;
    return previouslyAddedNodeOrAddedEdgeNode;
  }

  private ActivePoint walkDown(final int index) {
    while(!this.activePoint.pointsToActiveNode()
            && (this.activePoint.pointsToTheEndOfActiveEdge() || this.activePoint.pointsAfterTheEndOfActiveEdge())) {
        if(this.activePoint.pointsAfterTheEndOfActiveEdge()) {
            this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge(this.word, index);
        }
        else {
            this.activePoint = this.activePoint.moveToNextNodeOfActiveEdge();
        }
    }
    return this.activePoint;
  }

  public String toString(final String word) {
    return this.root.toString(word);
  }

  public boolean contains(final String suffix) {
    return this.root.contains(this.word, suffix);
  }

  public static void main(final String[] args) {
    final String[] words = {
            "abcabcabc$",
            "abc$",
            "abcabxabcd$",
            "abcabxabda$",
            "abcabxad$",
            "aabaaabb$",
            "aababcabcd$",
            "ababcabcd$",
            "abccba$",
            "mississipi$",
            "abacabadabacabae$",
            "abcabcd$",
            "00132220$"
    };
    Arrays.stream(words).forEach(word -> {
        System.out.println("Building suffix tree for word: " + word);
        final ST suffixTree = new ST(word);
        System.out.println("Suffix tree: " + suffixTree.toString(word));
        for(int i = 0; i < word.length() - 1; i++) {
            assert suffixTree.contains(word.substring(i)) : word.substring(i);
        }
    });
  }
}

6

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 ...


Uno de los trabajos académicos define "apropiado" como el "sufijo apropiado" de una cadena que no contiene su primer carácter. A veces se llama "sufijo" a toda una subcadena, pero cuando se define el algoritmo, los términos "cadena" y "subcadena" y "sufijo" se generan libremente y, a veces, debe ser muy claro lo que quiere decir con "sufijo". el término "sufijo apropiado" excluye llamar a todo el asunto un sufijo. Por lo tanto, una subcadena de sufijo de una cadena puede ser cualquier subcadena legítima y puede tener un sufijo adecuado que no sea el mismo sufijo. Porque la lógica
Blair Houghton

3

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

    require 'pry'


class Edge
    attr_accessor :data , :edges , :suffix_link
    def initialize data
        @data = data
        @edges = []
        @suffix_link = nil
    end

    def find_edge element
        self.edges.each do |edge|
            return edge if edge.data.start_with? element
        end
        return nil
    end
end

class SuffixTrees
    attr_accessor :root , :active_point , :remainder , :pending_prefixes , :last_split_edge , :remainder

    def initialize
        @root = Edge.new nil
        @active_point = { active_node: @root , active_edge: nil , active_length: 0}
        @remainder = 0
        @pending_prefixes = []
        @last_split_edge = nil
        @remainder = 1
    end

    def build string
        string.split("").each_with_index do |element , index|


            add_to_edges @root , element        

            update_pending_prefix element                           
            add_pending_elements_to_tree element
            active_length = @active_point[:active_length]

            # if(@active_point[:active_edge] && @active_point[:active_edge].data && @active_point[:active_edge].data[0..active_length-1] ==  @active_point[:active_edge].data[active_length..@active_point[:active_edge].data.length-1])
            #   @active_point[:active_edge].data = @active_point[:active_edge].data[0..active_length-1]
            #   @active_point[:active_edge].edges << Edge.new(@active_point[:active_edge].data)
            # end

            if(@active_point[:active_edge] && @active_point[:active_edge].data && @active_point[:active_edge].data.length == @active_point[:active_length]  )
                @active_point[:active_node] =  @active_point[:active_edge]
                @active_point[:active_edge] = @active_point[:active_node].find_edge(element[0])
                @active_point[:active_length] = 0
            end
        end
    end

    def add_pending_elements_to_tree element

        to_be_deleted = []
        update_active_length = false
        # binding.pry
        if( @active_point[:active_node].find_edge(element[0]) != nil)
            @active_point[:active_length] = @active_point[:active_length] + 1               
            @active_point[:active_edge] = @active_point[:active_node].find_edge(element[0]) if @active_point[:active_edge] == nil
            @remainder = @remainder + 1
            return
        end



        @pending_prefixes.each_with_index do |pending_prefix , index|

            # binding.pry           

            if @active_point[:active_edge] == nil and @active_point[:active_node].find_edge(element[0]) == nil

                @active_point[:active_node].edges << Edge.new(element)

            else

                @active_point[:active_edge] = node.find_edge(element[0]) if @active_point[:active_edge]  == nil

                data = @active_point[:active_edge].data
                data = data.split("")               

                location = @active_point[:active_length]


                # binding.pry
                if(data[0..location].join == pending_prefix or @active_point[:active_node].find_edge(element) != nil )                  


                else #tree split    
                    split_edge data , index , element
                end

            end
        end 
    end



    def update_pending_prefix element
        if @active_point[:active_edge] == nil
            @pending_prefixes = [element]
            return

        end

        @pending_prefixes = []

        length = @active_point[:active_edge].data.length
        data = @active_point[:active_edge].data
        @remainder.times do |ctr|
                @pending_prefixes << data[-(ctr+1)..data.length-1]
        end

        @pending_prefixes.reverse!

    end

    def split_edge data , index , element
        location = @active_point[:active_length]
        old_edges = []
        internal_node = (@active_point[:active_edge].edges != nil)

        if (internal_node)
            old_edges = @active_point[:active_edge].edges 
            @active_point[:active_edge].edges = []
        end

        @active_point[:active_edge].data = data[0..location-1].join                 
        @active_point[:active_edge].edges << Edge.new(data[location..data.size].join)


        if internal_node
            @active_point[:active_edge].edges << Edge.new(element)
        else
            @active_point[:active_edge].edges << Edge.new(data.last)        
        end

        if internal_node
            @active_point[:active_edge].edges[0].edges = old_edges
        end


        #setup the suffix link
        if @last_split_edge != nil and @last_split_edge.data.end_with?@active_point[:active_edge].data 

            @last_split_edge.suffix_link = @active_point[:active_edge] 
        end

        @last_split_edge = @active_point[:active_edge]

        update_active_point index

    end


    def update_active_point index
        if(@active_point[:active_node] == @root)
            @active_point[:active_length] = @active_point[:active_length] - 1
            @remainder = @remainder - 1
            @active_point[:active_edge] = @active_point[:active_node].find_edge(@pending_prefixes.first[index+1])
        else
            if @active_point[:active_node].suffix_link != nil
                @active_point[:active_node] = @active_point[:active_node].suffix_link               
            else
                @active_point[:active_node] = @root
            end 
            @active_point[:active_edge] = @active_point[:active_node].find_edge(@active_point[:active_edge].data[0])
            @remainder = @remainder - 1     
        end
    end

    def add_to_edges root , element     
        return if root == nil
        root.data = root.data + element if(root.data and root.edges.size == 0)
        root.edges.each do |edge|
            add_to_edges edge , element
        end
    end
end

suffix_tree = SuffixTrees.new
suffix_tree.build("abcabxabcd")
binding.pry
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.