Perché l'ordine nei dizionari e nei set è arbitrario?


152

Non capisco come il ciclo su un dizionario o impostato in Python sia fatto per ordine "arbitrario".

Voglio dire, è un linguaggio di programmazione, quindi tutto nella lingua deve essere determinato al 100%, giusto? Python deve avere un qualche tipo di algoritmo che decide quale parte del dizionario o set viene scelta, prima, seconda e così via.

Cosa mi sto perdendo?


1
La nuovissima build PyPy (2.5, per Python 2.7) crea dizionari ordinati per impostazione predefinita .
Veedrac,

Risposte:


236

Nota: questa risposta è stata scritta prima della modifica dell'implementazione del dicttipo, in Python 3.6. La maggior parte dei dettagli di implementazione in questa risposta è ancora valida, ma l'ordine di elenco delle chiavi nei dizionari non è più determinato dai valori di hash. L'implementazione impostata rimane invariata.

L'ordine non è arbitrario, ma dipende dalla cronologia di inserimento ed eliminazione del dizionario o del set, nonché dalla specifica implementazione di Python. Per il resto di questa risposta, per 'dizionario', puoi anche leggere 'set'; gli insiemi sono implementati come dizionari con solo chiavi e senza valori.

Le chiavi sono hash e i valori di hash sono assegnati agli slot in una tabella dinamica (può crescere o ridursi in base alle esigenze). E quel processo di mappatura può portare a collisioni, il che significa che una chiave dovrà essere inserita nello slot successivo in base a ciò che è già lì.

Elencare i loop dei contenuti sugli slot, quindi le chiavi sono elencate nell'ordine in cui risiedono attualmente nella tabella.

Prendi i tasti 'foo'e 'bar', ad esempio, e supponiamo che la dimensione della tabella sia di 8 slot. In Python 2.7, hash('foo')è -4177197833195190597, hash('bar')è 327024216814240868. Modulo 8, ciò significa che questi due tasti sono inseriti negli slot 3 e 4, quindi:

>>> hash('foo')
-4177197833195190597
>>> hash('foo') % 8
3
>>> hash('bar')
327024216814240868
>>> hash('bar') % 8
4

Questo informa il loro ordine di quotazione:

>>> {'bar': None, 'foo': None}
{'foo': None, 'bar': None}

Tutti gli slot ad eccezione di 3 e 4 sono vuoti, ripetendo ciclicamente la tabella prima elenca lo slot 3, quindi lo slot 4, quindi 'foo'è elencato prima 'bar'.

bare baz, tuttavia, hanno valori di hash distanti esattamente 8 e quindi associati allo stesso slot esatto 4:

>>> hash('bar')
327024216814240868
>>> hash('baz')
327024216814240876
>>> hash('bar') % 8
4
>>> hash('baz') % 8
4

Il loro ordine ora dipende da quale chiave è stata inserita per prima; la seconda chiave dovrà essere spostata nello slot successivo:

>>> {'baz': None, 'bar': None}
{'bar': None, 'baz': None}
>>> {'bar': None, 'baz': None}
{'baz': None, 'bar': None}

Qui l'ordine delle tabelle differisce, perché l'una o l'altra chiave è stata inserita per prima.

