wandres.dev
COLECCIONES · Vec, HashMap, BTreeMap

VecDeque, HashSet y BinaryHeap: y cómo elegir la colección correcta

Tres colecciones especializadas y una guía de decisión. VecDeque es la cola por ambos extremos sobre un buffer circular; HashSet es la pertenencia sin valor; BinaryHeap es la cola de prioridad que siempre entrega el máximo. Cómo elegir la estructura según tu patrón de acceso, no por costumbre.

⏱ 19 min

Vec, HashMap y BTreeMap cubren la mayoría de los casos, pero la biblioteca estándar guarda tres colecciones más, cada una afilada para un patrón de acceso concreto. VecDeque es una cola eficiente por ambos extremos; HashSet responde solo a “¿está esto aquí?”; BinaryHeap entrega siempre el elemento de mayor prioridad. Conocerlas es media batalla; la otra media es saber elegir. La pregunta que decide la colección correcta no es “¿cuál es la más rápida?”, sino “¿cómo voy a acceder a estos datos?” —por índice, por los extremos, por pertenencia, por orden o por prioridad—.

🎯 Al terminar esta lección sabrás
  • Usar VecDeque como cola por ambos extremos sobre un buffer circular.
  • Aplicar HashSet a pertenencia, deduplicación y operaciones de conjunto.
  • Extraer siempre el máximo con BinaryHeap y simular un mínimo con Reverse.
  • Elegir la colección adecuada a partir del patrón de acceso, no por costumbre.

VecDeque: la cola por ambos extremos

Un Vec es óptimo por el final —push y pop amortizados O(1)— pero pésimo por el principio: insert(0, x) o remove(0) obligan a desplazar todos los demás elementos, en O(n). Cuando necesitas una cola de verdad, que crezca por un extremo y mengüe por el otro, la respuesta es VecDeque<T>: una cola de doble extremo con push_front, push_back, pop_front y pop_back, todos amortizados O(1).

use std::collections::VecDeque;

fn main() {
    let mut cola: VecDeque<i32> = VecDeque::new();
    cola.push_back(1);    // por el final
    cola.push_back(2);
    cola.push_front(0);   // por el principio, sin desplazar nada: O(1)

    assert_eq!(cola.pop_front(), Some(0)); // saca por delante
    assert_eq!(cola[0], 1);                 // indexacion tambien O(1)
    assert_eq!(cola.pop_back(), Some(2));   // ...o por detras
}

El truco está en su representación: un VecDeque es un buffer circular (ring buffer) sobre una única reserva contigua, con dos índices, cabeza y cola, que avanzan y dan la vuelta al llegar al final. Empujar por delante solo mueve el índice de cabeza hacia atrás; no desplaza datos. Por eso conserva casi toda la cache-friendliness de un Vec —sigue siendo una sola asignación contigua— mientras gana el extremo frontal barato. La única cesión es que, al poder envolverse, sus elementos no siempre son un tramo contiguo en memoria: por eso ofrece as_slices, que devuelve dos slices en vez de uno. Es la estructura natural para colas FIFO, ventanas deslizantes y el recorrido en anchura (BFS) de un grafo.

💡
VecDeque es la respuesta correcta casi siempre que pensarías en una lista enlazada

La intuición heredada de otros lenguajes dice “para una cola, usa una lista enlazada”. En Rust casi nunca es cierto. Una LinkedList dispersa cada nodo por el heap y encadena punteros, castigando la caché en cada paso; un VecDeque te da las mismas operaciones por los extremos en O(1) amortizado sobre memoria contigua, y gana en la práctica por márgenes amplios. La LinkedList de std existe, pero su nicho —empalmar listas en O(1) por el medio sin invalidar punteros— es tan estrecho que la regla sana es: si dudas entre lista enlazada y VecDeque, es VecDeque.

HashSet: pertenencia sin valor

