Entschuldigung, wenn meine Antwort überflüssig erscheint, aber ich habe kürzlich den Algorithmus von Ukkonen implementiert und tagelang damit zu kämpfen gehabt. Ich musste mehrere Artikel zu diesem Thema durchlesen, um das Warum und Wie einiger Kernaspekte des Algorithmus zu verstehen.
Ich fand den 'Regeln'-Ansatz früherer Antworten nicht hilfreich, um die zugrunde liegenden Gründe zu verstehen , deshalb habe ich alles unten geschrieben und mich ausschließlich auf die Pragmatik konzentriert. Wenn Sie Probleme haben, anderen Erklärungen zu folgen, genau wie ich, wird meine ergänzende Erklärung möglicherweise dazu führen, dass Sie darauf klicken.
Ich habe meine C # -Implementierung hier veröffentlicht: https://github.com/baratgabor/SuffixTree
Bitte beachten Sie, dass ich kein Experte auf diesem Gebiet bin. Daher können die folgenden Abschnitte Ungenauigkeiten (oder Schlimmeres) enthalten. Wenn Sie auf etwas stoßen, können Sie es jederzeit bearbeiten.
Voraussetzungen
Der Ausgangspunkt der folgenden Erklärung setzt voraus, dass Sie mit dem Inhalt und der Verwendung von Suffixbäumen sowie mit den Merkmalen des Ukkonen-Algorithmus vertraut sind, z. B. wie Sie den Suffixbaum Zeichen für Zeichen von Anfang bis Ende erweitern. Grundsätzlich gehe ich davon aus, dass Sie einige der anderen Erklärungen bereits gelesen haben.
(Allerdings musste ich eine grundlegende Erzählung für den Fluss hinzufügen, damit sich der Anfang tatsächlich überflüssig anfühlt.)
Der interessanteste Teil ist die Erklärung des Unterschieds zwischen der Verwendung von Suffix-Links und dem erneuten Scannen von der Wurzel aus . Dies gab mir viele Fehler und Kopfschmerzen bei meiner Implementierung.
Offene Blattknoten und ihre Einschränkungen
Ich bin sicher, Sie wissen bereits, dass der grundlegendste Trick darin besteht, zu erkennen, dass wir das Ende der Suffixe einfach offen lassen können, dh auf die aktuelle Länge der Zeichenfolge verweisen, anstatt das Ende auf einen statischen Wert zu setzen. Auf diese Weise werden diese Zeichen beim Hinzufügen zusätzlicher Zeichen implizit allen Suffix-Labels hinzugefügt, ohne dass alle Zeichen besucht und aktualisiert werden müssen.
Dieses offene Ende von Suffixen funktioniert jedoch aus offensichtlichen Gründen nur für Knoten, die das Ende der Zeichenfolge darstellen, dh für die Blattknoten in der Baumstruktur. Die Verzweigungsoperationen, die wir für den Baum ausführen (das Hinzufügen neuer Verzweigungsknoten und Blattknoten), werden nicht automatisch überall dort weitergegeben, wo sie benötigt werden.
Es ist wahrscheinlich elementar und erfordert keine Erwähnung, dass wiederholte Teilzeichenfolgen nicht explizit im Baum erscheinen, da der Baum diese bereits enthält, da sie Wiederholungen sind. Wenn der sich wiederholende Teilstring jedoch auf ein sich nicht wiederholendes Zeichen trifft, müssen wir an diesem Punkt eine Verzweigung erstellen, um die Divergenz von diesem Punkt an darzustellen.
Zum Beispiel muss im Fall der Zeichenfolge 'ABCXABCY' (siehe unten) eine Verzweigung zu X und Y zu drei verschiedenen Suffixen hinzugefügt werden, ABC , BC und C ; Andernfalls wäre es kein gültiger Suffixbaum, und wir könnten nicht alle Teilzeichenfolgen der Zeichenfolge finden, indem wir Zeichen von der Wurzel abwärts abgleichen.
Noch einmal, um zu betonen, dass jede Operation, die wir für ein Suffix im Baum ausführen, auch durch die aufeinanderfolgenden Suffixe (z. B. ABC> BC> C) wiedergegeben werden muss, da sie sonst einfach keine gültigen Suffixe mehr sind.
Aber selbst wenn wir akzeptieren, dass wir diese manuellen Updates durchführen müssen, woher wissen wir, wie viele Suffixe aktualisiert werden müssen? Da wir, wenn wir das wiederholte Zeichen A (und den Rest der wiederholten Zeichen nacheinander) hinzufügen , noch keine Ahnung haben, wann / wo wir das Suffix in zwei Zweige aufteilen müssen. Die Notwendigkeit der Teilung wird nur festgestellt, wenn wir auf das erste sich nicht wiederholende Zeichen stoßen, in diesem Fall Y (anstelle des X , das bereits im Baum vorhanden ist).
Was wir tun können, ist, die längste wiederholte Zeichenfolge zu finden, die wir können, und zu zählen, wie viele ihrer Suffixe wir später aktualisieren müssen. Dafür steht "Rest" .
Das Konzept von "Rest" und "erneutes Scannen"
Die Variable gibt an remainder
, wie viele wiederholte Zeichen implizit ohne Verzweigung hinzugefügt wurden. dh wie viele Suffixe müssen wir besuchen, um den Verzweigungsvorgang zu wiederholen, sobald wir das erste Zeichen gefunden haben, mit dem wir nicht übereinstimmen können. Dies entspricht im Wesentlichen der Anzahl der Zeichen, die wir von ihrer Wurzel aus im Baum haben.
Wenn wir also beim vorherigen Beispiel der Zeichenfolge ABCXABCY bleiben , stimmen wir den wiederholten ABC- Teil "implizit" ab und erhöhen ihn remainder
jedes Mal, was zu einem Rest von 3 führt. Dann stoßen wir auf das sich nicht wiederholende Zeichen "Y" . Hier teilen wir das zuvor hinzugefügte ABCX in ABC -> X und ABC -> Y auf . Dann dekrementieren wir remainder
von 3 auf 2, weil wir uns bereits um die ABC- Verzweigung gekümmert haben . Jetzt wiederholen wir den Vorgang, indem wir die letzten 2 Zeichen - BC - von der Wurzel abgleichen , um den Punkt zu erreichen, an dem wir teilen müssen, und wir teilen BCX auch in BC-> X und BC -> Y . Wieder dekrementieren wir remainder
auf 1 und wiederholen die Operation; bis das remainder
0 ist. Zuletzt müssen wir das aktuelle Zeichen ( Y ) selbst ebenfalls zur Wurzel hinzufügen .
Diese Operation, die den aufeinanderfolgenden Suffixen von der Wurzel folgt, um einfach den Punkt zu erreichen, an dem wir eine Operation ausführen müssen, wird im Ukkonen-Algorithmus als "erneutes Scannen" bezeichnet. Dies ist normalerweise der teuerste Teil des Algorithmus. Stellen Sie sich eine längere Zeichenfolge vor, in der Sie lange Teilzeichenfolgen über viele Dutzend Knoten hinweg erneut scannen müssen (wir werden dies später besprechen), möglicherweise tausende Male.
Als Lösung führen wir sogenannte Suffix-Links ein .
Das Konzept der "Suffix-Links"
Suffix-Links verweisen im Grunde genommen auf die Positionen, an die wir normalerweise erneut scannen müssten. Anstelle des teuren Rescan-Vorgangs können wir einfach zur verknüpften Position springen, unsere Arbeit erledigen, zur nächsten verknüpften Position springen und wiederholen - bis dahin Es sind keine Positionen mehr zu aktualisieren.
Eine große Frage ist natürlich, wie man diese Links hinzufügt. Die vorhandene Antwort lautet, dass wir die Verknüpfungen hinzufügen können, wenn wir neue Verzweigungsknoten einfügen, wobei wir die Tatsache nutzen, dass in jeder Erweiterung des Baums die Verzweigungsknoten natürlich nacheinander in der genauen Reihenfolge erstellt werden, in der wir sie miteinander verknüpfen müssten . Wir müssen jedoch vom zuletzt erstellten Zweigknoten (dem längsten Suffix) mit dem zuvor erstellten verknüpfen, sodass wir den zuletzt erstellten Knoten zwischenspeichern, diesen mit dem nächsten erstellten Knoten verknüpfen und den neu erstellten zwischenspeichern müssen.
Eine Konsequenz ist, dass wir tatsächlich oft keine Suffix-Links haben, denen wir folgen müssen, weil der angegebene Verzweigungsknoten gerade erstellt wurde. In diesen Fällen müssen wir immer noch auf das oben erwähnte "erneute Scannen" von der Wurzel zurückgreifen . Aus diesem Grund werden Sie nach dem Einfügen angewiesen, entweder den Suffix-Link zu verwenden oder zum Stammverzeichnis zu springen.
(Wenn Sie übergeordnete Zeiger in den Knoten speichern, können Sie alternativ versuchen, den übergeordneten Zeigern zu folgen, zu überprüfen, ob sie einen Link haben, und diesen verwenden. Ich habe festgestellt, dass dies sehr selten erwähnt wird, die Verwendung des Suffix-Links jedoch nicht Set in Steinen. Es gibt mehrere mögliche Ansätze, und wenn man den zugrundeliegenden Mechanismus verstehen , können Sie ein implementieren , die Ihren Bedürfnissen am besten passt.)
Das Konzept des "aktiven Punktes"
Bisher haben wir mehrere effiziente Werkzeuge zum Erstellen des Baums besprochen und uns vage auf das Überqueren mehrerer Kanten und Knoten bezogen, aber die entsprechenden Konsequenzen und Komplexitäten noch nicht untersucht.
Das zuvor erläuterte Konzept des "Restes" ist nützlich, um zu verfolgen, wo wir uns im Baum befinden, aber wir müssen erkennen, dass es nicht genügend Informationen speichert.
Erstens befinden wir uns immer an einer bestimmten Kante eines Knotens, sodass wir die Kanteninformationen speichern müssen. Wir werden dies "aktive Kante" nennen .
Zweitens haben wir auch nach dem Hinzufügen der Kanteninformationen noch keine Möglichkeit, eine Position zu identifizieren, die weiter unten im Baum liegt und nicht direkt mit dem Wurzelknoten verbunden ist. Also müssen wir auch den Knoten speichern. Nennen wir diesen "aktiven Knoten" .
Schließlich können wir feststellen, dass der "Rest" nicht ausreicht, um eine Position an einer Kante zu identifizieren, die nicht direkt mit der Wurzel verbunden ist, da "Rest" die Länge der gesamten Route ist. und wir wollen uns wahrscheinlich nicht darum kümmern, die Länge der vorherigen Kanten zu merken und zu subtrahieren. Wir brauchen also eine Darstellung, die im Wesentlichen der Rest an der aktuellen Kante ist . Dies nennen wir "aktive Länge" .
Dies führt zu dem, was wir "aktiven Punkt" nennen - einem Paket von drei Variablen, die alle Informationen enthalten, die wir über unsere Position im Baum benötigen:
Active Point = (Active Node, Active Edge, Active Length)
Auf dem folgenden Bild können Sie sehen, wie die übereinstimmende Route von ABCABD aus 2 Zeichen am Rand AB (von der Wurzel ) plus 4 Zeichen am Rand CABDABCABD (vom Knoten 4) besteht - was zu einem "Rest" von 6 Zeichen führt. Unsere aktuelle Position kann also als aktiver Knoten 4, aktive Kante C, aktive Länge 4 identifiziert werden .
Eine weitere wichtige Rolle des "aktiven Punkts" besteht darin, dass er eine Abstraktionsschicht für unseren Algorithmus bereitstellt. Dies bedeutet, dass Teile unseres Algorithmus ihre Arbeit am "aktiven Punkt" ausführen können, unabhängig davon, ob sich dieser aktive Punkt in der Wurzel oder irgendwo anders befindet . Dies macht es einfach, die Verwendung von Suffix-Links in unserem Algorithmus sauber und unkompliziert zu implementieren.
Unterschiede zwischen dem erneuten Scannen und der Verwendung von Suffix-Links
Der schwierige Teil, der meiner Erfahrung nach viele Fehler und Kopfschmerzen verursachen kann und in den meisten Quellen nur unzureichend erklärt wird, ist der Unterschied in der Verarbeitung der Suffix-Link-Fälle gegenüber den Rescan-Fällen.
Betrachten Sie das folgende Beispiel für die Zeichenfolge 'AAAABAAAABAAC' :
Sie können oben beobachten, wie der 'Rest' von 7 der Gesamtsumme der Zeichen von root entspricht, während die 'aktive Länge' von 4 der Summe von übereinstimmenden Zeichen von der aktiven Kante des aktiven Knotens entspricht.
Nach dem Ausführen einer Verzweigungsoperation am aktiven Punkt enthält unser aktiver Knoten möglicherweise eine Suffix-Verknüpfung oder nicht.
Wenn ein Suffix-Link vorhanden ist: Wir müssen nur den Teil 'aktive Länge' verarbeiten . Der 'Rest' ist irrelevant, da der Knoten, zu dem wir über die Suffix-Verknüpfung springen, den impliziten 'Rest' bereits implizit codiert , einfach weil er sich in dem Baum befindet, in dem er sich befindet.
Wenn kein Suffix-Link vorhanden ist: Wir müssen von Null / Wurzel erneut scannen , was bedeutet, dass das gesamte Suffix von Anfang an verarbeitet wird. Zu diesem Zweck müssen wir den gesamten "Rest" als Grundlage für das erneute Scannen verwenden.
Beispielvergleich der Verarbeitung mit und ohne Suffix-Link
Überlegen Sie, was im nächsten Schritt des obigen Beispiels passiert. Vergleichen wir, wie Sie dasselbe Ergebnis erzielen - dh zum nächsten zu verarbeitenden Suffix wechseln - mit und ohne Suffixverknüpfung.
Verwenden des Suffix-Links
Beachten Sie, dass wir automatisch "am richtigen Ort" sind, wenn wir einen Suffix-Link verwenden. Dies ist häufig nicht unbedingt der Fall, da die "aktive Länge " mit der neuen Position "inkompatibel" sein kann.
Da die 'aktive Länge' im obigen Fall 4 beträgt, arbeiten wir mit dem Suffix ' ABAA' , beginnend mit dem verknüpften Knoten 4. Nachdem wir jedoch die Kante gefunden haben, die dem ersten Zeichen des Suffix ( 'A') entspricht. ) stellen wir fest, dass unsere 'aktive Länge' diese Kante um 3 Zeichen überschreitet. Also springen wir über die volle Kante zum nächsten Knoten und verringern die 'aktive Länge' um die Zeichen, die wir mit dem Sprung verbraucht haben.
Nachdem wir die nächste Kante 'B' gefunden haben , die dem dekrementierten Suffix 'BAA ' entspricht, stellen wir schließlich fest, dass die Kantenlänge größer ist als die verbleibende 'aktive Länge' von 3, was bedeutet, dass wir die richtige Stelle gefunden haben.
Bitte beachten Sie, dass dieser Vorgang normalerweise nicht als "erneutes Scannen" bezeichnet wird, obwohl er für mich das direkte Äquivalent zum erneuten Scannen ist, nur mit einer verkürzten Länge und einem nicht-root-Startpunkt.
Verwenden von 'Rescan'
Beachten Sie, dass wir, wenn wir eine herkömmliche 'Rescan'-Operation verwenden (hier so tun, als hätten wir keinen Suffix-Link), am oberen Rand des Baums, an der Wurzel, beginnen und uns wieder nach unten an die richtige Stelle arbeiten müssen. entlang der gesamten Länge des aktuellen Suffixes folgen.
Die Länge dieses Suffixes ist der 'Rest', den wir zuvor besprochen haben. Wir müssen den gesamten Rest verbrauchen, bis er Null erreicht. Dies kann (und oft auch) das Springen durch mehrere Knoten beinhalten, wobei bei jedem Sprung der Rest um die Länge der Kante verringert wird, durch die wir gesprungen sind. Dann erreichen wir endlich eine Kante, die länger ist als unser verbleibender "Rest" ; Hier setzen wir die aktive Kante auf die angegebene Kante, setzen 'aktive Länge' auf den verbleibenden 'Rest ' und fertig.
Beachten Sie jedoch, dass die tatsächliche 'Rest'- Variable beibehalten und erst nach jedem Einfügen eines Knotens dekrementiert werden muss. Was ich oben beschrieben habe, ging also davon aus, dass eine separate Variable verwendet wurde, die auf "Rest" initialisiert wurde .
Hinweise zu Suffix-Links und Rescans
1) Beachten Sie, dass beide Methoden zum gleichen Ergebnis führen. Das Suffix-Link-Jumping ist jedoch in den meisten Fällen erheblich schneller. Das ist die ganze Begründung hinter Suffix-Links.
2) Die tatsächlichen algorithmischen Implementierungen müssen sich nicht unterscheiden. Wie oben erwähnt, ist die 'aktive Länge' selbst bei Verwendung der Suffix-Verknüpfung häufig nicht mit der verknüpften Position kompatibel, da dieser Zweig des Baums möglicherweise zusätzliche Verzweigungen enthält. Im Wesentlichen müssen Sie also nur "aktive Länge" anstelle von "Rest" verwenden und dieselbe Rescan-Logik ausführen, bis Sie eine Kante finden, die kürzer als Ihre verbleibende Suffixlänge ist.
3) Eine wichtige Bemerkung zur Leistung ist, dass nicht jedes einzelne Zeichen während des erneuten Scannens überprüft werden muss. Aufgrund der Art und Weise, wie ein gültiger Suffixbaum erstellt wird, können wir davon ausgehen, dass die Zeichen übereinstimmen. Sie zählen also meistens die Längen, und die einzige Notwendigkeit für die Überprüfung der Zeichenäquivalenz entsteht, wenn wir zu einer neuen Kante springen, da Kanten durch ihr erstes Zeichen identifiziert werden (das im Kontext eines bestimmten Knotens immer eindeutig ist). Dies bedeutet, dass sich die Logik des erneuten Scannens von der Logik für die vollständige Zeichenfolgenanpassung unterscheidet (dh die Suche nach einer Teilzeichenfolge im Baum).
4) Die hier beschriebene ursprüngliche Suffixverknüpfung ist nur einer der möglichen Ansätze . Zum Beispiel NJ Larsson et al. bezeichnet diesen Ansatz als knotenorientiertes Top-Down und vergleicht ihn mit knotenorientiertem Bottom-Up und zwei kantenorientierten Sorten. Die verschiedenen Ansätze haben unterschiedliche typische und Worst-Case-Leistungen, Anforderungen, Einschränkungen usw., aber es scheint im Allgemeinen, dass kantenorientierte Ansätze eine allgemeine Verbesserung gegenüber dem Original darstellen.