Trie-based String Problems
A trie (prefix tree) stores a collection of strings as a tree of single characters. Inserting a word costs O(L); prefix searching costs O(L); and any of the N words in the dictionary is reachable in at most O(L) steps — making tries the fastest structure for prefix queries, autocomplete, and word search problems.
Table of contents
- Before we start
- Picture this first (no code yet)
- First, the slow way (so you feel the pain)
- The turning point
- The one idea to remember
- Building a trie from scratch
- Watch it happen, frame by frame
- Application 1 — Word Search II (LC #212)
- Application 2 — Longest Common Prefix (LC #14)
- When a hash set is faster, when trie wins
- Common traps
- Complexity
- Say it like a pro (interview one-liner)
- Check yourself
- Practice problems
Before we start
Binary search trees store numbers and sort them by value. A trie stores strings and sorts them by prefix. If you understand a linked list (each node points to the next) and a dictionary (each node has up to 26 children), you already understand the trie. By the end of this chapter you will be able to:
- Build a trie from scratch and insert/search words in O(L) per word.
- Explain why a trie beats a hash set for prefix queries.
- Use the trie skeleton to solve Word Search II (backtracking + trie) and Longest Common Prefix.
Picture this first (no code yet)
A real-life story
You are at a library that organises books not by author or title, but letter by letter. The entrance hall has 26 doors, one per first letter of the title. You walk through door 'C' for "Computer Science." Inside, there are 26 more doors for the second letter — you pick 'O'. And so on, until you reach a room that holds every book whose title starts with "CO".
This is a trie. Each room is a node. Each door is a character. You never need to look at the full title of every book — you follow the path character by character and arrive exactly where you need to be in O(title length) steps, regardless of how many books (N) the library holds.
A hash set would tell you if "Computer Science" exists, but it cannot answer "what books start with 'CO'?" in O(1) — it would have to check all N titles. The trie answers prefix queries in O(L) — the length of the prefix. For autocomplete systems with millions of titles, this is the difference between "instant" and "seconds."
First, the slow way (so you feel the pain)
You have a dictionary of 100 000 words and want to check if any starts with the prefix "pre". With a list: scan all 100 000 words and check .startswith("pre") — O(N × L). For N = 100 000 and L = 10, that is 1 million character comparisons, repeated for every keystroke the user types. Autocomplete for a phone keyboard fires on every keypress — 8 million comparisons per second just for prefix checking. A trie does this in O(L) = 10 steps.
The turning point
Pause & think
Consider words "apple", "app", "apt", "bat". Draw them letter by letter as a tree — common prefixes share the same path. Where does the path for "app" branch from the path for "apple"? Where does "apt" split from "app"? How many nodes total does this tree have?
Check your drawing
root
├── a
│ └── p
│ ├── p [END: "app"]
│ │ └── l
│ │ └── e [END: "apple"]
│ └── t [END: "apt"]
└── b
└── a
└── t [END: "bat"]
"app" and "apple" share the path a→p→p. "apt" shares a→p but then branches on 't'. Total nodes: root + a + p + p(end) + l + e(end) + t(end) + b + a + t(end) = 10 nodes for 4 words. Without a trie (storing full strings), 4 separate strings of total length 18 characters. The trie saved 8 characters of storage by sharing "ap" between three words.
The one idea to remember
The entire pattern in one sentence
A trie node has an array (or dictionary) of 26 child pointers and an is_end flag; inserting a word walks (and creates) nodes character by character in O(L); searching walks the same path without creating nodes — O(L) — and prefix search stops after the prefix length, never scanning the rest of the dictionary.
Building a trie from scratch
class TrieNode:
def __init__(self):
self.children = {} # char → TrieNode
self.is_end = False # True if a complete word ends here
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode() # create node if missing
node = node.children[ch] # walk down
node.is_end = True # mark end of word
def search(self, word: str) -> bool:
node = self.root
for ch in word:
if ch not in node.children:
return False # path doesn't exist
node = node.children[ch]
return node.is_end # must land on a complete word, not just a prefix
def startsWith(self, prefix: str) -> bool:
node = self.root
for ch in prefix:
if ch not in node.children:
return False
node = node.children[ch]
return True # any node reached = prefix exists
Key difference between search and startsWith:
search("app")returns True only if "app" was inserted — checksis_end.startsWith("app")returns True if any inserted word starts with "app" — nois_endcheck.
Watch it happen, frame by frame
Insert "apple" and "app", then search.
Insert "apple":
root → create 'a' → create 'p' → create 'p' → create 'l' → create 'e'
Mark 'e' node: is_end = True
Insert "app":
root → 'a' exists → 'p' exists → 'p' exists (shared!)
Mark this 'p' node: is_end = True
Now the tree:
root
└── a
└── p
└── p [is_end=True ← "app"]
└── l
└── e [is_end=True ← "apple"]
search("apple"): walk a→p→p→l→e, is_end=True → True ✅
search("app"): walk a→p→p, is_end=True → True ✅
search("ap"): walk a→p, is_end=False → False ✅ (not an inserted word)
startsWith("ap"):walk a→p, return True → True ✅ (prefix exists)
Application 1 — Word Search II (LC #212)
Given a 2D grid of characters and a list of words, find all words from the list that appear in the grid (adjacent cells horizontally/vertically, no cell reuse).
Why trie + backtracking? Without a trie, for each word you'd do a full DFS — O(N × 4^L). With a trie, one DFS explores all words simultaneously — you follow the trie path as you walk the grid, and prune whenever the current path isn't a prefix of any word.
def findWords(board, words):
root = TrieNode()
for w in words:
node = root
for ch in w:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = w # store the word itself (not just True) for easy retrieval
rows, cols = len(board), len(board[0])
result = set()
def dfs(r, c, node):
ch = board[r][c]
if ch not in node.children:
return # prune: no word goes this way
next_node = node.children[ch]
if next_node.is_end:
result.add(next_node.is_end) # found a complete word
next_node.is_end = False # avoid duplicates
board[r][c] = '#' # mark visited
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] != '#':
dfs(nr, nc, next_node)
board[r][c] = ch # restore
for r in range(rows):
for c in range(cols):
dfs(r, c, root)
return list(result)
Why set is_end = False after finding a word? Once found, we don't want to add the same word again if another grid path leads to the same trie leaf. Clearing is_end prevents duplicates without removing the node (which could break other words sharing the prefix).
Application 2 — Longest Common Prefix (LC #14)
Find the longest prefix shared by all strings in a list.
def longestCommonPrefix(strs: list[str]) -> str:
if not strs:
return ""
# Insert all strings into trie, then walk until branching point
root = TrieNode()
for s in strs:
node = root
for ch in s:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
# Walk from root: keep going as long as exactly one child and not is_end
prefix = []
node = root
while len(node.children) == 1 and not node.is_end:
ch = next(iter(node.children))
prefix.append(ch)
node = node.children[ch]
return "".join(prefix)
The walk stops when: a node has more than one child (strings diverge here), or a node is is_end (one of the strings ends here — the common prefix can't be longer).
O(N × L) to build, O(L) to walk — total O(N × L).
When a hash set is faster, when trie wins
| Task | Hash Set | Trie |
|---|---|---|
| Exact word lookup: does "apple" exist? | O(L) | O(L) — same |
| Prefix query: does any word start with "app"? | O(N × L) — scan all | O(L) — trie wins |
| Count words with a prefix | O(N × L) | O(L) with count stored in nodes |
| Autocomplete: list all words with prefix | O(N × L) | O(L + output size) |
| Memory | O(N × L) total chars | O(N × L) total, but shared prefixes reduce actual usage |
Common traps
Watch out for these
- Confusing
searchwithstartsWith.searchrequiresis_end = Trueat the final node.startsWithonly checks that the path exists — nois_endcheck. Swapping them is the most common trie bug. - Using
children = [None] * 26instead of{}. The array is faster (O(1) by index) but only works for lowercase letters. A dict works for any character set (digits, uppercase, Unicode). Know which to use based on the problem constraints. - Not clearing
is_endafter finding a word in Word Search II. Without this, the same word gets added multiple times if multiple grid paths lead to the same trie leaf. - Forgetting to mark
is_endduring insert. A node on the path exists even if the word wasn't fully inserted — you must explicitly mark the final node as a word endpoint.
Complexity
| Operation | Time | Space |
|---|---|---|
| Insert one word (length L) | O(L) | O(L) new nodes |
| Search / startsWith | O(L) | O(1) |
| Build trie from N words, avg length L | O(N × L) | O(N × L) |
| Word Search II (R×C grid, N words, max length L) | O(R×C×4^L + N×L) | O(N×L) |
Say it like a pro (interview one-liner)
"I'll use a trie — each node has a children map (char to node) and an
is_endflag. Insertion walks the path character by character creating nodes as needed in O(L). Search checks the path and theis_endflag. Prefix search is the same without theis_endcheck. This gives O(L) per query regardless of dictionary size, which beats O(N×L) linear scan for prefix problems."
Remember this forever
Trie — node + 3 methods
class TrieNode:
def __init__(self):
self.children = {} # char → TrieNode
self.is_end = False
# insert: walk + create nodes + set is_end = True at last
# search: walk + return is_end at last (False if path missing)
# startsWith: walk + return True if path exists (ignore is_end)
Word Search II trick: store the word string in is_end (not just True), and set is_end = False after finding to avoid duplicates.
LCP trick: walk root until len(children) != 1 or is_end.
Trap: search checks is_end; startsWith does NOT.
Check yourself
You insert "app" and "apple" into a trie. Then you call `search("app")`. How does the trie distinguish between "app" (a complete word) and "ap" (just a prefix that was never inserted)?
The is_end flag on each node makes the distinction. After inserting "app", the node for the second 'p' (depth 3) has is_end = True. After inserting "apple", only the 'e' node (depth 5) additionally has is_end = True. When search("app") walks to depth 3 and checks is_end, it finds True — "app" is a complete word. When search("ap") walks to depth 2 and checks is_end, it finds False — "ap" was never inserted as a word, only as a prefix.
In the Word Search II DFS, why do we mark `board[r][c] = '#'` before recursing and restore it after?
This is the standard backtracking visited-mark technique. The problem says no cell can be reused in the same word path. Marking with '#' prevents the DFS from revisiting the current cell on the same path (since '#' will never match any trie child character). After all recursive branches return, we restore the original character so other DFS paths starting from different cells (or this same cell for different words) can still use this position.
Why does the Longest Common Prefix walk stop when a node has more than one child OR when `is_end` is True?
More than one child: the strings diverge here — different strings go to different characters next. No character past this node is shared by all strings. is_end = True: one of the inserted strings ends at this node. That string's length equals the depth we've walked so far. Going further would exceed the length of that string — so the common prefix cannot extend further. Both conditions represent "we've gone as far as all strings can agree."
Practice problems
| Problem | Difficulty | What to notice | Link |
|---|---|---|---|
| Implement Trie (Prefix Tree) | Medium | Build the 3-method class from scratch | LC #208 |
| Longest Common Prefix | Easy | Walk trie until branching or is_end | LC #14 |
| Word Search II | Hard | Backtracking + trie prunes impossible paths | LC #212 |
| Design Add and Search Words | Medium | Trie with . wildcard — DFS on all children at wildcard | LC #211 |
| Replace Words | Medium | For each sentence word, walk trie to find shortest root | LC #648 |
This completes Chapter 3 — Strings (12 patterns). Next: Chapter 4 — Linked Lists, starting with Floyd's fast & slow pointer technique for cycle detection.