it-swarm.com.de

Wie werden die eingebauten Wörterbücher von Python implementiert?

Weiß jemand, wie der eingebaute Wörterbuchtyp für python implementiert ist? Meines Wissens nach handelt es sich um eine Art Hash-Tabelle, aber ich konnte keine endgültige Antwort finden.

251
ricree

Hier ist alles über Python, was ich zusammenstellen konnte (wahrscheinlich mehr als jeder andere gerne wissen würde; aber die Antwort ist umfassend).

  • Python-Wörterbücher werden als - Hash-Tabellen implementiert.
  • Hash-Tabellen müssen - Hash-Kollisionen zulassen, d. H. Selbst wenn zwei verschiedene Schlüssel denselben Hash-Wert haben, muss die Implementierung der Tabelle eine Strategie zum eindeutigen Einfügen und Abrufen der Schlüssel- und Wertepaare enthalten.
  • Python dict verwendet open addressing , um Hash-Kollisionen aufzulösen (siehe unten) (siehe dictobject.c: 296-297 ).
  • Python-Hash-Tabelle ist nur ein zusammenhängender Speicherblock (ähnlich wie ein Array, sodass Sie eine O(1)-Suche nach Index durchführen können).
  • Jeder Slot in der Tabelle kann nur einen Eintrag speichern. Dies ist wichtig.
  • Jeder Eintrag in der Tabelle ist tatsächlich eine Kombination der drei Werte: <Hash, Schlüssel, Wert> . Dies ist als C-Struktur implementiert (siehe dictobject.h: 51-56 ).
  • Die folgende Abbildung ist eine logische Darstellung einer Python -Hash-Tabelle. In der folgenden Abbildung sind 0, 1, ..., i, ... links die Indizes der slots in der Hash-Tabelle (sie dienen nur der Veranschaulichung und werden nicht zusammen mit dem gespeichert Tisch offensichtlich!).

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
    
  • Wenn ein neues Diktat initialisiert wird, beginnt es mit 8 Slots . (siehe dictobject.h: 49 )

  • Wenn wir der Tabelle Einträge hinzufügen, beginnen wir mit einem Slot, i, der auf dem Hash des Schlüssels basiert. CPython verwendet zunächst i = hash(key) & mask (wobei mask = PyDictMINSIZE - 1 ist, aber das ist nicht wirklich wichtig). Beachten Sie nur, dass der erste Slot, i, der markiert ist, vom Hash des Schlüssels abhängt.
  • Wenn dieser Slot leer ist, wird der Eintrag zum Slot hinzugefügt (mit Eintrag meine ich <hash|key|value>). Aber was ist, wenn dieser Steckplatz belegt ist? Höchstwahrscheinlich, weil ein anderer Eintrag denselben Hash hat (Hash-Kollision!)
  • Wenn der Slot belegt ist, vergleicht CPython (und sogar PyPy) den Hash UND den Schlüssel (durch Vergleich meine ich == Vergleich nicht den is Vergleich) des Eintrags im Slot mit dem Hash und dem Schlüssel des aktuell einzufügenden Eintrags ( dictobject.c: 337,344-345 ). Wenn beide übereinstimmen, dann glaubt es, dass der Eintrag bereits existiert, gibt auf und geht zum nächsten einzufügenden Eintrag über. Wenn Hash oder Schlüssel nicht übereinstimmen, wird probing gestartet.
  • Das Prüfen bedeutet nur, dass die Slots nach Slots durchsucht werden, um einen leeren Slot zu finden. Technisch könnten wir einfach eine nach der anderen gehen, i+1, i+2, ... und die erste verfügbare verwenden (das ist lineares Abtasten). Aus Gründen, die in den Kommentaren (siehe dictobject.c: 33-126 ) erläutert wurden, verwendet CPython random probing . Beim zufälligen Abtasten wird der nächste Schlitz in einer Pseudozufallsreihenfolge ausgewählt. Der Eintrag wird dem ersten freien Slot hinzugefügt. Für diese Diskussion ist der tatsächliche Algorithmus, der zum Auswählen des nächsten Slots verwendet wird, nicht wirklich wichtig (siehe dictobject.c: 33-126 für den Algorithmus zum Prüfen). Wichtig ist, dass die Steckplätze geprüft werden, bis der erste freie Steckplatz gefunden wird.
  • Das gleiche passiert für Lookups, fängt einfach mit dem ersten Slot i an (wobei ich vom Hash des Schlüssels abhängt). Wenn sowohl der Hash als auch der Schlüssel nicht mit dem Eintrag im Slot übereinstimmen, beginnt die Prüfung, bis ein Slot mit einer Übereinstimmung gefunden wird. Wenn alle Steckplätze erschöpft sind, wird ein Fehler gemeldet.
  • Übrigens wird die Größe des dict geändert, wenn es zu zwei Dritteln voll ist. Dies verhindert, dass die Suche verlangsamt wird. (siehe dictobject.h: 64-65 )