Il nome tecnico per la struttura sottostante utilizzata da CPython (l'implementazione di Python più comunemente usata) è una tabella hash , che utilizza l'indirizzamento aperto. Se sei curioso e capisci abbastanza bene C, dai un'occhiata all'implementazione di C per tutti i dettagli (ben documentati). Potresti anche guardare questa presentazione di Pycon 2010 di Brandon Rhodes su come dictfunziona CPython , o prendere una copia di Beautiful Code , che include un capitolo sull'implementazione scritto da Andrew Kuchling.

Si noti che a partire da Python 3.3 viene utilizzato anche un seed hash casuale, rendendo imprevedibili le collisioni hash per impedire alcuni tipi di denial of service (laddove un utente malintenzionato non risponde a un server Python causando collisioni hash di massa). Ciò significa che l'ordine di un determinato dizionario o set dipende anche dal seme di hash casuale per l'invocazione Python corrente.

Altre implementazioni sono libere di utilizzare una struttura diversa per i dizionari, purché soddisfino per loro l'interfaccia Python documentata, ma credo che tutte le implementazioni finora utilizzino una variante della tabella hash.

CPython 3.6 introduce una nuova dict implementazione che mantiene l'ordine di inserimento ed è più veloce ed efficiente in termini di memoria per l'avvio. Anziché mantenere una tabella sparsa di grandi dimensioni in cui ogni riga fa riferimento al valore hash archiviato e agli oggetti chiave e valore, la nuova implementazione aggiunge un array hash più piccolo che fa riferimento solo agli indici in una tabella 'densa' separata (una che contiene solo tante righe poiché esistono coppie chiave-valore effettive), ed è la tabella densa che elenca gli elementi contenuti in ordine. Vedi la proposta a Python-Dev per maggiori dettagli . Si noti che in Python 3.6 questo è considerato un dettaglio di implementazione, Python-the-language non specifica che altre implementazioni debbano mantenere l'ordine. Questo è cambiato in Python 3.7, dove questo dettaglio è stato elevato per essere una specifica del linguaggio ; affinché qualsiasi implementazione sia correttamente compatibile con Python 3.7 o più recente, deve copiare questo comportamento di conservazione dell'ordine. E per essere espliciti: questa modifica non si applica agli insiemi, poiché gli insiemi hanno già una struttura di hash "piccola".

Python 2.7 e versioni successive forniscono anche una OrderedDictclasse , una sottoclasse dictche aggiunge una struttura di dati aggiuntiva per registrare l'ordine delle chiavi. Al prezzo di una certa velocità e memoria aggiuntiva, questa classe ricorda in quale ordine sono state inserite le chiavi; l'elenco delle chiavi, dei valori o degli elementi lo farà in questo ordine. Utilizza un elenco doppiamente collegato memorizzato in un dizionario aggiuntivo per mantenere l'ordine aggiornato in modo efficiente. Vedi il post di Raymond Hettinger che delinea l'idea . OrderedDictgli oggetti presentano altri vantaggi, come il riordino .

Se si desidera un set ordinato, è possibile installare il osetpacchetto ; funziona su Python 2.5 e versioni successive.


1
Non credo che altre implementazioni di Python possano usare qualsiasi cosa che non sia una tabella hash in un modo o nell'altro (anche se ora ci sono miliardi di modi diversi per implementare le tabelle hash, quindi c'è ancora un po 'di libertà). Il fatto che i dizionari utilizzino __hash__e __eq__(e nient'altro) è praticamente una garanzia linguistica, non un dettaglio di implementazione.

1
@delnan: Mi chiedo se puoi ancora usare un BTree con hash e test di uguaglianza .. Non lo escluderò sicuramente, in ogni caso. :-)
Martijn Pieters

1
È certamente corretto, e sarei felice di essere smentito dalla fattibilità sbagliata, ma non vedo alcun modo in cui si possa battere un tavolo di hash senza richiedere un contratto più ampio. Un BTree non avrebbe prestazioni migliori nel caso medio e non ti darebbe neanche il caso peggiore (le collisioni di hash significano ancora ricerca lineare). Quindi ottieni solo una migliore resistenza a molti hash neomg congruenti (mod tablesize), e ci sono molti altri ottimi modi per gestirlo (alcuni dei quali sono utilizzati in dictobject.c) e finiscono con molti meno confronti di quanti un BTree abbia bisogno di trovare persino il giusto sottostruttura.

@delnan: sono completamente d'accordo; Soprattutto non volevo essere criticato per non aver permesso altre opzioni di implementazione.
Martijn Pieters

37

Questa è più una risposta a Python 3.41 Un set prima che fosse chiuso come duplicato.


