Design Add and Search Words Data Structure
Store the words in a trie and search it depth first, letting each "." try every child. Solved two ways, a plain list in O(N × L) per search and a trie in O(26ᵈ × L) per search for d dots, with a step-through, code in five languages and the Big O worked out from the code.
The problem
Section titled “The problem”Build a class WordDictionary with two methods:
addWord(word)storesword.search(word)returnstrueif some stored word matchesword. Inword, a dot.matches any one letter. Every other letter must match exactly, and the lengths must be equal.
Each example below is a list of calls on one new WordDictionary, with what each call returns (null
for addWord).
Example 1
Input: addWord("cot"), addWord("cut"), addWord("cub"), search("cat"), search("c.b"), search("cu")
Output: null, null, null, false, true, false
No word has “a” as its second letter. “c.b” matches “cub”. “cu” is only the start of “cut” and “cub”, not a whole word.
Example 2
Input: addWord("a"), search("."), search("a.")
Output: null, true, false
“.” matches the one-letter word “a”. “a.” needs two letters.
Example 3
Input: addWord("go"), search(".."), search("g")
Output: null, true, false
Two dots match any two-letter word. “g” is too short.
Constraints
Section titled “Constraints”- 1 ≤ word.length ≤ 25
- Words passed to addWord use only a to z. Words passed to search use a to z and
.. - A search word holds at most 2 dots.
- At most 10,000 calls in total.
Clarifying questions
Section titled “Clarifying questions”- Does "." match one letter or many?
- Exactly one. So the lengths must be equal.
- Must the match be a whole word?
- Yes. "cu" does not match "cut".
- How many dots can a search have?
- At most 2. That keeps the branching small, as the Big O will show.
- Can stored words contain dots?
- No. Only search words do.
Spot the pattern
Section titled “Spot the pattern”Stored words that share a start can share one path: that is a trie, the prefix tree from the previous problem. A plain letter follows one edge. A dot has to try every edge and give up on the ones that lead nowhere. Trying a choice, and going back when it fails, is backtracking.
Solutions
Section titled “Solutions”This problem has two approaches. There is no useful step between them.
Brute force O(N × L)
The idea
Section titled “The idea”Keep every word in a list. To search, compare the query with each stored word. Skip words of the wrong length. Otherwise walk both words letter by letter: a dot always passes, any other letter must be equal.
It is simple and correct. But every search may compare against every stored word, and the list gives no way to skip the words that clearly cannot match.
Walkthrough
Section titled “Walkthrough”- words = []
- def addWord(word): words.append(word)
- def search(word):
- for stored_word in words:
- if len(stored_word) == len(word) and all(
- wanted_letter == "." or stored_letter == wanted_letter
- for stored_letter, wanted_letter in zip(stored_word, word)):
- return True
- return False
addWord stores "cot", "cut" and "cub" in a plain list, one step each. Now answer the searches.
// Brute force: keep every word in a list and compare letter by letter,// letting "." match anything. With N words of length L, N * L per search.class WordDictionary { private final List<String> words = new ArrayList<>();
public void addWord(String word) { words.add(word); }
public boolean search(String word) { for (String storedWord : words) { // "." stands for exactly one letter, so the lengths must match. if (storedWord.length() != word.length()) continue; boolean ok = true; for (int i = 0; i < storedWord.length() && ok; i++) { ok = word.charAt(i) == '.' || word.charAt(i) == storedWord.charAt(i); } if (ok) return true; } return false; }}# Brute force: keep every word in a list and compare letter by letter,# letting "." match anything. With N words of length L, N * L per search.class WordDictionary: def __init__(self): self.words = []
def addWord(self, word: str) -> None: self.words.append(word)
def search(self, word: str) -> bool: for stored_word in self.words: # "." stands for exactly one letter, so the lengths must match. if len(stored_word) == len(word) and all( wanted_letter == "." or stored_letter == wanted_letter for stored_letter, wanted_letter in zip(stored_word, word) ): return True return False// Brute force: keep every word in a list and compare letter by letter,// letting "." match anything. With N words of length L, N * L per search.public class WordDictionary{ private readonly List<string> words = new List<string>();
public void AddWord(string word) { words.Add(word); }
public bool Search(string word) { foreach (string storedWord in words) { // "." stands for exactly one letter, so the lengths must match. if (storedWord.Length != word.Length) continue; bool ok = true; for (int i = 0; i < storedWord.Length && ok; i++) { ok = word[i] == '.' || word[i] == storedWord[i]; } if (ok) return true; } return false; }}// Brute force: keep every word in a list and compare letter by letter,// letting "." match anything. With N words of length L, N * L per search.type WordDictionary struct { words []string}
func Constructor() WordDictionary { return WordDictionary{} }
func (dictionary *WordDictionary) AddWord(word string) { dictionary.words = append(dictionary.words, word)}
func (dictionary *WordDictionary) Search(word string) bool { for _, storedWord := range dictionary.words { // "." stands for exactly one letter, so the lengths must match. if len(storedWord) != len(word) { continue } ok := true for i := 0; i < len(storedWord) && ok; i++ { ok = word[i] == '.' || word[i] == storedWord[i] } if ok { return true } } return false}// Brute force: keep every word in a list and compare letter by letter,// letting "." match anything. With N words of length L, N * L per search.class WordDictionary { words: string[] = [];
addWord(word: string): void { this.words.push(word); }
search(word: string): boolean { // "." stands for exactly one letter, so the lengths must match. return this.words.some( (storedWord) => storedWord.length === word.length && [...storedWord].every((storedLetter, i) => word[i] === "." || word[i] === storedLetter), ); }}1 Find the work that repeats
The lit line compares the query with one stored word. It reads letters until one fails, so one compare costs up to L steps, where L is the word length. It runs once per stored word, and there are N of them. Each letter read counts as 1 step.
- words = []
- def addWord(word): words.append(word)
- def search(word):
- for stored_word in words:
- if len(stored_word) == len(word) and all(
- wanted_letter == "." or stored_letter == wanted_letter
- for stored_letter, wanted_letter in zip(stored_word, word)):
- return True
- return False
2 Count the runs on Example 1
The table counts letters read on Example 1. “cat” fails at the second letter of each word: 2 + 2 + 2. “c.b” reads all 3 letters of each word before it decides. “cu” skips every word on length alone.
6 + 9 + 0 = 15 letters read.
search "cat" "c.b" "cu" words compared 3 3 3 letters read 6 9 0 total6 + 9 + 0 = 15 letters read3 Write the count for any n
In the worst case every stored word has the right length, and each compare reads all L letters:
N × L letters per searchCheck it with “c.b”: N = 3 and L = 3, so 9 letters, as counted.
4 Keep only the part that grows fastest
There is nothing to drop: N × L has no smaller term. Each search gets slower as more words are added.
So the time is O(N × L).
S Space: count the extra memory the same way
The list keeps every word, up to N × L letters in all. The search itself keeps only a few counters. So the space is O(N × L).
words (all letters of all words)
Trie O(26ᵈ × L)
The idea
Section titled “The idea”Store the words in a trie: a tree where each edge is one letter and a flag marks where a word ends.
addWord walks down from the root and adds a node for each letter that has no edge yet.
search is a recursive walk. It holds position, its place in the query, and a node in the trie.
- If
positionhas reached the end, the match is real only if this node ends a word. - If the letter is plain, there is one edge to follow. If it is missing, this path fails.
- If the letter is a dot, try every child. As soon as one child leads to a match, stop and return
true. If a child fails, go back and try the next one.
Why it never misses the answer: each stored word is one path from the root. The search follows every path that agrees with the query, letter by letter, so if a matching word exists, its path gets tried.
Walkthrough
Section titled “Walkthrough”- def addWord(word):
- node = root
- for letter in word:
- node = node.children.setdefault(letter, TrieNode())
- node.is_word = True
- def search(word, position=0, node=root):
- if position == len(word):
- return node.is_word
- letter = word[position]
- if letter == ".":
- return any(search(word, position + 1, child) for child in node.children.values())
- if letter not in node.children:
- return False
- return search(word, position + 1, node.children[letter])
Start with an empty trie: a root, and one edge per letter below it.
// Trie with a depth-first search. A letter follows one edge; a "." tries// every child. With d dots, at most 26^d paths of length L are walked.class WordDictionary { private static class Node { Node[] children = new Node[26]; boolean isWord; }
private final Node root = new Node();
public void addWord(String word) { Node node = root; for (char letter : word.toCharArray()) { if (node.children[letter - 'a'] == null) node.children[letter - 'a'] = new Node(); node = node.children[letter - 'a']; } node.isWord = true; }
public boolean search(String word) { return matchesFrom(word, 0, root); }
private boolean matchesFrom(String word, int position, Node node) { // All letters used: a word must end exactly here. if (position == word.length()) return node.isWord; char letter = word.charAt(position); // "." can be any letter, so any child may lead to a match. if (letter == '.') { for (Node child : node.children) { if (child != null && matchesFrom(word, position + 1, child)) return true; } return false; } // A plain letter has only one way to go. Node nextNode = node.children[letter - 'a']; return nextNode != null && matchesFrom(word, position + 1, nextNode); }}# Trie with a depth-first search. A letter follows one edge; a "." tries# every child. With d dots, at most 26^d paths of length L are walked.class TrieNode: def __init__(self): self.children = {} self.is_word = False
class WordDictionary: def __init__(self): self.root = TrieNode()
def addWord(self, word: str) -> None: node = self.root for letter in word: node = node.children.setdefault(letter, TrieNode()) node.is_word = True
def search(self, word: str) -> bool: def matches_from(position: int, node: TrieNode) -> bool: # All letters used: a word must end exactly here. if position == len(word): return node.is_word letter = word[position] # "." can be any letter, so any child may lead to a match. if letter == ".": return any(matches_from(position + 1, child) for child in node.children.values()) # A plain letter has only one way to go. if letter not in node.children: return False return matches_from(position + 1, node.children[letter])
return matches_from(0, self.root)// Trie with a depth-first search. A letter follows one edge; a "." tries// every child. With d dots, at most 26^d paths of length L are walked.public class WordDictionary{ private class Node { public Node[] children = new Node[26]; public bool isWord; }
private readonly Node root = new Node();
public void AddWord(string word) { Node node = root; foreach (char letter in word) { if (node.children[letter - 'a'] == null) node.children[letter - 'a'] = new Node(); node = node.children[letter - 'a']; } node.isWord = true; }
public bool Search(string word) { return MatchesFrom(word, 0, root); }
private bool MatchesFrom(string word, int position, Node node) { // All letters used: a word must end exactly here. if (position == word.Length) return node.isWord; char letter = word[position]; // "." can be any letter, so any child may lead to a match. if (letter == '.') { foreach (Node child in node.children) { if (child != null && MatchesFrom(word, position + 1, child)) return true; } return false; } // A plain letter has only one way to go. Node nextNode = node.children[letter - 'a']; return nextNode != null && MatchesFrom(word, position + 1, nextNode); }}// Trie with a depth-first search. A letter follows one edge; a "." tries// every child. With d dots, at most 26^d paths of length L are walked.type trieNode struct { children [26]*trieNode isWord bool}
type WordDictionary struct { root *trieNode}
func Constructor() WordDictionary { return WordDictionary{root: &trieNode{}} }
func (dictionary *WordDictionary) AddWord(word string) { node := dictionary.root for _, letter := range word { if node.children[letter-'a'] == nil { node.children[letter-'a'] = &trieNode{} } node = node.children[letter-'a'] } node.isWord = true}
func (dictionary *WordDictionary) Search(word string) bool { return matchesFrom(word, 0, dictionary.root)}
func matchesFrom(word string, position int, node *trieNode) bool { // All letters used: a word must end exactly here. if position == len(word) { return node.isWord } // "." can be any letter, so any child may lead to a match. if word[position] == '.' { for _, child := range node.children { if child != nil && matchesFrom(word, position+1, child) { return true } } return false } // A plain letter has only one way to go. nextNode := node.children[word[position]-'a'] return nextNode != nil && matchesFrom(word, position+1, nextNode)}// Trie with a depth-first search. A letter follows one edge; a "." tries// every child. With d dots, at most 26^d paths of length L are walked.class TrieNode { children = new Map<string, TrieNode>(); isWord = false;}
class WordDictionary { root = new TrieNode();
addWord(word: string): void { let node = this.root; for (const letter of word) { if (!node.children.has(letter)) node.children.set(letter, new TrieNode()); node = node.children.get(letter)!; } node.isWord = true; }
search(word: string): boolean { const matchesFrom = (position: number, node: TrieNode): boolean => { // All letters used: a word must end exactly here. if (position === word.length) return node.isWord; const letter = word[position]; // "." can be any letter, so any child may lead to a match. if (letter === ".") { for (const child of node.children.values()) { if (matchesFrom(position + 1, child)) return true; } return false; } // A plain letter has only one way to go. const nextNode = node.children.get(letter); return nextNode !== undefined && matchesFrom(position + 1, nextNode); }; return matchesFrom(0, this.root); }}1 Find the work that repeats
The work repeats through recursion, so we count the calls of
search. Each call does a fixed job for one letter: a check, one lookup, or a loop over the children for a dot. So each call counts as 1 step, and a dot can start up to 26 calls.addWordtakes one step per letter.- def addWord(word):
- node = root
- for letter in word:
- node = node.children.setdefault(letter, TrieNode())
- node.is_word = True
- def search(word, position=0, node=root):
- if position == len(word):
- return node.is_word
- letter = word[position]
- if letter == ".":
- return any(search(word, position + 1, child) for child in node.children.values())
- if letter not in node.children:
- return False
- return search(word, position + 1, node.children[letter])
2 Count the runs on Example 1
The table counts calls on Example 1. “cat” makes 2 calls and stops at the missing “a”. “c.b” makes 5: the root, “c”, the dead end “o”, then “u” and “b”. “cu” makes 3.
2 + 5 + 3 = 10 calls.
search "cat" "c.b" "cu" calls 2 5 3 dots 0 1 0 total2 + 5 + 3 = 10 calls3 Write the count for any n
A query without dots follows one path, so at most L + 1 calls. Each dot can multiply the number of paths by up to 26, one per letter. With d dots:
at most 26ᵈ × (L + 1) callsCheck it with “c.b”: d = 1 and L = 3, so the bound is 26 × 4 = 104. The real trie had only 2 children under “c”, so the search made 5 calls.
4 Keep only the part that grows fastest
Drop the + 1. One extra call per path does not change the growth. What is left is 26ᵈ × L. The constraints say d ≤ 2, so the factor 26ᵈ is at most 676, a fixed number. For queries without dots, the search is O(L), however many words are stored.
addWordis always O(L).So the time is O(26ᵈ × L).
S Space: count the extra memory the same way
The trie holds at most one node per letter added, so N × L nodes in the worst case, and fewer when words share starts. The search adds a call stack L + 1 deep. So the space is O(N × L).
trie nodes (one per new letter)call stack (L frames)
Tested at the limits
Section titled “Tested at the limits”2,000 random call sequences of 1 to 15 calls, on words of 1 to 4 letters made of a, b and c, with up to two dots anywhere in a search. The brute force and the trie gave the same answers every time.
| Largest inputs | Result |
|---|---|
| 10,000 calls (the most allowed): 5,000 words of 25 letters that start with all 676 two-letter pairs, then 5,000 searches that start with two dots. Trie. | 3.4 s, and about 4.5 MB of extra memory. Each search follows all 676 paths to the end, which is the 26ᵈ part of the time with d = 2. |
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.