Skip to content
intermediate Phase 8 · Collections and Iterators

HashMap and BTreeMap

Store key-value data with hashing or sorted iteration depending on needs.

45m
0 problems
Topic Progress 0%

HashMap for Lookups, BTreeMap for Order: Choosing With the Query, Not the Habit

Two map types ship in the standard library, and the choice is decided by what your readers do with the data:

use std::collections::{HashMap, BTreeMap};

let mut scores: HashMap<&str, u32> = HashMap::new();
scores.insert("ada", 90);

let mut ranks: BTreeMap<&str, u32> = BTreeMap::new();
ranks.insert("ada", 90);

HashMap hashes keys into buckets: lookup, insert, and remove are O(1) average, degrading to O(n) only under adversarial collisions. The default hasher is randomized per process (RandomState), so two runs of the same program iterate in different orders — that randomization is deliberate, it is the defense against hash-flooding denial-of-service, and it is also why you must never depend on HashMap iteration order or write a test that asserts one. BTreeMap keeps keys in a balanced binary search tree: O(log n) for every operation, but iteration comes out sorted and range queries are first-class:

let mut prices: BTreeMap<String, f64> = BTreeMap::new();
// every operation O(log n)
for (sku, price) in prices.range("A000"..="B999") {   // ordered scan of a key window
    println!("{sku}: {price}");
}

HashMap has no range at all — a windowed query means iterating everything and filtering, O(n). The decision table:

Requirement Choose Why
Point lookups by key HashMap average O(1) vs O(log n)
Sorted iteration / min / max BTreeMap order is the data structure
Range or predecessor queries BTreeMap range, first_key_value, last_key_value
Memory footprint BTreeMap (often) no hasher state, nodes are compact
Deterministic order in tests BTreeMap sorted output, no RandomState
Key with expensive Ord but cheap Hash HashMap hashing runs once per lookup; comparison runs log n times

Type bounds differ too, and they bite during refactors: a HashMap key needs Eq + Hash, a BTreeMap key needs Ord (which implies Eq). That is why f64 can never be a map key in either — f64 implements neither Hash nor Ord (NaN breaks reflexivity: NaN != NaN), so you wrap it in a newtype and hash the bits, or key by an integer. A newtype around an ID with #[derive(Hash, Eq, PartialEq)] is the standard fix for a domain key.

