648. Replace Words
Problem Description
This problem asks you to replace words in a sentence with their root forms using a given dictionary of roots.
Key Concepts:
- A root is a base word (like "help")
- A derivative is a longer word that starts with a root (like "helpful" which starts with "help")
- You're given a dictionary containing multiple roots
- You're given a sentence with words separated by spaces
Task: Replace each word in the sentence that is a derivative with its corresponding root from the dictionary.
Rules for Replacement:
- If a word starts with a root from the dictionary, replace the entire word with that root
- If a word can be replaced by multiple roots (meaning it starts with multiple different roots from the dictionary), choose the shortest root
- If a word doesn't start with any root from the dictionary, keep it unchanged
Example:
- Dictionary:
["help", "hel"]
- Sentence:
"helpful helping hello"
- Result:
"hel hel hello"
- "helpful" starts with both "help" and "hel", but "hel" is shorter, so we use "hel"
- "helping" starts with both "help" and "hel", but "hel" is shorter, so we use "hel"
- "hello" starts with "hel", so it becomes "hel"
The solution uses a Trie data structure to efficiently store all roots and find the shortest matching root for each word. The Trie allows checking prefixes character by character and returning immediately when the first (shortest) root is found.
Intuition
The naive approach would be to check every word in the sentence against every root in the dictionary to see if the word starts with that root. However, this would be inefficient, especially when we have many roots or long words.
The key insight is that we're looking for prefixes - we want to find if any root is a prefix of the current word. This naturally leads us to think about a Trie (prefix tree), which is specifically designed for efficient prefix matching.
Why is a Trie perfect here?
-
Efficient prefix search: As we traverse each character of a word, we can simultaneously check if we've formed a complete root at any point. The moment we find a root, we can stop - this automatically gives us the shortest root since we're checking character by character from the beginning.
-
Early termination: If we're checking the word "helpful" and our dictionary has both "hel" and "help", we'll encounter "hel" first (after 3 characters) and can immediately return it without checking further. This guarantees we always get the shortest matching root.
-
No redundant comparisons: Without a Trie, checking if "helpful" starts with "help" requires comparing 4 characters. If we have 100 roots, we might need to do this comparison 100 times for each word. With a Trie, we traverse the word only once, following the path down the tree.
The algorithm becomes straightforward:
- Build a Trie from all dictionary roots
- For each word in the sentence, traverse it character by character in the Trie
- As soon as we hit a node that marks the end of a root (
ref != -1
), we've found our shortest replacement - If we can't continue in the Trie (no matching child), the word has no root prefix
The ref
field in each Trie node stores the index of the root in the dictionary, allowing us to quickly retrieve the replacement word when we find a match.
Learn more about Trie patterns.
Solution Approach
The solution uses a Trie data structure to efficiently find and replace words with their shortest root forms.
Trie Implementation
The [Trie](/problems/trie_intro)
class has the following components:
-
Node Structure:
children
: An array of 26 elements (for lowercase letters a-z), where each element can be eitherNone
or another Trie noderef
: An integer that stores the index of the root word in the dictionary (-1 if this node doesn't represent a complete root)
-
Insert Method (
insert(w, i)
):def insert(self, w: str, i: int): node = self for c in w: idx = ord(c) - ord("a") # Convert character to index (0-25) if node.children[idx] is None: node.children[idx] = [Trie](/problems/trie_intro)() # Create new node if path doesn't exist node = node.children[idx] node.ref = i # Mark the end of this root with its dictionary index
- Traverses each character of the root word
- Creates new nodes as needed along the path
- Marks the final node with the root's index in the dictionary
-
Search Method (
search(w)
):def search(self, w: str) -> int: node = self for c in w: idx = ord(c) - ord("a") if node.children[idx] is None: return -1 # No matching prefix found node = node.children[idx] if node.ref != -1: return node.ref # Found a root! Return its index immediately return -1 # Traversed entire word without finding a root
- Traverses the word character by character
- Returns immediately when finding the first (shortest) root
- Returns -1 if no root prefix exists
Main Algorithm
The replaceWords
method follows these steps:
-
Build the Trie:
trie = Trie() for i, w in enumerate(dictionary): trie.insert(w, i)
- Insert all roots from the dictionary into the Trie
- Each root is stored with its index for quick retrieval
-
Process the sentence:
ans = [] for w in sentence.split(): idx = [trie](/problems/trie_intro).search(w) ans.append(dictionary[idx] if idx != -1 else w)
- Split the sentence into individual words
- For each word, search for a root prefix in the Trie
- If a root is found (idx != -1), replace with
dictionary[idx]
- Otherwise, keep the original word
-
Return the result:
return " ".join(ans)
- Join all processed words back into a sentence
Time and Space Complexity
-
Time Complexity:
- Building Trie:
O(N ร L)
where N is the number of roots and L is the average length of roots - Processing sentence:
O(M ร W)
where M is the number of words and W is the average word length - Overall:
O(N ร L + M ร W)
- Building Trie:
-
Space Complexity:
O(N ร L)
for storing the Trie structure
The beauty of this approach is that the Trie automatically handles the "shortest root" requirement - by returning as soon as we find a complete root during traversal, we guarantee we're always using the shortest possible replacement.
Ready to land your dream job?
Unlock your dream job with a 3-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example to understand how the Trie-based solution works.
Given:
- Dictionary:
["cat", "bat", "rat"]
- Sentence:
"the cattle was rattled by the battery"
Step 1: Build the Trie
We insert each root into the Trie:
Insert "cat": root | c (ref=-1) | a (ref=-1) | t (ref=0) โ marks end of "cat" with index 0 Insert "bat": root / \ c b (ref=-1) | | a a (ref=-1) | | t t (ref=1) โ marks end of "bat" with index 1 (ref=0) Insert "rat": root /|\ c b r (ref=-1) | | | a a a (ref=-1) | | | t t t (ref=2) โ marks end of "rat" with index 2
Step 2: Process Each Word
Now we search for each word in the sentence:
-
"the":
- Try to traverse: t โ h โ e
- 't' doesn't exist as a child of root
- Return -1 (no root found)
- Keep original: "the"
-
"cattle":
- Traverse: c โ a โ t
- At 't', we find ref=0 (this is the end of root "cat")
- Return index 0 immediately (don't need to check 't', 'l', 'e')
- Replace with: dictionary[0] = "cat"
-
"was":
- Try to traverse: w โ a โ s
- 'w' doesn't exist as a child of root
- Return -1 (no root found)
- Keep original: "was"
-
"rattled":
- Traverse: r โ a โ t
- At 't', we find ref=2 (this is the end of root "rat")
- Return index 2 immediately (don't need to check 't', 'l', 'e', 'd')
- Replace with: dictionary[2] = "rat"
-
"by":
- Try to traverse: b โ y
- After 'b', we try to find 'y' but it doesn't exist
- Return -1 (no root found)
- Keep original: "by"
-
"the":
- Same as step 1
- Keep original: "the"
-
"battery":
- Traverse: b โ a โ t
- At 't', we find ref=1 (this is the end of root "bat")
- Return index 1 immediately (don't need to check 't', 'e', 'r', 'y')
- Replace with: dictionary[1] = "bat"
Step 3: Build Result
Join the processed words: ["the", "cat", "was", "rat", "by", "the", "bat"]
Final Output: "the cat was rat by the bat"
The key insight is that by returning immediately when we find a complete root (ref != -1), we automatically get the shortest possible root. For example, if our dictionary also contained "catt", we would still return "cat" for "cattle" because we encounter "cat" first during our character-by-character traversal.
Solution Implementation
1from typing import List, Optional
2
3class Trie:
4 """Trie (prefix tree) data structure for efficient prefix searching."""
5
6 def __init__(self):
7 # Array to store 26 possible children (one for each lowercase letter)
8 self.children: List[Optional['Trie']] = [None] * 26
9 # Index reference to dictionary word (-1 if this node doesn't represent a complete word)
10 self.ref: int = -1
11
12 def insert(self, word: str, index: int) -> None:
13 """
14 Insert a word into the trie with its dictionary index.
15
16 Args:
17 word: The word to insert
18 index: The index of this word in the dictionary
19 """
20 node = self
21 for char in word:
22 # Calculate the index for this character (0-25 for a-z)
23 char_index = ord(char) - ord('a')
24 # Create a new node if it doesn't exist
25 if node.children[char_index] is None:
26 node.children[char_index] = Trie()
27 # Move to the child node
28 node = node.children[char_index]
29 # Mark the end of the word with its dictionary index
30 node.ref = index
31
32 def search(self, word: str) -> int:
33 """
34 Search for the shortest prefix of the word that exists in the trie.
35
36 Args:
37 word: The word to search for
38
39 Returns:
40 The dictionary index of the shortest matching prefix, or -1 if none found
41 """
42 node = self
43 for char in word:
44 # Calculate the index for this character
45 char_index = ord(char) - ord('a')
46 # If path doesn't exist, no prefix found
47 if node.children[char_index] is None:
48 return -1
49 # Move to the child node
50 node = node.children[char_index]
51 # If we found a complete word (prefix), return its index immediately
52 if node.ref != -1:
53 return node.ref
54 # No prefix found that is a complete word
55 return -1
56
57
58class Solution:
59 def replaceWords(self, dictionary: List[str], sentence: str) -> str:
60 """
61 Replace words in sentence with their shortest root word from dictionary.
62
63 Args:
64 dictionary: List of root words
65 sentence: Space-separated sentence to process
66
67 Returns:
68 The sentence with words replaced by their roots where applicable
69 """
70 # Build trie from dictionary words
71 trie = Trie()
72 for i, word in enumerate(dictionary):
73 trie.insert(word, i)
74
75 # Process each word in the sentence
76 result = []
77 for word in sentence.split():
78 # Search for shortest prefix in trie
79 index = trie.search(word)
80 # Use the root word if found, otherwise keep original word
81 result.append(dictionary[index] if index != -1 else word)
82
83 # Join words back into a sentence
84 return " ".join(result)
85
1class Solution {
2 public String replaceWords(List<String> dictionary, String sentence) {
3 // Create a HashSet from dictionary for O(1) lookup time
4 Set<String> rootSet = new HashSet<>(dictionary);
5
6 // Split the sentence into individual words
7 String[] words = sentence.split(" ");
8
9 // Process each word in the sentence
10 for (int i = 0; i < words.length; i++) {
11 String currentWord = words[i];
12
13 // Check all possible prefixes of the current word
14 for (int prefixLength = 1; prefixLength <= currentWord.length(); prefixLength++) {
15 String prefix = currentWord.substring(0, prefixLength);
16
17 // If prefix exists in dictionary, replace the word with its root
18 if (rootSet.contains(prefix)) {
19 words[i] = prefix;
20 break; // Stop at the shortest root found
21 }
22 }
23 }
24
25 // Join the processed words back into a sentence
26 return String.join(" ", words);
27 }
28}
29
1class Trie {
2private:
3 Trie* children[26]; // Array to store pointers to child nodes (one for each letter a-z)
4 int refIndex; // Index of dictionary word that ends at this node (-1 if none)
5
6public:
7 // Constructor: Initialize the trie node
8 Trie() : refIndex(-1) {
9 memset(children, 0, sizeof(children)); // Set all child pointers to nullptr
10 }
11
12 // Insert a word into the trie with its corresponding dictionary index
13 void insert(const string& word, int index) {
14 Trie* currentNode = this;
15
16 // Traverse through each character in the word
17 for (const char& ch : word) {
18 int charIndex = ch - 'a'; // Convert character to array index (0-25)
19
20 // Create new node if path doesn't exist
21 if (!currentNode->children[charIndex]) {
22 currentNode->children[charIndex] = new Trie();
23 }
24
25 currentNode = currentNode->children[charIndex];
26 }
27
28 // Mark the end of the word with its dictionary index
29 currentNode->refIndex = index;
30 }
31
32 // Search for the shortest root word that is a prefix of the given word
33 int search(const string& word) {
34 Trie* currentNode = this;
35
36 // Traverse through each character in the word
37 for (const char& ch : word) {
38 int charIndex = ch - 'a'; // Convert character to array index (0-25)
39
40 // If path doesn't exist, no root word found
41 if (!currentNode->children[charIndex]) {
42 return -1;
43 }
44
45 currentNode = currentNode->children[charIndex];
46
47 // If we found a complete root word, return its index immediately
48 // This ensures we get the shortest root
49 if (currentNode->refIndex != -1) {
50 return currentNode->refIndex;
51 }
52 }
53
54 // No root word found that is a prefix of the given word
55 return -1;
56 }
57};
58
59class Solution {
60public:
61 // Replace words in sentence with their root words from dictionary
62 string replaceWords(vector<string>& dictionary, string sentence) {
63 // Build trie from dictionary words
64 Trie* trieRoot = new Trie();
65 for (int i = 0; i < dictionary.size(); ++i) {
66 trieRoot->insert(dictionary[i], i);
67 }
68
69 // Process sentence word by word
70 stringstream sentenceStream(sentence);
71 string currentWord;
72 string result;
73
74 while (sentenceStream >> currentWord) {
75 // Search for root word in trie
76 int rootIndex = trieRoot->search(currentWord);
77
78 // Use root word if found, otherwise use original word
79 if (rootIndex == -1) {
80 result += currentWord + " ";
81 } else {
82 result += dictionary[rootIndex] + " ";
83 }
84 }
85
86 // Remove trailing space
87 result.pop_back();
88
89 return result;
90 }
91};
92
1// Trie node structure to store dictionary words
2interface TrieNode {
3 children: Record<string, TrieNode>;
4 wordIndex: number; // Index of the dictionary word that ends at this node (-1 if no word ends here)
5}
6
7// Create a new Trie node
8function createTrieNode(): TrieNode {
9 return {
10 children: {},
11 wordIndex: -1
12 };
13}
14
15// Insert a word into the Trie with its dictionary index
16function insert(root: TrieNode, word: string, index: number): void {
17 let currentNode = root;
18
19 // Traverse through each character in the word
20 for (const char of word) {
21 // Create child node if it doesn't exist
22 if (!currentNode.children[char]) {
23 currentNode.children[char] = createTrieNode();
24 }
25 currentNode = currentNode.children[char];
26 }
27
28 // Mark the end of the word with its dictionary index
29 currentNode.wordIndex = index;
30}
31
32// Search for the shortest prefix of a word in the Trie
33function search(root: TrieNode, word: string): number {
34 let currentNode = root;
35
36 // Traverse through each character in the word
37 for (const char of word) {
38 // If character doesn't exist in Trie, no prefix found
39 if (!currentNode.children[char]) {
40 return -1;
41 }
42
43 currentNode = currentNode.children[char];
44
45 // If we found a complete dictionary word, return its index
46 if (currentNode.wordIndex !== -1) {
47 return currentNode.wordIndex;
48 }
49 }
50
51 // No prefix found that matches a dictionary word
52 return -1;
53}
54
55// Replace words in sentence with their shortest dictionary roots
56function replaceWords(dictionary: string[], sentence: string): string {
57 // Build Trie from dictionary words
58 const trieRoot = createTrieNode();
59 for (let i = 0; i < dictionary.length; i++) {
60 insert(trieRoot, dictionary[i], i);
61 }
62
63 // Process each word in the sentence
64 const words = sentence.split(' ');
65 const replacedWords = words.map(word => {
66 const dictionaryIndex = search(trieRoot, word);
67 // Replace with dictionary word if prefix found, otherwise keep original
68 return dictionaryIndex !== -1 ? dictionary[dictionaryIndex] : word;
69 });
70
71 // Join the processed words back into a sentence
72 return replacedWords.join(' ');
73}
74
Time and Space Complexity
Time Complexity: O(D ร L + S ร M)
Where:
D
= number of words in dictionaryL
= average length of dictionary wordsS
= number of words in sentenceM
= average length of sentence words
Breaking down the operations:
- Building the Trie: For each word in dictionary, we insert it character by character. This takes
O(D ร L)
time. - Processing the sentence: For each word in the sentence, we search in the Trie up to
M
characters (in worst case). This takesO(S ร M)
time. - Joining the result:
O(S)
time to join the words, which is dominated by the above operations.
Space Complexity: O(D ร L ร 26 + S)
Where the components are:
- Trie storage: In the worst case, if all dictionary words have completely different prefixes, we need
O(D ร L)
nodes. Each node contains an array of 26 pointers, giving usO(D ร L ร 26)
space. - Result list:
O(S)
space to store the processed words. - The split operation creates a list of
S
words, but this is temporary and dominated by the Trie space.
Note: The space complexity can be more precisely stated as O(ALPHABET_SIZE ร N + S)
where N
is the total number of nodes in the Trie and ALPHABET_SIZE = 26
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Not Handling the "Shortest Root" Requirement Correctly
A common mistake is implementing a solution that finds any matching root rather than the shortest one. For example, using a simple string matching approach:
Incorrect Approach:
def replaceWords(dictionary, sentence):
words = sentence.split()
for i, word in enumerate(words):
for root in dictionary: # This doesn't guarantee shortest root!
if word.startswith(root):
words[i] = root
break
return " ".join(words)
Problem: If dictionary = ["help", "hel"] and word = "helpful", this might replace with "help" instead of "hel" depending on dictionary order.
Solution: The Trie approach naturally solves this by returning immediately upon finding the first complete root during character-by-character traversal, which is guaranteed to be the shortest.
2. Inefficient Dictionary Lookup Using Nested Loops
Inefficient Approach:
def replaceWords(dictionary, sentence):
# Sort to get shortest roots first
dictionary.sort(key=len)
result = []
for word in sentence.split():
replaced = False
for root in dictionary:
if word.startswith(root):
result.append(root)
replaced = True
break
if not replaced:
result.append(word)
return " ".join(result)
Problem: This has O(M ร N) complexity where M is the number of words and N is the size of dictionary. For large dictionaries, this becomes very slow.
Solution: The Trie reduces the search to O(W) where W is the word length, independent of dictionary size.
3. Memory Issues with Character Index Calculation
Common Bug:
# Forgetting to handle only lowercase letters
char_index = ord(char) - ord('a') # Assumes lowercase only!
Problem: If the input contains uppercase letters or special characters, this will cause index out of bounds errors or incorrect behavior.
Solution: Either validate input or normalize it:
def search(self, word: str) -> int:
node = self
for char in word:
if not char.islower():
return -1 # Handle non-lowercase characters
char_index = ord(char) - ord('a')
if node.children[char_index] is None:
return -1
node = node.children[char_index]
if node.ref != -1:
return node.ref
return -1
4. Not Preserving Word Spacing
Incorrect Handling:
# Using wrong split method words = sentence.split(' ') # This doesn't handle multiple spaces correctly
Problem: If the sentence has multiple consecutive spaces, split(' ')
will create empty strings in the result.
Solution: Use split()
without arguments, which handles any whitespace correctly and filters out empty strings. Then use single space in join()
:
words = sentence.split() # Handles any whitespace return " ".join(result) # Ensures single space between words
5. Trie Node Reference Overwriting
Potential Bug:
def insert(self, word: str, index: int):
# ... traversal code ...
node.ref = index # What if this node already has a ref?
Problem: If multiple roots share the same prefix (e.g., "he" and "help"), the shorter one might get overwritten.
Solution: Only update if not already set, or ensure dictionary is processed in order of increasing length:
def insert(self, word: str, index: int):
node = self
for char in word:
char_index = ord(char) - ord('a')
if node.children[char_index] is None:
node.children[char_index] = Trie()
node = node.children[char_index]
# Only set if not already a word (preserves shorter roots)
if node.ref == -1:
node.ref = index
Or sort dictionary by length before inserting:
# In replaceWords method
sorted_dict = sorted(enumerate(dictionary), key=lambda x: len(x[1]))
for original_index, word in sorted_dict:
trie.insert(word, original_index)
Which of the tree traversal order can be used to obtain elements in a binary search tree in sorted order?
Recommended Readings
Trie Introduction Imagine you have a magic dictionary where as soon as you start spelling a word it immediately tells you how many words that start with those letters How cool would that be This is exactly the kind of magic a data structure called trie performs What's a Trie
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Donโt Miss This!