wandres.dev
COLECCIONES · Vec, HashMap, BTreeMap

HashMap: la API, el patrón entry y por qué exige Hash y Eq

HashMap asocia clave con valor en tiempo constante medio. Su API completa, el patrón entry que colapsa dos búsquedas en una, y la razón profunda de que toda clave necesite Hash y Eq: el hash elige el bucket y Eq confirma la identidad. Más el hasher aleatorio que blinda contra ataques de colisión.

⏱ 20 min

Cuando necesitas asociar una clave con un valor y recuperarlo rápido, la respuesta es HashMap<K, V>: inserción, búsqueda y borrado en tiempo constante medio, sin importar cuántos millones de entradas guarde. Detrás hay una tabla hash moderna —la SwissTable, sondeo abierto con aceleración SIMD— pero su superficie es sencilla y su idioma central es el patrón entry, que evita el desperdicio de buscar la misma clave dos veces. Y todo descansa sobre un requisito que no es capricho: la clave debe implementar Hash y Eq, porque uno elige dónde mirar y el otro confirma qué encontraste.

🎯 Al terminar esta lección sabrás
  • Manejar la API esencial: insert, get, remove, contains_key y la iteración.
  • Colapsar la doble búsqueda con entry, or_insert y and_modify.
  • Explicar por qué toda clave necesita Hash + Eq y qué papel juega cada uno.
  • Reconocer el hasher aleatorio por defecto y cuándo cambiarlo.

La API esencial

Un HashMap se llena con insert, que devuelve el valor anterior si la clave ya existía (envuelto en Option), y se consulta con get, que devuelve Option<&V> porque la clave podría no estar. El resto de la superficie es igual de directo.

use std::collections::HashMap;

fn main() {
    let mut edad: HashMap<String, u32> = HashMap::new();
    edad.insert(String::from("Ada"), 36);
    let previo = edad.insert(String::from("Ada"), 37); // Some(36): sobrescribe
    assert_eq!(previo, Some(36));

    if let Some(a) = edad.get("Ada") {          // get acepta &str, no solo &String
        println!("Ada tiene {a}");
    }
    println!("existe Alan: {}", edad.contains_key("Alan")); // false
    edad.remove("Ada");                          // Option<u32> con el valor quitado

    for (nombre, a) in &edad {                   // orden NO especificado
        println!("{nombre}: {a}");
    }
}

Un detalle fino y muy útil: get no exige &K, sino cualquier tipo que la clave pueda prestar mediante el trait Borrow. Por eso un HashMap<String, V> se consulta con un &str sin construir un String temporal: String se presta como str. La iteración con for recorre pares (&K, &V) en un orden no especificado y deliberadamente aleatorizado entre ejecuciones; si necesitas orden, esa es señal de que quieres un BTreeMap, no un HashMap.

El patrón entry: una sola búsqueda

Considera la tarea canónica de contar frecuencias. La versión ingenua consulta la clave, decide, y vuelve a consultarla para escribir: hashea y sondea la misma clave dos o tres veces.

use std::collections::HashMap;

fn main() {
    let texto = "el gato el perro el gato";
    let mut cuenta: HashMap<&str, u32> = HashMap::new();

    // INGENUO: dos busquedas por palabra
    for palabra in texto.split_whitespace() {
        if !cuenta.contains_key(palabra) {  // busqueda 1: hash + sondeo
            cuenta.insert(palabra, 0);       // busqueda 2: hash + sondeo
        }
        *cuenta.get_mut(palabra).unwrap() += 1; // busqueda 3: otra vez
    }
}

El patrón entry elimina el desperdicio. map.entry(clave) calcula el hash y localiza el bucket una sola vez, y te devuelve un Entry —una vista sobre esa ranura, esté ocupada o vacía— con métodos para resolver el caso sin volver a buscar.

use std::collections::HashMap;

