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

  1. Das Universum aller theoretisch möglichen Schlüssel sehr klein ist (hier: 676)
  2. 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:
Theorem. (Universelles Hashing). Angenommen, wir wählen \(a\) und \(b\) zufällig aus \(\Z_p\) und definieren \(h_{a,b}(x) := (ax + b) \textnormal{ modulo } p\). Seien \(x_1, x_2 \in \Z_p\) zwei beliebige verschiedene Zahlen. Dann ist das Paar \(h(x_1),h(x_2)\) gleichverteilt über alle \(p^2\) Werte in \( \Z_p \times \Z_p \).

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*}

Beweis. Bei jeder Aussage über Wahrscheinlichkeiten und Erwartungswerte müssen Sie sich im Klaren sein, was denn zufällig gewählt wird und was nicht. Die Input-Werte \(x_1, x_2\) werden nicht zufällig gewählt; die Parameter \(a,b\) unserer "Geraden" sind zufällig gewählt. Es gibt genau \(p^2\) mögliche Werte für diese Parameter und genau \(p^2\) mögliche Werte für das Ausgabe-Paar \((y_1, y_2)\). Das Theorem behauptet also, dass es für jedes mögliche Output-Paar \((y_1, y_2)\) genau eine Parameterwahl \((a,b)\) gibt. In anderen Worten, dass das Gleichungssystem \begin{align*} a x_1 + b & \equiv y_1 \mod p \\ a x_2 + b & \equiv y_2 \mod p \end{align*} mit den Variablen \(a\) und \(b\) genau eine Lösung hat, was auch immer die Werte von \(x_1, x_2, y_1, y_2\) sein sollen (mit der einzigen Bedingung, dass \(x_1, x_2 \in \Z_p)\) verschieden sind. Der Ausdruck \(A \equiv B \mod p\) heißt, dass \(A - B\) durch \(p\) teilbar ist; in diesem Kontext können Sie es aber auch als "\(A \mod p = B \mod p\) lesen. Versuchen wir, das Gleichungssystem zu lösen. Wir ziehen die zweite von der ersten Gleichung ab und erhalten \begin{align*} a (x_1 - x_2) \equiv y_1 - y_2 \ . \end{align*} Jetzt kommt ins Spiel, dass es \(p\) eine Primzahl ist.
Lemma Wenn \(p\) eine Primzahl ist und \(x \not \equiv 0 \mod p\), dann gibt es genau eine Zahl \(y \in \Z_p\) mit \(xy \equiv 1 \mod 1\). Wir bezeichnen dieses \(y\) mit \(x^{-1]}\) und nennen sie das Inverse von \(x\).
Beweis. Die Aussage \(x \not \equiv 0 \mod p\) heißt, dass \(x\) nicht durch \(p\) teilbar ist. Schreiben wir nun die Zahlen \begin{align*} 1 \cdot x, 2 \cdot x, 3 \cdot x, \dots, (p-1) \cdot x \end{align*} auf. Ich behaupte, keine dieser \(p-1\) Zahlen sind identisch oder auch nur äquivalent modulo \(p\). Falls nämlich \( ix \equiv jx \mod p\) sein sollte für \(1 \leq i \lt j \leq p-1\), so wäre \( (j-i) x \) durch \(p\) teilbar. Da nun \(p\) eine Primzahl ist und \(x\) nicht teilt, muss es \(j-i\) teilen. Allerdings gilt \(1 \leq j-i \leq p-2\) und somit ist es nicht durch \(p\) teilbar.

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\)

Da nach Annahme \(x_1 - x_2 \not \equiv p\) gilt (die beiden Werte sind verschieden und liegen beide in \Z_p\), so gibt es ein Inverses \((x_1-x_2)^{-1}\) Multiplizieren wir nun beide Seiten der Gleichung \begin{align*} a (x_1 - x_2) \equiv y_1 - y_2 \end{align*} mit dem Inversen \((x_1-x_2)^{-1}\) und erhalten \begin{align*} a \equiv (y_1 - y_2) (x_1 - x_2)^{-1} \ . \end{align*} Wir setzen das in die erste Gleichung ein und erhalten \begin{align*} b = y_1 - a x_1 \equiv y_1 - (y_1 - y_2) (x_1 - x_2)^{-1} x_1 \ . \end{align*} Wir sehen: das obige Gleichungssystem hat genau eine Lösung. \(\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:

\begin{align*} z - z' & \equiv 0 \mod p \\ z - z' & \equiv m \mod p \\ z - z' & \equiv 2 m \mod p \\ \dots \\ z - z' & \equiv k m \mod p \ , \end{align*} wobei \(km \leq p-1 \) gelten muss, also \(k \leq \ceil{\frac{p}{m}} -1\). Wenn also eine von \(\ceil{\frac{p}{m}}\) Gleichungen gilt. Jede Gleichung der Form \(z - z' \equiv j \mod \p\) hat genau \(p\) Lösungen modulo \(p\): wähle \(z\) frei und setze \(z' = z + j\). Es gibt also insgesamt \(p \ceil{\frac{p}{m}} \) Paare \((z, z)'\), die eine der obigen Gleichungen lösen; somit gibt es \(p \ceil{\frac{p}{m}} \) Paare \((z,z')\in \Z_ \times \Z_p\), für die \(z \equiv z' \mod n\) gilt. Die Wahrscheinlichkeit, dass \((h(x), h(x'))\) bei zufällig gewähltem \(a\) und \(b\) so ein Paar ergibt, ist \begin{align*} \frac{p \ceil{\frac{p}{m}} } {p^2} \leq \frac{p \left( \frac{p}{m} + 1 \right)}{p^2} = \frac{1}{m} + \frac{1}{p} \ . \end{align*}

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.
Theorem Sei \(m\) die Länge des Arrays und \(K\) die Menge der Schlüssel und \(p\) eine Primzahl mit \(p \geq |U|\) und \(p \geq Km\). Seien \(a,b \in \Z_p\) zufällig gewählt und \begin{align*} H(x) := (ax + b \mod p) \mod m \ . \end{align*} Dann gibt es für jedes \(x \in K\) im Erwartungswert höchstens \(|K|/m\) andere Schlüssel \(x' \in K\) mit \(H(x') = H(x)\).

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

Beobachtung Seien \(x,y \leq L\) zwei verschiedene Zahlen. Die Anzahl der Primfaktoren in \(|x-y|\) ist höchstens \begin{align*} \floor{\log_2 L} = \floor{\log_2 (2^l - 1) } = l \ . \end{align*}

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.

Theorem Sei \(U\) die Menge aller Schlüssel, die aus bis zu \(l\) Bits bestehen (die Menge aller theoretisch möglichen Schlüssel) und \(K \subseteq U\) (die im Voraus nicht bekannte Menge der tatsächlich vorkommenden Schlüssel). Sei \(p\) eine zufällige Primzahl aus dem Interval \(\{1, \dots, T\}\) für \(T = |K|^2 l^2\). Wir wählen \(a, b \in \Z_p\) zufällig, erstellen ein Array aus \(m\) Zellen und definieren \begin{align*} h & : \Z_p \rightarrow \Z_p \\ z & \mapsto ax + b \mod p \end{align*} und \begin{align*} H & : U \rightarrow \{0,\dots,m-1\} \ , x & \mapsto h(x \mod p) \mod m \ . \end{align*} Wir speichern jeden Schlüssel \(x \in K\) in der Zelle \(H(x)\). Dann werden im Erwartungswert höchstens \begin{align*} \left(1 + \frac{2 \ln (|K| l)}{|K|l}\right) \left(1 + \frac{|K|}{m}\right) \end{align*} Schlüssel in der Zelle \(H(x)\) gespeichert.

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.