La mirada del replicante

Sobre hashtables

09 de Abril del 2022

Creo que la mayoría de los programadores saben qué son las hash tables (también conocidas como diccionarios o mapas). Normalmente, te suelen enseñar sólo una manera de implementarlas, pero hay muchas otras formas distintas. Durante el desarrollo de Quartz he tenido que implementar una hash table medianamente rápida y aquí quiero dejar todo lo que conozco sobre el tema, que no creo que sea mucho, pero quiero recopilarlo. La mayoría de esta información viene de distintas partes de internet, yo lo único que hago es unificarla aquí mismo.

¿Qué es una hash table?

Pues el concepto es muy sencillo: es una estructura de datos que permite relacionar una clave con un valor. Por ejemplo, digamos que quiero guardar la edad de la gente. En concreto, quiero recordar que Martín tiene 29 años y que Noelia tiene 32. Entonces la hash table guardaría:

Clave Valor
Martín 29
Noelia 32

En una hash table debería poder hacerse estas dos operaciones esenciales:

Pero nos queda aún lo más importante de las hash tables, la razón por la que es una estructura de datos ampliamente utilizada y, por lo tanto, muy importante: el rendimiento. Aquí dejo las complejidades de las operaciones principales (con respecto a la cantidad de elementos guardados), además del espacio en memoria utilizado:

Caso peor Caso medio Caso Mejor
Espacio O(n) Θ(n) Ω(n)
Búsqueda O(n) Θ(1) Ω(1)
Inserción O(n) Θ(1) Ω(1)
Borrado O(n) Θ(1) Ω(1)

Lo interesante de esta estructura de datos es el caso normal para la búsqueda, inserción y borrado: su complejidad es constante. Eso significa que aunque tengas medio millón de datos guardados la complejidad será constante. Una aclaración importante es que normalmente no sólo basta con que la complejidad sea constante sino que también sea rápido. De nada vale que la complejidad sea constante si se tira un minuto buscando (tenga la cantidad de elementos que tenga).

El caso peor se refiere cuando la estructura de datos se ha degradado tanto que tiene que recorrerse todos los elementos del hash table para hacer cualquier operación (hay una manera para hacer que el caso peor también sea de O(1), pero requiere conocer las claves de antemano). En este caso no supone ninguna mejora sobre otras estructuras de datos como una lista enlazada.

Sabiendo qué es lo que hace vamos a ver cómo lo hace:

Toy Hash Table

Vamos a hacer una hash table de juguete para tener una idea de cómo funcionan. Por el momento vamos a obviar problemas que tienen las hash tables de verdad y vamos a quedarnos con la esencia. Lo que vamos a querer es asignar una letra del abecedario a un número.

Toy Hash Table

De hecho, es tan sencillo que vamos a dar una implementación en C. Lo más fácil para hacerlo es declararnos un array con la cantidad de letras que tenemos (vamos a usar 27 porque no incluimos la ñ y todos son minúsculas):

char buckets[27];

Llamamos “buckets” a cada sitio donde una clave deja su valor (es el término que se suele utilizar). En este caso, imagina que cada cubo tiene una etiqueta con la letra que le corresponde y dentro del cubo tenemos el número.

Necesitaremos inicializar esa memoria a algo. La idea va a ser que si no has “insertado” la letra te devuelva -1:

void init_buckets() {
    memset(buckets, -1, sizeof(char) * 27);
}

Ahora necesitamos “mapear” una letra a su posición. Esto lo podemos hacer fácilmente conociendo sus posiciones en la tabla ASCII. Nosotros solo vamos a usar letras minúsculas, así que usamos la letra “a” (posición 97) para la posición 0 del array.

int map_key(char key) {
    return key - 97;
}

La inserción es muy fácil:

void insert(char key, int value) {
    int position = map_key(key);
    buckets[position] = value;
}

Y la búsqueda es muy similar a la inserción:

int find(char key) {
    int position = map_key(key);
    return buckets[position];
}

Con esto ya podemos jugar con nuestra hash table (de juguete)

int main() {
    init_buckets();
    insert('b', 12);
    insert('z', 23);
    insert('a', 2);
    printf("B: %d\n", find('b'));
    printf("Z: %d\n", find('z'));
    printf("A: %d\n", find('a'));
    printf("H: %d\n", find('h'));
}

Esto devuelve al ejecutarlo:

B: 12
Z: 23
A: 2
H: -1

La H no se insertó, por lo que no la encuentra y devuelve -1.

Strings y hashes

La verdad es que una hash table que solo permita caracteres como claves no nos vale de mucho. Vamos a intentar ahora usar cualquier string como clave. Para eso, tendremos que cambiar la función que mapea la clave con el bucket donde guarda su valor (lo que antes llamábamos como int map_key(char key)). Aquí entran las funciones hash. Una función hash lo que hace es devolvernos el “resumen” (un número entero) que identifica un cierto string. Mucha gente le ha dedicado mucho tiempo buscando los algoritmos más eficientes para implementar hashes, así que no tienes que preocuparte de implementar tú uno. Lo que sí deberías conocer es qué tipo de función hash deberías elegir para una hash table. Una hash table debería usar una función hash que:

