Rewrite: Trie with arena allocation
Arena-allocated trie using Vec<Node> + usize indices
Vec<TrieNode> with usize indices instead of Box pointers.
Exercise
Re-implement the classic Trie data structure using arena allocation — a Vec<TrieNode> of contiguous nodes with usize indices instead of Box<TrieNode> pointers. Contract:
new() -> Trieinsert(&mut self, word: &str)contains(&self, word: &str) -> bool(word was previously inserted)starts_with(&self, prefix: &str) -> bool
Why arena? Three reasons Rust programmers hit over and over: (1) no borrow-checker friction from parents borrowing children mutably, (2) contiguous allocation is cache-friendly, (3) deletion (not part of this exercise but natural extension) is trivial — just mark slots as free.
Intuition
A pointer-based trie in Rust wants to hold HashMap<char, Box<TrieNode>> or [Option<Box<TrieNode>>; 26]. Both work, but with Box you have to juggle &mut self while walking the children — Rust's borrow checker will eventually frustrate you.
Flip the representation: one Vec<TrieNode> holds every node in the trie. Each node's "children" is an array of i32 indices into the vector (-1 means no child). The root is always at index 0. Inserting a new node is vec.push(...); the new index is the updated len() - 1.
This is the same pattern used by the Rust compiler's own arena allocators (rustc_arena), by many game engines (ECS storage), and by modern compilers and serializers. Mastering the pattern is more transferable than any single algorithm.
Approach
HashMap-Children Trie
O(L) per opO(N · L)Each node stores HashMap<char, Box<TrieNode>>. Compact for sparse alphabets (Unicode, mixed case) but dereferences a Box and hashes on each step. For lowercase ASCII it's about 3× slower than the arena version because of the allocator traffic and the hash.
use std::collections::HashMap;
#[derive(Default)]
struct TrieNode { children: HashMap<char, Box<TrieNode>>, end: bool }
pub struct Trie { root: TrieNode }
impl Trie {
pub fn new() -> Self { Self { root: TrieNode::default() } }
pub fn insert(&mut self, word: &str) {
let mut cur = &mut self.root;
for c in word.chars() {
cur = cur.children.entry(c).or_default();
}
cur.end = true;
}
pub fn contains(&self, word: &str) -> bool {
self.walk(word).map_or(false, |n| n.end)
}
pub fn starts_with(&self, prefix: &str) -> bool {
self.walk(prefix).is_some()
}
fn walk(&self, s: &str) -> Option<&TrieNode> {
let mut cur = &self.root;
for c in s.chars() {
cur = cur.children.get(&c)?;
}
Some(cur)
}
}
class Trie {
private root: { children: Map<string, any>; end: boolean } = { children: new Map(), end: false };
insert(word: string): void {
let cur = this.root;
for (const c of word) {
if (!cur.children.has(c)) cur.children.set(c, { children: new Map(), end: false });
cur = cur.children.get(c);
}
cur.end = true;
}
contains(word: string): boolean {
const n = this.walk(word);
return n !== null && n.end;
}
startsWith(prefix: string): boolean { return this.walk(prefix) !== null; }
private walk(s: string): { children: Map<string, any>; end: boolean } | null {
let cur = this.root;
for (const c of s) {
const nx = cur.children.get(c);
if (!nx) return null;
cur = nx;
}
return cur;
}
}
Arena Trie with usize indices
O(L) per opO(26 · N · L)Node: { children: [i32; 26], end: bool }. Arena: Vec<Node>. The root is always at index 0. insert walks by index, pushes new nodes when the needed child slot is -1. contains and starts_with walk without mutating; both short-circuit on a missing child.
#[derive(Clone)]
struct TrieNode {
children: [i32; 26],
end: bool,
}
impl TrieNode {
fn new() -> Self { Self { children: [-1; 26], end: false } }
}
pub struct Trie {
nodes: Vec<TrieNode>,
}
impl Trie {
pub fn new() -> Self {
Self { nodes: vec![TrieNode::new()] }
}
pub fn insert(&mut self, word: &str) {
let mut cur: usize = 0;
for b in word.bytes() {
let idx = (b - b'a') as usize;
let next = self.nodes[cur].children[idx];
cur = if next == -1 {
self.nodes.push(TrieNode::new());
let new_id = (self.nodes.len() - 1) as i32;
self.nodes[cur].children[idx] = new_id;
new_id as usize
} else {
next as usize
};
}
self.nodes[cur].end = true;
}
pub fn contains(&self, word: &str) -> bool {
self.walk(word).map_or(false, |id| self.nodes[id].end)
}
pub fn starts_with(&self, prefix: &str) -> bool {
self.walk(prefix).is_some()
}
fn walk(&self, s: &str) -> Option<usize> {
let mut cur: usize = 0;
for b in s.bytes() {
let idx = (b - b'a') as usize;
let nx = self.nodes[cur].children[idx];
if nx == -1 { return None; }
cur = nx as usize;
}
Some(cur)
}
}
// Arena-backed trie. Each node is 26 child indices + an end flag.
// Indices are stored in a single flat Int32Array for cache locality.
class Trie {
private children: number[][] = [new Array(26).fill(-1)];
private end: boolean[] = [false];
insert(word: string): void {
let cur = 0;
for (let i = 0; i < word.length; i++) {
const idx = word.charCodeAt(i) - 97;
if (this.children[cur][idx] === -1) {
this.children.push(new Array(26).fill(-1));
this.end.push(false);
this.children[cur][idx] = this.children.length - 1;
}
cur = this.children[cur][idx];
}
this.end[cur] = true;
}
contains(word: string): boolean {
const id = this.walk(word);
return id !== -1 && this.end[id];
}
startsWith(prefix: string): boolean {
return this.walk(prefix) !== -1;
}
private walk(s: string): number {
let cur = 0;
for (let i = 0; i < s.length; i++) {
const idx = s.charCodeAt(i) - 97;
const nx = this.children[cur][idx];
if (nx === -1) return -1;
cur = nx;
}
return cur;
}
}Trace
Inserting "ab" then "ac":
| op | arena state |
|---|---|
| after init | [0: {a: -1, b: -1, ...}] |
| insert('ab') char a | push new node; root.children[a] = 1 → [0, 1] |
| insert('ab') char b | push new node; nodes[1].children[b] = 2 → [0, 1, 2]; nodes[2].end = true |
| insert('ac') char a | reuse child 1 (not -1) |
| insert('ac') char c | push new node; nodes[1].children[c] = 3 → [0, 1, 2, 3]; nodes[3].end = true |
Edge cases
- Empty string insert — the root becomes a terminal node;
contains("")returnstrue. - Prefix that is also a word (
insert("ab"); insert("abc")) — bothcontainscalls returntrue; the intermediate node just hasend = truewithout a terminator child. - Duplicate insert — walks to the same terminal; sets
end = trueagain (idempotent). - Non-lowercase input — out of contract; either panic or widen
[i32; 26]to[i32; 256]if full ASCII is needed.
The arena pattern works for any tree-like structure where the node set grows only. For deletion-heavy workloads, keep a free list of reclaimed indices and reuse them on insert. For persistent/immutable tries (Merkle tries, HAMTs), move to structural sharing — a different design.
Takeaway
Index-based arena allocation is Rust's go-to for tree-like structures. It eliminates borrow-checker friction and delivers better cache locality than heap-allocated Boxes. Use it whenever you would otherwise write Box<Node> children — tries, BSTs, expression trees, scene graphs.
In interviews, volunteering "I'll use an arena" signals you understand Rust-the-language, not just Rust-the-syntax. It costs you nothing in code length and pays back in clarity.
Build the basic trie. In Rust, use Vec<Option<Box<TrieNode>>> or arena alloc.
Binary trie. Insert all numbers, then for each number greedily pick opposite bits.
Offline sort + incremental trie insertion + XOR query.
More in Rust-Specific Practice
Build a SegTree<T: Monoid> that works for sum, min, max, and GCD.
Implement as a struct with rank and size. Make it reusable.
Use std::cmp::Reverse for min-heap. Handle the Rust BinaryHeap idioms.
mod_pow, mod_inv, Combo struct with nCr. Your Olympiad toolkit in Rust.