HARD Lab · Tries · 74

211. Design Add and Search Words Data Structure

Build a dictionary that stores words and answers searches where . matches any single letter. A trie handles the prefixes; DFS with backtracking handles the wildcard by trying every child at each ..

MediumTrieDFS WildcardTypeScript

PROBLEM What we're building

Implement addWord(word) and search(word). A search string may contain ., which matches any one letter. After adding "bad", "dad", "mad": search("pad")false, search(".ad")true, search("b..")true.

KEY IDEA Trie for prefixes, DFS for the wildcard

Insight → store words in a trie so shared prefixes share nodes. To match a pattern, walk it character by character: a literal letter follows exactly one edge, but a . means "any child works," so you recurse into every child and succeed if anybranch reaches a word end. The recursion is the whole trick — it turns "try all possibilities" into a clean DFS.

RECIPE Insert plainly, search recursively

  • 0 · Node shape. Each TrieNode has a children map (char → node) and an end flag — so we know where a real word stops.
  • 1 · addWord. Walk the trie creating missing edges, then set end = true on the final node. Pure prefix insertion, no recursion needed.
  • 2 · search — base case. When the pattern is fully consumed (i === word.length), return the current node's endflag — reaching a node isn't enough; it must be a word end.
  • 3 · search — literal.Follow the single matching edge; if it's missing, this path fails immediately.
  • 4 · search — . wildcard. Recurse into every child; return true the moment one branch succeeds. If all fail, backtrack and report failure.
Classic confusion → at the end of the pattern you must return node.end, not just true. Otherwise search("ba") would wrongly match after inserting only "bad"— you'd be at a valid node that isn't a complete word.

COST Complexity & the wildcard blow-up

search worst case (all dots)
O(26ᵐ)
Every "." forks into up to 26 children.
search with no dots
O(m)
A single root-to-leaf walk.

Why dots are expensive

addWord is always O(m) for a word of length m. search with no . is also O(m). But each . multiplies the branching factor by up to 26, so a pattern of all dots can explore O(26ᵐ) paths — bounded in practice by how many words actually share those prefixes. Space is O(total characters inserted).

Pattern transfer → the trie + DFS skeleton powers Word Search II (trie of words, DFS the board), Implement Trie (the insert/search core), and Replace Words(walk the trie for the shortest root). The "wildcard forks into all children" move generalizes to any pattern-matching-over-a-tree problem.

RUN IT Insert into a trie, then DFS the pattern (. forks)

step 0 / 20
ADDaddWord("bad") — walked/created 3 nodes (3 new) and marked the last as a word end.
wordb0a1d2
State
bad
inserted
3
new nodes
literal follow / insertedactive char. wildcard forkdead end / fail
slowfast

TYPESCRIPT The solution, annotated

wordDictionary.ts
class TrieNode {
  children: Map<string, TrieNode> = new Map();
  end = false;
}

class WordDictionary {
  private root = new TrieNode();

  addWord(word: string): void {
    let node = this.root;
    for (const ch of word) {                 // walk, creating edges
      let next = node.children.get(ch);
      if (!next) {
        next = new TrieNode();
        node.children.set(ch, next);
      }
      node = next;
    }
    node.end = true;                          // mark a complete word
  }

  search(word: string): boolean {
    const dfs = (node: TrieNode, i: number): boolean => {
      if (i === word.length) return node.end; // consumed pattern → is it a word?
      const ch = word[i];
      if (ch === '.') {                        // wildcard: try EVERY child
        for (const child of node.children.values()) {
          if (dfs(child, i + 1)) return true;
        }
        return false;                          // no branch worked
      }
      const next = node.children.get(ch);      // literal: one edge only
      return next ? dfs(next, i + 1) : false;
    };
    return dfs(this.root, 0);
  }
}

Reading it block by block

TrieNode. A node is just a children map and an end boolean. The map keys are single characters; end marks where an inserted word terminates.
addWord. Walk the word, creating any missing child node, then set node.end = true. This is the ordinary trie insert — O(m) and no recursion.
search base case. i === word.length means the whole pattern was consumed. Return node.end — being ata node isn't a match unless it's a complete word.
The wildcard. For ., loop over node.children.values() and recurse one level deeper into each. The first branch that returns true short- circuits the rest; if none do, this . fails and we backtrack.
The literal. For a normal letter, fetch the one matching child. If it exists, recurse; if not, return falseimmediately — there's nothing to explore.
Complexity → addWord: O(m). search: O(m) with no dots, up to O(26ᵐ) when the pattern is all dots (each . branches into every child). Space: O(total characters stored).

INTERVIEWFollow-ups they'll ask

  • "Why return node.end and not true at the end?" Otherwise prefixes of stored words (e.g. "ba" for "bad") would falsely match.
  • "How bad can search get?" A pattern of all .can fan out by 26 at each level → O(26ᵐ); in practice it's bounded by the words that share those prefixes.
  • "Array vs Map for children?" A fixed TrieNode[26] is faster and cache-friendly for lowercase a–z; a Map is cleaner for larger or unknown alphabets.
  • "What if * matched zero-or-more chars?"You'd add a branch that both stays at the node (consume the *) and descends without consuming — like regex/DP matching.
  • "Word Search II connection?" Build a trie of all target words, then DFS the board pruning by trie edges — same trie + DFS pairing.

MNEMONIC The one-liner

"Insert plainly; on a dot, fork into every child — match only at a word end."

TRIGGERS When you see ___ → reach for ___

"search with . wildcards"trie + DFS over children
hit a "." in the patternrecurse into ALL children
pattern fully consumedreturn node.end (not true)
many words, shared prefixestrie to share nodes

SKELETON The reusable shape

skeleton.ts
class TrieNode { children = new Map<string, TrieNode>(); end = false; }

addWord(w) {                       // plain trie insert
  let n = root;
  for (const c of w) { if (!n.children.has(c)) n.children.set(c, new TrieNode()); n = n.children.get(c); }
  n.end = true;
}

search(w) {
  const dfs = (n, i) => {
    if (i === w.length) return n.end;
    if (w[i] === '.') { for (const c of n.children.values()) if (dfs(c, i+1)) return true; return false; }
    const nx = n.children.get(w[i]);
    return nx ? dfs(nx, i+1) : false;
  };
  return dfs(root, 0);
}

FLASHCARDS Tap to flip

What data structure stores the words?
A trie — each node has a children map and an end flag.
tap to flip
1 / 7
Answer to reveal explanations. No penalty for retries.Score: 0 / 7
QUESTION 01
How does search handle a "." in the pattern?
QUESTION 02
When the pattern is fully consumed (i === word.length), search returns:
QUESTION 03
After adding "bad", "dad", "mad", search(".ad") returns:
QUESTION 04
Worst-case time for search on a pattern of length m that is all dots:
QUESTION 05
Time complexity of addWord for a word of length m?
QUESTION 06
After adding only "bad", what does search("ba") return?
QUESTION 07
For a literal (non-dot) character, search explores: