¿Qué debe saber todo programador sobre tablas hash y funciones hash?

En esta respuesta, profundizaré un poco en cómo las tablas hash se convierten en O (n) en lugar de O (1).

Una tabla hash básica es:

  1. Una función hash que mapea un espacio grande en un espacio pequeño
  2. Una matriz del tamaño del espacio pequeño – llame a ese tamaño M – con el comportamiento de búsqueda O (1) porque usamos la salida del hash como índice de matriz
  3. M contenedor o elementos de matriz de lista (“cubos”) cada uno con el comportamiento de búsqueda O (K), donde K es el número de elementos en el cubo.

La búsqueda de la tabla hash es O (1) si y solo si O (K) == O (1). Esto sucede cuando se cumple lo siguiente: el hash es perfecto, distribuyendo datos de manera uniforme entre los cubos; y, hay más cubos M que elementos de datos n , por lo que a lo sumo un elemento por cubo. *

La violación de cualquiera de estas condiciones conduce a un comportamiento de búsqueda de O (n).

Puede violar una condición O (1) usted mismo especificando una función hash terrible. Ejemplo:

  size_t hash (mydatatype X) {return 1;  } // Este es un hash terrible

Con esta función hash, todo entra en el mismo depósito sin importar cuántos depósitos haya, y O (K) == O (n); su tabla hash ahora es O (n).

Algunas implementaciones de tablas hash intentan mitigar terribles funciones hash volviendo a aplicar hash a su hash, y muchas también cambian dinámicamente el tamaño de la matriz de cubos para mantener M > n usando su módulo de hash poderes sucesivos de dos como índice. En condiciones ideales, esto conduce a algo llamado comportamiento de búsqueda de O (1) amortizado . Puede pensarlo como un comportamiento O (1) la mayor parte del tiempo, con un pase ocasional de O (n) para duplicar el tamaño de la matriz y reequilibrar la tabla hash.

La búsqueda de la tabla hash se amortiza O (1) si y solo si el algoritmo de amortización puede mantener M > n para todos los n razonables. Si no, la tabla hash comenzará O (1) y se degradará a O (n) para valores grandes de n . Que es grande La respuesta podría ser 2 ^ 30, o podría ser 1.

Como programador, puede violar las condiciones de O (1) amortizadas con una función hash inadecuada. Los ejemplos incluyen funciones hash que no son “buenas” módulo 2 ^ b para cualquier b, y funciones hash que solo producen P valores únicos. El algoritmo de amortización podría mitigar el primero de estos si vuelve a ajustar el hash, pero no hay nada que pueda hacer sobre el segundo: una vez que n excede P , efectivamente tenemos M == P sin importar cuán grande sea n ; el número promedio de artículos en cada cubo se vuelve proporcional a n (es decir, K = n / P ); O (K) == O (n).

Tres cosas que usted como programador no controla puede violar una condición O (1) u O (1) amortizada a medida que n aumenta. Estos son:

  • El código de la tabla hash. Puede fallar en la protección contra las malas funciones hash, y puede, por sí mismo, imponer un límite superior en M que es más pequeño que su conjunto de datos n . Una vez que M no puede crecer, O (K) == O (n).
  • El sistema operativo en el que se ejecuta el código de la tabla hash. Una matriz asignada dinámicamente solo puede ser tan grande como la asignación de memoria contigua más grande admitida por el sistema operativo. Una vez que la matriz no puede ser más grande, M no puede ser más grande, O (K) == O (n). En los sistemas operativos de 16 bits con administración de memoria, la asignación máxima, es decir (size_t) (- 1) , es de solo 64 K, aunque el espacio de proceso podría ser de 16 MB. Si el tamaño de un puntero es de 3 a 4 bytes, M alcanza un máximo de 16,000 a 22,000. Una vez que n excede eso, O (K) == O (n).
  • La arquitectura de memoria del hardware en el que se ejecuta el código de la tabla hash. La mayoría de los programadores están acostumbrados a trabajar en CPU de 32 bits o 64 bits con espacio de direcciones lineal. Los sistemas embebidos (incluyendo cualquier cosa que ejecute una CPU 80286) y consolas de juegos económicas (incluida cualquier cosa que ejecute una CPU 65816) pueden tener arquitecturas de memoria segmentadas, almacenadas o asignadas por página en las que la memoria contigua direccionable más grande es 32K o 64K. En dicho sistema, es difícil o ineficiente implementar una matriz más grande que esta, incluso a nivel de lenguaje ensamblador. Una implementación sencilla de un mapa hash en un sistema de este tipo alcanza un máximo de M entre 16,000 y 22,000. Una vez que n excede eso, O (K) == O (n).

Todo programador debe saber estas cosas sobre las tablas hash y las funciones hash para tomar decisiones informadas al escribir código. No asuma ciegamente que su tabla hash será O (1) o O (1) amortizada. Verifique sus casos de uso y su función de hashing, y si cumplen con los criterios O (1) u O (1) amortizado, ¡elimine el hash!


* Más precisamente, un número nominalmente constante de artículos por cubo; no debe crecer como log n o como n etc.

Si una tabla hash es buena depende en gran medida de
1. cuánto espacio estás dispuesto a usar en la memoria y
2. qué tan buena es su función hash para sus datos.

