Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 40 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 40 minutes

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.

The trie of salem, salempur, satna and sangli: one shared path s, a, then three branches; salem ends on the way to salempur.rootssasalsalesalemsalempsalempusalempursansangsanglsanglisatsatnsatnaa name ends herebold: the new letter

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.

  1. 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.
  2. From sa, three branches leave: sal on the left, san in the middle and sat on the right.
  3. The right branch runs sat, satn, satna. satna has a double border: the name satna ends there.
  4. The middle branch runs san, sang, sangl, sangli. sangli has a double border.
  5. 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.
  6. 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 sale stands 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. sal and sale exist 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.

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:

The brute force: read the list again on every call Python · stations/brute.py
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

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.

  1. 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, set is_word on the node you reached, even when the whole path was already there.
  2. 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’s is_word.
  3. 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_word is 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, s to salempur, and marks the last one.
  • insert('salem'): every child it needs exists already. The walk creates nothing, stops at the node salem and marks it. An insert that marked only nodes it had just created would lose salem here.
  • search('sale'): four steps reach the node sale. 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 child s, but s has no child x, 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:

The trie in Python and JavaScript

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

The same trie in Java, C++ and Go

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) returns None for 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, whose get returns undefined for 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 of null, 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_back may move the vector to a larger block of memory, which makes earlier references into it invalid, so insert writes 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 of Trie is 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

A trie of words of up to L characters
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:

3,000 random cases against the brute force, and two slips Python · stations/stress.py
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

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:

  • SearchForgetsTheEnd checks only that the path exists. After insert('aab'), search('a') answers True. With the station names, it reports sale as a station.
  • MarksOnlyNewPaths sets the mark only when insert created a node. insert('a') after insert('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, so starts_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 dict or a Map takes 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 throws ArrayIndexOutOfBoundsException in 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_nodes is 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:

Four ways to answer starts_with, measured in CPython Python · children/measure.py
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:

Array and hash map children, measured in C++

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:

search and starts_with on a sorted list Python · stations/sorted_list.py
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

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 -1 if 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.

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.

  1. Question 1 of 7 A trie holds one name, 'salempur'. What do search('salem') and starts_with('salem') return?

    Choose one answer.

    Show the answer to question 1

    Answer: search returns False, starts_with returns True

    The nodes s, sa, sal, sale and salem all exist, because they lie on the path of salempur, so starts_with('salem') reaches its node and answers True. No word ends at that node, though: its end mark stays False until 'salem' itself is inserted, so search answers False.

  2. Question 2 of 7 How many nodes does the trie of 'pune', 'puri', 'purna' and 'patna' have, counting the root?

    Type a number.

    Show the answer to question 2

    Answer: 13

    A trie has one node for every distinct prefix of its words, plus the root. The prefixes are p, pu, pun, pune, pur, puri, purn, purna, pa, pat, patn and patna: 12 of them, so 13 nodes. The four names have 18 letters between them; sharing p, pu and pur saves the other 6.

  3. Question 3 of 7 This insert has a slip. Which sequence of calls shows it?

    Read the code, then choose one answer.

    def insert(self, word):
        node = self.root
        for ch in word:
            if ch not in node.children:
                node.children[ch] = Node()
            node = node.children[ch]
            node.is_word = True
    Show the answer to question 3

    Answer: insert('salem'), then search('sal') returns True

    The mark is set inside the loop, so every node on the path is marked as the end of a word, and search('sal') answers True although only salem was inserted. starts_with('sal') returning True is right, search('salem') returning False after inserting only 'sal' is right, and the last node of salem is marked, so search('salem') still works.

  4. Question 4 of 7 names = ['kalka', 'kalyan', 'katni', 'kota'] is sorted, and bisect_left(names, 'kaly') returns 1. What does that tell you?

    Choose one answer.

    Show the answer to question 4

    Answer: 'kalyan', the first name that is not smaller than 'kaly', starts with 'kaly', so at least one name does

    Every name that starts with 'kaly' sorts at or after 'kaly', and every name between 'kaly' and such a name starts with 'kaly' too, so if any name starts with it, the first name at or after it does. Here that is 'kalyan'. It says nothing about how many names do: 'katni', at position 2, does not.

  5. Question 5 of 7 In the measured run on 64-bit CPython, how did a list of 27 children per node compare with a dict of children per node?

    Choose one answer.

    Show the answer to question 5

    Answer: The list took 320 bytes a node against 210, and was not meaningfully faster

    A list of 27 slots costs 8 bytes a slot whether or not the slot holds a child, and most nodes have one child or none, so it took about half as much memory again as the dict. It was not meaningfully faster either: turning a character into its slot number is itself a lookup in a dict. In the C++ run, where indexing an array is a single memory access, the array was several times faster than a hash map.

  6. Question 6 of 7 The Java trie of this lesson has 27 slots, for 'a' to 'z' and the space. What happens when someone inserts 'Delhi', with a capital D?

    Choose one answer.

    Show the answer to question 6

    Answer: slot('D') is 'D' − 'a', which is −29, so next[−29] throws ArrayIndexOutOfBoundsException

    'D' is 68 and 'a' is 97, so the slot number is −29, and Java checks every array index when the program runs. The same slip is undefined behaviour in C++ and a run-time panic in Go. Bring every name and every typed text into the alphabet first, for example by lower-casing them, or use a map of children when the alphabet is large or unknown.

  7. Question 7 of 7 brute.py prints, for five texts, both answers of the list-based version and how many words its two calls read from the 26 names. What does it print?

    What does this program print? Choose one answer.

    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}")
    Show the answer to question 7

    Answer: it prints

    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

    Each loop stops at the first name that answers it. 'salempur' is the first name in the list, so both calls stop after one word each. A text that is not a name makes search read all 26 names, and 'sx' makes both calls read all 26, which is 52: the cost of every miss, and why the brute force is O(n) per call.

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, search and starts_with each 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 insert creates no node, and read it in search.
  • 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.