Diskrete Modellierung Wintersemester 2016/17 Martin Mundhenk Uni Jena, Institut für Informatik 12. Februar 2017 4.4 Symboltabellen Eine Symboltabelle assoziiert Werte mit Schlüsselwörtern. § Ein Adressbuch, in dem Namen (Schlüsselwörter) Adressen, Telefonnummern und Geburtstage (Werte) zugeordnet werden, ist ein Anwendungsbeispiel dafür. § Eine Konkordanz, die zu einem Buch die Liste der vorkommenden Wörter mit den Seitennummern enthält, auf denen sie vorkommen, ist ein weiteres Beispiel. § Eine Suchmaschine im Web benutzt letztlich eine Konkordanz für Internetseiten. Symboltabellen sind von grundlegender Bedeutung beim Programmieren. Wir werden uns ansehen, wie man sie implementieren kann. In Python gibt es mit dem Datentyp dict (dictionary) eine ausgefeilte Implementierung. Eine API für SymbolTable Für Symboltabellen braucht man Operationen zum Eintragen von Schlüssel-Wert-Paaren, zum Abfragen des Wertes, der zu einem Schlüssel gehört, und zum Iterieren über alle Schlüssel. Operation SymbolTable() Beschreibung eine neue (leere) Symboltabelle st[key] = val trage zum Schlüssel key den Wert val in die Symboltabelle st ein st[key] der Wert von key in der Symboltabelle st key in st gibt True zurück, falls st einen Wert mit Schlüssel key enthält for key in st: iteriere über alle Schlüssel in der Symboltabelle st 4.4.2 csvToSt.py – Nachschlagen in der SymbolTable Die folgende Funktion csvToSt liest eine csv-Datei und trägt aus jeder Zeile zwei durch ihre Indizes vorgegebene Einträge als Schlüssel/Wert-Paar in eine Symbol-Tabelle ein. Die erstellte Symboltabelle wir als Ergebnis zurückgegeben. Der Test-Client liest einen Dateinamen und zwei Indizes von der Kommandozeile. Damit wird die Funktion csvToSt aufgerufen. Anschließend werden strings von stdio eingelesen. Jeder String wird als Schlüssel aufgefasst und der zugeordnete Wert in der Symbol-Tabelle wird ausgegeben. def csvToSt(dateiname,schluesselIndex,werteIndex): eingabe = InStream(dateiname).readAllLines() st = SymbolTable() for zeile in eingabe: eintraege = zeile.split(',') schluessel = eintraege[schluesselIndex] wert = eintraege[werteIndex] st[schluessel] = wert return st #-----------------------------------------------------------------def test(): eingabedatei = sys.argv[1] schluesselIndex = int(sys.argv[2]) werteIndex = int(sys.argv[3]) st = csvToSt(eingabedatei, schluesselIndex, werteIndex) text = "" stdio.write("Eingabe: ") while not stdio.isEmpty(): m = stdio.readString() if m in st: text += st[m] + " " stdio.writeln("Ausgabe: " + text) #-----------------------------------------------------------------if __name__=='__main__': test() 4.4.4 Datei morsecode.csv: A,.B,-... C,-.-. D,-.. E,. F,..-. G,--. H,.... I,.. J,.--K,-.L,.-.. M,-N,-. O,--P,.--. Q,--.. . . 7,--... 8,---.. 9,----. Wir benutzen csvToSt.py, um Zeichenfolgen in Morse-Code zu übersetzen und umgekehrt. $ python csvToSt.py morsecode.csv 0 1 Eingabe: P Y T H O N Ausgabe: .--. -.-- - .... --- -. $ python csvToSt.py morsecode.csv 1 0 Eingabe: .--. -.-- - .... --- -. Ausgabe: P Y T H O N index.py – Erstellung eines Index Das Programm index.py liest eine Text-Datei ein und berechnet eine Index der Wörter in der Datei. D.h. für jedes Wort wird eine Liste geführt, an welcher Stelle im Text es vorkommt. Bei Büchern steht im Index, auf welchen Seiten welches Wort vorkommt. Wir fassen hier den Text als Array von Strings auf, die durch Leerzeichen getrennt sind. Der Index enthält dann für jeden String alle Indizes, unter denen er im Array vorkommt. Wir benutzen als Datenstruktur OrderedSymbolTable. Die Iteration darüber wird in der Ordnung der Schlüssel durchgeführt. 4.4.6 import sys, stdio reload(sys) sys.setdefaultencoding('utf-8') from instream import InStream from bst import OrderedSymbolTable def indiziere(dateiname,minLaenge=5): eingabewoerter = InStream(dateiname).readAllStrings() index = OrderedSymbolTable() for i in range(len(eingabewoerter)): w = eingabewoerter[i] if len(w)>=minLaenge: if w not in index: index[w] = [] index[w] += [i] return index def indexausgabe(index,minVorkommen=10): for wort in index: if len(index[wort]) >= minVorkommen: stdio.writeln(wort + ": " + str(index[wort])) #-----------------------------------------------------------------def test(): index = indiziere(sys.argv[1],int(sys.argv[2])) indexausgabe(index,int(sys.argv[3])) #-----------------------------------------------------------------if __name__=='__main__': test() 4.4.7 Datei Grundgesetz.txt: ... Die Grundrechte Art 1 (1) Die Würde des Menschen ist unantastbar. Sie zu achten und zu schützen ist Verpflichtung aller Gewalt. (2) Das Deutsche Volk bekennt sich darum zu unverletzlichen und unveräußerlichen Menschenrechten a Grundlage jeder menschlichen Gemeinschaft, des Friedens und der Gerechtigkeit in der Welt. (3) Die nachfolgenden Grundrechte binden Gesetzgebung, vollziehende Gewalt und Rechtsprechung als unmittelbar geltendes Recht. Art 2 Ein Service des Bundesministeriums der Justiz und für Verbraucherschutz in Zusammenarbeit mit der juris GmbH - www.juris.de - Seite 2 von 49 ... $ python index.py Grundgesetz.txt 5 5 (Artikel: [2704, 2716, 2721, 2756, 2763, 2777, 2783, 2789, 2793, 2800, 2804, 2809] Absatz: [1407, 1579, 1596, 1639, 1683, 1959, 1969, 1976, 2303] Artikel: [29, 151, 1043, 1048, 2214, 2955] Bundesministeriums: [305, 729, 1240, 1840, 2378, 2906] Deutschen: [198, 923, 960, 1150, 1262] Entschädigung: [2145, 2148, 2166, 2198, 2212] Freiheit: [226, 364, 454, 461, 526, 576, 1372, 1658, 2771] Gefahr: [1199, 1703, 1798, 1822, 1864, 1940, 2016] Gesetz: [388, 948, 1119, 1162, 1277, 1417, 1546, 1608, 1670, 1735, 2133, 2269, 2532, 2842, 2853, 2 Gesetzes: [32, 377, 637, 953, 1093, 1167, 1282, 1422, 1551, 1613, 1675, 2026, 2138, 2242, 2847] Grund: [130, 375, 635, 951, 1091, 1165, 1280, 1420, 1549, 1611, 1673, 1750, 1835, 2024, 2136, 2179 4.4.8 Implementierung von SymbolTable (1) Sehr einfache Idee: Nimm zwei Arrays keys und values. Zum Eintragen eines neuen Schlüssel/Wert-Paares s und v wird keys += [s] und values += [v] ausgeführt. Beim Suchen nach dem Wert, der einem Schlüssel zugeordnet ist, wird das keys-Array nach dem Schlüssel durchsucht. Der Index i des Schlüssels in keys liefert dann den Wert unter values[i]. Grober Nachteil: zur Suche nach dem Wert für einen Schlüssel muss das ganze keys-Array durchsucht werden: das hat lineare Rechenzeit . . . 4.4.9 class SymbolTable: def __init__(self): self._keys = [] self._vals = [] def __getitem__(self,key): for i in range(len(self._keys)): if self._keys[i] == key: return self._vals[i] return None def __setitem__(self,key,value): for i in range(len(self._keys)): if self._keys[i] == key: self._vals[i] = value return self._keys += [key] self._vals += [value] 4.4.10 Implementierung von SymbolTable (2) Die Funktion hash liefert zu jedem string einen Zahlenwert. Man benutzt diesen Zahlenwert als Index des Schlüssels. Der Schlüssel wird dann im Array keys unter dem Index gespeichert, und der zugehörige Wert im Array values unter dem gleichen Index. Damit die Indizes in einem vernünftigen“ Bereich bleiben, ” nimmt man die Hashwerte modulo einem vorgegebenen Wert m. Jeder Eintrag in keys und values ist ein Array. Wird zwei verschiedenen Schlüsseln der gleiche Index zugeordnet (Kollision), dann wird das neue Schlüssel/Wert-Paar an diese Arrays angehängt (wie unter (1)). Die Hoffnung“ ist, dass es nur wenige Kollisionen gibt. ” 4.4.11 class SymbolTable: #-- Construct a new SymbolTable object. -------------------def __init__(self, m=1024): self._m = m self._keys = stdarray.create2D(m, 0) self._vals = stdarray.create2D(m, 0) #-- Return the value associated with key in self. ---------def __getitem__(self, key): i = hash(key) % self._m for j in range(len(self._keys[i])): if self._keys[i][j] == key: return self._vals[i][j] raise KeyError #-- Associate key with val in self. ----------------------- def __setitem__(self, key, val): i = hash(key) % self._m for j in range(len(self._keys[i])): if self._keys[i][j] == key: self._vals[i][j] = val return self._keys[i] += [key] self._vals[i] += [val] #-- Return True if key is in self, and False otherwise. -------------------def __contains__(self, key): i = hash(key) % self._m for j in range(len(self._keys[i])): if self._keys[i][j] == key: return True return False #-- Return an iterator for self. ------------------------------------------ def __iter__(self): a = [] for i in range(self._m): a += self._keys[i] return iter(a) 4.4.13 Implementierung von SymbolTable (3) als binärer Baum Binäre Bäume sind Strukturen aus Knoten, die jeweils maximal 2 Nachfolger haben. Der linke binäre Baum ist vollständig. Die Tiefe der beiden Bäume ist 4 bzw. 5. 4.4.14 Struktur eines binären Baums Einen Knoten, der kein Nachfolger eines anderen Knotens ist, heißt Wurzel. Der linke Nachfolger der Wurzel ist Wurzel des linken Teilbaums, und der rechte Nachfolger ist Wurzel des rechten Teilbaums. 1 2 3 4 Teilbaum Bl Teilbaum Br 4.4.15 Binäre Suchbäume Ein binärer Suchbaum ist ein binärer Baum, in dem jeder Knoten einen Schlüssel enthält. Die Schlüssel haben folgende Eigenschaft: In jedem Teilbaum gilt für den Schlüssel k der Wurzel: 1. alle Schlüssel im rechten Teilbaum sind ą k und 2. alle Schlüssel im linken Teilbaum sind ă k. 20 18 32 11 35 10 5 3 20 12 8 11 3 16 19 16 42 32 40 8 45 5 12 10 42 35 18 19 45 40 4.4.16 Suchen im binären Suchbaum Zur Suche eines Schlüssels s vergleicht man ihn mit dem Schlüssel der Wurzel und wiederholt die Suche entsprechend im linken oder rechten Teilbaum. Bsp.: Suche nach Schlüssel 18. T T 22 32 11 3 32 3 16 16 18 12 22 11 18 12 20 20 22 T 11 3 32 32 11 3 16 12 22 18 16 12 20 T 18 20 4.4.17 Erfolglose Suche Sucht man nach einem Schlüssel, der nicht im Suchbaum ist, dann stößt man auf eine Lücke“. ” Beispiel: Suche nach 34. 20 32 11 3 16 8 5 12 10 42 35 18 19 45 40 4.4.18 Einfügen eines neuen Schlüssels Zum Einfügen eines neuen Schlüssels sucht man die Stelle, an der man ihn finden müsste, und hängt dort einen neuen Knoten an. Bsp.: füge Knoten mit Schlüssel 13 ein. v k k 22 32 11 3 32 11 3 16 20 3 11 k 16 12 16 18 12 20 22 32 32 v 12 20 22 11 3 18 32 20 22 v 11 18 12 22 k 3 16 18 12 v 22 k 18 32 11 3 16 v 16 18 12 20 13 20 4.4.19 Anzahl der Vergleiche beim Suchen/Einfügen Ein binärer Suchbaum mit n Knoten hat im günstigen Fall Tiefe log n und im ungünstigen Fall Tiefe n. 3 12 16 18 22 12 3 28 16 22 28 18 Die Anzahl der Vergleiche (« Rechenzeit) beim Suchen entspricht der Tiefe. 4.4.20 Suchbäume als Symboltabellen Man speichert in jedem Knoten“ zusätzliche zum Schlüssel noch den ” zugehörigen Wert. Suche und Einfügen geht wie bei binären Suchbäumen. Damit Suchen und Einfügen möglichst schnell geht, gibt es Strategien, den Baum so zu balancieren, dass seine Tiefe bei n Knoten möglichst wenig von log n abweicht. AVL-Bäume haben Tiefe ď 1.44 ¨ log n ` 1. Binäre Suchbäume haben bei zufällig gewählten aufeinanderfolgend eingefügten Schlüsseln erwartete Tiefe der Größenordnung log n. Allerdings ist der konstante Faktor etwas größer als bei AVL-Bäumen. Vergleich von Implementierungen Wir vergleichen folgende Implementierungen: § einfachst.py – einfachste Implementierung in dieser VL § hashst.py – Implementierung als Hashtabelle (diese VL) § bst buch – Impl. mit binärem Suchbaum (aus dem Buch zur VL) § bst mit – Impl. mit binärem Suchbaum (MIT OpenCourseWare) § avl mit – Impl. mit AVL-Baum (MIT OpenCourseWare) 4.4.22 Versuchsaufbau Wir machen zwei Versuche. 1. Aufbau einer Symboltabelle § § mit Einträgen, die aufsteigend sortiert sind mit zufällig gewählten Einträgen Hier machen wir den Verdopplungstest“ – also Testläufe mit jeweils ” verdoppelter Anzahl der Einträge. Der Test wird beendet, wenn mehr als 20 sec zum Aufbau der Symboltabelle benötigt wurden. 2. Anfragen an eine Symboltabelle mit zufällig gewählten Einträgen. Hier kann man beim Verdopplungstest stets doppelte Rechenzeit erwarten. 4.4.23 Ergebnisse für einfachst Zuerst betrachten wir den Aufbau der Symboltabelle. n ist die Größe der Symboltabelle. Die Rechenzeit wird in Sekunden angegeben. Hier ist es egal, ob die Einträge für Schlüssel in sortierter oder zufälliger Reihenfolge gemacht werden. n= n= n= n= 4000, 8000, 16000, 32000, Faktor=3.77, Faktor=3.87, Faktor=4.24, Faktor=4.18, Rechenzeit= 0.26 Rechenzeit= 1.02 Rechenzeit= 4.30 Rechenzeit= 17.97 Das Ergebnis zeigt, was zu erwarten war: Rechenzeit „ n2 . Für 32000 Abfragen aus einer ST mit 32000 Einträgen werden 47 sec benötigt. Ergebnisse für hashst Zuerst betrachten wir den Aufbau der Symboltabelle. Die Größe der Hashtabelle wird am Anfang des Aufbaus festgelegt. Ist sie unabhängig von der Anzahl der Einträge, dann ist die Rechenzeit „ n2 . Wählt man die Größe abhängig von der Anzahl der Einträge, wird die Rechenzeit besser. In den Experimenten haben wir bei n Einträgen die Größe auf n/8 gesetzt. Es ist wieder egal, ob die Einträge für Schlüssel in sortierter oder zufälliger Reihenfolge gemacht werden. n= n= n= n= n= n= n= 128000, 256000, 512000, 1024000, 2048000, 4096000, 8192000, Faktor=1.99, Faktor=1.98, Faktor=1.99, Faktor=2.00, Faktor=2.05, Faktor=2.05, Faktor=2.01, Rechenzeit= 0.26 Rechenzeit= 0.51 Rechenzeit= 1.02 Rechenzeit= 2.05 Rechenzeit= 4.19 Rechenzeit= 8.57 Rechenzeit= 17.26 Das Ergebnis zeigt, dass die Rechenzeit „ n ist. 512000 Abfragen aus einer ST mit 128000 Einträgen dauern 0.58 sec. Ergebnisse für bst buch Da das Eintragen in einen binären Suchbaum komplett rekursiv implementiert ist, scheitert der Aufbau bei Eingabe einer sortierten Folge von Schlüssel bereits bei 1600 Einträgen. Bei zufällig gewählten Eingaben kann man große Symboltabellen aufbauen. n= 32000, Faktor=2.06, Rechenzeit= 0.17 n= 64000, Faktor=2.26, Rechenzeit= 0.37 n= 128000, Faktor=2.85, Rechenzeit= 1.06 n= 256000, Faktor=2.02, Rechenzeit= 2.14 n= 512000, Faktor=2.26, Rechenzeit= 4.83 n= 1024000, Faktor=2.22, Rechenzeit= 10.74 n= 2048000, Faktor=2.30, Rechenzeit= 24.65 n= 4096000, Faktor=2.13, Rechenzeit= 52.55 Das Ergebnis lässt erahnen, dass die Rechenzeit „ n ¨ log n ist. 512000 Abfragen aus einer ST mit 128000 Einträgen dauern 3.25 sec. 4.4.26 Ergebnisse für bst mit Hier wurde das Eintragen in einen binären Suchbaum nicht rekursiv implementiert ist. Das Experiment für die Eingabe einer sortierten Folge von Schlüsseln lässt erahnen, dass die Rechenzeit „ n2 ist. n= n= n= n= 4000, 8000, 16000, 32000, Faktor=3.91, Faktor=3.99, Faktor=4.27, Faktor=4.28, Rechenzeit= 0.83 Rechenzeit= 3.31 Rechenzeit= 14.16 Rechenzeit= 60.56 16000 Abfragen aus einer ST mit 16000 Einträgen dauern 30 sec. 4.4.27 Die Ergebnisse werden entschieden besser, wenn die Eintäge zufällig ausgewählt werden. Das Experiment lässt erahnen, dass die Rechenzeit „ n ¨ log n ist. n= 32000, Faktor=2.32, Rechenzeit= 0.15 n= 64000, Faktor=2.30, Rechenzeit= 0.34 n= 128000, Faktor=2.36, Rechenzeit= 0.81 n= 256000, Faktor=2.34, Rechenzeit= 1.91 n= 512000, Faktor=2.25, Rechenzeit= 4.29 n= 1024000, Faktor=2.26, Rechenzeit= 9.71 n= 2048000, Faktor=2.34, Rechenzeit= 22.72 n= 4096000, Faktor=2.08, Rechenzeit= 47.25 512000 Abfragen aus einer ST mit 128000 Einträgen dauern 2.34 sec. 4.4.28 Ergebnisse für avl mit Beim AVL-Baum ist es egal, ob die Schlüssel sortiert oder zufällig gewählt werden. n= 32000, n= 64000, n= 128000, n= 256000, n= 512000, n= 1024000, Faktor=2.20, Faktor=2.09, Faktor=2.18, Faktor=2.21, Faktor=2.13, Faktor=2.13, Rechenzeit= 0.73 Rechenzeit= 1.52 Rechenzeit= 3.32 Rechenzeit= 7.32 Rechenzeit= 15.60 Rechenzeit= 33.19 Der Steigerungsfaktor ist etwas kleiner als bei den bst-Implementierungen. Um ihn zuverlässiger zu ermitteln, müste man mehr Experimente machen. Das Experiment lässt erahnen, dass die Rechenzeit „ n ¨ log n ist. 512000 Abfragen aus einer ST mit 128000 Einträgen dauern 2.54 sec. 4.4.29 Vergleich der Ergebnisse der Experimente Die einfachste Implementierung ist offensichtlich so schlecht, dass man sie nicht benutzen kann. Die Implementierung mittels Hashtabelle schneidet sehr gut ab. Wenn man abschätzen kann, wie groß die Symboltabelle wird, ist sie zu bevorzugen. Die Implementierungen mit binären Suchbäumen lohnen sich, wenn man die Größe der Symboltabelle nicht abschätzen kann. Die Implementierung mit AVL-Bäumen ist (bei zufälligen Einträgen) nicht wirklich besser als mit (unbalancierten) binären Bäumen. Der Aufwand für die Implementierung ist jedoch viel höher. 4.4.30