The Hash + Eq contract deserves its own sentence because violating it is the classic map bug: if two keys are equal, they must produce the same hash. Derive both together (#[derive(PartialEq, Eq, Hash)]) and never hand-write Eq in a way that disagrees with Hash. Break it and entries become unreachable — inserted under one hash, looked up under another, silently None, no error anywhere.

When performance claims matter: HashMap with SipHash (the default) trades speed for DoS resistance; a HashMap<K, V, BuildHasherDefault<...>> or the fxhash/ahash crates speed up integer-heavy workloads when all keys are trusted. BTreeMap nodes allocate per entry and compare keys log n times per operation — for small maps (a few dozen entries) the difference is noise, which is exactly when a BTreeMap's deterministic order is worth taking. In interviews, "when would you pick BTreeMap over HashMap?" is answered with order and ranges, not with speed.

Two API details that round out the choice: BTreeMap exposes range, range_mut, first_key_value, last_key_value, and pop_first/pop_last (ordered extraction — a min-heap workload without a heap), while HashMap offers none of those but adds try_fold-style iteration and cheap retain. Both provide Default, Extend<(K, V)>, and FromIterator, so collect() builds either from a tuple iterator identically — swapping containers rarely changes the pipeline, only the capabilities.

The Entry API: One Lookup Instead of Two

The counting bug every language has:

let mut counts: HashMap<String, u32> = HashMap::new();
for word in text.split_whitespace() {
    if let Some(n) = counts.get_mut(word) {   // lookup #1
        *n += 1;
    } else {
        counts.insert(word.to_string(), 1);   // lookup #2 (hash again, find slot again)
    }
}

Two hash computations and up to two probes per word, plus an allocation of the key in the miss branch that the entry API lets you defer. The same loop with entry:

for word in text.split_whitespace() {
    *counts.entry(word.to_string()).or_insert(0) += 1;
}

One lookup, one code path. entry takes the key by value and returns an Entry enum that has already located the slot:

Call Behavior on vacant Behavior on occupied
or_insert(v) inserts v returns existing &mut V
or_insert_with(f) runs closure, inserts result returns existing &mut V, closure never runs
or_default() inserts V::default() returns existing &mut V
and_modify(f) does nothing applies f(&mut V)
and_modify(f).or_insert(v) inserts v applies f to the old value

or_insert_with matters the same way unwrap_or_else does — the closure defers an allocation or a computation to the branch that needs it:

let start = cache
    .entry(key.clone())
    .or_insert_with(|| expensive_load(key));   // not called on a hit

For control flow beyond "insert if missing," match on the entry:

match logs.entry(request_id) {
    Entry::Occupied(mut e) => { e.get_mut().push(line); }
    Entry::Vacant(e) => { e.insert(vec![line]); }
}

That is and_modify + or_insert spelled out — useful when the two arms need real statements rather than expressions.

The other half of the double-lookup story is insert, which returns the previous value: if let Some(old) = map.insert(k, v) { ... } tells you an overwrite happened, so detecting collisions (a key you assumed unique) costs nothing extra. Pair it with contains_key only when you need to test without writing — and when you truly need "get, and if absent, compute and store," entry still wins because it does the locate once.

Two details that make entry feel native rather than exotic. First, the API is identical on BTreeMap — same entry, or_insert*, and_modify methods, only the path differs (std::collections::btree_map::Entry) — so switching containers never rewrites your update logic. Second, entry hands back &mut V directly, so a simple policy stays one expression, and anything with real statements in both branches grows into a match on the entry instead of a nested conditional:

*hits.entry(path.to_string()).or_insert(0) += 1;      // one lookup, one expression

// both branches carry statements:
match counters.entry(user) {
    Entry::Occupied(mut e) => { *e.get_mut() = (*e.get_mut()).max(1); }
    Entry::Vacant(e) => { e.insert(1); }
}

Grouping is the canonical real-world use — one pass over records, no intermediate Vecs:

let mut by_tenant: HashMap<String, Vec<Order>> = HashMap::new();
for o in orders {
    by_tenant.entry(o.tenant.clone()).or_default().push(o);   // or_default(): Vec::new() on miss
}

or_default() on a HashMap<String, Vec<_>> inserts an empty Vec only when the tenant is new, and push then hits an existing (or fresh) bucket. The clone of o.tenant is the one allocation the entry API cannot avoid — because the key must be owned by the map — and it is exactly the kind of cost you accept once, per grouping, instead of per comparison.

Micro-note worth knowing: entry consumes the key, so counts.entry(word.to_string()) allocates the String even when the key is already present — for hot paths over borrowed data, pre-check with get_key_value (it returns (&K, &V) so you can clone the existing key instead of allocating a new one) or keep keys as &str borrowed from an arena. The map-side cost is one probe either way; the allocation is what you can avoid.

Borrowed Lookups, Owned Keys, and Removal Without Surprises

A HashMap<String, u32> can be queried with a plain &str without building a String — and understanding why prevents the "why doesn't this compile" week:

let mut ages: HashMap<String, u32> = HashMap::new();
ages.insert("ada".to_string(), 36);

let n = ages.get("ada");                     // works: get<Q: ?Sized>(&self, k: &Q)
                                             // where String: Borrow<Q>, Q: Hash + Eq

The Borrow bound is the contract: String: Borrow<str> (and Borrow<String>), so get, contains_key, remove, and entry accept &str for a String-keyed map — hashing the borrowed form must agree with hashing the owned form, which is exactly the same "consistent hash" requirement from the previous chapter. Consequences: lookups over &str never allocate, and a HashMap<String, _> you only read can be built from borrowed slices (HashMap<&str, _> where the source strings outlive it — the lifetime rules from the reference topics apply unchanged).

Removal has three shapes, each with a different return:

let v: Option<u32> = ages.remove("ada");           // just the value
let kv: Option<(String, u32)> = ages.remove_entry("ada");  // value AND the owned key back
ages.retain(|_k, v| *v >= 18);                     // bulk filter, one pass
let leftover: HashMap<String, u32> = std::mem::take(&mut ages);  // move the whole map out

remove_entry is the one people miss — when the key is expensive to reconstruct (a parsed identifier, a joined path), getting it back out with the value avoids re-deriving it. retain mirrors Vec::retain: one pass, no intermediate allocation. And std::mem::take (or mem::replace) moves the map out while leaving an empty one in place, which is how you hand a buffer of state to a worker without cloning.

Iteration deserves a warning regardless of which map you chose:

for (k, v) in &map { }        // borrows: map still usable
for (k, v) in map { }         // consumes: pairs are moved out (keys and values)
map.iter_mut()                // &mut V, mutate values in place (keys are immutable by design)

Mutating keys is impossible through iteration because that would break the map's own invariants — the key's position depends on its hash or its sort order. To change a key, remove the old entry and insert under the new one; this is a design statement, not a limitation: &mut K would let an entry become unfindable. Inserting while iterating has the same borrow-conflict shape you met with Vec — collect your additions first, or use extend after the loop.

Two value-update shapes, and when each is right: get_mut when the key is known to exist (a loop over a pre-populated config) — it is a plain lookup with a mutable borrow; entry when existence is genuinely unknown — it is the lookup-plus-decision in one call. Mixing them up costs either a redundant contains_key (with get_mut) or a redundant probe (with entry), and only one of those is visible in a profile.

Borrowed keys are the other everyday design choice, and lifetimes make the trade explicit:

let words: Vec<&str> = text.split_whitespace().collect();
let mut counts: HashMap<&str, usize> = HashMap::new();
for w in words { *counts.entry(w).or_insert(0) += 1; }
// `counts` borrows from `text` — it cannot outlive the buffer it indexes into

Keying by &str means zero allocations for keys, and the compiler enforces the data's lifetime: the moment text would be dropped, counts becomes a borrow error. That is usually what you want for short-lived aggregation; when the map must outlive its source — cached parsed names, a struct's field — clone the keys into Strings at insert time and pay one allocation per distinct key, once.

Finally, capacity: HashMap::with_capacity(n) reserves buckets up front (load factor ~0.75 means it reserves a bit more than n), and reserve(additional) avoids rehashing inside a bulk-load loop. A HashMap that grows during a 100k-element import rehashes log-wise several times; one with_capacity call turns that into a single allocation. This is the same reallocation discipline as Vec and Dynamic Arrays, one level up.

Quiz

1. You need to iterate invoice IDs in ascending order and run `range(start..=end)` queries. Which map?

Question 1 options

2. What does `map.insert(k, v)` return, and why does it matter?

Question 2 options

3. A `HashMap<String, i32>` is queried with `map.get("port")` where the argument is `&'static str`. Why does that compile?

Question 3 options

4. Your custom `UserKey` is inserted and then `get` returns None even though you just inserted it. What is the most likely cause?

Question 4 options

Flashcards

Question

HashMap vs BTreeMap — complexity, bounds, and iteration order.

Answer

HashMap: average O(1) get/insert/remove, keys need `Eq + Hash`, order is randomized per process (`RandomState`) — never assert on it. BTreeMap: O(log n) everywhere, keys need `Ord`, iteration is sorted and `range`/`first_key_value` are available.

Question

What is the `entry` API for, and what does `or_insert_with` guarantee?

Answer

It performs a single lookup and returns an `Entry::{Occupied, Vacant}` describing the slot, so check-and-insert is one code path instead of `contains_key` + `insert`. `or_insert_with(f)` runs `f` only when the key is absent — the closure never executes on a hit, which keeps expensive defaults off the hot path.

Question

State the Hash/Eq contract for map keys and what violating it looks like.

Answer

If `a == b` then `hash(a) == hash(b)` — always derive `PartialEq, Eq, Hash` together and keep the same fields in both. Violation symptoms: entries you just inserted come back `None`, no error, because insert and get probe different buckets.

Question

What does `Borrow` buy you when a map is keyed by `String`?

Answer

`get`/`remove`/`contains_key`/`entry` accept `&str` (any `Q` where `String: Borrow<Q>, Q: Hash + Eq`), so lookups borrow instead of allocating a temporary String — and hashing the borrowed bytes must equal hashing the owned key for the entry to be found.

Question

Three removal tools and what each returns.

Answer

`remove(k) -> Option<V>` (value only); `remove_entry(k) -> Option<(K, V)>` (value plus the owned key back); `retain(|k, v| keep)` (bulk filter in one pass). `mem::take(&mut map)` moves the entire map out, leaving an empty one — state handoff without cloning.

Revision Notes

Key Takeaways

  • 1. `HashMap` gives average O(1) with randomized iteration order (the hash-flooding defense); `BTreeMap` gives O(log n) with sorted iteration and native `range` queries — order, not speed, decides between them
  • 2. The `Hash`+`Eq` contract is the map's correctness invariant: derive them together or entries silently become unreachable
  • 3. `entry` collapses check-and-insert into one lookup, and `or_insert_with` defers the expensive default exactly like `unwrap_or_else`
  • 4. `insert` returns the displaced `Option<V>`, so overwrite detection is free; `remove` vs `remove_entry` decides whether you get the key back
  • 5. `Borrow` lets `String`-keyed maps answer `&str` lookups without allocating — provided hashing agrees between the owned and borrowed forms

Interview Tips

  • • "\"HashMap or BTreeMap?\" — answer from the query: point lookups → HashMap; sorted iteration/range/need deterministic order → BTreeMap; add the bounds (`Eq + Hash` vs `Ord`) and the randomized-order gotcha"
  • • "\"What does entry do better?\" — one hash/probe instead of two, plus a single code path; give the word-count example and note `or_insert_with` never runs the closure on a hit"
  • • "\"Why does my newly inserted key return None?\" — Hash/Eq mismatch; ask which fields are in each impl, this demonstrates you have debugged the bug rather than read about it"
  • • "\"Can I change a key in place?\" — no: `&mut K` would break hash/sort placement; remove the entry and re-insert, and explain why the API gives `&mut V` but never `&mut K`"

Cheat Sheet

HashMap and BTreeMap

Choose by query:

Need Pick
point lookups, avg O(1) HashMap (keys: Eq + Hash)
sorted iteration, range(a..b), min/max BTreeMap (keys: Ord)
deterministic test output BTreeMap
small map, cheap memory either — measure

Insert / check / count:

*map.entry(k).or_insert(0) += 1;              // one lookup
map.entry(k).or_insert_with(|| load());       // default only on miss
map.entry(k).and_modify(|v| *v += 1).or_insert(1);
match map.entry(k) { Entry::Occupied(mut e) => ..., Entry::Vacant(e) => ... }
let old: Option<V> = map.insert(k, v);        // Some(old) = overwrite

Read / remove:

map.get("borrowed-str")        // works for String keys: K: Borrow<Q>
map.get_mut(&k)                // mutate the value in place
map.remove(&k) -> Option<V>    map.remove_entry(&k) -> Option<(K, V)>
map.retain(|_k, v| keep(v));   std::mem::take(&mut map)

Invariants: equal keys must hash equally (derive PartialEq, Eq, Hash together); keys are immutable while stored (change = remove + insert); no mutation while iterating (borrow conflict).

Continue Learning

Before You Start

Next Topics

Related Reading