¿Cómo se implementa set ()?

151

He visto a gente decir que los setobjetos en python tienen O (1) verificación de membresía. ¿Cómo se implementan internamente para permitir esto? ¿Qué tipo de estructura de datos utiliza? ¿Qué otras implicaciones tiene esa implementación?

Cada respuesta aquí fue realmente esclarecedora, pero solo puedo aceptar una, así que iré con la respuesta más cercana a mi pregunta original. ¡Gracias a todos por la información!

Daenyth
fuente

Respuestas:

139

De acuerdo con este hilo :

De hecho, los conjuntos de CPython se implementan como algo así como diccionarios con valores ficticios (las claves son los miembros del conjunto), con algunas optimizaciones que aprovechan esta falta de valores.

Entonces, básicamente, setusa una tabla hash como su estructura de datos subyacente. Esto explica la verificación de membresía O (1), ya que buscar un elemento en una tabla hash es una operación O (1), en promedio.

Si está tan inclinado, incluso puede navegar por el código fuente de CPython para el conjunto que, según Achim Domma , es principalmente cortar y pegar desde la dictimplementación.

Justin Ethier
fuente
18
IIRC, la setimplementación original en realidad era dict con valores ficticios, y se optimizó más tarde.
dan04
1
¿No es grande O el peor de los casos? Si puede encontrar una instancia donde el tiempo es O (n), entonces es O (n) ... No entiendo nada de todos esos tutoriales en este momento.
Claudiu Creanga
44
No, el caso promedio es O (1) pero el peor caso es O (N) para la búsqueda de tablas hash.
Justin Ethier
44
@ClaudiuCreanga este es un comentario antiguo, pero solo para aclarar: la notación big-O le dice límites superiores en la tasa de crecimiento de las cosas, pero puede limitar el crecimiento del rendimiento promedio de los casos y puede limitar el crecimiento del peor de los casos por separado actuación.
Kirk Boyer
79

Cuando las personas dicen que los conjuntos tienen O (1) verificación de membresía, están hablando del caso promedio . En el peor de los casos (cuando todos los valores hash colisionan) la verificación de membresía es O (n). Vea el wiki de Python sobre la complejidad del tiempo .

El artículo de Wikipedia dice que la mejor complejidad de tiempo de caso para una tabla hash que no cambia de tamaño es O(1 + k/n). Este resultado no se aplica directamente a los conjuntos de Python ya que los conjuntos de Python usan una tabla hash que cambia el tamaño.

Un poco más adelante en el artículo de Wikipedia dice que para el caso promedio , y suponiendo una función de hashing uniforme simple, la complejidad del tiempo es O(1/(1-k/n)), donde k/npuede estar limitada por una constante c<1.

Big-O se refiere solo al comportamiento asintótico como n → ∞. Como k / n puede estar limitado por una constante, c <1, independiente de n ,

O(1/(1-k/n))no es más grande que lo O(1/(1-c))que equivale a O(constant)= O(1).

Entonces, suponiendo un hashing simple y uniforme, en promedio , la verificación de membresía para conjuntos de Python es O(1).

unutbu
fuente
14

Creo que es un error común, la setbúsqueda (o tabla hash) no son O (1).
de la Wikipedia

En el modelo más simple, la función hash está completamente sin especificar y la tabla no cambia de tamaño. Para la mejor elección posible de la función hash, una tabla de tamaño n con direccionamiento abierto no tiene colisiones y contiene hasta n elementos, con una sola comparación para una búsqueda exitosa, y una tabla de tamaño n con encadenamiento y teclas k tiene el mínimo máximo (0, kn) colisiones y O (1 + k / n) comparaciones para la búsqueda. Para la peor elección de la función hash, cada inserción provoca una colisión, y las tablas hash degeneran en búsqueda lineal, con Ω (k) comparaciones amortizadas por inserción y hasta k comparaciones para una búsqueda exitosa.

Relacionado: ¿Es un hashmap de Java realmente O (1)?

