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.
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—.
- Usar
VecDequecomo cola por ambos extremos sobre un buffer circular. - Aplicar
HashSeta pertenencia, deduplicación y operaciones de conjunto. - Extraer siempre el máximo con
BinaryHeapy simular un mínimo conReverse. - 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.
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.
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.
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.
- Simula una cola FIFO con
VecDeque: encola cinco tareas por detrás y procésalas por delante; luego razona por qué unVecconremove(0)sería O(n). - Deduplica un
Veccon repetidos recogiéndolo en unHashSet, y calcula la intersección con otro conjunto. - Mete siete números en un
BinaryHeapy sácalos todos conpop; observa que salen de mayor a menor y explica por qué no es lo mismo que ordenar. - Convierte ese montículo en un min-heap con
Reversey comprueba que ahorapopentrega el menor. - 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.