Learn/DSA Patterns
DSA PatternsStringshard12 min read

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.

#trie#prefix-tree#strings#design#word-search#autocomplete#beginner#interview
Table of contents

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 — checks is_end.
  • startsWith("app") returns True if any inserted word starts with "app" — no is_end check.

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=TrueTruesearch("app"):   walk a→p→p, is_end=TrueTruesearch("ap"):    walk a→p, is_end=FalseFalse ✅ (not an inserted word)
startsWith("ap"):walk a→p, return TrueTrue ✅ (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

TaskHash SetTrie
Exact word lookup: does "apple" exist?O(L)O(L) — same
Prefix query: does any word start with "app"?O(N × L) — scan allO(L) — trie wins
Count words with a prefixO(N × L)O(L) with count stored in nodes
Autocomplete: list all words with prefixO(N × L)O(L + output size)
MemoryO(N × L) total charsO(N × L) total, but shared prefixes reduce actual usage

Common traps

Watch out for these

  • Confusing search with startsWith. search requires is_end = True at the final node. startsWith only checks that the path exists — no is_end check. Swapping them is the most common trie bug.
  • Using children = [None] * 26 instead 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_end after 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_end during 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

OperationTimeSpace
Insert one word (length L)O(L)O(L) new nodes
Search / startsWithO(L)O(1)
Build trie from N words, avg length LO(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_end flag. Insertion walks the path character by character creating nodes as needed in O(L). Search checks the path and the is_end flag. Prefix search is the same without the is_end check. 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

ProblemDifficultyWhat to noticeLink
Implement Trie (Prefix Tree)MediumBuild the 3-method class from scratchLC #208
Longest Common PrefixEasyWalk trie until branching or is_endLC #14
Word Search IIHardBacktracking + trie prunes impossible pathsLC #212
Design Add and Search WordsMediumTrie with . wildcard — DFS on all children at wildcardLC #211
Replace WordsMediumFor each sentence word, walk trie to find shortest rootLC #648

This completes Chapter 3 — Strings (12 patterns). Next: Chapter 4 — Linked Lists, starting with Floyd's fast & slow pointer technique for cycle detection.