Replace Words
Problem
Given a dictionary of words and a sentence, replace all successors in the sentence with the shortest root in the dictionary that is a prefix of the word, and return the modified sentence.
- 1 ≤ dictionary.length ≤ 10⁵
- 1 ≤ dictionary[i].length ≤ 100
- 1 ≤ sentence.length ≤ 10⁶
- dictionary[i] and sentence consist only of lowercase letters
- The sentence consists of words separated by single spaces
Example
dictionary = ["cat", "bat", "rat"], sentence = "the cattle was rattled by the battery""the cat was rat by the bat"The brute-force approach would check each word in the sentence against every root in the dictionary to find the shortest prefix, resulting in a costly O(m * n * k) time complexity where m is the number of words in the sentence, n is the number of roots, and k is the average root length. The optimal approach uses a Trie to efficiently find the shortest prefix for each word. For example, for the word 'cattle', the Trie traversal finds 'cat' as the shortest prefix root, replacing 'cattle' with 'cat'. Similarly, 'rattled' is replaced by 'rat' and 'battery' by 'bat'. This reduces the prefix search to O(k) per word, where k is the length of the word.
Approach
Straightforward Solution
A naive solution checks each word in the sentence against every root in the dictionary to find the shortest prefix. This leads to O(m * n * k) time complexity, which is impractical for large inputs.
Core Observation
The problem reduces to efficiently finding the shortest prefix of each word in the sentence that exists in the dictionary. This is a classic prefix search problem where a Trie data structure excels by enabling prefix queries in O(k) time, where k is the length of the word.
Path to Optimal
Recognizing the prefix search nature, the dictionary is stored in a Trie, where each node represents a character and may mark the end of a root word. For each word in the sentence, the algorithm traverses the Trie character by character until it finds a node marking a root or exhausts the path. This traversal finds the shortest prefix root in O(k) time per word, drastically improving efficiency.
Optimal Approach
Build a Trie from the dictionary roots. For each word in the sentence, traverse the Trie to find the shortest root prefix. If found, replace the word with this root; otherwise, keep the original word. Join all processed words to form the final sentence. This approach achieves O(N + M * K) time complexity, where N is total characters in dictionary, M is number of words in sentence, and K is average word length.
Time
O(N + M * K)
Building the Trie takes O(N) time, where N is the sum of lengths of all dictionary words. Searching for prefixes for each of the M words in the sentence takes O(K) time per word, where K is the average length of the words, resulting in O(M * K).
Space
O(N)
The Trie stores all dictionary words character by character, requiring O(N) auxiliary space proportional to the total length of all dictionary words. The output string space is excluded.
Pattern Spotlight
Trie (Prefix Tree for Efficient Prefix Matching)
When needing to find the shortest prefix from a large dictionary for multiple query words, build a Trie to represent all prefixes, then traverse it per query word to find the earliest prefix match, enabling O(k) prefix search instead of costly repeated string comparisons.
Solution
| 1 | class TrieNode: |
| 2 | def __init__(self): |
| 3 | self.children = {} |
| 4 | self.word = None |
| 5 | |
| 6 | class Solution: |
| 7 | def replaceWords(self, dictionary: List[str], sentence: str) -> str: |
| 8 | root = TrieNode() |
| 9 | |
| 10 | for word in dictionary: |
| 11 | node = root |
| 12 | for c in word: |
| 13 | if c not in node.children: |
| 14 | node.children[c] = TrieNode() |
| 15 | node = node.children[c] |
| 16 | node.word = word |
| 17 | |
| 18 | res = [] |
| 19 | |
| 20 | for word in sentence.split(): |
| 21 | node = root |
| 22 | replacement = word |
| 23 | |
| 24 | for c in word: |
| 25 | if c not in node.children: |
| 26 | break |
| 27 | node = node.children[c] |
| 28 | if node.word: |
| 29 | replacement = node.word |
| 30 | break |
| 31 | |
| 32 | res.append(replacement) |
| 33 | |
| 34 | return " ".join(res) |
Step-by-Step Solution
Construct Trie from Dictionary Roots
| 8 | root = TrieNode() |
| 10 | for word in dictionary: |
| 11 | node = root |
| 12 | for c in word: |
| 13 | if c not in node.children: |
| 14 | node.children[c] = TrieNode() |
| 15 | node = node.children[c] |
| 16 | node.word = word |
Objective
To build a prefix tree that compactly represents all dictionary roots for efficient prefix lookup.
Key Insight
Building a Trie allows shared prefixes among dictionary roots to be stored once, reducing redundant comparisons. Each node holds children nodes keyed by characters and optionally stores a root word when a root ends at that node. This structure enables fast prefix queries by traversing character by character.
Interview Quick-Check
Core Logic
Insert each root word into the Trie by creating or traversing child nodes for each character, marking the end node with the root word.
Common Pitfalls & Bugs
Forgetting to mark the end of a root word in the Trie node leads to incorrect prefix matches.
Traverse Trie to Replace Words with Shortest Prefix
| 18 | res = [] |
| 20 | for word in sentence.split(): |
| 21 | node = root |
| 22 | replacement = word |
| 24 | for c in word: |
| 25 | if c not in node.children: |
| 26 | break |
| 27 | node = node.children[c] |
| 28 | if node.word: |
| 29 | replacement = node.word |
| 30 | break |
| 32 | res.append(replacement) |
Objective
To find and replace each word in the sentence with its shortest root prefix from the Trie, if such a prefix exists.
Key Insight
For each word, traversing the Trie character by character allows early termination as soon as a root prefix is found, ensuring the shortest prefix is selected. If no prefix is found, the original word remains. This avoids costly string comparisons and leverages the Trie structure for efficient prefix detection.
Interview Quick-Check
Core Logic
Traverse the Trie for each character in the word; if a node with a root word is found, replace the word immediately with that root.
State & Boundaries
Break traversal if a character is missing in the Trie children, indicating no prefix match.
Common Pitfalls & Bugs
Not breaking early upon finding a root word leads to longer prefixes being chosen instead of the shortest.
Join Replaced Words to Form Final Sentence
| 34 | return " ".join(res) |
Objective
To combine all processed words into a single string representing the modified sentence.
Key Insight
After replacing words with their shortest prefix roots where applicable, joining the list of words with spaces reconstructs the sentence in the required format.
Interview Quick-Check
Core Logic
Use a single join operation to efficiently concatenate all words with spaces.
Line Analysis
This solution has 5 Critical lines interviewers watch for.
self.word = None
Initialize the word attribute to None to mark the end of a root word.
Storing the root word at the terminal node allows quick identification of prefix matches during traversal.
node.word = word
Mark the end of the root word by storing it in the node.
This marks the node as a valid root prefix, enabling early prefix detection during search.
if node.word:
Check if the current node marks the end of a root word.
Finding a node with a stored root word indicates a valid prefix match has been found.
replacement = node.word
Update the replacement word to the found root prefix.
Replacing the original word with the shortest prefix root satisfies the problem requirement.
break
Break out of the loop after finding the shortest prefix.
Stopping traversal ensures the shortest prefix is used, preventing longer prefixes from overriding it.
self.children = {}
Initialize the children dictionary for the TrieNode.
This dictionary stores child nodes keyed by characters, enabling efficient traversal and insertion of prefixes.
node.children[c] = TrieNode()
Create a new TrieNode for the missing character.
This extends the Trie to include the new prefix path for the root word.
if c not in node.children:
Check if the current character is not a child of the current node.
If the character is missing, no prefix match exists beyond this point, so traversal stops.
break
Break out of the loop if no child node exists for the character.
Early termination prevents unnecessary traversal when no prefix match is possible.
root = TrieNode()
Create the root node of the Trie.
The root node serves as the entry point for all prefix insertions and lookups, representing an empty prefix.
if c not in node.children:
Check if the current character is not already a child of the current node.
If the character is missing, a new node must be created to represent this prefix path.
node = node.children[c]
Move to the child node corresponding to the current character.
Advancing the node pointer allows insertion or traversal to continue along the prefix path.
node = root
Start traversal from the root node for the current word.
Prefix search begins at the root of the Trie for each word to find the shortest matching root.
for c in word:
Iterate over each character in the current word.
Traverse the Trie character by character to find the shortest prefix root.
node = node.children[c]
Move to the child node corresponding to the current character.
Advancing the node pointer continues the prefix search along the Trie path.
for word in dictionary:
Iterate over each root word in the dictionary.
Each root word must be inserted into the Trie to build the prefix structure for efficient lookup.
node = root
Start traversal from the root node for the current root word.
Insertion begins at the root, moving down or creating child nodes for each character.
for c in word:
Iterate over each character in the root word.
Each character corresponds to a level in the Trie, guiding node creation or traversal.
for word in sentence.split():
Split the sentence into individual words and iterate over them.
Processing each word separately allows prefix replacement on a per-word basis.
replacement = word
Initialize replacement to the original word.
If no prefix is found, the original word remains unchanged in the result.
res.append(replacement)
Append the replacement word to the result list.
Collecting the processed words allows reconstruction of the final sentence after all replacements.
res = []
Initialize the result list to store processed words.
This list accumulates the final words after prefix replacement for efficient joining later.
return " ".join(res)
Join all words in the result list with spaces to form the final sentence.
Joining the list into a string produces the output in the required format efficiently.
Test Your Understanding
Why does using a Trie improve prefix search efficiency compared to checking each root against every word?
A Trie organizes all dictionary roots into a tree structure where common prefixes share nodes, allowing prefix searches to be done in O(k) time per word by traversing the tree once, instead of repeatedly comparing each root string, which would be O(n*k) per word.
Related Problems
Tries pattern
Don't just read it. Drill it.
Reconstruct Replace Words from memory until it sticks. AlgoDrill blanks out key lines and makes you fill them back in, step by step.
Unlock the Replace Words drill