A veces no quieres asociar un valor a una clave, solo saber si un elemento está o no está. Para eso es HashSet<T>, que por dentro es literalmente un HashMap<T, ()>: guarda elementos únicos y responde a contains en tiempo constante medio, con el mismo requisito T: Hash + Eq. Insertar un duplicado no hace nada y insert devuelve false para avisarlo.

use std::collections::HashSet;

fn main() {
    let mut vistos: HashSet<&str> = HashSet::new();
    assert!(vistos.insert("a"));   // true: era nuevo
    assert!(!vistos.insert("a"));  // false: ya estaba, no se duplica
    println!("{}", vistos.contains("a")); // true, en O(1) medio

    // Operaciones de conjunto, directas:
    let a: HashSet<i32> = [1, 2, 3].into_iter().collect();
    let b: HashSet<i32> = [2, 3, 4].into_iter().collect();
    let comun: Vec<_> = a.intersection(&b).collect();      // 2 y 3
    let solo_a: Vec<_> = a.difference(&b).collect();        // 1
    println!("{comun:?} {solo_a:?}");
}

Sus dos usos canónicos son la deduplicación (recoger una secuencia en un HashSet funde repetidos) y la pertenencia rápida —el clásico conjunto de “ya visitados” en algoritmos de grafos, que evita reprocesar nodos—. Además ofrece el álgebra de conjuntos completa: union, intersection, difference, symmetric_difference, is_subset, is_disjoint. Si necesitaras el conjunto ordenado o con consultas de rango, ya sabes que la variante es BTreeSet.

BinaryHeap: la cola de prioridad

Cuando lo que necesitas en cada paso no es un elemento cualquiera sino el más prioritario —el máximo—, la estructura es BinaryHeap<T> con T: Ord. Es un montículo binario implementado sobre un Vec: push y pop cuestan O(log n), y peek —mirar el máximo sin sacarlo— es O(1). No mantiene todo ordenado, solo garantiza que la cima es siempre el mayor.

use std::collections::BinaryHeap;
use std::cmp::Reverse;

fn main() {
    let mut monticulo = BinaryHeap::new();
    for x in [3, 1, 4, 1, 5, 9, 2] {
        monticulo.push(x);
    }
    assert_eq!(monticulo.peek(), Some(&9));  // la cima es el maximo, O(1)
    assert_eq!(monticulo.pop(), Some(9));    // saca el maximo, O(log n)
    assert_eq!(monticulo.pop(), Some(5));    // luego el siguiente mayor

    // MIN-heap: envuelve en Reverse para invertir el orden.
    let mut minimos = BinaryHeap::new();
    for x in [3, 1, 4, 1, 5] {
        minimos.push(Reverse(x));
    }
    assert_eq!(minimos.pop(), Some(Reverse(1))); // ahora sale el minimo
}

Por defecto es un max-heap: pop entrega el mayor. Para un min-heap no hay tipo aparte; se envuelve cada elemento en std::cmp::Reverse, que invierte el orden y hace que la cima pase a ser el menor —un truco idiomático que reutiliza el mismo montículo—. Es la columna vertebral de las colas de prioridad: planificadores de tareas, el algoritmo de Dijkstra, la fusión de flujos ordenados o quedarte con los k mayores de un torrente. Si al final quieres todo ordenado de una vez, into_sorted_vec lo vacía en un Vec creciente.

Elegir la colección: una guía por acceso

Toda esta variedad se ordena con una sola pregunta: ¿cómo accedes a los datos? El patrón de acceso, no la costumbre, elige la estructura.

📇

Por índice y por el final

Secuencia con acceso v[i] y crecimiento por el final: Vec<T>. La opción por defecto y la más cache-friendly.

↔️

Por ambos extremos

Cola FIFO, ventana deslizante o BFS que empuja y saca por delante y por detrás: VecDeque<T>, O(1) en los dos extremos.

🔑

Por clave

