Perché un dict Python può avere più chiavi con lo stesso hash?


92

Sto cercando di capire la hashfunzione Python sotto il cofano. Ho creato una classe personalizzata in cui tutte le istanze restituiscono lo stesso valore hash.

class C:
    def __hash__(self):
        return 42

Ho solo supposto che solo un'istanza della classe sopra possa essere in a dictin qualsiasi momento, ma in realtà a dictpuò avere più elementi con lo stesso hash.

c, d = C(), C()
x = {c: 'c', d: 'd'}
print(x)
# {<__main__.C object at 0x7f0824087b80>: 'c', <__main__.C object at 0x7f0823ae2d60>: 'd'}
# note that the dict has 2 elements

Ho sperimentato un po 'di più e ho scoperto che se sovrascrivo il __eq__metodo in modo tale che tutte le istanze della classe siano uguali, allora l' dictunica consente un'istanza.

class D:
    def __hash__(self):
        return 42
    def __eq__(self, other):
        return True

p, q = D(), D()
y = {p: 'p', q: 'q'}
print(y)
# {<__main__.D object at 0x7f0823a9af40>: 'q'}
# note that the dict only has 1 element

Quindi sono curioso di sapere come una dictpuò avere più elementi con lo stesso hash.


3
Come hai scoperto tu stesso, i set e i dict possono contenere più oggetti con hash uguali se gli oggetti non sono uguali. Cosa stai chiedendo? Come funzionano le tabelle? Questa è una domanda abbastanza generale con molto materiale esistente ...

@delnan stavo pensando di più a questo dopo aver pubblicato la domanda; che questo comportamento non può essere limitato a Python. E hai ragione. Immagino che dovrei approfondire la letteratura generale sulle tabelle Hash. Grazie.
Praveen Gollakota

Risposte:


58

Per una descrizione dettagliata di come funziona l'hashing di Python, vedere la mia risposta a Perché il ritorno anticipato è più lento di altri?

Fondamentalmente usa l'hash per scegliere uno slot nel tavolo. Se è presente un valore nello slot e l'hash corrisponde, confronta gli elementi per vedere se sono uguali.

Se l'hash non corrisponde o gli elementi non sono uguali, prova un altro slot. C'è una formula per scegliere questo (che descrivo nella risposta a cui si fa riferimento) e gradualmente estrae parti inutilizzate del valore hash; ma una volta che li ha usati tutti, alla fine si farà strada attraverso tutti gli slot nella tabella hash. Ciò garantisce che alla fine troviamo un oggetto corrispondente o uno slot vuoto. Quando la ricerca trova uno slot vuoto, inserisce il valore o rinuncia (a seconda che stiamo aggiungendo o ottenendo un valore).

La cosa importante da notare è che non ci sono elenchi o bucket: esiste solo una tabella hash con un numero particolare di slot e ogni hash viene utilizzato per generare una sequenza di slot candidati.


7
Grazie per avermi indirizzato nella giusta direzione sull'implementazione della tabella hash. Ho letto molto di più di quanto avrei mai voluto sulle tabelle hash e ho spiegato i miei risultati in una risposta separata. stackoverflow.com/a/9022664/553995
Praveen Gollakota

117