Gli altri hanno ragione: non fare affidamento sull'ordine. Non fingere nemmeno che ce ne sia uno.

Detto questo, c'è una cosa su cui puoi contare:

list(myset) == list(myset)

Cioè, l'ordine è stabile .


Capire perché esiste un ordine percepito richiede la comprensione di alcune cose:

  • Che Python utilizza set di hash ,

  • Come il set di hash di CPython è archiviato in memoria e

  • Come i numeri vengono cancellati

Dall'alto:

Un set di hash è un metodo per archiviare dati casuali con tempi di ricerca molto rapidi.

Ha un array di supporto:

# A C array; items may be NULL,
# a pointer to an object, or a
# special dummy object
_ _ 4 _ _ 2 _ _ 6

Ignoreremo l'oggetto fittizio speciale, che esiste solo per semplificare la gestione delle rimozioni, poiché non rimuoveremo da questi set.

Per avere una ricerca davvero veloce, fai un po 'di magia per calcolare un hash da un oggetto. L'unica regola è che due oggetti uguali abbiano lo stesso hash. (Ma se due oggetti hanno lo stesso hash possono essere disuguali.)

Quindi fai l'indice prendendo il modulo per la lunghezza dell'array:

hash(4) % len(storage) = index 2

Questo rende veramente veloce l'accesso agli elementi.

Gli hash sono solo la maggior parte della storia, come hash(n) % len(storage)e hash(m) % len(storage)possono provocare lo stesso numero. In tal caso, diverse strategie diverse possono tentare di risolvere il conflitto. CPython usa il "probing lineare" 9 volte prima di fare cose complicate, quindi guarderà a sinistra dello slot per un massimo di 9 posti prima di cercare altrove.

I set di hash di CPython sono memorizzati in questo modo:

  • Un set di hash non può essere più di 2/3 completo . Se sono presenti 20 elementi e l'array di backup è lungo 30 elementi, l'archivio di backup verrà ridimensionato per essere più grande. Questo perché si ottengono collisioni più spesso con piccoli negozi di supporto e le collisioni rallentano tutto.

  • Il negozio di supporto si ridimensiona in potenze di 4, a partire da 8, ad eccezione di grandi set (50k elementi) che si ridimensionano in potenze di due: (8, 32, 128, ...).

Pertanto, quando si crea un array, l'archivio di supporto ha lunghezza 8. Quando è 5 pieno e si aggiunge un elemento, conterrà brevemente 6 elementi. 6 > ²⁄₃·8quindi questo attiva un ridimensionamento e il negozio di supporto quadruplica alla dimensione 32.

Infine, hash(n)restituisce solo i nnumeri (tranne -1che è speciale).


Quindi, diamo un'occhiata al primo:

v_set = {88,11,1,33,21,3,7,55,37,8}

len(v_set)è 10, quindi il negozio di supporto è almeno 15 (+1) dopo che tutti gli articoli sono stati aggiunti . Il potere rilevante di 2 è 32. Quindi il negozio di supporto è:

__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __

abbiamo

hash(88) % 32 = 24
hash(11) % 32 = 11
hash(1)  % 32 = 1
hash(33) % 32 = 1
hash(21) % 32 = 21
hash(3)  % 32 = 3
hash(7)  % 32 = 7
hash(55) % 32 = 23
hash(37) % 32 = 5
hash(8)  % 32 = 8

quindi questi inseriscono come:

__  1 __  3 __ 37 __  7  8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __
   33 ← Can't also be where 1 is;
        either 1 or 33 has to move

Quindi ci aspetteremmo un ordine simile

{[1 or 33], 3, 37, 7, 8, 11, 21, 55, 88}

con 1 o 33 che non è all'inizio altrove. Questo utilizzerà il probing lineare, quindi avremo:


__  1 33  3 __ 37 __  7  8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __

o


__ 33  1  3 __ 37 __  7  8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __

