254 lines
12 KiB
YAML
254 lines
12 KiB
YAML
title: Design Add and Search Words Data Structure
|
||
slug: design-add-and-search-words-data-structure
|
||
difficulty: medium
|
||
leetcode_id: 211
|
||
leetcode_url: https://leetcode.com/problems/design-add-and-search-words-data-structure/
|
||
categories:
|
||
- strings
|
||
- trees
|
||
patterns:
|
||
- trie
|
||
- dfs
|
||
|
||
function_signature: "class WordDictionary: ..."
|
||
|
||
test_cases:
|
||
visible:
|
||
- input:
|
||
operations: ["WordDictionary", "addWord", "addWord", "addWord", "search", "search", "search", "search"]
|
||
arguments: [[], ["bad"], ["dad"], ["mad"], ["pad"], ["bad"], [".ad"], ["b.."]]
|
||
expected: [null, null, null, null, false, true, true, true]
|
||
hidden:
|
||
- input:
|
||
operations: ["WordDictionary", "addWord", "search", "search"]
|
||
arguments: [[], ["a"], ["a"], ["."]]
|
||
expected: [null, null, true, true]
|
||
- input:
|
||
operations: ["WordDictionary", "addWord", "addWord", "search", "search", "search"]
|
||
arguments: [[], ["apple"], ["app"], ["app"], ["apple"], ["appl"]]
|
||
expected: [null, null, null, true, true, false]
|
||
- input:
|
||
operations: ["WordDictionary", "search"]
|
||
arguments: [[], ["a"]]
|
||
expected: [null, false]
|
||
- input:
|
||
operations: ["WordDictionary", "addWord", "addWord", "search", "search"]
|
||
arguments: [[], ["at"], ["and"], ["a"], [".at"]]
|
||
expected: [null, null, null, false, false]
|
||
- input:
|
||
operations: ["WordDictionary", "addWord", "addWord", "addWord", "search", "search", "search"]
|
||
arguments: [[], ["abc"], ["adc"], ["aec"], ["a.c"], ["..c"], ["..."]]
|
||
expected: [null, null, null, null, true, true, true]
|
||
- input:
|
||
operations: ["WordDictionary", "addWord", "search", "search", "search"]
|
||
arguments: [[], ["ab"], ["a."], [".b"], [".."]]
|
||
expected: [null, null, true, true, true]
|
||
|
||
description: |
|
||
Design a data structure that supports adding new words and finding if a string matches any previously added string.
|
||
|
||
Implement the `WordDictionary` class:
|
||
|
||
- `WordDictionary()` Initializes the object.
|
||
- `void addWord(word)` Adds `word` to the data structure, it can be matched later.
|
||
- `bool search(word)` Returns `true` if there is any string in the data structure that matches `word` or `false` otherwise. `word` may contain dots `'.'` where dots can be matched with **any 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^4` calls will be made to `addWord` and `search`.
|
||
|
||
examples:
|
||
- input: |
|
||
["WordDictionary","addWord","addWord","addWord","search","search","search","search"]
|
||
[[],["bad"],["dad"],["mad"],["pad"],["bad"],[".ad"],["b.."]]
|
||
output: "[null,null,null,null,false,true,true,true]"
|
||
explanation: |
|
||
WordDictionary wordDictionary = new WordDictionary();
|
||
wordDictionary.addWord("bad");
|
||
wordDictionary.addWord("dad");
|
||
wordDictionary.addWord("mad");
|
||
wordDictionary.search("pad"); // return False, no word matches "pad"
|
||
wordDictionary.search("bad"); // return True, exact match
|
||
wordDictionary.search(".ad"); // return True, matches "bad", "dad", "mad"
|
||
wordDictionary.search("b.."); // return True, matches "bad"
|
||
|
||
explanation:
|
||
intuition: |
|
||
This problem combines two classic concepts: the **Trie** (prefix tree) data structure and **depth-first search** with wildcards.
|
||
|
||
Think of a Trie as an autocomplete system: each node represents a character, and following a path from root to a marked "end" node spells out a complete word. For "bad", "dad", and "mad", the Trie would have three branches from the root (for 'b', 'd', 'm'), each leading through 'a' to 'd', with the final 'd' nodes marked as word endings.
|
||
|
||
The twist here is the wildcard `.` character, which can match **any** letter. When we encounter a `.` during search, we can't just follow one path — we must explore **all possible branches** at that level. This is where DFS comes in: we recursively try every child node when we see a dot.
|
||
|
||
Imagine searching for ".ad" in our Trie. At the root, the `.` means we try the 'b', 'd', and 'm' branches simultaneously. Each path then continues matching 'a' and 'd' literally. If any path reaches the end and finds a complete word, we return `true`.
|
||
|
||
approach: |
|
||
We implement a Trie with wildcard search using DFS:
|
||
|
||
**Step 1: Define the TrieNode structure**
|
||
|
||
- Each node has a dictionary `children` mapping characters to child nodes
|
||
- Each node has a boolean `is_end` marking whether a complete word ends here
|
||
|
||
|
||
|
||
**Step 2: Implement `addWord`**
|
||
|
||
- Start at the root node
|
||
- For each character in the word, create a child node if it doesn't exist
|
||
- Move to that child node
|
||
- After processing all characters, mark the final node as `is_end = True`
|
||
|
||
|
||
|
||
**Step 3: Implement `search` with DFS**
|
||
|
||
- Use a helper function `dfs(index, node)` that searches from position `index` in the word starting at `node`
|
||
- **Base case:** If `index == len(word)`, return `node.is_end` (did we reach a valid word ending?)
|
||
- **Literal character:** If the current character is a letter, check if it exists in `children`. If yes, recurse; if no, return `False`
|
||
- **Wildcard `.`:** Try **all** children nodes recursively. If any path returns `True`, the search succeeds
|
||
|
||
|
||
|
||
**Step 4: Return the result**
|
||
|
||
- Start the DFS from index `0` and the root node
|
||
- Return `True` if any valid path is found
|
||
|
||
common_pitfalls:
|
||
- title: Using a List or Set Instead of a Trie
|
||
description: |
|
||
A naive approach stores all words in a list or set and checks each word against the pattern during search.
|
||
|
||
For exact matches, this works. But with wildcards, you'd need to iterate through all stored words and check character-by-character, resulting in **O(n × m)** per search where `n` is the number of words and `m` is the word length.
|
||
|
||
With a Trie, exact searches are **O(m)**, and wildcard searches only branch when necessary, making it significantly faster for large dictionaries.
|
||
wrong_approach: "Store words in a list, iterate and match on search"
|
||
correct_approach: "Use a Trie for O(m) prefix-based lookup"
|
||
|
||
- title: Not Handling the Wildcard Correctly
|
||
description: |
|
||
When encountering `.`, you must explore **all** children, not just one. A common mistake is treating `.` as matching a specific character or stopping at the first match found without exploring other branches.
|
||
|
||
For example, searching ".ad" in a Trie with "bad" and "dad" should try both the 'b' and 'd' branches. If you only try one and it fails, you might incorrectly return `False`.
|
||
wrong_approach: "Match '.' to a single arbitrary character"
|
||
correct_approach: "Use DFS to try all children when encountering '.'"
|
||
|
||
- title: Forgetting the Word-End Check
|
||
description: |
|
||
The Trie might contain "bad" but not "ba". If you search for "ba", you'll successfully traverse 'b' → 'a', but that doesn't mean "ba" is a stored word.
|
||
|
||
Always check `is_end` at the final node. A successful traversal only counts if the final node marks a complete word.
|
||
wrong_approach: "Return True if traversal completes"
|
||
correct_approach: "Return node.is_end to verify word existence"
|
||
|
||
key_takeaways:
|
||
- "**Trie fundamentals**: A Trie stores strings character-by-character, enabling efficient prefix operations and exact lookups in O(m) time"
|
||
- "**Wildcard + DFS**: When a pattern can match multiple characters, use DFS to explore all possibilities — this pattern appears in regex matching, file globbing, and game AI"
|
||
- "**Space-time tradeoff**: Tries use more memory than a simple list, but provide much faster search operations, especially for prefix-based queries"
|
||
- "**Design pattern**: This problem demonstrates the classic pattern of combining a specialised data structure (Trie) with a traversal algorithm (DFS) to solve complex matching problems"
|
||
|
||
time_complexity: "O(m) for `addWord` where `m` is the word length. O(26^k × m) worst case for `search` where `k` is the number of wildcards, though typically much faster due to pruning."
|
||
space_complexity: "O(n × m) where `n` is the number of words and `m` is the average word length, to store all characters in the Trie."
|
||
|
||
solutions:
|
||
- approach_name: Trie with DFS
|
||
is_optimal: true
|
||
code: |
|
||
class TrieNode:
|
||
def __init__(self):
|
||
# Maps character to child TrieNode
|
||
self.children: dict[str, 'TrieNode'] = {}
|
||
# True if a complete word ends at this node
|
||
self.is_end = False
|
||
|
||
|
||
class WordDictionary:
|
||
def __init__(self):
|
||
# Root node of the Trie
|
||
self.root = TrieNode()
|
||
|
||
def addWord(self, word: str) -> None:
|
||
# Start at root and traverse/create nodes for each char
|
||
node = self.root
|
||
for char in word:
|
||
# Create child node if it doesn't exist
|
||
if char not in node.children:
|
||
node.children[char] = TrieNode()
|
||
# Move to child node
|
||
node = node.children[char]
|
||
# Mark the end of a complete word
|
||
node.is_end = True
|
||
|
||
def search(self, word: str) -> bool:
|
||
def dfs(index: int, node: TrieNode) -> bool:
|
||
# Base case: reached end of search word
|
||
if index == len(word):
|
||
return node.is_end
|
||
|
||
char = word[index]
|
||
|
||
if char == '.':
|
||
# Wildcard: try ALL children
|
||
for child in node.children.values():
|
||
if dfs(index + 1, child):
|
||
return True
|
||
return False
|
||
else:
|
||
# Literal character: must match exactly
|
||
if char not in node.children:
|
||
return False
|
||
return dfs(index + 1, node.children[char])
|
||
|
||
# Start search from root at index 0
|
||
return dfs(0, self.root)
|
||
explanation: |
|
||
**Time Complexity:**
|
||
- `addWord`: O(m) where `m` is the word length — we traverse/create one node per character.
|
||
- `search`: O(m) for exact matches. O(26^k × m) worst case when there are `k` wildcards, as each `.` can branch into up to 26 children. In practice, the Trie structure prunes many branches.
|
||
|
||
**Space Complexity:** O(n × m) for `n` words of average length `m`. Each character potentially creates a new node, though shared prefixes reduce actual usage.
|
||
|
||
The Trie enables efficient prefix-based storage, and DFS handles wildcard matching by exploring all valid paths.
|
||
|
||
- approach_name: Brute Force with List
|
||
is_optimal: false
|
||
code: |
|
||
class WordDictionary:
|
||
def __init__(self):
|
||
# Store all words in a list
|
||
self.words: list[str] = []
|
||
|
||
def addWord(self, word: str) -> None:
|
||
# Simply append to the list
|
||
self.words.append(word)
|
||
|
||
def search(self, word: str) -> bool:
|
||
def matches(pattern: str, candidate: str) -> bool:
|
||
# Length must match
|
||
if len(pattern) != len(candidate):
|
||
return False
|
||
# Check character by character
|
||
for p, c in zip(pattern, candidate):
|
||
# '.' matches anything, otherwise must be equal
|
||
if p != '.' and p != c:
|
||
return False
|
||
return True
|
||
|
||
# Check every word in the list
|
||
for stored_word in self.words:
|
||
if matches(word, stored_word):
|
||
return True
|
||
return False
|
||
explanation: |
|
||
**Time Complexity:**
|
||
- `addWord`: O(1) — just append to list.
|
||
- `search`: O(n × m) where `n` is the number of stored words and `m` is the word length. We must check every word against the pattern.
|
||
|
||
**Space Complexity:** O(n × m) to store all words.
|
||
|
||
This approach is simpler but becomes slow with many words. For `10^4` operations, the Trie approach significantly outperforms this brute force solution, especially for repeated searches.
|