fn main() {
    let texto = "el gato el perro el gato";
    let mut cuenta: HashMap<&str, u32> = HashMap::new();

    for palabra in texto.split_whitespace() {
        *cuenta.entry(palabra).or_insert(0) += 1; // UNA busqueda por palabra
    }
    // {"el": 3, "gato": 2, "perro": 1}

    // and_modify para el caso ocupado, or_insert para el vacio:
    let mut vistos: HashMap<&str, u32> = HashMap::new();
    vistos.entry("clave").and_modify(|n| *n += 1).or_insert(1);
}

or_insert(v) inserta v si la ranura está vacía y, en ambos casos, devuelve &mut V al valor —por eso el += 1 funciona directamente sobre él—. Sus parientes afinan el patrón: or_insert_with(|| caro()) difiere la construcción del valor por defecto para no pagarla si la clave ya existe, or_default() usa el Default del tipo, y and_modify aplica un cambio solo cuando la entrada estaba ocupada. Es el idioma con el que Rust dice inserta-o-actualiza sin recorrer la tabla dos veces.

flowchart TB
e[Llamada a entry de la clave] --> h[Se calcula el hash y se sondea una sola vez]
h --> occ[Variante Occupied si la clave ya existe]
h --> vac[Variante Vacant si la clave no existe]
occ --> mod[and_modify ajusta el valor presente]
vac --> ins[or_insert coloca el valor inicial]
mod --> ret[Referencia mutable al valor]
ins --> ret
style h fill:#fab387,color:#11111b
style occ fill:#a6e3a1,color:#11111b
style vac fill:#89b4fa,color:#11111b
style ret fill:#cba6f7,color:#11111b

Por qué exige Hash y Eq

Aquí está la razón de fondo. Una tabla hash guarda cada par en un bucket elegido a partir de una huella numérica de la clave: eso lo aporta Hash. Pero varias claves distintas pueden caer en el mismo bucket —una colisión—, así que al buscar hace falta comparar la clave candidata con las que hay allí para saber cuál es la tuya: eso lo aporta Eq. Uno dice dónde mirar; el otro dice cuál es. Por eso la cota exigida a toda clave es K: Hash + Eq, y ninguno de los dos sobra.

De ahí nace un contrato inviolable, el mismo del nivel 16: si dos claves son iguales, deben tener la misma huella. Formalmente, a == b obliga a hash(a) == hash(b). Si se rompe, dos claves iguales irían a buckets distintos y buscar una que insertaste devolvería None —el valor está, pero el mapa mira en el sitio equivocado—. Por eso Eq y Hash se derivan siempre juntos.

use std::collections::HashMap;

#[derive(PartialEq, Eq, Hash)]   // los tres, y coherentes por construccion
struct Coord { x: i32, y: i32 }

fn main() {
    let mut m = HashMap::new();
    m.insert(Coord { x: 1, y: 2 }, "A");
    assert_eq!(m.get(&Coord { x: 1, y: 2 }), Some(&"A"));
}
⚠️
Por qué f64 no puede ser clave

Un f64 implementa PartialEq pero no Eq, y por tanto no puede ser clave de un HashMap. La causa es NaN: el estándar de coma flotante decreta que NaN != NaN, lo que rompe la reflexividad (a == a) que Eq exige. Sin Eq total, el contrato con Hash no se puede sostener, así que Rust te prohíbe en compilación usar flotantes como clave. Si de verdad necesitas indexar por un número real, envuélvelo en un tipo que decida qué hacer con NaN (por ejemplo, un newtype con orden total), pero nunca metas la ambigüedad de NaN dentro de una tabla hash.

El hasher por defecto y cuándo cambiarlo

El HashMap de std no calcula la huella con cualquier función: usa SipHash 1-3 sembrado con una clave aleatoria por proceso. Esto no es un adorno: sin semilla aleatoria, un atacante que conociera el algoritmo podría fabricar miles de claves que colisionan a propósito, degradando cada bucket a una lista y convirtiendo tus O(1) en O(n) —un ataque de denegación de servicio por colisión, el célebre HashDoS—. La aleatoriedad por proceso lo neutraliza, a cambio de un hash algo más lento y de que el orden de iteración cambie entre ejecuciones.

