HashMap obtener / poner complejidad

131

Estamos acostumbrados a decir que las HashMap get/putoperaciones son O (1). Sin embargo, depende de la implementación de hash. El hash de objeto predeterminado es en realidad la dirección interna en el montón JVM. ¿Estamos seguros de que es lo suficientemente bueno para afirmar que get/putson O (1)?

La memoria disponible es otro problema. Según tengo entendido por los javadocs, el HashMap load factordebería ser 0.75. ¿Qué pasa si no tenemos suficiente memoria en JVM y load factorexcede el límite?

Entonces, parece que O (1) no está garantizado. ¿Tiene sentido o me estoy perdiendo algo?

Miguel
fuente
1
Es posible que desee buscar el concepto de complejidad amortizada. Vea, por ejemplo, aquí: stackoverflow.com/questions/3949217/time-complexity-of-hash-table La peor complejidad del caso no es la medida más importante para una tabla hash
Dr G
3
Correcto - está amortizado O (1) - nunca olvides esa primera parte y no tendrás este tipo de preguntas :)
Ingeniero
La peor complejidad en el tiempo es O (logN) desde Java 1.8 si no me equivoco.
Tarun Kolla

Respuestas:

216

Depende de muchas cosas. Por lo general, es O (1), con un hash decente que en sí mismo es tiempo constante ... pero podría tener un hash que demore mucho en calcular, y si hay varios elementos en el mapa hash que devuelven el mismo código hash, gettendrá que iterar sobre ellos llamando equalsa cada uno de ellos para encontrar una coincidencia.

En el peor de los casos, a HashMaptiene una búsqueda O (n) debido a que recorre todas las entradas en el mismo depósito de hash (por ejemplo, si todas tienen el mismo código de hash). Afortunadamente, el peor de los casos no aparece muy a menudo en la vida real, en mi experiencia. Entonces, no, O (1) ciertamente no está garantizado, pero generalmente es lo que debe asumir al considerar qué algoritmos y estructuras de datos usar.

En JDK 8, HashMapse ha ajustado de modo que si las claves se pueden comparar para ordenar, cualquier cubo densamente poblado se implementa como un árbol, de modo que incluso si hay muchas entradas con el mismo código hash, la complejidad es O (log norte). Eso puede causar problemas si tiene un tipo de clave donde la igualdad y el orden son diferentes, por supuesto.

Y sí, si no tiene suficiente memoria para el mapa hash, tendrá problemas ... pero eso será cierto independientemente de la estructura de datos que utilice.

