Trie implementation
How to build a trie in Java: array or hash-map children, what a node costs in memory, how to shrink the alphabet, and when to prefer a loop over recursion.
Learning goals
- Choose between an array and a HashMap for the children of a node
- Estimate the memory a node costs and why tries can be memory hungry
- Shrink child storage with a bitmask when most nodes have few children
- Write both an iterative and a recursive walk and pick the right one
Prerequisites
A trie trades memory for speed. The same idea can be coded as a compact 26-slot array or as a flexible map; knowing the price of each decides whether the structure fits in memory at all.
Array child pointers
The array layout gives every node a Node[] of the alphabet size. The child for a letter is one array read, with no hashing and no boxing, so it is the fastest layout when the alphabet is small and fixed. Choosing the alphabet size as a constructor parameter makes the same class work for digits (10), DNA (4) or lowercase letters (26).
The price is space: every node pays for all its slots even if it has one child. A node of a word list with long, unshared tails (for example a dictionary of 10-letter names) is mostly empty slots.
1class ArrayTrie {2private static class Node {3final Node[] next;4boolean end;5Node(int sigma) { next = new Node[sigma]; }6}7private final int sigma;8private final char first; // the letter that maps to slot 09private final Node root;10int nodes;11ArrayTrie(int sigma, char first) { this.sigma = sigma; this.first = first; this.root = new Node(sigma); }12void insert(String word) {13Node n = root;14for (char c : word.toCharArray()) {15int i = c - first;16if (i < 0 || i >= sigma) throw new IllegalArgumentException("outside the alphabet: " + c);17if (n.next[i] == null) { n.next[i] = new Node(sigma); nodes++; }18n = n.next[i];19}20n.end = true;21}22boolean contains(String word) {23Node n = root;24for (char c : word.toCharArray()) {25int i = c - first;26if (i < 0 || i >= sigma || (n = n.next[i]) == null) return false;27}28return n.end;29}30}
HashMap child map
A HashMap<Character, Node> per node stores only the children that exist. It handles any alphabet, including digits, upper case, punctuation and any UTF-16 code unit, without choosing a size in advance. Nodes with one child cost one map entry instead of 26 slots.
The price is time and per-entry overhead: each lookup hashes a boxed Character, and a HashMap has a table and entry objects of its own, so for dense nodes it is larger than an array. A common compromise is to create the map lazily, so leaves (which have no children) do not allocate one.
1class MapTrie {2private static class Node {3Map<Character, Node> next; // created on first use: leaves have none4boolean end;5}6private final Node root = new Node();7void insert(String word) {8Node n = root;9for (char c : word.toCharArray()) {10if (n.next == null) n.next = new HashMap<>();11n = n.next.computeIfAbsent(c, k -> new Node());12}13n.end = true;14}15boolean contains(String word) {16Node n = root;17for (char c : word.toCharArray()) {18if (n.next == null || (n = n.next.get(c)) == null) return false;19}20return n.end;21}22boolean startsWith(String prefix) {23Node n = root;24for (char c : prefix.toCharArray()) {25if (n.next == null || (n = n.next.get(c)) == null) return false;26}27return true;28}29}
Node memory
Tries can use much more memory than the strings they hold, so it is worth estimating. On a typical 64-bit HotSpot JVM with compressed references, a Node object with a reference to its child array and a boolean takes about 24 bytes (12-byte header, 4-byte reference, 1 byte, padded to a multiple of 8), and a Node[26] array takes 16 + 26 × 4 = 120 bytes. That is about 144 bytes per node, even for a leaf with no children.
The numbers below are layout estimates, not measurements: the exact sizes depend on the JVM and its flags, and the JOL tool or a heap dump gives the real figures for your machine. The shape of the argument does not change: with the array layout every node pays for the whole alphabet, so the cost grows with the number of nodes times the alphabet size, not with the number of words.
| Child storage | Per-node cost (estimate) | Lookup | Best for |
|---|---|---|---|
| Node[26] array | about 144 bytes | one array read | small, fixed alphabet; dense nodes |
| HashMap per node | a map object, its table and one entry object per child | hash + compare | large or unknown alphabet; sparse nodes |
| Bitmask + compact array | the node, one int and an array with one reference per existing child | bitCount + array read | many sparse nodes with a small alphabet |
1static long align(long bytes) { return (bytes + 7) / 8 * 8; } // objects are padded to 8 bytes2// estimate for a 64-bit HotSpot JVM with compressed references3static long arrayNodeBytes(int alphabet) {4long node = align(12 + 4 + 1); // header, array reference, boolean5long slots = align(16 + 4L * alphabet); // array header (with length) + one reference per slot6return node + slots;7}
Compressed alphabets
A middle path keeps the speed of array indexing while paying only for children that exist. Each node stores a bitmask with one bit per letter and a compact array holding just the present children. The slot of letter c is the number of set bits below bit c, which Integer.bitCount computes in one instruction.
Lookups stay O(1) per letter. The cost moves to insertion: adding a child in the middle means growing and shifting the compact array. This layout suits tries that are built once and queried many times, such as a dictionary loaded at start-up.
1class BitTrie {2private static class Node {3int mask; // bit i set: the child for letter 'a' + i exists4Node[] kids = new Node[0]; // only the children that exist, in letter order5boolean end;6}7private final Node root = new Node();8private static int slot(Node n, int i) { return Integer.bitCount(n.mask & ((1 << i) - 1)); }9void insert(String word) {10Node n = root;11for (char c : word.toCharArray()) {12int i = c - 'a';13int s = slot(n, i);14if ((n.mask & (1 << i)) == 0) { // add a child: grow the array and open slot s15Node[] bigger = new Node[n.kids.length + 1];16System.arraycopy(n.kids, 0, bigger, 0, s);17bigger[s] = new Node();18System.arraycopy(n.kids, s, bigger, s + 1, n.kids.length - s);19n.kids = bigger;20n.mask |= 1 << i;21}22n = n.kids[s];23}24n.end = true;25}26boolean contains(String word) {27Node n = root;28for (char c : word.toCharArray()) {29int i = c - 'a';30if ((n.mask & (1 << i)) == 0) return false;31n = n.kids[slot(n, i)];32}33return n.end;34}35}
Iterative vs recursive walk
Insertion, search and prefix queries walk one path, so a loop is the natural form: no stack frames, no recursion limit, and the code is shorter. Use recursion when the walk must come back, as when pruning during delete or exploring all branches for wildcards and autocomplete (Lesson 28.03 and 28.04).
A recursive walk uses stack space proportional to the word length. For words of a few dozen letters that is harmless; for very long keys (thousands of characters) the loop avoids a possible StackOverflowError. Both versions below behave identically on every input.
1class WalkTrie {2private static class Node { Node[] next = new Node[26]; boolean end; }3private final Node root = new Node();4void insert(String word) { // iterative: one loop, no stack5Node n = root;6for (char c : word.toCharArray()) {7int i = c - 'a';8if (n.next[i] == null) n.next[i] = new Node();9n = n.next[i];10}11n.end = true;12}13boolean containsIterative(String word) {14Node n = root;15for (char c : word.toCharArray()) {16n = n.next[c - 'a'];17if (n == null) return false;18}19return n.end;20}21boolean containsRecursive(String word) { return contains(root, word, 0); }22private boolean contains(Node n, String w, int i) { // recursive: one frame per letter23if (n == null) return false;24if (i == w.length()) return n.end;25return contains(n.next[w.charAt(i) - 'a'], w, i + 1);26}27}
Trie: insert words, then search a word or a prefix
Interactive · edit the inputtrie · 1 node
An empty trie: only the root. Each edge is one letter; a word is the path from the root to a node marked as an ending.
1class Trie {2private static class Node { Node[] next = new Node[26]; boolean end; }3private final Node root = new Node();4void insert(String word) {5Node n = root;6for (char c : word.toCharArray()) {7if (n.next[c - 'a'] == null) n.next[c - 'a'] = new Node(); // create the missing edge8n = n.next[c - 'a'];9}10n.end = true; // a word ends here11}12private Node find(String s) {13Node n = root;14for (char c : s.toCharArray()) {15n = n.next[c - 'a'];16if (n == null) return null; // the path breaks17}18return n;19}20boolean contains(String word) { Node n = find(word); return n != null && n.end; }21boolean startsWith(String prefix) { return find(prefix) != null; }22}
Live state
words
5
Execution trace (dry run table)Show 25 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L3 | words=5 | An empty trie: only the root. Each edge is one letter; a word is the path from the root to a node marked as an ending. |
| 2 | L7 | word=car char=c nodes created=1 | insert("car"): there is no 'c' edge below "root", so a new node is created. |
| 3 | L7 | word=car char=a nodes created=2 | insert("car"): there is no 'a' edge below "c", so a new node is created. |
| 4 | L7 | word=car char=r nodes created=3 | insert("car"): there is no 'r' edge below "ca", so a new node is created. |
| 5 | L10 | word=car new nodes=3 | Mark the last node of "car" as a word ending (3 new nodes). |
| 6 | L8 | word=cart char=c | insert("cart"): the 'c' edge already exists (a shared prefix), so it is reused: no new node. |
| 7 | L8 | word=cart char=a | insert("cart"): the 'a' edge already exists (a shared prefix), so it is reused: no new node. |
| 8 | L8 | word=cart char=r | insert("cart"): the 'r' edge already exists (a shared prefix), so it is reused: no new node. |
| 9 | L7 | word=cart char=t nodes created=4 | insert("cart"): there is no 't' edge below "car", so a new node is created. |
| 10 | L10 | word=cart new nodes=1 | Mark the last node of "cart" as a word ending (1 new node). |
| 11 | L8 | word=cat char=c | insert("cat"): the 'c' edge already exists (a shared prefix), so it is reused: no new node. |
| 12 | L8 | word=cat char=a | insert("cat"): the 'a' edge already exists (a shared prefix), so it is reused: no new node. |
| 13 | L7 | word=cat char=t nodes created=5 | insert("cat"): there is no 't' edge below "ca", so a new node is created. |
| 14 | L10 | word=cat new nodes=1 | Mark the last node of "cat" as a word ending (1 new node). |
| 15 | L7 | word=dog char=d nodes created=6 | insert("dog"): there is no 'd' edge below "root", so a new node is created. |
| 16 | L7 | word=dog char=o nodes created=7 | insert("dog"): there is no 'o' edge below "d", so a new node is created. |
| 17 | L7 | word=dog char=g nodes created=8 | insert("dog"): there is no 'g' edge below "do", so a new node is created. |
| 18 | L10 | word=dog new nodes=3 | Mark the last node of "dog" as a word ending (3 new nodes). |
| 19 | L8 | word=do char=d | insert("do"): the 'd' edge already exists (a shared prefix), so it is reused: no new node. |
| 20 | L8 | word=do char=o | insert("do"): the 'o' edge already exists (a shared prefix), so it is reused: no new node. |
| 21 | L10 | word=do new nodes=0 | Mark the last node of "do" as a word ending (no new nodes were needed: it was a prefix of existing words). |
| 22 | L13 | query=ca | startsWith("ca"): follow the query letter by letter from the root. |
| 23 | L15 | query=ca char=c matched=1 | 'c' edge exists: now at the node for "c". |
| 24 | L15 | query=ca char=a matched=2 | 'a' edge exists: now at the node for "ca". |
| 25 | L21 | return=true | The whole prefix was found: startsWith("ca") = true. Cost: O(2), independent of how many words are stored. |
Practice ladder
Now try it yourself
Easy
- Implement Trie (Prefix Tree)
- Longest Common Prefix
Medium
- Implement Trie II (Prefix Tree)
- Design Add and Search Words Data Structure
Advanced
- Stream of Characters
- Palindrome Pairs
Key takeaways
- Array children are fastest but pay for the whole alphabet in every node; maps pay per child but cost more per lookup.
- Estimate memory as nodes × per-node cost, not words × length, and measure it on your own JVM.
- Use a loop for single-path walks and recursion when the walk must return, such as pruning and enumeration.