Mathematisches Praktikum - SoSe 2015 Prof. Dr. Wolfgang Dahmen — Felix Gruber, Igor Voulis

Aufgabe 4 Bearbeitungszeit: drei Wochen (bis Freitag, 5. Juni 2015) Mathematischer Hintergrund: String-Matching-Algorithmen, Hash-Funktionen Elemente von C++: Schleifen, Funktionen, Vektoren (STL), Klassen, Doxygen

Einleitung Bei vielen verschiedenen Problemstellungen ist es wichtig, einen vorgegebenen Text in einem anderen Text zu finden und seine Position zurück zu geben. Beispiele für Anwendungen sind das Suchen auf Webseiten oder das Aufspüren von Plagiaten. Algorithmen, die sich mit diesem Problem beschäftigen, sind die so genannten String-Matching-Algorithmen. In dieser Übung werden wir verschiedene String-Matching-Algorithmen implementieren und auch vergleichen.

String-Matching-Algorithmen Das String-Matching-Problem besteht, grob gesprochen, darin, einen Text (Pattern) in einem anderen Text zu finden. Formal ausgedrückt betrachten wir folgendes Problem. Definition 1 (String-Matching Problem) Gegeben sei ein Alphabet Σ, ein Wort 𝑝 = 𝑝1 𝑝2 . . . 𝑝𝑘 ∈ Σ𝑘 (Pattern) der Länge 𝑘 ∈ N und ein Text 𝑇 = 𝑇1 . . . 𝑇𝑛 ∈ Σ* der Länge 𝑛 ∈ N. Hierbei bezeichnet Σ* die Menge aller Texte, die aus dem Alphabet Σ gebildet werden können. Das String-Matching Problem besteht darin, alle 𝑖 ∈ {1, . . . , 𝑛} mit 𝑇𝑖 . . . 𝑇𝑖+𝑘−1 = 𝑝1 . . . 𝑝𝑘 zu finden. Grundlegend basieren die hier verwendeten Algorithmen darauf, das Pattern unter den Anfang des Textes zu schreiben und dann immer wieder die folgenden zwei Schritte durchzuführen: a.) Vergleiche: Vergleiche das Pattern mit dem Text b.) Verschiebe: Verschiebe das Pattern unter dem Text zur nächsten Position 𝑇: 1. Vergleiche 𝑃: 2. Verschiebe Abbildung 1: Allgemeine Vorgehensweise eines String-Matching-Algorithmus. Es stellt sich nun die Frage, wie man das Verschieben und das Vergleichen optimieren kann, um einen schnellen Algorithmus zu erhalten, welcher mit möglichst wenigen Vergleichen auskommt. Hierzu betrachten wir zunächst eine simple Version des Algorithmus.

1

Ein erster naiver Ansatz Unser erster naiver Ansatz vergleicht das Pattern jeweils von links nach rechts mit dem Text und verschiebt es anschließend um eine Position. Es ist leicht ersichtlich, dass dieses Vorgehen bei bestimmten Wörtern zu einer hohen Anzahl an Vergleichen führen kann. Derartige Wörter sind allerdings im Falle einer natürlichen Sprache eher selten, sodass der Algorithmus bereits eine relativ gute Laufzeit hat, welche jedoch noch stark verbessert werden kann. Beispiel 1 Wir betrachten den Text 𝐴𝐴𝐵𝐴𝐴𝐴𝐴𝐵𝐴𝐴𝐵 und das Pattern 𝐴𝐴𝐵𝐴𝐴𝐵. Die Anwendung des naiven Algorithmus ist in Abbildung 2 dargestellt. A A B A A B A A A

A A B A A

A A A B A A

A B A A B A A

A B A A B B A A B A

B A B A A B B A A B

Abbildung 2: Beispiel 1: Suche mit dem naiven Algorithmus. Erfolgreiche Vergleiche sind fett markiert, fehlerhafte Vergleiche sind kursiv mit grauem Hintergrund dargestellt.