Potresti aspettarti che il 33 sia quello che è stato spostato perché l'1 era già lì, ma a causa del ridimensionamento che accade mentre il set viene creato, questo non è in realtà il caso. Ogni volta che il set viene ricostruito, gli elementi già aggiunti vengono effettivamente riordinati.

Ora puoi vedere perché

{7,5,11,1,4,13,55,12,2,3,6,20,9,10}

potrebbe essere in ordine. Ci sono 14 elementi, quindi il negozio di supporto è almeno 21 + 1, il che significa 32:

__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __

1 a 13 hash nei primi 13 slot. 20 va nello slot 20.

__  1  2  3  4  5  6  7  8  9 10 11 12 13 __ __ __ __ __ __ 20 __ __ __ __ __ __ __ __ __ __ __

55 va nello slot hash(55) % 32che è 23:

__  1  2  3  4  5  6  7  8  9 10 11 12 13 __ __ __ __ __ __ 20 __ __ 55 __ __ __ __ __ __ __ __

Se invece scegliessimo 50, ci aspetteremmo

__  1  2  3  4  5  6  7  8  9 10 11 12 13 __ __ __ __ 50 __ 20 __ __ __ __ __ __ __ __ __ __ __

Ed ecco:

{1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 20, 50}
#>>> {1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 50, 20}

pop è implementato semplicemente dall'aspetto delle cose: attraversa l'elenco e fa apparire il primo.


Questo è tutto dettaglio di implementazione.


17

"Arbitrario" non è la stessa cosa di "non determinato".

Quello che stanno dicendo è che non ci sono proprietà utili nell'ordine di iterazione del dizionario che sono "nell'interfaccia pubblica". Ci sono quasi certamente molte proprietà dell'ordine di iterazione che sono completamente determinate dal codice che attualmente implementa l'iterazione del dizionario, ma gli autori non ti stanno promettendo come qualcosa che puoi usare. Questo dà loro più libertà di cambiare queste proprietà tra le versioni di Python (o anche solo in diverse condizioni operative, o completamente a caso in fase di esecuzione) senza preoccuparsi che il programma si interrompa.

Pertanto, se scrivi un programma che dipende da qualsiasi proprietà dell'ordine del dizionario, allora stai "rompendo il contratto" dell'uso del tipo di dizionario e gli sviluppatori Python non promettono che funzionerà sempre, anche se sembra funzionare per ora quando lo provi. Fondamentalmente è l'equivalente di fare affidamento sul "comportamento indefinito" in C.


3
Si noti che una parte dell'iterazione del dizionario è ben definita: l'iterazione su chiavi, valori o elementi di un determinato dizionario avverrà ciascuno nello stesso ordine, purché non siano state apportate modifiche al dizionario nel mezzo. Ciò significa che d.items()è essenzialmente identico a zip(d.keys(), d.values()). Se vengono comunque aggiunti elementi al dizionario, tutte le scommesse sono disattivate. L'ordine potrebbe cambiare completamente (se la tabella hash avesse bisogno di essere ridimensionata), anche se la maggior parte delle volte potresti trovare il nuovo oggetto apparire in qualche punto arbitrario della sequenza.
Blckknght,

6

Le altre risposte a questa domanda sono eccellenti e ben scritte. L'OP chiede "come" che interpreto come "come fanno a cavarsela" o "perché".

La documentazione di Python afferma che i dizionari non sono ordinati perché il dizionario Python implementa l' array associativo di tipi di dati astratti . Come dicono

l'ordine in cui vengono restituiti i binding può essere arbitrario

In altre parole, uno studente di informatica non può presumere che sia ordinata una matrice associativa. Lo stesso vale per i set in matematica

l'ordine in cui sono elencati gli elementi di un set è irrilevante

e informatica

un set è un tipo di dati astratto che può memorizzare determinati valori, senza alcun ordine particolare

L'implementazione di un dizionario usando una tabella hash è un dettaglio di implementazione che è interessante in quanto ha le stesse proprietà degli array associativi per quanto riguarda l'ordine.