Jon Skeet
fuente
@marcog: ¿Asumes O (n log n) para una sola búsqueda ? Eso me parece tonto. Dependerá de la complejidad de las funciones de hash e igualdad, por supuesto, pero es poco probable que dependa del tamaño del mapa.
Jon Skeet
1
@marcog: Entonces, ¿qué estás suponiendo que es O (n log n)? ¿Inserción de n elementos?
Jon Skeet
1
+1 para una buena respuesta. ¿Podría proporcionar enlaces como esta entrada de wikipedia para la tabla hash en su respuesta? De esa manera, el lector más interesado podría llegar al meollo de entender por qué dio su respuesta.
David Weiser
2
@SleimanJneidi: Todavía lo es si la clave no implementa Comparable <T> `, pero actualizaré la respuesta cuando tenga más tiempo.
Jon Skeet
1
@ ip696: Sí, putse "amortiza O (1)", generalmente O (1), ocasionalmente O (n), pero rara vez es suficiente para compensar.
Jon Skeet
9

No estoy seguro de que el código hash predeterminado sea la dirección: leí la fuente de OpenJDK para la generación de código hash hace un tiempo, y recuerdo que era algo un poco más complicado. Todavía no es algo que garantice una buena distribución, tal vez. Sin embargo, eso es hasta cierto punto discutible, ya que pocas clases que usarías como claves en un hashmap usan el hashcode predeterminado: proporcionan sus propias implementaciones, lo que debería ser bueno.

Además de eso, lo que quizás no sepa (una vez más, esto se basa en la fuente de lectura, no está garantizado) es que HashMap agita el hash antes de usarlo, para mezclar entropía de toda la palabra en los bits inferiores, que es donde está necesario para todos menos los hashmaps más grandes. Eso ayuda a lidiar con hashes que específicamente no lo hacen ellos mismos, aunque no puedo pensar en ningún caso común en el que lo veas.

Finalmente, lo que sucede cuando la tabla se sobrecarga es que se degenera en un conjunto de listas vinculadas en paralelo: el rendimiento se convierte en O (n). Específicamente, el número de enlaces atravesados ​​será en promedio la mitad del factor de carga.

Tom Anderson
fuente
66
Maldición Elijo creer que si no hubiera tenido que escribir esto en una pantalla táctil de un teléfono móvil, podría haber derrotado a Jon Sheet. Hay una insignia para eso, ¿verdad?
Tom Anderson
8

La operación HashMap es un factor dependiente de la implementación de hashCode. Para el escenario ideal, digamos la buena implementación de hash que proporciona un código de hash único para cada objeto (sin colisión de hash), el mejor, el peor y el escenario promedio sería O (1). Consideremos un escenario donde una mala implementación de hashCode siempre devuelve 1 o un hash que tiene una colisión de hash. En este caso, la complejidad del tiempo sería O (n).

Ahora, llegando a la segunda parte de la pregunta sobre la memoria, entonces sí, JVM se ocuparía de la restricción de memoria.

Pranav
fuente
8

Ya se ha mencionado que los hashmaps son O(n/m)en promedio, si nes el número de elementos y mel tamaño. También se ha mencionado que, en principio, todo podría colapsar en una lista vinculada individualmente con el O(n)tiempo de consulta. (Todo esto supone que calcular el hash es tiempo constante).

Sin embargo, lo que no se menciona a menudo es que, al menos con probabilidad 1-1/n(por lo tanto, para 1000 artículos con una probabilidad del 99.9%) ¡el cubo más grande no se llenará más que O(logn)! Por lo tanto, coincide con la complejidad promedio de los árboles de búsqueda binarios. (Y la constante es buena, un límite más estricto es (log n)*(m/n) + O(1)).

Todo lo que se requiere para este límite teórico es que use una función hash razonablemente buena (consulte Wikipedia: Universal Hashing . Puede ser tan simple como a*x>>m). Y, por supuesto, la persona que le da los valores al hash no sabe cómo ha elegido sus constantes aleatorias.

TL; DR: con probabilidad muy alta, el peor de los casos es la complejidad get / put de un hashmap O(logn).

Thomas Ahle
fuente
(Y observe que nada de esto supone datos aleatorios. La probabilidad surge únicamente de la elección de la función hash)
Thomas Ahle
También tengo la misma pregunta con respecto a la complejidad del tiempo de ejecución de una búsqueda en un mapa hash. Parecería que es O (n) ya que se supone que se deben descartar factores constantes. El 1 / m es un factor constante y, por lo tanto, se deja dejando O (n).
nickdu
4

Estoy de acuerdo con:

  • La complejidad general amortizada de O (1)
  • una mala hashCode()implementación podría dar lugar a múltiples colisiones, lo que significa que, en el peor de los casos, cada objeto va al mismo depósito, por lo tanto, O ( N ) si cada depósito está respaldado por a List.
  • desde Java 8, HashMapreemplaza dinámicamente los nodos (lista vinculada) utilizados en cada depósito con TreeNodes (árbol rojo-negro cuando una lista se hace más grande que 8 elementos), lo que resulta en un peor rendimiento de O ( logN ).

Pero, esto NO es toda la verdad si queremos ser 100% precisos. La implementación hashCode()y el tipo de clave Object(inmutable / en caché o ser una Colección) también puede afectar la complejidad real en términos estrictos.

Asumamos los siguientes tres casos:

  1. HashMap<Integer, V>
  2. HashMap<String, V>
  3. HashMap<List<E>, V>

¿Tienen la misma complejidad? Bueno, la complejidad amortizada del primero es, como se esperaba, O (1). Pero, por lo demás, también necesitamos calcular hashCode()el elemento de búsqueda, lo que significa que podríamos tener que atravesar matrices y listas en nuestro algoritmo.

Supongamos que el tamaño de todas las matrices / listas anteriores es k . Entonces, HashMap<String, V>y HashMap<List<E>, V>tendrá O (k) complejidad amortizada y, de manera similar, O ( k + logN ) en el peor de los casos en Java8.

* Tenga en cuenta que el uso de una Stringclave es un caso más complejo, ya que es inmutable y Java almacena el resultado hashCode()en una variable privada hash, por lo que solo se calcula una vez.

/** Cache the hash code for the string */
    private int hash; // Default to 0

Pero, lo anterior también tiene su propio peor caso, porque la String.hashCode()implementación de Java está verificando si hash == 0antes de computar hashCode. Pero bueno, hay cadenas no vacías que hashcodegeneran un cero, como "f5a5a608", vea aquí , en cuyo caso la memorización podría no ser útil.

Kostas Chalkias
fuente
2

En la práctica, es O (1), pero en realidad es una simplificación terrible y matemáticamente sin sentido. La notación O () dice cómo se comporta el algoritmo cuando el tamaño del problema tiende al infinito. Hashmap get / put funciona como un algoritmo O (1) para un tamaño limitado. El límite es bastante grande desde la memoria de la computadora y desde el punto de vista del direccionamiento, pero lejos del infinito.

Cuando uno dice que el hashmap get / put es O (1) realmente debería decir que el tiempo necesario para el get / put es más o menos constante y no depende del número de elementos en el hashmap hasta donde el hashmap puede ser presentado en el sistema informático real. Si el problema va más allá de ese tamaño y necesitamos mapas hash más grandes, luego de un tiempo, sin duda, el número de bits que describen un elemento también aumentará a medida que se agoten los posibles elementos diferentes que se pueden describir. Por ejemplo, si usamos un hashmap para almacenar números de 32 bits y luego aumentamos el tamaño del problema para que tengamos más de 2 ^ 32 elementos de bit en el hashmap, entonces los elementos individuales se describirán con más de 32 bits.

El número de bits necesarios para describir los elementos individuales es log (N), donde N es el número máximo de elementos, por lo tanto, get and put son realmente O (log N).

Si lo compara con un conjunto de árbol, que es O (log n), entonces el conjunto de hash es O (long (max (n)) y simplemente sentimos que esto es O (1), porque en cierta implementación max (n) es fijo, no cambia (el tamaño de los objetos que almacenamos se mide en bits) y el algoritmo que calcula el código hash es rápido.

Finalmente, si encontrar un elemento en cualquier estructura de datos fuera O (1), crearíamos información de la nada. Teniendo una estructura de datos de n elemento, puedo seleccionar un elemento de n manera diferente. Con eso, puedo codificar la información de bit de registro (n). Si puedo codificar eso en cero bits (eso es lo que significa O (1)), entonces creé un algoritmo ZIP de compresión infinita.

Peter Verhas
fuente
¿No debería ser la complejidad del conjunto de árboles O(log(n) * log(max(n))), entonces? Si bien la comparación en cada nodo puede ser más inteligente, en el peor de los casos necesita inspeccionar todos los O(log(max(n))bits, ¿verdad?
maaartinus