Verbesserte Algorithmen Um den naiven Algorithmus zu verbessern, stellen wir uns folgende zwei Fragen: a.) Kann ich durch die Kenntnis des Patterns 𝑝 Vergleiche einsparen? b.) Kann ich den Vergleich des Patterns mit dem Text vereinfachen? Die nun folgenden Algorithmen optimieren den naiven Algorithmus unter dem Gesichtspunkt jeweils einer der obigen Fragen. Knuth-Morris-Pratt-Algorithmus Der Knuth-Morris-Pratt-Algorithmus (KMP) wurde 1977[3] veröffentlicht und verbessert den naiven Algorithmus durch eine Patternanalyse. Die Idee hinter dem Algorithmus ist, dass wenn ein Teil des Pattern 𝑝 welches erfolgreich verglichen wurde, ein Suffix besitzt das Präfix des Pattern ist, so kann man mit diesem Suffix starten und so die entsprechenden Vergleiche sparen. Somit wird zuerst für jeden Teilstring 𝑝1 . . . 𝑝𝑖 untersucht, was das längste echte Suffix ist, welches zugleich ein Präfix ist. Dazu wird eine Funktion 𝑓 aufgestellt, die zu einer vorgegebenen Länge die Länge des Präfix angibt, das bereits bekannt ist. Setze f (0) = -1 Betrachte p [1]... p [ i ] mit i =1 ,... , k Finde l , so dass p [i - l +1]... p [ i ]= p [1]... p [ l ] mit l maximal und i -l >=1 Setze f ( i ) = l Die Suche startet wie die naive Suche, allerdings wird im Fall eines fehlerhaften Vergleichs oder eines erfolgreichen Fundes die nächste Position anders bestimmt.

2

Starte den Vergleich mit s =1 und i =1 ( s entspricht der aktuellen Position im Text , ab der verglichen wird , und i der Position im Pattern ) Vergleiche solange bis T [ s +i -1]!= p [ i ] oder i = k gilt Setzte s = s +( i -1) -f (i -1) und i = f (i -1) +1 Starte den naechsten Vergleich

Beispiel 2 Wir betrachten wieder den Text 𝐴𝐴𝐵𝐴𝐴𝐴𝐴𝐵𝐴𝐴𝐵 und das Pattern 𝐴𝐴𝐵𝐴𝐴𝐵. Wir wollen den Knuth-Morris-Pratt Algorithmus anwenden, d.h. wir analysieren zunächst das Pattern, siehe Abbildung 3. In Abbildung 4 ist die Suche dargestellt. 0 1 2 3 4 5 6

𝐴 𝐴A 𝐴𝐴𝐵 𝐴𝐴𝐵A 𝐴𝐴𝐵AA 𝐴𝐴𝐵AAB

𝐴𝐴𝐵𝐴𝐴𝐵 𝐴𝐴𝐵𝐴𝐴𝐵 A𝐴𝐵𝐴𝐴𝐵 𝐴𝐴𝐵𝐴𝐴𝐵 A𝐴𝐵𝐴𝐴𝐵 AA𝐵𝐴𝐴𝐵 AAB𝐴𝐴𝐵

Abbildung 3: Pattern Analyse für den Knuth-Morris-Pratt Algorithmus. Erste Spalte: Länge des gefundenen Teils, zweite Spalte: entsprechender gefundener String und letzte Spalte: das Pattern selber. Fett makiert ist das Suffix des gefundenen Strigs, welches Präfix des Patterns ist.

A A B A A A A B A A A A A

A B B A A

A B A A B A A B B A A B A B A A B

Abbildung 4: Beispiel 2: Suche nach dem Wort 𝐴𝐴𝐵𝐴𝐴𝐵 mit Hilfe des Knuth-Morris-Pratt Algorithmus. Erfolgreiche Vergleiche sind fett markiert, fehlerhafte Vergleiche sind kursiv mit grauem Hintergrund dargestellt.

