Coding Interview Patterns Module 19 – Tries
Tries: insert, search and prefixes
Build a trie with insert, search and starts_with, mark where words end, weigh dict against array children, and know when a sorted list is enough.
What you will learn
- Implement a trie with insert, search and starts_with in five languages
- Set end-of-word marks so that a prefix is never reported as a word, and test them
- Compare dictionary children with fixed arrays for memory and speed
- Choose between a trie, a sorted list with binary search and a set of prefixes
Before you start
What you’ll learn · Before you start
What you will learn
- Implement a trie with insert, search and starts_with in five languages
- Set end-of-word marks so that a prefix is never reported as a word, and test them
- Compare dictionary children with fixed arrays for memory and speed
- Choose between a trie, a sorted list with binary search and a set of prefixes
Before you start
On this page
A trie, also called a prefix tree, stores words letter by letter in a tree. Each node stands for the text that
the path from the root down to it spells. It keeps one child for each character that can come next, and a mark that
says whether a stored word ends there. Words that start the same way share the nodes of that start. Three operations
make up a basic trie. insert(word) walks the word’s path from the root, adds the nodes that are missing and marks the
last one. search(word) walks the same path and answers yes only if it reaches the end and finds the mark.
starts_with(prefix) answers yes as soon as the whole path exists. Each one takes O(L) steps for a text of L
characters, however many words the trie holds. Reach for a trie when a problem asks many questions about the starts of
words, as a search box does with every key.
This lesson builds one and solves the module’s first problem in full: a station search box, from the brute force to code in five languages and a stress test that catches the two classic slips with word ends. Then it measures what a node costs when its children sit in a dictionary or in an array, in Python and in C++, and shows when a sorted list with binary search does the same job in far less memory.
Words that share a start share a path
Take four station names: salem, salempur, satna and sangli. In a trie they share their first two letters, so the
nodes s and sa exist once, and the names part below sa: l leads to salem and salempur, n to sangli and t
to satna.
Four names share one path for as long as they agree
Text description of the diagram
The diagram is a tree drawn from the top down. Each box is a node, labelled with the text that its path from the root spells; the last letter of each label is bold, because it is the one letter that the step from the box above adds. A double border marks a node where a name ends.
- The root, at the top, stands for the empty text. Below it is the node s, and below that the node sa. All four names pass through these two nodes.
- From sa, three branches leave: sal on the left, san in the middle and sat on the right.
- The right branch runs sat, satn, satna. satna has a double border: the name satna ends there.
- The middle branch runs san, sang, sangl, sangli. sangli has a double border.
- The left branch runs sal, sale, salem. salem has a double border, because salem is a name, but the branch goes on: salemp, salempu, salempur, and salempur has a double border too.
- The nodes sal and sale have single borders: they lie on the way to names but are not names themselves. A search for sale reaches its node and answers no; a check for names that start with sale reaches the same node and answers yes.
Three details of the drawing are the whole idea:
- A node is a prefix. The box
salestands for the text sale, and the path from the root spells it. The node stores no letters of its own: they are on the way down. - A mark, not the shape, says where a word ends. salem is a name, yet its node has a child, because salempur goes on below it. So reaching a node is not enough for a search: it has to read the mark.
- A path can exist without being a word.
salandsaleexist only because salem passes through them. A search for sale answers no, while a check for names that start with sale answers yes.
A trie has one node for every distinct prefix of its words, plus the root. The four names have 24 letters between
them and need 15 nodes below the root, because the shared start sa is stored once for all four and salem once for
two of them.
Edward Fredkin named the structure in his paper “Trie memory”, after the middle of the word retrieval. He meant it to be said like “tree”; many people say “try”, to keep the two apart.
Word Finder (Crossword & 5-Letter Helper) Type a pattern such as sal* to list English words that start with sal: in a trie, every one of them would share the path s, a, l.A worked problem: a station search box
The problem below opens the module. Its solution makes nine stops: a slow version, the clue in the problem, the trie with its proof, a dry run, code in five languages, the cost, a stress test, edge cases and follow-up questions.
The problem
A journey planner’s search box knows the names of the stations it serves. After every key, it asks two questions
about the text typed so far: is it the full name of a station, and does any station name start with it? When the
second answer is no, the box can say “No such station” at once, without waiting for the Enter key. Write a class
Trie with three methods: insert(name) adds a name, search(text) answers the first question and
starts_with(text) the second. Names and typed texts have 1 to 30 characters, lowercase English letters and single
spaces. There are up to 100,000 names and up to 100,000 calls of search and starts_with together.
The examples below use 26 station names. Typed one key at a time, salempur gives these answers: s, sal and sale
start names but are not names, salem is both, salemp starts a name, and salempur is a name. sx is neither.
The brute force
Keep the names in a list. search compares the text with each name in turn, and starts_with asks each name whether
it starts with the text:
class TrieBrute:
"""The same three operations on a plain list of words: right by construction, and O(n) per call."""
def __init__(self):
self.words = []
self.read = 0 # how many words the calls have looked at
def insert(self, word):
if not self.search(word):
self.words.append(word)
def search(self, word):
for w in self.words:
self.read += 1
if w == word:
return True
return False
def starts_with(self, prefix):
for w in self.words:
self.read += 1
if w.startswith(prefix):
return True
return False
if __name__ == "__main__":
from trie import STATIONS
brute = TrieBrute()
for name in STATIONS:
brute.insert(name)
print("typed a station? starts a name? words read")
for typed in ["sale", "salempur", "delhi cantt", "new", "sx"]:
brute.read = 0
station = "yes" if brute.search(typed) else "no"
prefix = "yes" if brute.starts_with(typed) else "no"
print(f"{typed!r:16} {station:12} {prefix:16} {brute.read}") Output
typed a station? starts a name? words read 'sale' no yes 27 'salempur' yes yes 2 'delhi cantt' yes yes 48 'new' no yes 52 'sx' no no 52
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 brute.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
It is right by construction, and a call can read every name. new and sx cost 52 reads each: neither is a name,
so search reads all 26 names, and starts_with reads all 26 again, because the only name that starts with new is
the last one in the list, and none starts with sx. At the limits, 100,000 calls on 100,000 names come to up to 10¹⁰
name comparisons of up to 30 characters each. Compiled code manages roughly 10⁸ simple steps a second, the planning
figure from the lesson on reading constraints, so that is
minutes of work where a judge allows a second or two. The slow version still earns its place: the stress test further
down uses it as the reference.
What to notice
The questions are all about the starts of names, and the names share their starts. delhi, delhi cantt and delhi
sarai rohilla begin with the same five letters, and all 26 names begin with one of only five letters. The brute force
compares those shared letters again for every name and on every key. A hash set of the names would answer search
in O(L) time on average, but it says nothing about starts. Store each shared start once, as a path of nodes, and both
questions become a walk of at most L steps.
The trie, step by step
Each node keeps a map from a character to the child node for it, and a flag, is_word, which is true when an
inserted word ends at that node. The root is the node of the empty text.
insert(word): start at the root. For each character, move to the child for it, creating that child first if it is missing. After the last character, setis_wordon the node you reached, even when the whole path was already there.search(word): walk the same way, but create nothing. If a child is missing, the answer is no. If the walk reaches the end of the word, the answer is that node’sis_word.starts_with(prefix): walk the same way. If a child is missing, the answer is no. If the walk reaches the end, the answer is yes, because at least one word passes through that node.
search and starts_with share the walk, so the code below has one helper, _walk, that returns the node of a text,
or nothing when the path breaks off.
Why it is right
Two facts stay true after every insert, and together they are the proof:
- The nodes are exactly the prefixes. A node exists for a text t when, and only when, t is the start of at least one inserted word. The root counts as the start of every word.
- The marks are exactly the words. A node’s
is_wordis true when, and only when, its text was inserted.
Both hold for an empty trie, which is a root and nothing else. insert(w) creates the nodes for the prefixes of w that
are missing, and no others, and it sets the mark of w’s node, and no other mark. Nothing is ever removed, so both
facts still hold after it. With the two facts in place, the walks answer correctly. starts_with(t) finds t’s node
exactly when t is the start of some word. search(t) finds the node and then reads its mark, which is true exactly
when t itself was inserted. A walk that breaks off at a missing child means that some prefix of t starts no word, and
then t cannot start one either.
A dry run
Start with an empty trie, insert salempur and then salem, and ask three questions:
insert('salempur'): the root has no children, so the walk creates eight nodes,stosalempur, and marks the last one.insert('salem'): every child it needs exists already. The walk creates nothing, stops at the nodesalemand marks it. An insert that marked only nodes it had just created would lose salem here.search('sale'): four steps reach the nodesale. Its mark is false, so the answer is no.starts_with('sale'): the same four steps reach the same node, so the answer is yes.starts_with('sx'): the root has the childs, butshas no childx, so the walk breaks off after one step and the answer is no.
The code in five languages
Here is the trie in Python and JavaScript, which you can run on this page, with the 26 names and a few typed texts:
Python · stations/trie.py
class Node:
"""One node of the trie: its children, one per next character, and whether a word ends here."""
__slots__ = ("children", "is_word")
def __init__(self):
self.children = {} # character -> Node
self.is_word = False # True when an inserted word ends exactly at this node
class Trie:
def __init__(self):
self.root = Node() # the node of the empty prefix
def insert(self, word):
"""Walks the path of word from the root, adding the nodes that are missing, then marks its end."""
node = self.root
for ch in word:
child = node.children.get(ch)
if child is None:
child = node.children[ch] = Node()
node = child
node.is_word = True # also when the whole path was already there
def _walk(self, text):
"""The node whose path from the root spells text, or None if no inserted word starts with text."""
node = self.root
for ch in text:
node = node.children.get(ch)
if node is None:
return None
return node
def search(self, word):
"""Was exactly this word inserted?"""
node = self._walk(word)
return node is not None and node.is_word
def starts_with(self, prefix):
"""Does at least one inserted word start with prefix?"""
return self._walk(prefix) is not None
STATIONS = [
"salempur", "salem", "satna", "sangli", "surat", "sealdah", "secunderabad", "shimla", "siliguri", "solapur",
"patna", "pune", "puri", "purna", "palghar", "panvel", "pathankot",
"kalyan", "kalka", "katni", "kota", "kollam",
"delhi", "delhi cantt", "delhi sarai rohilla", "new delhi",
]
def count_nodes(node):
"""The node itself and every node below it."""
return 1 + sum(count_nodes(child) for child in node.children.values())
if __name__ == "__main__":
trie = Trie()
for name in STATIONS:
trie.insert(name)
letters = sum(len(name) for name in STATIONS)
print(f"{len(STATIONS)} names, {letters} characters, {count_nodes(trie.root) - 1} nodes below the root")
print()
print("typed a station? starts a name?")
for typed in ["s", "sal", "sale", "salem", "salemp", "salempur", "delhi", "delhi c", "delhi cantt", "new", "pat", "sx"]:
station = "yes" if trie.search(typed) else "no"
prefix = "yes" if trie.starts_with(typed) else "no"
print(f"{typed!r:16} {station:12} {prefix}") Output
26 names, 179 characters, 131 nodes below the root typed a station? starts a name? 's' no yes 'sal' no yes 'sale' no yes 'salem' yes yes 'salemp' no yes 'salempur' yes yes 'delhi' yes yes 'delhi c' no yes 'delhi cantt' yes yes 'new' no yes 'pat' no yes 'sx' no no
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trie.py
JavaScript · stations/trie.mjs
class TrieNode {
constructor() {
this.children = new Map(); // character -> TrieNode
this.isWord = false; // true when an inserted word ends exactly at this node
}
}
class Trie {
constructor() {
this.root = new TrieNode(); // the node of the empty prefix
}
/** Walks the path of word from the root, adding the nodes that are missing, then marks its end. */
insert(word) {
let node = this.root;
for (const ch of word) {
let child = node.children.get(ch);
if (child === undefined) {
child = new TrieNode();
node.children.set(ch, child);
}
node = child;
}
node.isWord = true; // also when the whole path was already there
}
/** The node whose path from the root spells text, or null if no inserted word starts with text. */
walk(text) {
let node = this.root;
for (const ch of text) {
node = node.children.get(ch);
if (node === undefined) return null;
}
return node;
}
/** Was exactly this word inserted? */
search(word) {
const node = this.walk(word);
return node !== null && node.isWord;
}
/** Does at least one inserted word start with prefix? */
startsWith(prefix) {
return this.walk(prefix) !== null;
}
}
const STATIONS = [
"salempur", "salem", "satna", "sangli", "surat", "sealdah", "secunderabad", "shimla", "siliguri", "solapur",
"patna", "pune", "puri", "purna", "palghar", "panvel", "pathankot",
"kalyan", "kalka", "katni", "kota", "kollam",
"delhi", "delhi cantt", "delhi sarai rohilla", "new delhi",
];
/** The node itself and every node below it. */
function countNodes(node) {
let count = 1;
for (const child of node.children.values()) count += countNodes(child);
return count;
}
const trie = new Trie();
for (const name of STATIONS) trie.insert(name);
const letters = STATIONS.reduce((sum, name) => sum + name.length, 0);
console.log(`${STATIONS.length} names, ${letters} characters, ${countNodes(trie.root) - 1} nodes below the root`);
console.log();
console.log("typed a station? starts a name?");
for (const typed of ["s", "sal", "sale", "salem", "salemp", "salempur", "delhi", "delhi c", "delhi cantt", "new", "pat", "sx"]) {
const station = trie.search(typed) ? "yes" : "no";
const prefix = trie.startsWith(typed) ? "yes" : "no";
console.log(`${`'${typed}'`.padEnd(16)} ${station.padEnd(12)} ${prefix}`);
} Output
26 names, 179 characters, 131 nodes below the root typed a station? starts a name? 's' no yes 'sal' no yes 'sale' no yes 'salem' yes yes 'salemp' no yes 'salempur' yes yes 'delhi' yes yes 'delhi c' no yes 'delhi cantt' yes yes 'new' no yes 'pat' no yes 'sx' no no
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node trie.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Java · compiled/Trie.java
import java.util.List;
public class Trie {
private static final int SLOTS = 27; // 'a' to 'z', then the space
private static final class Node {
final Node[] next = new Node[SLOTS]; // every slot starts as null: no child yet
boolean isWord; // true when an inserted word ends exactly at this node
}
private final Node root = new Node(); // the node of the empty prefix
private static int slot(char c) {
return c == ' ' ? 26 : c - 'a';
}
/** Walks the path of word from the root, adding the nodes that are missing, then marks its end. */
public void insert(String word) {
Node node = root;
for (int i = 0; i < word.length(); i++) {
int s = slot(word.charAt(i));
if (node.next[s] == null) node.next[s] = new Node();
node = node.next[s];
}
node.isWord = true; // also when the whole path was already there
}
/** The node whose path from the root spells text, or null if no inserted word starts with text. */
private Node walk(String text) {
Node node = root;
for (int i = 0; i < text.length() && node != null; i++) node = node.next[slot(text.charAt(i))];
return node;
}
/** Was exactly this word inserted? */
public boolean search(String word) {
Node node = walk(word);
return node != null && node.isWord;
}
/** Does at least one inserted word start with prefix? */
public boolean startsWith(String prefix) {
return walk(prefix) != null;
}
private static int countNodes(Node node) {
int count = 1;
for (Node child : node.next) if (child != null) count += countNodes(child);
return count;
}
static final List<String> STATIONS = List.of(
"salempur", "salem", "satna", "sangli", "surat", "sealdah", "secunderabad", "shimla", "siliguri", "solapur",
"patna", "pune", "puri", "purna", "palghar", "panvel", "pathankot",
"kalyan", "kalka", "katni", "kota", "kollam",
"delhi", "delhi cantt", "delhi sarai rohilla", "new delhi");
public static void main(String[] args) {
Trie trie = new Trie();
int letters = 0;
for (String name : STATIONS) {
trie.insert(name);
letters += name.length();
}
System.out.printf("%d names, %d characters, %d nodes below the root%n", STATIONS.size(), letters, countNodes(trie.root) - 1);
System.out.println();
System.out.println("typed a station? starts a name?");
for (String typed : List.of("s", "sal", "sale", "salem", "salemp", "salempur", "delhi", "delhi c", "delhi cantt", "new", "pat", "sx")) {
String station = trie.search(typed) ? "yes" : "no";
String prefix = trie.startsWith(typed) ? "yes" : "no";
System.out.printf("%-16s %-12s %s%n", "'" + typed + "'", station, prefix);
}
}
} C++ · compiled/trie.cpp
#include <array>
#include <cstdio>
#include <string>
#include <string_view>
#include <vector>
// The nodes live in one vector and refer to each other by index: node 0 is the root, and a child index of 0 means
// "no child", because the root is nobody's child.
class Trie {
static constexpr int kSlots = 27; // 'a' to 'z', then the space
std::vector<std::array<int, kSlots>> next_; // next_[node][slot]: the index of the child, or 0
std::vector<char> is_word_; // is_word_[node]: does an inserted word end here?
static int slot(char c) { return c == ' ' ? 26 : c - 'a'; }
// The node whose path from the root spells text, or -1 if no inserted word starts with text.
int walk(std::string_view text) const {
int node = 0;
for (char c : text) {
node = next_[node][slot(c)];
if (node == 0) return -1;
}
return node;
}
public:
Trie() : next_(1), is_word_(1, 0) {} // just the root, with every slot 0
// Walks the path of word from the root, adding the nodes that are missing, then marks its end.
void insert(std::string_view word) {
int node = 0;
for (char c : word) {
int child = next_[node][slot(c)];
if (child == 0) {
child = static_cast<int>(next_.size());
next_.emplace_back(); // a new node, all slots 0; it may move the vector, so no reference is kept
is_word_.push_back(0);
next_[node][slot(c)] = child;
}
node = child;
}
is_word_[node] = 1; // also when the whole path was already there
}
bool search(std::string_view word) const { // was exactly this word inserted?
int node = walk(word);
return node != -1 && is_word_[node];
}
bool startsWith(std::string_view prefix) const { // does at least one inserted word start with prefix?
return walk(prefix) != -1;
}
std::size_t nodes() const { return next_.size(); }
};
int main() {
const std::vector<std::string_view> stations = {
"salempur", "salem", "satna", "sangli", "surat", "sealdah", "secunderabad", "shimla", "siliguri", "solapur",
"patna", "pune", "puri", "purna", "palghar", "panvel", "pathankot",
"kalyan", "kalka", "katni", "kota", "kollam",
"delhi", "delhi cantt", "delhi sarai rohilla", "new delhi"};
Trie trie;
std::size_t letters = 0;
for (std::string_view name : stations) {
trie.insert(name);
letters += name.size();
}
std::printf("%zu names, %zu characters, %zu nodes below the root\n\n", stations.size(), letters, trie.nodes() - 1);
std::printf("typed a station? starts a name?\n");
for (std::string_view typed : {"s", "sal", "sale", "salem", "salemp", "salempur", "delhi", "delhi c", "delhi cantt", "new", "pat", "sx"}) {
std::string quoted = "'" + std::string(typed) + "'";
std::printf("%-16s %-12s %s\n", quoted.c_str(), trie.search(typed) ? "yes" : "no", trie.startsWith(typed) ? "yes" : "no");
}
} Go · compiled/trie.go
package main
import "fmt"
const slots = 27 // 'a' to 'z', then the space
type node struct {
next [slots]*node // every slot starts as nil: no child yet
isWord bool // true when an inserted word ends exactly at this node
}
// Trie keeps its root by value: the zero Trie is an empty trie, ready to use.
type Trie struct {
root node
}
func slot(c byte) int {
if c == ' ' {
return 26
}
return int(c - 'a')
}
// Insert walks the path of word from the root, adding the nodes that are missing, then marks its end.
func (t *Trie) Insert(word string) {
n := &t.root
for i := 0; i < len(word); i++ {
s := slot(word[i])
if n.next[s] == nil {
n.next[s] = &node{}
}
n = n.next[s]
}
n.isWord = true // also when the whole path was already there
}
// walk returns the node whose path from the root spells text, or nil if no inserted word starts with text.
func (t *Trie) walk(text string) *node {
n := &t.root
for i := 0; i < len(text) && n != nil; i++ {
n = n.next[slot(text[i])]
}
return n
}
// Search reports whether exactly this word was inserted.
func (t *Trie) Search(word string) bool {
n := t.walk(word)
return n != nil && n.isWord
}
// StartsWith reports whether at least one inserted word starts with prefix.
func (t *Trie) StartsWith(prefix string) bool {
return t.walk(prefix) != nil
}
func countNodes(n *node) int {
count := 1
for _, child := range n.next {
if child != nil {
count += countNodes(child)
}
}
return count
}
func main() {
stations := []string{
"salempur", "salem", "satna", "sangli", "surat", "sealdah", "secunderabad", "shimla", "siliguri", "solapur",
"patna", "pune", "puri", "purna", "palghar", "panvel", "pathankot",
"kalyan", "kalka", "katni", "kota", "kollam",
"delhi", "delhi cantt", "delhi sarai rohilla", "new delhi",
}
var trie Trie
letters := 0
for _, name := range stations {
trie.Insert(name)
letters += len(name)
}
fmt.Printf("%d names, %d characters, %d nodes below the root\n\n", len(stations), letters, countNodes(&trie.root)-1)
fmt.Println("typed a station? starts a name?")
for _, typed := range []string{"s", "sal", "sale", "salem", "salemp", "salempur", "delhi", "delhi c", "delhi cantt", "new", "pat", "sx"} {
station, prefix := "no", "no"
if trie.Search(typed) {
station = "yes"
}
if trie.StartsWith(typed) {
prefix = "yes"
}
fmt.Printf("%-16s %-12s %s\n", "'"+typed+"'", station, prefix)
}
} Build and run all three · compiled/trie_session.py
from session import run, versions
# Trie.java runs as a single source file, trie.cpp is built with the sanitizers on, and go run builds trie.go.
versions("java -version", "clang++ --version", "go version")
run("""
java Trie.java
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined trie.cpp -o trie && ./trie
go run trie.go
""") Output
# openjdk version "25.0.4.1" LTS # Apple clang version 21.0.0 (clang-2100.3.34.2) # go version go1.27.1 darwin/arm64 $ java Trie.java 26 names, 179 characters, 131 nodes below the root typed a station? starts a name? 's' no yes 'sal' no yes 'sale' no yes 'salem' yes yes 'salemp' no yes 'salempur' yes yes 'delhi' yes yes 'delhi c' no yes 'delhi cantt' yes yes 'new' no yes 'pat' no yes 'sx' no no $ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined trie.cpp -o trie && ./trie 26 names, 179 characters, 131 nodes below the root typed a station? starts a name? 's' no yes 'sal' no yes 'sale' no yes 'salem' yes yes 'salemp' no yes 'salempur' yes yes 'delhi' yes yes 'delhi c' no yes 'delhi cantt' yes yes 'new' no yes 'pat' no yes 'sx' no no $ go run trie.go 26 names, 179 characters, 131 nodes below the root typed a station? starts a name? 's' no yes 'sal' no yes 'sale' no yes 'salem' yes yes 'salemp' no yes 'salempur' yes yes 'delhi' yes yes 'delhi c' no yes 'delhi cantt' yes yes 'new' no yes 'pat' no yes 'sx' no no
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trie_session.py
All five print the same 15 lines. The 26 names have 179 characters between them, yet the trie needs only 131 nodes below the root, because every shared start is stored once. The versions differ in how a node keeps its children:
- Python gives each node a
dict.children.get(ch)returnsNonefor a character that has no child, which ends the walk.__slots__keeps the many small nodes lean: Python then stores the two fields in the object itself, with no dictionary of attributes for each node. - JavaScript gives each node a
Map, whosegetreturnsundefinedfor a missing key. - Java gives each node an array of 27 children:
'a'to'z'in slots 0 to 25 and the space in slot 26. A new array of objects starts full ofnull, so a new node has no children. - C++ keeps every node in one vector, and a child is the index of a node in it. Index 0 is the root, which is
nobody’s child, so a 0 in a slot can mean “no child”.
emplace_backmay move the vector to a larger block of memory, which makes earlier references into it invalid, soinsertwrites the child’s index only after the new node exists. - Go gives each node an array of 27 pointers, which start as
nil, their zero value. So the zero value ofTrieis an empty trie, ready to use.
The session compiles the C++ version with AddressSanitizer and UndefinedBehaviorSanitizer switched on. They watch each memory access and each operation whose result C++ leaves undefined, for as long as the program runs, and neither of them reported a problem.
Python Online Compiler Paste the Python trie and insert names of your own, such as one that is the start of another, in both orders.The cost
| Operation | Time | Extra space |
|---|---|---|
| insert | O(L) | O(L) |
| search or starts_with | O(L) | O(1) |
| The brute force, per call on n names | O(n × L) | O(1) |
An insert adds at most one node per character, which is its O(L) of extra space. At the limits, inserting 100,000 names of up to 30 characters takes at most 3 million steps, and the 100,000 calls at most 3 million more. The memory is one node per distinct prefix: 3 million nodes at most, when the names share nothing, and far fewer when they share starts, as station names do. The section on children below measures what one node costs.
Check it against the brute force
A stress test, as in the lesson on stress testing, runs the trie and the list side by side on many small random cases. It also tries two plausible slips:
import random
from brute import TrieBrute
from trie import STATIONS, Node, Trie
class SearchForgetsTheEnd(Trie):
"""A slip: search() only checks that the path exists."""
def search(self, word):
return self._walk(word) is not None
class MarksOnlyNewPaths(Trie):
"""A slip: insert() marks the end only when it had to add a node."""
def insert(self, word):
node, added = self.root, False
for ch in word:
child = node.children.get(ch)
if child is None:
child = node.children[ch] = Node()
added = True
node = child
if added:
node.is_word = True
def random_case(rng, steps):
"""steps random calls on words of 1 to 3 letters a and b, so that words share prefixes often."""
calls = []
for _ in range(steps):
call = rng.choice(["insert", "search", "starts_with"])
word = "".join(rng.choice("ab") for _ in range(rng.randint(1, 3)))
calls.append((call, word))
return calls
def answers(make, calls):
"""Makes the calls, in order, on a new make(): what each one returned (None for insert)."""
structure = make()
return [getattr(structure, call)(word) for call, word in calls]
def first_difference(make, calls):
"""The first call on which make() and the brute force disagree, as (position, got, expected), or None."""
for i, (got, expected) in enumerate(zip(answers(make, calls), answers(TrieBrute, calls))):
if got != expected:
return i, got, expected
return None
def shrink(make, calls):
"""Leaves out one call at a time, as long as the case still fails, so that the report is as short as it can be."""
i = 0
while i < len(calls):
shorter = calls[:i] + calls[i + 1:]
if first_difference(make, shorter):
calls = shorter
else:
i += 1
return calls
def stress(make, cases=3_000, seed=4):
"""Random cases of 1 to 8 calls, the shortest first; the first failure is shrunk, then reported."""
rng = random.Random(seed)
for case in range(cases):
calls = random_case(rng, 1 + case * 8 // cases)
if first_difference(make, calls):
calls = shrink(make, calls)
i, got, expected = first_difference(make, calls)
steps = ", ".join(f"{call}({word!r})" for call, word in calls[: i + 1])
return f"fails after {steps}: got {got}, expected {expected}"
return f"agrees with the brute force on {cases:,} random cases"
if __name__ == "__main__":
for make in [Trie, SearchForgetsTheEnd, MarksOnlyNewPaths]:
print(f"{make.__name__ + ':':21}{stress(make)}")
print()
for make in [SearchForgetsTheEnd, MarksOnlyNewPaths]:
trie = make()
for name in STATIONS: # salempur goes in before salem
trie.insert(name)
print(f"{make.__name__ + ':':21}search('sale') is {trie.search('sale')}, search('salem') is {trie.search('salem')}") Output
Trie: agrees with the brute force on 3,000 random cases
SearchForgetsTheEnd: fails after insert('aab'), search('a'): got True, expected False
MarksOnlyNewPaths: fails after insert('aa'), insert('a'), search('a'): got False, expected True
SearchForgetsTheEnd: search('sale') is True, search('salem') is True
MarksOnlyNewPaths: search('sale') is False, search('salem') is False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stress.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The trie agrees with the list on all 3,000 cases. Each case is a short random sequence of calls on words made of the letters a and b, so that one word is often the start of another, which is exactly where both slips live. When a case fails, the program leaves out calls one at a time for as long as it still fails, so each report is as short as it can be:
SearchForgetsTheEndchecks only that the path exists. Afterinsert('aab'),search('a')answers True. With the station names, it reports sale as a station.MarksOnlyNewPathssets the mark only wheninsertcreated a node.insert('a')afterinsert('aa')creates nothing, so a is never marked. In the station list, salem comes after salempur, so this version loses salem.
Neither slip shows up in tests with unrelated words, such as pune, kota and surat. A trie needs tests with words that are prefixes of each other, inserted in both orders.
Edge cases
- A word inside another word. salem and salempur: only the mark tells the word from the prefix, whichever goes in first.
- The same word twice. The second insert walks an existing path and sets a mark that is set already, so nothing changes.
- An empty text. The problem rules it out, and the code would get one case wrong:
_walk('')returns the root, sostarts_with('')answers True even for an empty trie. Every word starts with the empty text, so the right answer is whether the trie holds any word at all. If empty texts can occur, keep a count of the words and check it. - Characters outside the alphabet. A
dictor aMaptakes any character, but an array needs a slot for each one. A capital D has no slot in the 27-slot versions: asking for its slot throwsArrayIndexOutOfBoundsExceptionin Java, is undefined behaviour in C++ and causes a run-time panic in Go. Lower-case the text before it reaches the trie, or use a map when the alphabet is large. - Long words. The walks are loops, so a word of 10,000 characters costs 10,000 steps and no deep recursion. Only
the demo’s
count_nodesis recursive, and its depth is the length of the longest name.
Follow-up questions
Does the box have to walk from the root on every key? No. Keep the node of the text typed so far: a new key moves one child down, which is O(1) a key. When the child is missing, no longer text can start a name either, so the box can stop asking until a key is deleted. For the backspace key, keep a stack of the nodes visited, one for each typed character, and pop it.
Would a set of the names be enough? For search, yes: a hash set answers it in O(L) time on average. For
starts_with, only if the set holds every prefix of every name, which is a different structure. The next section
measures it beside the trie.
Dictionary children or an array of 27
A node can keep its children in a hash map, which holds only the characters that occur, or in an array with a slot
for every character of the alphabet, used or not. Here are both in Python, beside two structures that answer
starts_with without a trie, on 9,000 made-up station names such as rampur road and shivnagar junction:
import random
import sys
import timeit
import tracemalloc
from bisect import bisect_left
# Made-up station names: every combination of a first part, a second part and an ending, such as "rampur road".
FIRST = ["ram", "shiv", "hari", "krishna", "lakshmi", "ganga", "chandra", "sundar", "dev", "raj", "kamal", "anand",
"vijay", "nand", "mohan", "gopal", "keshav", "madhav", "narayan", "prem", "bhim", "arjun", "indra", "surya",
"sita", "radha", "shanti", "kali", "durga", "uma", "jay", "vir", "bal", "kesh", "man", "sar", "dhar", "kris",
"pal", "sur"]
SECOND = ["pur", "nagar", "garh", "abad", "gaon", "wadi", "pada", "ganj", "kot", "pet", "palli", "puram", "halli",
"ur", "wada", "ner", "khed", "ghat", "kund", "sar", "bag", "pura", "patti", "kheda", "dih"]
ENDING = ["", " road", " junction", " city", " town", " halt", " cantt", " east", " west"]
NAMES = sorted({first + second + ending for first in FIRST for second in SECOND for ending in ENDING})
class DictNode:
__slots__ = ("children", "is_word")
def __init__(self):
self.children = {} # character -> DictNode, only for the characters that occur
self.is_word = False
SLOT = {ch: i for i, ch in enumerate("abcdefghijklmnopqrstuvwxyz ")} # character -> slot 0 to 26
class ListNode:
__slots__ = ("children", "is_word")
def __init__(self):
self.children = [None] * 27 # one slot for every character of the alphabet
self.is_word = False
def dict_trie(names):
root = DictNode()
for name in names:
node = root
for ch in name:
child = node.children.get(ch)
if child is None:
child = node.children[ch] = DictNode()
node = child
node.is_word = True
return root
def list_trie(names):
root = ListNode()
for name in names:
node = root
for ch in name:
i = SLOT[ch]
if node.children[i] is None:
node.children[i] = ListNode()
node = node.children[i]
node.is_word = True
return root
def count_nodes(root):
"""The nodes of a dict trie, the root included, counted without recursion."""
count, todo = 0, [root]
while todo:
node = todo.pop()
count += 1
todo.extend(node.children.values())
return count
def every_prefix(names):
return {name[:end] for name in names for end in range(1, len(name) + 1)}
def dict_starts_with(root, prefix):
node = root
for ch in prefix:
node = node.children.get(ch)
if node is None:
return False
return True
def list_starts_with(root, prefix):
node = root
for ch in prefix:
node = node.children[SLOT[ch]]
if node is None:
return False
return True
def set_starts_with(prefixes, prefix):
return prefix in prefixes
def sorted_starts_with(names, prefix):
i = bisect_left(names, prefix) # the first name that is not smaller than prefix
return i < len(names) and names[i].startswith(prefix)
def traced(build):
"""What build() returns, and how many bytes of memory it still holds."""
tracemalloc.start()
result = build()
held = tracemalloc.get_traced_memory()[0]
tracemalloc.stop()
return result, held
def queries(count, seed=5):
"""Prefixes of random names; in about a third of them the last character is changed, so many miss."""
rng = random.Random(seed)
out = []
for _ in range(count):
name = rng.choice(NAMES)
prefix = name[: rng.randint(1, len(name))]
if rng.random() < 0.3:
prefix = prefix[:-1] + rng.choice("abcdefghijklmnopqrstuvwxyz")
out.append(prefix)
return out
if __name__ == "__main__":
tries = traced(lambda: dict_trie(NAMES))
nodes = count_nodes(tries[0]) - 1 # the same as the number of distinct prefixes of the names
print(f"{len(NAMES):,} made-up station names, {sum(map(len, NAMES)):,} characters, {nodes:,} trie nodes below the root")
name_bytes = sum(sys.getsizeof(name) for name in NAMES)
structures = [
("trie, a dict of children per node", tries, dict_starts_with, nodes + 1, "node"),
("trie, a list of 27 children per node", traced(lambda: list_trie(NAMES)), list_starts_with, nodes + 1, "node"),
("a set of every prefix", traced(lambda: every_prefix(NAMES)), set_starts_with, nodes, "prefix"),
("the sorted list of names", traced(lambda: sorted(NAMES)), sorted_starts_with, len(NAMES), "name"),
]
print()
print("Memory held, measured with tracemalloc:")
for label, (_, held), _, count, unit in structures:
print(f" {label:38} {held / 1e6:6.2f} MB {held / count:5.0f} bytes a {unit}")
print(f" {'the names themselves':38} {name_bytes / 1e6:6.2f} MB {name_bytes / len(NAMES):5.0f} bytes a name")
# The four take turns, 25 rounds of 20,000 calls each, and each keeps its fastest round: other programs on the
# computer slow some rounds down, and taking turns spreads that over all four.
calls = queries(20_000)
best = [float("inf")] * len(structures)
for _ in range(25):
for i, (_, (structure, _), starts_with, _, _) in enumerate(structures):
best[i] = min(best[i], timeit.timeit(lambda: [starts_with(structure, p) for p in calls], number=1))
print()
print(f"Time for one starts_with call, the fastest of 25 rounds of {len(calls):,} calls:")
for (label, (structure, _), starts_with, _, _), seconds in zip(structures, best):
found = sum(starts_with(structure, prefix) for prefix in calls)
print(f" {label:38} {seconds / len(calls) * 1e6:5.2f} µs ({found:,} found)") Output
9,000 made-up station names, 120,420 characters, 39,899 trie nodes below the root Memory held, measured with tracemalloc: trie, a dict of children per node 8.40 MB 210 bytes a node trie, a list of 27 children per node 12.77 MB 320 bytes a node a set of every prefix 3.72 MB 93 bytes a prefix the sorted list of names 0.07 MB 8 bytes a name the names themselves 0.49 MB 54 bytes a name Time for one starts_with call, the fastest of 25 rounds of 20,000 calls: trie, a dict of children per node 0.54 µs (14,923 found) trie, a list of 27 children per node 0.53 µs (14,923 found) a set of every prefix 0.16 µs (14,923 found) the sorted list of names 0.43 µs (14,923 found)
This output changes from run to run: the times depend on the computer and on what else it is doing; the memory figures are the same on every run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure.py
tracemalloc counts the blocks of memory that Python hands out, so the memory column is exact, and it repeats on
every run of the same 64-bit CPython. The times change from run to run and between computers, so each structure keeps
its fastest round, the figure that other programs disturb least. Your times will differ from the
recorded ones; the comparisons below should not:
- A list of 27 children took half as much memory again as a dict: 320 bytes a node against 210. A reference takes 8 bytes in 64-bit CPython, so the 27 slots alone are 216 bytes, and most nodes use one slot or none.
- And the list was not faster in any way that matters. A list cannot be indexed by a character, so each character first becomes a slot number, here through a lookup in a dict, and both versions make one hashed lookup per character. Their times are close, and which one is ahead changes from run to run.
- The set of every prefix was the fastest of the four, a single hashed lookup of the whole text, and it held less than half the trie’s memory: one entry per trie node, since every node is a distinct prefix, at 93 bytes each. It answers only yes or no, though, and adding a name means adding every prefix of it.
- The sorted list needed almost nothing: 8 bytes a name on top of the names themselves, which an application keeps
anyway to show them. The next section explains how it answers
starts_with.
In a compiled language the trade is different, because indexing an array is a single memory access, while a hash
map hashes the key and searches a bucket. Here is the same pair of tries in C++, with the nodes in one vector as in
the worked problem. The program counts every byte it allocates by replacing the global operator new, which the C++
standard allows a program to do:
C++ · compiled/children.cpp
// The station trie two ways in C++: an array of 27 child indices per node, or an unordered_map of children per node.
// It counts the bytes each one holds on the heap and times startsWith on the same calls.
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <new>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
// Every allocation goes through these two functions, which keep a count of the bytes in use.
static std::size_t g_in_use = 0;
void* operator new(std::size_t size) {
auto* block = static_cast<std::size_t*>(std::malloc(size + 16)); // 16 extra bytes in front remember the size
if (block == nullptr) throw std::bad_alloc();
*block = size;
g_in_use += size;
return reinterpret_cast<char*>(block) + 16;
}
void operator delete(void* p) noexcept {
if (p == nullptr) return;
auto* block = reinterpret_cast<std::size_t*>(static_cast<char*>(p) - 16);
g_in_use -= *block;
std::free(block);
}
void operator delete(void* p, std::size_t) noexcept { operator delete(p); }
static int slot(char c) { return c == ' ' ? 26 : c - 'a'; }
static std::string with_commas(std::size_t n) { // 39899 -> "39,899"
std::string digits = std::to_string(n), out;
for (std::size_t i = 0; i < digits.size(); ++i) {
if (i > 0 && (digits.size() - i) % 3 == 0) out += ',';
out += digits[i];
}
return out;
}
struct ArrayTrie {
std::vector<std::array<int, 27>> next{1}; // node 0 is the root; a child index of 0 means "no child"
std::vector<char> is_word = std::vector<char>(1, 0);
void insert(const std::string& word) {
int node = 0;
for (char c : word) {
int child = next[node][slot(c)];
if (child == 0) {
child = static_cast<int>(next.size());
next.emplace_back();
is_word.push_back(0);
next[node][slot(c)] = child;
}
node = child;
}
is_word[node] = 1;
}
bool startsWith(const std::string& prefix) const {
int node = 0;
for (char c : prefix) {
node = next[node][slot(c)];
if (node == 0) return false;
}
return true;
}
};
struct MapTrie {
std::vector<std::unordered_map<char, int>> next{1}; // only the children that exist
std::vector<char> is_word = std::vector<char>(1, 0);
void insert(const std::string& word) {
int node = 0;
for (char c : word) {
auto found = next[node].find(c);
int child;
if (found == next[node].end()) {
child = static_cast<int>(next.size());
next.emplace_back();
is_word.push_back(0);
next[node].emplace(c, child);
} else {
child = found->second;
}
node = child;
}
is_word[node] = 1;
}
bool startsWith(const std::string& prefix) const {
int node = 0;
for (char c : prefix) {
auto found = next[node].find(c);
if (found == next[node].end()) return false;
node = found->second;
}
return true;
}
};
template <class Trie>
Trie build(const std::vector<std::string>& names, std::size_t& bytes) {
std::size_t before = g_in_use;
Trie trie;
for (const auto& name : names) trie.insert(name);
trie.next.shrink_to_fit(); // drop the spare room the vectors kept for growth
trie.is_word.shrink_to_fit();
bytes = g_in_use - before;
return trie;
}
template <class Trie>
double seconds(const Trie& trie, const std::vector<std::string>& calls, std::size_t& found) {
auto start = std::chrono::steady_clock::now();
found = 0;
for (const auto& prefix : calls) found += trie.startsWith(prefix);
return std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
}
int main() {
const char* first[] = {"ram", "shiv", "hari", "krishna", "lakshmi", "ganga", "chandra", "sundar", "dev", "raj",
"kamal", "anand", "vijay", "nand", "mohan", "gopal", "keshav", "madhav", "narayan", "prem", "bhim", "arjun",
"indra", "surya", "sita", "radha", "shanti", "kali", "durga", "uma", "jay", "vir", "bal", "kesh", "man",
"sar", "dhar", "kris", "pal", "sur"};
const char* second[] = {"pur", "nagar", "garh", "abad", "gaon", "wadi", "pada", "ganj", "kot", "pet", "palli",
"puram", "halli", "ur", "wada", "ner", "khed", "ghat", "kund", "sar", "bag", "pura", "patti", "kheda", "dih"};
const char* ending[] = {"", " road", " junction", " city", " town", " halt", " cantt", " east", " west"};
std::set<std::string> unique;
for (auto f : first)
for (auto s : second)
for (auto e : ending) unique.insert(std::string(f) + s + e);
std::vector<std::string> names(unique.begin(), unique.end());
// 200,000 calls: prefixes of names picked by a fixed sequence, a third of them with a changed last letter.
std::vector<std::string> calls;
std::uint64_t x = 7;
for (int i = 0; i < 200000; ++i) {
x = x * 6364136223846793005ULL + 1442695040888963407ULL;
const std::string& name = names[(x >> 33) % names.size()];
std::string prefix = name.substr(0, 1 + (x >> 13) % name.size());
if ((x >> 7) % 3 == 0) prefix.back() = static_cast<char>('a' + (x >> 40) % 26);
calls.push_back(prefix);
}
std::size_t array_bytes = 0, map_bytes = 0;
ArrayTrie array_trie = build<ArrayTrie>(names, array_bytes);
MapTrie map_trie = build<MapTrie>(names, map_bytes);
std::size_t nodes = array_trie.next.size();
std::printf("%s made-up station names, %s trie nodes below the root\n\n", with_commas(names.size()).c_str(), with_commas(nodes - 1).c_str());
std::printf("Memory held on the heap, counted by this program's operator new:\n");
std::printf(" trie, an array of 27 child indices per node %5.2f MB %4.0f bytes a node\n", array_bytes / 1e6, double(array_bytes) / nodes);
std::printf(" trie, an unordered_map of children per node %5.2f MB %4.0f bytes a node\n", map_bytes / 1e6, double(map_bytes) / nodes);
// The two take turns, 25 rounds each, and each keeps its fastest round.
double best_array = 1e9, best_map = 1e9;
std::size_t found_array = 0, found_map = 0;
for (int round = 0; round < 25; ++round) {
best_array = std::min(best_array, seconds(array_trie, calls, found_array));
best_map = std::min(best_map, seconds(map_trie, calls, found_map));
}
std::printf("\nTime for one startsWith call, the fastest of 25 rounds of %s calls:\n", with_commas(calls.size()).c_str());
std::printf(" trie, an array of 27 child indices per node %5.1f ns (%s found)\n", best_array / calls.size() * 1e9, with_commas(found_array).c_str());
std::printf(" trie, an unordered_map of children per node %5.1f ns (%s found)\n", best_map / calls.size() * 1e9, with_commas(found_map).c_str());
} Build and run · compiled/children_session.py
from session import run, versions
# Built with -O2 and without the sanitizers, which slow every memory access down and would distort the times.
versions("clang++ --version")
run("""
clang++ -std=c++23 -O2 -Wall -Wextra children.cpp -o children && ./children
""") Output
# Apple clang version 21.0.0 (clang-2100.3.34.2) $ clang++ -std=c++23 -O2 -Wall -Wextra children.cpp -o children && ./children 9,000 made-up station names, 39,899 trie nodes below the root Memory held on the heap, counted by this program's operator new: trie, an array of 27 child indices per node 4.35 MB 109 bytes a node trie, an unordered_map of children per node 3.18 MB 80 bytes a node Time for one startsWith call, the fastest of 25 rounds of 200,000 calls: trie, an array of 27 child indices per node 40.0 ns (143,306 found) trie, an unordered_map of children per node 218.3 ns (143,306 found)
This output changes from run to run: the times depend on the computer and on what else it is doing; the memory figures are the same on every run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 children_session.py
Here the array was several times faster, for about a third more memory: 109 bytes a node, 108 of them for the 27
child indices and 1 for the mark, against 80 bytes with an unordered_map. The rule that follows: in C++, and for the
same reason in Java and Go, use an array of children when the alphabet is small and fixed, such as lowercase letters
or digits, and the speed matters. Use a map when the alphabet is large or unknown, such as any Unicode character,
where an array per node would be almost empty. In Python, a dict per node is the better default on both counts.
When a sorted list is enough
When the list of names does not change, binary search answers both questions without a trie. Sort the names once.
For a typed text, bisect_left finds the first name that is not smaller than the text, and if any name starts with
the text, that one does:
from bisect import bisect_left
from trie import STATIONS
def starts_with_sorted(names, prefix):
"""names is sorted. The first name that is not smaller than prefix starts with it, if any name does."""
i = bisect_left(names, prefix)
return i < len(names) and names[i].startswith(prefix)
def search_sorted(names, word):
i = bisect_left(names, word)
return i < len(names) and names[i] == word
names = sorted(STATIONS)
print("typed position name there a station? starts a name?")
for typed in ["sale", "salem", "delhi c", "pat", "kab", "sx"]:
i = bisect_left(names, typed)
there = repr(names[i]) if i < len(names) else "(past the end)"
station = "yes" if search_sorted(names, typed) else "no"
prefix = "yes" if starts_with_sorted(names, typed) else "no"
print(f"{typed!r:11} {i:<10} {there:17} {station:12} {prefix}")
# Every prefix of every name, and each of them with its last letter changed, against a plain scan of the list.
tests = {name[:end] for name in STATIONS for end in range(1, len(name) + 1)}
tests |= {t[:-1] + letter for t in list(tests) for letter in "aeiouxyz "}
same = sum(starts_with_sorted(names, t) == any(n.startswith(t) for n in names) for t in tests)
print(f"\nsame answer as a scan of every name for {same:,} of {len(tests):,} texts") Output
typed position name there a station? starts a name? 'sale' 16 'salem' no yes 'salem' 16 'salem' yes yes 'delhi c' 1 'delhi cantt' no yes 'pat' 11 'pathankot' no yes 'kab' 3 'kalka' no no 'sx' 26 (past the end) no no same answer as a scan of every name for 1,047 of 1,047 texts
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sorted_list.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Python compares strings character by character, by their Unicode code points, which is what makes this work. Suppose
some name w starts with the text p, and let v be the first name that is not smaller than p, so that p ≤ v ≤ w. If v
did not start with p, it would differ from p at some position inside p, with a larger character there. But w agrees
with p at every position up to that one, so w would be smaller than v, which is impossible. So v starts with p, and
one comparison after the binary search settles the question. search is the same binary search followed by an
equality test. The last line checks both against a plain scan of every name, for every prefix of every name and for
texts that start no name.
Each call costs O(L log n): about 17 comparisons of up to L characters for 100,000 names. Here are the three options side by side:
| What you need | Trie | Sorted list | Prefix set |
|---|---|---|---|
| Both questions | O(L) | O(L log n) | O(L) on average |
| Memory, CPython run | 210 bytes a node | 8 bytes a name | 93 bytes a prefix |
| Add a name | O(L) | O(n) | O(L²) |
| Each typed key | O(1) | a new search | hash it all again |
| List the names with a start | yes | yes | no |
Adding a name costs the sorted list O(n), because every later name moves up one place, and the set O(L²), a new string for each prefix of the name. While someone types, the trie keeps the node of the text so far and moves one step a key, the sorted list starts a new search, and the set hashes the whole text again. The trie lists the names that start with a text by walking the nodes below its node; the sorted list reads them from the position it found, since they sit together there; the set cannot list them at all.
For a fixed list and one-off questions, the sorted list wins on memory and is simple to get right. The trie earns its memory when names arrive while questions are being answered, when typing walks it one key at a time, and when a search walks it together with something else, such as a grid of letters, a pattern with wildcards or the bits of numbers, which the later lessons of this module do.
Exercise
The search box asked whether a text starts a name. The exercise asks the same question of a list of codes as they arrive, in both directions, and the end marks decide every answer.
Exercise · Easy · Python
Find the first code that clashes
A canteen's ordering kiosk gives every dish a short code of digits. The kiosk never waits for an Enter key: as soon as the digits typed so far match a code, it orders that dish. So no code may be the start of another one. With both 12 and 1234 in use, nobody could ever order 1234, because the kiosk orders 12 first. Two dishes with the same code clash as well.
Write first_clash(codes) in codes.py. The codes join the menu in the order of the list.
- Return the position of the first code that clashes with an earlier code by being equal to it, starting with it or being its start.
- Return
-1if no code clashes. - There are 1 to 100,000 codes, and each is a string of 1 to 12 digits.
first_clash(["12", "345", "1234"]) # 2: "12" is the start of "1234"
first_clash(["911", "91", "7"]) # 1: "91" is the start of "911"
first_clash(["5", "60", "61"]) # -1: "60" and "61" share a start, but neither is the start of the other
The sample tests check the examples, a code that appears twice, clashes in both directions, codes that only share their first digits, and 300 random short lists. The last test gives your function 20,000 codes, counts how many codes it reads from the list, and stops it once it has read more than four times as many as the list holds.
Starter code · codes.py
def first_clash(codes):
"""The position of the first code that equals, starts or is started by an earlier code, or -1 if none does."""
# Replace this line with your code.
return -1 The sample tests · test_codes.py
import random
from codes import first_clash
def trusted(codes):
"""The answer the tests trust: compare each code with every earlier one."""
for position, code in enumerate(codes):
for earlier in codes[:position]:
if code.startswith(earlier) or earlier.startswith(code):
return position
return -1
class Codes(list):
"""A list of codes that counts the codes read from it, and stops a solution that reads too many."""
def __init__(self, values, limit):
super().__init__(values)
self.reads = 0
self.limit = limit
def _read(self, count):
self.reads += count
if self.reads > self.limit:
raise AssertionError(
f"too slow: more than {self.limit:,} codes were read from a list of {len(self):,}; comparing every "
"pair of codes is O(n²), so read each code once and keep what later codes need to know"
)
def __getitem__(self, index):
self._read(len(range(*index.indices(len(self)))) if isinstance(index, slice) else 1)
return super().__getitem__(index)
def __iter__(self):
for position in range(len(self)):
self._read(1)
yield super().__getitem__(position)
def test_prompt_examples():
"""the three examples from the prompt"""
assert first_clash(["12", "345", "1234"]) == 2
assert first_clash(["911", "91", "7"]) == 1
assert first_clash(["5", "60", "61"]) == -1
def test_same_code_twice():
"""a code equal to an earlier one clashes"""
assert first_clash(["42", "7", "42"]) == 2
def test_earlier_code_starts_a_later_one():
"""an earlier code is the start of a later one: an end mark lies on the later code's path"""
assert first_clash(["8", "81"]) == 1
assert first_clash(["30", "12", "301"]) == 2
def test_later_code_starts_an_earlier_one():
"""a later code is the start of an earlier one: its path stops at a node that already has a child"""
assert first_clash(["555", "55"]) == 1
assert first_clash(["1234", "9", "12"]) == 2
def test_shared_first_digits():
"""codes may share their first digits as long as none is the start of another"""
assert first_clash(["123", "124", "125", "13"]) == -1
assert first_clash(["123", "124", "12"]) == 2
assert first_clash(["0"]) == -1
def test_random_small_lists():
"""300 random lists of up to 8 codes of 1 to 3 digits, against the answer the tests trust"""
rng = random.Random(11)
for _ in range(300):
codes = ["".join(rng.choice("012") for _ in range(rng.randint(1, 3))) for _ in range(rng.randint(1, 8))]
assert first_clash(codes) == trusted(codes), f"first_clash({codes})"
def test_many_codes():
"""20,000 codes of 10 digits, the last of which clashes, reading at most four codes per code in the list"""
rng = random.Random(3)
seen, codes = set(), []
while len(codes) < 19_999:
code = "".join(rng.choice("0123456789") for _ in range(10))
if code not in seen: # distinct codes of one length: none is the start of another
seen.add(code)
codes.append(code)
codes.append(codes[123][:4]) # the start of an early code
assert first_clash(Codes(codes, limit=4 * len(codes))) == 19_999 A hint
Add the codes to a trie one at a time, in the order of the list, and check each one while you add it. An end mark on the path of the new code means that an earlier code is the start of it. Once the path is walked, look at the node where it stops: if that node already has a child or an end mark, the new code is the start of an earlier code, or equal to one.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Warm-up (fresher): What is a trie, and what do insert, search and starts_with cost?
A trie is a tree of characters. Each node stands for the text spelled by the path from the root to it, holds one child per next character and has a flag that says whether an inserted word ends there, so words that share a start share its nodes. Insert walks the word’s path from the root, adds the nodes that are missing and sets the flag on the last one. Search walks the same path and answers yes only if it reaches the end and the flag is set. Starts_with answers yes as soon as the whole path exists. Each of the three takes O(L) steps for a text of L characters, however many words the trie holds. The memory is one node per distinct prefix, at most the total length of the words.
Warm-up (fresher): Why does a trie node need an end-of-word mark, and what goes wrong without it?
Because a path can exist without being a word. After inserting only salempur, the nodes for s, sa, sal, sale and salem all exist, yet none of those texts was inserted, so a search that only checks that the path exists reports sale as a word. The mark must also be set when insert creates no node at all: inserting salem after salempur walks an existing path, and an insert that marks only new nodes loses salem. Both slips pass tests with unrelated words, so test words that are prefixes of each other, in both orders, and compare with a brute force on small random cases.
Key takeaways
- A trie stores words as paths of characters. Each node is one distinct prefix, and words that share a start share its nodes.
insert,searchandstarts_witheach walk one node per character: O(L), however many words the trie holds.- The mark, not the shape of the tree, says which prefixes are words. Set it even when
insertcreates no node, and read it insearch. - Test a trie with words that are prefixes of each other, in both orders, against a brute force on small random cases.
- In CPython, a dict of children per node beat a list of 27 on memory, 210 against 320 bytes a node, and matched it on speed. In C++, the array was several times faster for about a third more memory.
- For a list that does not change, a sorted list with binary search answers both questions in O(L log n) with almost no extra memory.
References
- Trie memory (Edward Fredkin, Communications of the ACM 3(9)) (Association for Computing Machinery)
- trie (Dictionary of Algorithms and Data Structures) (National Institute of Standards and Technology)
- bisect: array bisection algorithm (The Python Standard Library) (Python Software Foundation)
- Value comparisons (The Python Language Reference) (Python Software Foundation)
- Data model: __slots__ (The Python Language Reference) (Python Software Foundation)
- Mapping types: dict.get (The Python Standard Library) (Python Software Foundation)
- tracemalloc: trace memory allocations (The Python Standard Library) (Python Software Foundation)
- timeit: measure execution time of small code snippets (The Python Standard Library) (Python Software Foundation)
- ECMAScript language specification: Map.prototype.get (Ecma International (TC39))
- The Java Language Specification, Java SE 25: 4.12.5 Initial Values of Variables (Oracle)
- The Java Language Specification, Java SE 25: 10.4 Array Access (Oracle)
- Working Draft, Programming Languages: C++ (N4950): [vector.modifiers], [sequence.reqmts], [new.delete] (ISO/IEC JTC1/SC22/WG21)
- AddressSanitizer (Clang documentation) (The Clang Team (LLVM Project))
- UndefinedBehaviorSanitizer (Clang documentation) (The Clang Team (LLVM Project))
- The Go Programming Language Specification: The zero value (The Go Authors)
- The Go Programming Language Specification: Index expressions (The Go Authors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress