Apuntes DAM
Volver al inicio

Tabla hash

AlgoritmosEstructuras de datosNivel intermedioTambién: hash table, HashMap, diccionario, tabla de dispersión

Guarda pares clave-valor en un array: una función hash convierte la clave en una posición y buscar, insertar o borrar cuesta O(1) de media. Es lo que hay detrás de HashMap y dict.

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

Tabla hash

Escribe las claves que se insertan y mira en qué cubeta cae cada una, las colisiones y cómo la tabla crece cuando se llena.

Hasta 8 palabras distintas (pueden repetirse), separadas por espacios

Paso 1

Una tabla hash de 5 cubetas vacías. La función hash convierte cada clave en un número; el resto de dividirlo entre el número de cubetas dice en cuál va.

1class TablaHash {
2    private List<String>[] cubetas = new List[5];
3    private int n = 0;
4
5    static int hash(String s) {                     // como String.hashCode, sin desbordarse
6        int h = 0;
7        for (char c : s.toCharArray()) h = (31 * h + c) % 1_000_003;
8        return h;
9    }
10
11    void insertar(String clave) {
12        int i = hash(clave) % cubetas.length;
13        if (cubetas[i] == null) cubetas[i] = new ArrayList<>();
14        if (cubetas[i].contains(clave)) return;
15        cubetas[i].add(clave);
16        n++;
17        if (n > 0.75 * cubetas.length) redimensionar();
18    }
19
20    private void redimensionar() {
21        List<String>[] viejas = cubetas;
22        cubetas = new List[2 * viejas.length + 1];
23        n = 0;
24        for (List<String> c : viejas)
25            if (c != null) for (String k : c) insertar(k);
26    }
27}

Variables

n
0
cubetas
5
carga
0/5 = 0,00

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

Buscar en una lista es O(n); en un array ordenado, O(log n). La tabla hash baja a O(1) con un truco: en vez de buscar dónde está una clave, calcula dónde debería estar.

Una función hash convierte la clave (un texto, un número, un objeto) en un número entero; el resto de dividirlo entre el tamaño del array da la casilla, llamada cubeta. Para guardar «ana», se calcula su hash y se mete en su cubeta; para buscarla, se calcula el mismo hash y se mira solo esa cubeta.

El problema son las colisiones: dos claves distintas pueden caer en la misma cubeta (hay infinitas claves y pocas cubetas). La solución más común es el encadenamiento: cada cubeta guarda una pequeña lista con todas las claves que caen en ella. Si la función hash reparte bien, esas listas son muy cortas y todo sigue siendo O(1) de media.

Para que las listas no crezcan, la tabla vigila su factor de carga (claves / cubetas). Cuando pasa de un límite (0,75 en HashMap), crea un array más grande y vuelve a colocar todas las claves, porque con otro tamaño el resto cambia. Es caro, pero pasa tan pocas veces que insertar sigue siendo O(1) amortizado.

Cuándo usarlo

  • Buscar por una clave muy rápido: un usuario por su correo, un producto por su código, una palabra en un diccionario.
  • Contar apariciones (frecuencias de palabras, votos) o agrupar elementos por una característica.
  • Quitar repetidos o comprobar si algo ya se ha visto (HashSet).
  • Cachés: guardar resultados ya calculados por su entrada (memoización).

Cuándo no

  • Si se necesitan las claves ordenadas o búsquedas por rango («entre 10 y 20»): un árbol (TreeMap).
  • Si las claves son enteros pequeños y densos (0 a 100): un array normal es más simple y rápido.

Paso a paso

  1. Calcular el hash. La función hash convierte la clave en un entero, siempre el mismo para la misma clave (en Java, hashCode()).
  2. Elegir la cubeta. hash % número de cubetas (sin signo): la posición del array donde va la clave.
  3. Buscar en la cubeta. Se recorre la lista de esa cubeta comparando con equals: si está, se lee o se actualiza; si no, se añade.
  4. Crecer. Si claves / cubetas supera el factor de carga, se crea un array mayor y se reinserta todo (rehash).

El código

Contar palabras con una tabla hash propia

