web-dev-qa-db-fra.com

Algorithme de suffixe d'Ukkonen en anglais courant

Je me sens un peu épais à ce stade. J'ai passé des jours à essayer de comprendre complètement la construction d'arbres suffixe, mais comme je n'ai pas de formation en mathématiques, de nombreuses explications m'échappent alors qu'elles commencent à utiliser excessivement la symbologie mathématique. L'explication la plus proche d'une bonne explication que j'ai trouvée est recherche rapide de chaînes avec des arbres suffixés, mais il passe en revue divers points et certains aspects de l'algorithme Reste incertain.

Une explication étape par étape de cet algorithme ici sur Stack Overflow serait inestimable pour beaucoup d'autres que moi, j'en suis sûr.

Pour référence, voici l'article de Ukkonen sur l'algorithme: http://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf

Ma compréhension de base, jusqu'à présent:

  • Je dois parcourir chaque préfixe P d'une chaîne donnée T
  • Je dois parcourir chaque suffixe S dans le préfixe P et l'ajouter à l'arbre
  • Pour ajouter le suffixe S à l’arbre, je dois parcourir chaque caractère de S, les itérations consistant à descendre une branche existante qui commence par le même ensemble de caractères C en S et à diviser potentiellement Edge en nœuds descendants lorsque atteignez un caractère différent dans le suffixe, OR s'il n'y a pas de Edge correspondant à suivre. Lorsqu'aucun Edge correspondant n'est trouvé pour C, un nouveau bord feuille est créé pour C.

L’algorithme de base semble être O (n2), comme le soulignent la plupart des explications, car nous devons parcourir tous les préfixes, nous devons parcourir chacun des suffixes pour chaque préfixe. L'algorithme d'Ukkonen est apparemment unique en raison de la technique de pointeur de suffixe qu'il utilise, bien que je pense que est ce que j'ai du mal à comprendre.

J'ai aussi du mal à comprendre:

  • exactement quand et comment le "point actif" est attribué, utilisé et modifié
  • ce qui se passe avec l'aspect canonisation de l'algorithme
  • Pourquoi les implémentations que j'ai vues ont besoin de "réparer" les variables limites qu'elles utilisent

Voici le code source complété C # . Non seulement cela fonctionne correctement, mais il prend en charge la canonisation automatique et rend un graphe de texte plus esthétique. Le code source et l'exemple de sortie sont à:

https://Gist.github.com/2373868


Mise à jour 2017-11-04

Après de nombreuses années, j'ai trouvé un nouvel usage des arborescences de suffixes et implémenté l'algorithme en JavaScript . Gist est en dessous. Cela devrait être sans bug. Déposez-le dans un fichier js, npm install chalk à partir du même emplacement, puis exécutez-le avec node.js pour afficher une sortie colorée. Il y a une version allégée dans le même Gist, sans aucun code de débogage.

https://Gist.github.com/axefrog/c347bf0f5e0723cbd09b1aaed6ec6fc6

1048
Nathan Ridley

Ce qui suit est une tentative pour décrire l’algorithme Ukkonen en montrant d’abord ce qu’il fait lorsque la chaîne est simple (c’est-à-dire ne contient aucun caractère répété), puis en l’étendant à l’algorithme complet.

Tout d’abord, quelques déclarations préliminaires.

  1. Ce que nous construisons est fondamentalement comme un test de recherche. Donc, il y a un nœud racine, les bords qui en sortent conduisant à de nouveaux nœuds, et d'autres bords qui en sortent, etc.

  2. Mais : Contrairement aux critères de recherche, les étiquettes de bord ne sont pas des caractères uniques. Au lieu de cela, chaque Edge est étiqueté à l'aide d'une paire d'entiers [from,to]. Ce sont des pointeurs dans le texte. En ce sens, chaque Edge porte une étiquette de longueur arbitraire, mais ne prend que O(1) espace (deux pointeurs).

Principe de base

Je voudrais d’abord montrer comment créer l’arborescence de suffixes d’une chaîne particulièrement simple, une chaîne sans caractères répétés:

abc

L'algorithme fonctionne par étapes, de gauche à droite . Il y a une étape pour chaque caractère de la chaîne . Chaque étape peut impliquer plus d'une opération individuelle, mais nous verrons (voir les observations finales à la fin) que le nombre total d'opérations est O (n).

Donc, nous partons de à gauche , et n’insérons d’abord que le seul caractère a en créant un bord à partir du noeud racine (à gauche) dans une feuille et en le nommant [0,#] , ce qui signifie que l’Edge représente la sous-chaîne commençant à la position 0 et se terminant à la fin courante . J'utilise le symbole # pour signifier l'extrémité actuelle , qui est à la position 1 (juste après a).

Nous avons donc un arbre initial, qui ressemble à ceci:

Et ce que cela signifie, c'est ceci:

Nous passons maintenant à la position 2 (juste après b). Notre objectif à chaque étape est d'insérer tous les suffixes jusqu'à la position actuelle . Nous faisons cela par

  • extension du a- Edge existant à ab
  • insertion d'un nouveau bord pour b

Dans notre représentation cela ressemble à

enter image description here

Et ce que cela signifie, c'est:

Nous observons deux choses:

  • La représentation de Edge pour ab est identique à celle utilisée auparavant dans l’arborescence initiale: [0,#]. Sa signification a automatiquement changé car nous avons mis à jour la position actuelle # de 1 à 2.
  • Chaque bord utilise O(1) espace, car il ne comporte que deux pointeurs dans le texte, quel que soit le nombre de caractères qu'il représente.

Ensuite, nous incrémentons à nouveau la position et mettons à jour l’arborescence en ajoutant un c à chaque Edge existant et en insérant un nouvel Edge pour le nouveau suffixe c.

Dans notre représentation cela ressemble à

Et ce que cela signifie, c'est:

Nous observons:

  • L’arbre est l’arbre de suffixe correct jusqu’à la position actuelle après chaque étape
  • Il y a autant d'étapes qu'il y a de caractères dans le texte
  • La quantité de travail à chaque étape est O (1), car toutes les arêtes existantes sont mises à jour automatiquement en incrémentant # et l'insertion du nouvel arête pour le dernier caractère peut être effectuée dans le temps O(1). Par conséquent, pour une chaîne de longueur n, seul le temps O(n) est requis.

Première extension: répétitions simples

Bien sûr, cela fonctionne si bien seulement parce que notre chaîne ne contient aucune répétition. Nous examinons maintenant une chaîne plus réaliste:

abcabxabcd

Il commence par abc comme dans l'exemple précédent, puis ab est répété et suivi de x, puis abc est répété, suivi de d.

Etapes 1 à 3: Après les 3 premières étapes, nous avons l'arbre de l'exemple précédent:

Étape 4: Nous déplaçons # en position 4. Cela met implicitement à jour toutes les arêtes existantes:

et nous devons insérer le suffixe final de l'étape en cours, a, à la racine.

Avant de faire cela, nous introduisons deux autres variables (en plus de #), qui ont bien sûr été présentes tout le temps mais que nous n'avons pas utilisées eux jusqu'à présent:

  • Le point actif , qui est un triple (active_node,active_Edge,active_length)
  • remainder, qui est un entier indiquant le nombre de nouveaux suffixes à insérer

Le sens exact de ces deux deviendra bientôt clair, mais pour l'instant disons simplement:

  • Dans l'exemple simple abc, le point actif était toujours (root,'\0x',0), c'est-à-dire active_node était le nœud racine, active_Edge était spécifié comme caractère NULL '\0x' et active_length était égal à zéro. Cela a eu pour effet que le nouvel Edge que nous avons inséré à chaque étape a été inséré au nœud racine en tant que Edge nouvellement créé. Nous verrons bientôt pourquoi un triple est nécessaire pour représenter cette information.
  • remainder a toujours été défini sur 1 au début de chaque étape. Cela signifiait que le nombre de suffixes que nous devions insérer activement à la fin de chaque étape était de 1 (toujours le dernier caractère).

Maintenant cela va changer. Lorsque nous insérons le dernier caractère actuel a à la racine, nous remarquons qu’il existe déjà un Edge sortant commençant par a, plus précisément: abca. Voici ce que nous faisons dans un tel cas:

  • Nous n'insérons pas un nouvel Edge [4,#] au niveau du noeud racine. Au lieu de cela, nous remarquons simplement que le suffixe a est déjà dans notre arbre. Cela se termine au milieu d’un Edge plus long, mais cela ne nous gêne pas. Nous laissons les choses comme elles sont.
  • Nous définissons le point actif sur (root,'a',1). Cela signifie que le point actif se trouve maintenant quelque part au milieu du bord sortant du nœud racine qui commence par a, en particulier après la position 1 sur ce bord. Nous remarquons que l’Edge est spécifié simplement par son premier caractère a. Cela suffit car il ne peut y avoir qu'un seul bord commençant par un caractère particulier (confirmez que cela est vrai après avoir lu toute la description).
  • Nous incrémentons également remainder. Ainsi, au début de la prochaine étape, ce sera 2.

Observation: lorsque le suffixe final que nous devons insérer existe déjà dans l'arborescence , l’arbre lui-même est pas changé (nous ne mettons à jour que le point actif et remainder). L’arbre n’est alors plus une représentation précise de l’arbre de suffixe jusqu’à la position actuelle , mais il contient tous les suffixes (car le suffixe final a est contenu implicitement ). Par conséquent, en dehors de la mise à jour des variables (qui sont toutes de longueur fixe, il s’agit donc de O (1)), il n’y a aucun travail effectué à cette étape .

Étape 5: Nous mettons à jour la position actuelle # en 5. Ceci met automatiquement à jour l'arbre à ceci:

Et parce que remainder est égal à 2 , nous devons insérer deux suffixes finaux de la position actuelle: ab et b. C'est essentiellement parce que:

  • Le suffixe a de l'étape précédente n'a jamais été correctement inséré. Donc, il est resté , et depuis que nous avons progressé d’un pas, il est maintenant passé de a à ab.
  • Et nous devons insérer le nouveau Edge b final.

En pratique, cela signifie que nous allons au point actif (qui pointe derrière le a sur ce qui est maintenant le bord abcab) et insérons le dernier caractère actuel, b. Mais: Encore une fois, il s'avère que b est également déjà présent sur ce même Edge.

Donc, encore une fois, nous ne changeons pas l’arbre. Nous simplement:

  • Mettez à jour le point actif sur (root,'a',2) (même noeud et Edge qu'avant, mais nous pointons maintenant derrière le b)
  • Incrémentez remainder à 3 car nous n’avons toujours pas inséré correctement le bord final de l’étape précédente et nous n’insérons pas non plus le bord final en cours.

Pour être clair: nous avons dû insérer ab et b dans l'étape actuelle, mais comme ab a déjà été trouvé, nous avons mis à jour le point actif et nous n'avons même pas tenté d'insérer b. Pourquoi? Parce que si ab est dans l’arbre, chaque suffixe (y compris b) doit également être dans l’arbre. Peut-être seulement implicitement , mais il doit être là, à cause de la façon dont nous avons construit l’arbre jusqu’à présent.

Nous procédons à étape 6 en incrémentant #. L'arbre est automatiquement mis à jour pour:

Parce que remainder est égal à 3 , nous devons insérer abx, bx et x. Le point actif nous indique où se termine ab, il suffit donc d’y sauter et d’y insérer le x. En effet, x n’y étant pas encore, nous avons divisé abcabx Edge et inséré un nœud interne:

Les représentations Edge sont toujours des pointeurs dans le texte, vous pouvez donc fractionner et insérer un nœud interne en O(1).

Nous avons donc traité abx et décrémenté remainder à 2. Maintenant, nous devons insérer le prochain suffixe restant, bx. Mais avant cela, nous devons mettre à jour le point actif. La règle pour cela, après avoir scindé et inséré un bord, sera appelée Règle 1 ci-dessous, elle s'applique dès que le active_node est root (nous apprendrons la règle 3 pour les autres cas plus bas). Voici la règle 1:

Après une insertion depuis la racine,

  • active_node reste la racine
  • active_Edge est défini sur le premier caractère du nouveau suffixe à insérer, à savoir b
  • active_length est réduit de 1

Par conséquent, le nouveau point triple triple (root,'b',1) indique que l’insertion suivante doit être effectuée à l’arête bcabx, derrière un caractère, c’est-à-dire derrière b. Nous pouvons identifier le point d’insertion dans le temps O(1) et vérifier si x est déjà présent ou non. S'il était présent, nous terminerions l'étape en cours et laisserons les choses en l'état. Mais x n'est pas présent, nous l'insérons donc en scindant Edge:

Encore une fois, cela a pris O(1) temps et nous mettons à jour remainder à 1 et le point actif à (root,'x',0) en tant qu'état de la règle 1.

Mais il reste encore une chose à faire. Nous appellerons ceci Règle 2:

Si nous scindons un Edge et insérons un nouveau nœud, et si c'est et non le premier nœud créé au cours de l'étape en cours, nous connectons le nœud précédemment inséré et le nouveau nœud via une connexion spéciale. pointeur, un suffixe lien . Nous verrons plus tard pourquoi cela est utile. Voici ce que nous obtenons, le lien de suffixe est représenté par un Edge en pointillé:

Nous devons encore insérer le suffixe final de l'étape en cours, x. Le composant active_length du nœud actif étant tombé à 0, l'insertion finale est effectuée directement à la racine. Comme il n'y a pas de Edge sortant au nœud racine commençant par x, nous insérons un nouveau Edge:

Comme nous pouvons le constater, toutes les insertions restantes ont été faites à l'étape actuelle.

Nous passons à étape 7 en définissant # = 7, qui ajoute automatiquement le caractère suivant, a, à tous les bords de la feuille, comme toujours. Nous essayons ensuite d’insérer le nouveau caractère final dans le point actif (la racine) et trouvons qu’il est déjà là. Nous terminons donc l'étape en cours sans rien insérer et mettons à jour le point actif en (root,'a',1).

Dans étape 8 , # = 8, nous ajoutons b et, comme indiqué précédemment, cela signifie uniquement que nous mettons à jour le point actif en (root,'a',2) et incrémentons remainder sans rien faire. rien d'autre, parce que b est déjà présent. Cependant, nous remarquons (dans O(1) heure) que le point actif est maintenant à la fin d'un bord. Nous réfléchissons cela en le redéfinissant sur (node1,'\0x',0). Ici, j’utilise node1 pour faire référence au noeud interne où se termine ab Edge.

Ensuite, à étape # = 9 , nous devons insérer "c" et cela nous aidera à comprendre le dernier truc:

Deuxième extension: utilisation de liens de suffixe

Comme toujours, la mise à jour # ajoute automatiquement c aux bords de la feuille et nous allons au point actif pour voir si nous pouvons insérer "c". Il s'avère que "c" existe déjà sur cet Edge. Nous avons donc défini le point actif sur (node1,'c',1), incrémenté remainder et ne faisons rien d'autre.

Maintenant dans étape # = 10 , remainder is 4, et nous devons donc tout d'abord insérer abcd (qui reste de 3 étapes précédentes) en insérant d au point actif .

Tenter d’insérer d au point actif provoque la division du Edge au bout de O(1):

Le active_node, à partir duquel le fractionnement a été initié, est marqué en rouge ci-dessus. Voici la règle finale, Règle 3:

Après avoir séparé un Edge d'un active_node qui n'est pas le nœud racine, nous suivons le lien de suffixe sortant de ce nœud, le cas échéant, et réinitialisons le active_node sur le nœud vers lequel il pointe. S'il n'y a pas de lien de suffixe, nous définissons le active_node à la racine. active_Edge et active_length restent inchangés.

Donc, le point actif est maintenant (node2,'c',1), et node2 est marqué en rouge ci-dessous:

L'insertion de abcd étant terminée, nous décrémentons remainder en 3 et considérons le prochain suffixe restant de l'étape en cours, bcd. La règle 3 a défini le point actif uniquement sur les nœuds et bords appropriés. Vous pouvez donc insérer bcd en insérant simplement son dernier caractère d au niveau du point actif.

Cela provoque une autre division Edge, et à cause de la règle 2 , nous devons créer un lien de suffixe du noeud précédemment inséré au nouveau:

Nous observons: Les liens suffixés nous permettent de réinitialiser le point actif afin que nous puissions faire le prochain restant insert à O(1) effort. Examinez le graphique ci-dessus pour vérifier que le nœud de l'étiquette ab est lié au nœud de b (son suffixe) et que le nœud de abc est lié à bc.

L'étape en cours n'est pas encore terminée. remainder est maintenant 2 et nous devons suivre la règle 3 pour réinitialiser le point actif à nouveau. Étant donné que le active_node (en rouge ci-dessus) n'a pas de lien de suffixe, nous réinitialisons à la racine. Le point actif est maintenant (root,'c',1).

Par conséquent, l’insertion suivante a lieu au niveau du bord sortant du nœud racine dont l’étiquette commence par c: cabxabcd, derrière le premier caractère, c’est-à-dire derrière c. Cela provoque une autre scission:

Et puisque cela implique la création d'un nouveau nœud interne, nous suivons la règle 2 et définissons un nouveau lien de suffixe à partir du nœud interne précédemment créé:

(J'utilise Graphviz Dot pour ces petits graphiques. Le nouveau lien de suffixe a obligé point à réorganiser les arêtes existantes. Vérifiez donc soigneusement que le seul élément inséré ci-dessus est un nouveau lien de suffixe. .)

Avec cela, remainder peut être défini sur 1 et, puisque active_node est root, nous utilisons la règle 1 pour mettre à jour le point actif en (root,'d',0). Cela signifie que l'insertion finale de l'étape en cours consiste à insérer un seul d à la racine:

C'était la dernière étape et nous avons terminé. Il y a un nombre d'observations finales , cependant:

  • A chaque étape, nous déplaçons # d'une position. Ceci met automatiquement à jour tous les nœuds d'extrémité dans le temps O(1).

  • Mais il ne traite pas avec a) les suffixes restants des étapes précédentes, et b) avec le dernier caractère de l'étape en cours.

  • remainder nous dit combien d'inserts supplémentaires nous devons faire. Ces insertions correspondent un-à-un aux suffixes finaux de la chaîne qui se termine à la position actuelle #. Nous considérons les uns après les autres et fabriquons l'insert. Important: Chaque insertion est effectuée dans le temps O(1), car le point actif nous indique exactement où aller, et nous devons ajouter un seul caractère au point actif. Pourquoi? Parce que les autres caractères sont contenus implicitement (sinon le point actif ne serait pas là où il se trouve).

  • Après chaque insertion, nous décrémentons remainder et suivons le lien de suffixe s’il en existe. Sinon, nous allons à la racine (règle 3). Si nous sommes déjà à la racine, nous modifions le point actif à l'aide de la règle 1. Dans tous les cas, cela ne prend que O(1) temps.

  • Si, pendant l'une de ces insertions, nous trouvons que le caractère que nous voulons insérer est déjà présent, nous ne faisons rien et mettons fin à l'étape en cours, même si remainder> 0. La raison en est que les insertions restantes seront des suffixes de celui que nous venons d'essayer de créer. Par conséquent, ils sont tous implicites dans l'arbre en cours. Le fait que remainder> 0 garantit que nous traiterons les suffixes restants ultérieurement.

  • Et si à la fin de l'algorithme remainder> 0? Ce sera le cas chaque fois que la fin du texte est une sous-chaîne qui s'est produite quelque part auparavant. Dans ce cas, nous devons ajouter un caractère supplémentaire à la fin de la chaîne qui ne s’est pas produite auparavant. Dans la littérature, le signe dollar $ est généralement utilisé comme symbole. Pourquoi est-ce important? -> Si plus tard, nous utilisons l’arborescence des suffixes terminée pour rechercher des suffixes, nous ne devons accepter les correspondances que si elles se termine par une feuille . Sinon, nous aurions beaucoup de correspondances parasites, car il y a beaucoup de chaînes implicitement contenues dans l'arbre qui ne sont pas des suffixes réels de la chaîne principale. Forcer remainder à 0 à la fin est essentiellement un moyen de s’assurer que tous les suffixes se terminent par un nœud feuille. Cependant, si nous voulons utiliser l’arbre pour rechercher des sous-chaînes générales , pas seulement suffixes de la chaîne principale, cette dernière étape n'est en effet pas nécessaire, comme le suggère le commentaire de l'OP ci-dessous.

  • Alors, quelle est la complexité de l'algorithme entier? Si le texte est long de n caractères, il y a évidemment n étapes (ou n + 1 si on ajoute le signe dollar). A chaque étape, nous ne faisons rien (autre que la mise à jour des variables) ou nous insérons des insertions remainder, chacune prenant O(1) temps. Puisque remainder indique combien de fois nous n'avons rien fait aux étapes précédentes et est décrémenté pour chaque insertion que nous faisons maintenant, le nombre total de fois que nous faisons quelque chose est exactement n (ou n + 1). Par conséquent, la complexité totale est O (n).

  • Cependant, il y a une petite chose que je n'ai pas bien expliquée: il peut arriver que nous suivions un lien de suffixe, que nous mettions à jour le point actif et que nous trouvions ensuite que son composant active_length ne fonctionnait pas bien avec le nouveau active_node. Par exemple, considérons une situation comme celle-ci:

(Les lignes pointillées indiquent le reste de l'arbre. La ligne en pointillé est un lien de suffixe.)

Laissez maintenant le point actif être (red,'d',3), donc il pointe vers la place derrière le f sur le bord defg. Supposons maintenant que nous avons effectué les mises à jour nécessaires et suivons maintenant le lien de suffixe pour mettre à jour le point actif conformément à la règle 3. Le nouveau point actif est (green,'d',3). Cependant, d- Edge sortant du nœud vert est de, il ne contient donc que 2 caractères. Afin de trouver le point actif correct, nous devons évidemment suivre cet Edge jusqu'au nœud bleu et réinitialiser à (blue,'f',1).

Dans un cas particulièrement grave, le active_length pourrait être aussi grand que remainder, ce qui peut être aussi grand que n. Et il se peut très bien que pour trouver le bon point actif, nous n’avions pas besoin de sauter par-dessus un noeud interne, mais peut-être de nombreux, jusqu’à n dans le pire des cas. Est-ce que cela signifie que l'algorithme a un O caché (n2) complexité, car à chaque étape remainder est généralement O (n), et les ajustements postérieurs au nœud actif après le suivi d'un suffixe pourraient également être O (n)?

La raison en est que si nous devons effectivement ajuster le point actif (par exemple du vert au bleu comme ci-dessus), cela nous amène à un nouveau nœud qui possède son propre lien de suffixe, et active_length sera réduit. Au fur et à mesure que nous suivons la chaîne de liens de suffixe, nous faisons les insertions restantes, active_length ne peut que diminuer, et le nombre d'ajustements de points actifs que nous pouvons faire en cours de route ne peut pas être supérieur à active_length à un moment donné. Puisque active_length ne peut jamais être plus grand que remainder, et que remainder est O(n) non seulement à chaque étape, mais que la somme totale des incréments jamais faits à remainder au cours du processus est égale à O(n) aussi, le nombre d'ajustements de points actifs est également limité par O (n).

2295
jogojapan

J'ai essayé d'implémenter l'arbre de suffixe avec l'approche donnée dans la réponse de jogojapan, mais cela n'a pas fonctionné dans certains cas en raison de la formulation utilisée pour les règles. De plus, j'ai mentionné que personne n'avait réussi à implémenter une arborescence de suffixes absolument correcte en utilisant cette approche. Ci-dessous, j'écrirai un "aperçu" de la réponse de jogojapan avec quelques modifications aux règles. Je vais également décrire le cas où nous oublions de créer des liens importants.

Autres variables utilisées

  1. point actif - un triple (active_node; active_Edge; active_length), indiquant d'où nous devons commencer à insérer un nouveau suffixe.
  2. reste - indique le nombre de suffixes à ajouter explicitement. Par exemple, si notre mot est 'abcaabca', et reste = 3, cela signifie que nous devons traiter les 3 derniers suffixes: bca , ca et a .

Utilisons le concept d'un noeud interne - tous les noeuds, à l'exception du racine et du leafs = sont des noeuds internes .

Observation 1

Lorsque le suffixe final que nous devons insérer existe déjà dans l'arborescence, celle-ci n'est pas modifiée du tout (nous ne mettons à jour que le active point et remainder).

Observation 2

Si, à un moment donné, active_length est supérieur ou égal à la longueur du bord actuel (Edge_length), nous déplaçons notre active point jusqu'à ce que Edge_length soit strictement supérieur à active_length.

Maintenant, redéfinissons les règles:

Règle 1

Si, après une insertion à partir du nœud actif = racine, la longueur active est supérieure à 0, alors:

  1. nœud actif n'est pas modifié
  2. longueur active est décrémenté
  3. Edge actif est décalé à droite (au premier caractère du suffixe suivant, nous devons l'insérer)

Règle 2

Si nous créons un nouveau noeud interneOU faisons un inserteur à partir d'un noeud interne, et ce n'est pas le premier TELnœud interne à l'étape actuelle, nous lions le précédent TEL noeud avec CE un par un lien de suffixe =.

Cette définition du Rule 2 est différente de jogojapan ', dans la mesure où nous prenons en compte non seulement les nœuds internes nouvellement créés, mais également les nœuds internes à partir desquels nous effectuons une insertion.

Règle 3

Après une insertion du nœud actif qui n'est pas le nœud racine, nous devons suivre le lien de suffixe et attribuer le nœud actif au nœud le pointe vers. S'il n'y a pas de lien de suffixe, définissez le nœud actif sur le nœud racine. Dans les deux cas, bord actif et longueur active restent inchangés.

Dans cette définition de Rule 3, nous considérons également les insertions de noeuds feuille (pas seulement les noeuds scindés).

Et enfin, observation 3:

Lorsque le symbole que nous voulons ajouter à l'arborescence se trouve déjà sur Edge, nous, conformément à Observation 1, ne mettons à jour que active point et remainder, en laissant l'arbre inchangé. MAIS s'il existe un nœud interne marqué comme lien nécessitant un suffixe, nous devons nous connecter ce nœud avec notre active node actuel via un lien de suffixe.

Regardons l'exemple d'une arborescence de suffixes pour cdddcdc si nous ajoutons un lien de suffixe dans ce cas et si nous ne le faisons pas:

  1. Si nous NE FONT PAS connecter les nœuds via un lien de suffixe:

    • avant d'ajouter la dernière lettre c :

    • après avoir ajouté la dernière lettre c :

  2. Si nous FAISONS connectons les nœuds via un lien de suffixe:

    • avant d'ajouter la dernière lettre c :

    • après avoir ajouté la dernière lettre c :

On dirait qu'il n'y a pas de différence significative: dans le second cas, il existe deux liens de suffixe supplémentaires. Mais ces liens de suffixe sont correct, et l’un d’eux - du noeud bleu au noeud rouge - est très important pour notre approche avec point actif . Le problème est que si nous ne mettons pas un lien de suffixe ici, plus tard, lorsque nous ajouterons de nouvelles lettres à l’arborescence, nous pourrions omettre d’ajouter certains nœuds à l’arborescence en raison du Rule 3, car, selon ce qui précède, un lien de suffixe, alors nous devons mettre le active_node à la racine.

Lorsque nous avons ajouté la dernière lettre à l’arborescence, le nœud rouge existait déjà avant que nous ne fassions une insertion à partir du nœud bleu (le bord marqué 'c' ). Comme il y avait un insert du noeud bleu, nous le marquons comme nécessitant un lien de suffixe. Ensuite, en s'appuyant sur l'approche du point actif , le active node a été défini sur le nœud rouge. Mais nous ne faisons pas d'insertion à partir du noeud rouge, car la lettre 'c' est déjà sur le bord. Cela signifie-t-il que le nœud bleu doit rester sans lien de suffixe? Non, nous devons connecter le nœud bleu au nœud rouge via un lien de suffixe. Pourquoi est-ce correct? Parce que l’approche active du point garantit que nous arrivons au bon endroit, c’est-à-dire au prochain endroit où nous devons traiter un insert de suffixe plus court .

Enfin, voici mes implémentations de l’arbre de suffixe:

  1. Java
  2. C++

J'espère que cet "aperçu" combiné à la réponse détaillée de jogojapan aidera quelqu'un à mettre en œuvre son propre arbre suffixé.

126
makagonov

Merci pour le tutoriel bien expliqué par @ jogojapan , j’ai implémenté l’algorithme en Python.

Quelques problèmes mineurs mentionnés par @jogojapan se révèlent plus sophistiqués que je ne le pensais et doivent être traités avec beaucoup de précautions. Il m'a fallu plusieurs jours pour que mon implémentation soit suffisamment robuste (je suppose). Les problèmes et les solutions sont énumérés ci-dessous:

  1. Terminez avec Remainder > 0 Il se trouve que cette situation peut également se produire pendant l'étape de déroulement , pas seulement la fin de tout l'algorithme. Lorsque cela se produit, nous pouvons laisser le reste, actnode, actedge et actlength inchangés , mettre fin à l'étape de déploiement en cours et commencer une autre étape ou se dérouler selon que le prochain caractère de la chaîne d'origine se trouve ou non sur le chemin actuel.

  2. Sauter sur les nœuds: Lorsque nous suivons un lien de suffixe, mettons à jour le point actif, puis constatons que son composant active_length ne fonctionne pas bien avec le nouveau nœud actif. Nous devons avancer au bon endroit pour scinder ou insérer une feuille. Ce processus pourrait être pas si simple car pendant le déplacement, actlength et actedge continuaient à changer tout le chemin, il fallait alors revenir à la position nœud racine , le actedge et actlength pourrait être incorrect à cause de ces mouvements. Nous avons besoin de variable (s) supplémentaire (s) pour conserver cette information.

    enter image description here

Les deux autres problèmes ont en quelque sorte été signalés par @ managonov

  1. La scission peut dégénérer Lorsque vous tentez de scinder un Edge, vous constaterez parfois que l'opération de scission est effectuée sur un nœud. Dans ce cas, il suffit d'ajouter une nouvelle feuille à ce nœud. Prenez-le comme une opération standard de fractionnement Edge, ce qui signifie que les suffixes s'il y en a un doivent être conservés en conséquence.

  2. Liens de suffixe masqués Il existe un autre cas particulier provoqué par problème 1 et problème 2 . Parfois, nous avons besoin de sauter sur plusieurs nœuds au bon point pour le scinder, nous pourrions dépasser le bon point si nous nous déplaçons en comparant la chaîne restante et le chemin Étiquettes. Dans ce cas, le lien de suffixe sera négligé involontairement, s’il devrait y en avoir. Cela pourrait être évité en en se rappelant le bon point lors de la progression. Le lien de suffixe doit être conservé si le nœud fractionné existe déjà ou même si le problème _ (1) se produit au cours d'une étape de déploiement.

Enfin, mon implémentation dans Python est la suivante:

Astuces: Il comprend une arborescence naïve dans le code ci-dessus, ce qui est très important lors du débogage . Cela m'a fait gagner beaucoup de temps et est pratique pour localiser des cas particuliers.

9
mutux

@jogojapan vous avez apporté une explication et une visualisation impressionnantes. Mais comme @makagonov l'a mentionné, il manque certaines règles concernant l'établissement de liens de suffixe. Il est visible à Nice lorsque vous passez pas à pas http://brenden.github.io/ukkonen-animation/ à travers le mot 'aabaaabb'. Lorsque vous passez des étapes 10 à 11, il n'y a pas de lien de suffixe du nœud 5 au nœud 2, mais le point actif s'y déplace soudainement.

@makagonov Depuis que je vis dans le monde Java, j’ai également essayé de suivre votre implémentation pour comprendre le flux de travail de la construction de ST, mais c’était difficile pour moi à cause de:

  • combiner des arêtes avec des nœuds
  • en utilisant des pointeurs d'index au lieu de références
  • rompt les déclarations;
  • continuer les déclarations;

Je me suis donc retrouvé avec une telle implémentation dans Java qui, je l’espère, reflète toutes les étapes de manière plus claire et permettra de réduire le temps d’apprentissage pour les autres Java personnes:

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);
        }
    });
  }
}
7
Kamil

Toutes mes excuses si ma réponse semble redondante, mais j’ai récemment implémenté l’algorithme d’Ukkonen, et je me suis retrouvé aux prises avec ce problème pendant des jours; J'ai dû lire plusieurs articles sur le sujet pour comprendre le pourquoi et le comment de certains aspects essentiels de l'algorithme.

J'ai trouvé l'approche "règles" des réponses précédentes peu utile pour comprendre le sous-jacent raisons, j'ai donc tout écrit ci-dessous en mettant l'accent uniquement sur la pragmatique. Si, comme moi, vous avez eu du mal à suivre d’autres explications, mon explication supplémentaire vous permettra peut-être de cliquer.

J'ai publié mon implémentation C # ici: https://github.com/baratgabor/SuffixTree

Veuillez noter que je ne suis pas un expert sur ce sujet, les sections suivantes peuvent donc contenir des inexactitudes (ou pire). Si vous en rencontrez, n'hésitez pas à éditer.

Conditions préalables

Le point de départ de l'explication suivante suppose que vous connaissez le contenu et l'utilisation des arborescences de suffixes, ainsi que les caractéristiques de l'algorithme d'Ukkonen, par exemple. comment vous étendez l’arborescence du suffixe, caractère par caractère, du début à la fin. Fondamentalement, je suppose que vous avez déjà lu certaines des autres explications.

(Cependant, j'ai dû ajouter un récit de base pour le flux, de sorte que le début peut en effet se sentir redondant.)

La partie la plus intéressante est la explication sur la différence entre l’utilisation de liens de suffixe et la réanalyse à la racine. C'est ce qui m'a donné beaucoup de bugs et de maux de tête dans mon implémentation.

Noeuds feuilles ouverts et leurs limites

Je suis sûr que vous savez déjà que le "truc" le plus fondamental est de réaliser que nous pouvons simplement laisser la fin des suffixes "ouverte", c'est-à-dire référencer la longueur actuelle de la chaîne au lieu de définir la fin à une valeur statique. Ainsi, lorsque nous ajouterons des caractères supplémentaires, ceux-ci seront implicitement ajoutés à toutes les étiquettes de suffixe, sans avoir à les consulter et à les mettre à jour.

Mais cette fin ouverte de suffixes - pour des raisons évidentes - ne fonctionne que pour les nœuds qui représentent la fin de la chaîne, c'est-à-dire les nœuds d'extrémité de la structure arborescente. Les opérations de branchement que nous exécutons sur l’arborescence (l’ajout de nouveaux nœuds de branche et de feuille) ne se propagent pas automatiquement là où elles sont nécessaires.

C'est probablement élémentaire, et ne nécessiterait pas la mention, que les sous-chaînes répétées n'apparaissent pas explicitement dans l'arbre, car l'arbre les contient déjà parce qu'elles sont des répétitions; Cependant, lorsque la sous-chaîne répétitive finit par rencontrer un caractère non répétitif, nous devons créer un branchement à ce point pour représenter la divergence à partir de ce point.

Par exemple, dans le cas de la chaîne 'ABCXABCY' (voir ci-dessous), il faut ajouter une branche à X et Y à trois suffixes différents, ABC, BC et C; sinon, ce ne serait pas un arbre de suffixe valide, et nous ne pourrions pas trouver toutes les sous-chaînes de la chaîne en faisant correspondre les caractères de la racine vers le bas.

Une fois encore, pour souligner - any l'opération que nous exécutons sur un suffixe de l'arborescence doit également être reflétée par ses suffixes consécutifs (par exemple, ABC> BC> C), sinon ils cessent simplement d'être des suffixes valides. .

Repeating branching in suffixes

Mais même si nous acceptons de devoir effectuer ces mises à jour manuelles, comment savons-nous combien de suffixes doivent être mis à jour? Puisque nous ajoutons le caractère répété A (et le reste des caractères répétés successivement), nous ne savons pas encore quand/où avons-nous besoin de scinder le suffixe en deux branches. La nécessité de se séparer n'est constatée que lorsque nous rencontrons le premier caractère non répétitif, dans ce cas Y (au lieu du X qui existe déjà dans l'arbre ).

Ce que nous pouvons faire est de faire correspondre la chaîne répétée la plus longue possible et de compter le nombre de suffixes à mettre à jour ultérieurement. C'est ce que 'reste' représente.

Le concept de 'reste' et de 'rescanning'

La variable remainder nous indique le nombre de caractères répétés que nous avons ajoutés implicitement, sans créer de branche; c'est-à-dire combien de suffixes nous devons visiter pour répéter l'opération de création de branche une fois que nous avons trouvé le premier caractère auquel nous ne pouvons pas correspondre. Cela équivaut essentiellement au nombre de caractères "profonds" de notre racine dans l'arbre.

Donc, en restant à l’exemple précédent de la chaîne ABCXABCY, nous associons la partie répétée ABC 'implicitement', incrémentant remainder à chaque fois, qui résultats dans le reste de 3. Ensuite, nous rencontrons le caractère non répétitif 'Y'. Ici, nous avons divisé les ABCX précédemment ajoutés en ABC -> X et ABC -> Y. Ensuite, nous décrémentons remainder de 3 à 2, car nous nous sommes déjà occupés de la branche ABC. Maintenant, nous répétons l'opération en faisant correspondre les 2 derniers caractères - _ bc _] - depuis la racine pour atteindre le point où nous devons nous séparer, puis nous nous séparons BCX dans BC -> X et BC -> Y. De nouveau, nous décrémentons remainder sur 1 et répétons l'opération; jusqu’à ce que remainder soit égal à 0. Enfin, nous devons également ajouter le caractère actuel (Y) à la racine.

Cette opération, qui suit les suffixes consécutifs à la racine simplement pour atteindre le point où nous devons effectuer une opération, s'appelle ce que l'on appelle 'rescanning' dans l'algorithme d'Ukkonen, et il s'agit généralement de la partie la plus chère de l'algorithme. . Imaginez une chaîne plus longue dans laquelle vous devez "réanalyser" de longues sous-chaînes, sur plusieurs dizaines de nœuds (nous en reparlerons plus tard), potentiellement des milliers de fois.

En guise de solution, nous introduisons ce que nous appelons 'suffix links'.

Le concept de 'liens de suffixe'

Les liens de suffixe pointent essentiellement vers les positions que nous aurions normalement à 'rescan' vers, donc au lieu de l'opération coûteuse de réanalyse, nous pouvons simplement passer à la position liée, faire notre travail, passer à la suivante liée position et répétez - jusqu'à ce qu'il n'y ait plus de positions à mettre à jour.

Bien sûr, une grande question est de savoir comment ajouter ces liens. La réponse existante est que nous pouvons ajouter les liens lorsque nous insérons de nouveaux nœuds de branche, en utilisant le fait que, dans chaque extension de l'arborescence, les nœuds de branche sont naturellement créés les uns après les autres dans l'ordre exact où nous aurions besoin de les lier ensemble. . Bien que nous devions relier le dernier nœud de branche créé (le suffixe le plus long) au nœud précédemment créé, nous devons donc mettre en cache le dernier que nous avons créé, le lier au prochain nœud que nous créons et mettre en cache celui qui vient d'être créé.

