Come vengono implementati i dizionari integrati di Python?


294

Qualcuno sa come viene implementato il tipo di dizionario integrato per Python? La mia comprensione è che si tratta di una sorta di tabella hash, ma non sono stato in grado di trovare una risposta definitiva.


4
Ecco un discorso approfondito sui dizionari Python dalla 2.7 alla 3.6. Link
Sören,

Risposte:


494

Ecco tutto ciò che riguarda i Dit Python che sono stato in grado di mettere insieme (probabilmente più di quanto chiunque vorrebbe sapere; ma la risposta è completa).

  • I dizionari Python sono implementati come tabelle hash .
  • Le tabelle hash devono consentire le collisioni hash, vale a dire anche se due chiavi distinte hanno lo stesso valore hash, l'implementazione della tabella deve avere una strategia per inserire e recuperare le coppie chiave e valore in modo inequivocabile.
  • Python dictutilizza l'indirizzamento aperto per risolvere le collisioni di hash (spiegate di seguito) (vedere dictobject.c: 296-297 ).
  • La tabella hash di Python è solo un blocco contiguo di memoria (un po 'come un array, quindi puoi fare una O(1)ricerca per indice).
  • Ogni slot nella tabella può memorizzare una e una sola voce. Questo è importante.
  • Ogni voce nella tabella in realtà una combinazione dei tre valori: <hash, chiave, valore> . Questo è implementato come C struct (vedi dictobject.h: 51-56 ).
  • La figura seguente è una rappresentazione logica di una tabella hash Python. Nella figura in basso, 0, 1, ..., i, ...a sinistra ci sono indici degli slot nella tabella hash (sono solo a scopo illustrativo e ovviamente non sono memorizzati insieme alla tabella!).

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
    
  • Quando un nuovo dict viene inizializzato, inizia con 8 slot . (vedi dictobject.h: 49 )

  • Quando si aggiungono voci alla tabella, iniziamo con alcuni slot, ibasati sull'hash della chiave. CPython inizialmente utilizza i = hash(key) & mask(dove mask = PyDictMINSIZE - 1, ma non è molto importante). Basta notare che lo slot iniziale i, che viene controllato dipende dall'hash della chiave.
  • Se quello slot è vuoto, la voce viene aggiunta allo slot (per voce, intendo, <hash|key|value>). Ma cosa succede se quello slot è occupato !? Molto probabilmente perché un'altra voce ha lo stesso hash (hash collision!)
  • Se lo slot è occupato, CPython (e persino PyPy) confronta l'hash AND la chiave (per confronto intendo ==confronto non il isconfronto) della voce nello slot con l'hash e la chiave della voce corrente da inserire ( dictobject.c : 337.344-345 ) rispettivamente. Se entrambi corrispondono, allora pensa che la voce esista già, si arrende e passa alla voce successiva da inserire. Se l'hash o la chiave non corrispondono, inizia il sondaggio .
  • Probare significa solo che cerca negli slot per slot per trovare uno slot vuoto. Tecnicamente potremmo semplicemente andare uno per uno i+1, i+2, ...e usare il primo disponibile (che è il sondaggio lineare). Ma per ragioni spiegate magnificamente nei commenti (vedi dictobject.c: 33-126 ), CPython usa il sondaggio casuale . Nel sondaggio casuale, lo slot successivo viene scelto in un ordine pseudo casuale. La voce viene aggiunta al primo slot vuoto. Per questa discussione, l'algoritmo effettivo utilizzato per scegliere lo slot successivo non è molto importante (vedere dictobject.c: 33-126 per l'algoritmo per il sondaggio). Ciò che è importante è che gli slot vengano sondati fino a quando non viene trovato il primo slot vuoto.
  • La stessa cosa accade per le ricerche, inizia solo con lo slot iniziale i (dove dipendo dall'hash della chiave). Se l'hash e la chiave non corrispondono entrambi alla voce nello slot, inizia a sondare, fino a quando non trova uno slot con una corrispondenza. Se tutti gli slot sono esauriti, viene segnalato un errore.
  • A proposito, dictverrà ridimensionato se è pieno per due terzi. Questo evita di rallentare le ricerche. (vedi dictobject.h: 64-65 )

NOTA: ho svolto ricerche sull'implementazione di Python Dict in risposta alla mia domanda su come più voci in un dict possano avere gli stessi valori di hash. Ho pubblicato qui una versione leggermente modificata della risposta perché tutta la ricerca è molto rilevante anche per questa domanda.


8
Hai detto che quando sia l'hash sia la chiave corrispondono, (insert op) si arrende e va avanti. In questo caso non si inserisce la sovrascrittura della voce esistente?
0xc0de,

65

Come vengono implementati i dizionari integrati di Python?

Ecco il breve corso:

  • Sono tabelle hash. (Vedi sotto per i dettagli dell'implementazione di Python.)
  • Un nuovo layout e algoritmo, a partire da Python 3.6, li rende
    • ordinato per inserimento chiave e
    • occupare meno spazio,
    • praticamente senza alcun costo in termini di prestazioni.
  • Un'altra ottimizzazione consente di risparmiare spazio quando i dicts condividono le chiavi (in casi speciali).

L'aspetto ordinato non è ufficiale a partire da Python 3.6 (per dare ad altre implementazioni la possibilità di tenere il passo), ma ufficiale in Python 3.7 .

I dizionari di Python sono tabelle hash

Per molto tempo ha funzionato esattamente così. Python avrebbe preallocato 8 righe vuote e usato l'hash per determinare dove incollare la coppia chiave-valore. Ad esempio, se l'hash per la chiave termina in 001, lo inserisce nell'indice 1 (cioè 2 °) (come nell'esempio seguente).

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

Ogni riga occupa 24 byte su un'architettura a 64 bit, 12 su 32 bit. (Nota che le intestazioni di colonna sono solo etichette per i nostri scopi qui - in realtà non esistono in memoria.)

Se l'hash terminava come l'hash di una chiave preesistente, questa è una collisione e quindi la coppia chiave-valore si attaccherebbe in una posizione diversa.

Dopo aver memorizzato 5 valori-chiave, quando si aggiunge un'altra coppia chiave-valore, la probabilità di collisioni con hash è troppo grande, quindi il dizionario ha dimensioni doppie. In un processo a 64 bit, prima del ridimensionamento, abbiamo 72 byte vuoti e, successivamente, stiamo sprecando 240 byte a causa delle 10 righe vuote.

Questo richiede molto spazio, ma il tempo di ricerca è abbastanza costante. L'algoritmo di confronto delle chiavi consiste nel calcolare l'hash, andare nella posizione prevista, confrontare l'id della chiave: se sono lo stesso oggetto, sono uguali. Se poi non confrontare i valori hash, se sono non la stessa, non sono uguali. Altrimenti, infine confrontiamo le chiavi per l'uguaglianza e, se sono uguali, restituiamo il valore. Il confronto finale per l'uguaglianza può essere piuttosto lento, ma i controlli precedenti di solito abbreviano il confronto finale, rendendo le ricerche molto veloci.

Le collisioni rallentano le cose e un attaccante potrebbe teoricamente usare le collisioni di hash per eseguire un attacco denial of service, quindi abbiamo randomizzato l'inizializzazione della funzione hash in modo tale che calcoli hash diversi per ogni nuovo processo di Python.

Lo spazio sprecato sopra descritto ci ha portato a modificare l'implementazione dei dizionari, con un'emozionante nuova funzionalità che i dizionari sono ora ordinati per inserimento.

Le nuove tabelle hash compatte

Iniziamo invece preallocando un array per l'indice dell'inserzione.

Poiché la nostra prima coppia chiave-valore va nel secondo slot, indicizziamo in questo modo:

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

E la nostra tabella viene popolata solo per ordine di inserzione:

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

Quindi quando cerchiamo una chiave, usiamo l'hash per verificare la posizione che ci aspettiamo (in questo caso, andiamo direttamente all'indice 1 dell'array), quindi andiamo a quell'indice nella tabella hash (es. Indice 0 ), verificare che le chiavi siano uguali (utilizzando lo stesso algoritmo descritto in precedenza) e, in tal caso, restituire il valore.

Manteniamo tempi di ricerca costanti, con minori perdite di velocità in alcuni casi e guadagni in altri, con i lati positivi che risparmiamo un bel po 'di spazio sull'implementazione preesistente e manteniamo l'ordine di inserzione. L'unico spazio sprecato sono i byte null nella matrice dell'indice.

Raymond Hettinger lo ha introdotto su python-dev nel dicembre 2012. È finalmente entrato in CPython in Python 3.6 . L'ordinamento per inserzione è stato considerato un dettaglio dell'implementazione per 3.6 per consentire ad altre implementazioni di Python di recuperare.

Chiavi condivise

Un'altra ottimizzazione per risparmiare spazio è un'implementazione che condivide le chiavi. Quindi, invece di avere dizionari ridondanti che occupano tutto quello spazio, abbiamo dizionari che riutilizzano le chiavi condivise e gli hash delle chiavi. Puoi pensarlo in questo modo:

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

Per una macchina a 64 bit, ciò potrebbe salvare fino a 16 byte per chiave per dizionario aggiuntivo.

Chiavi condivise per oggetti personalizzati e alternative

Questi dicts a chiave condivisa sono destinati all'uso per oggetti personalizzati ' __dict__. Per ottenere questo comportamento, credo che tu debba finire di popolare il tuo __dict__prima di istanziare il tuo prossimo oggetto ( vedi PEP 412 ). Questo significa che dovresti assegnare tutti i tuoi attributi nel __init__o __new__, altrimenti potresti non ottenere i tuoi risparmi di spazio.

Tuttavia, se conosci tutti i tuoi attributi al momento __init__dell'esecuzione, potresti anche fornire il __slots__tuo oggetto e garantire che __dict__non sia stato creato affatto (se non disponibile nei genitori), o addirittura consentire __dict__ma garantire che i tuoi attributi previsti siano memorizzato comunque negli slot. Per ulteriori informazioni __slots__, vedi la mia risposta qui .

Guarda anche:


1
Hai detto "noi" e "per consentire ad altre implementazioni di Python di recuperarci" - questo significa che "conosci le cose" e che potrebbe diventare una caratteristica permanente? C'è qualche svantaggio per i dadi ordinati secondo le specifiche?
toonarmycaptain

L'aspetto negativo di essere ordinati è che se si prevede che i dadi vengano ordinati, non possono facilmente passare a un'implementazione migliore / più veloce che non è ordinata. Sembra improbabile che sia così. Io "conosco le cose" perché guardo molte discussioni e leggo molte cose scritte da membri principali e altri con una reputazione nel mondo reale migliore di me, quindi anche se non ho una fonte immediatamente disponibile da citare, di solito lo so di cosa sto parlando. Ma penso che tu possa ottenere quel punto da uno dei discorsi di Raymond Hettinger.
Aaron Hall

1
Hai spiegato in qualche modo vagamente come funziona l'inserimento ("Se l'hash terminava come l'hash di una chiave preesistente, ... allora avrebbe incollato la coppia chiave-valore in una posizione diversa" - nessuna?), Ma non hai spiegato come funzionano la ricerca e il test di appartenenza. Non è del tutto chiaro come sia determinata la posizione dall'hash, ma suppongo che la dimensione sia sempre una potenza di 2, e prendi gli ultimi bit dell'hash ...
Alexey,

@Alexey L'ultimo link che fornisco ti dà l'implementazione del dict ben annotata - dove puoi trovare la funzione che fa questo, attualmente sulla linea 969, chiamata find_empty_slot: github.com/python/cpython/blob/master/Objects/dictobject.c # L969 - e partendo dalla linea 134 c'è della prosa che lo descrive.
Aaron Hall

46

I dizionari Python utilizzano l' indirizzamento aperto ( riferimento all'interno di Beautiful code )

NB! L'indirizzamento aperto , noto anche come hashing chiuso , non dovrebbe essere confuso, come osservato in Wikipedia, con il suo hashing aperto opposto !

L'indirizzamento aperto indica che il dict utilizza slot di array e quando la posizione principale di un oggetto viene presa nel dict, il punto dell'oggetto viene ricercato in un indice diverso nello stesso array, utilizzando uno schema "perturbazione", in cui il valore hash dell'oggetto gioca un ruolo .


5
"non essere confuso con il suo hashing aperto opposto! (che vediamo nella risposta accettata)." - Non sono sicuro di quale risposta sia stata accettata quando l'hai scritta, o cosa ha detto quella risposta in quel momento - ma questo commento tra parentesi non è attualmente vero della risposta accettata e sarebbe meglio rimuoverlo.
Tony Delroy,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.