Siete cubetas con listas de nodos clave-valor: «el» e «y» aparecen varias veces y se cuentan en su nodo; las colisiones comparten cubeta.

Java
1public class Main {
2    /** Una tabla hash de String a int con encadenamiento: cada cubeta es una lista de nodos. */
3    static class Tabla {
4        static class Nodo {
5            final String clave;
6            int valor;
7            Nodo siguiente;
8            Nodo(String clave, int valor, Nodo siguiente) { this.clave = clave; this.valor = valor; this.siguiente = siguiente; }
9        }
10
11        private final Nodo[] cubetas = new Nodo[7];
12
13        static int hash(String s) {                       // como String.hashCode, sin desbordarse
14            int h = 0;
15            for (char c : s.toCharArray()) h = (31 * h + c) % 1_000_003;
16            return h;
17        }
18
19        /** Suma uno al valor de la clave (y la crea con 1 si no estaba). */
20        void contar(String clave) {
21            int i = hash(clave) % cubetas.length;
22            for (Nodo n = cubetas[i]; n != null; n = n.siguiente) {
23                if (n.clave.equals(clave)) { n.valor++; return; }          // ya estaba
24            }
25            cubetas[i] = new Nodo(clave, 1, cubetas[i]);                     // nueva, al principio de su cubeta
26        }
27
28        void mostrar() {
29            for (int i = 0; i < cubetas.length; i++) {
30                StringBuilder sb = new StringBuilder("cubeta " + i + ":");
31                for (Nodo n = cubetas[i]; n != null; n = n.siguiente) sb.append(" ").append(n.clave).append("=").append(n.valor);
32                System.out.println(sb);
33            }
34        }
35    }
36
37    public static void main(String[] args) {
38        Tabla t = new Tabla();
39        for (String p : "el gato y el perro y el raton".split(" ")) t.contar(p);
40        t.mostrar();
41    }
42}

Salida al ejecutarlo (la misma en los 5 lenguajes)

cubeta 0: raton=1
cubeta 1:
cubeta 2: perro=1 y=2
cubeta 3:
cubeta 4:
cubeta 5: el=3
cubeta 6: gato=1

Claves propias: equals y hashCode

Si dos objetos son iguales según equals, tienen que dar el mismo hashCode, o un HashMap nunca los encontrará.

Java
1// Para usar objetos propios como clave, equals y hashCode tienen que ir de la mano:
2// dos objetos iguales DEBEN dar el mismo hash, o la tabla los buscará en cubetas distintas.
3record Punto(int x, int y) { }          // un record ya trae equals y hashCode basados en sus campos
4
5class Alumno {
6    private final String dni;
7    private String nombre;
8
9    @Override public boolean equals(Object o) {
10        return o instanceof Alumno a && dni.equals(a.dni);
11    }
12
13    @Override public int hashCode() {
14        return dni.hashCode();             // los mismos campos que equals
15    }
16}

Traza: insertar claves en 5 cubetas

ClavehashCubeta¿Colisión?
ana9672496724 % 5 = 4cubeta vacía
luis333226333226 % 5 = 1cubeta vacía
eva100816100816 % 5 = 1colisión con luis
pablo421398421398 % 5 = 3cubeta vacía
marta666454666454 % 5 = 4colisión con ana

hash(s) = (31·h + código de cada letra) % 1.000.003, la misma idea que String.hashCode de Java. Con 5 claves en 5 cubetas ya hay colisiones: por eso la tabla crece antes de llenarse.

Complejidad

OperaciónMediaPeor caso
BuscarO(1)O(n)
InsertarO(1) amortizadoO(n)
BorrarO(1)O(n)
Recorrer todoO(n + m)O(n + m)

El peor caso es que todas las claves caigan en la misma cubeta (una función hash mala o un atacante que elige claves con el mismo hash). Desde Java 8, las cubetas muy largas se convierten en árboles y el peor caso baja a O(log n).

0102030405015101520tamaño de la entrada (n)operacionesO(n!)O(2ⁿ)O(n²)O(n log n)O(1)O(log n)O(n)
  • Mejor caso: O(1)
  • Caso medio: O(1)
  • Peor caso: O(n)

