Atlasingeniería

Estructuras de datosTablas hash y grafosTema 1

Tablas hash y resolución de colisiones

Una tabla hash convierte la clave en una posición del arreglo y accede en tiempo constante promedio. Todo lo interesante pasa cuando dos claves caen en la misma posición.

Para este tema conviene tener claro:Diccionarios y conjuntos

Buscar en un arreglo desordenado cuesta recorrerlo entero; en uno ordenado, partirlo a la mitad varias veces. Una tabla hash promete algo mejor: calcular dónde está el dato y acceder directo, sin comparar con nadie. La promesa se cumple mientras las colisiones se mantengan bajo control.

De la clave a un índice

La idea es reducir la clave a un número y usarlo como índice. Una función de hash toma la clave y devuelve un entero; el resto de la división por el tamaño de la tabla da la posición:

posicion(clave)=hash(clave)modm.posicion(clave)=hash(clave) \bmod m.

Si la función reparte parejo, cada clave aterriza en un lugar distinto y leer o escribir cuesta O(1)O(1). El arreglo dejó de recorrerse: se calcula.

Antes de seguir, predecí

Guardás un objeto como clave y después cambiás uno de sus campos. ¿Qué pasa al buscarlo?

Las colisiones son inevitables por conteo

El problema es inevitable por conteo: las claves posibles son muchísimas más que las mm posiciones, así que dos claves distintas van a compartir posición. Eso es una colisión, y no es un error de la función de hash sino una consecuencia del tamaño.

De hecho llegan antes de lo que la intuición sugiere: con una tabla de 365 posiciones alcanzan 23 claves al azar para que la probabilidad de colisión pase el 50%. Es la paradoja del cumpleaños. Una tabla hash no evita colisiones: las administra.

Encadenamiento: una lista por posición

La primera estrategia es el encadenamiento: cada posición guarda una lista con todas las claves que cayeron ahí. Insertar agrega a la lista; buscar recorre sólo esa lista y compara las claves completas, porque hashes iguales no significan claves iguales.

El costo de una operación es el largo de su lista. Con nn claves en mm posiciones, el promedio es el factor de carga α=n/m\alpha = n/m: constante mientras la tabla crezca junto con los datos. En el peor caso, con todas las claves en la misma lista, degenera a O(n)O(n).

Tabla de cinco cubetas, vacía.

1 / 6
h(k) = k mod 5. Tres de las cuatro claves terminan encadenadas en la cubeta 2: buscar ahí adentro es recorrer una lista, y eso es exactamente el peor caso.

Direccionamiento abierto: todo dentro del arreglo

La otra estrategia guarda todo dentro del arreglo, sin listas. Si la posición está ocupada, se prueba otra siguiendo una secuencia fija: la siguiente libre en sondeo lineal, saltos cuadráticos, o un segundo hash que define el paso.

El sondeo lineal aprovecha la caché porque recorre posiciones contiguas, pero forma grupos: un bloque ocupado atrae más choques y se alarga solo. El doble hashing dispersa mejor esos grupos a cambio de saltar por la memoria.

Borrar tiene una trampa

Borrar con direccionamiento abierto tiene una trampa. Si simplemente vaciamos la posición, cortamos la cadena de sondeo y una clave que estaba más adelante se vuelve inalcanzable: la búsqueda ve un hueco y se detiene antes de llegar.

La solución habitual es marcar la posición como borrada en vez de vaciarla. La búsqueda sigue de largo por esas marcas y la inserción las reutiliza. Acumular marcas degrada la tabla, así que conviene reconstruirla cada tanto.

Redimensionar cuando la carga sube

Cuando el factor de carga crece, todo se vuelve más lento: las listas se alargan y los sondeos recorren más posiciones. Por eso, al pasar un umbral, la tabla se agranda —típicamente al doble— y todas las claves se reubican con el nuevo módulo.

Rehashear cuesta O(n)O(n), pero se paga cada vez que la cantidad de elementos se duplica. Ese costo repartido entre las inserciones intermedias da un costo amortizado constante, el mismo argumento que sostiene a los arreglos dinámicos.

Lo que una tabla hash no da

Lo que una tabla hash no da es orden. No hay recorrido de menor a mayor, ni mínimo, ni consultas por rango: el hash justamente destruye la relación entre claves parecidas. Para eso están los árboles de búsqueda.