HINWEIS: Ich habe die Implementierung von Python Dict untersucht, als Antwort auf meine eigene Frage , wie mehrere Einträge in einem Dict dieselben Hash-Werte haben können. Ich habe hier eine leicht bearbeitete Version der Antwort gepostet, da die gesamte Forschung auch für diese Frage sehr relevant ist.

419

Python-Wörterbücher verwenden Offene Adressierung ( Verweis in schönem Code )

ACHTUNG! Offene Adressierung, aka geschlossenes Hashing sollte, wie in Wikipedia vermerkt , nicht zu verwechseln mit dem Gegenteil open hashing !

Offene Adressierung bedeutet, dass das Diktat Array-Slots verwendet. Wenn die primäre Position eines Objekts im Diktat eingenommen wird, wird die Position des Objekts an einem anderen Index im selben Array gesucht, wobei ein "Störungs" -Schema verwendet wird, bei dem der Hash-Wert des Objekts eine Rolle spielt .

44
u0b34a0f6ae

Wie werden die integrierten Wörterbücher von Python implementiert?

Hier ist der kurze Kurs:

  • Sie sind Hash-Tabellen.
  • Eine neue Prozedur/ein neuer Algorithmus macht sie ab Python 3.6)
    • sortiert nach Schlüsseleinwurf und
    • weniger Platz in Anspruch nehmen,
    • praktisch ohne Leistungsverlust.
  • Eine weitere Optimierung spart Platz, wenn Diktate Schlüssel freigeben (in besonderen Fällen).

Der geordnete Aspekt ist ab Python 3.6, aber offiziell in Python 3.7 .

Pythons Wörterbücher sind Hash-Tabellen

Lange hat es genau so funktioniert. Python würde 8 leere Zeilen vorbelegen und den Hash verwenden, um zu bestimmen, wo das Schlüssel-Wert-Paar abgelegt werden soll. Wenn der Hash für den Schlüssel beispielsweise auf 001 endete, würde er ihn in die 1 eintragen index (wie im folgenden Beispiel)

     hash         key    value
     null        null    null
...010001    ffeb678c    633241c4 # addresses of the keys and values
     null        null    null
      ...         ...    ...

Jede Zeile belegt in einer 64-Bit-Architektur 24 Byte, in einer 32-Bit-Architektur 12 Byte. (Beachten Sie, dass die Spaltenüberschriften nur Beschriftungen sind - sie sind tatsächlich nicht im Speicher vorhanden.)

Wenn der Hash genauso endet wie der Hash eines bereits vorhandenen Schlüssels, handelt es sich um eine Kollision, bei der das Schlüssel-Wert-Paar an einer anderen Stelle abgelegt wird.

Nachdem 5 Schlüsselwerte gespeichert wurden, ist beim Hinzufügen eines weiteren Schlüsselwertpaars die Wahrscheinlichkeit von Hash-Kollisionen zu groß, sodass sich die Größe des Wörterbuchs verdoppelt. In einem 64-Bit-Prozess sind vor der Größenänderung 72 Byte leer, und danach werden 240 Byte aufgrund der 10 leeren Zeilen verschwendet.

Dies nimmt viel Platz in Anspruch, aber die Nachschlagezeit ist ziemlich konstant. Der Schlüsselvergleichsalgorithmus berechnet den Hash, geht zum erwarteten Ort und vergleicht die ID des Schlüssels. Wenn es sich um dasselbe Objekt handelt, sind sie gleich. Wenn nicht, dann vergleichen Sie die Hash-Werte. Wenn sie nicht gleich sind, sind sie nicht gleich. Andernfalls vergleichen wir schließlich die Schlüssel auf Gleichheit und geben den Wert zurück, wenn sie gleich sind. Der endgültige Vergleich für die Gleichheit kann recht langsam sein, aber die früheren Überprüfungen verkürzen normalerweise den endgültigen Vergleich, was die Suche sehr schnell macht.

(Kollisionen verlangsamen die Geschwindigkeit, und ein Angreifer könnte theoretisch Hash-Kollisionen verwenden, um einen Denial-of-Service-Angriff durchzuführen. Daher haben wir die Hash-Funktion so randomisiert, dass sie für jeden neuen Python) - Prozess einen anderen Hash berechnet. )

Der oben beschriebene verschwendete Speicherplatz hat uns veranlasst, die Implementierung von Wörterbüchern zu modifizieren, und zwar mit einer aufregenden neuen (wenn auch inoffiziellen) Funktion, bei der Wörterbücher jetzt (nach Einfügung) sortiert werden.

Die neuen kompakten Hash-Tabellen

Wir beginnen stattdessen mit der Vorbelegung eines Arrays für den Index der Einfügung.

Da unser erstes Schlüssel-Wert-Paar in den zweiten Slot fällt, indexieren wir wie folgt:

[null, 0, null, null, null, null, null, null]

Und unsere Tabelle wird nur nach Einfügereihenfolge gefüllt:

     hash         key    value
...010001    ffeb678c    633241c4 
      ...         ...    ...

Wenn wir also nach einem Schlüssel suchen, verwenden wir den Hash, um die erwartete Position zu überprüfen (in diesem Fall gehen wir direkt zu Index 1 des Arrays) und gehen dann zu diesem Index in der Hash-Tabelle (z. B. Index 0) ), überprüfen Sie, ob die Schlüssel gleich sind (mit demselben Algorithmus wie zuvor beschrieben), und geben Sie in diesem Fall den Wert zurück.

Wir behalten eine konstante Nachschlagezeit bei, mit geringen Geschwindigkeitsverlusten in einigen Fällen und Gewinnen in anderen Fällen, mit dem Vorteil, dass wir gegenüber der bereits vorhandenen Implementierung eine Menge Platz sparen. Der einzige verschwendete Speicherplatz sind die Null-Bytes im Index-Array.

Raymond Hettinger führte dies im Dezember 2012 in python-dev ein. Es gelangte schließlich in Python 3.6 in CPython. Die Reihenfolge nach Einfügung wird immer noch als Implementierungsdetail betrachtet, damit andere Implementierungen von Python eine Chance zum Aufholen bieten.

Freigegebene Schlüssel

Eine weitere Optimierung, um Platz zu sparen, ist eine Implementierung, bei der Schlüssel gemeinsam genutzt werden. Anstatt redundante Wörterbücher zu haben, die den gesamten Speicherplatz belegen, haben wir Wörterbücher, die die gemeinsam genutzten Schlüssel und die Hashes der Schlüssel wiederverwenden. Sie können sich das so vorstellen:

     hash         key    dict_0    dict_1    dict_2...
...010001    ffeb678c    633241c4  fffad420  ...
      ...         ...    ...       ...       ...

Auf einer 64-Bit-Maschine könnten dadurch bis zu 16 Byte pro Schlüssel und zusätzliches Wörterbuch eingespart werden.

Freigegebene Schlüssel für benutzerdefinierte Objekte und Alternativen

Diese Shared-Key-Dikte sollen für benutzerdefinierte Objekte '__dict__ Verwendet werden. Um dieses Verhalten zu erzielen, müssen Sie meines Erachtens das Auffüllen Ihres __dict__ Beenden, bevor Sie Ihr nächstes Objekt instanziieren ( siehe PEP 412 ). Dies bedeutet, dass Sie alle Ihre Attribute in __init__ Oder __new__ Zuweisen sollten, da Sie sonst möglicherweise keine Platzersparnis erzielen.

Wenn Sie jedoch alle Attribute zum Zeitpunkt der Ausführung von __init__ Kennen, können Sie auch __slots__ Für Ihr Objekt angeben und sicherstellen, dass __dict__ Überhaupt nicht erstellt wird (falls nicht bei Eltern verfügbar), oder erlauben Sie sogar __dict__, aber stellen Sie sicher, dass Ihre vorausgesehenen Attribute trotzdem in Slots gespeichert werden. Für mehr über __slots__, siehe meine Antwort hier .

Siehe auch:

42
Aaron Hall