**Dies ist eine alte Version des Dokuments!**
Home-Made Dictionaries
Das Dictionary ist neben der Liste einer der grundlegenden Container-Datentypen, die in den meisten Programmiersprachen vorkommen. Manchmal wird es auch Map, Mapping oder Associative Array genannt.
Ein Dictionary ordnet jedem möglichen Schlüssel (key) maximal einen Wert (value) zu. Die folgenden Operationen werden mindestens unterstützt:
lookup(key)
: gibt den zukey
zugeordneten Wert zurückinsert(key, value)
: definiert eine neue Zuordnung (eine bereits existierende Zuordnung wird überschrieben)remove(key)
: entfernt eine Zuordnung
Variante 1: Tupel-Liste
Eine einfache Möglichkeit, in Python selber ein Dictionary zu implementieren, wäre eine Liste von Tupeln (key, value)
zu speichern. Ein Tupel ist nichts anderes als eine unveränderliche Liste und wird (fast) immer in runde Klammern eingebettet.
Wenn wir möchten, dass unsere TupelListe die Python Syntax mit eckigen Klammern unterstützt, verwenden wir für die obigen Operationen die folgenden Special Method Names:
- Lookup:
__getitem__(key)
- Insert:
__setitem__(key, value)
- Remove:
__delitem__(key)
Deren Spezifikation besagt, dass Lookup und Remove einen KeyError
auslösen sollen, wenn der Schlüssel nicht existiert.
class TupelList: """A linear mapping type using list of (key, value) tuples.""" def __init__(self): self._tuples = [] def _finditem(self, key): """Returns the index of key, otherwise raises KeyError.""" for index, (k, _) in enumerate(self._tuples): if key == k: return index raise KeyError def __getitem__(self, key): return self._tuples[self._finditem(key)][1] def __delitem__(self, key): return self._tuples.pop(self._finditem(key))[1] def __setitem__(self, key, value): try: # replace existing mapping self._tuples[self._finditem(key)] = (key, value) except KeyError: # add new mapping self._tuples.append((key, value)) map = TupelList() # calls __init__ map['one'] = 42 # calls __setitem__ map['two'] = 21 print(map['one']) # calls __getitem__ map['one'] = 'foobar' print(map['one'])
Laufzeit-Messung
Die Tupel-Liste funktioniert, aber ist sie auch schnell? Wir machen eine kleine Messung:
def perf_test(map_type): import time, random # Measure how many mappings can be inserted, take no longer than 10s elapsed = 0 count = 10 # start with 100 mappings while elapsed < 1: map = map_type() count = count * 10 start = time.time() for i in range(count): key = random.randint(0, 1e6) map[key] = str(key) assert map[key] == str(key) elapsed = time.time() - start time_per_op = elapsed / count print(f"{map_type.__name__}: N={count:7} Time per Op: {time_per_op:2.1e}") perf_test(TupelList)
Output:
TupelList: N= 100 Time per Op: 5.4e-06 TupelList: N= 1000 Time per Op: 2.9e-05 TupelList: N= 10000 Time per Op: 2.5e-04
Die Zeit, um eine einzelne Zuordnung abzurufen wächst also linear mit der Anzahl Zuordnungen - wir sagen auch, dass die Lookup-Operation eine Laufzeit-Komplexität von $\mathcal{O}(n)$ hat.
Aufgabe 1 - Zeitmessung
Implementiere deine eigene Tupel-Liste und miss die Zeit für Lookup auf deinem Computer!
Variante 2: Binary Search
Natürlich möchten wir schneller sein als linear - zum Glück erinnern wir uns an die Informatik 1 und die Binärsuche!
Falls die verwendeten Schlüssel miteinander vergleichbar sind, könnten wir die Tupel-Liste ja sortieren, der Zugriff sollte dementsprechend schneller sein. Mathematiker sprechen auch davon, dass eine Totalordnung über die Schlüssel besteht. In Python ist dies der Fall, wenn wir für die Schlüssel Zahlen oder Strings (lexikographische Ordnung) verwenden, oder wenn die Schlüssel die Spezial-Funktionen für die Ordnung aufweisen.
Aufgabe 2 - Sortierte Tupel-Liste
Implementiere eine class SortedTupelList
- Hinweise:
- Wir können von
TupelList
erben:class SortedTupelList(TupelList)
um einigen Code wiederzuverwenden:- Die private Methode
_finditem(key)
muss angepasst werden um die Binärsuche zu implementieren. - In
__getitem__
und__delitem__
bleiben sich gleich und müssen nicht angepasst werden.
- In
__setitem__
benötigen wir den Einfüge-Index, falls_finditem
nichts findet. Dafür gibt es verschiedene Möglichkeiten..
Führe jetzt eine Zeitmessung durch und vergleiche!
Variante 3 - Hash-Map
Können wir noch schneller sein als Binärsuche?
Binärsuche ist sehr viel schneller als lineare Suche: jedes Mal wenn wir die Anzahl Einträge quadrieren vergrössert sich die Zeit um einen konstanten Faktor. Mann kann auch sagen, dass die Zeit pro Lookup mit dem Logarithmus der Anzahl Einträge wächst, oder dass Lookup die Komplexität $\mathcal{O}(log(n))$ hat.
Allerdings wissen wir auch, dass der eigentliche Zugriff auf die zugrundeliegende Liste nicht von deren Grösse abhängt. Wenn wir den richtigen Index kennen würden, könnten wir den Zugriff in konstanter Zeit (oder $\mathcal{O}(1)$) schaffen. Dies wird mit einer Hashmap erreicht, indem der Index aus dem Key berechnet wird. Dazu wird für jedes Key-Objekt ein Hashwert (eine Ganzzahl) berechnet. Aus dem Hashwert wird der Index in der Tupel-Liste mittels hash % len(self.tuples)
berechnet.
In Python können wir die eingebaute hash(object)
Funktion verwenden, um für jedes Objekt einen Hashwert zu erhalten. Für die meisten Objekte ist dies ein Wert, der aus der internen Speicheradresse abgeleitet wird; für Typen, die __eq__
implementieren, wie beispielsweise Strings, basiert der Hashwert auf dem Inhalt, so dass zwei unterschiedliche String-Instanzen mit dem gleichen Inhalt trotzdem den gleichen Hashwert produzieren.
Natürlich kann es vorkommen, dass zwei unterschiedliche Keys denselben Hashwert produzieren, eine sogenannte Kollision. Wir müssen also beim Suchen sicherstellen, dass an der berechneten Adresse tatsächlich der gesuchte Key steht. Zur Behandlung von Kollisionen gibt es viele Möglichkeiten:
- Probing: In der Liste weiterzusuchen nach dem nächsten freien Slot.
- … aber dann müssen wir bei
Remove
sicherstellen, dass die folgenden Elemente des Clusters wieder nach vorne gerückt werden.
- Chaining: Jedes Listenelement ist eine simple Tupel-Liste, die wir linear nach dem richtigen Key durchsuchen.
Um die Anzahl Kollisionen tief zu halten ist es wichtig, dass die Liste nie ganz gefüllt ist - die Tupel-Liste wird also bewusst grösser gewählt als nötig und bei Bedarf ein rehash ausgeführt: alle Einträge werden in eine neue, grössere Liste umgefüllt, wobei die Hashwerte dann neu berechnet werden müssen. Je nach gewähltem Faktor für die tolerierbare Kollisionszahl benötigt die Hashmap also mehr Speicher, um damit eine bessere Laufzeit zu erreichen.
Aufgabe 3 - HashMap
Implementiere eine class HashMap
.
Hinweise:
- wir können
_tuples
und__getitem__
wieder vonTupelList
erben.