1
Hai fondamentalmente ragione, ma sarebbe un po 'più vicino (e dare un buon suggerimento al motivo per cui è "non ordinato") per dire che è un'implementazione di una tabella hash piuttosto che un array assoc.
Two-Bit Alchemist,

5

Python usa la tabella hash per archiviare i dizionari, quindi non c'è ordine nei dizionari o in altri oggetti iterabili che usano la tabella hash.

Ma per quanto riguarda gli indici degli oggetti in un oggetto hash, Python calcola gli indici in base al seguente codice all'internohashtable.c :

key_hash = ht->hash_func(key);
index = key_hash & (ht->num_buckets - 1);

Perciò, come il valore hash di interi è il numero intero sé * L'indice è basato sul numero ( ht->num_buckets - 1è una costante) quindi l'indice calcolato da Bitwise-e tra (ht->num_buckets - 1)ed il numero stesso * (-1 aspetterebbe per cui di hash è -2 ) e per altri oggetti con il loro valore di hash.

considerare il seguente esempio con setquell'uso hash-table:

>>> set([0,1919,2000,3,45,33,333,5])
set([0, 33, 3, 5, 45, 333, 2000, 1919])

Per numero 33abbiamo:

33 & (ht->num_buckets - 1) = 1

Che in realtà è:

'0b100001' & '0b111'= '0b1' # 1 the index of 33

Nota in questo caso (ht->num_buckets - 1)è 8-1=7o 0b111.

E per 1919:

'0b11101111111' & '0b111' = '0b111' # 7 the index of 1919

E per 333:

'0b101001101' & '0b111' = '0b101' # 5 the index of 333

Per maggiori dettagli sulla funzione hash di Python è bene leggere le seguenti citazioni dal codice sorgente di Python :

Principali sottigliezze: la maggior parte degli schemi di hash dipende dall'avere una "buona" funzione hash, nel senso di simulare la casualità. Python no: le sue funzioni hash più importanti (per stringhe e ints) sono molto regolari nei casi comuni:

>>> map(hash, (0, 1, 2, 3))
  [0, 1, 2, 3]
>>> map(hash, ("namea", "nameb", "namec", "named"))
  [-1658398457, -1658398460, -1658398459, -1658398462]

Questo non è necessariamente male! Al contrario, in una tabella di dimensione 2 ** i, prendere i bit di ordine inferiore come indice della tabella iniziale è estremamente veloce e non ci sono collisioni per i dadi indicizzati da un intervallo contiguo di ints. Lo stesso vale approssimativamente quando le chiavi sono stringhe "consecutive". Quindi questo dà un comportamento migliore di quello casuale nei casi comuni, ed è molto desiderabile.

OTOH, quando si verificano collisioni, la tendenza a riempire sezioni contigue della tabella hash rende cruciale una buona strategia di risoluzione delle collisioni. Anche prendere solo gli ultimi i bit del codice hash è vulnerabile: ad esempio, considera l'elenco [i << 16 for i in range(20000)]come un insieme di chiavi. Poiché gli ints sono i loro codici hash, e questo rientra in un valore di dimensione 2 ** 15, gli ultimi 15 bit di ogni codice hash sono tutti 0: tutti mappano sullo stesso indice di tabella.

Ma soddisfare casi insoliti non dovrebbe rallentare i soliti, quindi prendiamo comunque gli ultimi bit. Spetta alla risoluzione delle collisioni fare il resto. Se di solito troviamo la chiave che stiamo cercando al primo tentativo (e, a quanto pare, di solito lo facciamo - il fattore di carico della tabella viene mantenuto sotto i 2/3, quindi le probabilità sono decisamente a nostro favore), quindi ha senso mantenere lo sporco iniziale di calcolo dell'indice a buon mercato.


* La funzione hash per la classe int:

class int:
    def __hash__(self):
        value = self
        if value == -1:
            value = -2
        return value


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.