¿Por qué el orden en diccionarios y conjuntos es arbitrario?
No entiendo cómo el bucle sobre un diccionario o conjunto en Python se realiza mediante un orden "arbitrario".
Quiero decir, es un lenguaje de programación por lo que todo en el lenguaje debe estar determinado al 100%, ¿correcto? Python debe tener algún tipo de algoritmo que decida qué parte del diccionario o conjunto se elige, la primera, la segunda, etc.
¿Qué me estoy perdiendo?
Nota: Esta respuesta se escribió antes de que cambiara la implementación del
dict
tipo, en Python 3.6. La mayoría de los detalles de implementación en esta respuesta aún se aplican, pero el orden de lista de claves en los diccionarios ya no está determinado por los valores hash. La implementación establecida permanece sin cambios.
El orden no es arbitrario, sino que depende del historial de inserción y eliminación del diccionario o conjunto, así como de la implementación específica de Python. Para el resto de esta respuesta, para "diccionario", también puede leer "conjunto"; Los conjuntos se implementan como diccionarios con solo claves y sin valores.
Las claves están codificadas y los valores hash se asignan a las ranuras en una tabla dinámica (puede crecer o reducirse según las necesidades). Y ese proceso de mapeo puede provocar colisiones, lo que significa que será necesario colocar una clave en la siguiente ranura en función de lo que ya está allí.
La lista de contenidos se repite en las ranuras, por lo que las claves se enumeran en el orden en que residen actualmente en la tabla.
Tome las teclas 'foo'
y 'bar'
, por ejemplo, y supongamos que el tamaño de la mesa es de 8 ranuras. En Python 2.7, hash('foo')
es -4177197833195190597
, hash('bar')
es 327024216814240868
. Módulo 8, eso significa que estas dos llaves se colocan en las ranuras 3 y 4, luego:
>>> hash('foo')
-4177197833195190597
>>> hash('foo') % 8
3
>>> hash('bar')
327024216814240868
>>> hash('bar') % 8
4
Esto informa su orden de listado:
>>> {'bar': None, 'foo': None}
{'foo': None, 'bar': None}
Todos los espacios, excepto el 3 y el 4, están vacíos; al recorrer la tabla, primero se enumera el espacio 3, luego el espacio 4, por lo que 'foo'
aparece antes 'bar'
.
bar
y baz
, sin embargo, tienen valores hash que están exactamente separados por 8 y, por lo tanto, se asignan exactamente a la misma ranura 4
:
>>> hash('bar')
327024216814240868
>>> hash('baz')
327024216814240876
>>> hash('bar') % 8
4
>>> hash('baz') % 8
4
Su orden ahora depende de qué llave se insertó primero; la segunda clave deberá moverse a la siguiente ranura:
>>> {'baz': None, 'bar': None}
{'bar': None, 'baz': None}
>>> {'bar': None, 'baz': None}
{'baz': None, 'bar': None}
El orden de la tabla difiere aquí porque primero se insertó una u otra llave.
El nombre técnico de la estructura subyacente utilizada por CPython (la implementación de Python más utilizada) es tabla hash , una que utiliza direccionamiento abierto. Si tiene curiosidad y comprende C lo suficientemente bien, eche un vistazo a la implementación de C para conocer todos los detalles (bien documentados). También puede ver esta presentación de Pycon 2010 realizada por Brandon Rhodes sobre cómo funciona CPython dict
, o adquirir una copia de Beautiful Code , que incluye un capítulo sobre la implementación escrito por Andrew Kuchling.
Tenga en cuenta que a partir de Python 3.3, también se utiliza una semilla hash aleatoria, lo que hace que las colisiones hash sean impredecibles para evitar ciertos tipos de denegación de servicio (donde un atacante hace que un servidor Python no responda provocando colisiones masivas de hash). Esto significa que el orden de un diccionario o conjunto determinado también depende de la semilla hash aleatoria para la invocación actual de Python.
Otras implementaciones son libres de usar una estructura diferente para los diccionarios, siempre que cumplan con la interfaz Python documentada para ellos, pero creo que todas las implementaciones hasta ahora usan una variación de la tabla hash.
CPython 3.6 presenta una nueva dict
implementación que mantiene el orden de inserción y es más rápido y eficiente en cuanto a memoria para arrancar. En lugar de mantener una tabla grande y dispersa donde cada fila hace referencia al valor hash almacenado y a los objetos de clave y valor, la nueva implementación agrega una matriz hash más pequeña que solo hace referencia a índices en una tabla 'densa' separada (una que solo contiene tantas filas como sea posible). ya que existen pares clave-valor reales), y es la tabla densa la que enumera los elementos contenidos en orden. Consulte la propuesta para Python-Dev para obtener más detalles . Tenga en cuenta que en Python 3.6 esto se considera un detalle de implementación ; el lenguaje Python no especifica que otras implementaciones deben mantener el orden. Esto cambió en Python 3.7, donde este detalle se elevó a especificación del lenguaje ; Para que cualquier implementación sea adecuadamente compatible con Python 3.7 o posterior, debe copiar este comportamiento de preservación del orden. Y para ser explícito: este cambio no se aplica a los conjuntos, ya que los conjuntos ya tienen una estructura hash "pequeña".
Python 2.7 y versiones posteriores también proporcionan una OrderedDict
clase , una subclase dict
que agrega una estructura de datos adicional para registrar el orden de las claves. Al precio de algo de velocidad y memoria adicional, esta clase recuerda en qué orden insertaste las claves; enumerar claves, valores o elementos lo hará en ese orden. Utiliza una lista doblemente enlazada almacenada en un diccionario adicional para mantener el pedido actualizado de manera eficiente. Vea la publicación de Raymond Hettinger que describe la idea . OrderedDict
Los objetos tienen otras ventajas, como ser reordenables .
Si desea un conjunto ordenado, puede instalar el oset
paquete ; Funciona en Python 2.5 y versiones posteriores.
Esto es más una respuesta al conjunto Python 3.41 A antes de que se cerrara como duplicado.
Los demás tienen razón: no te fíes del orden. Ni siquiera finjas que existe uno.
Dicho esto, hay una cosa en la que puedes confiar:
list(myset) == list(myset)
Es decir, el orden es estable .
Comprender por qué se percibe un orden requiere comprender algunas cosas:
Que Python usa conjuntos de hash ,
Cómo se almacena el conjunto de hash de CPython en la memoria y
Cómo se procesan los números
Desde la parte superior:
Un conjunto de hash es un método para almacenar datos aleatorios con tiempos de búsqueda realmente rápidos.
Tiene una matriz de respaldo:
# A C array; items may be NULL,
# a pointer to an object, or a
# special dummy object
_ _ 4 _ _ 2 _ _ 6
Ignoraremos el objeto ficticio especial, que existe sólo para hacer que las eliminaciones sean más fáciles de manejar, porque no eliminaremos de estos conjuntos.
Para realizar una búsqueda realmente rápida, se hace algo de magia para calcular un hash a partir de un objeto. La única regla es que dos objetos iguales tienen el mismo hash. (Pero si dos objetos tienen el mismo hash, pueden ser desiguales).
Luego creas un índice tomando el módulo por la longitud de la matriz:
hash(4) % len(storage) = index 2
Esto hace que el acceso a los elementos sea realmente rápido.
Los hashes son sólo la mayor parte de la historia, ya que hash(n) % len(storage)
y hash(m) % len(storage)
pueden dar como resultado el mismo número. En ese caso, varias estrategias diferentes pueden intentar resolver el conflicto. CPython utiliza "sondeo lineal" 9 veces antes de realizar un sondeo pseudoaleatorio, por lo que buscará a la derecha de la ranura hasta en 9 lugares antes de buscar en otra parte.
Los conjuntos de hash de CPython se almacenan así:
Un conjunto de hash no puede estar lleno en más del 60 % ( nota: este factor de carga era anteriormente del 66 % y se redujo en Python 3.7). Si hay 20 elementos y la matriz de respaldo tiene 30 elementos de largo, el tamaño del almacén de respaldo cambiará para ser más grande. Esto se debe a que se producen colisiones con mayor frecuencia con tiendas de respaldo pequeñas y las colisiones ralentizan todo.
When the backing store becomes too full, it will be automatically resized to increase the ratio of unused space (a higher ratio of unused space means it's faster to find a slot when handling a hash collision). For small sets, storage will be quadrupled in size, and for large sets (>50,000) it will be doubled in size (source).
So when you create an array the backing store is length 8. Once it is 4 full and you add an element, it will contain 5 elements. 5 > ³⁄₅·8
so this triggers a resize, and the backing store quadruples to size 32.
>>> import sys
>>> s = set()
>>> for i in range(10):
... print(len(s), sys.getsizeof(s))
... s.add(i)
...
0 216
1 216
2 216
3 216
4 216
5 728
6 728
7 728
8 728
9 728
Finally, hash(n)
just returns n
for integers (except for hash(-1)
which returns -2
because the value -1
is reserved for another usage).
So, let's look at the first one:
v_set = {88,11,1,33,21,3,7,55,37,8}
len(v_set)
is 10, so the backing store is at least 15(+1) after all items have been added. The relevant power of 2 is 32. So the backing store is:
__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __
We have
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
so these insert as:
__ 1 __ 3 __ 37 __ 7 8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __
33 ← Can't also be where 1 is;
either 1 or 33 has to move
So we would expect an order like
{[1 or 33], 3, 37, 7, 8, 11, 21, 55, 88}
with the 1 or 33 that isn't at the start somewhere else. This will use linear probing, so we will either have:
↓
__ 1 33 3 __ 37 __ 7 8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __
or
↓
__ 33 1 3 __ 37 __ 7 8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __
You might expect the 33 to be the one that's displaced because the 1 was already there, but due to the resizing that happens as the set is being built, this isn't actually the case. Every time the set gets rebuilt, the items already added are effectively reordered.
Now you can see why
{7,5,11,1,4,13,55,12,2,3,6,20,9,10}
might be in order. There are 14 elements, so the backing store is at least 21+1, which means 32:
__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __
1 to 13 hash in the first 13 slots. 20 goes in slot 20.
__ 1 2 3 4 5 6 7 8 9 10 11 12 13 __ __ __ __ __ __ 20 __ __ __ __ __ __ __ __ __ __ __
55 goes in slot hash(55) % 32
which is 23:
__ 1 2 3 4 5 6 7 8 9 10 11 12 13 __ __ __ __ __ __ 20 __ __ 55 __ __ __ __ __ __ __ __
If we chose 50 instead, we'd expect
__ 1 2 3 4 5 6 7 8 9 10 11 12 13 __ __ __ __ 50 __ 20 __ __ __ __ __ __ __ __ __ __ __
And lo and behold:
>>> {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
is implemented quite simply by the looks of things: it traverses the underlying array and pops the first element, skipping over unused slots and "dummy" entries (tombstone markers from removed elements).