Reasoning Into CodeArunank Divya Sharan · Python edition← Arunank’s portfolio
Module 18 · Tree BFS, Binary Search Trees, and Tries

179. Implement Trie

Interview · Download this solution · All solutions and shared helpers

Problem

Implement Trie with insert(word), search(word), and startsWith(prefix) for strings. Matching is exact and case-sensitive. insert returns None. Empty strings are supported: an empty prefix always exists, while search('') becomes true only after inserting the empty word. Repeated insertion is harmless.

Examples

Example 1

run_operations(Trie,[("insert","apple"),("search","app"),("startsWith","app"),("insert","app"),("search","app")])
=> [None, False, True, None, True]

Prefix existence and exact-word membership differ.

Example 2

run_operations(Trie,[("startsWith",""),("search",""),("insert",""),("search","")])
=> [True, False, None, True]

The root represents the empty prefix and can also become terminal.

Example 3

run_operations(Trie,[("insert","A"),("search","a"),("search","A")])
=> [None, False, True]

Character matching is case-sensitive.

Reveal the worked solution and reasoning

Why this mechanism works

Each edge consumes one character, so a root-to-node path identifies a prefix. A separate terminal flag distinguishes a complete inserted word from a prefix of a longer word. Search and prefix lookup share traversal; only exact search needs the terminal check.

State and boundaries

Each node has a children dictionary and terminal flag. The current node represents all characters consumed so far.

Protocol

  1. Start at the root.
  2. During insertion create missing character edges.
  3. Mark the final node terminal.
  4. During lookup fail on a missing edge.
  5. For exact search require terminal; for prefix search require only a reached node.

Visualization

root - a - p - p - l - e*
prefix 'app': path exists
word 'app': false until its node is marked *

Python implementation

from structures import run_operations

class TrieNode:
    def __init__(self):
        self.children = {}
        self.terminal = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.terminal = True

    def _walk(self, text):
        node = self.root
        for char in text:
            node = node.children.get(char)
            if node is None:
                return None
        return node

    def search(self, word):
        node = self._walk(word)
        return node is not None and node.terminal

    def startsWith(self, prefix):
        return self._walk(prefix) is not None

Worked execution

Recorded state changes after statements; L numbers refer to the solution file. Long traces are explicitly condensed.

run_operations(Trie,[("insert","apple"),("search","app"),("startsWith","app"),("insert","app"),("search","app")])

L5 __init__: self.children = {}
self=TrieNode{'children':{}}

L6 __init__: self.terminal = False
self=TrieNode{'children':{}, 'terminal':False}

L10 __init__: self.root = TrieNode()
self=Trie{'root':TrieNode...}

L13 insert: node = self.root
self=Trie{'root':TrieNode...}; word='apple'; node=TrieNode{'children':{}, 'terminal':False}

L14 insert: for char in word:
self=Trie{'root':TrieNode...}; word='apple'; node=TrieNode{'children':{}, 'terminal':False}; char='a'

L5 __init__: self.children = {}
self=TrieNode{'children':{}}

L6 __init__: self.terminal = False
self=TrieNode{'children':{}, 'terminal':False}

L16 insert: node.children[char] = TrieNode()
self=Trie{'root':TrieNode...}; word='apple'; node=TrieNode{'children':{...:...}, 'terminal':False}; char='a'

... 34 intermediate state changes omitted ...

L22 _walk: for char in text:
self=Trie{'root':TrieNode...}; text='app'; node=TrieNode{'children':{...:...}, 'terminal':False}; char='a'

L22 _walk: for char in text:
self=Trie{'root':TrieNode...}; text='app'; node=TrieNode{'children':{...:...}, 'terminal':False}; char='p'

L23 _walk: node = node.children.get(char)
self=Trie{'root':TrieNode...}; text='app'; node=TrieNode{'children':{...:...}, 'terminal':True}; char='p'

L29 search: node = self._walk(word)
self=Trie{'root':TrieNode...}; word='app'; node=TrieNode{'children':{...:...}, 'terminal':True}

Result: [None, False, True, None, True]. Prefix existence and exact-word membership differ.

run_operations(Trie,[("startsWith",""),("search",""),("insert",""),("search","")])

L5 __init__: self.children = {}
self=TrieNode{'children':{}}

L6 __init__: self.terminal = False
self=TrieNode{'children':{}, 'terminal':False}

L10 __init__: self.root = TrieNode()
self=Trie{'root':TrieNode...}

L21 _walk: node = self.root
self=Trie{'root':TrieNode...}; text=''; node=TrieNode{'children':{}, 'terminal':False}

L33 startsWith: return self._walk(prefix) is not None
self=Trie{'root':TrieNode...}; prefix=''

L21 _walk: node = self.root
self=Trie{'root':TrieNode...}; text=''; node=TrieNode{'children':{}, 'terminal':False}

L29 search: node = self._walk(word)
self=Trie{'root':TrieNode...}; word=''; node=TrieNode{'children':{}, 'terminal':False}

L13 insert: node = self.root
self=Trie{'root':TrieNode...}; word=''; node=TrieNode{'children':{}, 'terminal':False}

L18 insert: node.terminal = True
self=Trie{'root':TrieNode...}; word=''; node=TrieNode{'children':{}, 'terminal':True}

L21 _walk: node = self.root
self=Trie{'root':TrieNode...}; text=''; node=TrieNode{'children':{}, 'terminal':True}

L29 search: node = self._walk(word)
self=Trie{'root':TrieNode...}; word=''; node=TrieNode{'children':{}, 'terminal':True}

Result: [True, False, None, True]. The root represents the empty prefix and can also become terminal.

Why the solution is correct

Insertion creates exactly the path spelling the word and marks its endpoint. Lookup follows that unique character path, so a reached node proves prefix existence; its terminal flag proves exact insertion. Each edge consumes a character, bounding all operations by input length.

Time and space

Expected O(L) time per length-L operation with dictionary edges. O(S) stored nodes/edges for total inserted characters S, plus the root; lookup uses O(1) auxiliary state.

Common mistakes

Treating every reached node as a word makes search('app') succeed after inserting only 'apple'. Using a character as a terminal sentinel can collide with valid input; a separate flag avoids that.

Reasoning card

State: Each node has a children dictionary and terminal flag. The current node represents all characters consumed so far.

Invariant: A root-to-node path represents exactly one prefix.

Decision to rehearse: Each edge consumes one character; mark end-of-word separately.

Finish: For exact search require terminal; for prefix search require only a reached node

Transfer exercise

Add deletion without removing nodes still needed by another word or prefix.