Facebook Pixel

648. Replace Words

MediumTrieArrayHash TableString
Leetcode Link

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:

  1. If a word starts with a root from the dictionary, replace the entire word with that root
  2. If a word can be replaced by multiple roots (meaning it starts with multiple different roots from the dictionary), choose the shortest root
  3. 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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?

  1. 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.

  2. 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.

  3. 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:

  1. Node Structure:

    • children: An array of 26 elements (for lowercase letters a-z), where each element can be either None or another Trie node
    • ref: An integer that stores the index of the root word in the dictionary (-1 if this node doesn't represent a complete root)
  2. 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
  3. 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:

  1. 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
  2. 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
  3. 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)
  • 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 Evaluator

Example 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:

  1. "the":

    • Try to traverse: t โ†’ h โ†’ e
    • 't' doesn't exist as a child of root
    • Return -1 (no root found)
    • Keep original: "the"
  2. "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"
  3. "was":

    • Try to traverse: w โ†’ a โ†’ s
    • 'w' doesn't exist as a child of root
    • Return -1 (no root found)
    • Keep original: "was"
  4. "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"
  5. "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"
  6. "the":

    • Same as step 1
    • Keep original: "the"
  7. "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 dictionary
  • L = average length of dictionary words
  • S = number of words in sentence
  • M = average length of sentence words

Breaking down the operations:

  1. Building the Trie: For each word in dictionary, we insert it character by character. This takes O(D ร— L) time.
  2. Processing the sentence: For each word in the sentence, we search in the Trie up to M characters (in worst case). This takes O(S ร— M) time.
  3. 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:

  1. 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 us O(D ร— L ร— 26) space.
  2. Result list: O(S) space to store the processed words.
  3. 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)
Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

Which of the tree traversal order can be used to obtain elements in a binary search tree in sorted order?


Recommended Readings

Want a Structured Path to Master System Design Too? Donโ€™t Miss This!

Load More