3.7 Hashtabellen
Lassen Sie uns eine Datenstruktur entwerfen für
die Schlüsselmenge aller Wörter, die aus zwei kleinen Buchstaben bestehen,
also "br", "gx", "az"
und so weiter. Wie üblich
wollen wir die Operationen contains, insert, delete
unterstützen. Hier ist eine einfache und extrem effiziente Lösung:
ich interpretiere jeden Buchstaben als Zahl, also
\(a \equiv 0\), \(b \equiv 1\) und so weiter, und das Wort \((a_1a_2)\)
als zweistellige Zahl in Basis 26, also
$$(a_1 a_2) \equiv 26 a_1 + a_2
$$
Jedes Wort der Länge zwei entspricht also einer Zahl
in der Menge \(\{0,1,\dots, 26^2-1\}\). Wir legen ein Array der Größe
\(26^2 = 676\) an und speichern einfach Boolesche Werte.
Hier sehen Sie meinen Python-Code:
# c: a character
# maps 'a' to 0, 'b' to 1 and so on
def getCodeChar(c):
return (ord(c) - ord('a'))
# tlw: a two-letter word
def getCode(tlw):
return 26*getCodeChar(tlw[0]) +getCodeChar(tlw[1])
def makeTable():
return [False] * (26*26)
def insert(word, table):
table[getCode(word)] = True
def delete(word, table):
table[getCode(word)] = False
def contains(word, table):
return table[getCode(word)]
Was Sie hier sehen, nennt sich direkte Adressierung. Wir können unseren Schlüssel direkt in eine Array-Adresse umrechnen. Dies geht, weil
- Das Universum aller theoretisch möglichen Schlüssel sehr klein ist (hier: 676)
- wir seine Struktur sehr gut verstehen
und somit gut aus dem Schlüssel eine Zahl berechnen können.
Als zweites Beispiel nehmen wir die Anzahl der Keywords aus Java. Eine (hoffentlich) vollständige Liste finden Sie in javaKeywords.py. Extrem schneller Zugriff auf Keywords ist wichtig für den Compilerbau. Können wir obigen Trick wiederholen? Naja, das längste Keyword ist
synchronized
Es hat 12 Buchstaben. Wieviele Wörter mit 12 Buchstaben gibt es, wenn wir nur Kleinbuchstaben berücksichtigen? Ganz klar: \(26^{12} = 95.428.956.661.682.176\). Das ist zuviel. Wir können kein Array dieser Größe erstellen. Dennoch: wir können ein beliebiges Wort in eine Zahl umrechnen indem wir es als Zahl in Basis 26 betrachten:
def stringToInteger(word):
result = 0
for c in word:
result *= 26
result += getCodeChar(c)
return result
Das gibt uns zum Beispiel
stringToInteger("synchronized") = 69525273910093823
.
Hier ist ein Trick, wie wir diese recht große Zahl herunterbrechen auf eine
kleinere, die wir dann als Index in einem Array verwenden können.
Wir nehmen die (riesige) Zahl modulo einer (kleineren) anderen:
stringToInteger("synchronized") % 29 10 stringToInteger("public") % 29 26 stringToInteger("class") % 29 3
Schaut gut aus. Wir brauchen also nur ein Array der Größe \(30\), weil unsere Indices Werte in \(0, \dots, 29\) annehmen. Nur gibt es ein kleines Problem...
stringToInteger("if") % 29 10
Die Wörter if
und synchronized
bekommen
beide den Index 10 zugewiesen und streiten sich nun um die Speicherzelle in unserem
Array. Es kann ja auch gar nicht gutgehen: es gibt 50 Keywords in Java, also
braucht unser Array ja mindestens 50 Zellen. Ich habe es ausprobiert,
und mit Modulus 332 statt 29 funktioniert es. Also:
def javaKeywordToIndex(word):
return stringToInteger(word) % 332
funktioniert. Schön, allerdings ist dies eine recht spezielle Anwendung. Denn erstens ist hier die Schlüsselmenge extrem klein, zweitens kennen wir sie im Voraus. Dennoch praxisrelevant, denn für einen Compiler (oder Interpreter) brauchen wir so etwas.
Universelles Hashing
Beim universellen Hashing haben wir ein riesigen Universum \(U\)
von Schlüsseln und wollen eine
"vernünftig" große Folge von find, insert, delete
-Operationen
effizient bearbeiten.
Wir wissen nichts über die Art und Weise und Reihenfolge
dieser Operationen, insbesondere wissen wir
nichts über die Schlüssel, denen wir begegnen werden, außer, dass es
maximal \(n\) viele sein werden. Wir haben also nicht
wie beim Java-Keyword-Hashing im Voraus eine moderat große Liste der
möglichen Schlüssel gegeben.
Dennoch wollen wir eine Datenstruktur, die für alle
Input-Reihenfolgen gutes Verhalten zeigt. Das geht nur
mit Randomisierung.
Als Vereinfachung stellen wir uns vor, dass
\(U = \{0,1,2,\dots,L-1\}\) ist, dass also jeder Schlüssel eine
(womöglich sehr große) Zahl ist.
Die Idee ist, dass wir ein Array
array[0...m-1]
der Größe \(m\) anlegen.
Da \(L\) sehr groß ist, array
aber in unseren
Speicher passen muss, gilt im allgemeinen
\(m \ll L\). Wir brauchen eine Funktion
\(h: U \rightarrow \{0,\dots, m-1\}\).
Sei \(X\) die uns unbekannte Menge aller Schlüssel, die in unserer
Hashtabelle gespeichert werden sollen und \(n := |X| \).
Idealerweise ist \(h\) injektiv auf \(X\), also
$$
\forall x\ne y \in X: \ h(x) \ne h(y) \ .
$$
So ein \(h\) nennen wir eine perfekte Hashfunktion.
Für die Keywords in Java haben wir so eine perfekte Funktion gefunden.
Im Allgemeinen kennen wir \(X\) nicht im Voraus, daher können
wir nicht auf ein perfektes \(h\) hoffen.
Stattdessen, mit Randomisierung im Hinterkopf, hoffen wir, das
$$
\mathbf{E}[ |\{y \in X: h(y) = h(x)\} | ]
$$
für jedes \(x\) klein ist. Das sich also jedes \(x\) im Mittelwert
nur mit wenigen anderen \(y\) seine Zelle teilen muss.
Erinnern Sie sich an die Eigenschaft der randomisierten
Treaps: die Zugriffszeit auf ein Element \(x\) ist
dort proportional zu seiner Tiefe \(\depth(x,T)\); die Tiefe
selbst ist jedoch im Erwartungswert klein.
Wie sollen wir \(h: U \rightarrow \{0,\dots,n-1\}\) wählen? Als erstes suchen wir eine Primzahl \(p\), die ein bisschen größer als \(L\) ist und schreiben \(\Z_p := \{0,1,\dots,p-1\}\); dann wählen wir \(a, b \in \Z_p \) und definieren \begin{align*} h(x) = (ax + b) \textnormal{ modulo } p \end{align*} Erkennen Sie, dass das einer Geradengleichung mit Steigung \(a\) ähnelt? Der Unterschied ist, dass wir als Eingabe nur ganze Zahlen erlauben (oder noch strenger: nur aus \(Z_p\)) und das Ergebnis modulo \(p\) nehmen. Dennoch schaut auch der Plot von \(h\) ein wenig wie eine sich herumwindende Gerade aus. Hier zeigen wir einen Plot der Funktion \(h(x) = ax + b % p\) für \(p = 1009\) für verschiedene Werte von \(a\) und \(b\). Die beiden roten Punkte sind \( (400, h(400))\) und \((500, h(500))\).
Achten Sie in den Beispiel auf \(h(400)\) und \(h(500)\), den \(y\)-Koordinaten der roten Punkte. Die scheinen ziemlich zufällig hin und her zu springen. Und in der Tat:In anderen Worten: für jedes Paar \( (y_1, y_2) \in \Z_p \times \Z_p \) gilt \begin{align*} \Pr_{(a,b) \in \Z_p \times \Z_p } [h_{a,b}(x_1) = y_1 \textnormal{ und } h(x_2) = h_2] = p^{-2} \ . \end{align*}
Wir schließen nun, dass \(1 \cdot x \mod p , 2 \cdot x \mod p, 3 \cdot x \mod p, \dots, (p-1) \cdot x\mod p \) tatsächlich alle verschieden sind; des Weiteren ist keine dieser Zahlen 0, da \(ix\) nicht durch \(p\) teilbar ist. Wir folgern, dass jede Zahl aus \( \{1,\dots,p-1\}\) in der besagten Folge genau einmal vorkommt; also muss auch die Zahl 1 vorkommen, d.h. es gibt ein \(y \in \{1,\dots,p-1\}\) mit \(y x \mod p = 1\).\(\square\)
Hashtabelle als randomisierte Datenstruktur
Sei nun \(m\) die Anzahl der Schlüssel, die wir in unserer
Anwendung zu sehen erwarten; dies ist zum Beispiel höchstens die
Anzahl der delete, insert, find
-Operationen, die
unsere Anwendung ausführen muss.
Wir legen ein Array der Länge \(m\) an und
speichern den Schlüssel \(x \in U = \{0,\dots,L-1\}\) an der Stelle
\(h(x) \textnormal{ mod } m\). Sie haben richtig gelesen:
in der Berechnung von \(h\) rechnen wir \(mod p\); danach rechnen
wir \(\mod m\). Unsere Gesamt-Hashfunktion ist also
\begin{align*}
H & : \{0,\dots,L-1\} \rightarrow \{0,\dots,m\}
x & \mapsto [(ax + b \mod p) \mod m] \ .
\end{align*}
Kollisionen. Es kann vorkommen, dass
\(H(x_1) = H(x_2)\) gilt, dass also zwei Schlüssel sich die gleiche
Zelle im Array teilen müssen; daher speichern wir in jeder Zelle
den Pointer auf eine verkettete Liste; bei insert
hängen wir
den neuen Schlüssel einfach an die Liste; bei
find
und delete
müssen wir die gesamte Liste durchgehen.
Die Laufzeit der Operationen
find x
und delete x
sind also
proportional zu der Anzahl der Elemente, die in der Zelle
\(H(x)\) liegen. Da wir es mit einer randomisierte Datenstruktur
zu tun haben, können wir nur Erwartungswerte angeben.
Sei nun \(x' \ne x\). Wir untersuchen, mit welcher Wahrscheinlich
\(x'\) und \(x\) in der selben Zelle landen.
Wir fragen uns nun: von allen \(p^2\) möglichen Werten \((z,z') \in \Z_p \times \Z_p\),
für wieviele gilt \(z \equiv z' \mod m\)? Dies gilt, falls die
Differenz \(z - z'\) modulo \(p\) einen der Werte \(0, m, 2m, \dots\) annimt,
also eine der folgenden Gleichungen erfüllt ist:
Die Anzahl der Schlüssel, die in der gleichen Zelle landen wie \(x\) ist also im Erwartungswert
\begin{align*} \sum_{x' \in K} \Pr[ H(x') = H(x)] & = 1 + \sum_{x' \in K \setminus \{x\} } \Pr[ H(x') = H(x)] \\ & \leq 1 + \frac{|K|-1}{m} + \frac{|K|-1}{p} \ . \end{align*} Erinnerin Sie sich, dass \(p \geq |U|\) eine sehr große Zahl ist, während \(K, m\) "realistisch" große Zahlen sind. Wir können uns also erlauben, den obigen etwas komplizierten Ausdruck mit zu vereinfachen und die erwartete Zellenbelegung mit \(1 + \frac{|K|}{m}\) abzuschätzen. Dies hat eine sehr intuitive Interpretation: die Zelle von \(x\) hat ja auf jeden Fall schon einen Bewohner, nämlich \(x\) selbst; daher die \(1 +\) im Ausdruck; jeder weitere Schlüssel, den wir in unserer Tabelle speichern, landet nur mit Wahrscheinlichkeit \(1/m\) in der gleichen Zelle.
Die Laufzeiten für insert, find, delete
sind also proportional zur Zellenbelegung von \(x\),
also \(1 + |K|/m\).
Bit-Komplexität
Wir haben einige Sachen unter den Teppich gekehrt. Laut unsere Annahme
kommen die Schlüssel aus einem Universum \(U\) der Größe \(L\); wir
müssen also die Primzahl \(p\) größer als \(L\) wählen, um einen
Schlüssel \(x \in U\) überhaupt als Zahl in \(\Z_p\) darstellen zu können.
Die Bit-Länge der Zahl \(p\) ist also mindestens die von \(L\), also
\begin{align*}
b := {\rm bitlength} (p) \geq \ceil{ \log_2(L+1)} \ .
\end{align*}
Beachten Sie auch, dass die Bit-Länge \(b\) in die Laufzeitkomplexität
miteinfließt: um \(H(x)\) auszurechnen, müssen wir schließlich
\(b\)-stellige Zahlen multiplizieren, addieren, modulo \(p\) nehmen.
Auch wenn wir Algorithmen für Multiplikation erst in
Algorithmen und Komplexität
behandeln werden,
so ist doch einigermaßen klar, dass die Laufzeit
für Addition und Multiplikation mindestens proportional
zur Anzahl \(b\) der Bits ist, wenn nicht noch höher. Unsere (erwartete)
Laufzeit von insert, find, delete
ist also
\(O(b)\) (wenn wir annehmen, dass wir alle arithmetische Operationen in \(O(b)\)
ausgeführen können; dies ist nicht der Fall, aber die Wirklichkeit ist nicht
weit davon entfernt).
Wie groß ist \(b\)? Wie groß müssen wir unsere Primzahl
wählen? Mindestens \(L\), der Anzahl theoretisch möglicher vorkommender
Schlüssel. Wenn unser Hash also zum Beispiel Tweets speichern
soll, die aus bis zu 280 Zeichen bestehen, dann gibt es mindestens
\begin{align*}
L \geq 52^{280}
\end{align*}
mögliche Tweets, und hierbei tun wir so, als seien nur Groß- und Kleinbuchstaben
erlaubt; in Wirklichkeit gibt es viel mehr erlaubte Zeichen (inklusive nicht-lateinischer
Schrifzeichen wie zum Beispiel chinesische, die laut
developer.twitter.com
als zwei Zeichen zählen); aber immerhin, die Bitlänge von \(L\) ist mindestens
\begin{align*}
\ceil {\log_2 (52^{280} + 1)} = 1027.
\end{align*}
Ihre Primzahl \(p\) würde also mindestens \(1027\) Bits benötigen; in einem
64-Bit-Rechner könnten Sie das gar nicht als int
speichern, sondern
müssten beispielsweise die Java-Klasse BigInteger
verwenden.
Wäre dies schlimm? Naja, es ist nicht so verwunderlich, dass die
Laufzeit von \(H(x)\) proportional zur Bit-Länge von \(x\) ist, also
wäre das schon zu ertragen.
Problematischer wird es, wenn wir keine scharfe Obergrenze für unsere Schlüssel festlegen wollen. Wenn wir beispielsweise Emails oder gar Dateien in einer Hash-Tabelle speichern wollen, dann sollten wir vorsichtig sein mit Obergrenzen. Nehmen wir als Gedankenexperiment mal 1MB als Obergrenze, also 8 Megabit. Dann wäre \(L \approx 2^{8.000.000}\), unser \(p\) müsste also selbst \(\ceil{\log_2 (L+1)} \approx 8.000.000\) Bits, also 1MB haben. Das hieße, bei jedem Aufruf von \(H(x)\) müssen Sie 8-millionen-stellige Zahlen multiplizieren. Auch wenn fast alle Dateien / Emails, die sie in Ihrer Hash-Tabelle speichern wollen, viel kleiner sind. Die Laufzeit von der Hashfunktion \(H(x)\) ist also nicht proportional zur Größe des Schlüssels \(x\), sondern proportional zur maximalen theoretisch erlaubten Größe.
Bessere Wahl der Primzahl \(p\)
Wir hätten gerne eine Hashfunktion, deren Laufzeit proportional zur Bit-Länge von \(x\) ist. Im Folgenden sie wiederum \(\ell \) die theoretische Obergrenze der Bit-Länge unserer Schlüssel (und somit \(L = 2^{l+1}-1\)), also z.B. \(l = 8 \cdot 10^{6}\) und \(K\) die Menge der tatsächlich vorkommenden Schlüssel. Ein Schlüssel ist uns gegeben als endlicher String von Nullen und Einsen, also ein Element in \(\{0,1\}^*\). Wir können jeden solchen String als natürliche Zahl codieren, in dem wir eine 1 voranstellen und das Ergebnis als Binärzahl interpretieren: der String \(001\) wird dann zu \(1001\), also 9. Wir müssen eine 1 voranstellen, um führende Nullen zu vermeiden und zum Beispiel die Strings \(001\) und \(0001\) zu unterscheiden.
Die Idee ist, dass wir \(p\) viel kleiner als \(L\) wählen und bei gegebenem Schlüssel \(x\) zuerst einmal den Pseudo-Schlüssel \(x \mod p\) berechnen, wobei \(x\) im obigen Sinne als Binärzahl zu interpretieren ist. Wir setzen dann \(H(x) := h(x \mod p) \mod m \), also wie zuvor, nur dass wir jedes Mal den Schlüssel \(x\) durch seinen Pseudo-Schlüssel \(x \mod p\) ersetzen. Der Rechenaufwand ist nun proportional zur "echten" Bitlänge von \(x\) und \(p\), nicht zur theoretischen Obergrenze. Die Gefahr ist, dass nun im Worst Case alle Schlüssel größer sind als \(p\) und per \(x \mod p\) auf den gleichen Pseudo-Schlüssel abgebildet werden; dann landen ungeachtet der Werte von \(a,b\) alle Schlüssel in der gleichen Zelle landen. Das wäre schlecht. Wir hätten gerne, dass \begin{align*} x \mapsto x \mod p \end{align*} injektiv ist, dass also keine zwei Schlüssel den gleichen Pseudo-Schlüssel bekommen; das geht im Allgemeinen natürlich nicht, da \(U\) viel größer ist als \(\Z_p\); wir wollen \(p\) ja klein wählen können. Ein bescheideneres Ziel ist es, dass \(x \mapsto x \mod p\) injektiv auf der tatsächlichen Schlüsselmenge \(K\) ist, also: \begin{align*} \forall x, y \in K, x \ne y: \quad x \not \equiv y \mod p \ \end{align*} oder äquivalent: \begin{align*} \forall x, y \in K, x \ne y: \quad |x-y| \textnormal{ ist nicht durch $p$ teilbar.} \end{align*} Wir kennen \(K\) nicht im Voraus, also müssen wir uns abermals mit Zufall behelfen und mit den Primzahlen beschäftigen. Jede der Zahlen \(|x-y|\) liegt im Bereich \(1, \dots, L\). Wenn wir für eine Zahl \(k \in \(1,\dots\L)\) ihre Primzahlzerlegung hinschreiben, also \begin{align*} k = p_1^{e_1} p_2^{e_2} p_3^{e_3} p_4^{e_4} \cdots p_s^{e_s} \ , \end{align*} also zum Beispiel \(28 = 2^4 \cdot 7^1\), dann sehen wir, dass \(k \gt 2^{e_s}\) ist, weil jede Primzahl mindestens 2 ist; \(k\) hat also weniger als \(\log_2 k\) viele verschiedene Primfaktoren; da \(|x-y| \leq L\) folgern wir
Wir wählen nun die Primzahl \(p\) zufällig aus \([T] :=\{1,\dots,T\}\) und hoffen, dass der Pseudoschlüssel \(x \mod p\) mit wenigen anderen Schlüsseln "kollidiert". Was ist die Wahrscheinlichkeit, dass ein anderer Schlüssel \(y\) den gleichen Pseudo-Schlüssel bekommt, also dass \(x \equiv y \mod p\)? Wir schreiben \begin{align*} \Pi(T) & := \{p \in [T] \ | \ p \textnormal{ ist eine Primzahl}\} \\ \pi(T) & := |\Pi(T)| \ . ) \end{align*} Dann gilt für einen festen Schlüssel \(y \ne x\): \begin{align*} \Pr_{p \in \Pi(T)} [ p | (x-y)] & \leq \frac{l}{\pi(T)} \ , \end{align*} weil ja die Anzahl der verschiedenene Primfaktoren von \(x-y\) höchstens \(l\) ist. Die Anzahl der Schlüssel \(y \in K \setminus \{x\}\), die den gleichen Pseudoschlüssel bekommen wie \(x\), ist somit im Erwartungswert höchstens \begin{align*} \frac{|K| l}{\pi(T)} \ . \end{align*} Wir setzen \(T := |K|^2 l^2\). Der Wikiepdia: Primzahlsatz besagt, dass die Anzahl der Primzahlen in \(\{1,\dots,T\}\) ungefähr \begin{align*} \frac{T}{\ln T} \end{align*} ist. Also gilt \begin{align*} \frac{|K| l}{\pi(T)} & \approx \frac{|K| l \ln T}{T} \\ & = \frac{2 \ln (|K| l)}{|K|l} \ . \end{align*} In Worten: im Erwartungswert teilt sich \(x\) seinen Pseudo-Schlüssel mit äußerst wenig Schlüsseln.
Zusammenfassend sehen wir: wenn unsere Hash-Tabelle aus \(m\) Zellen besteht, dann speichern wir in der Zelle \(H(x)\) im Erwartungswert höchstens \(1 + |K|/m\) Pseudo-Schlüssel. Hinter jedem Pseudo-Schlüssel stehen (im Erwartungswert über die Wahl der Zahl \(p\)) höchstens \(1 + \frac{2 \ln (|K| l)}{|K|l} \) viele Schlüssel.
Die Bit-Größe der Zahl \(p\) ist nun höchstens \(\ceil{\log_2 (T+1)} = O(\log K + \log l)\). Vergleichen Sie das zu unserer vorherigen Wahl \(p \geq L\), was eine Bitgröße von circa \(l\) zur Folge hat. In unserem Email/Dateien-Beispiel gilt \(l = 8 \cdot 10^6\), und somit haben wir nun die Bitgröße der involvierten Zahlen von 8 Millionen auf \(\log_2 (8 \cdot 10^6 \approx 60\) reduziert.