La situación ideal es cuando puede crear un hash perfecto, una función de hash que sabe que hará hash de manera única todos los elementos de su conjunto de datos. Entonces solo necesitas n cubos para n elementos, y todo toma O (1) tiempo. Sin embargo, eso generalmente requiere conocer de antemano una cantidad poco práctica sobre sus datos para obtener esa buena función hash de tal manera que el uso de una matriz u otra estructura de datos también podría serle útil.

Si es como la mayoría de los usuarios de una tabla hash y está tratando de llevar los datos que deberían ser hash a una distribución uniforme, entonces tendrá una compensación entre memoria y eficiencia: cuantos más cubos permita, más espacio (y para en menor grado, tiempo) necesitará inicializar la tabla hash, pero menos tiempo pasará accediendo a los elementos después porque es menos probable que choquen. Por lo general, algunas pruebas manuales pueden darle una buena idea de dónde está el punto óptimo para sus restricciones de memoria y necesidades de rendimiento.

Las tablas hash no son una panacea de programación. No resolverán todos los problemas más rápido que otra estructura de datos. Una cosa críticamente importante sobre el uso de tablas hash es que requieren iterar a través de todo su conjunto de datos. Si solo va a hacer búsquedas de O (1) después, no vale la pena crear una nueva estructura de datos para buscarla, especialmente si sus datos ya tenían una cierta cantidad de estructura de búsqueda. Piense en el tiempo amortizado de sus operaciones, incluida esa inicialización, antes de comprometerse.

Las tablas hash no escalan.

No importa cuán buena sea su función hash, no puede hacer que la tabla hash sea arbitrariamente ancha. Eso desperdiciaría la memoria. Eso implica que a medida que el problema aumenta, tus cubos de hash se vuelven más y más profundos. Y terminas en una solución O (n) / (# hash buckets).

Usa un árbol en su lugar.

Hay un blog que explica las matemáticas internas detrás de la tabla hash.

Números que los programadores deben saber sobre Hash

En resumen, el factor de carga (no. Key / no. Bucket) es muy importante en la implementación de una tabla hash. Hasta donde yo sé, un factor de carga utilizado habitualmente es 0,75. Superior a 0,75 hace que la tasa de colisión aumente muy rápidamente.

Si el factor de carga es 1 no. clave == no. bucket, la distribución de claves es la siguiente, y el 37% (1 / e) buckets están vacíos.

Y a continuación se muestra cómo cambia la tasa de colisión (rojo) a medida que aumenta el factor de carga.

Y a continuación hay un gráfico que muestra que cuando el factor de carga se convierte en 10,000, el número de llaves en cada cucharón se vuelve suficiente.

Números que los programadores deben saber sobre Hash

Tablas hash : que son un pony de un solo truco . Funcionan muy bien para búsquedas en teclas completas, pero apestan en casi todo lo demás (por ejemplo, búsquedas de prefijos).

Funciones hash: que no siempre tienen que ser perfectas o casi perfectas; para algunas aplicaciones como la detección de plagio o la huella digital acústica, realmente desea colisiones con teclas similares (para alguna definición de “similar”). Siempre observe el uso previsto antes de decidir cómo debe comportarse su función hash.

1. Las colisiones son más frecuentes de lo que piensas (ver paradoja de cumpleaños)
2. Hay formas muy simples y eficientes de lidiar con las colisiones (Cuckoo Hashing, Hopscotch hashing)
3. ¡Una función hash tiene que ser rápida! (ver FNV, Jenkins, Murmullo)
4. Siempre hay una manera de resolver el problema que tienes con tu tabla hash 🙂

Tiempo medio constante de búsqueda / inserción / eliminación – O (1). ¡Supera eso!

Pruebe mi propia versión de una tabla hash escasa (encabezado único), rápida y * extremadamente * eficiente en memoria. Descargar en greg’s sparsehash.

Para explicar la excelente respuesta que Xanda dio:

“Tabla hash” es un término bastante general, no una cosa única para todos. Puede implementar uno de diferentes maneras, optimizado para las características de su conjunto de datos. Por ejemplo, si las inserciones van a ser poco frecuentes pero las búsquedas son frecuentes, puede elegir una implementación que esté altamente optimizada para las búsquedas mientras que las inserciones son caras. O si sabe que nunca eliminará elementos, puede elegir implementaciones que sean apropiadas solo en esa situación. O si su conjunto de datos inicial es pequeño pero sabe que crecerá en dos órdenes de magnitud a través del uso continuo durante los próximos diez años, puede elegir un esquema que se adapte a eso.

Entonces, ¿qué deberías saber? Antes de comenzar a diseñar una implementación de tabla hash, asegúrese de comprender realmente las características de su conjunto de datos, tanto hoy como en el futuro. ¡Y documente bien esas características junto con los documentos de diseño y el código! Porque un día alguien querrá cambiar esas suposiciones y necesitará saber si la tabla hash debe cambiarse o volver a implementarse en consecuencia.

Un hash no está ordenado. Dado un elemento, no puede encontrar el siguiente elemento en orden.
Para los datos genéricos, el rendimiento es impredecible y podría ser tan malo como recorrer una lista vinculada.
Para claves conocidas y un tamaño de conjunto de datos conocido y no necesita orden, los hash son geniales