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 GetHashCode(); Dictionary 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{ public int Row { get; set; } public int Number { get; set; }
public Seat(int row, int number) { Row = row; Number = number; }}Dictionary<Seat, string> bookings = new Dictionary<Seat, string>();bookings[new Seat(5, 12)] = "María";
Console.WriteLine(bookings.ContainsKey(new Seat(5, 12)));Console.WriteLine(bookings.Count);bookings[new Seat(5, 12)] = "Iván";Console.WriteLine(bookings.Count);False12María compró el asiento 5/12, pero ContainsKey con el mismo asiento dice False. 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 Dictionary 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 GetHashCode(). Todo objeto lo tiene, porque está en object.
Console.WriteLine("María".GetHashCode() == "María".GetHashCode());Console.WriteLine("María".GetHashCode() == "Mario".GetHashCode());Console.WriteLine("María".GetHashCode() == "maría".GetHashCode());Console.WriteLine(1004.GetHashCode());TrueFalseFalse1004- Dentro de una misma ejecución, el mismo texto da el mismo número.
- Un cambio pequeño da otro número: "María", "Mario" y "maría" son tres códigos hash distintos.
- El código hash de un
intes el propio número. - Un código hash se calcula rápido, sin importar cuántas claves haya en el diccionario.
Aquí no se imprime a propósito el número de un texto. .NET calcula el código hash de un string de forma distinta en cada ejecución del programa, así que te saldría otro. Las comparaciones salen igual siempre.
El método que produce el número se llama función hash.
Más precisamente, para curiosos
- ¿Por qué cambia el código hash de un texto? Si se conociera de antemano, alguien podría enviar a un servidor miles de claves con el mismo código hash y volver lento el diccionario. Por eso .NET elige un punto de partida aleatorio para el cálculo en cada ejecución. El código hash de un texto no se guarda en un archivo ni en una base de datos, ni se envía a otro programa: allí es distinto.
- 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.
GetHashCode()sirve para búsquedas rápidas y no da esa protección.
Míralo
Las cubetas
Por dentro, un Dictionary 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. El código hash de un texto cambia en cada ejecución, así que aquí las claves son números de pedido: para ellos, el código hash es el propio número:
int[] keys = { 1007, 2043, 3010, 4051 };int buckets = 8;foreach (int key in keys){ int hash = key.GetHashCode(); int index = (hash % buckets + buckets) % buckets; Console.WriteLine(key + " -> " + hash + " -> cubeta " + index);}Paso a paso, en palabras
- Cuatro claves (números de pedido) y ocho cubetas vacías, numeradas del 0 al 7.
- 1007.GetHashCode() es el propio número, 1007. El resto de dividir entre 8 es 7: esa es la cubeta.
- 2043 da la cubeta 3.
- 3010 da la cubeta 2.
- 4051 también cae en la cubeta 3: una colisión. Las dos claves están en una misma cubeta: 4051 cuelga detrás de 2043 como una cadena.
- Se busca 4051: el mismo número, la misma cubeta 3. Equals con 2043: no. Con 4051: sí.
- Escribir
bookings[clave] = …calcula la cubeta y guarda ahí el par. Buscar (ContainsKey,TryGetValue,[clave]) calcula la misma cubeta y busca solo en ella. Por eso es rápido incluso con un millón de claves. %en C#, igual que en Java, puede dar un número negativo:-7 % 8es -7. Por eso se escribe(hash % buckets + buckets) % buckets: así el resto nunca es negativo. Un código hash puede ser negativo, pero un número de cubeta no.- 2043 y 4051 cayeron en la cubeta 3. Esto se llama colisión. De eso trata la siguiente tarjeta.
Más precisamente, para curiosos
- El
Dictionaryreal convierte el código hash en un número sin signo (uint), que nunca es negativo, y toma el resto de dividirlo entre el número de cubetas. Ese número es primo (3, 7, 17, 37…), así que allí los números son otros, pero el principio es el mismo.
Console.WriteLine(-7 % 8 + " " + (-7 % 8 + 8) % 8);-7 1Pruébalo
¿Qué cubeta?
El código hash de un int 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 };foreach (int id in orderIds){ Console.WriteLine(id + " -> cubeta " + id.GetHashCode() % 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: GetHashCode encuentra la cubeta, Equals encuentra la clave
Claves distintas pueden tener el mismo código hash. El código hash de un long es el «o» exclusivo (XOR) de su mitad superior y su mitad inferior, de 32 bits cada una. En -1 todos los bits son 1, las dos mitades se anulan, y queda 0, igual que para el propio 0:
Console.WriteLine(0L.GetHashCode() + " " + (-1L).GetHashCode());
Dictionary<long, string> codes = new Dictionary<long, string>();codes[0L] = "cero";codes[-1L] = "menos uno";Console.WriteLine(codes[0L] + " " + codes[-1L]);0 0cero menos unoLas dos claves están en una misma cubeta, pero el diccionario no las confunde. La cubeta guarda una cadena de pares. Una búsqueda la recorre y compara con Equals hasta encontrar la clave.
Así que los dos pasos funcionan así:
GetHashCode()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 compara referencias, y GetHashCode 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 GetHashCode 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{ public int Row { get; set; } public int Number { get; set; }
public Seat(int row, int number) { Row = row; Number = number; }
public override bool Equals(object other) { if (other is not Seat s) { return false; } return Row == s.Row && Number == s.Number; }
public override int GetHashCode() { return 31 * Row + Number; }}Con el mismo código en Main que en la primera tarjeta:
Dictionary<Seat, string> bookings = new Dictionary<Seat, string>();bookings[new Seat(5, 12)] = "María";
Console.WriteLine(bookings.ContainsKey(new Seat(5, 12)));Console.WriteLine(bookings.Count);bookings[new Seat(5, 12)] = "Iván";Console.WriteLine(bookings.Count);True11Las reglas que espera C#:
- Si
a.Equals(b), entoncesa.GetHashCode() == b.GetHashCode(). Siempre. - Si los códigos hash son iguales, los objetos pueden ser distintos. Eso es una colisión.
- Si sobrescribes
Equals, sobrescribe tambiénGetHashCode, con las mismas propiedades.
Si sobrescribes solo Equals, el compilador te advierte, pero el programa compila:
class Seat{ public int Row { get; set; } public int Number { get; set; }
public Seat(int row, int number) { Row = row; Number = number; }
public override bool Equals(object other) { if (other is not Seat s) { return false; } return Row == s.Row && Number == s.Number; }}warning CS0659: 'Seat' overrides Object.Equals(object o) but does not override Object.GetHashCode()Con este Seat, el primer programa vuelve a imprimir False, 1 y 2: el asiento se vende dos veces. La advertencia no detiene al compilador, así que léela.
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
HashCode.Combine. Calcula un código hash a partir de los valores que le das:
public override int GetHashCode(){ return HashCode.Combine(Row, Number);}- El número de
HashCode.Combinetambién cambia en cada ejecución, como el código hash de un texto. Para el diccionario eso no importa: dentro de una misma ejecución, asientos iguales tienen códigos hash iguales. Equalsrecibe unobject, así que primero comprueba si el otro es siquiera unSeat.other is not Seat ses la comprobación de tipo del módulo «Herencia y polimorfismo»: si el otro es unSeat,ses ese objeto, ya del tipoSeat. Por esonew Seat(5, 12).Equals("5/12")esFalse.- En C#, una clase que va a ser clave suele tener también
IEquatable<Seat>: un métodoEquals(Seat other), sin conversión desdeobject.DictionaryyHashSetlo llaman cuando existe, yEquals(object)solo lo llama a él. Aquí las propiedades son de solo lectura. Verás por qué en la tarjeta «Las cubetas se llenan»:
class Seat : IEquatable<Seat>{ public int Row { get; } public int Number { get; }
public Seat(int row, int number) { Row = row; Number = number; }
public bool Equals(Seat other) { return other is not null && Row == other.Row && Number == other.Number; }
public override bool Equals(object other) { return Equals(other as Seat); }
public override int GetHashCode() { return HashCode.Combine(Row, Number); }}- En el módulo «C# moderno» verás
record. Con una línea,record Seat(int Row, int Number);,EqualsyGetHashCodea partir de las propiedades quedan listos, y el primer programa imprimeTrue,1y1.
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:HashSet<Product> catalog = new HashSet<Product>();catalog.Add(new Product("A-100", 25.0));// El precio bajó, pero es el mismo producto.Console.WriteLine(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 GetHashCode también usa el precio. Los dos productos son iguales según Equals, pero tienen códigos hash distintos. Esto rompe el contrato. Un HashSet guarda el código hash de cada elemento y compara primero eso, así que ni siquiera llega a Equals. GetHashCode tiene que usar las mismas propiedades que Equals: return Code.GetHashCode();. 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 Dictionary crece:
- Al principio no hay cubetas. La primera vez que agregas algo, crea 3.
- Cuando los lugares se llenan, crea por lo menos el doble de cubetas (otra vez un número primo) 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. Aquí se ve cuándo crece:
Dictionary<string, int> prices = new Dictionary<string, int>();int capacity = prices.EnsureCapacity(0);for (int i = 1; i <= 20; i++){ prices["producto " + i] = i; if (prices.EnsureCapacity(0) != capacity) { capacity = prices.EnsureCapacity(0); Console.WriteLine("Crece en el par " + i + ": " + capacity + " lugares"); }}Crece en el par 1: 3 lugaresCrece en el par 4: 7 lugaresCrece en el par 8: 17 lugaresCrece en el par 18: 37 lugaresEnsureCapacity(0)devuelve cuántos lugares tiene el diccionario, sin cambiar nada. Aquí sirve solo para ver cuándo cambia la tabla. En unDictionaryhay tantos lugares como cubetas.- Por eso, en promedio, agregar y buscar son rápidos, pero de vez en cuando agregar algo tarda 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);HashSet<Seat> sold = new HashSet<Seat>();sold.Add(seat);
seat.Number = 13;Console.WriteLine(sold.Contains(seat));Console.WriteLine(sold.Contains(new Seat(5, 12)));Console.WriteLine(sold.Count);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 las propiedades de una clave sean de solo lectura: public int Number { get; }.Más precisamente, para curiosos
- Puedes fijar cuántos lugares hay al principio en el constructor:
new Dictionary<string, int>(64). El diccionario toma un número primo que no sea menor (aquí 71), y con 64 claves no tiene que crecer por el camino. Cuándo crece (cuando se llenan los lugares) no se puede fijar. - En .NET una cubeta siempre es una cadena: no hay árbol como en Java. Contra muchas colisiones provocadas a propósito con texto, lo que lo protege es que el código hash de un texto es distinto en cada ejecución.
Dictionary<string, int> prices = new Dictionary<string, int>(64);Console.WriteLine(prices.EnsureCapacity(0));for (int i = 1; i <= 64; i++){ prices["producto " + i] = i;}Console.WriteLine(prices.EnsureCapacity(0));7171Ejercicio
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 GetHashCode. Para GetHashCode, 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 si los códigos hash de los dos primeros son iguales, si los códigos hash del primero y el tercero son iguales, y cuántas personas distintas quedan en un HashSet. No imprimas los códigos hash en sí: con texto cambian en cada ejecución.
TrueFalsePersonas 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{ public int X { get; set; } public int Y { get; set; }
public Point(int x, int y) { X = x; Y = y; }}Movimientos: 8Celdas distintas: 73. El hash de un archivo. Un método HashFileContent(string filePath) lee el archivo y devuelve el hash de su contenido como texto hexadecimal. GetHashCode() no sirve aquí: en la siguiente ejecución el mismo archivo tendría otro código hash. Usa SHA-256 de System.Security.Cryptography: Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content))) (Encoding está en System.Text). Un archivo se escribe con File.WriteAllText(path, text) y se lee con File.ReadAllText(path) (en System.IO). 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 archivos distintos, y hashes SHA-256 iguales significan, en la práctica, archivos iguales.
a.txt: a7c494bfd367dcfa0a42a273ad6d6d1e5886537b8abf228413732e2a7d29fc8ab.txt: a7c494bfd367dcfa0a42a273ad6d6d1e5886537b8abf228413732e2a7d29fc8ac.txt: 55326c55a315a74bbe791da3c40262c0e4186a48b0d4c098801c64390993b9b1a 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
Dictionary. Crea un array de 8 cubetas, cada una unaListde 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"y los primeros 8 caracteres del SHA-256 del correo en minúsculas. ¿El ID es el mismo paraIvan@Example.comyivan@example.com? ¿Por quéGetHashCode()no sirve aquí? ¿Pueden dos correos distintos recibir el mismo ID? ¿Cómo lo averiguarías? - Contar colisiones. Calcula la cubeta de las claves
user1…user1000conGetHashCode()para 16, 64 y 1024 cubetas, y cuenta cuántas claves caen en una cubeta que ya tiene una clave. Ejecuta el programa dos veces: el número cambia un poco, porque el código hash de un texto cambia. - 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. EscribeEqualsyGetHashCodeconHashCode.Combine, 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: try, catch y finally · Listas · Pila, cola y lista enlazada · Dictionary 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