Gliederung 5. Compiler 1. 2. 3. 4. Struktur eines Compilers Syntaxanalyse durch rekursiven Abstieg Ausnahmebehandlung Arrays und Strings 6. Sortieren und Suchen 1. Grundlegende Datenstrukturen 2. Bäume 3. Hashing (Streuspeicherung) 7. Graphen 1. Darstellung und Topologisches Sortieren 2. Kürzeste Wege 3. Fluß- und Zuordnungsprobleme Hashing (Streuspeicherung) • Verfahren, die allgemein auf Bäume basieren garantieren einen logarithmischen Aufwand beim Datenzugriff (Suche, einfügen, löschen) • Motivation für Hashverfahren – Voraussetzung für Binäre Suchbäume: (totale) Ordnung auf der Nutzinformation – Ordnung nicht immer verfügbar: z.B. Einträge in einer Datenbank (Kundendaten etc.) • Grundidee der Hashverfahren – Datensätze werden in einem normalen Feld mit direktem Zugriff gespeichert – Die Hash-Funktion ermöglicht für jeden gespeicherten Wert den direkten Zugriff auf den Datensatz 2 Hashing (1) • Hashing (engl.: to hash=zerhacken) beschreibt eine spezielle Art der Speicherung der Elemente einer Menge durch Zerlegung des SchlüsselUniversums • Die Position des Daten-Elements im Speicher ergibt sich (zunächst) durch Berechnung direkt aus dem Schlüssel • Die Menge aller möglichen Schlüssel (der Wertebereich) sei D (Domain) • Der Speicher wird ebenfalls zerlegt in m gleich große Behälter (Buckets) • Es ist |D| sehr viel größer als m • Eine Hash-Funktion h kann nun für jeden Schlüssel s die Nummer des Buckets h(s) ∈ {0,1,...,m − 1} berechnen • Ideal wäre eine eindeutige Speicher-Zuordnung eines Datums mit Schlüssel s zum Bucket mit Nummer h(s): Einfügen und Suchen könnten in konstanter Zeit (O(1)) erfolgen. • Tatsächlich treten natürlich Kollisionen auf: Mehrere Elemente können auf die gleiche Hash-Adresse abgebildet werden. Kollisionen müssen (auf eine von verschiedenen Arten) behandelt werden. 3 Hashing: Prinzip • Schlüssel werden in einen zweiten, endlichen Bereich, mit dem eine Reihung angesteuert wird, abgebildet • Suche wird mit speziellen Verfahren durchgeführt (z.B. Reihungsindizierung, Kollisionsauflösung) Schlüssel Hashfunktion H (BereichsTransformation) Hashwert Suche Reihungsindizierung oder Kollisionsauflösung Objekt Reihungsindex oder Kollisionswert 4 Hashing: Beispiel • Daten werden in einem Feld von 0 bis 9 gespeichert • Elemente 42 und 119 werden gespeichert • Hashfunktion: h= i mod 10 Index Eintrag 0 1 2 42 3 4 Kollision für die Werte 69 und 119: 69 mod 10 -> 9 119 mod 10 -> 9 5 6 7 8 9 119 5 Hash-Funktionen Def.: Es sei D ein Wertebereich von Schlüsseln und m die Anzahl der Behälter Bo , ... , Bm-1 zum Speichern einer gegebenen Menge {e1, .. en} von Elementen (Datensätzen) Eine Hash-Funktion h ist eine (totale) Abbildung h: D → {0, .. m-1}, die jedem Schlüsselwert w ∈ D eine Nummer h(w) und damit einen Behälter Bh(w) zuordnet. Die Nummern der Behälter werden auch als Hash-Adressen und die Menge der Behälter als Hash-Tabelle bezeichnet. Wertebereich D h(w) 0 Bo 1 B1 ... ... ... ... m-1 Bm-1 Hash-Adressen Hash-Tabelle 6 Probleme beim Hashing • Menge der möglichen Schlüssel (hier: alle Wörter) sehr viel größer als die endliche Menge der Tabellenindizes – Kollisionen, d.h. zwei oder mehrere Schlüssel werden auf dieselbe Adresse abgebildet; müssen behandelt werden – Jede Strategie der Kollisionsbehandlung hat Vor- und Nachteile • Hash-Funktion h sollte die Schlüssel möglichst gleichmäßig auf den Bereich der Tabellenindizes verteilen (gute Streuung) • h soll effizient berechenbar sein (O(log M) oder O(1)). 7 Anforderungen an Hash-Funktionen • Eine Kollision tritt dann auf, wenn bei Einfügen eines Elementes mit Schlüssel s der Bucket Bh(s) schon belegt ist. Nach einer Kollision werden Operationen zur Kollisionsbehandlung durchgeführt • Eine Hash-Funktion h heißt perfekt für eine Menge von Schlüsseln S, falls keine Kollisionen für S auftreten. • Ist h perfekt und |S| = n, dann gilt: n ≤ m. • Das Verhältnis BF = # Gespeicherte Schlüssel/Größe der Hash-Tabelle = n/m bezeichnet man als Belegungsfaktor der Hash-Tabelle • Eine Hash-Funktion ist gut gewählt, wenn – der Belegungsfaktor möglichst hoch ist, – für viele Schlüssel-Mengen die Anzahl der Kollisionen möglichst klein ist, – sie leicht und effizient zu berechnen ist. 8 Hashing (2) Beispiel: Hash-Funktion für Strings public static int h (String s){ int k = 0, m = 13; for (int i=0; i < s.length(); i++) k += (int)s.charAt (i); return ( k%m ); } Folgende Hash-Adressen werden generiert für m = 13. Schlüssel h(s) Test 0 Hallo 2 SE 9 Algo 10 h wird perfekter, je größer m gewählt wird. 9 Grundoperationen auf Hash-Tabellen • Einfügen eines Datenelements – Wende Hash-Funktion h auf den Schlüssel des Datenelements an: Einfügeposition ∈ {1, ,,, m-1} in der Hash-Tabelle – Ist der Platz in der Hash-Tabelle noch frei, Datensatz dort speichern, ansonsten Kollisionsbehandlung • Löschen eines Datenelements – Wende h auf den Schlüssel des Datenelements an: Position ∈ {1, ,,, m-1} in der Hash-Tabelle – Falls Datensatz an dieser Position gefunden, Datensatz löschen, sonst je nach Kollisionsbehandlung weiter • Suchen eines Datenelements – Wende h auf den Schlüssel des Datenelements an: Position ∈ {1, ,,, m-1} in der Hash-Tabelle – Steht der Datensatz nicht auf dieser Tabellenposition, erfolgen je nach Kollisionsbehandlung Zusatzoperationen (z.B. weitere Suche) 10 Wahl der Hash-Funktion • Die Anforderungen hoher Belegungsfaktor und Kollisionsfreiheit stehen in Konflikt zueinander. Es ist ein geeigneter Kompromiss zu finden. • Für die Schlüssel-Menge S mit |S| = n und Behälter B0, . . . , Bm-1 gilt: – für n > m sind Konflikte unausweichlich – für n < m gibt es eine (Rest-) Wahrscheinlichkeit PK(n,m) für das Auftreten mindestens einer Kollision. • Abschätzung für die Wahrscheinlichkeit einer Kollision PK(n,m) – Für beliebigen Schlüssel s ist die Wahrscheinlichkeit dafür, dass h(s) = j mit j ∈ {0, . . . ,m - 1}: P[ h(s) = j ] = 1/m, falls Gleichverteilung gilt – Es gilt PK(n,m) = 1 - P¬K(n,m), wobei P¬K(n,m) die Wahrscheinlichkeit dafür ist, dass es beim Speichern von n Elementen in m Behälter zu keinen Kollisionen kommt. 11 Wahrscheinlichkeit von Kollisionen • Werden n Schlüssel nacheinander auf die Behälter B0, . . . , Bm-1 verteilt (bei Gleichverteilung), gilt jedes mal P [ h(s) = j ] = 1/m • Es sei P(i) die Wahrscheinlichkeit, dass beim Einfügen des i-ten Datensatzes in m Sektoren keine Kollision auftritt • Dann gilt: ... P(1) = 1 P(2) = (m-1) / m P(i) = (m-i+1) / m • Damit ist m(m − 1)...( m − n + 1) PK(n,m) = 1 − P(1)* P(2)*...* P(n) = 1 − mn Für m = 365 etwa ist P(23) > 50% und P (50) ≈ 97% (Geburtstagsparadoxon) 12 Hash-Funktionen in der Praxis In der Praxis verwendete Hash-Funktionen (Siehe D.E. Knuth: The Art of Computer Programming) • Sei W = integer, d.h. die Schlüsselwerte sind ganze Zahlen. Dann liefert die sog. Divisions-Rest-Methode gute Hash-Funktionen: – h(w) = (a * w) mod m (a ≠ 0, a ≠ m und m Primzahl) • Sei M eine Menge von Zeichenreihen der Form s = [s1, ... , sq] . Dann ist (neben der oben bereits genannten einfachen Funktion) k −1 i w h(s) = ∑ B si mod 2 mod m i =0 eine gute Hash-Funktion Für die Wahl von B wird der Wert 131 empfohlen – w ist die Wortbreite des Computers (also w = 32, w = 64) 13 Behandlung von Kollisionen • Die Behandlung von Kollisionen erfolgt bei verschiedenen Verfahren unterschiedlich. • Ein Datensatz mit Schlüssel s ist ein Überläufer, wenn der Behälter h(s) schon durch einen anderen Satz belegt ist. • Strategien zur Kollisionsbehandlung – Verkettung der Überläufer (Offenes Hashing): Es wird eine Liste mit den Elementen aufgebaut, die dieselbe Position belegen – Sondieren (Geschlossenes Hashing): Es wird eine alternative Position im Fall einer Kollision gesucht 14 Verkettung der Überläufer • Überläufer außerhalb der Hash-Tabelle ablegen – Z.B. als verkettete lineare Liste – Liste wird an den Hash-Tabelleneintrag angehängt, der sich durch Anwendung der Hash-Funktion auf die Schlüssel ergibt • Separate Verkettung der Überläufer – die Hash-Tabelle enthält je einen Datensatz und einen Listen-Kopf pro Behälter. – Weitere Datensätze (= Überläufer) werden in je einer Liste an den betreffenden Behälter angehängt • Direkte Verkettung der Überläufer – Die Hash-Tabelle enthält nichts als die Listen-Köpfe, alle Datensätze befinden sich in den Listen – Alle Datensätze werden in den Überlaufketten gespeichert 15 Direkte Verkettung: Beispiel • Verwendete Hash-Funktion : h(s) = (ord(s[1]) + ord(s[2]) + ord(s[3])) mod 4 • Zeichenkette: ‘‘Almodovar“ h ('Almodovar') = (65+108+109) mod 4 = (1+0+1) mod 4 = 2 0 1 Allen Jarmusch 2 Wenders Kurosawa 3 Herzog Fellini Almodovar 16 Separate Verkettung • Im Gegensatz zum Speichern durch direkte Verkettung wird pro Behälter ein Datensatz direkt in der Hash-Tabelle gespeichert. • Fallen in einem Behälter Überläufer an, so wird dafür eine Überlauf-Liste angelegt. 0 1 Allen Jarmusch 2 Wenders Kurosawa 3 Herzog Fellini Almodovar • Vergleich mit dem Speichern durch direkte Verkettung: – Überlauf-Listen werden nur bei Auftreten von Kollisionen benötigt. – enthält ein Behälter keinen Datensatz, so wird trotzdem Speicher dafür vorgehalten. 17 Separate Verkettung der Überläufer: Implementierung • Die Hash-Tabelle ist ein Array (der Länge m) von Listen. D.h. jeder Behälter der Hash-Tabelle wird durch eine Liste implementiert class hashTable { Liste [] ht; hashTable (int m){ ht = new Liste[m]; for (int i = 0; i < m; i++) ht[i] = new Liste(); } ... Konstruktor konstruiert Array mit m listen } 18 Verkettung mit selbst-organisierender Liste • Selbstorganisierend bedeutet, dass das zuletzt eingesetzte oder aufgesuchte Element an den Anfang der Liste gebracht wird. • Vorteile: Hash-Tabelle funktioniert auch noch (obwohl langsam), wenn die Größe der Hash-Tabelle zu klein gewählt war. • Nachteile: Zusätzlicher Speicherplatz für die Zeiger wird benötigt. Lange Listen können entstehen. 19 Aufwand des offenen Hashing • worst case h(s) liefert immer den gleichen Wert, alle Datensätze sind in einer Liste: Verhalten wie bei Linearer Liste. • average case – bei erfolgreicher Suche (und damit auch für den Fall des Entfernens eines Datensatzes) gilt: Aufwand ≈ 1 + 0.5 * BF – bei erfolgloser Suche (und damit auch für den Fall des Einfügens eines Datensatzes) gilt: Aufwand ≈ BF • best case Die Suche führt unmittelbar zum Erfolg: Aufwand ist O(1) 20 Geschlossene Hash-Verfahren • Geschlossenes Hashing bedeutet, dass Überläufer nicht in separaten Listen, sondern in noch freien Bereichen der HashTabelle selbst gespeichert werden. • Der Prozess der Suche nach einer geeigneten Position bezeichnet man als Sondieren • Vorgehensweise zum Speichern eines Datensatzes mit Schlüssel s: 1. Speichere an Position h(s), wenn dort frei 2. Sonst speichere an der nächsten freien Position, die durch Sondieren ermittelt wird 3. Ist kein Platz mehr frei, kann nicht gespeichert werden. 21 Sondierverfahren (1) • Beim linearen Sondieren (linear probing) wird linear und zyklisch nach dem nächsten freien Platz in der Hash-Tabelle gesucht. • Sekundärkollision: Schlüssel mit unterschiedlicher Streuadresse kollidieren • Quadratisches Sondieren – Mit quadratisch wachsendem Abstand wird nach einem freien Platz gesucht. 22 Sondierverfahren • Lineares Sondieren: Falls die Position h(e) in der Hash-Tabelle besetzt ist, prüft das Verfahren der Reihe nach die Positionen: (h(w)+1) mod m, (h(w)+2) mod m, ... • Quadratisches Sondieren: Lineares Sondieren neigt zur Clusterbildung: „Klumpen“ in denen alle Positionen bereits besetzt sind und sich dadurch lange Sondierfolgen bilden. Um dies zu vermeiden, wird die Folge der Quadratzahlen für die Sondierabstände verwendet (h(w)+1) mod m, (h(w)+4) mod m, ...,(h(w)+i 2) mod m • Double Hashing (Doppelte Streuadressierung): Die doppelte Streuadressierung soll Sekundärkollisionen verhindern h(w) - h2(w) mod m, h(w) - 2*h2(w) mod m, ..., h(w) - (m-1)*h2(w) mod m (wobei h2(w)≠ 0 und h2(w) teilerfremd zu m) Optimierungsmöglichkeit: Sondierung in beide Richtungen • • Lineares Sondieren: (h(w)+1) mod m, (h(w)-1) mod m,(h(w)+21) mod m, .. Quadratisches Sondieren: (h(w)+1) mod m, (h(w)-1) mod m, (h(w)+4) mod, (h(w)-4) mod m 23 Löschen bei geschlossenen Hash-Verfahren • Geschlossenes Hashing erfordert eine besondere Behandlung der Lösch-Operationen: Soll ein Datensatz gelöscht werden, so kann ein anderer Datensatz unerreichbar werden • Zu löschende Datensätze dürfen also nicht physisch gelöscht, sondern nur als gelöscht markiert werden. Jeder Behälter muss als "frei", "belegt" oder "gelöscht" markiert sein. • Beim Sondieren werden Behälter mit gelöschten Datensätzen wie belegte Sektoren zum "Weiterhangeln" benutzt. 24 Löschen bei geschlossenen Hash-Verfahren: Beispiel D = Integer, h(w) = w mod 7, (absteigend) lineares Sondieren 1. Einfügen von: 78, 57, 80, 16, 21 Beachte: (78 % 7 == 1), (57 % 7 == 1), (80 % 7 == 3), (16 % 7 == 2), (21 % 7 == 0), (29 % 7 == 1) 57 78 16 80 21 2. Einfügen von: 29 57 78 16 80 frei belegt 29 21 gelöscht 3. Löschen von: 57 57 78 16 80 29 21 Das Löschen von "57" würde "21" und "29" unerreichbar machen, falls nicht markiert! 25 Aufwand bei geschlossenen Hash-Verfahren • Abschätzung nur über Wahrscheinlichkeiten • Wahrscheinlichkeit einer Kollision hängt vom Füllungsgrad ab – Sei α der Anteil der belegten Buckets – Aufwand einer erfolglosen Suche: 1+ α + α2+.......=1/1-α • Beispiele – Bei einer halbgefüllten Hash-Tabelle werden im Mittel 1/1-0,5=2 Zugriffe benötigt – Bei einem Belegungsfaktor von 90% sind bereits 10 Zugriffe notwendig • Faustregel: Beim geschlossenen Hashing sollte der Belegungsfaktor nicht größer als 80% werden 26 Hybride Hash-Verfahren (1) Eine Analyse der bisher betrachteten Verfahren zeigt: • Die offenen Verfahren sind wesentlich schneller als die geschlossenen, Sondieren ist zeitaufwendig (vor allem bei hohem Belegungsfaktor) • Bei den offenen Verfahren wird Speicherplatz für die nicht belegten Sektoren verschwendet (umso mehr, je niedriger der Belegungsfaktor ist) • Das hybride hashing (engl. auch: "Coalesced hashing") versucht Vorteile beider Verfahren miteinander zu verbinden – Überläufer werden wie beim geschlossenen Verfahren innerhalb der Hash-Tabelle gespeichert, aber durch Referenzen miteinander verkettet. – Jeder Sektor enthält (potentiell) einen Datensatz und eine HashAdresse, die auf einen anderen Sektor verweist, in dem nach einem "Überläufer" zu suchen ist. 27 Hybride Hash-Verfahren (2) Die Hash-Tabelle ist jetzt ein doppeltes Array (der Länge m) (a) für die Datensätze, (b) für die Position eines möglichen "Überläufers". class hashTable { Datensätze wie bei separater Verkettung. Elem [] ht; int [] next; Zusatz-Array für Sondierziel bei Überlauf. ... } Beispiel: W = Integer, h(w) = w mod 7, Sondierziel: höchster freier Behälter: Einfügen von: 78, 57, 80, 16, 21 21 78 16 80 Einfügen von: 69 (hashAdresse 6 ist bereits belegt!) 21 78 16 80 57 next 6 6 ht 69 57 5 ht next 28 Dynamische hashverfahren (1) • Alle bisher betrachteten Verfahren hängen wesentlich vom Belegungsfaktor BF und damit von der (bisher als fest angenommenen) Länge der Hash-Tabelle ab. – Ist BF klein (etwa BF < 0.5), so wird Speicherplatz verschwendet. – Ist BF groß (etwa BF < 0.8), so werden das Einfügen und Suchen (zeit-) ineffizient. • Zeigt die Hash-Tabelle insgesamt ein stark anwachsendes Verhalten, so verschlechtert sich ihre Leistung mit wachsendem Belegungsfaktor • Dieses grundsätzliche Problem kann auf zwei Arten gelöst werden: – Globale Reorganisation der Hash-Tabelle: Wahl einer neuen HashFunktion und Umspeichern der Datensätze. (sehr aufwendig, Sperrung während der Reorganisation notwendig!) – Dynamisches Hashing: Die Hash-Tabelle wird durch kleine lokale Reorganisationen ständig an die Anzahl der Datensätze angepaßt. 29 Dynamische hashverfahren (2) • Grundidee: Beim Überschreiten eines vorgegebenen Schwellenwerts für den Belegungsfaktor BF wird ein neuer Behälter in der Hash-Tabelle (mit Hash-Adresse m) angelegt und die Datensätze des am meisten belegten Behälters werden möglichst gleichmäßig auf den bisherigen und auf den neuen Behälter aufgeteilt • Die einzelnen Verfahren unterscheiden sich im Wesentlichen darin, wie die Hash-Funktion h zu adaptieren ist – Beim sog. linearen hashing wird die Hash-Tabelle durch die Aufspaltung eines Sektors schrittweise expandiert, eine neue Expansionsstufe ist erreicht, wenn alle Sektoren aufgespalten sind und sich damit die Größe der Tabelle verdoppelt hat. Die Menge der hash-Adressen läßt sich z.B. erweitern, wenn man eine hash-Funktion h = w mod m zu h = w mod 2*m modifiziert. – Beim sog. virtuellen hashing wird der Übergang auf eine neue Expansionsstufe in einem Schritt vollzogen. 30 Hashing: Zusammenfassung • Hash-Tabellen sind in der Praxis die schnellsten Verfahren für Suchen/Einsetzen (vorausgesetzt sind natürlich gute Streufunktion und eine Tabelle, die nicht mehr als ca. 80%-90% voll ist). • Hash-Tabellen liefern keine sortierte Reihenfolge oder das Minimum oder Maximum. Es ist auch nicht möglich, den größten Schlüssel <= k oder einen Schlüssel zwischen k1 und k2 zu finden • Eine Hash-Tabelle hat i.d.R. eine feste Größe, die anders als bei den Suchbäumen nicht mit den Datenmengen wächst. Eine dynamische Mengenanpassung wird dadurch erreicht, dass ein Schwellenwert festgelegt wird, bei dem neue Behälter in die Hash-Tabelle angelegt werden (z.B. bei einer 90%-iger Füllung) • obere Schranke für Aufwand ist O(m) (im Durchschnitt nahezu konst.) 31