Boyer-Moore-Algorithmus Der nun folgende Algorithmus wurde 1977[1] von Boyer und Moore vorgestellt. Verglichen mit dem vorherigen Algorithmus wird das Pattern noch genauer untersucht. Wie zuvor beginnen wir am Anfang des Textes, vergleichen allerdings nicht von links nach rechts, sondern von rechts nach links. Wir beginnen also mit dem letzten Buchstaben des Patterns. Sobald eine Ungleichheit auftritt, gibt es zwei Möglichkeiten, das Pattern zu verschieben. Hierbei wird immer die maximale Verschiebung verwendet: ∙ Ist 𝑎 der verglichene Buchstabe im Text, so wird im Pattern das letzte Vorkommen des Buchstaben 𝑎 gesucht und das Pattern soweit verschoben, bis die beiden gleichen Buchstaben untereinander stehen, wobei allerdings immer mindestens um eine Position nach rechts verschoben wird. ∙ Entspricht das verglichene Endstück einem Teilwort des Patterns, so wird das Pattern soweit nach rechts verschoben, bis die beiden Teile übereinander liegen. Gibt es mehrere Teilworte, welche dem Endstück entsprechen, so wird das weiter hinten liegende ausgewählt. Falls keine der beiden Regeln greift, wird das Pattern um seine komplette Länge verschoben. Es kann vorkommen, dass die Regeln eine Verschiebung in negative Richtung empfehlen, dann wird immer um 1 verschoben.

3

Wie beim Knuth-Morris-Pratt-Algorithmus wird vor der eigentlichen Suche das Pattern analysiert und in einer Sprungtabelle gespeichert. Beispiel 3 Wir betrachten wieder den Text 𝐴𝐴𝐵𝐴𝐴𝐴𝐴𝐵𝐴𝐴𝐵 und das Pattern 𝐴𝐴𝐵𝐴𝐴𝐵. Wir wollen den Boyer-Moore Algorithmus anwenden, d.h. wir analysieren zunächst das Pattern, siehe Abbildung 5. In Abbildung 6 ist die Suche dargestellt. 0 1 2 3 4 5 6

𝐴 1 𝐵 0

B AB AAB 𝐵AAB 𝐴𝐵AAB 𝐴𝐴𝐵AAB

𝐴𝐴𝐵𝐴𝐴𝐵 𝐴𝐴B𝐴𝐴𝐵 𝐴AB𝐴𝐴𝐵 AAB𝐴𝐴𝐵 AAB𝐴𝐴𝐵 AAB𝐴𝐴𝐵 AAB𝐴𝐴𝐵

Abbildung 5: links: erste Regel (Position vom letzten Buchstaben aus gesehen), rechts: zweite Regel

A A B A A B A A A

A A B A

A A A B

A B A A A

A B A A B B A B A B A A B

Abbildung 6: Suche mit dem Boyer-Moore Algorithmus. Fett: erfolgreicher Vergleich, kursiv und grauer Hintergrund: fehlerhafter Vergleich

Algorithmus mit Hash-Funktion Ziel der vorherigen Algorithmen war es, durch eine Analyse des Patterns die Anzahl an Vergleichen zu minimieren. Der nun folgende Algorithmus versucht, den Vergleich 𝑠𝑖 . . . 𝑠𝑖+𝑘−1 = 𝑝1 . . . 𝑝𝑘 so zu vereinfachen, dass möglichst schnell eine kleine Obermenge an möglichen Treffern generiert wird. Hierbei soll insbesondere nicht Buchstabe für Buchstabe verglichen werden. 𝑇: 1. Vergleiche 𝑃: 2. Verschiebe Abbildung 7: Allgemeine Vorgehensweise des Rabin-Karp Algoritmus.

