Ce qui suit est une tentative de 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.
Ce que nous construisons, c'est essentiellement comme un trieur de recherche. Il y a donc un nœud racine, des bords en sortant menant à de nouveaux nœuds, et d'autres bords en sortant, etc.
Mais : contrairement à un tri de recherche, les étiquettes de bord ne sont pas des caractères uniques. Au lieu de cela, chaque bord est étiqueté à l'aide d'une paire d'entiers
[from,to]
. Ce sont des pointeurs dans le texte. Dans ce sens, chaque bord porte une étiquette de chaîne de longueur arbitraire, mais ne prend que l'espace O (1) (deux pointeurs).
Principe de base
Je voudrais d'abord montrer comment créer l'arborescence des 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 commençons par la gauche et insérons d'abord uniquement le caractère unique
a
en créant un bord du nœud racine (à gauche) vers une feuille, et en l'étiquetant comme [0,#]
, ce qui signifie que le bord représente la sous-chaîne commençant à la position 0 et se terminant à la fin actuelle . J'utilise le symbole #
pour désigner la fin actuelle , qui est à la position 1 (juste après a
).
Nous avons donc un arbre initial, qui ressemble à ceci:
Et cela signifie 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 le faisons en
- étendre la
a
portée existante àab
- insérer un nouveau bord pour
b
Dans notre représentation, cela ressemble à
Et cela signifie:
Nous observons deux choses:
- La représentation de bord pour
ab
est le même que l'habitude d'être dans l'arbre initial: [0,#]
. Sa signification a changé automatiquement car nous avons mis à jour la position actuelle #
de 1 à 2.
- Chaque bord consomme de l'espace O (1), car il ne contient 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 bord existant et en insérant un nouveau bord pour le nouveau suffixe c
.
Dans notre représentation, cela ressemble à
Et cela signifie:
On observe:
- L'arbre est l'arbre de suffixe correct jusqu'à la position actuelle
après chaque étape
- Il y a autant d'étapes que de caractères dans le texte
- La quantité de travail dans chaque étape est O (1), car tous les bords existants sont mis à jour automatiquement par incrémentation
#
, et l'insertion du seul nouveau bord pour le caractère final peut être effectuée en 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 ne fonctionne si bien que parce que notre chaîne ne contient aucune répétition. Nous regardons 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
répété suivi de d
.
Étapes 1 à 3: Après les 3 premières étapes, nous avons l'arborescence de l'exemple précédent:
Étape 4: Nous passons #
à la position 4. Cela met implicitement à jour tous les bords existants en ceci:
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 bien sûr ont été là tout le temps mais nous ne les avons pas utilisées jusqu'à présent:
- Le point actif , qui est un triple
(active_node,active_edge,active_length)
- Le
remainder
, qui est un entier indiquant combien de nouveaux suffixes nous devons insérer
La signification exacte de ces deux éléments deviendra claire bientôt, mais pour l'instant disons simplement:
- Dans l'
abc
exemple simple , le point actif était toujours
(root,'\0x',0)
, c.-à active_node
-d. Était le nœud racine, active_edge
était spécifié comme le caractère nul '\0x'
et active_length
était zéro. L'effet de ceci était que le seul nouveau bord que nous avons inséré à chaque étape a été inséré au nœud racine en tant que bord fraîchement créé. Nous verrons bientôt pourquoi un triple est nécessaire pour représenter cette information.
- Le
remainder
était toujours réglé 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 juste le caractère final).
Maintenant, cela va changer. Lorsque l' on insère la finale du caractère actuellement a
à la racine, on constate qu'il existe déjà un bord sortant en commençant par a
, en particulier: abca
. Voici ce que nous faisons dans un tel cas:
- Nous n'insérons pas un nouveau bord
[4,#]
au nœud 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 bord plus long, mais cela ne nous dérange pas. Nous laissons les choses telles qu'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 a
, spécifiquement, après la position 1 sur ce bord. On remarque que l'arête est spécifiée 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 c'est vrai après avoir lu toute la description).
- Nous incrémentons également
remainder
, donc 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'arbre , l'arbre lui-même n'est pas modifié du tout (nous ne mettons à jour que le point actif et remainder
). L'arbre n'est alors plus une représentation précise de l'arbre des suffixes jusqu'à la position actuelle , mais il contient tous les suffixes (car le suffixe final a
est contenu implicitement ). Par conséquent, à part la mise à jour des variables (qui sont toutes de longueur fixe, il s'agit donc de O (1)), aucun travail n'a été
effectué à cette étape.
Étape 5: Nous mettons à jour la position actuelle #
à 5. Cela met automatiquement à jour l'arborescence à ceci:
Et parce que remainder
vaut 2 , nous devons insérer deux suffixes finaux de la position actuelle: ab
et b
. C'est essentiellement parce que:
- Le
a
suffixe de l'étape précédente n'a jamais été correctement inséré. Il est donc resté , et depuis que nous avons progressé d'une étape, il est maintenant passé de a
à ab
.
- Et nous devons insérer le nouveau bord final
b
.
En pratique, cela signifie que nous allons au point actif (qui pointe derrière derrière a
sur ce qui est maintenant le abcab
bord) et insérons le caractère final actuel b
. Mais: Encore une fois, il s'avère qu'il b
est également déjà présent sur ce même bord.
Donc, encore une fois, nous ne changeons pas l'arbre. Nous simplement:
- Mettre à jour le point actif sur
(root,'a',2)
(même nœud et bord que précédemment, mais maintenant nous pointons derrière b
)
- Incrémentez le
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 actuel.
Pour être clair: nous avons dû insérer ab
et b
dans l'étape en cours, mais parce qu'il ab
était déjà trouvé, nous avons mis à jour le point actif et n'avons même pas tenté d'insérer b
. Pourquoi? Parce que si se ab
trouve dans l'arborescence,
chaque suffixe (y compris b
) doit également être dans l'arborescence. Peut-être seulement implicitement , mais il doit être là, en raison de la façon dont nous avons construit l'arbre jusqu'à présent.
Nous passons à l' étape 6 par incrémentation #
. L'arbre est automatiquement mis à jour pour:
Parce que remainder
c'est 3 , nous devons insérer abx
, bx
et
x
. Le point actif nous indique où ab
se termine, nous n'avons donc qu'à y sauter et insérer le x
. En effet, x
n'est pas encore là, donc on scinde le abcabx
bord et on insère un noeud interne:
Les représentations de bord sont toujours des pointeurs dans le texte, donc la division et l'insertion d'un nœud interne peuvent être effectuées en temps O (1).
Nous avons donc traité abx
et décrément remainder
à 2. Maintenant , nous avons besoin d'insérer le prochain suffixe restant bx
. Mais avant de faire cela, nous devons mettre à jour le point actif. La règle pour cela, après avoir divisé et inséré une arête, sera appelée la règle 1 ci-dessous, et elle s'applique chaque fois que la
active_node
racine est (nous apprendrons la règle 3 pour les autres cas plus loin). Voici la règle 1:
Après une insertion depuis la racine,
active_node
reste racine
active_edge
est réglé sur le premier caractère du nouveau suffixe que nous devons insérer, c.-à-d. b
active_length
est réduit de 1
Par conséquent, le nouveau triple de point actif (root,'b',1)
indique que l'insertion suivante doit être faite au bcabx
bord, derrière 1 caractère, c'est-à-dire derrière b
. Nous pouvons identifier le point d'insertion en temps O (1) et vérifier s'il x
est déjà présent ou non. S'il était présent, nous terminerions l'étape en cours et laisserions tout comme ça. Mais x
n'est pas présent, nous l'insérons donc en divisant le bord:
Encore une fois, cela a pris du temps O (1) et nous mettons à jour remainder
à 1 et le point actif à (root,'x',0)
comme règle 1 déclare.
Mais il y a encore une chose que nous devons faire. Nous appellerons cette règle 2:
Si nous divisons un bord et insérons un nouveau nœud, et si ce n'est pas 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 un pointeur spécial, un lien suffixe . Nous verrons plus tard pourquoi cela est utile. Voici ce que nous obtenons, le lien de suffixe est représenté comme un bord en pointillé:
Nous avons quand même besoin d'insérer le suffixe final de l'étape en cours,
x
. Étant donné que le active_length
composant du nœud actif est tombé à 0, l'insertion finale est effectuée directement à la racine. Puisqu'il n'y a pas de bord sortant au nœud racine commençant par x
, nous insérons un nouveau bord:
Comme nous pouvons le voir, dans l'étape actuelle, tous les inserts restants ont été réalisés.
Nous passons à l' étape 7 en définissant #
= 7, qui ajoute automatiquement le caractère suivant
a
, à tous les bords des feuilles, comme toujours. Ensuite, nous essayons d'insérer le nouveau caractère final au point actif (la racine), et constatons qu'il est déjà là. Nous terminons donc l'étape en cours sans rien insérer et mettons à jour le point actif (root,'a',1)
.
À l' étape 8 , #
= 8, nous ajoutons b
, et comme vu précédemment, cela signifie uniquement que nous mettons à jour le point actif (root,'a',2)
et l'incrémentons remainder
sans rien faire d'autre, car il b
est déjà présent. Cependant, nous remarquons (en temps O (1)) que le point actif est maintenant à la fin d'une arête. Nous reflétons cela en le réinitialisant
(node1,'\0x',0)
. Ici, j'utilise node1
pour faire référence au nœud interne où ab
se termine le bord.
Ensuite, à l' étape #
= 9 , nous devons insérer «c» et cela nous aidera à comprendre l'astuce finale:
Deuxième extension: utilisation de liens de suffixe
Comme toujours, la #
mise à jour s'ajoute c
automatiquement aux bords des feuilles et nous allons au point actif pour voir si nous pouvons insérer «c». Il s'avère que «c» existe déjà à ce bord, nous avons donc défini le point actif sur
(node1,'c',1)
, incrémenter remainder
et ne rien faire d'autre.
Maintenant à l' étape #
= 10 , remainder
c'est 4, et donc nous devons d'abord insérer
abcd
(qui reste d'il y a 3 étapes) en insérant d
au point actif.
La tentative d'insertion d
au point actif provoque une division des bords en temps O (1):
Le active_node
, à partir duquel la scission a été initiée, est marqué en rouge ci-dessus. Voici la règle finale, la règle 3:
Après avoir séparé un bord d'un active_node
nœud 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 active_node
le nœud vers lequel il pointe. S'il n'y a pas de lien de suffixe, nous mettons 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:
Depuis l'insertion abcd
est complète, nous décrémentons remainder
à 3 et considérons le prochain suffixe restant de l'étape en cours,
bcd
. La règle 3 a défini le point actif sur juste le nœud et le bord droit afin que l'insertion bcd
puisse être effectuée en insérant simplement son caractère final
d
au point actif.
Cela provoque une autre division des bords et, en raison de la règle 2 , nous devons créer un lien de suffixe du nœud précédemment inséré vers le nouveau:
Nous observons: Les liens de suffixe nous permettent de réinitialiser le point actif afin que nous puissions faire l' insertion restante suivante à l'effort O (1). Regardez le graphique ci-dessus pour confirmer qu'effectivement le nœud à l'étiquette ab
est lié au nœud à b
(son suffixe), et le nœud à 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 à nouveau le point actif. Puisque le courant active_node
(rouge ci-dessus) n'a pas de lien de suffixe, nous réinitialisons à la racine. Le point actif est maintenant (root,'c',1)
.
D' où l'insert suivant se produit à un bord de sortie du noeud racine dont le label commence par c
: cabxabcd
, derrière le premier caractère, soit derrière c
. Cela provoque une autre scission:
Et comme 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 amené le point à réorganiser les bords existants, alors vérifiez soigneusement pour confirmer que la seule chose qui a été insérée ci-dessus est un nouveau lien de suffixe.)
Avec cela, remainder
peut être défini sur 1 et puisque la active_node
racine est, nous utilisons la règle 1 pour mettre à jour le point actif vers (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 cependant un certain nombre d' observations finales :
À chaque étape, nous avançons #
d'une position. Cela met automatiquement à jour tous les nœuds terminaux en temps O (1).
Mais il ne traite pas a) des suffixes restants des étapes précédentes, et b) du dernier caractère de l'étape en cours.
remainder
nous indique combien d'insertions 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 réalisons l'insert. Important: chaque insertion est effectuée en 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 est).
Après chaque insertion, nous décrémentons remainder
et suivons le lien de suffixe s'il y en a. Sinon, nous allons à la racine (règle 3). Si nous sommes déjà à la racine, nous modifions le point actif en utilisant la règle 1. Dans tous les cas, cela ne prend que O (1).
Si, lors d'une de ces insertions, nous constatons que le caractère que nous voulons insérer est déjà là, nous ne faisons rien et terminons 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 faire. Par conséquent, ils sont tous implicites dans l'arbre actuel. Le fait que remainder
> 0 assure que nous traiterons les suffixes restants plus tard.
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 pour cela. Pourquoi est-ce important? -> Si plus tard nous utilisons l'arborescence des suffixes complétée pour rechercher les suffixes, nous ne devons accepter les correspondances que si elles se terminent par une feuille . Sinon, nous obtiendrions beaucoup de correspondances parasites, car il y a de nombreuses chaînes implicitement contenues dans l'arborescence qui ne sont pas des suffixes réels de la chaîne principale. Forcerremainder
être 0 à la fin est essentiellement un moyen de s'assurer que tous les suffixes se terminent à un nœud feuille. Cependant, si nous voulons utiliser l'arborescence pour rechercher des sous - chaînes générales , non seulement des suffixes de la chaîne principale, cette dernière étape n'est en effet pas requise, comme le suggère le commentaire de l'OP ci-dessous.
Quelle est donc la complexité de l'ensemble de l'algorithme? Si le texte est de n caractères, il y a évidemment n étapes (ou n + 1 si l'on ajoute le signe dollar). À chaque étape, nous ne faisons rien (autre que la mise à jour des variables), ou nous faisons des remainder
insertions, chacune prenant O (1) fois. Depuis remainder
indique combien de fois nous n'avons rien fait dans les étapes précédentes, et est décrémenté pour chaque insertion que nous faisons maintenant, le nombre total de fois où 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 correctement expliquée: il peut arriver que nous suivions un lien de suffixe, mettons à jour le point actif, puis constations que son active_length
composant ne fonctionne 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 pointillée est un lien suffixe.)
Maintenant, laissez le point actif être (red,'d',3)
, de sorte qu'il pointe vers l'endroit derrière f
le defg
bord. Supposons maintenant que nous avons effectué les mises à jour nécessaires et suivez maintenant le lien du suffixe pour mettre à jour le point actif conformément à la règle 3. Le nouveau point actif est (green,'d',3)
. Cependant, la d
bordure sortant du nœud vert l'est de
, donc elle n'a que 2 caractères. Afin de trouver le point actif correct, nous devons évidemment suivre ce bord jusqu'au nœud bleu et réinitialiser (blue,'f',1)
.
Dans un cas particulièrement mauvais, le active_length
pourrait être aussi grand que
remainder
, ce qui peut être aussi grand que n. Et il pourrait très bien arriver que pour trouver le point actif correct, nous devons non seulement sauter par-dessus un nœud interne, mais peut-être plusieurs, jusqu'à n dans le pire des cas. Cela signifie-t-il que l'algorithme a une complexité O (n 2 ) cachée , car à chaque étape remainder
est généralement O (n), et les post-ajustements du nœud actif après avoir suivi un lien de suffixe pourraient également être O (n)?
Non. La raison est que si en effet nous devons ajuster le point actif (par exemple du vert au bleu comme ci-dessus), cela nous amène à un nouveau nœud qui a 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 pouvons que diminuer, et le nombre d'ajustements de points actifs que nous pouvons faire en cours de route ne peut pas être plus important qu'à active_length
un moment donné. Puisque
active_length
ne peut jamais être plus grande que remainder
, et remainder
est O (n) non seulement dans chaque étape, mais la somme totale des incréments jamais fait à remainder
au cours de l'ensemble du processus est O (n) aussi, le nombre d'ajustements de points actifs est également délimité par O (n).