Buscar, insertar y borrar: O(1) de media; O(n) si todas las claves caen en la misma cubeta (una función hash mala). Las curvas grises son las demás clases, para comparar.

En la práctica

  • HashMap, HashSet y LinkedHashMap en Java; dict y set en Python; Map, Set y los objetos en JavaScript; Dictionary en C#; los arrays asociativos de PHP.
  • Los índices hash de las bases de datos y las cachés como Redis o Memcached.
  • Las contraseñas no se guardan, se guarda su hash (con funciones criptográficas lentas como bcrypt o PBKDF2, no con hashCode).
  • Git identifica cada fichero y cada commit por un hash de su contenido.

Errores típicos

  • Redefinir equals sin redefinir hashCode: dos objetos «iguales» caen en cubetas distintas y el mapa no los encuentra.
  • Usar como clave un objeto mutable y cambiarle un campo después de meterlo: su hash cambia y queda perdido en la cubeta antigua.
  • Calcular la cubeta con hash % m cuando el hash puede ser negativo: sale un índice negativo. Hay que usar Math.floorMod o quitar el signo.
  • Esperar que HashMap recorra las claves en el orden en que se metieron: no hay orden. Para eso está LinkedHashMap.
  • Confundir una tabla hash con una función hash criptográfica: hashCode no sirve para guardar contraseñas.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. Tu propia tabla hash

La primera línea es el número de cubetas m; después vienen palabras. Mételas en una tabla hash con encadenamiento hecha con una lista de listas: cada palabra va al final de la lista de su cubeta, salvo que ya estuviera. Muestra cómo queda cada cubeta, cuántas hay ocupadas y cuántas colisiones ha habido (una colisión es meter una palabra nueva en una cubeta que ya tenía alguna). La función hash ya está escrita: completa cubeta e insertar.

  • Entrada: 5 y luego palabras separadas por espacios o saltos de línea (se pasan a minúsculas).
  • Salida: una línea por cubeta, 2: luis, eva o 3: -, y al final Cubetas ocupadas: O de M · colisiones: C.
  • Errores: Número de cubetas no válido: «…» (de 2 a 99) y Clave no válida: «…» (solo letras, hasta 20).
☕JavaTu propia tabla hashMedio

Ejemplo

Entrada (lo que se escribe por teclado)
5
ana luis eva pablo marta sara
Salida esperada
0: -
1: luis, eva
2: -
3: pablo
4: ana, marta, sara
Cubetas ocupadas: 3 de 5 · colisiones: 3
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    static int colisiones = 0;
5
6    static int hash(String s) {
7        int h = 0;
8        for (char c : s.toCharArray()) h = (31 * h + c) % 1_000_003;
9        return h;
10    }
11
12    /** Cubeta de la clave en una tabla de m cubetas. */
13    static int cubeta(String clave, int m) {
14        return hash(clave) % m;
15    }
16
17    /** Inserta la clave al final de su cubeta si no estaba; cuenta una colisión si la cubeta ya tenía claves. */
18    static void insertar(List<List<String>> tabla, String clave) {
19        List<String> c = tabla.get(cubeta(clave, tabla.size()));
20        if (c.contains(clave)) return;
21        if (!c.isEmpty()) colisiones++;
22        c.add(clave);
23    }
24
25    public static void main(String[] args) {
26        Scanner sc = new Scanner(System.in);
27        String primera = sc.hasNextLine() ? sc.nextLine().trim() : "";
28        if (!primera.matches("\\d{1,2}") || Integer.parseInt(primera) < 2) {
29            System.out.println("Número de cubetas no válido: «" + primera + "»");
30            return;
31        }
32        int m = Integer.parseInt(primera);
33        List<List<String>> tabla = new ArrayList<>();
34        for (int i = 0; i < m; i++) tabla.add(new ArrayList<>());
35        int claves = 0;
36        while (sc.hasNext()) {
37            String k = sc.next().toLowerCase();
38            if (!k.matches("[a-zñ]{1,20}")) {
39                System.out.println("Clave no válida: «" + k + "»");
40                continue;
41            }
42            insertar(tabla, k);
43            claves++;
44        }
45        int ocupadas = 0;
46        for (int i = 0; i < m; i++) {
47            List<String> c = tabla.get(i);
48            if (!c.isEmpty()) ocupadas++;
49            System.out.println(i + ": " + (c.isEmpty() ? "-" : String.join(", ", c)));
50        }
51        System.out.println("Cubetas ocupadas: " + ocupadas + " de " + m + " · colisiones: " + colisiones);
52    }
53}