El segundo requisito habla de algo que aún no he comentado. Las funciones hash tienen lo que se llaman “colisiones”. Se dice que se tiene una colisión cuando dos strings distintos dan el mismo resumen. Como podrás imaginar, es un problema para una hash table porque tenemos que manejar que dos elementos con distinta clave mapeen al mismo bucket. La mayor parte del trabajo y de la complejidad de la hash table se va a ir solucionando este problema. Que una función hash minimice las colisiones significa que debe hashear a cualquier bucket independientemente de dónde ha hasheado cualquier otro elemento. A esto se llama “simple uniform hashing”. Esto quiere decir que nos gustaría que nuestra hash table tenga una distribución uniforme de “sacar” cualquier número.

Solucionando colisiones: Open Addressing VS Separate Chaining

Como he dicho, las funciones hash pueden tener colisiones, por lo que nuestras tablas de hashes también. Toda la investigación sobre cómo mejorar las hash tables se centran en esto. Bien, pues hay tres estrategias principales:

Ese proceso de buscar siguiendo el patrón de búsqueda que he comentado se suele llamar “probing”. Hay muchas formas para implementarlo (hay muchas más de las que pongo aquí, pero estas me parecen las más usadas):

Siendo H el resultado de hashear la clave. También tiene problemas de clustering: Si dos elementos hashean al mismo número H, la secuencia será la misma.

Tamaño

Hasta ahora no hemos hablado del tamaño de las hash tables. En nuestra toy hash table era fijo porque no tenía sentido que para 27 letras hiciésemos uno variable (y porque su cometido era introducirte a las hash tables). Puedes dar un tamaño fijo a las hash tables. Pero en ese caso te tienes que limitar a las del tipo Separate Chaining. Obviamente que puedas hacerlo no significa que debas hacerlo: fijar el tamaño hace que, si la cantidad de elementos que tiene que guardar la hash table es mayor que su tamaño, la hash table se degradará hasta tener una complejidad de búsqueda, inserción y borrado de O(n).

Así que lo ideal es que la hash table vaya cambiando de tamaño a medida que se vayan añadiendo elementos. Bien ahora nos encontramos con dos preguntas:

Respecto a la primera pregunta lo que se hace es medir el factor de carga de la hash table y, cuando llegue a un límite, aumentar el tamaño. El factor de carga es un porcentaje que se calcula como:

factor de carga = cantidad de elementos / tamaño del hash table

El límite es muy importante. Dependiendo de qué método utilces para implementar tu hash table, no deberías dejar que pase de un cierto límite. En caso contrario tu hash table se va a degradar hasta el punto que sus operaciones tengan un coste lineal. Normalmente soluciones basadas en Separate chaining permiten un load factor del 100% sin sufrir una acusada degradación (de hecho, como en chaining guardamos los elementos que colisionan fuera del array de la hash table, el factor de carga máximo, que no recomendable, puede ser incluso mayor que un 100%). Por otro lado, las que se basen en Open Addressing es recomendable que no supere el 75%.

Ahora viene un problema derivado de la segunda pregunta (¿Cuál es el tamaño que debería elegir?). Resulta que cuando quieres buscar el bucket que le pertenece a una clave lo que haces es calcular el módulo del hash de la clave con el tamaño actual del hash table. Si el tamaño cambia, también lo hará el resultado de ese cálculo, por lo que debes volver a reubicar todos los elementos de la tabla. Esta operación tiene una complejidad O(n), por lo que no es deseable repetirla muchas veces. Aquí uno podría pensar que se puede obtener una respuesta a la segunda pregunta, y es un punto intermedio entre no provocar redimensionar muchas veces la tabla y no consumir mucha memoria.

Optimizaciones respecto al tamaño

Si intentas implementar una hash table tal como se ha dicho hasta ahora, será con toda seguridad lenta (o al menos no todo lo rápida que debería). Al ver que tiene problemas de rendimiento podrías coger un profiler y buscar el problema. Y verás que está probablemente en una línea: donde haces el módulo con el hash y el tamaño de la hash table. Resulta que la operación de módulo es muy ineficiente. Un procesador x86_64 de intel tarda entre 80 y 95 ciclos de cpu en ejecutar el módulo (Manual de intel).

Pero hay formas de remediarlo. Una de ellas supone usar potencias de dos como tamaño de la hash table. Gracias a esto podemos sustituir el módulo por un AND con una máscara. Simplemente calculamos la máscara como el tamaño del hash - 1. Luego, cada vez que quieras calcular el módulo, sólo tienes que hacer el AND entre la máscara y el hash. Acabamos de pasar de 80-95 ciclos para calcular el módulo a únicamente 1. Podemos ver un ejemplo para ver qué está ocurriendo:

Tenemos un tamaño de 8 cuya representación en binario es 1000. Su máscara será entonces 0111. Tenemos por ejemplo que el hash es 53 (110101). Calculamos el AND entre 8 y 53 (110101 & 000111) lo cual da 5 (0101).

Con esto ya puedes tener una hash table razonablemente rápida. Aún quedan cosas por contar, como por ejemplo los hash attacks, pero este post es ya muy largo como para continuar.

Bibliografía