Stage 5: Interview Simulation·Rust-Specific Practice
Practice9 min readTrieArena AllocationDesign

Rewrite: Trie with arena allocation

Arena-allocated trie using Vec<Node> + usize indices

Vec<TrieNode> with usize indices instead of Box pointers.

Published Apr 17, 2026

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() -> Trie
  • insert(&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

TimeO(L) per op
SpaceO(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 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)
    }
}
Rust · runnable

Arena Trie with usize indices

TimeO(L) per op
SpaceO(26 · N · L)
Recommended

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)
    }
}
Rust · runnable

Trace

Inserting "ab" then "ac":

oparena state
after init[0: {a: -1, b: -1, ...}]
insert('ab') char apush new node; root.children[a] = 1 → [0, 1]
insert('ab') char bpush new node; nodes[1].children[b] = 2 → [0, 1, 2]; nodes[2].end = true
insert('ac') char areuse child 1 (not -1)
insert('ac') char cpush 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("") returns true.
  • Prefix that is also a word (insert("ab"); insert("abc")) — both contains calls return true; the intermediate node just has end = true without a terminator child.
  • Duplicate insert — walks to the same terminal; sets end = true again (idempotent).
  • Non-lowercase input — out of contract; either panic or widen [i32; 26] to [i32; 256] if full ASCII is needed.
💡
Tip

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.

More in Rust-Specific Practice