Códigos hash y funciones hash
En resumen: Un código hash es un número que se calcula a partir de una clave con hashCode(); HashMap lo usa para elegir la cubeta donde buscar, así que encuentra una clave rápido incluso entre un millón.
Problema
El asiento se vendió dos veces
La taquilla del teatro guarda los asientos vendidos en un diccionario: asiento → nombre del cliente. Un asiento es una clase con una fila y un número:
class Seat { int row; int number;
Seat(int row, int number) { this.row = row; this.number = number; }}Map<Seat, String> bookings = new HashMap<>();bookings.put(new Seat(5, 12), "María");
System.out.println(bookings.get(new Seat(5, 12)));System.out.println(bookings.size());bookings.put(new Seat(5, 12), "Iván");System.out.println(bookings.size());null12María compró el asiento 5/12, pero get con el mismo asiento devuelve null. Después Iván compra el mismo asiento, y el diccionario ahora tiene dos claves 5/12. ¿Por qué?
La idea
Código hash: un número a partir de la clave
Un HashMap no compara la clave con todas las demás. Calcula un número entero a partir de la clave, y con él sabe dónde buscar. Ese número se llama código hash, y lo devuelve el método hashCode(). Todo objeto lo tiene, porque está en Object.
System.out.println("María".hashCode());System.out.println("María".hashCode());System.out.println("Mario".hashCode());System.out.println("maría".hashCode());741178427411784274113764103670514- El mismo texto da el mismo número, siempre y en cualquier computadora.
- Un cambio pequeño da otro número: "María", "Mario" y "maría" son tres códigos hash distintos.
- Un código hash se calcula rápido, sin importar cuántas claves haya en el diccionario.
El método que produce el número se llama función hash.
Más precisamente, para curiosos
- Para
String, la fórmula está definida en la documentación de Java:s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1], dondes[i]es el código de cada carácter. El resultado es unint, así que al desbordarse se vuelve negativo.
- Las funciones hash criptográficas, como SHA-256, son otra cosa. Dan un resultado largo, y en la práctica es imposible encontrar dos textos con el mismo hash. Por eso se usan para comprobar que un archivo no fue alterado y para guardar contraseñas.
hashCode()sirve para búsquedas rápidas y no da esa protección.
Míralo
Las cubetas
Por dentro, un HashMap es un array de cubetas. El número de cubeta de una clave es el resto de dividir el hash entre el número de cubetas:
String[] keys = { "Clara", "Luis", "Iván", "Rosa" };int buckets = 8;for (String key : keys) { int hash = key.hashCode(); int index = Math.floorMod(hash, buckets); System.out.println(key + " -> " + hash + " -> cubeta " + index);}Paso a paso, en palabras
- Cuatro claves y ocho cubetas vacías, numeradas del 0 al 7.
- "Clara".hashCode() es 65190183. El resto de dividir entre 8 es 7: esa es la cubeta.
- "Luis" da 2379923, cubeta 3.
- "Iván" da 2295226, cubeta 2.
- "Rosa" también cae en la cubeta 3: una colisión. Las dos claves están en una misma cubeta: "Rosa" cuelga detrás de "Luis" como una cadena.
- Se busca "Rosa": el mismo número, la misma cubeta 3. equals con "Luis": no. Con "Rosa": sí.
putcalcula la cubeta y guarda ahí el par.getcalcula la misma cubeta y busca solo en ella. Por eso es rápido incluso con un millón de claves.Math.floorModes un resto de la división que nunca es negativo. Un código hash puede ser negativo, pero un número de cubeta no.- "Luis" y "Rosa" cayeron en la cubeta 3. Esto se llama colisión. De eso trata la siguiente tarjeta.
Más precisamente, para curiosos
- El
HashMapreal calcula el índice de forma un poco distinta:(h ^ (h >>> 16)) & (n - 1). El número de cubetasnsiempre es una potencia de dos, y& (n - 1)es un resto rápido de la división entren. Por eso allí los números son otros, pero el principio es el mismo.
Pruébalo
¿Qué cubeta?
El código hash de un Integer es el propio número. Los números de pedido van a un diccionario con 4 cubetas.
¿Qué imprimirá el programa?
int[] orderIds = { 6, 10, 13, 21 };for (int id : orderIds) { Integer key = id; System.out.println(id + " -> cubeta " + Math.floorMod(key.hashCode(), 4));}Los restos de dividir entre 4: 6 y 10 dan 2, y 13 y 21 dan 1. Cuatro claves, dos colisiones, dos cubetas vacías. Con más cubetas, las claves se reparten de forma más pareja.
La idea
Colisión: hashCode encuentra la cubeta, equals encuentra la clave
Claves distintas pueden tener el mismo código hash. Los dos ejemplos más conocidos:
System.out.println("Aa".hashCode() + " " + "BB".hashCode());
Map<String, Integer> codes = new HashMap<>();codes.put("Aa", 1);codes.put("BB", 2);System.out.println(codes.get("Aa") + " " + codes.get("BB"));2112 21121 2Las dos claves están en una misma cubeta, pero el diccionario no las confunde. La cubeta guarda una pequeña lista enlazada de pares. get la recorre y compara con equals hasta encontrar la clave.
Así que los dos pasos funcionan así:
hashCode()dice en qué cubeta buscar.equals()dice cuál de las claves de la cubeta es la que buscas.
Ahora queda claro qué pasó con el asiento del teatro. Seat no sobrescribe ninguno de los dos, así que usa las versiones de Object. Ahí, equals es ==, y hashCode suele ser distinto para cada objeto. Los dos objetos new Seat(5, 12) son dos objetos distintos, así que también son dos claves distintas.
La idea
El contrato: equals y hashCode juntos
Seat tiene que decir cuándo dos asientos son el mismo asiento, y dar a asientos iguales el mismo código hash:
class Seat { int row; int number;
Seat(int row, int number) { this.row = row; this.number = number; }
@Override public boolean equals(Object other) { if (!(other instanceof Seat)) { return false; } Seat s = (Seat) other; return row == s.row && number == s.number; }
@Override public int hashCode() { return 31 * row + number; }}Con el mismo código en main que en la primera tarjeta:
Map<Seat, String> bookings = new HashMap<>();bookings.put(new Seat(5, 12), "María");
System.out.println(bookings.get(new Seat(5, 12)));System.out.println(bookings.size());bookings.put(new Seat(5, 12), "Iván");System.out.println(bookings.size());María11Las reglas que espera Java:
- Si
a.equals(b), entoncesa.hashCode() == b.hashCode(). Siempre. - Si los códigos hash son iguales, los objetos pueden ser distintos. Eso es una colisión.
- Si sobrescribes
equals, sobrescribe tambiénhashCode, con los mismos campos.
Más precisamente, para curiosos
- ¿Por qué 31? Es un número primo impar, y mezcla bien. Multiplicar por 31 hace que el hash dependa del orden: el asiento 5/12 y el asiento 12/5 tienen códigos hash distintos. Con
row + number, los dos caerían en la misma cubeta. - En lugar de la fórmula, puedes escribir
Objects.hashdejava.util.Objects. Calcula lo mismo a partir de los campos que le das:
@Overridepublic int hashCode() { return Objects.hash(row, number);}equalsrecibe unObject, así que primero comprueba coninstanceofsi el otro es siquiera unSeat, y después lo convierte. Es la conversión hacia abajo del módulo «Herencia y polimorfismo».
Pruébalo
Encuentra el error
HashSet. Dos productos con el mismo código son el mismo producto, aunque el precio haya cambiado. Pero contains con el precio nuevo devuelve false:Set<Product> catalog = new HashSet<>();catalog.add(new Product("A-100", 25.0));// El precio bajó, pero es el mismo producto.System.out.println(catalog.contains(new Product("A-100", 19.9)));false¿En qué línea de la clase está el error?
Elige la línea con el error.
equals compara solo el código, pero hashCode también usa el precio. Los dos productos son iguales según equals, pero tienen códigos hash distintos. Esto rompe el contrato, así que el HashSet busca en otra cubeta y ni siquiera los compara. hashCode tiene que usar los mismos campos que equals: return code.hashCode();. Entonces contains devuelve true.La idea
Las cubetas se llenan
Cuando hay muchos pares, las cubetas se alargan y las búsquedas se vuelven lentas. Por eso un HashMap crece:
- Al principio hay 16 cubetas.
- Cuando hay más pares que el 75 % del número de cubetas, crea el doble de cubetas y reparte de nuevo todas las claves. El número de cubeta depende del número de cubetas, así que cada clave se calcula otra vez.
- Por eso, en promedio,
putygetson rápidos, pero de vez en cuando unputtarda más.
También hay una trampa. Una clave no debe cambiar mientras está en un diccionario o en un conjunto:
Seat seat = new Seat(5, 12);Set<Seat> sold = new HashSet<>();sold.add(seat);
seat.number = 13;System.out.println(sold.contains(seat));System.out.println(sold.contains(new Seat(5, 12)));System.out.println(sold.size());falsefalse1contains(seat) busca en la cubeta de 5/13 y no lo encuentra. contains(new Seat(5, 12)) busca en la cubeta correcta, pero el objeto que está ahí ya no es igual a 5/12. El elemento está en el conjunto (el tamaño es 1), pero no se puede encontrar. Por eso conviene que los campos de una clave sean final.Más precisamente, para curiosos
- Ese 75 % se llama factor de carga, y puedes fijarlo en el constructor:
new HashMap<>(64, 0.75f). Si sabes cuántas claves habrá, da el número inicial de cubetas, y el diccionario no tendrá que crecer por el camino. - Desde Java 8, una cubeta con más de 8 pares (
TREEIFY_THRESHOLD) pasa de lista a árbol balanceado, si hay por lo menos 64 cubetas. Si al repartir de nuevo quedan 6 pares o menos en esa cubeta (UNTREEIFY_THRESHOLD), el árbol vuelve a ser una lista. Así, con muchas colisiones, una búsqueda en una cubeta con n pares hace unas log n comparaciones en lugar de n.
Ejercicio
Tus propios códigos hash
Resuelve las tareas en tu editor.
1. Un código hash para una persona. Una clase Person con nombre, apellido y edad. Sobrescribe equals y hashCode. Para hashCode, usa la receta: empieza en 17, y para cada campo, result = 31 * result + el hash del campo. Para un campo int, el hash es el propio número. Crea dos veces a Iván Pérez con 30 años y una vez con 31. Imprime los códigos hash y cuántas personas distintas quedan en un HashSet.
420260857420260857420260858Personas distintas: 22. El robot del almacén. El robot empieza en la celda (0, 0) y hace los movimientos NNEESWWN: N es arriba (y + 1), S es abajo, E es a la derecha (x + 1), W es a la izquierda (W de west, oeste en inglés). ¿Cuántas celdas distintas visitó, contando la inicial? Guárdalas en un HashSet<Point>. Con esta clase el programa dice 9, pero debería decir 7. Corrígelo:
class Point { int x; int y;
Point(int x, int y) { this.x = x; this.y = y; }}Movimientos: 8Celdas distintas: 73. El hash de un archivo. Un método hashFileContent(String filePath) lee el archivo y devuelve el código hash de su contenido como texto hexadecimal: Integer.toHexString(content.hashCode()). Escribe tres informes: a.txt y b.txt con «Informe de octubre: 1250 €», y c.txt con «Informe de octubre: 1260 €». Compara sus hashes. Hashes distintos significan que los archivos son distintos con seguridad. Hashes iguales significan «casi seguro que son iguales»: con un int es posible una colisión, así que para una comprobación de verdad se usa SHA-256.
a.txt: dfba5017b.txt: dfba5017c.txt: dfc86798a y b: igualesa y c: distintosMás tareas
- Tu propia tabla hash. Una continuación de la tabla de usuarios de la lección anterior, esta vez sin
HashMap. Crea un array de 8 cubetas, cada una unArrayListde pares de nombre de usuario y correo. La función hash es la suma de los códigos de los caracteres del nombre. Escribeadd,gety un método que imprima cuántos pares hay en cada cubeta. - Un ID a partir de un correo.
generateUserId(String email)devuelve"U" + Integer.toHexString(email.toLowerCase().hashCode()). ¿El ID es el mismo paraIvan@Example.comyivan@example.com? ¿Pueden dos correos distintos recibir el mismo ID? ¿Cómo lo averiguarías? - Contar colisiones. Calcula la cubeta de las claves
user1…user1000con 16, 64 y 1024 cubetas, y cuenta cuántas claves caen en una cubeta que ya tiene una clave. - La caja de zapatos. Una clase
Boxcon ancho, alto y profundidad. Dos cajas son la misma si sus medidas son iguales, en el mismo orden. EscribeequalsyhashCodeconObjects.hash, y comprueba las cajas 30×20×10 y 10×20×30 en unHashSet.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Archivos · Listas · Pila, cola y lista enlazada · HashMap y HashSet
- Continúa en: record, switch y patrones · Tabla hash · Contraseñas · Dependencias: el código ajeno que ejecuta el tuyo
- Ejercicios sobre el tema: Colecciones