Hash-Funktionen Im Allgemeinen bilden Hash-Funktionen die Elemente aus einer (evtl. unendlich) großen Menge von Werten auf eine kleine Menge von Hash-Werten ab. Definition 2 (Hash-Funktion) Eine Abbildung ℎ : 𝑆 → 𝐾 heißt Hash-Funktion, wenn |𝑆| ≥ |𝐾| gilt.

4

In unserem Fall wäre dies z.B. eine Abbildung ℎ : Σ𝑖 → {0, . . . , 9}𝑗 , mit 𝑖, 𝑗 ∈ N. In unserer Anwendung müssen wir schnell zwei Hash-Werte vergleichen. Hierzu verwenden wir sogenannte Hash-Tabellen. Definition 3 (Hash-Tabelle) Die Tabelle definiert durch die Abbildung 𝑡 : 𝐾 → {0, 1} wird Hash-Tabelle genannt. Möchten wir 𝑝 mit den Werten 𝑤1 , . . . , 𝑤𝑛 vergleichen, so berechnen wir die Hash-Werte der entsprechenden Eingaben und setzten 𝑡(ℎ(𝑝)) = 1. Nun können wir 𝑡(ℎ(𝑤𝑖 )) berechnen und überprüfen, ob dort eine 1 oder 0 steht. Das Setzen und Überprüfen ist verglichen mit dem direkten Vergleich der Hash-Werte sehr effizient. Es können allerdings Kollisionen auftreten: zwei Werte aus 𝑆 können den gleichen Hash-Wert haben. Wir erhalten also lediglich eine Obermenge der eigentlichen Lösungsmenge. Rabin-Karp-Algorithmus Der Rabin-Karp-Algorithmus[2] wurde 1987 vorgestellt und verwendet zum Vergleich in Abbildung 7 eine Hash-Funktion ℎ. Mit Hilfe der Hash-Funktion wird eine Liste von möglichen Treffern generiert, welche dann noch überprüft werden muss. Berechne h1 = h ( p ) und speichere h1 in einer Hashtabelle Fuer s =1 ,... , n - k +1 berechne h2 = h ( T [ s ]... T [ s +k -1]) Vergleiche h1 und h2 mit Hilfe der Hashtabelle Vergleiche alle Treffer mit p Die entscheidene Frage ist, was eine geeignete Hash-Funktion ist. Wenn der Hashwert für jedes 𝑠 komplett neu berechnet werden muss, ist anzunehmen, dass der Algorithmus nicht schneller läuft als der naive Algorithmus. Daher verlangen wir einen bestimmten Typ von Hashfunktionen. Definition 4 (Rolling-Hash-Funktion) Eine Hashfunktion ℎ heißt rolling-Hash-Funktion, wenn es eine Funktion 𝑟 : Σ × Σ × 𝐾 gibt, die aus dem Hashwert zu 𝑤1 . . . 𝑤𝑘 und den Buchstaben 𝑤1 und 𝑤𝑘+1 den Hashwert zu 𝑤2 . . . 𝑤𝑘+1 berechnen kann. Genauer gesagt soll folgende Identität gelten: ℎ(𝑤2 𝑤3 𝑤4 . . . 𝑤𝑘+1 ) = 𝑟(𝑤1 , 𝑤𝑘+1 , ℎ(𝑤1 𝑤2 𝑤3 . . . 𝑤𝑘 )) Folgende zwei Hash-Funktionen sind rolling-Hash-Funktionen. ∙ ℎ1 (𝑤1 . . . 𝑤𝑘 ) =

𝑘 ∑︀

𝑐ℎ𝑎𝑟(𝑤𝑖 )

𝑖=1

∙ ℎ2 (𝑤1 . . . 𝑤𝑘 ) =

𝑘 ∑︀

𝑐ℎ𝑎𝑟(𝑤𝑖 ) · 𝑎𝑘−𝑖 mod 𝑧, wobei 𝑎 eine Zahl und 𝑧 eine Primzahl ist

