Suffixbaumerstellung Ronny Peine Matr.-Nr: 2177913 Universität Hannover Studienarbeit Wintersemester 2005/2006 Unter Leitung von: Prof. Dr. R. Parchmann 12. April 2006 1 Inhaltsverzeichnis 1 Einführung 1.1 Beispiel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 Die 2.1 2.2 2.3 2.4 2.5 2.6 Algorithmen Überblick . . . . . . . . . . . . . . . . . . . . . . Beispiel: Suffix-Zeiger . . . . . . . . . . . . . . . . Der Algorithmus von McCreight . . . . . . . . . . Schematische Darstellung McCreight-Algorithmus Der Algorithmus von Ukkonen . . . . . . . . . . . Schematische Darstellung Ukkonen-Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 4 5 5 5 6 8 11 13 3 Die Datenstruktur Suffixbaum 14 3.1 Generischer Suffixbaum . . . . . . . . . . . . . . . . . . . . . . 14 3.2 Binärer Suffixbaum . . . . . . . . . . . . . . . . . . . . . . . . 16 4 Die Implementierung 17 5 Benchmarks und Laufzeitfaktorabschätzung 5.1 Die Benchmarkumgebung . . . . . . . . . . 5.2 Das Benchmarkverfahren . . . . . . . . . . . 5.3 Die Benchmarks . . . . . . . . . . . . . . . . 5.4 Die Benchmarkergebnisse . . . . . . . . . . . 5.5 Fazit . . . . . . . . . . . . . . . . . . . . . . 18 18 18 19 20 24 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6 Benutzung des Programms 25 6.1 Beispiel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 6.2 Makefile-Erläuterungen . . . . . . . . . . . . . . . . . . . . . . 26 2 1 Einführung In dieser Studienarbeit geht es um die Implementierung von Algorithmen zur Erstellung von Suffixbäumen. Dabei werden zwei effiziente Algorithmen vorgestellt. Desweiteren werden Datenstrukturen für den Suffixbaum dargelegt. Zunächst klären wir erstmal, was ein Suffixbaum ist. Ein Suffixbaum ist ein Baum, der alle Suffixe eines gegebenen Strings darstellt. Genauer: sei Σ eine endliche, nicht leere Menge, dann nennen wir Σ ein Alphabet. Die Elemente von Σ nennen wir Terminale. Die Konkatenation von Terminalen bezeichnen wir als Wörter, dabei ist das leere Wort, da es kein Terminal enthält. Σ∗ ist dabei die Menge aller Wörter über dem Alphabet Σ. Sei weiter w ∈ Σ∗ und u, v ∈ Σ∗ , so dass w = uv, dann nennen wir v ein Suffix von w. Um Suffixe eindeutig zu bezeichnen, verwenden wir die Notation sufi , mit i ∈ N und i ≤ |w|, wobei w ein Wort über Σ und |w| die Länge des Wortes w ist. Die Länge eines Wortes w ist dabei wie folgt definiert: 1. || = 0 2. |xw| = |w| + 1 für x ∈ Σ, w ∈ Σ∗ Dabei ist sufi das Suffix, dass mit dem i-ten Buchstaben des Wortes beginnt. Für i = 1 ergibt sich damit suf1 = w. Kommen wir nun zur Definition von Bäumen. Ein Baum ist ein Tupel (V, E), wobei V eine endliche, nicht leere Menge von Knoten und E eine endliche Menge von gerichteten Kanten ist. Dabei ist eine Kante ein Tupel (u, v) mit u, v ∈ V , was eine gerichtete Verbindung vom Knoten u zum Knoten v darstellt. Für jeden Baum gilt: 1. ∃! einen ausgezeichneten Knoten u ∈ V , so dass ∀v ∈ V : (v, u) ∈ / E. Diesen Knoten nennen wir die Wurzel (auch root genannt). 2. ∀v ∈ V, v ist nicht die Wurzel: ∃!u ∈ V mit (u, v) ∈ E. Jeder Knoten außer der Wurzel besitzt also einen Elternknoten. u nennen wir dabei den Elternknoten (auch parent genannt) und v einen Tochterknoten (auch child genannt) von u. 3. ∀u, v ∈ V : Gibt es einen Pfad von u nach v, so gibt es keinen Pfad von v nach u. Der Baum muss also azyklisch sein. Ein Pfad ist dabei eine endliche Folge von Kanten (ui , vi ) ∈ E, i ∈ N, mit vi = ui+1 . Die Knoten, die keine Kinder haben, nennen wir Blätter, alle anderen Knoten nennen wir innere Knoten. 3 Ein Suffixbaum T zu einem präfixfreien Wort w ∈ Σ∗ ist nun ein Baum für den gilt: 1. Jede Kante von T ist mit einem nicht leeren Teilwort von w beschriftet. 2. Für je zwei paarweise verschiedene Töchterknoten von einem Knoten u beginnen die Kantenbeschriftungen mit einem verschiedenen Terminal. 3. x ist Suffix von w ⇔ es gibt ein Blatt v und einen Pfad von der Wurzel zu v mit: die Konkatenation der Kantenbeschriftungen von der Wurzel nach v ergibt das Suffix. 4. Jeder innere Knoten außer der Wurzel besitzt mindestens zwei Töchterknoten. Ein Wort w ∈ Σ∗ ist präfixfrei genau dann, wenn es kein Suffix von w gibt, dass Präfix eines anderen Suffixes von w ist. 1.1 Beispiel Suffixbaum für den String abaab$. 4 2 2.1 Die Algorithmen Überblick In dieser Studienarbeit sind zwei Algorithmen implementiert worden. Beide Algorithmen erstellen einen Suffixbaum von einem gegebenen präfixfreien Wort w in linearer Zeit, also in O(|w|). Es soll dabei der Faktor c für die Laufzeit möglichst genau durch Messung abgeschätzt werden. Aus [Op05] geht dabei hervor, dass Suffixbäume maximal 2 · |w| − 1 Knoten haben. Durch Verwendung von Indizes statt echten Strings für die Kantenbeschriftung, erhält man somit auch insgesamt einen Speicherverbrauch von O(|w|) für den Suffixbaum. Dies nennt man auch Edge-label Kompression. Dabei speichert man den Anfangspunkt und Endpunkt eines Teilwortes in dem Wort w, z.B. für baa in abaab$ ergibt sich [1,3], falls wir bei 0 anfangen zu zählen. Somit ist erst eine Laufzeit von O(|w|) für die Erstellung eines Suffixbaumes möglich. Um nun den Algorithmus von McCreight bzw. Ukkonen zu verstehen, benötigen wir noch den Begriff des Suffix-Zeigers. Seien x ∈ Σ und α ∈ Σ∗ so, dass ein Knoten u in dem Suffixbaum für das Wort xα und ein Knoten v für das Wort α existiert. Dann setzen wir einen Zeiger von u nach v, diesen nennen wir Suffix-Zeiger. Damit wird es uns möglich sein, eine gute Laufzeit für die beiden Algorithmen zu ermöglichen. 2.2 Beispiel: Suffix-Zeiger 5 2.3 Der Algorithmus von McCreight Der Algorithmus von McCreight arbeitet sukzessiv, indem er beginnend von i = 1 bis i = |w| die Suffixe sufi dem enstehenden Suffixbaum hinzufügt. Dabei beginnt er mit dem Baum, der nur aus der Wurzel besteht und fügt zuerst suf1 also das gesamte Wort w in den Baum ein. Um nun nicht jedes Mal von der Wurzel aus zu dem Knoten zu navigieren, wo ein neuer Knoten erzeugt werden soll, verwendet man die Suffix-Zeiger. Um von sufi zu sufi+1 zu kommen, benötigen wir noch den Begriff von headi . Dabei ist headi das längste Präfix von sufi , dass auch Präfix von sufj ist mit j < i. Umgangssprachlich ist es also das längste Präfix von sufi , dass mit den vorherigen Suffixen übereinstimmt. Dabei ist u ∈ Σ∗ ein Präfix von w, falls es ein Wort v ∈ Σ∗ gibt mit w = uv. In [Op05] ist gezeigt, dass headi ohne das erste Zeichen ein Präfix von headi+1 ist. Somit können wir ausgehend vom Knoten der headi darstellt zum Knoten headi+1 gehen ohne für headi+1 vollständig von der Wurzel durch den Suffixbaum zu navigieren. Wir gehen dabei vom Knoten u der headi darstellt zum Elternknoten, falls headi nicht die Wurzel ist. Dabei merken wir uns die Kantenbeschriftung β vom Elternknoten zu u. Vom Elternknoten aus folgen wir dann dem SuffixZeiger, falls der Elternknoten nicht die Wurzel ist, zu einem Knoten v. Von dort aus müssen wir nur noch die Stelle für β finden, indem wir ein schnelles Navigieren verwenden. Dabei vergleichen wir nur das erste Zeichen einer Kante. Falls dieses übereinstimmt mit dem ersten Zeichen von β so folgen wir dieser Kante. Ist |β| > der Länge der Kantenbeschriftung, so verkürzen wir β um so viele Zeichen beginnend vom ersten Zeichen von β, wie die Kantenbeschriftung lang ist und beginnen die schnelle Navigation nun mit dem verkürzten β ab dem gerade erreichten Knoten. Ist |β| = der Länge der Kantenbeschriftung, so folgen wir der Kante zu dem jeweiligen Knoten und enden dort. Ist |β| < der Länge der Kantenbeschriftung, so merken wir uns die Stelle nach dem |β|-ten Zeichen der Kantenbeschriftung. In dem Fall, dass headi der Wurzelknoten war, müssen wir leider langsam durch den Baum beginnend bei der Wurzel navigieren und dabei jedes einzelne Zeichen von sufi+1 mit der Kantenbeschriftung vergleichen. Beim ersten Unterschied bleiben wir an der Stelle stehen, egal ob es in einer Kante oder einem Knoten ist. Ist der Fall aufgetreten, dass der Elternknoten vom headi repräsentierenden Knoten die Wurzel ist, dann müssen wir von β das erste Zeichen entfernen 6 und von der Wurzel aus diesmal mit dem schnelleren Navigieren das übrig gebliebene β ohne den ersten Buchstaben im Baum finden. Nun heißt es, das fehlende Stück von headi+1 noch zu finden, da headi ja nur ein Präfix von headi+1 ist. Im Fall das headi die Wurzel war, befinden wir uns bereits an der richtigen Stelle. Sind wir in einer Kante, so splitten wir die Kante in zwei Teile, indem wir einen Knoten dazwischen einfügen, dessen eingehende Kante den übereinstimmenden Teil mit unserem β erhält. Die ausgehende Kante erhält dabei den Rest der Kantenbeschriftung. Dieser neue Knoten repräsentiert nun headi+1 . Von diesem neuen Knoten aus erzeugen wir einen Tochterknoten, dessen Kantenbeschriftung des Restes vom Suffix sufi+1 ohne headi+1 entspricht. Befinden wir uns im Falle headi = root in einem Knoten statt in einer Kante nach dem Navigieren, so ist dieser Knoten headi+1 und wir fügen nur noch wie im Fall vorher sufi+1 ein. In den übrigen Fällen, wo headi nicht der Wurzel entsprach, und wir in einem Knoten endeten, müssen wir noch mit Hilfe der langsamen Navigation den Rest von sufi+1 im Baum suchen. Bei der ersten Unterscheidung bleiben wir stehen. Befinden wir uns in einer Kante, so müssen wir wie oben bereits beschrieben die Kante splitten. Der dabei entstehende Knoten ist dann headi+1 . An diesem fügen wir einen neuen Tochterknoten mit dem Rest von sufi+1 an. Enden wir hingegen in einem Knoten, so stellt dieser headi+1 dar und wir fügen nur noch den Rest von sufi+1 ein. In dem übrigen Fall, wo headi 6= root, und wir beim Navigieren in einer Kante geendet sind, splitten wir die Kante. Der neu entstehende Knoten ist dann headi+1 und wir müssen nur noch den Rest von sufi+1 einfügen. Um auch weiterhin Suffix-Zeiger für die schnelle Navigation nutzen zu können, wird im Fall headi 6= root ein Suffix-Zeiger von headi zum gefundenen bzw. erzeugten Knoten nach der schnellen Navigation erzeugt. Ist dies iterativ für alle Suffixe erfolgt, so ist der Suffixbaum erstellt. Die Korrektheit dieses Verfahrens, sowie dass das schnelle Navigieren auch funktioniert und die Laufzeit O(|w|) ist, ergibt sich aus [Op05]. Nachfolgend eine schematische Darstellung der Fälle und Vorgehensweisen. Dabei bedeutet β(i) das i-te Zeichen des Wortes β ∈ Σ∗ . 7 2.4 Schematische Darstellung McCreight-Algorithmus 1. Fall: headi = root, Navigation landet in einem Knoten 2. Fall: headi = root, Navigation landet in einer Kante 8 3. Fall: headi 6= root, Elternknoten = root, schnelle Navigation landet in einem Knoten, langsame Navigation endet in einem Knoten 4. Fall: headi 6= root, Elternknoten = root, schnelle Navigation landet in einem Knoten, langsame Navigation endet an einer Kante 9 5. Fall: headi 6= root, Elternknoten = root, schnelle Navigation landet in einer Kante 6. Fall: headi 6= root, Elternknoten 6= root, schnelle Navigation landet in einem Knoten, langsame Navigation endet in einem Knoten 10 Die Fälle headi 6= root, Elternknoten 6= root, wobei die schnelle bzw. langsame Navigation in einer Kante endet, sind analog zu den Fällen 4-5, nur dass dabei dem Suffix-Zeiger gefolgt wird, wie im Fall 6 beschrieben. 2.5 Der Algorithmus von Ukkonen Der Algorithmus von Ukkonen gehört zu den sogenannten Online-Algorithmen, da er den Suffixbaum erstellen kann, während der Text übertragen wird. Dabei baut er den Suffixbaum sukzessiv durch die Suffixe der Präfixe des Textes auf. Also z.B. für abaab$, die Suffixe für die Präfixe a, ab, aba, abaa, abaab und abaab$. Diese Präfixe bilden dabei die Phasen des Algorithmus, genauer: in phasei werden die Suffixe von prefi in den Suffixbaum eingefügt. Dabei ist prefi das Präfix des Textes der Länge i. Die Suffixe in einer Phase bilden dabei die Extensionen, also in prefi wird das Suffix sufj , j ≤ i, in extensionj eingefügt. Dabei können 3 Situationen auftreten, die als Regeln bezeichnet werden. Regel 1: Bei der Suche nach sufj in phasei+1 endet man in einem Blatt und fügt das i + 1-te Zeichen des Textes ein. Regel 2: Bei der Suche nach sufj in phasei+1 endet man in einem inneren Knoten oder einer Kante und fügt einen weiteren Knoten für das i + 1-te Zeichen des Textes ein. Regel 3: sufj in phasei+1 ist vollständig mit dem i + 1-ten Zeichen des Textes bereits im Suffixbaum enthalten. Beim Aufbau des Suffixbaums handelt es sich dabei in jeder Phase um einen impliziten Suffixbaum, da Suffixe auch mitten im Baum und nicht an einem Blatt enden können. Durch hinzufügen eines letzten Zeichens, das nicht im Text vorkommt, wird der Text präfixfrei, wodurch wieder jedes Suffix in einem Blatt endet. Um nun eine Laufzeit von O(|w|) zu erhalten, müssen einige Tricks angewandt werden. Es gilt, wenn einmal in extensionj in phasei Regel 1 in Kraft trat, so wird sie auch in allen folgenden Phasen k, k > i, in extensionj auftreten. Das heißt statt jedes mal dieses Suffix um einen Buchstaben zu erweitern, fügt man es gleich vollständig ein. Da Indizes verwendet werden, bleibt die online Eigenschaft erhalten, wenn man weiß, wie lang der Text ist. Bei der schnellen Navigation mittels Suffix-Zeigern müssen dabei nur die Buchstaben wk , k > i, in phasei+1 ignoriert werden. Damit kann man dann alle extensionj in den folgenden Phasen ignorieren. 11 Der nächste Trick wird bei Regel 3 genutzt. Wenn einmal Regel 3 in extensionj in phasei auftritt, so tritt diese Regel in allen extensionk , j < k ≤ i auf und es kann somit direkt in phasei+1 weitergemacht werden. Der letzte Trick bezieht sich auf Regel 2. Wenn einmal Regel 2 eintritt, so wird dabei ein neues Blatt für sufj in extensionj in phasei erzeugt, was in den nachfolgenden Phasen immer Regel 1 impliziert bei extensionj . Man fügt also bei dem Blatt ebenfalls gleich das gesamte Suffix des Textes w ein und überspringt dann diese Extension in den folgenden Phasen. Desweiteren werden noch andere Tricks genutzt. Es gilt: Regel 1 tritt vor Regel 2 auf für alle Extensionen in einer Phase. Genauso gilt: Regel 2 tritt vor Regel 3 auf in allen Extensionen in einer Phase. Somit merkt man sich, falls Regel 1 oder 2 auftritt in welcher Extension man ist und beginnt in den folgenden Phasen mit der nachfolgenden Extension. Falls dabei nicht Regel 3 zum Zuge kam, fängt man wieder in der Extension bei der Wurzel an zu suchen. Nutzt dann aber in den nachfolgenden Extensionen die Suche mittels Suffix-Zeiger. Ist hingegen Regel 3 aufgetreten, so verbleibt man an dem Knoten, und beginnt in der nachfolgenden Phase i + 1 in der selben Extension mit der Suche nach dem i + 1-ten Zeichen des Textes ausgehend von dem Knoten. Dabei kann wieder Regel 3 auftreten oder aber Regel 2 und man fügt schließlich das Suffix ein. Insgesamt erhält man eine lineare Laufzeit O(|w|). Zu den theoretischen Grundlagen, sowie Beweise für die Korrektheit, siehe [Sp03]. Fängt man mit einem Baum an, der das erste Suffix des Textes enthält und beginnt direkt in extension2 , so erspart man sich mit obigen Tricks, dass Regel 1 überhaupt eintritt. Es müssen somit nur noch Regel 2 und 3 abgearbeitet werden, die nach obiger Beschreibung sehr schnell vollführt werden. Die Navigation erfolgt dabei analog zu dem Algorithmus von McCreight, man sucht also beginnend von headi nach headi+1 , mit Ausnahme der ersten Extension einer Phase ohne das vorher Regel 3 angewandt wurde, dort beginnt man immer an der Wurzel. Bei der Navigation ist auch hier immer der Suffix-Zeiger zu setzen, um sie später nutzen zu können. 12 2.6 Schematische Darstellung Ukkonen-Algorithmus 13 3 Die Datenstruktur Suffixbaum Für eine optimale Laufzeit und einen möglichst geringen Speicherverbrauch ist eine möglichst effiziente Datenstruktur für den Suffixbaum notwendig. Dabei hat sich herausgestellt, dass insbesondere der Speicherverbrauch das Hauptproblem bei Suffixbäumen darstellt. Es werden hier zwei Datenstrukturen für den Suffixbaum dargestellt, sowie eine geschätzte Berechnung für den Speicherverbrauch auf Basis des Worst-Case-Falls mit 2·|w|−1 Knoten (siehe [Op05]) durchgeführt. Es ist dabei zu sagen, dass beide Datenstrukturen relativ weit vom Optimum entfernt sind. Eine sehr effiziente Implementierung ist sehr aufwendig, da viele Tricks genutzt werden, um den Speicherverbrauch in den Griff zu bekommen teilweise auch zu Lasten der Laufzeit. Hoch effiziente Datenstrukturen benötigen nach heutigem Wissen ca. 8.5 Bytes pro Zeichen Text im Durchschnitt und 12 Bytes pro Zeichen Text im Worst-Case-Fall, indem sie redundante Informationen aus dem Suffixbaum entfernen (siehe [Ku03]). Diese effizienten Datenstrukturen füllen teilweise Doktorarbeiten und sind deshalb vom Aufwand zu hoch, um in einer Studienarbeit behandelt zu werden. Deshalb beschränke ich mich auf die beiden implementierten Datenstrukturen: der generische Suffixbaum sowie der binäre Suffixbaum. Diese stellen soweit einen brauchbaren Kompromiss zwischen Laufzeit und Speicherverbrauch dar, da fast alle Zugriffsmethoden, die von den Algorithmen verwendet werden, eine Laufzeit von O(1) haben. 3.1 Generischer Suffixbaum Der generische Suffixbaum stellt soweit die intuitivste Datenstruktur dar. Die Datenstruktur des Suffixbaums habe ich dabei als Typ suffixtree definiert. Die folgende Darstellung wird in C-Syntax stattfinden, um eine genaue Betrachtung zu ermöglichen. suffixtree ist dabei von folgender Gestalt: Listing 1: suffixtree struct s u f f i x t r e e { text t ∗ text ; struct s u f f i x t r e e n o d e ∗ r o o t ; }; text t ist dabei standardmäßig vom Typ char, kann aber z.B. für Unicode über andere Datentypen definiert werden. suffixtreenode ist dabei der Datentyp für einen Knoten. 14 Diese haben folgenden Aufbau: Listing 2: suffixtreenode struct s u f f i x t r e e n o d e { position t position ; t e x t p o s i t i o n tpos ; struct s u f f i x t r e e n o d e ∗ s u f f i x l i n k ; struct s u f f i x t r e e n o d e ∗ p a r e n t ; struct s u f f i x t r e e n o d e ∗ ∗ c h i l d r e n ; }; position t ist dabei ein Integerdatentyp, der bei position die Nummer des Knotens darstellt, also die Zahl i bei sufi falls vorhanden. textposition ist dabei eine struct die zwei Elemente vom Typ position t enthält (begintext und endtext). Diese stellen die Indizes dar, die für die Edge-label Kompression des Textes verwandt werden. suffixlink ist der Suffix-Zeiger eines Knotens. parent verweist auf den Elternknoten des Knotens falls vorhanden. children ist ein Array von Zeigern auf Töchterknoten falls vorhanden. Man erkennt, dass diese Darstellung eine eins zu eins Abbildung eines beliebigen Baumes ermöglicht. Allerdings ist der Speicherverbrauch recht hoch, da bei children einerseits ein Array von Zeigern allokiert werden muss, sowie die Töchterknoten noch zusätzlich Speicher benötigen. Nehmen wir an, Zeiger hätten eine Größe von 4 Byte genauso wie Integer. Dann ergibt sich somit im Worst-Case-Fall mit 2 · |w| − 1 Knoten und der Annahme von 2 Töchterknoten pro Knoten einen Speicherverbauch von: Textgröße + Wurzelzeigergröße +|w|· Blattgröße +(|w| − 1)· innere Knotengröße = |w| + 4 + |w| · (4 + 2 · 4 + 4 + 4 + 4) + (|w| − 1) · (4 + 2 · 4 + 4 + 4 + 2 · 2 · 4) 32 Bytes = 61 · |w| − 32 Bytes für den gesamten Text w. Dies macht 61 − |w| pro Zeichen. Die knapp 61 Bytes pro Zeichen sind natürlich weit vom Optimum entfernt, so dass sich diese Datenstruktur nur für relativ kleine Texte eignet. 15 3.2 Binärer Suffixbaum Als Alternative zum generischen Suffixbaum bietet sich der binäre Suffixbaum an, dabei werden nicht sämtliche Töchterknoten einem Knoten über children zugewiesen, sondern jeder Knoten besitzt zwei Zeiger vom Typ suffixtreenode, wobei ein Zeiger aufs erste Kind zeigt (child ) und der zweite auf den nächsten Geschwisterknoten (sibling). Somit kann jeder Baum durch einen binären Baum äquivalent repräsentiert werden. Dadurch spart man sich die extra Speicherallokierungen für den Zeiger auf die Töchterknoten-Zeiger. Bei einer Annahme, wie oben, von 4 Byte für Zeiger- und Integerspeichergröße ergibt sich für den Worst-Case-Fall von 2 · |w| − 1 Knoten: Textgröße + Wurzelzeigergröße +(2 · |w| − 1)· Knotengröße = |w| + 4 + (2 · |w| − 1) · (4 + 2 · 4 + 4 + 4 + 4 + 4) = 57 · |w| − 24 24 Bytes pro Zeichen Text. Damit ergibt sich gegenüber dem Dies macht 57− |w| generischen Suffixbaum immerhin eine Ersparnis von 4 Bytes Pro Zeichen für große Texte w. 16 4 Die Implementierung Die Implementierung der Algorithmen sowie der Datenstrukturen erfolgte in C. Aus Testgründen für die Datenstrukturen wurde auch das naive Verfahren implementiert, da es sehr leicht implementierbar ist und damit sich schnell Testfälle für die komplizierteren Algorithmen von McCreight bzw. Ukkonen ergeben. Dabei wurden die Datenstrukturen abstrakt implementiert, wodurch die Algorithmen nur durch Methoden auf die Datenstrukturen zugreifen können. Dies erlaubt eine Implementierung der Algorithmen unabhängig von den Datenstrukturen, so dass ein einfacher Wechsel der Datenstrukturen möglich ist. Dabei nehmen die Algorithmen an, dass eine Datenstruktur Suffixbaum (suffixtree im Code genannt) einmal den Text als Datentyp text t* enthält sowie den Wurzelknoten root. Knoten sind dabei vom Typ suffixtreenode. Insbesondere können die Algorithmen über get-Methoden die Elternknoten, Töchterknoten sowie Suffix-Zeiger erhalten. Der in Kanten repräsentierte Text wird dabei dem Knoten zugewiesen, zudem die Kante führt, und ist vom Typ textposition. textposition ist dabei ein Tupel aus zwei Elementen vom Typ position t, die jeweils ein Index in dem als Array aufgefassten Text darstellen. Um eine andere Datenstruktur einzubinden, sollte das include für datastructures/suffixtree bin base.h in suffixtreealgos.c geändert werden. Dabei müssen sämtliche Methoden und Datentypen, die in suffixtree bin.h, suffixtree bin.c und suffixtree bin base.h in datastructures zu finden sind, implementiert bzw. definiert werden. Damit ist ein recht unkomplizierter Wechsel der Datenstruktur möglich und somit eine Optimierung dieser denkbar ohne die Algorithmen anpassen zu müssen. Genauere Informationen zur Implementierung sind dem Quellcode zu entnehmen, sowie aus den generierten Dokumentationen von Doxygen (make docs). Doxygen generiert dabei aus dem Quelltext sowie den Kommentaren eine recht gute, Javadoc sehr ähnliche Dokumentation über die Dateien, Datentypen und Methoden. 17 5 5.1 Benchmarks und Laufzeitfaktorabschätzung Die Benchmarkumgebung Die Benchmarks wurden auf einem Gentoo Linux System durchgeführt. Dabei liefen nur der Linux Kernel, udev und syslog-ng, um möglichst Einflüsse durch andere Programme zu minimieren. Als Hardware standen folgende Komponenten zur Verfügung: CPU: Athlon XP 2600+ (1.9 GHz) RAM: 2x512MB DDR-SDRAM als Dual-Channel genutzt Chipsatz: VIA KT880 Auf Softwareseite dürften folgende Komponenten von Interesse sein: Kernel: Linux 2.6.15 (gentoo-sources-2.6.15-r1) Lib-C: glibc-2.3.5 (glibc-2.3.5-r3) binutils: binutils-2.16.1 Compiler: gcc-3.4.5 (gcc-3.4.5-r1) Das Programm für die Benchmarks suffixtreealgos wurde mit den Compileroptionen (CFLAGS) -s -ansi -pedantic -Wall -D FILE OFFSET BITS=64 -O3 -fomit-frame-pointer -funroll-loops -pipe übersetzt. Die Benchmarks wurden mithilfe des Makefiles gestartet (make benchmark ). Als Datenstruktur wurde der binäre Suffixbaum verwandt. 5.2 Das Benchmarkverfahren Im Verzeichnis benchmark des Projektverzeichnisses liegen die einzelnen Benchmarks als txt-Datei. Diese beinhalten einen präfixfreien Text, für den der Suffixbaum erstellt werden soll. Das benchmark-Verzeichnis enthält auch das Script benchmarksuite zum starten des Benchmarks, was von make benchmark verwendet wird. Die Benchmarkergebnisse werden dabei auf den Standardausgabekanal stdout ausgegeben. Sämtliche Suffixbäume für die txtDateien werden einmal mit dem McCreight- und einmal mit dem UkkonenAlgorithmus erstellt. Beim Benchmarkvorgang werden dabei für jede txtDatei 11 Iterationen durchgeführt, wobei nur die letzten 10 in die Messungen einfließen. Dies wird getan, damit das Betriebssystem bei der ersten Iteration den Hauptspeicher freiräumt und dabei andere Prozesse in den Swap verdrängt, da dies sonst die Messungen stark verfälschen würde. Danach wird die Zeit für die übrigen 10 Iterationen sekundengenau gemessen und durch 10 geteilt, um das arithmetische Mittel für eine Iteration zu erhalten. 18 Dies dient lediglich der Minimierung von Schwankungen und Rauschen des Messprozesses. 5.3 Die Benchmarks Es gibt drei Typen von Benchmarks im benchmark-Verzeichnis. Einmal ist dies der xMio-a.txt-Test, dabei steht x für eine natürliche Zahl zwischen 1 und 11. Es wird dabei ein präfixfreier Text verwandt, der nur aus x Millionen a’s besteht und einem $ am Ende, um die Präfixfreiheit zu garantieren. Diese Tests stellen den Worst-Case-Fall dar, was die Anzahl an Knoten betrifft. Ebenfalls werden anhand dieser Tests die Laufzeitfaktorabschätzung durchgeführt. Aufgrund ihres kleinen Alphabets (es enthält nur das a) werden sie sehr schnell vom McCreight- und Ukkonen-Algorithmus durchgeführt, da hier die Suffix-Zeiger optimal zur Geltung kommen. Der zweite Benchmarktyp ist ein aus zufälligen Zeichen generierter präfixfreier Text. Er wurde durch Auslesen aus /dev/urandom erzeugt und mithilfe von uuencode in lesbare Zeichen umgewandelt. Durch ein $ am Ende des Textes erlangt er seine Präfixfreiheit, da sämtliche Vorkommen von $ vorher durch ein Leerzeichen ersetzt wurden. Der Test ist wie auch der xMioa.txt-Test nach Textlänge aufgebaut und befindet sich in den Dateien xMioRandom.txt. Dieser Benchmarktyp zeichnet sich durch ein recht großes Alphabet aus, in dem er faßt die komplette Bandbreite eines char ausnutzt. Außerdem sind die Zeichen möglichst gleich verteilt über dem Text durch Nutzung von /dev/urandom. Dies minimiert die Nutzungsmöglichkeit von Suffix-Zeigern, da viele vollkommen verschiedene head für die Suffixe existieren, die viele Verzweigungen bei den Knoten forcieren. Der Baum ist somit relativ flach und breit. Auch dieser Benchmark wird für die Laufzeitfaktorabschätzung herangezogen. Als letzter Benchmarktyp kommt ein realistischer Fall zur Geltung, nämlich ein Fall aus der Praxis. Es wird ein Suffixbaum aus der DNA von Chromosom 12 von Saccharomyces cerevisiae erstellt. Da Suffixbäume insbesondere in der Bio-Informatik genutzt werden, um z.B. DNA-Vergleiche durchzuführen, gibt dieser Test eine realistische Möglichkeit an, die Laufzeit in solchen Bereichen festzustellen. Der Text zeichnet sich durch ein relativ kleines Alphabet aus (nur A,G,C,T) und enthält ein $ am Ende des Textes, um auch hier die Präfixfreiheit zu gewährleisten. Der Text ist dabei 1078175 Zeichen lang. Auch hier spielen Suffix-Zeiger in der Regel ihre Stärken aus, was zu relativ geringen Laufzeiten führt. 19 5.4 Die Benchmarkergebnisse Zunächst erst einmal die Messergebnisse für den xMio-a.txt-Test. Es ist zu erkennen, dass die Laufzeit für 1 Millionen a’s lediglich 0.5 Sekunden benötigt. Für jede weitere Million a’s werden ca. zusätzliche 0.5 Sekunden in etwa benötigt. Es ergibt sich somit ein Laufzeitfaktor von 0.5 · 10−6 Sekunden, sprich die Laufzeit beträgt 0.5 · 10−6 · |w| Sekunden für den McCreightAlgorithmus. Hierbei ist sehr gut die lineare Laufzeit des Algorithmus zu erkennen. Da dieser Benchmarktyp jedoch stark durch Suffix-Zeiger profitiert, ist diese Abschätzung sehr optimistisch. 20 Der Ukkonen-Algorithmus zeigt hierbei eine fast identische Laufzeit, was nachfolgende Messergebnisse zeigen. Auch hierbei ist ein Laufzeitfaktor von 0.5 · 10−6 zu erkennen, der zur selben Laufzeit von 0.5 · 10−6 · |w| wie beim McCreight-Algortihmus führt. Ebenfalls profitiert dieser Algorithmus sehr stark von Suffix-Zeigern, so dass sich diese optimistische Laufzeit einstellt. 21 Für den xMio-Random.txt-Test ergaben sich folgende Messwerte für den McCreight-Algorithmus: Hierbei zeigt sich die relativ gute Gleichverteilung der lesbaren Zeichen eines char, indem die Laufzeit relativ schlecht ist, da Suffix-Zeiger hier weniger stark genutzt werden können. Es ergibt sich ein Laufzeitfaktor von ungefähr 12.5·10−6 Sekunden und somit eine Laufzeit von 12.5 · 10−6 · |w| Sekunden. Diese Eingabedaten sorgen somit für eine ungefähr 25 mal langsamere Laufzeit als beim xMio-a.txt-Test. Dieser Fall kann so ziemlich als Worst-Case-Fall angesehen werden und dürfte in der Praxis sehr selten auftreten. 22 Beim Ukkonen-Algorithmus zeigen sich fast die selben Messwerte, die nur um wenige Zehntelsekunden von den Messergebnissen des McCreightAlgorithmus abweichen: Auch hier wird ein Laufzeitfaktor von ungefähr 12.5 · 10−6 Sekunden ausgemacht, so dass sich die selbe Laufzeit von 12.5 · 10−6 · |w| Sekunden ergibt. Eine Abschätzung des Laufzeitfaktors für den durchschnittlichen Fall könnte das arithmetische Mittel von dem xMio-a.txt-Test und dem xMio-Random.txtTest darstellen, da beide Benchmarks den Best- bzw. Worst-Case-Fall betrachten. Es ergibt sich somit ein Laufzeitfaktor von: 0.5+12.5 · 10−6 = 6.5 · 10−6 Sekunden und damit eine Laufzeit von 6.5 · 10−6 · |w| 2 Sekunden. 23 Beim Saccharomyces cerevisiae DNA Benchmark ergab sich eine Laufzeit von 1.6 Sekunden beim McCreight-Algorithmus und 1.7 Sekunden beim Ukkonen-Algorithmus. Beide haben also mit Blick auf die Messgenauigkeit in etwa die selbe Laufzeit. Es zeigt sich, dass das Erstellen eines Suffixbaumes für DNA-Text vergleichsweise effizient ist, da ein relativ kleines Alphabet vorhanden ist. Wenn man diese Laufzeit ohne Berücksichtigung des Speicherverbrauchs und eventuelle Laufzeitverzerrungen durch Speicherzugriffe hochrechnet auf den geschätzten 3 Milliarden Zeichen umfassenden Text der menschlichen DNA, so würde sich 3·109 · 1.6 Sekunden ≈ 4452 Sekunden eine Laufzeit von 1078175 = 1 Stunde 14 Minuten 12 Sekunden ergeben. Der Suffixbaum ist natürlich meist nur einmal aufzubauen und kann dann für weitere Verfahren wie String Matching verwendet werden. Somit wären die Verfahren in ihrer jetzigen Implementierung schon recht vernünftig in der Praxis nutzbar. Lediglich der Speicherverbrauch müsste durch effizientere Datenstrukturen gesenkt werden, was aber in der Regel zu einer höheren Laufzeit führen würde. 5.5 Fazit Es zeigt sich in den Benchmarks, dass der McCreight-Algorithmus genauso schnell operiert wie der Ukkonen-Algorithmus. Beide haben eine lineare Laufzeit und es ergab sich ein Laufzeitfaktor von im Mittel 6.5 · 10−6 Sekunden auf dem Testsystem. 24 6 Benutzung des Programms Um die Algorithmen und Datenstrukturen testen zu können, wurden sie in einem Programm suffixtreealgos eingebunden. Dies kann sehr leicht über die Kommandozeile genutzt werden. Dabei können folgende Optionen genutzt werden: -h: Damit läßt sich die Hilfe des Programms anzeigen, die die einzelnen Optionen erklärt. -i <DATEINAME>: Mit dieser Option übergibt man dem Programm die Eingabedatei, die zum Auslesen des Textes verwendet wird, um den Suffixbaum auf dieser Basis zu erstellen. Dabei ist darauf zu achten, dass jedes Byte als ein Zeichen verstanden wird (im Fall text t = char = 1 Byte), somit auch newline-Zeichen am Ende des Textes mitgelesen werden. Ebenfalls ist auf die Präfixfreiheit des Textes zu achten, da sonst das naive Verfahren und der McCreight-Algorithmus versagen. Gute Beispiele befinden sich im test- und im benchmark -Verzeichnis. -m <ALGORITHM>: Hiermit läßt sich das Verfahren für die Suffixbaumerstellung bestimmen. Zur Auswahl stehen naive für das naive Verfahren, sowie mccreight und ukkonen für die beiden anderen Algorithmen. -b: Diese Option unterdrückt die Ausgabe, um bei Benchmarks lediglich die Zeit für den Aufbau des Suffixbaumes sowie dessen Speicherfreigabe zu messen. Dadurch gibt es weniger Verzerrungen bei der Laufzeitmessung. Das Programm geht folgendermaßen vor. Erst liest es die per -i Option übergebene Datei ein und erstellt den Suffixbaum mithilfe des mit -m spezifizierten Algorithmus. Danach gibt es den Suffixbaum als String kodiert aus, falls nicht -b gesetzt ist. Die Ausgabe hat dabei folgendes Format: ( [Node: <KNOTENNUMMER>, Edgetext: <KANTENTEXT> (KIND 1) ... (KIND M) ) Dabei sind KIND 1 bis KIND M genauso kodiert mit ( [Node: <KNOTENNUMMER>, Edgetext: <KANTENTEXT>] ... ). Die Darstellung ist somit eindeutig und unterliegt nicht den Nachteilen manch anderer Darstellung mit Pfeilen, die bei großen Suffixbäumen aufgrund relativ kleiner Bildschirmbreiten schnell einen unschönen Zeichensalat fabrizieren. Desweiteren funktioniert sie für beliebig lange Texte und ist nach einiger Eingewöhnung schnell in eine für Menschen besser lesbare graphische 25 Baumdarstellung konvertierbar. 6.1 Beispiel suffixtreealgos -i test/test01.txt -m mccreight liest die Datei test01.txt im Ordner test ausgehend vom aktuellen Ordner ein und erstellt den Suffixbaum aus diesem Text mithilfe des McCreight-Algorithmus. Bei Annahme, dass dies im Projektordner dieser Studienarbeit geschehen ist, ergibt sich folgende Ausgabe: Output of suffixtree as string: ( [ Node: <none>, Edgetext: <none> ] ( [ Node: <none>, Edgetext: a ] ( [ Node: <none>, Edgetext: b ] ( [ Node: 1, Edgetext: aab$ ] ) ( [ Node: 4, Edgetext: $ ] ) ) ( [ Node: 3, Edgetext: ab$ ] ) ) ( [ Node: <none>, Edgetext: b ] ( [ Node: 2, Edgetext: aab$ ] ) ( [ Node: 5, Edgetext: $ ] ) ) ( [ Node: 6, Edgetext: $ ] ) ) Dies ist die Stringdarstellung des Suffixbaumes für den Text abaab$. 6.2 Makefile-Erläuterungen Das Makefile befindet sich im Projektverzeichnis. Mithilfe dessen ist es leicht das Programm suffixtreealgos zu compilieren, zu testen und zu benchmarken. Durch Aufruf von make <OPTION> wird die gewünschte Aktion durchgeführt. Auf nicht GNU-Systemen ist eventuell statt make gmake aufzurufen. Falls keine Option übergeben wird, wird standardmäßig das Programm suffixtreealgos compiliert. Folgende Optionen stehen zur Auswahl: help: Dies führt zur Ausgabe einer kleinen Hilfe, die alle möglichen Optionen nennt. suffixtreealgos: Mithilfe dieser Option wird das Programm suffixtreealgos compiliert. docs: Durch diese Option wird das Generieren der Dokumentation gestartet. Dabei erzeugt Doxygen eine HTML-Dokumentation mithilfe des Quelltextes und den vorgegebenen Einstellungen in doc/doxygen-conf. Die erzeugte Dokumentation landet dabei in doc/html. Sie können durch öffnen der Datei index.html im doc/html-Verzeichnis mithilfe eines Browsers eingesehen werden. 26 Desweiteren wird die Studienarbeit aus der tex-Datei suffixbaumerstellung.tex in doc/studienarbeit compiliert. Dies geschieht mithilfe von pdflatex. Die so erzeugte PDF-Datei kann in doc/studienarbeit geöffnet und eingelesen werden, z.B. mithilfe von Acrobat Reader. test: Diese Option führt zum Ausführen der testsuite, die sich in dem testVerzeichnis befindet. Dabei werden 11 Tests von jedem implementierten Algorithmus (Naiv, McCreight, Ukkonen) durchgeführt. Für jede testxy.txt-Datei wird dabei der Suffixbaum erstellt und mit resultxy.txt die Ausgabe verglichen. Bei Übereinstimmung gilt der Test als bestanden, bei Unterschieden in der Ausgabe ist der Test fehlgeschlagen. benchmark: Bei dieser Option wird die Benchmarksuite gestartet. Nähere Informationen dazu befinden sich im Kapitel Benchmarks und Laufzeitfaktorabschätzung. clean: Diese Option führt zum Aufräumen des Projektverzeichnisses. Dabei werden alle generierten Dateien gelöscht bis auf die PDF-Datei der Studienarbeit. Um eventuelle Anpassungen für systemspezifische Konfigurationen vorzunehmen, kann das Makefile editiert werden. Am wichtigsten dürfte hier die Wahl des Compilers sein. Dieser kann durch die CC-Variable gesetzt werden. Diese Variable enthält dabei das auszuführende Programm, so wie man es in der shell aufrufen würde. Die CFLAGS-Variable gibt dabei an welche Optionen an den Compiler zu übergeben sind. Die DOXYGEN-Variable legt die auszuführende Datei für das Programm Doxygen fest. Ebenso legt die PDFLATEX-Variable die auszuführende Datei für pdflatex fest. Falls Doxygen oder pdflatex nicht auf dem System vorhanden sind, können die entsprechenden Zeilen bei docs: auskommentiert werden. Dies kann mit dem Zeichen # am Anfang der Zeile erfolgen. Für weitere oder detailliertere Informationen empfiehlt sich [Make]. 27 Literatur [Op05] OPITZ, Heike: Suffix-Bäume und verwandte Datenstrukturen, Diplomarbeit, http://www-psue.informatik.uni-hannover.de/forschung/diplomarbeiten/opitz.pdf, 28.09.2005 [Sp03] SPERSCHNEIDER, Volker: Algorithmus von Ukkonen zur Konstruktion von Suffixbäumen, Skript zur Bioinformatik WS 03/04, http://www.inf.uos.de/theo/veranst/bioinf0304/SuffixTreesUkkonen.pdf, 23.10.2003 [Ku03] GRIEGERICH, R.; KURTZ, S.; STOYE, J.: Efficient implementation of lazy suffix trees, Software - Practice and Experience, http://www.zbh.uni-hamburg.de/staff/kurtz/papers/GieKurSto2003.pdf, 25.06.2003 [Make] SMITH, P.: GNU ’make’, Free Software Foundation, Inc. http://www.gnu.org/software/make/manual/ 03.04.2006 28