Toda la magia de la tabla hash está en cubeta: una cuenta que solo depende de la clave y que reparte las claves por el array. Buscar después es mirar una sola lista corta.

Prueba a cambiar m: con más cubetas hay menos colisiones, que es lo que hace HashMap al crecer cuando la carga pasa de 0,75.

2. Agrupar anagramas

Dos palabras son anagramas si tienen las mismas letras en otro orden (amor, roma, mora, ramo). Lee palabras y agrúpalas por anagramas con un mapa cuya clave es la «firma» de la palabra: sus letras ordenadas. Escribe los grupos de dos o más palabras en el orden en que apareció su primera palabra, y después las que no tienen pareja. Completa firma y agrupar.

  • Entrada: palabras separadas por espacios o saltos de línea (se pasan a minúsculas; las repetidas se ignoran).
  • Salida: un grupo por línea, amor, roma, mora; si no hay ninguno, Ningún anagrama; y al final Sin pareja: … si queda alguna.
  • Errores: Palabra no válida: «…» (solo letras) y No hay palabras.
☕JavaAgrupar anagramasMedio

Ejemplo

Entrada (lo que se escribe por teclado)
amor roma mora ramo casa saca perro
Salida esperada
amor, roma, mora, ramo
casa, saca
Sin pareja: perro
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3public class Main {
4    /** La firma de una palabra: sus letras ordenadas. Dos palabras son anagramas si tienen la misma firma. */
5    static String firma(String palabra) {
6        char[] c = palabra.toCharArray();
7        Arrays.sort(c);
8        return new String(c);
9    }
10
11    /** Agrupa las palabras por su firma, conservando el orden de aparición de los grupos y de las palabras. */
12    static Map<String, List<String>> agrupar(List<String> palabras) {
13        Map<String, List<String>> grupos = new LinkedHashMap<>();
14        for (String p : palabras) grupos.computeIfAbsent(firma(p), k -> new ArrayList<>()).add(p);
15        return grupos;
16    }
17
18    public static void main(String[] args) {
19        Scanner sc = new Scanner(System.in);
20        List<String> palabras = new ArrayList<>();
21        Set<String> vistas = new HashSet<>();
22        while (sc.hasNext()) {
23            String p = sc.next().toLowerCase();
24            if (!p.matches("[a-zñ]{1,20}")) System.out.println("Palabra no válida: «" + p + "»");
25            else if (vistas.add(p)) palabras.add(p);
26        }
27        if (palabras.isEmpty()) {
28            System.out.println("No hay palabras");
29            return;
30        }
31        List<String> solas = new ArrayList<>();
32        int grupos = 0;
33        for (List<String> g : agrupar(palabras).values()) {
34            if (g.size() > 1) {
35                System.out.println(String.join(", ", g));
36                grupos++;
37            } else solas.add(g.get(0));
38        }
39        if (grupos == 0) System.out.println("Ningún anagrama");
40        if (!solas.isEmpty()) System.out.println("Sin pareja: " + String.join(", ", solas));
41    }
42}

Elegir bien la clave lo resuelve todo: todas las palabras de un grupo tienen la misma firma y caen en la misma entrada del mapa, en O(1).

Comparar cada palabra con todas las demás sería O(n²); con el mapa es O(n · k log k), con k la longitud de las palabras.

Test

Test: Tabla hash

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.¿Cuánto cuesta buscar una clave en una tabla hash de media?

  2. 2.¿Qué es una colisión?

  3. 3.Si se redefine equals en una clase que se usa como clave de un HashMap, ¿qué más hay que redefinir?

  4. 4.¿Por qué una tabla hash crece antes de estar llena?

  5. 5.Necesitas las claves ordenadas alfabéticamente. ¿Qué usas en Java?

Relacionado