Une conséquence est que nous n'avons souvent pas de liens de suffixe à suivre, car le nœud de branche donné vient d'être créé. Dans ces cas, nous devons toujours revenir à la racine mentionnée ci-dessus 'rescanning'. C'est pourquoi, après une insertion, vous êtes invité à utiliser le lien de suffixe ou à accéder à la racine.

(Ou bien, si vous stockez des pointeurs parents dans les nœuds, vous pouvez essayer de suivre les parents, vérifier s'ils ont un lien et l'utiliser. J'ai trouvé que cela est très rarement mentionné, mais le suffixe L’utilisation des liens n’est pas figée. Il existe de nombreuses approches possibles et, si vous comprenez le mécanisme sous-jacent, vous pouvez en implémenter celle qui répond le mieux à vos besoins.

Le concept de "point actif"

Jusqu'ici, nous avons discuté de plusieurs outils efficaces pour la construction de l'arbre et nous avons vaguement évoqué le fait de traverser plusieurs arêtes et nœuds, mais nous n'avons pas encore exploré les conséquences et les complexités correspondantes.

Le concept précédemment expliqué de 'reste' est utile pour garder une trace de notre position dans l'arbre, mais nous devons réaliser qu'il ne stocke pas assez d'informations.

Tout d'abord, nous résidons toujours sur un Edge spécifique d'un nœud, nous devons donc stocker les informations Edge. Nous appellerons cela 'bord actif'.

Deuxièmement, même après l’ajout des informations Edge, nous n’avons toujours aucun moyen d’identifier une position plus éloignée dans l’arborescence, et non directement connectée au noeud root. Nous devons donc stocker le noeud également. Appelons cela 'nœud actif'.

Enfin, nous pouvons noter que le 'reste' ne permet pas d'identifier une position sur un bord qui n'est pas directement connecté à la racine, car 'reste' est la longueur du itinéraire complet; et nous ne voulons probablement pas nous soucier de nous rappeler et de soustraire la longueur des arêtes précédentes. Nous avons donc besoin d’une représentation qui soit essentiellement le reste de l’actuel Edge. C'est ce que nous appelons 'longueur active'.

Cela conduit à ce que nous appelons 'point actif' - un paquet de trois variables contenant toutes les informations que nous devons conserver sur notre position dans l'arbre:

Active Point = (Active Node, Active Edge, Active Length)

Vous pouvez observer sur l'image suivante comment la route correspondante de ABCABD se compose de 2 caractères sur le bord AB (de racine) , plus 4 caractères sur le bord CABDABCABD (du noeud 4) - résultant en un 'reste' de 6 caractères. Donc, notre position actuelle peut être identifiée comme Actif Node 4, Actif Edge C, Actif Longueur 4.

Remainder and Active Point

Un autre rôle important du 'point actif' est de fournir une couche d'abstraction pour notre algorithme, ce qui signifie que certaines parties de notre algorithme peuvent effectuer leur travail sur le 'point actif' , que ce point actif soit à la racine ou ailleurs. Cela facilite l'utilisation de liens de suffixe dans notre algorithme de manière claire et directe.

Différences entre réanalyse et utilisation de liens de suffixe

Maintenant, la partie délicate, et selon mon expérience, peut causer beaucoup de bogues et de maux de tête, et est mal expliquée dans la plupart des sources, est la différence entre le traitement des cas de lien suffixe et des cas de nouvelle analyse.

Prenons l'exemple suivant de la chaîne 'AAAABAAAABAAC':

Remainder across multiple edges

Vous pouvez observer ci-dessus comment le 'reste' sur 7 correspond à la somme totale des caractères de la racine, tandis que 'longueur active' sur 4 correspond à la somme des caractères correspondants de le bord actif du noeud actif.

Maintenant, après avoir exécuté une opération de branchement au point actif, notre nœud actif peut contenir ou non un lien de suffixe.

Si un lien de suffixe est présent: Il suffit de traiter la partie 'longueur active'. Le 'reste' n'a aucune importance, car le nœud auquel nous accédons via le lien de suffixe code déjà le bon 'reste' implicitement, simplement en raison de son emplacement dans l'arbre où c'est.

Si un lien de suffixe n'est PAS présent: Nous devons 'rescan' à partir de zéro/racine, ce qui signifie que le suffixe complet est traité. Depuis le début. À cette fin, nous devons utiliser l'ensemble 'reste' comme base de la nouvelle analyse.

Exemple de comparaison de traitement avec et sans lien de suffixe

Considérez ce qui se passe à la prochaine étape de l'exemple ci-dessus. Comparons comment atteindre le même résultat - c’est-à-dire passer au prochain suffixe à traiter - avec et sans lien de suffixe.

Utilisation de 'suffix link'

Reaching consecutive suffixes via suffix links

Notez que si nous utilisons un lien de suffixe, nous sommes automatiquement "au bon endroit". Ce qui est souvent faux, car 'durée active' peut être 'incompatible' avec le nouveau poste.

Dans le cas ci-dessus, puisque 'longueur active' est 4, nous travaillons avec le suffixe 'ABAA', en commençant par le lien Node 4. Mais après avoir trouvé le bord qui correspond au premier caractère du suffixe ('A'), nous remarquons que notre 'longueur active' dépasse ce bord de 3 caractères. . Donc, nous sautons sur tout le bord, jusqu'au prochain nœud, et décrémentons 'longueur active' par les caractères que nous avons consommés avec le saut.

Ensuite, après avoir trouvé le prochain bord 'B', correspondant au suffixe décrémenté 'BAA', nous remarquons enfin que la longueur du bord est supérieure à celle du reste 'longueur active' sur 3, ce qui signifie que nous avons trouvé le bon endroit.

Veuillez noter qu'il semble que cette opération ne soit généralement pas appelée "analyse de numérisation", même si selon moi, il s'agit apparemment de l'équivalent direct de l'analyse de numérisation, avec seulement une longueur raccourcie et un point de départ non racine.

Utilisation de 'rescan'

Reaching consecutive suffixes via rescanning

Notez que si nous utilisons une opération traditionnelle de "resanalyse" (ici, en prétendant que nous n’avions pas de lien de suffixe), nous commençons au sommet de l’arbre, à la racine, et nous devons redescendre au bon endroit, suivant sur toute la longueur du suffixe actuel.

La longueur de ce suffixe est la 'reste' dont nous avons discuté auparavant. Nous devons consommer la totalité de ce reste jusqu'à ce qu'il atteigne zéro. Cela peut (et inclut souvent) inclure de sauter par plusieurs nœuds, à chaque saut, diminuant le reste de la longueur de l’Edge que nous avons sauté. Enfin, nous atteignons un bord plus long que notre reste 'reste'; Ici, nous définissons le bord actif sur le bord donné, définissons 'longueur active' sur le reste 'reste' ', et nous avons terminé.

Notez cependant que la variable 'reste' doit être préservée et décrémentée uniquement après chaque insertion de nœud. Donc, ce que j'ai décrit ci-dessus supposait l'utilisation d'une variable distincte initialisée à 'reste'.

Notes sur les liens de suffixe et les analyses à nouveau

1) Notez que les deux méthodes conduisent au même résultat. Le lien suffixe est toutefois nettement plus rapide dans la plupart des cas; c'est la raison d'être des liens de suffixe.

