Disclaimer: Diese Mitschrift ist als Ergänzung zur Vorlesung gedacht. Wir erheben keinen Anspruch auf Vollständigkeit und Korrektheit. Wir sind jederzeit froh um Hinweise zu Fehlern oder Unklarheiten an grafdan@ethz.ch.
Wenn Sie an dieser Mitschrift mitarbeiten möchten, melden Sie sich hier.
Hinweis: Zum Thema Hashing gibt es eine separates, detailliertes Dokument auf der Vorlesungshomepage. Diese Mitschrift hier entspricht daher nicht genau der Vorlesung vom 4. März.
Wir haben gestern gesehen wie schnell wir nach einem Schlüssel in einer Menge von Schlüsseln suchen können: In unsortierten Arrays in
und in sortierten Arrays in
Kann es schneller gehen, wenn wir mit den Schlüsseln rechnen können und nicht nur Vergleiche machen können? Zum Beispiel für eine Studentendatenbank mit Leginummern können wir uns folgendes überlgen:
Wir machen ein Array der Grösse
1 15-123-456 100’000’000
|==============1=================|
Was ist das Problem? Dieser Array braucht riesig viel Platz. Als Verbesserungsidee: Nehmen wir an es gibt nicht mehr als
1 23-456 100’000
|==============1=================|
Das Problem jetzt: Diese Kennzahl muss nicht mehr zwingend eindeutig sein.
Ein weiteres Beispiel: Wir schreiben ein Stück Code in Java
void f(int n) {
int k;
if(n==1) {
int k; // Compiler erzeugt Fehler,
// da die Variable k doppelt vorkommt!
...
Wie merkt sich der Compiler welche Variablen schon definiert wurden?
Ziel der heutigen Vorlesung ist es eine Datenstruktur zu konstruieren, die folgende Operationen unterstützt:
Solche Datenstrukturen nennt man Wörterbücher. Wir können eine solche Datenstruktur beispielsweise mit einem Array oder mit einer linearen Liste implementieren.
Naive Lösungen:
| Verfahren | INSERT | SEARCH |
|–-|–-|–-|–-|–-|
| unsortiertes Array |
| sortiertes Array |
| lineare Liste |
Das alles ist nicht zufriedenstellend, da wir keine Operationen in linearer Zeit wollen.
Die neue Idee ist nun einen Array zu benutzen und die Adresse des Elements (also die Position im Array) direkt aus dem Schlüssel selbst zu ermitteln.
Wir definieren dazu eine Hashfunktion
0 | 1 | 2 | … | m-1 | m-1 | ||
---|---|---|---|---|---|---|---|
Solche Verfahren, die mit dem Schlüssel rechnen, um einen kleineren Kennwert zu bekommen, heissen Hashverfahren.
Im Allgemeinen ist
Sind
Bezogen auf Hashing bedeutet dies, dass es bei einer Tabelle mit 365 Einträgen und 23 eingefügten Schlüsseln bereits wahrscheinlich ist, dass es zu einer Kollision kommt.
Anforderungen an gute Hashfunktionen:
Was wollen wir von solchen Hashfunktionen? Es ist unmöglich dass es keine Kollisionen gibt, denn es gibt unendlich viele Schlüssel und nur konstant viele mögliche Hashwerte. Was wir möchten, ist dass die Hashfunktion möglichst gut, also gleichmässig, über die
Ausserdem möchten wir, dass Kollisonen effizient auflösbar sind.
Zwei populäre Hashfunktionen sind diese beiden:
Im Fall der Divisionsrestmethode sollte man für
Falls die benutzten Schlüssel
In der Realität ist das aber oft nicht möglich. Zum einen sind die Schlüssel oft nicht im Vorfeld bekannt, zum anderen sind sich die Schlüssel in
Für eine einzelne, fixe Hashfunktion können wir das unmöglich erreichen, denn wir können als „böser Gegner“ immer stets Kollisionen finden und nur solche Schlüssel abfragen. Aber wenn die Hashfunktion dem Eingaben-Ersteller nicht bekannt sind, weil wir sie zufällig aus einer grossen Menge an Hashfunktionen auswählen, kann es funktionieren.
Sei
Eine zufällig gewählte Funktion aus
Universelle Hashklassen sind also nützlich, doch bis jetzt haben wir noch keine gesehen. Nun ein Beispiel einer solchen universellen Hash-Familie: Sei
Zu zeigen, dass diese Familie eine universelle Hashfunktion ist, ist nicht ganz einfach, aber sie gibt uns ein sehr einfach zu implementierendes Verfahren. Universelle Hashklassen existieren also und sind leicht zu berechnen.
Was zu klären bleibt: Wie behandeln wir die Fälle, wenn mehrere Schlüssel den selben Hashwert haben?
Die einfachste Variante ist es, in jeder der
0 i m-1
[ [k] ]
|
[k’]
|
[k’’]
In der Hashtabelle speichern wir also nicht nur ein Ja/Nein, um uns zu merken ob der Platz belegt ist, sondern wir speichern eine Liste mit den gesamten Schlüsseln.
Ein Beispiel: Sei
Wir speichern die Zahlen, die laut unserer Hashfunktion an der gleichen Stellen liegen sollen, in einer verketteten Liste.
Search(x): Durchlauf in der bei
Insert(x): Wir prüfen ob,
Delete(x): Wir prüfen ob,
Falls die Datensätze die wir in der Hashtabelle speichern wollen gross sind, macht es wenig Sinn
Eine solche Hashtabelle unterstützt die Operationen des abstrakten Datentyps Wörterbuch. Man kann also Schlüssel Suchen, Einfügen und Entfernen. Wie schnell ist eine solche Implementierung mit einer Hashtabelle für
Analysieren wir die mittlere Zeit für eine erfolgreiche und eine erfolglose Suche.
Für eine erfolgreiche Suche
Bei einer erfolglosen Suche nach
Dieses
Im schlimmsten Fall, kann die Laufzeit aber immer noch
Für eine erfolgreiche Suche
Wie weit müssen wir im Mittel in der Schlüsselliste suchen, bis wir den Schlüssel finden? Einfach die Hälfte der Listen?
Schauen wir uns die Schlüssel in der Reihenfolge an in der sie eingefügt wurden. Denn wen wir nach dem Schlüssel suchen der als
Unsere Intuition, dass wir im Mittel etwa die halbe Liste pro Platz ablaufen ist also korrekt. Ein Vergleich kommt einfach noch hinzu, da wir mit dem neuesten Element oder einem leeren Platz vergleichen müssen.
Der Worst Case ist natürlich auch hier immer noch
Was sind die Vor- und Nachteile dieser Methode?
Vorteile
Nachteile
Wir verzichten nun auf die verlinkten Listen pro Speicherplatz. Solche Hashverfahren, die bei Kollisionen direkt im Array nach freien Plätzen suchen, nennt man offenes Hashverfahren (vom englischen open addressing).
Wie suchen wir nach solchen freien Plätzen? Suchen wir einfach nach links oder nach rechts oder springen wir wild umher? Die Reihenfolge wie wir nach solchen freien Plätzen suchen nennt man Sondierungsfolge.
Eine Sondierungsfunktion
Suchen wir einfach nach links, also ans Adresse
SEARCH(x)
i := h(x) - s(0,x)
j := 0
while T[i] nicht frei und j < n do
if T[i] = x:
return "x gefunden"
else
j := j + 1
i := h(x) - s(j,x)
return "nicht gefunden"
Beispiel:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
19 | 15 | 2 | 5 | 53 | 12 | 43 |
Wie gut funktioniert dieses Sondieren? Man kann folgende mittleren Laufzeiten fürs lineare Sondieren zeigen:
Als Beispiel: Bei einem Belegungsfaktor von
Das Problem: es kommt schnell zur Klumpenbildung, da sich die Schlüssel schnell aufsammeln und die Ketten so lokal immer zusammenwachsen. Wenn dann irgendwo in diesen Block gehasht wird, wird die Kette um eines länger. Diese Problem nennt man primäre Häufung.
Was können wir dagegen tun? Die Abstände Schritt für Schritt grösser machen, also
Natürlich springen wir auch so modulo
Ein Beispiel:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
15 | 2 | 19 | 53 | 12 | 5 |
Das Problem der primären Häufung lässt sich so beheben. Doch für alle Schlüssel mit dem selben Hashwert klappern wir genau die selben Pätze ab, da die Sondierungsfolge immer die selbe ist. Dies nennt man sekundäre Häufung.
Wunsch: Jede Permutation der anderen
Was wir hingegen anschauen wollen ist folgende Sondierfunktion, welche eine zweite Hashfunktion
Wenn
Wenn wir beim Entfernen von Elementen einfach den Platz freigeben, dann kann die Sondierung bei einer späteren Suche zu früh abbrechen und dementsprechend ein Element nicht mehr finden obwohl es noch in der Hashtabelle ist. Um das zu beheben, sollten wir gelöschte Elemente nicht einfach als freie Plätze hinterlassen sondern als gelöschte Elemente markieren. Ein solches gelöschtes Element können wir bei einer Einfügeoperation wieder belegen (überschreiben) aber müssen wir beim Sondieren unbedingt als belegt betrachten und weitersondieren bis wir einen leeren Platz finden.
Zusammenfassend: Wir haben noch keine Datenstruktur die im Worst Case alle Operationen effizient machen kann. Wir haben einfach die Effizienz gemittelt und so effiziente Operationen erzielen können.