Shay Erlichmen
fuente
44
Pero toman tiempo constante para buscar elementos: python -m timeit -s "s = set (range (10))" "5 in s" 10000000 bucles, lo mejor de 3: 0.0642 usec por bucle <--> python - m timeit -s "s = set (range (10000000))" "5 in s" 10000000 bucles, lo mejor de 3: 0.0634 usec por bucle ... y ese es el conjunto más grande que no arroja MemoryErrors
Jochen Ritzel
2
@ THC4k Todo lo que demostró es que buscar X se realiza en tiempo constante, pero eso no significa que el tiempo para buscar X + Y tomará la misma cantidad de tiempo, de eso se trata O (1).
Shay Erlichmen
3
@intuited: lo hace, pero la prueba anterior no prueba que pueda buscar "5" al mismo tiempo que puede buscar "485398", o algún otro número que pueda estar en un horrible espacio de colisión. No se trata de buscar el mismo elemento en un hash de diferente tamaño al mismo tiempo (de hecho, eso no es obligatorio), sino más bien se trata de si puede acceder a cada entrada en la misma cantidad de tiempo en la tabla actual: algo que es básicamente imposible de lograr para las tablas hash, ya que generalmente siempre habrá colisiones.
Nick Bastin
3
En otras palabras, el tiempo para hacer una búsqueda depende de la cantidad de valores almacenados, porque eso aumenta la probabilidad de colisiones.
intuido
3
@intuited: no, eso es incorrecto. Cuando aumenta el número de valores almacenados, Python aumentará automáticamente el tamaño de la tabla hash y la tasa de colisión se mantendrá aproximadamente constante. Suponiendo un algoritmo hash O (1) distribuido uniformemente, la búsqueda de tabla hash se amortiza O (1). Es posible que desee ver la presentación en video "The Mighty Dictionary" python.mirocommunity.org/video/1591/…
Lie Ryan
13

Todos tenemos acceso fácil a la fuente , donde el comentario anterior set_lookkey()dice:

/* set object implementation
 Written and maintained by Raymond D. Hettinger <[email protected]>
 Derived from Lib/sets.py and Objects/dictobject.c.
 The basic lookup function used by all operations.
 This is based on Algorithm D from Knuth Vol. 3, Sec. 6.4.
 The initial probe index is computed as hash mod the table size.
 Subsequent probe indices are computed as explained in Objects/dictobject.c.
 To improve cache locality, each probe inspects a series of consecutive
 nearby entries before moving on to probes elsewhere in memory.  This leaves
 us with a hybrid of linear probing and open addressing.  The linear probing
 reduces the cost of hash collisions because consecutive memory accesses
 tend to be much cheaper than scattered probes.  After LINEAR_PROBES steps,
 we then use open addressing with the upper bits from the hash value.  This
 helps break-up long chains of collisions.
 All arithmetic on hash should ignore overflow.
 Unlike the dictionary implementation, the lookkey function can return
 NULL if the rich comparison returns an error.
*/


...
#ifndef LINEAR_PROBES
#define LINEAR_PROBES 9
#endif

/* This must be >= 1 */
#define PERTURB_SHIFT 5

static setentry *
set_lookkey(PySetObject *so, PyObject *key, Py_hash_t hash)  
{
...
gimel
fuente
2
Esta respuesta se beneficiaría de C resaltado de sintaxis . El resaltado de la sintaxis de Python del comentario se ve muy mal.
user202729
Con respecto al comentario "Esto nos deja con un híbrido de sondeo lineal y direccionamiento abierto", ¿no es el sondeo lineal un tipo de resolución de colisión en el direccionamiento abierto, como se describe en en.wikipedia.org/wiki/Open_addressing ? Por lo tanto, el sondeo lineal es un subtipo de direccionamiento abierto y el comentario no tiene sentido.
Alan Evangelista
2

Para enfatizar un poco más la diferencia entre set'sy dict's, aquí hay un extracto de las setobject.csecciones de comentarios, que aclara la principal diferencia de los conjuntos contra los dictados.

Los casos de uso para conjuntos difieren considerablemente de los diccionarios donde es más probable que las claves buscadas estén presentes. En contraste, los conjuntos son principalmente sobre pruebas de membresía donde la presencia de un elemento no se conoce de antemano. En consecuencia, la implementación del conjunto debe optimizarse tanto para el caso encontrado como para el no encontrado.

fuente en github

usuario1767754
fuente