Ecco tutto sui dict di Python che sono stato in grado di mettere insieme (probabilmente più di quanto chiunque vorrebbe sapere; ma la risposta è completa). Un grido a Duncan per aver sottolineato che i dettami di Python usano gli slot e mi hanno portato in questa tana del coniglio.

  • I dizionari Python sono implementati come tabelle hash .
  • Le tabelle hash devono consentire collisioni hash, ovvero anche se due chiavi hanno lo stesso valore hash, l'implementazione della tabella deve avere una strategia per inserire e recuperare le coppie chiave e valore in modo univoco.
  • Python dict usa l'indirizzamento aperto per risolvere le collisioni hash (spiegate di seguito) (vedere dictobject.c: 296-297 ).
  • La tabella hash di Python è solo un blocco contingente di memoria (una specie di array, quindi puoi eseguire la 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 -. Questo è implementato come una struttura C (vedere dictobject.h: 51-56 )
  • La figura seguente è una rappresentazione logica di una tabella hash Python. Nella figura sotto, 0, 1, ..., i, ... a sinistra ci sono gli indici degli slot nella tabella hash (sono solo a scopo illustrativo e non vengono memorizzati insieme alla tabella ovviamente!).

    # 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, si inizia con uno slot, iche si basa sull'hash della chiave. CPython usa initial 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 anche PyPy) confronta l' hash E la chiave (per confronto intendo ==confronto non isconfronto) della voce nello slot con la chiave della voce corrente da inserire ( dictobject.c: 337 , 344-345 ). Se entrambi corrispondono, allora pensa che la voce esista già, rinuncia e passa alla voce successiva da inserire. Se l'hash o la chiave non corrispondono, inizia il sondaggio .
  • Sondare significa semplicemente che cerca gli 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 rilevamento lineare). Ma per ragioni spiegate magnificamente nei commenti (vedere 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 controllati fino a quando non viene trovato il primo slot vuoto.
  • La stessa cosa accade per le ricerche, inizia semplicemente con lo slot iniziale i (dove i dipende dall'hash della chiave). Se l'hash e la chiave non corrispondono entrambi alla voce nello slot, inizia a sondare, finché non trova uno slot con una corrispondenza. Se tutti gli slot sono esauriti, segnala un errore.
  • BTW, il dict verrà ridimensionato se è pieno per due terzi. Ciò evita di rallentare le ricerche. (vedi dictobject.h: 64-65 )

Ecco qua! L'implementazione Python di dict controlla sia l'uguaglianza hash di due chiavi che la normale uguaglianza ( ==) delle chiavi quando si inseriscono elementi. Quindi, in sintesi, se ci sono due chiavi, aed be hash(a)==hash(b), ma a!=b, poi entrambi possono esistere armoniosamente in un dict Python. Ma se hash(a)==hash(b) e a==b , allora non possono essere entrambi nello stesso dict.

Poiché dobbiamo sondare dopo ogni collisione di hash, un effetto collaterale di troppe collisioni di hash è che le ricerche e gli inserimenti diventeranno molto lenti (come sottolinea Duncan nei commenti ).

Immagino che la risposta breve alla mia domanda sia: "Perché è così che è implementato nel codice sorgente;)"

Anche se questo è buono a sapersi (per i punti geek?), Non sono sicuro di come possa essere utilizzato nella vita reale. Perché a meno che tu non stia cercando di rompere esplicitamente qualcosa, perché due oggetti che non sono uguali avrebbero lo stesso hash?


9
Questo spiega come funziona riempire il dizionario. Ma cosa succede se c'è una collisione hash durante il recupero di una coppia key_value. Supponiamo di avere 2 oggetti A e B, entrambi con hash 4. Quindi prima A viene assegnato lo slot 4 e poi B viene assegnato lo slot tramite sondaggio casuale. Cosa succede quando voglio recuperare gli hash B. B su 4, quindi Python controlla prima lo slot 4, ma la chiave non corrisponde quindi non può restituire A. Poiché lo slot di B è stato assegnato da un sondaggio casuale, come viene restituito B in O (1) tempo?
sayantankhan

4
@ Bolt64 il sondaggio casuale non è realmente casuale. Per gli stessi valori di chiave segue sempre la stessa sequenza di sonde, quindi alla fine troverà B. Non è garantito che i dizionari siano O (1), se si verificano molte collisioni possono richiedere più tempo. Con le versioni precedenti di Python è facile costruire una serie di chiavi che entreranno in collisione e in quel caso le ricerche nel dizionario diventano O (n). Questo è un possibile vettore per attacchi DoS, quindi le versioni più recenti di Python modificano l'hashing per rendere più difficile farlo deliberatamente.
Duncan

3
@ Duncan, cosa succede se A viene eliminato e quindi eseguiamo una ricerca su B? Immagino che in realtà non elimini le voci ma le contrassegni come eliminate? Ciò significherebbe che i dict non sono adatti per inserimenti ed eliminazioni continui ....
gen-ys

2
@ gen-ys yes cancellati e inutilizzati vengono gestiti in modo diverso per la ricerca. Unused interrompe la ricerca di una corrispondenza, ma l'eliminazione no. All'inserimento, cancellati o inutilizzati vengono trattati come slot vuoti che possono essere utilizzati. Gli inserimenti e le eliminazioni continui vanno bene. Quando il numero di slot inutilizzati (non cancellati) scende troppo, la tabella hash verrà ricostruita nello stesso modo come se fosse diventata troppo grande per la tabella corrente.
Duncan

1
Questa non è una risposta molto buona sul punto di collisione a cui Duncan ha cercato di porre rimedio. È una risposta particolarmente scarsa a cui fare riferimento per l'implementazione dalla tua domanda. La cosa principale per capirlo è che se c'è una collisione Python prova di nuovo usando una formula per calcolare il prossimo offset nella tabella hash. Al recupero, se la chiave non è la stessa, utilizza la stessa formula per cercare l'offset successivo. Non c'è niente di casuale in questo.
Evan Carroll

20

Modifica : la risposta di seguito è uno dei possibili modi per gestire le collisioni di hash, tuttavia non è come lo fa Python. Anche il wiki di Python a cui si fa riferimento di seguito non è corretto. La migliore fonte fornita da @Duncan di seguito è l'implementazione stessa: https://github.com/python/cpython/blob/master/Objects/dictobject.c Mi scuso per il miscuglio.


Memorizza un elenco (o un bucket) di elementi nell'hash, quindi scorre tale elenco finché non trova la chiave effettiva in quell'elenco. Un'immagine dice più di mille parole:

Tabella hash

Qui vedi John Smithed Sandra Deeentrambi hash to 152. Bucket li 152contiene entrambi. Quando si cerca Sandra Dee, prima trova l'elenco nel bucket 152, quindi scorre l'elenco finché non Sandra Deeviene trovato e ritorna 521-6955.

Quanto segue è sbagliato, è qui solo per contesto: Sul wiki di Python puoi trovare il codice (pseudo?) Su come Python esegue la ricerca.

In realtà ci sono diverse possibili soluzioni a questo problema, controlla l'articolo di wikipedia per una bella panoramica: http://en.wikipedia.org/wiki/Hash_table#Collision_resolution


Grazie per la spiegazione e soprattutto per il collegamento alla voce wiki di Python con lo pseudo codice!
Praveen Gollakota

2
Scusa, ma questa risposta è semplicemente sbagliata (così come l'articolo wiki). Python non memorizza un elenco o un bucket di elementi nell'hash: memorizza esattamente un oggetto in ogni slot della tabella hash. Se lo slot che tenta di utilizzare per primo è occupato, seleziona un altro slot (inserendo parti inutilizzate dell'hash il più a lungo possibile) e poi un altro e un altro. Poiché nessuna tabella hash è mai piena per più di un terzo, alla fine deve trovare uno slot disponibile.
Duncan

@ Duncan, il wiki di Python dice che è implementato in questo modo. Sarei felice di trovare una fonte migliore. La pagina wikipedia.org sicuramente non è sbagliata, è solo una delle possibili soluzioni come affermato.
Rob Wouters

@ Duncan Puoi spiegare per favore ... tirare dentro le parti non utilizzate dell'hash il più a lungo possibile? Tutti gli hash nel mio caso valutano 42. Grazie!
Praveen Gollakota

@PraveenGollakota Segui il link nella mia risposta, che spiega in dettaglio cruento come viene utilizzato l'hash. Per un hash 42 e una tabella con 8 slot inizialmente vengono utilizzati solo i 3 bit più bassi per trovare lo slot numero 2, ma se quello slot è già utilizzato, entrano in gioco i bit rimanenti. Se due valori hanno esattamente lo stesso hash, il primo va nel primo slot provato e il secondo ottiene lo slot successivo. Se ci sono 1000 valori con hash identici, finiamo per provare 1000 slot prima di trovare il valore e la ricerca nel dizionario diventa molto lenta!
Duncan

4

Le tabelle hash, in generale, devono consentire le collisioni di hash! Sarai sfortunato e due cose finiranno per avere la stessa cosa. Sotto, c'è un insieme di oggetti in un elenco di elementi che hanno la stessa chiave hash. Di solito, c'è solo una cosa in quell'elenco, ma in questo caso, continuerà a impilarli nello stesso. L'unico modo per sapere che sono diversi è tramite l'operatore uguale.

Quando ciò accade, le tue prestazioni peggioreranno nel tempo, motivo per cui vuoi che la tua funzione hash sia il più "casuale possibile".


2

Nel thread non ho visto cosa fa esattamente Python con le istanze di classi definite dall'utente quando lo inseriamo in un dizionario come chiavi. Leggiamo un po 'di documentazione: dichiara che solo gli oggetti hashable possono essere usati come chiavi. Hashable sono tutte le classi incorporate immutabili e tutte le classi definite dall'utente.

Le classi definite dall'utente hanno i metodi __cmp __ () e __hash __ () per impostazione predefinita; con loro, tutti gli oggetti si confrontano in modo disuguale (eccetto con se stessi) e x .__ hash __ () restituisce un risultato derivato da id (x).

Quindi, se hai un __hash__ costantemente nella tua classe, ma non fornisci alcun metodo __cmp__ o __eq__, allora tutte le tue istanze non sono uguali per il dizionario. D'altra parte, se fornisci un metodo __cmp__ o __eq__, ma non fornisci __hash__, le tue istanze sono ancora disuguali in termini di dizionario.

class A(object):
    def __hash__(self):
        return 42


class B(object):
    def __eq__(self, other):
        return True


class C(A, B):
    pass


dict_a = {A(): 1, A(): 2, A(): 3}
dict_b = {B(): 1, B(): 2, B(): 3}
dict_c = {C(): 1, C(): 2, C(): 3}

print(dict_a)
print(dict_b)
print(dict_c)

Produzione

{<__main__.A object at 0x7f9672f04850>: 1, <__main__.A object at 0x7f9672f04910>: 3, <__main__.A object at 0x7f9672f048d0>: 2}
{<__main__.B object at 0x7f9672f04990>: 2, <__main__.B object at 0x7f9672f04950>: 1, <__main__.B object at 0x7f9672f049d0>: 3}
{<__main__.C object at 0x7f9672f04a10>: 3}
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.