2) Les implémentations algorithmiques réelles n'ont pas besoin de différer. Comme je l'ai mentionné ci-dessus, même dans le cas de l'utilisation du lien de suffixe, le 'longueur active' est souvent incompatible avec la position liée, car cette branche de l'arbre peut contenir des branches supplémentaires. Vous devez donc essentiellement utiliser 'longueur active' au lieu de 'reste', et exécuter la même logique de réanalyse jusqu'à ce que vous trouviez un bord plus court que la longueur de suffixe restante. .

3) Une remarque importante concernant les performances est qu'il n'est pas nécessaire de vérifier chaque caractère lors de la nouvelle analyse. En raison de la manière dont une arborescence de suffixes valide est construite, nous pouvons supposer en toute sécurité que les caractères correspondent. Donc, vous comptez principalement les longueurs, et le contrôle de l’équivalence des caractères s’impose uniquement lorsque nous sautons sur un nouveau bord, car les bords sont identifiés par leur premier caractère (qui est toujours unique dans le contexte d’un nœud donné). Cela signifie que la logique de "réanalyse" est différente de la logique de correspondance de chaîne complète (c'est-à-dire la recherche d'une sous-chaîne dans l'arborescence).

4) Le lien de suffixe original décrit ici est juste ne des approches possibles. Par exemple, NJ Larsson et al. nomme cette approche comme Bas-orienté vers le noeud et la compare à Bas-haut orienté vers le noeud et deux à bords variétés. Les différentes approches ont des performances, des exigences, des limitations, etc. typiques et dans le cas le plus défavorable, mais il semble généralement que les approches Edge-Oriented sont une amélioration globale de l'original.

7
MrValueType

Mon intuition est la suivante:

Après k itérations de la boucle principale, vous avez créé un arbre de suffixes contenant tous les suffixes de la chaîne complète commençant par les k premiers caractères.

Au début, cela signifie que l’arborescence des suffixes contient un seul nœud racine qui représente la chaîne entière (il s’agit du seul suffixe commençant à 0).

Après les itérations de len (chaîne), vous avez un arbre de suffixes qui contient tous les suffixes.

Pendant la boucle, la clé est le point actif. Je suppose que cela représente le point le plus profond de l’arborescence des suffixes qui correspond à un suffixe approprié des k premiers caractères de la chaîne. (Je pense que cela signifie que le suffixe ne peut pas être la chaîne entière.)

Par exemple, supposons que vous ayez vu des personnages "abcabc". Le point actif représenterait le point dans l'arborescence correspondant au suffixe 'abc'.

Le point actif est représenté par (origine, premier, dernier). Cela signifie que vous êtes actuellement au point de l’arbre auquel vous parlez en commençant au nœud Origine, puis en introduisant les caractères de la chaîne [first: last]

Lorsque vous ajoutez un nouveau caractère, vous regardez si le point actif est toujours dans l'arborescence existante. Si c'est le cas, vous avez terminé. Sinon, vous devez ajouter un nouveau noeud à l'arborescence de suffixe au point actif, revenir à la correspondance la plus courte suivante et vérifier à nouveau.

Remarque 1: Les pointeurs de suffixe donnent un lien vers la correspondance la plus courte suivante pour chaque nœud.

Remarque 2: lorsque vous ajoutez un nouveau nœud et que vous effectuez un repli, vous ajoutez un nouveau pointeur de suffixe pour le nouveau nœud. La destination de ce pointeur de suffixe sera le nœud au point actif raccourci. Ce nœud existera déjà ou sera créé à la prochaine itération de cette boucle de secours.

Note 3: La partie canonisation permet simplement de gagner du temps en vérifiant le point actif. Par exemple, supposons que vous ayez toujours utilisé Origin = 0 et que vous venez de changer en premier et en dernier. Pour vérifier le point actif, vous devez suivre l’arborescence des suffixes à chaque fois sur tous les nœuds intermédiaires. Il est logique de mettre en cache le résultat du suivi de ce chemin en enregistrant uniquement la distance depuis le dernier nœud.

Pouvez-vous donner un exemple de code de ce que vous entendez par "réparer" les variables englobantes?

Avertissement de santé: j'ai aussi trouvé cet algorithme particulièrement difficile à comprendre, alors sachez que cette intuition est susceptible d'être incorrecte dans tous les détails importants ...

6
Peter de Rivaz

Bonjour, j'ai essayé d'implémenter l'implémentation expliquée ci-dessus dans Ruby, veuillez le vérifier. Cela semble marcher correctement.

la seule différence dans l'implémentation est que, j'ai essayé d'utiliser l'objet Edge au lieu d'utiliser simplement des symboles.

il est également présent sur 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
3
Suchit Puri