𝑖=1

Bei der zweiten Hash-Funktion stellt sich die Frage, wie man die Funktion 𝑟(𝑤1 , 𝑤𝑘 , ℎ(𝑤1 𝑤2 𝑤3 . . . 𝑤𝑘−1 )) implementieren kann. Hierzu benötigen wir den Wert 𝑞 := 𝑎𝑘 mod 𝑧. Damit lässt sich die roll-Funktion wie folgt implementieren: 𝑟(𝑤1 , 𝑤𝑘+1 , ℎ𝑜𝑙𝑑 ) = (ℎ𝑜𝑙𝑑 · 𝑎 + 𝑐ℎ𝑎𝑟(𝑤𝑘+1 ) + 𝑧 − (𝑞 · 𝑐ℎ𝑎𝑟(𝑤1 )

5

mod 𝑧))

mod 𝑧

Suche nach mehreren Patterns Bei der Suche nach mehreren Patterns zeigt sich der Vorteil des Rabin-Karp-Algorithmus, wenn die Wörter eine ähnliche Länge haben. Durch die Sprungtabellen und Abbruchkriterien bei den ersten drei Verfahren kann man diese nicht parallel für die Suche nach mehreren Patterns verwenden, sondern muss nacheinander nach den entsprechenden Patterns suchen. Bei dem Rabin-Karp-Algorithmus sieht dies etwas anders aus. Haben wir mehrere Patterns, die gleich lang sind, so könnnen wir gleichzeitig nach den entsprechenden Hash-Werten suchen. Bei unterschiedlich langen Patterns, können wir die Patterns auf eine gemeinsame Länge kürzen und so schnell eine grobe Obermenge an möglichen Treffern generieren. Hierbei ist zu beachten, dass bei der Hashfunktion Kollisionen auftreten können. Daher verwenden wir eine Hash-Tabelle, in welcher statt 0 bzw. 1 eine Liste von Patterns steht, welche den gegebenen Hash-Wert besitzten.

Aufgabe Ziel dieses Übungsblattes ist es mehrere Verfahren zum Finden von einem oder mehreren Strings in einem Text zu implementieren. Hierzu finden Sie eine Code-Basis, in der Sie an verschiedenen Stellen die Algorithmen implementieren sollen. Das Grundgerüst des Programms ist in den Dateien string_matching.cpp, algo.h, hash.h und util.h gegeben. Machen Sie sich vor allem mit der Klassenstruktur der Algorithmen vertraut. Um sich mit den Funktionen und Klassen vertraut zu machen, wurden die bereits implementierten Funktionen mit Doxygen1 dokumentiert. Ihre Aufgabe ist es die Funktionen, die Sie implementieren, ebenfalls mit Doxygen zu dokumentieren. Algorithmen In der Datei algo.h ist das Grundgerüst für die Algorithmen gegeben. Es sollen die Funktionen zum Suchen von einem oder mehreren Pattern implementiert werden. Für die Suche nach einem Pattern sind dies die Funktionen Naiv :: find_single ( string &s , string &p , vector < int > & result ) KnuthMorrisPratt :: find_single ( string &s , string &p , vector < int > & result ) BoyerMoore :: find_single ( string &s , string &p , vector < int > & result ) RabinKarp :: find_single ( string &s , string &p , vector < int > & result ) Da ein Pattern öfters in einem Text vorkommen kann, werden die Positionen des ersten Buchstabens jedes Treffers in dem Vektor result gespeichert. Die Suche nach mehereren Pattern läuft für die Algorithmen Naiv, KnuthMorrisPratt und BoyerMoore ähnlich ab. Daher muss hierfür nur eine Funktion implementiert werden. Simple :: find_multiple ( string &s , vector < string > &p , vector < vector < int > > & result ) RabinKarp :: find_multiple ( string &s , vector < string > &p , vector < vector < int > > & result ) In result wird das Endergebnis gespeichert, d.h. für das Pattern p[i] werden in result[i] die Positionen des ersten Buchstabens jedes Treffers gespeichert.