También depende de que las claves usadas como índice no cambien después de insertarse, y de una función de hash que no sea predecible cuando las claves vienen de afuera: entradas elegidas para colisionar convierten el caso promedio en el peor caso.

Una tabla hash, escrita

Con encadenamiento, la tabla entera cabe en unas pocas líneas. Lo único que hace falta es una función que lleve claves a índices y un arreglo de cubetas.

class HashMap<V> {
  private buckets: Array<Array<[string, V]>>;
  private size = 0;

  constructor(private capacity = 8) {
    this.buckets = Array.from({ length: capacity }, () => []);
  }

  private indexFor(key: string): number {
    let hash = 2166136261;
    for (let position = 0; position < key.length; position += 1) {
      hash ^= key.charCodeAt(position);
      hash = Math.imul(hash, 16777619);
    }
    return Math.abs(hash) % this.capacity;
  }

  set(key: string, value: V): void {
    const bucket = this.buckets[this.indexFor(key)]!;
    const existing = bucket.find(([storedKey]) => storedKey === key);
    if (existing) {
      existing[1] = value; // la clave ya estaba: se reemplaza, no se agrega
      return;
    }
    bucket.push([key, value]);
    this.size += 1;
    if (this.size / this.capacity > 0.75) this.resize();
  }

  get(key: string): V | undefined {
    return this.buckets[this.indexFor(key)]!.find(([storedKey]) => storedKey === key)?.[1];
  }
}

Hay tres cosas en ese código que son toda la estructura. La primera: indexFor no guarda nada, calcula. Por eso get no recorre la tabla, va directo a una cubeta. La segunda: la cubeta guarda la clave junto al valor, y get la compara. Sin esa comparación, dos claves que caen en la misma cubeta serían indistinguibles, y por eso un hash igual no significa claves iguales. La tercera: set busca antes de agregar, y ahí está la unicidad de las claves.

Lo que la tabla no te da

Tabla hashÁrbol balanceado
Buscar una clave exactaO(1) promedioO(log n) garantizado
Recorrer en ordenno puede: el orden es arbitrarionatural
Buscar un rangohay que mirar todoO(log n) más lo que devuelva
Mínimo o máximohay que mirar todoO(log n)
Peor casoO(n) si todas las claves colisionanO(log n) siempre
Memoriamás: hay que dejar cubetas libresmenos, y con más punteros
La tabla hash gana en la primera fila y pierde en todas las que involucran orden. Elegirla es decidir que el orden no importa.
Más a fondo · formalPor qué 0,75 y no 0,95

El factor de carga es la cantidad de elementos dividida por la cantidad de cubetas. Con encadenamiento y un hash que distribuya uniformemente, la cantidad esperada de elementos por cubeta es exactamente ese factor, así que una búsqueda mira en promedio 1+α/21 + \alpha/2 elementos. Con α=0,75\alpha = 0{,}75 eso es poco más de una comparación; con α=5\alpha = 5 son tres y media, y sigue siendo constante.

La razón del 0,75 no es esa cuenta sino la del direccionamiento abierto, donde no hay cubetas que crezcan: los elementos se acomodan en el mismo arreglo, y la cantidad esperada de sondeos crece como 1/(1α)1/(1-\alpha). En α=0,75\alpha = 0{,}75 son 4 sondeos; en 0,90{,}9, 10; en 0,950{,}95, 20. Esa curva se dispara cerca de 1, y por eso el umbral se pone bastante antes de llenar la tabla: el costo de la memoria desperdiciada es lineal y el de apurarlo, explosivo.

Cierre

Una tabla hash cambia comparar por calcular, y paga ese cambio administrando colisiones: encadenamiento o direccionamiento abierto, borrado con marcas, redimensión por factor de carga. Cumplidas esas condiciones da acceso constante promedio; a cambio, entrega el orden.

Autoevaluación

¿Lo entendiste?

¿Por qué las colisiones son inevitables?
Con encadenamiento, ¿de qué depende el costo de una operación?
Buscar encontró una posición cuyo hash coincide. ¿Alcanza para devolver el valor?
Con direccionamiento abierto, ¿por qué no se puede vaciar una posición al borrar?