LeetCode #211 Medium

Design Add and Search Words Data Structure

Design Add and Search Words Data Structure: design a dictionary that stores words and searches patterns where . can stand for any single letter.

Constraints
  • 1 <= word.length <= 25
  • word in addWord consists of lowercase English letters.
  • word in search consist of '.' or lowercase English letters.
  • There will be at most 2 dots in word for search queries.
  • At most 10⁴ calls will be made to addWord and search.
triedepth-first-searchdesign
Open on LeetCode ↗
02

Intuition

Design add and search words data structure builds a dictionary supporting insertion and a search where . matches any single character. Without the wildcard this is an ordinary trie; the wildcard is what makes it interesting. Insertion is standard — walk the trie from the root, creating nodes as needed, and mark the final node as terminating a word. Searching splits into two cases at each character: - A concrete letter follows exactly one child; a . must try every child, since any could lead to a match. That second case forces the search to branch, which means a simple loop is no longer sufficient. Recursion (or an explicit stack) is required, because a wildcard can succeed down one child and fail down another, and failure must return to try the remaining ones. The recursive search takes a node and a position in the word. On a letter, descend into that child if it exists, or return false. On a ., try the search from every existing child, returning true if any succeeds. The base case is reaching the end of the word: return whether the current node is marked as a word ending. Returning true merely for arriving at a valid node is the classic bug — it makes searching for "ap" succeed when only "apple" was inserted, because the path exists without terminating a word. Worst case, a search of all dots visits every node, giving O(26^m) in principle but far less in practice, since only existing children are explored rather than all 26 possibilities.

How to spot this pattern

Repeated word insertion plus prefix-shaped lookup is a strong trie signal. When one pattern symbol can match any single character, keep normal trie traversal for fixed letters and use DFS only at wildcard positions.

03

Approach

Try it first

Before reading on: price up what the direct approach costs here, then ask what the shared prefixes let you avoid storing twice. Aim for O(L) for add; O(26^L) worst-case for search time and O(total inserted characters) space.

1

Build a standard trie

Each node holds children keyed by character and a flag marking a word ending. Without the wildcard this is an ordinary prefix tree, and insertion never changes.

2

Insert by walking and creating

Descend from the root, creating missing child nodes, then mark the final node as terminating a word. Insertion cost is proportional to the word length.

3

Follow a concrete letter directly

On a normal character, descend into that single child if it exists, or return false. This is the ordinary trie lookup path.

4

Branch on a wildcard

A . must try every existing child, since any could lead to a match. This branching is why the search must recurse — a failure down one child has to return and try the rest.

5

Check the word-ending flag

At the end of the word, return whether the node is marked as terminating. Returning true just for reaching a valid node makes "ap" match when only "apple" was added.

6

Explore only existing children

Iterate the children that exist rather than all 26 letters. The theoretical worst case is O(26^m), but real dictionaries explore a small fraction of that.

7

Cost of the operations

Insertion is O(m) for a word of length m. Search is O(m) with no wildcards, degrading toward O(26^m) as dots dominate. Space is O(total characters inserted).

04

Solution & live demo

▶1class TrieNode:
▶2 def __init__(self):
▶3 self.children = {}
▶4 self.is_word = False
▶5 
▶6class WordDictionary:
▶7 def __init__(self):
▶8 self.root = TrieNode()
▶9 
▶10 def addWord(self, word:
▶11 str) -> None:
▶12 node = self.root
▶13 for ch in word:
▶14 if ch not in node.children:
▶15 node.children[ch] = TrieNode()
▶16 node = node.children[ch]
▶17 node.is_word = True
▶18 
▶19 def search(self, word:
▶20 str) -> bool:
▶21 def dfs(node, index):
▶22 if index == len(word):
▶23 return node.is_word
▶24 
▶25 ch = word[index]
▶26 if ch == '.':
▶27 for child in node.children.values():
▶28 if dfs(child, index + 1):
▶29 return True
▶30 return False
▶31 
▶32 if ch not in node.children:
▶33 return False
▶34 return dfs(node.children[ch], index + 1)
▶35 
▶36 return dfs(self.root, 0)
05

Common pitfalls

Treating the wildcard as a literal key

✗ Wrong
node = node.children[ch]
✓ Right
for child in node.children.values():

A dot represents every available character at that depth. Looking up a child named . misses every valid wildcard match.

Accepting any consumed path as a word

✗ Wrong
return True
✓ Right
return node.is_word

Consuming the pattern may stop at a non-terminal prefix. The dictionary should match only words that were actually added.

Restarting after choosing a wildcard child

✗ Wrong
if dfs(self.root, index + 1):
✓ Right
if dfs(child, index + 1):

Once a wildcard chooses a character, the rest of the pattern must continue below that child. Restarting at the root combines pieces from unrelated words.

06

Edge cases

A pattern made entirely of dots, such as ...

The DFS explores trie paths of exactly three characters and accepts only a terminal node, so it matches a stored three-letter word but not a longer word sharing that prefix.

Searching a prefix that was never added, such as app after adding apple

Traversal reaches the node for the prefix, but its terminal flag is false, so the search correctly returns false.

Adding the same word more than once

The insertion follows the existing path and sets the same terminal flag again, leaving search behaviour unchanged.

07

Complexity

Time
O(L) for add; O(26^L) worst-case for search
Space
O(total inserted characters)
A search without wildcards follows one path; each dot may branch across the trie.