1

www.doxygen.org

6

Hashfunktionen Das Grundgerüst einer Hash-Funktion ist durch die Klasse RollingHash in der Datei hash.h gegeben. Die Aufgabe besteht nun darin, für die beiden Hash-Funktionen ℎ1 (HashSimple) und ℎ2 (HashBetter) zu implementieren. Hierzu soll eine Funktion zum Berechnen des kompletten Hashwertes und die Funktion 𝑟 implementiert werden: HashSimple :: set ( string & s ) HashSimple :: roll ( char & del , char & add ) HashBetter :: set ( string & s ) HashBetter :: roll ( char & del , char & add )

Endergebnis Um das Endergebnis mit der korrekten Lösung zu vergleichen, wird am Ende der Suche nach mehreren Patterns die Funktion finish_multiple aufgerufen. Hierbei wird auch die Anzahl an Vergleichen mit der korrekten Lösung verglichen. Aus diesem Grund dürfen die Funktionen start, start_multiple, finish und finish_multiple nicht gelöscht werden. Doxygen Doxygen ist ein Programm, das dabei hilft eine umfangreiche Dokumentation zu einem Projekt zu erstellen. Hierzu muss der Code in einer bestimmten Art und Weise kommentiert sein. Doxygen kann nun diese Kommentare lesen und erstellt eine HTML-Dokumentation des Projektes. Ein Teil des Codes ist schon in der für Doxygen lesbaren Weise kommentiert. Um die Dokumentation zu erstellen gibt es die Datei doxyfile, welche die wichtigsten Parameter setzt, um die Dokumentation zu generieren. Erzeugt werden kann die HTML-Dokumentation mit dem Befehl doxygen doxyfile . Nachdem das Programm durchgelaufen ist, existiert ein Ordner doxygen/html. In diesem Ordner wurde die Datei index.html erstellt, welche mit einem Browser geöffnet werden kann. Um zu sehen wie beispielsweise eine Klasse dokumentiert werden kann, schauen wir uns die Dokumentation der Klasse HashList an: /* * * \ brief Klasse zum Speichern von Daten in einer Hash - Tabelle . Ein Objekt der Klasse repraesentiert eine Liste von Elementen , wobei next immer auf das naechste Objekt der Liste zeigt . * \ param value Wert der an dieser Stelle gespeichert werden soll * \ param next Naechstes Element der Liste */ Dieser Kommentar muss unmittelbar vor der Klassendefinition stehe, damit Doxygen weiß, zu welcher Klasse er gehört. Nun gibt der Befehl \brief eine kurze Beschreibung der Klasse an. Mit \param werden alle Variablen der Klasse erklärt. Hierbei gibt der erste Wert nach \param den Namen der Variablen an und danach kommt die Beschreibung. Möchte man eine Methode erklären, verwendet man die selben Befehle, nur dass diesemal mit param die Funktionsparamenter erklärt werden. Ein weiterer Vorteil von Doxygen ist, dass man auch Latex-Befehle verwenden kann. So kann man zum Beispiel mit \f[ . . . \f] eine Formel zum Text hinzufügen. Weitere Informationen zu Doxygen finden sich in der offiziellen Dokumentation2 .

2

http://www.stack.nl/~dimitri/doxygen/manual/index.html

7

Literatur [1] Boyer, R. S. und J. S. Moore: A fast string searching algorithm. Communications of the ACM, 20(10):762–772, 1977. [2] Karp, R. M. und M. O. Rabin: Efficient randomized pattern-matching algorithms. IBM Journal of Research and Development, 31(2):249–260, 1987. [3] Knuth, D. E., J. H. Morris und V. R. Pratt: Fast Pattern Matching in Strings. SIAM Journal of Computing, 6(2):323–350, 1977.

8