Im Folgenden wird versucht, den Ukkonen-Algorithmus zu beschreiben, indem zunächst gezeigt wird, was er tut, wenn die Zeichenkette einfach ist (d. h. keine wiederholten Zeichen enthält), und dann auf den vollständigen Algorithmus ausgedehnt wird.
Zunächst ein paar Vorbemerkungen.
-
Was wir bauen, ist Grundsätzlich wie ein Suchauftrag. Es gibt also gibt es einen Wurzelknoten, von dem Kanten ausgehen, die zu neuen Knoten führen, und weitere Kanten, die von diesen ausgehen, und so weiter
-
Aber : Anders als in einem Such-Trie sind die Kantenbeschriftungen nicht ein Zeichen. Stattdessen wird jede Kante mit einem Paar ganzer Zahlen beschriftet [from,to]
. Dies sind Verweise auf den Text. In diesem Sinne ist jede Kante eine Zeichenfolge beliebiger Länge, benötigt aber nur O(1) Platz (zwei Zeiger).
Grundprinzip
Ich möchte zunächst zeigen, wie man den Suffixbaum einer besonders einfachen Zeichenkette, einer Zeichenkette ohne wiederholte Zeichen:
abc
Der Algorithmus arbeitet schrittweise, von links nach rechts . Es gibt ein Schritt für jedes Zeichen der Zeichenkette . Jeder Schritt kann mehr als eine einzelne Operation beinhalten, aber wir werden sehen (siehe die abschließenden Beobachtungen am Ende), dass die Gesamtzahl der Operationen O(n) ist.
Wir gehen also von der links und fügen Sie zunächst nur das einzelne Zeichen a
indem Sie eine Kante vom Wurzelknoten (links) zu einem Blatt erstellen, und beschriften sie als [0,#]
, d.h. die Kante repräsentiert die Teilstring, der an Position 0 beginnt und an das aktuelle Ende . I verwende das Symbol #
zu bedeuten das aktuelle Ende die sich an Position 1 befindet (direkt nach a
).
Wir haben also einen ersten Baum, der wie folgt aussieht:
Und das bedeutet Folgendes:
Jetzt gehen wir zu Position 2 (direkt nach b
). Unser Ziel bei jedem Schritt ist einzufügen alle Suffixe bis zur aktuellen Position . Wir tun dies von
- die Erweiterung der bestehenden
a
-Rand zu ab
- Einfügen einer neuen Kante für
b
In unserer Darstellung sieht das wie folgt aus
Und das bedeutet Folgendes:
Wir beobachten zwei Dinge:
- Die Kantendarstellung für
ab
ist derselbe wie es früher einmal war im ursprünglichen Baum: [0,#]
. Seine Bedeutung hat sich automatisch geändert weil wir die aktuelle Position aktualisiert haben #
von 1 bis 2.
- Jede Kante verbraucht O(1) Platz, da sie nur aus zwei Teilen besteht Zeigern in den Text besteht, unabhängig davon, wie viele Zeichen sie repräsentiert.
Als Nächstes erhöhen wir die Position erneut und aktualisieren den Baum durch Anhängen von a c
zu jeder bestehenden Kante und Einfügen einer neuen Kante für die neue Suffix c
.
In unserer Darstellung sieht das wie folgt aus
Und das bedeutet Folgendes:
Wir beobachten:
- Der Baum ist der richtige Suffixbaum bis zur aktuellen Position nach jedem Schritt
- Es gibt so viele Schritte, wie es Zeichen im Text gibt
- Der Arbeitsaufwand ist in jedem Schritt O(1), da alle vorhandenen Kanten automatisch durch Inkrementierung aktualisiert werden
#
und das Einfügen der eine neue Kante für das letzte Zeichen kann in O(1) Zeit erledigt werden. Für eine Zeichenkette der Länge n wird also nur O(n) Zeit benötigt.
Erste Verlängerung: Einfache Wiederholungen
Das funktioniert natürlich nur deshalb so gut, weil unser String keine keine Wiederholungen enthält. Sehen wir uns nun eine realistischere Zeichenfolge an:
abcabxabcd
Es beginnt mit abc
wie im vorherigen Beispiel, dann ab
wiederholt wird und gefolgt von x
und dann abc
wiederholt wird, gefolgt von d
.
Schritte 1 bis 3: Nach den ersten 3 Schritten haben wir den Baum aus dem vorherigen Beispiel:
Schritt 4: Wir bewegen uns #
auf Platz 4. Dies aktualisiert implizit alle bestehenden Kanten auf diese Position:
und wir müssen das letzte Suffix des aktuellen Schrittes einfügen, a
, bei der Wurzel.
Bevor wir dies tun, führen wir ein zwei weitere Variablen (zusätzlich zu #
), die natürlich schon immer da waren, die wir aber bisher nicht genutzt haben sie bisher nicht genutzt:
- En aktiver Punkt was ein Dreifaches ist
(active_node,active_edge,active_length)
- En
remainder
ist eine ganze Zahl, die angibt, wie viele neue Suffixe wir einfügen müssen
Die genaue Bedeutung dieser beiden Begriffe wird in Kürze klar werden, aber zunächst einmal lassen Sie uns einfach sagen:
- In der einfachen
abc
Beispiel war der aktive Punkt immer (root,'\0x',0)
d.h. active_node
war der Wurzelknoten, active_edge
wurde als Nullzeichen angegeben '\0x'
y active_length
war Null. Dies hatte zur Folge, dass die eine neue Kante, die die wir in jedem Schritt eingefügt haben, am Wurzelknoten als eine frisch erstellte Kante. Wir werden gleich sehen, warum ein Tripel notwendig ist, um diese Information darzustellen.
- En
remainder
immer auf 1 gesetzt wurde, und zwar zu Beginn eines jeden Schrittes. Dies bedeutete, dass die Anzahl der Suffixe, die wir am Ende jeder am Ende eines jeden Schritts aktiv einfügen mussten, 1 war (immer nur das letzte Zeichen).
Das wird sich jetzt ändern. Wenn wir das aktuelle Endzeichen einfügen Zeichen a
an der Wurzel, stellen wir fest, dass es bereits eine ausgehende Kante gibt, die mit a
konkret: abca
. Das tun wir in einem solchen Fall:
- Wir nicht eine neue Kante einfügen
[4,#]
am Wurzelknoten. Stattdessen wird einfach feststellen, dass das Suffix a
ist bereits in unserem Baum. Er endet in der Mitte einer längeren Kante, aber wir sind nicht stört uns das nicht. Wir lassen die Dinge einfach so, wie sie sind.
- Wir den aktiven Punkt einstellen a
(root,'a',1)
. Das bedeutet, dass der aktive Punkt liegt jetzt irgendwo in der Mitte der ausgehenden Kante des Wurzelknotens, die mit a
und zwar nach der Position 1 auf dieser Kante. Wir bemerken, dass die Kante einfach durch ihr erstes Zeichen angegeben wird Zeichen a
. Das reicht aus, denn es können sein nur einer Rand beginnend mit einem bestimmten Zeichen (vergewissern Sie sich, dass dies der Fall ist, nachdem Sie die gesamte Beschreibung durchgelesen haben).
- Wir erhöhen auch die
remainder
so dass zu Beginn des nächsten Schrittes wird es 2 sein.
Beobachtung: Wenn die endgültige Suffix, das wir einfügen müssen, wird gefunden als bereits im Baum vorhanden ist ist der Baum selbst nicht geändert überhaupt nicht (wir aktualisieren nur den aktiven Punkt und remainder
). Der Baum ist dann keine genaue Darstellung des Suffixbaums bis zum aktuellen Position nicht mehr, aber es enthält alle Suffixe (weil die Endung Suffix a
enthalten ist implizit ). Daher ist neben der Aktualisierung der Variablen (die alle eine feste Länge haben, so dass dies O(1) ist), gab es keine Arbeit in diesem Schritt durchgeführt.
Schritt 5: Wir aktualisieren die aktuelle Position #
bis 5. Diese wird der Baum automatisch auf diesen Wert aktualisiert:
Et denn remainder
ist 2 müssen wir zwei abschließende Suffixe an der aktuellen Position einfügen: ab
y b
. Dies ist im Wesentlichen darauf zurückzuführen:
- En
a
Suffix aus dem vorangegangenen Schritt wurde nie richtig eingefügt. Es hat also blieb und da wir einen Schritt weitergekommen sind Schritt gemacht haben, ist sie nun von a
a ab
.
- Und wir müssen die neue Endkante einfügen
b
.
In der Praxis bedeutet dies, dass wir zum aktiven Punkt gehen (der auf hinter dem a
auf dem heutigen Gelände der abcab
Kante), und setzen Sie die aktuellen Endzeichen b
. Aber: Auch hier zeigt sich, dass b
ist bereits an derselben Kante vorhanden.
Also noch einmal, wir ändern den Baum nicht. Wir einfach:
- Aktualisieren Sie den aktiven Punkt auf
(root,'a',2)
(gleicher Knoten und gleiche Kante wie vorher, aber jetzt zeigen wir auf hinter dem b
)
- Erhöhen Sie die
remainder
auf 3, weil wir immer noch nicht richtig die letzte Kante aus dem vorherigen Schritt eingefügt haben, und wir fügen nicht die aktuelle letzte Kante auch nicht ein.
Um es klar zu sagen: Wir mussten die ab
y b
im aktuellen Schritt, aber weil ab
bereits gefunden wurde, haben wir den aktiven Punkt aktualisiert und nicht einmal versucht, einen b
. Warum? Weil, wenn ab
ist im Baum, jede Nachsilbe davon (einschließlich b
) muss im Baum sein, auch. Vielleicht nur implizit aber sie muss da sein, wegen der wie wir den Baum bisher aufgebaut haben.
Wir fahren fort mit Stufe 6 durch Inkrementierung #
. Der Baum wird automatisch aktualisiert:
Denn remainder
ist 3 müssen wir einfügen abx
, bx
y x
. Der aktive Punkt sagt uns, wo ab
endet, also müssen wir nur springen und die x
. In der Tat, x
ist noch nicht da, also haben wir teilen die abcabx
Kante und fügen einen internen Knoten ein:
Die Randdarstellungen sind immer noch Zeiger auf den Text, so dass kann das Aufteilen und Einfügen eines internen Knotens in O(1) Zeit erfolgen.
Wir haben uns also mit abx
und Dekrement remainder
auf 2. Jetzt müssen wir müssen wir das nächste verbleibende Suffix einfügen, bx
. Aber bevor wir das tun müssen wir den aktiven Punkt aktualisieren. Die Regel dafür ist, nach dem Aufteilen und dem Einfügen einer Kante, wird Regel 1 unten, und sie gilt immer dann, wenn die active_node
ist Root (wir werden Regel 3 für andere Fälle noch lernen weiter unten). Hier ist Regel 1:
Nach einer Einfügung von Root,
active_node
bleibt Wurzel
active_edge
wird auf das erste Zeichen gesetzt eingefügt werden muss, d. h. b
active_length
wird um 1 reduziert
Daher ist das neue Aktivpunkt-Tripel (root,'b',1)
zeigt an, dass die die nächste Einfügung an der Stelle erfolgen muss bcabx
Rand, hinter 1 Zeichen, d.h. hinter b
. Wir können den Einfügepunkt in O(1) Zeit identifizieren und prüfen, ob x
bereits vorhanden ist oder nicht. Wenn sie vorhanden ist, würden wir würden wir den aktuellen Schritt beenden und alles so lassen, wie es ist. Aber x
ist nicht vorhanden, also fügen wir es durch Aufteilung der Kante ein:
Auch dies dauerte O(1) Zeit und wir aktualisieren remainder
auf 1 und die aktiven Punkt auf (root,'x',0)
wie Regel 1 besagt.
Aber es gibt noch eine Sache, die wir tun müssen. Wir nennen dies Regel 2:
Wenn wir eine Kante teilen und einen neuen Knoten einfügen, und wenn das n erste Knoten die während des laufenden eingefügten Knoten und den neuen Knoten durch einen speziellen Zeiger, einen Suffix Link . Wir werden später sehen, warum das nützlich ist. Hier ist, was wir erhalten, das Suffix-Link wird als gepunktete Kante dargestellt:
Wir müssen noch das letzte Suffix des aktuellen Schrittes einfügen, x
. Da die active_length
Komponente des aktiven Knotens gefallen ist auf 0 gefallen ist, wird die letzte Einfügung direkt an der Wurzel vorgenommen. Da es keine ausgehende Kante am Root-Knoten gibt, die mit x
fügen wir eine neue Kante ein:
Wie wir sehen können, wurden im aktuellen Schritt alle verbleibenden Einfügungen vorgenommen.
Wir fahren fort mit Stufe 7 durch Einstellung #
=7, wodurch automatisch das nächste Zeichen angehängt wird, a
zu allen Blatträndern, wie immer. Dann versuchen wir, das neue Endzeichen Zeichen in den aktiven Punkt (die Wurzel) einzufügen, und stellen fest, dass es sich dort bereits ist. Also beenden wir den aktuellen Schritt, ohne etwas einzufügen, und aktualisieren den aktiven Punkt auf (root,'a',1)
.
Unter Stufe 8 , #
=8, wir fügen hinzu b
und wie bereits erwähnt, ist dies nur bedeutet, dass wir den aktiven Punkt auf (root,'a',2)
und Inkrement remainder
ohne zu tun etwas anderes zu tun, denn b
ist bereits vorhanden. Allerdings, stellen wir fest (in O(1) Zeit), dass der aktive Punkt jetzt am Ende einer Kante liegt. Wir tragen dem Rechnung, indem wir ihn neu setzen auf (node1,'\0x',0)
. Hier verwende ich node1
um auf den internen Knoten der ab
Kante endet bei.
Dann, in Schritt #
=9 müssen wir 'c' einfügen, was uns helfen wird den letzten Trick zu verstehen:
Zweite Verlängerung: Verwendung von Suffix-Links
Wie immer ist die #
aktualisieren anhängt c
automatisch zu den Blatträndern und wir gehen zum aktiven Punkt, um zu sehen, ob wir 'c' einfügen können. Es stellt sich heraus sich heraus, dass 'c' bereits an dieser Kante existiert, also setzen wir den aktiven Punkt auf (node1,'c',1)
, Inkrement remainder
und nichts anderes tun.
Jetzt in Schritt #
=10 , remainder
ist 4, und deshalb müssen wir zuerst abcd
(der von vor 3 Schritten übrig geblieben ist) durch Einfügen von d
an der aktiven Punkt.
Versuch der Einfügung d
am aktiven Punkt führt zu einer Kantenspaltung in O(1) Zeit:
En active_node
von dem aus der Split eingeleitet wurde, ist in oben rot markiert. Hier ist die endgültige Regelung, Regel 3:
Nach der Abspaltung einer Kante von einer active_node
t Knoten ist, folgen wir dem Suffix-Link, der von diesem Knoten ausgeht, wenn es gibt, und setzen die active_node
auf den Knoten, auf den er zeigt. [ ] keinen Suffix-Link gibt, setzen wir den active_node
zur Wurzel. active_edge
y active_length
bleiben unverändert.
Der aktive Punkt ist also jetzt (node2,'c',1)
y node2
ist markiert in unten rot markiert:
Da die Einfügung von abcd
abgeschlossen ist, dekrementieren wir remainder
auf 3 und berücksichtigen das nächste verbleibende Suffix des aktuellen Schritts, bcd
. Regel 3 hat den aktiven Punkt auf den rechten Knoten und die rechte Kante gesetzt also Einfügen bcd
kann durch einfaches Einfügen des letzten Zeichens erreicht werden d
am aktiven Punkt.
Dies führt zu einer weiteren Kantenaufspaltung, und aufgrund von Regel 2 wir müssen wir einen Suffix-Link von dem zuvor eingefügten Knoten zu dem neuen erstellen erstellen:
Wir beobachten: Suffix-Links ermöglichen uns den nächsten verbleibender Einsatz mit O(1) Aufwand. Betrachten Sie die Diagramm oben, um zu bestätigen, dass der Knoten mit der Bezeichnung ab
i dem Knoten bei b
(sein Suffix), und der Knoten bei abc
i bc
.
Der aktuelle Schritt ist noch nicht abgeschlossen. remainder
ist jetzt 2, und wir müssen wir die Regel 3 befolgen, um den aktiven Punkt wieder zurückzusetzen. Da der aktuelle active_node
(oben rot) keine Suffix-Verknüpfung hat, setzen wir auf Wurzel. Der aktive Punkt ist nun (root,'c',1)
.
Die nächste Einfügung erfolgt also an der einen ausgehenden Kante des Wurzelknotens deren Etikett beginnt mit c
: cabxabcd
hinter dem ersten Zeichen, d.h. hinter c
. Dies führt zu einer weiteren Spaltung:
Und da dies die Erstellung eines neuen internen Knotens beinhaltet, folgen wir Regel 2 und setzen einen neuen Suffix-Link von dem zuvor erstellten internen Knoten:
(Ich verwende Graphviz-Punkt für diese kleinen Diagramme. Der neue Suffix-Link veranlasste dot, die bestehenden Kanten neu angeordnet hat. Prüfen Sie also sorgfältig, ob das einzige, was eine neue Suffix-Verbindung ist).
Mit diesem, remainder
kann auf 1 gesetzt werden, und da die active_node
ist Root, wir verwenden Regel 1, um den aktiven Punkt zu aktualisieren (root,'d',0)
. Diese bedeutet, dass die letzte Einfügung des aktuellen Schritts die Einfügung einer einzelnen d
bei Root:
Das war der letzte Schritt und wir sind fertig. Es gibt eine Reihe von endgültig Beobachtungen Allerdings:
-
Bei jedem Schritt bewegen wir uns #
um 1 Position nach vorne. Dadurch werden automatisch alle Blattknoten in O(1) Zeit aktualisiert.
-
Sie befasst sich jedoch nicht mit a) irgendwelchen Suffixen verbleibende aus früheren Schritten und b) mit dem letzten Zeichen des aktuellen Schrittes.
-
remainder
sagt uns, wie viele zusätzliche Einsätze wir brauchen, um machen müssen. Diese Einfügungen entsprechen eins-zu-eins den Endsuffixen von der Zeichenkette, die an der aktuellen Position endet #
. Wir betrachten eine nacheinander und fügen sie ein. Wichtig: Jeder Einsatz ist wird in O(1) Zeit durchgeführt, da der aktive Punkt uns genau sagt, wo wir und wir nur ein einziges Zeichen am aktiven Punkt hinzufügen müssen Punkt hinzufügen. Und warum? Weil die anderen Zeichen implizit enthalten (sonst wäre der aktive Punkt nicht dort, wo er ist).
-
Nach jeder solchen Einfügung wird die Zahl remainder
und folgen Sie der Suffix-Link, wenn es einen solchen gibt. Wenn nicht, gehen wir zu Root (Regel 3). Wenn wir bereits bei Root sind, ändern wir den aktiven Punkt nach Regel 1. Unter In jedem Fall dauert es nur O(1) Zeit.
-
Wenn wir bei einem dieser Einsätze feststellen, dass das gewünschte Zeichen Zeichen, das wir einfügen wollen, bereits vorhanden ist, tun wir nichts und beenden den aktuellen Schritt, auch wenn remainder
>0. Der Grund dafür ist, dass jede Einfügungen, die übrig bleiben, Suffixe derjenigen sind, die wir gerade versucht haben zu machen. Daher sind sie alle implizit im aktuellen Baum. Die Tatsache dass remainder
>0 stellt sicher, dass wir die verbleibenden Suffixe behandeln später.
-
Was wäre, wenn am Ende des Algorithmus remainder
>0? Dies wird der Fall sein der Fall, wenn das Ende des Textes eine Teilzeichenkette ist, die irgendwo vorher vorkam. In diesem Fall müssen wir ein zusätzliches Zeichen an das Ende der Zeichenkette anhängen, das vorher noch nicht vorkam. In der Literatur wird normalerweise das Dollarzeichen $
wird als Symbol verwendet für das. Warum ist das wichtig? --> Wenn wir später den vervollständigten Suffixbaum für die Suche nach Suffixen verwenden, dürfen wir nur Übereinstimmungen akzeptieren, wenn sie an einem Blatt enden . Andernfalls würden wir eine Menge falscher Treffer erhalten, da es viele viele Zeichenketten implizit die im Baum enthalten sind, aber keine eigentlichen Suffixe der Hauptzeichenkette sind. Erzwingen remainder
am Ende 0 zu sein, ist im Wesentlichen ein Weg, um sicherzustellen, dass alle Suffixe an einem Blattknoten enden. Allerdings, wenn wir den Baum verwenden wollen, um zu suchen allgemeine Substrate nicht nur Suffixe des Hauptstrangs ist dieser letzte Schritt in der Tat nicht erforderlich, wie in dem unten stehenden Kommentar des Auftraggebers vorgeschlagen.
-
Wie komplex ist also der gesamte Algorithmus? Wenn der Text n Zeichen lang ist, gibt es offensichtlich n Schritte (oder n+1, wenn wir das das Dollarzeichen). In jedem Schritt tun wir entweder nichts (außer Aktualisierung der Variablen), oder wir machen remainder
Einfügungen, die jeweils O(1) Zeit. Da remainder
zeigt an, wie oft wir nichts getan haben in den vorangegangenen Schritten getan haben, und wird für jede Einfügung, die wir vornehmen, dekrementiert Jetzt ist die Gesamtzahl der Schritte, die wir gemacht haben, genau n (oder n+1). Daher ist die Gesamtkomplexität O(n).
-
Es gibt jedoch eine Kleinigkeit, die ich nicht richtig erklärt habe: Es kann vorkommen, dass wir einem Suffix-Link folgen, den aktiven Punkt aktualisieren Punkt, und dann feststellen, dass sein active_length
Komponente nicht funktioniert nicht gut mit dem neuen active_node
. Betrachten Sie zum Beispiel eine Situation wie diese:
(Die gestrichelten Linien zeigen den Rest des Baumes an. Die gepunktete Linie ist ein Suffix-Verbindung.)
Der aktive Punkt sei nun (red,'d',3)
und verweist damit auf den Ort hinter dem f
über die defg
Rand. Nehmen wir nun an, wir haben die notwendigen Aktualisierungen vorgenommen und folgen nun dem Suffix-Link, um den aktiven Punkt zu aktualisieren gemäß der Regel 3. Der neue aktive Punkt ist (green,'d',3)
. Allerdings, die d
-Kante, die von dem grünen Knoten ausgeht, ist de
und hat daher nur 2 Zeichen. Um den richtigen aktiven Punkt zu finden, müssen wir natürlich müssen wir natürlich dieser Kante bis zum blauen Knoten folgen und auf (blue,'f',1)
.
In einem besonders schlimmen Fall, dem active_length
könnte so groß sein wie remainder
, die so groß wie n sein kann. Und es kann durchaus vorkommen dass wir, um den richtigen aktiven Punkt zu finden, nicht nur über eine internen Knoten springen müssen, sondern vielleicht über viele, im schlimmsten Fall bis zu n. Bedeutet das bedeutet das, dass der Algorithmus eine versteckte O(n 2 ) Komplexität, denn in jedem Schritt remainder
ist im Allgemeinen O(n), und die Nachkorrekturen an den aktiven Knoten nach einem Suffix-Link könnte auch O(n) sein?
Nein. Der Grund dafür ist, dass wir, wenn wir den aktiven Punkt tatsächlich anpassen müssen (z.B. von grün auf blau, wie oben), bringt uns das zu einem neuen Knoten, der seinen eigenen Suffix-Link hat, und active_length
reduziert werden. Als folgen wir der Kette der Suffix-Glieder und nehmen die restlichen Einfügungen vor, active_length
kann nur abnehmen, und die Zahl der aktiven Punkte, die wir auf dem Weg auf dem Weg nicht größer sein kann als active_length
zu jedem beliebigen Zeitpunkt. Da active_length
kann nie größer sein als remainder
y remainder
ist O(n) nicht nur in jedem einzelnen Schritt, sondern die Gesamtsumme der Inkremente die jemals an remainder
im Verlauf des gesamten Prozesses ist O(n) ist, ist auch die Anzahl der aktiven Punktanpassungen begrenzt durch O(n).