wandres.dev
COLECCIONES · Vec, HashMap, BTreeMap

BTreeMap y BTreeSet: mapas ordenados y consultas por rango

Un BTreeMap mantiene sus claves siempre ordenadas en un B-tree cache-friendly, y con ello desbloquea la operación que ningún HashMap tiene: pedir todas las claves de un intervalo. Cuándo elegirlo frente a HashMap, cómo funcionan los rangos y el mínimo y máximo, y qué exige el trait Ord.

⏱ 19 min

El HashMap es rapidísimo, pero paga ese precio renunciando a algo: el orden. Sus claves están esparcidas por buckets según su huella, así que preguntar “dame las claves entre 100 y 200” o “cuál es la menor” le resulta imposible sin recorrerlo entero. BTreeMap<K, V> hace la apuesta contraria: mantiene las claves siempre ordenadas, aceptando un coste logarítmico por operación a cambio de tres superpoderes que el hash no puede dar —iteración en orden, mínimo y máximo instantáneos, y consultas por rango—. Y lo hace sobre un B-tree, una estructura pensada para la memoria real, no para la pizarra teórica.

🎯 Al terminar esta lección sabrás
  • Usar BTreeMap como mapa cuyas claves se mantienen ordenadas por Ord.
  • Consultar sub-intervalos con range y extraer extremos con first_key_value y pop_last.
  • Decidir entre BTreeMap y HashMap según orden, rangos y coste.
  • Aplicar BTreeSet como conjunto ordenado con las mismas ventajas.

Un mapa que mantiene el orden

Un BTreeMap se usa casi igual que un HashMapinsert, get, remove, entry— pero con una diferencia visible en cuanto lo recorres: la iteración sale en orden ascendente de clave, siempre, de forma determinista. El requisito sobre la clave también cambia: ya no es Hash + Eq, sino K: Ord, porque para colocar cada clave en su sitio el árbol necesita compararlas, no resumirlas en una huella.

use std::collections::BTreeMap;

fn main() {
    let mut m = BTreeMap::new();
    m.insert(30, "treinta");
    m.insert(10, "diez");
    m.insert(20, "veinte");

    for (k, v) in &m {
        println!("{k}: {v}");   // 10, 20, 30 SIEMPRE en ese orden
    }
    println!("{:?}", m.get(&20)); // Some("veinte"), en O(log n)
}

Pese a su nombre, un B-tree no es un árbol binario. Cada nodo almacena un pequeño arreglo contiguo de claves ordenadas —en la implementación de std, hasta once por nodo— y ramifica en muchos hijos, no en dos. Esa anchura mantiene el árbol muy bajo (pocos saltos de puntero desde la raíz a una hoja) y hace que cada nodo llene líneas de caché enteras, en lugar de dispersar un dato por nodo como haría un árbol binario clásico. El resultado es una estructura logarítmica que, además, respeta la memoria real de la máquina.

flowchart TB
root[Nodo raiz claves 20 y 40] --> a[Hoja 5 10 15]
root --> b[Hoja 25 30 35]
root --> c[Hoja 45 50 55]
style root fill:#cba6f7,color:#11111b
style a fill:#89b4fa,color:#11111b
style b fill:#89b4fa,color:#11111b
style c fill:#89b4fa,color:#11111b

Rangos: la operación que HashMap no tiene

Aquí está la razón número uno para elegir BTreeMap. Como las claves están ordenadas, el mapa puede darte todas las que caen en un intervalo navegando directo al inicio del rango y recorriendo desde allí, en O(log n + k) para k resultados. Un HashMap no puede ni acercarse: tendría que examinar cada entrada.

use std::collections::BTreeMap;

fn main() {
    let mut notas = BTreeMap::new();
    for (k, v) in [(50, "E"), (65, "D"), (72, "C"), (88, "B"), (95, "A")] {
        notas.insert(k, v);
    }

    // Todas las claves en el intervalo semiabierto 65..90:
    for (nota, letra) in notas.range(65..90) {
        println!("{nota} -> {letra}");   // 65, 72, 88 en orden
    }

    // Extremos en O(log n), sin recorrer nada:
    println!("{:?}", notas.first_key_value()); // Some((50, "E"))
    println!("{:?}", notas.last_key_value());  // Some((95, "A"))
    let peor = notas.pop_first();               // saca y devuelve el minimo
    println!("{:?}", peor);                      // Some((50, "E"))
}

range acepta los mismos rangos semiabiertos que ya conoces —a..b, a..=b, ..b, a..— y devuelve un iterador ordenado. Junto a él, first_key_value, last_key_value, pop_first y pop_last te dan el mínimo y el máximo, para leer o para extraer, todo en tiempo logarítmico. Estas operaciones son la definición misma de por qué existe un mapa ordenado: consultas de vecindad y de extremo que en un hash costarían recorrerlo entero.

BTreeMap frente a HashMap: cuándo cada uno

La elección no es de gusto, es de perfil de acceso. Puestos lado a lado:

Criterio HashMap BTreeMap
Búsqueda puntual O(1) medio O(log n)
Requisito de la clave Hash + Eq Ord
Orden de iteración ninguno, aleatorizado ascendente, determinista
Consulta por rango imposible sin recorrer O(log n + k)
Mínimo o máximo recorrer todo O(log n)

