Design Add and Search Words Data Structure — Trie + Wildcard DFS
Advertisement
Problem Statement
LeetCode 211 — Design Add and Search Words Data Structure | Difficulty: Medium
Design a data structure WordDictionary that supports:
addWord(word)— Adds a word into the dictionary.search(word)— Returns true if any string in the dictionary matchesword. The search string may contain the wildcard character.which matches any single letter.
Constraints:
1 <= word.length <= 25wordinaddWordconsists of lowercase English letters only.wordinsearchconsists of lowercase letters or..- At most
2dots persearchcall (per LeetCode constraints — but practical solutions handle any number). - At most
10^4calls toaddWordandsearch.
Examples:
addWord("bad")
addWord("dad")
addWord("mad")
search("pad") → false
search("bad") → true
search(".ad") → true (matches "bad", "dad", or "mad")
search("b..") → true (matches "bad")Why This Problem Matters
This is one of the cleanest demonstrations of why a trie is dramatically more powerful than a hash set. With a hash set, supporting wildcard search like .ad would cost O(N times L) per query — you would have to scan every stored word. With a trie, the wildcard query becomes a controlled DFS that prunes whole subtrees as soon as a character fails to match.
It is asked at Google, Amazon, Facebook, and Microsoft because it tests three skills at once — designing a class around a data structure, implementing the trie template, and recursing cleanly with backtracking. Nailing this problem proves you can extend a vanilla trie with custom search logic, which is exactly what real autocomplete and spell-correction systems do.
The Core Insight
The trick is recognising that insertion is identical to a normal trie, while search becomes recursive at every wildcard. Concretely:
- Build a standard trie keyed on lowercase letters with an
isEndflag. - For
search, walk the trie character by character. - When the current character is a literal letter, follow that single child — fail fast if missing.
- When the current character is
., recurse into every existing child and return true if any branch succeeds.
The wildcard branching is what turns a single linear walk into a bounded DFS. Because each node has at most 26 children, the worst-case fan-out per dot is 26. The depth is bounded by the word length L, so the worst case is O(26^d times L) where d is the number of dots — fast in practice because the trie is usually sparse and most branches die early.
Visual Dry Run
After addWord("bad"), addWord("dad"), addWord("mad") the trie looks like this — three sibling branches sharing only the root.
root
/ | \
b d m
| | |
a a a
| | |
d* d* d*
(* = isEnd)Now consider search(".ad"):
Step 1: ch = '.', recurse into every child of root → b, d, m
Step 2 (b-branch): ch = 'a', b has child a → continue
Step 3 (b-branch): ch = 'd', a has child d, d.isEnd = true → return TRUEThe first branch succeeds and the recursion short-circuits — we never even explore the d-branch or m-branch. That early termination is what makes the wildcard variant practical even with large dictionaries.
For search("b..") we descend to the b-node, then on the first dot we try every child of b (only a exists), and on the second dot we try every child of a (only d). d.isEnd is true, so the answer is true.
Solution (Optimal) — Trie + DFS
Python
class TrieNode:
__slots__ = ("children", "is_end")
def __init__(self):
self.children = {} # char -> TrieNode
self.is_end = False # marks a complete word
class WordDictionary:
def __init__(self):
self.root = TrieNode()
def addWord(self, word: str) -> None:
# Standard trie insert — O(L) per word
node = self.root
for ch in word:
if ch not in node.children:
node.children[ch] = TrieNode()
node = node.children[ch]
node.is_end = True
def search(self, word: str) -> bool:
# DFS that branches on '.'
def dfs(i: int, node: TrieNode) -> bool:
if i == len(word):
return node.is_end
ch = word[i]
if ch == ".":
# Wildcard — try every existing child
for child in node.children.values():
if dfs(i + 1, child):
return True
return False
# Literal char — single branch
if ch not in node.children:
return False
return dfs(i + 1, node.children[ch])
return dfs(0, self.root)JavaScript
class TrieNode {
constructor() {
this.children = new Map();
this.isEnd = false;
}
}
class WordDictionary {
constructor() {
this.root = new TrieNode();
}
addWord(word) {
let node = this.root;
for (const ch of word) {
if (!node.children.has(ch)) node.children.set(ch, new TrieNode());
node = node.children.get(ch);
}
node.isEnd = true;
}
search(word) {
const dfs = (i, node) => {
if (i === word.length) return node.isEnd;
const ch = word[i];
if (ch === ".") {
// Branch into every child for wildcard
for (const child of node.children.values()) {
if (dfs(i + 1, child)) return true;
}
return false;
}
const next = node.children.get(ch);
if (!next) return false;
return dfs(i + 1, next);
};
return dfs(0, this.root);
}
}Complexity
addWord: O(L) time, O(L) extra space per new node created.searchworst case: O(26^d times L) where d is the number of dots. With no dots it is O(L).- Total space: O(N times L) for N words of average length L.
Common Mistakes
- Returning true on missing wildcard child — When the current node has zero children at a
., you must return false, not true. The dot still requires some letter to match. - Iterating with for-loop over 26 letters when using a Map — If you store children in a hash map, only iterate the existing children. Iterating all 26 letters and checking
hasworks but wastes time on dead branches. - Forgetting
isEndat recursion base case — The basei == len(word)must checknode.is_end, not just return true. Otherwisesearch("ba")would match "bad". - Mutating the trie inside search — Some candidates accidentally write
node.children[ch] = TrieNode()inside search. Search must never modify the trie. - Not handling repeated calls efficiently — Building a fresh trie inside the search function instead of reusing
self.rootmakes every search O(N times L). Build once, query many. - Using arrays of size 26 then iterating null slots — Acceptable for memory but be aware that worst-case wildcard fan-out still scans 26 slots per dot.
Interview Tips
- State the trie structure first, then the wildcard recursion. Interviewers value seeing the design before the code.
- Explicitly mention that
addWordis unchanged from LC 208 — this signals you recognise the pattern. - Walk through complexity in two cases — no dots and all dots — to show you understand the branching factor.
- If the interviewer asks for the worst case bound, say O(26^d times L) and explain why the constraint of 2 dots makes it cheap in practice.
- Mention iterative BFS as an alternative to recursion if stack depth is a concern, although recursion is cleaner and rarely deep enough to matter for L up to 25.
Follow-up Questions
- What if the dictionary contains millions of words? The trie compresses common prefixes, so memory grows sub-linearly. For extreme scale, consider a compressed trie (radix tree) or a DAWG.
- How would you support
*(zero or more characters)? That turns it into a regex matcher — recurse with two transitions per*, advance pattern only and advance both. See LC 10 Regular Expression Matching. - What about deletion? Walk down marking the path, clear
isEnd, then unlink nodes bottom-up that have no children and noisEnd. - Can you make search iterative? Yes — use a queue or stack of (node, index) pairs. BFS gives a level-by-level wildcard expansion.
- How would you return all matches instead of a boolean? Continue the DFS even after finding a match and collect words by tracking the path string.
Key Takeaways
- A trie plus DFS handles wildcard search elegantly — literal characters follow one child, dots fan out to all children.
addWordis the standard LC 208 insert; onlysearchchanges.- Worst-case search is O(26^d times L) where d is the number of dots, but pruning makes it fast in practice.
- Always check
isEndat the recursion base — never return true just for reaching depth L. - This is the canonical FAANG trie-with-twist problem; mastering it sets you up for word search II, autocomplete, and stream-of-characters.
- The pattern generalises: any time a search query has flexibility, recurse over the trie and branch only where needed.
Advertisement