Asociar clave con valor y recuperarlo: HashMap si el orden da igual, BTreeMap si necesitas orden o rangos.

Por pertenencia

Solo importa si un elemento está: HashSet para pertenencia rápida, BTreeSet si además lo quieres ordenado.

⛰️

Por prioridad

Extraer siempre el mayor (o el menor con Reverse): BinaryHeap<T>, con push y pop en O(log n).

🧭

La duda por defecto

Si no sabes: empieza con Vec. Cambias a algo especializado solo cuando el patrón de acceso lo pida y lo hayas medido.

La colección no la elige el dato, la elige la pregunta que le vas a hacer

Es tentador pensar que los datos determinan su estructura —“son números, van en un vector”— pero es al revés: la estructura la dicta la operación que vas a repetir un millón de veces. Los mismos enteros quieren un Vec si los recorres en orden, un HashSet si preguntas una y otra vez si cierto valor está, un BinaryHeap si siempre necesitas el mayor, y un BTreeSet si consultas rangos. El dato es idéntico; lo que cambia es la pregunta caliente, y cada colección es la respuesta cristalizada a una pregunta concreta, comprada al precio de ser mala en las demás. Un Vec es imbatible por índice y por el final, y por eso es lento insertando por el principio; un HashMap va directo a una clave, y por eso no sabe nada de orden; un BinaryHeap te da el máximo al instante, y a cambio no te da el segundo sin sacar el primero. No existe la colección que gane en todo, porque las ventajas se pagan unas con otras: acelerar un acceso es, casi siempre, ralentizar otro. Por eso la madurez con las colecciones no es memorizar sus métodos, sino invertir el instinto: antes de escribir el tipo, pregúntate cuál es la operación que este código ejecutará en su bucle más interno —¿indexo, encolo, consulto pertenencia, pido el extremo?— y deja que esa pregunta, y no la inercia de usar siempre lo mismo, nombre la estructura. Rust refuerza este hábito no dándote una colección universal cómoda que te tiente a no pensar: te obliga a escribir HashMap o BTreeMap, HashSet o BinaryHeap, y en ese acto de nombrar te fuerza, cada vez, a declarar qué le vas a pedir a tus datos. Elegir bien la colección es, en el fondo, el primer y más barato acto de diseño de rendimiento que harás en un programa, hecho mucho antes de medir nada, con solo haberte preguntado qué vas a preguntar.

📝
Lo esencial de las colecciones especializadas

VecDeque<T> es una cola por ambos extremos sobre un buffer circular contiguo, con push/pop frontal y trasero en O(1) amortizado; sustituye a la lista enlazada casi siempre. HashSet<T> es pertenencia sin valor, un HashMap<T, ()> con contains en O(1) medio, ideal para deduplicar y para conjuntos de “visitados”, más el álgebra de conjuntos. BinaryHeap<T> es una cola de prioridad max-heap con push/pop en O(log n) y peek en O(1); para min-heap se usa Reverse. Y la regla que las gobierna: elige por patrón de acceso —índice, extremos, clave, pertenencia o prioridad—, empezando por Vec cuando dudes.

⚔️ Empareja acceso con estructura
  1. Simula una cola FIFO con VecDeque: encola cinco tareas por detrás y procésalas por delante; luego razona por qué un Vec con remove(0) sería O(n).
  2. Deduplica un Vec con repetidos recogiéndolo en un HashSet, y calcula la intersección con otro conjunto.
  3. Mete siete números en un BinaryHeap y sácalos todos con pop; observa que salen de mayor a menor y explica por qué no es lo mismo que ordenar.
  4. Convierte ese montículo en un min-heap con Reverse y comprueba que ahora pop entrega el menor.
  5. Para cada caso —agenda que siempre atiende la cita más urgente, registro de IDs ya procesados, historial navegable por índice— nombra la colección correcta y justifica la elección por su patrón de acceso.