La regla práctica se lee sola. Elige HashMap cuando solo haces búsquedas puntuales y el orden te da igual: es más rápido en el caso común. Elige BTreeMap cuando necesites cualquier cosa que dependa del orden —iterar ordenado, consultar rangos, sacar el menor o el mayor, o simplemente querer una iteración reproducible entre ejecuciones—. Hay un matiz de rendimiento que sorprende: para mapas pequeños, el BTreeMap a veces gana incluso en búsqueda puntual, porque O(log n) sobre unas decenas de claves contiguas y sin hashear puede batir el coste fijo de calcular una huella SipHash. Como siempre, el asintótico no es toda la historia; lo veremos en la lección 19.5.

⚠️
Ord también tiene un contrato, y mutar una clave lo rompe

Igual que Hash exige coherencia con Eq, Ord exige ser un orden total consistente: reflexivo, antisimétrico, transitivo, y acorde con Eq. Si implementas un Ord incoherente, o —peor— mutas una clave ya insertada de forma que cambie su posición relativa (por ejemplo, a través de mutabilidad interior), rompes la invariante del árbol: quedan entradas en ramas donde una búsqueda ordenada no las encontrará. No es undefined behavior, pero sí una corrupción lógica silenciosa. La clave de un BTreeMap, como la de cualquier colección asociativa, debe tratarse como inmutable mientras esté dentro.

BTreeSet: el conjunto ordenado

Del mismo modo que HashSet es un HashMap sin valores, BTreeSet<T> es un BTreeMap<T, ()>: un conjunto que guarda elementos únicos y ordenados, con T: Ord. Hereda las tres ventajas del mapa —iteración ordenada, extremos rápidos y, sobre todo, range— además de las operaciones de conjunto (union, intersection, difference).

use std::collections::BTreeSet;

fn main() {
    let mut s: BTreeSet<i32> = [40, 10, 30, 20, 10].into_iter().collect();
    // Los duplicados se funden y queda ordenado: {10, 20, 30, 40}
    println!("{:?}", s);
    println!("min {:?} max {:?}", s.first(), s.last()); // Some(10) Some(40)

    for x in s.range(15..35) {   // elementos en un intervalo, en orden
        println!("{x}");          // 20, 30
    }
    s.insert(25);
}

Cuando quieras un conjunto pero necesites recorrerlo ordenado o consultarlo por rango —fechas dentro de una ventana, identificadores en un intervalo—, BTreeSet es la herramienta; si solo te importa la pertenencia rápida, quédate con HashSet, que veremos en la próxima lección.

Ordenar es un coste que compras por adelantado para volver baratas las preguntas de vecindad

La diferencia entre HashMap y BTreeMap es, en el fondo, una diferencia sobre cuándo pagas el orden. El hash renuncia a él por completo: dispersa las claves para que cada una tenga una dirección casi aleatoria, y esa dispersión es justo lo que hace la búsqueda puntual tan veloz —vas directo al sitio, sin comparar con nadie—. Pero al esparcir, destruye toda noción de vecindad: dos claves consecutivas, 100 y 101, acaban en buckets sin relación, así que ninguna pregunta que dependa de la proximidad —el rango, el mínimo, el sucesor— tiene respuesta sin recorrerlo todo. El B-tree hace lo opuesto: invierte trabajo en mantener el orden en cada inserción, acepta que localizar una clave cueste descender un árbol en vez de saltar a una dirección, y a cambio conserva intacta la vecindad. Y esa vecindad conservada es lo que convierte tres operaciones caras en baratas: pedir un rango es “encuentra el principio y camina”, pedir el mínimo es “baja siempre a la izquierda”, pedir el sucesor de una clave es dar un paso en un orden que ya existe. Es el mismo trueque que hay entre una pila de papeles sin ordenar y un archivador alfabético: buscar un papel concreto quizá sea igual de rápido tirando de memoria, pero “dame todo lo que empieza por G” solo es instantáneo si pagaste, hoja a hoja, el precio de mantenerlo ordenado. Rust no te esconde este trueque tras una colección “por defecto” universal: te da las dos, te obliga a nombrar cuál quieres, y con ello te fuerza a responder la única pregunta que importa —¿mis consultas son puntos sueltos, o son vecindarios?—. La respuesta a esa pregunta, no una regla general, es la que elige la estructura; y que el B-tree además esté diseñado en nodos anchos y contiguos, respetuosos con la caché, es el recordatorio de que en Rust hasta las estructuras “clásicas” están pensadas para la máquina real y no para el pseudocódigo de un libro.

📝
Lo esencial de BTreeMap y BTreeSet

BTreeMap<K, V> mantiene las claves ordenadas en un B-tree de nodos anchos y cache-friendly, con K: Ord, y ofrece inserción, búsqueda y borrado en O(log n) más iteración determinista ascendente. Su razón de ser es range, que devuelve un intervalo de claves en O(log n + k), junto a first_key_value, last_key_value y pop_first/pop_last para los extremos. Elige HashMap para búsquedas puntuales sin orden; BTreeMap cuando necesites orden, rangos o extremos. BTreeSet es su versión conjunto: elementos únicos y ordenados con las mismas ventajas.

⚔️ Consulta por vecindad
  1. Construye un BTreeMap<i32, &str> con cinco pares desordenados y comprueba que la iteración sale ascendente sin que tú ordenes nada.
  2. Usa range(a..=b) para imprimir todas las entradas de un intervalo cerrado, y explica por qué un HashMap no puede hacerlo en menos de O(n).
  3. Extrae el mínimo y el máximo con pop_first y pop_last, y razona su coste.
  4. Recoge una lista con duplicados en un BTreeSet mediante collect y observa cómo quedan únicos y ordenados; luego pide su range.
  5. Para un caso concreto (una caché por ID de acceso puntual frente a un registro de eventos consultado por ventana temporal), decide HashMap o BTreeMap y justifica la elección en una frase.