Cuando no recibes claves de un adversario —índices internos, claves que tú generas, un bucle caliente— puedes cambiar el hasher por uno más veloz sin tocar tus tipos, gracias a la separación Hash / Hasher del nivel 16. Basta construir el mapa con otro BuildHasher:

// Con el crate `rustc-hash`: mismo HashMap, hasher no criptografico y mas rapido.
use rustc_hash::FxHashMap;

fn main() {
    let mut veloz: FxHashMap<u32, &str> = FxHashMap::default();
    veloz.insert(7, "siete");     // misma API, sin resistencia a HashDoS
}

La regla práctica: deja el hasher por defecto siempre que las claves puedan venir de fuera; cámbialo a FxHashMap o ahash solo en rutas internas y calientes donde hayas medido que el hashing domina.

Una tabla hash es una apuesta estadística envuelta en dos contratos

Lo que hace mágico a un HashMap es que promete lo imposible: encontrar una aguja entre millones sin recorrer el pajar. El truco es convertir la clave en un número y usar ese número como dirección aproximada, de modo que buscar sea calcular en vez de comparar. Pero esa promesa es, en el fondo, una apuesta estadística —que las huellas se repartan uniforme y que las colisiones sean raras— y como toda apuesta, necesita reglas que impidan hacer trampa. La primera regla es el contrato Hash-Eq: el hash te lleva al vecindario correcto, pero solo Eq distingue tu clave de las vecinas que cayeron ahí por azar, y si los dos no concuerdan —si dos claves iguales pudieran tener huellas distintas— la apuesta se rompe en silencio y el mapa pierde datos que juraste haber guardado. Por eso Rust no te sugiere derivar los dos juntos: te obliga a que la clave sea Eq + Hash, cosifica el requisito en el sistema de tipos, y de paso te veta el f64 porque su NaN viola la reflexividad de la que todo depende. La segunda regla es más sutil y más bonita: la apuesta solo es justa si el adversario no puede amañar las colisiones, así que std siembra cada proceso con una llave secreta y usa un hash resistente, sacrificando velocidad y determinismo del orden a cambio de que tu servidor no se desplome cuando alguien le manda claves malévolas. Y como esa decisión —seguridad frente a velocidad— no es la misma para todos, Rust la deja intercambiable: la política de hashing vive en el BuildHasher, no soldada a tus tipos, para que cambies de apuesta con una línea. El patrón entry es la coda de todo esto: si buscar es la operación que la tabla optimiza, buscar dos veces para inserta-o-actualiza es pagar el peaje por partida doble, y entry te da la ranura una sola vez para que la resuelvas sin volver a pagar. HashMap no es una caja negra rápida; es un contrato estadístico cuidadosamente blindado, y conocer sus cláusulas es lo que separa usarlo de entenderlo.

📝
Lo esencial de HashMap

HashMap<K, V> da inserción, búsqueda y borrado en tiempo constante medio, con orden de iteración no especificado. insert devuelve el valor previo; get acepta cualquier tipo prestado por la clave. El patrón entry fusiona la doble búsqueda de inserta-o-actualiza en una sola, con or_insert, or_insert_with y and_modify. Toda clave necesita Hash + Eq porque el hash elige el bucket y Eq resuelve colisiones, bajo el contrato de que valores iguales tengan huellas iguales —de ahí que f64 quede excluido—. El hasher por defecto es SipHash sembrado al azar contra HashDoS, y se puede cambiar por uno más veloz cuando no hay adversario.

⚔️ Cuenta sin buscar dos veces
  1. Cuenta la frecuencia de cada palabra de una frase usando entry(...).or_insert(0), y reescribe la versión ingenua con contains_key para contar cuántas búsquedas hace cada una.
  2. Agrupa una lista de números en pares e impares dentro de un HashMap<bool, Vec<i32>> usando entry(...).or_default().push(n).
  3. Define struct Id(u32), derívale PartialEq, Eq, Hash y úsalo como clave; luego quítale Hash y lee el error de compilación.
  4. Intenta usar f64 como clave de un HashMap y explica, citando NaN, por qué el compilador lo rechaza.
  5. Razona qué garantía pierdes al cambiar a FxHashMap y en qué escenario esa pérdida es aceptable.