Rust and Data Structures
Choosing Data Structures
Choosing the right data structure in Rust balances asymptotic time complexity ($O(1)$, $O(\log n)$, $O(n)$) against constant-factor hardware realities (contiguous cache lines vs pointer chasing, and allocation count).
Decision Tree for Standard Rust Collections:
Need a sequence of items?
|-- Only push/pop at back or index by `usize`? ---> `Vec<T>` (Default for 90% of cases!)
|-- Need O(1) push/pop at BOTH front and back? ---> `VecDeque<T>`
+-- Always extract the largest/smallest item? ---> `BinaryHeap<T>`
Need key-value lookup or uniqueness (`Set`)?
|-- Point lookup (`get`, `insert`, `contains`)? ---> `HashMap<K, V>` / `HashSet<T>`
+-- Sorted order or range queries (`a..=b`)? ---> `BTreeMap<K, V>` / `BTreeSet<T>`
Asymptotic Complexity Matrix of Standard Library Collections
| Collection | Index [i] |
Search by Key/Value | Insert Front | Insert Back | Insert Key | Remove Mid | Memory Overhead per Item |
|---|---|---|---|---|---|---|---|
Vec<T> |
$O(1)$ | $O(n)$ (or $O(\log n)$ if sorted) | $O(n)$ | $O(1)$ amortized | — | $O(n)$ (or $O(1)$ swap_remove) |
0 bytes (beyond spare capacity) |
VecDeque<T> |
$O(1)$ | $O(n)$ | $O(1)$ amortized | $O(1)$ amortized | — | $O(n)$ | 0 bytes (beyond spare capacity) |
HashMap<K, V> |
— | $O(1)$ expected | — | — | $O(1)$ expected | $O(1)$ expected | ~1 control byte + load-factor slack |
BTreeMap<K, V> |
— | $O(\log n)$ | — | — | $O(\log n)$ | $O(\log n)$ | Amortized across 6–11 keys per node |
BinaryHeap<T> |
— | $O(1)$ max (peek) |
— | — | $O(\log n)$ (push) |
$O(\log n)$ (pop) |
0 bytes (backed by Vec<T>) |
LinkedList<T> |
$O(n)$ | $O(n)$ | $O(1)$ | $O(1)$ | — | $O(n)$ to reach | 16 bytes (prev + next pointers) + allocator header per node! |
Crossover Points: When Sorted Vec<(K, V)> Beats HashMap<K, V>
Asymptotic $O(1)$ vs $O(n)$ or $O(\log n)$ hides constant factors for small collections ($N \le 32$):
- Computing a SipHash over a key and probing a
HashMaptakes ~15–25 ns. - Scanning a contiguous
Vec<(K, V)>of 12 elements sitting inside two L1 cache lines takes under 5 ns—and requires less memory!
| Collection Size $N$ | Access Pattern | Fastest Data Structure in Practice |
|---|---|---|
| $N \le 16$–$32$ | Point lookup & iteration | Linear scan on Vec<(K, V)> (or SmallVec<[(K, V); 8]>) |
| $N > 32$ (Write once, read many) | Point lookup, range queries, & compact RAM | Sorted Vec<(K, V)> + binary_search_by_key (Zero hash table slack!) |
| $N > 32$ (Dynamic inserts & lookups) | Frequent insertions, deletions, & point lookups | HashMap<K, V> (or BTreeMap<K, V> if sorted range queries are needed) |
/// A read-optimized immutable lookup table backed by a sorted `Box<[(K, V)]>`.
/// Uses 0 bytes of hash-table overhead and answers queries in O(log n) via binary search!
pub struct FrozenMap<K, V> {
entries: Box<[(K, V)]>,
}
impl<K: Ord, V> FrozenMap<K, V> {
pub fn from_vec(mut vec: Vec<(K, V)>) -> Self {
vec.sort_unstable_by(|a, b| a.0.cmp(&b.0));
vec.dedup_by(|a, b| a.0 == b.0);
Self {
entries: vec.into_boxed_slice(),
}
}
pub fn get(&self, key: &K) -> Option<&V> {
let idx = self.entries.binary_search_by(|(k, _)| k.cmp(key)).ok()?;
Some(&self.entries[idx].1)
}
pub fn len(&self) -> usize {
self.entries.len()
}
}
fn main() {
let table = FrozenMap::from_vec(vec![
(404, "Not Found"),
(200, "OK"),
(500, "Internal Server Error"),
(201, "Created"),
]);
assert_eq!(table.get(&200), Some(&"OK"));
assert_eq!(table.get(&404), Some(&"Not Found"));
assert_eq!(table.get(&302), None);
println!("FrozenMap ({} entries) lookup 200 -> {:?}", table.len(), table.get(&200));
}
FrozenMap (4 entries) lookup 200 -> Some("OK")