Weiner´s Algorithmus Ausarbeitung von Daniel Stahr Vortrag,gehalten am 26.11.02 im Hauptseminar „Pattern Matching“ Inhalt: 1 2 3 Einleitung 1.1 Motivation 1.2 Naiver Algorithmus 1.3 Beispiel Weiner´s Algorithmus 2.1 Ansatz 2.2 Grundidee 2.3 Aktualisierung der Vektoren 2.4 Korrektheit 2.5 Beispiel 2.6 Zeitanalyse Zusammenfassung 1. Einleitung 1.1 Motivation Weiner führte die Suffix Bäume 1973 als Index ein. Mit seinem Algorithmus kann man Suffix Bäume in linearer Zeit bauen. Suffix Bäume waren damals eine großartige Entdeckung, da man mit Ihnen viele Anwendungen auf Text in linearer Zeit bewerkstelligen kann. Einige Beispiele dafür sind : die Textsuche, die Suche nach dem längsten sich wiederholenden Substring oder den längsten gemeinsamen Substring, sowie das Finden von Palindromen ( abba = reverse(abba) ist ein Palindrom). Es gibt noch viele weitere Anwendungen, jedoch ist die Textsuche eines der wichtigsten. Ein großer Vorteil ist, dass sowohl exakte als auch unexakte Textsuche möglich ist. 1.2 Naiver Ansatz: Definitionen Im Gegensatz zu den anderen Verfahren, konstruiert Weiner seine Suffix Bäume von hinten nach vorne. Man bezeichnet den aktuellen Substring als Sffi oder als S(i) bzw. S[i..m], wobei m die Länge des Eingabewortes ist. Angenommen m ist der String „abba“, so ist S(4) = Suff4 = „a“, S(1) = „abba“. Der aktuelle Baum wird als Ti bezeichnet. Anhand von Ti wird Ti+1 konstruiert. Um die Eindeutigkeit des Baums zu gewährleisten, wird am Ende des Strings „$“ angehängt (siehe letzter Vortrag). Man geht also von Tm+1 nach T1. Ablauf: S(m) = „$“ wird als erstes Blatt eingefügt ( „$“ ist die Kantenbezeichnung) Nun wird S(i) absteigend in den jeweiligen Baum Ti+1 eingefügt. Man vergleicht von der Wurzel aus jeweils den ersten Buchstaben der Kante mit dem ersten Buchstaben von S(i). Findet man keine Kante, so fügt man eine neue Kante vom Knoten aus ein. Gibt es eine Übereinstimmung, so geht man diesen Pfad so lange entlang (eventuell über Knoten hinaus) und vergleicht das jeweilige Zeichen mit dem entsprechenden Zeichen von S(i), bis man ein Missmatch findet. An dieser Stelle fügt man den neuen Knoten ein. Den Pfad von der Wurzel bis zum neuen Knoten nennt man Head(i). Head(i) ist somit der längste gemeinsame Präfix von S[i…m], der mit einen Substring von S[i+1…m] übereinstimmt. 1.2 Naiver Ansatz: Beispiel Wort „aba$“: Wir haben Baum T2 und fügen nun S(1) in den Baum ein. S(1) = „aba$“. Pfad 2 fängt mit „a“ an, erstes Missmatch bei „$“ Head(1) = 1 Neuer Knoten wird an dieser Stelle eingefügt. Alte Kantenbezeichnung wird geteilt Baum T2 Wurzel r b a $ Baum T1 Wurzel r b a $ 2 a a w 3 $ 2 $ b a $ 1 3 1.3 Naiver Ansatz: Analyse Der naive Ansatz vergleicht bei jedem Durchgang jedes Zeichen bis zum ersten Missmatch. Für das Erzeugen von Knoten braucht man konstant Zeit. Der „kostspielige“ Prozess ist das Finden von Head(i). Der Algorithmus braucht dafür O(m²) Zeit. Um den Suffix Baum in linearer Zeit konstruieren zu können, muss also das Finden von Head(i) optimiert werden. Dies wird im kommenden Abschnitt erläutert. Der große Platzbedarf wird dadurch reduziert, dass nicht mehr explizit der String in den Baum geschrieben wird, sondern nur noch ein Pointer auf Anfang und Ende (siehe letzter Vortrag). 2 Weiner´s Algorithmus 2.1 Ansatz Die Grundidee von Weiner´s Algorithmus sind zwei Vektoren, die in jedem NichtBlatt-Knoten abgespeichert werden (auch Wurzel). Der erste Vektor heißt Indikator Vektor und ist ein Binärvektor. Der Schlüssel des Vektors ist das verwendete Alphabet. Der Linkvektor hat ebenfalls als Schlüssel das verwendete Alphabet. Allerdings sind die entsprechenden Einträge Links auf andere Knoten im Baum. Während des gesamten Algorithmus werden diese beiden Vektoren eingefügt und erneuert. Indikatorvektor: Für jedes Zeichen x und jeden Knoten u ist Iu(x) = 1, falls ein Pfad xα in Ti+1 von der Wurzel aus existiert, wobei α der Pfad von der Wurzel bis Knoten u ist. xα muss nicht an einem Knoten enden. Andernfalls ist der Eintrag 0. Linkvektor: Für jedes Zeichen x und jeden Knoten u ist Lu(x) ein Link auf einen anderen Knoten u, falls u den Pfad α und u den Pfad xα hat. Ansonsten ist der Eintrag null. Diese Definitionen bedeuten, dass falls Lu(x) ≠ null ist, Iu(x) = 1 ist. Jedoch gilt dies nicht umgekehrt, z.B. ,der gesuchte Pfad existiert, jedoch endet er nicht an einem Knoten. 2.2 Weiner´s Algorithmus: Grundidee Anhand der beiden Vektoren ist es nicht mehr nötig jedes Zeichen zu vergleichen. Im aktuellen Baum Ti hat man gerade einen neuen Pfad α eingefügt. Als nächstes fügt man xα in den Baum ein. Man startet bei Blatt ti+1 und sucht auf dem Pfad vom Blatt bis zur Wurzel den ersten Knoten ,dessen Eintrag Iu(x) = 1 ist. Hat man den Knoten gefunden, so weis man, dass es den gesuchten Pfad gibt, jedoch nicht, wo dieser Pfad ist. Der Pfad muss auch nicht an einem Knoten enden. Als nächstes prüft man, ob Lu(x) ≠ null. Existiert der Eintrag, so springt man zu dem gefundenen Knoten. Head(i) endet in diesem Fall am Knoten. Ist Lu(x) = null, so springt man zum nächsten Knoten auf dem Weg zur Wurzel, bis man die Wurzel erreicht hat. Auf dem Weg merkt man sich die Anzahl der übersprungenen Zeichen ti. Findet man nun einen Knoten mit Lu(x) = u, so endet Head(i) genau ti Zeichen von dem Knoten u aus. Findet man kein u, so endet Head(i) genau ti Zeichen auf einer Kante von der Wurzel aus. Ist Iu(x) = 0 für alle Knoten auf dem Weg, so wird S(i) als neue Kante von der Wurzel aus eingefügt. 2.3 Weiner´s Algorithmus: Aktualisieren der Vektoren Damit der Algorithmus funktioniert, müssen die Vektoren nach jedem Schritt aktualisiert werden. Linkvektor: Da pro Schritt maximal ein Knoten eingefügt wird, muss auch nur maximal ein Eintrag verändert werden. Auf einen Knoten kann auch maximal ein Knoten zeigen. Falls Iv(S(i)) = 1 und Lv´(S(i)) ≠ null sind, so muss Lv(S(i)) auf den neuen Knoten w verweisen. Neue Knoten werden mit einem leeren Vektor initialisiert. Indikatorvektor: Da α existiert und wir xα einfügen, können alle Einträge auf dem Pfad α von der Wurzel aus, auf Iv(x) = 1 gesetzt werden, soweit sie es noch nicht sind. Dies kann schon während der Suche geschehen. Damit muss der Weg nur einmal durchlaufen werden. Fügt man einen neuen Knoten w in die Kante e(v´´, z) ein, so kopiert man den Indikatorvektor vom darunter liegenden Knoten z. 2.4 Weiner´s Algorithmus: Korrektheit des Algorithmus Um die Korrektheit zu beweisen, müssen zwei Theoreme bewiesen werden. Theorem 1: Angenommen Knoten v mit Pfad α wurde gefunden, dann ist der gesuchte String genau S(i)α. Beweis: Head(i) ist der längste Präfix von Suffi, der auch in Suffk ist, mit k > i. Da Iv(S(i)) = 1 gilt, gibt es einen Pfad in Ti+1, der mit S(i) beginnt. Head(i) ist also mindestens ein Zeichen lang, wir bezeichnen Head(i) mit S(i)β. Suffi und Suffk beginnen also beide mit Head(i) = S(i)β und divergieren danach, also S(i)βa und S(i)βb. Dann beginnen folglich Suffi+1 und Suffk+1 mit βa und βb. Somit gibt es eine Kante von der Wurzel aus, die mit β beginnt und sich später teilt. Also gibt es einen Knoten u in Ti+1, mit Pfad β und Iu(S(i)) = 1, wenn es einen Pfad S(i)β gibt. U muss auf dem Pfad zu Blatt i+1 sein, daβ ein Prefix von Suffi+1 ist. Nehmen wir nun an, Iv(S(i)) = 1 und v hat Pfad α, dann beginnt Head(i) mit S(i) α. Das bedeutet, das α ein Prefix von β ist und deshalb muss u mit Pfad β entweder v oder ein Knoten unterhalb von v auf dem Pfad sein. Wäre u unterhalb von v, so wäre Iu(S(i)) = 1 und wir hätten v anstatt von u gewählt. Dies bedeutet: v = u und α = β. Theorem 2: Angenommen v und v´ wurden gefunden und Lv´(S(i)) zeigt auf v´´. Ist li = 0 (Anzahl der gelesenen Zeichen), so endet Head(i) am Knoten v´´. Andernfalls endet Head(i) nach genau li Zeichen auf einer Kante von v´´. Beweis: Aufgrund der Definition des Linkvektors, muss v´´ in Head(i) enthalten sein. Head(i) muss mindestens bis zu diesem Knoten gehen. Laut Theorem 1 ist Head(i) = S(i)α, also muss Head(i) li Zeichen unter v´´ enden. Jetzt muss man noch zeigen, dass Head(i) nicht über die Kante e(v´´, z) hinaus gehen kann. Ist z ein Blatt, dann wäre der Pfad bis z ein Suffix von Head(i), was ein Widerspruch an sich ist. Angenommen z ist ein Knoten mit Pfad S(i)γ. In diesem Fall müsste es bereits einen Knoten z´ mit Pfad γ in Ti+1 geben. Dieser Knoten müsste unterhalb von v´, auf dem Pfad i+1 sein. Dies widerspricht der Wahl von v´. Somit muss Head(i) innerhalb der Kante e enden. 2.5 Weiner´s Algorithmus: Beispiel Die Bäume in den Beispielen dienen nur zur Veranschaulichung und sind keine richtigen Suffix Bäume. 1. Beispiel Wir fügen den String „bab aa cd$“ in den Baum ein. Der erste Knoten, mit I(S(i)) = 1, ist auch gleichzeitig der erste Knoten mit L(S(i)) = Link. Es werden also keine Zeichen gelesen ( t = 0). Die neue Kante entspringt dem Knoten v´´ . b a a b b v10 v1 Iv2(b) = 1 a a Lv2(b) = v11 a a b c v11 v2 c d c d v3 $ Iv3(b) = 0 Lv3(b) = null b b b 2. Beispiel Wir fügen den String „bab aa d$“ in den Baum ein. Bei v3 finden wir das erste Mal I(S(i)) = 1 (v). Nun suchen wir v´, in unserem Fall v2. Auf dem Weg zu v´ merken wir die übersprungenen Zeichen ( t = 1). Der neue Knoten muss also nach dem ersten Zeichen, auf einer Kante von v´´ eingefügt werden. b a a b a a b v10 v1 v11 v2 b Iv2(b) = 1 Lv2(b) = v11 a a b c d $ v3 1 Zeichen gemerkt: ti =1 Iv3(b) = 1 Lv3(b) = null b b b 2.6 Weiner´s Algorithmus: Zeitanalyse Das Springen zwischen Knoten, sowie das Einfügen von Knoten, kann in konstanter Zeit bewerkstelligt werden. Die Zeitkomplexität des Verfahrens hängt von der Anzahl der Sprünge ab, die man während der Suche nach Head(i) macht. Pro Schritt wird ein neuer Knoten erzeugt. Da durch einen Sprung die Pfadlänge höchstens um eins erhöht wird, kann die aktuelle Pfadlänge höchstens um 2 Schritte erhöht werden. Ingesamt kann die aktuelle Pfadlänge also nur 2m mal erhöht werden. Daraus folgt auch, dass sie maximal 2m mal erniedrigt werden kann (wenn man den Pfad nach oben läuft, auf der Suche nach Head(i)). D.h. man muss höchstens 2m Knoten bei der Suche nach Head(i) besuchen. Die Laufdauer des Algorithmus ist proportional zur Anzahl der Knoten, die wir besuchen. => Lineare Laufzeit 3. Schlusswort Da Weiner´s Algorithmus in linearer Zeit terminiert, ist er in Bezug auf die Laufzeit genauso gut, wie seine Nachfolger. Allerdings hat er einen höheren Platzbedarf. Dies kommt daher, dass man in jedem Nicht-Blatt-Knoten die zwei Vektoren abspeichert. Ukkonens Algorithmus speichert im Ganzen nur 2 Vektoren ab (siehe Vortrag Ukkonens Algorithmus). Dies ist auch der Grund, weshalb Weiner´s Algorithmus nicht mehr so sehr verbreitet ist, wie die anderen Verfahren.