648. Replace Words

Given a dictionary of roots, replace every word in a sentence with the shortest root that is a prefix of it. Build a trie of the roots, then walk each word char by char — the first node flagged isEnd is the winning root.

MediumTrieHash MapTypeScript

PROBLEM What we're solving

You're given a dictionary of roots and a sentence of space-separated words. If a word has a root in the dictionary as a prefix, replace the word with that root; if several roots match, use the shortest. Words with no matching root stay unchanged. Example: dict=["cat","bat","rat"], sentence="the cattle was rattled by the battery" "the cat was rat by the bat".

KEY IDEA A trie turns prefix lookup into one walk per word

Insight →the question "does any root prefix this word, and which is shortest?" is exactly what a trie answers. Insert every root and mark its terminal node isEnd. Now walk a word character by character down the trie: the first time you step onto an isEnd node, the characters consumed so far ARE the shortest matching root — stop immediately. If you fall off the trie (a child is missing) before hitting any isEnd, no root matches and the word is kept as-is.

RECIPE Build the trie, then walk each word to its first end-flag

  • 1 · Build the root trie. Insert each dictionary root, creating missing children as you descend, and set isEnd = true on the last node — that flag is what marks a complete root.
  • 2 · Walk each word. Start at the trie root with an empty prefix accumulator. For each character, descend into the child and append the char to the accumulator.
  • 3 · Shortest root wins. The moment you land on an isEnd node, return the accumulated prefix — because you walked left-to-right, this is the shortest root that prefixes the word.
  • 4 · Fall off ⇒ keep original. If a child is missing before any isEnd, the word has no root in the dictionary, so emit the original word unchanged.
  • 5 · Reassemble. Map every word through this lookup and join(' ') the result back into a sentence.
Classic confusion → you must check isEnd as soon as you arrive on a node, not only after consuming the whole word. For "cattle" you stop at "cat" the instant its node is flagged; if you kept walking to the end of "cattle"you'd fall off the trie and wrongly keep the original. First flag wins.

COST Complexity & alternatives

Per word, test every root
O(W · R · L)
For each of W words, check all R roots char-by-char.
Trie of roots
O(D + S)
Build once (D = total root chars); each word walk is O(its length).

Why a trie

Building the trie costs O(D) in the total characters of all roots. Then every word is resolved by a single walk of length O(min(word length, longest root)), so processing the whole sentence is O(S)in its total characters. The trie shares common prefixes among roots, and short-circuits at the first match — exactly the structure this "shortest matching prefix" query wants.

Pattern transfer → the same root-trie + walk-to-first-flag idea powers Implement Trie, autocomplete, longest-common-prefix, Word Search II (a dictionary trie pruning a grid DFS), and IP-routing longest-prefix matching. A HashSet of roots can also work if you probe each prefix length of every word.

RUN IT Trie the roots, walk each word to the first end-flag

step 0 / 42
STARTPhase 1: build a trie of the dictionary roots ["cat", "bat", "rat"], marking each root's last node isEnd.
1class TrieNode {
2 children: Record<string, TrieNode> = {};
3 isEnd = false;
4}
5
6function replaceWords(dictionary: string[], sentence: string): string {
7 // 1) Build a trie of the dictionary roots.
8 const root = new TrieNode();
9 for (const w of dictionary) {
10 let node = root;
11 for (const c of w) {
12 if (!node.children[c]) node.children[c] = new TrieNode();
13 node = node.children[c];
14 }
15 node.isEnd = true; // mark end-of-root
16 }
17
18 // 2) For each word, walk the trie until the first end-of-root.
19 const shortestRoot = (word: string): string => {
20 let node = root;
21 let prefix = '';
22 for (const c of word) {
23 if (!node.children[c]) break; // fell off the trie → no root
24 prefix += c;
25 node = node.children[c];
26 if (node.isEnd) return prefix; // first root wins (shortest)
27 }
28 return word; // no root matched → keep original
29 };
30
31 return sentence.split(' ').map(shortestRoot).join(' ');
32}
rootscatbatrat
State
build trie
phase
root
root
active char / opwalked prefixisEnd / replacedfell off / kept
slowfast

TYPESCRIPT The solution, annotated

replaceWords.ts
class TrieNode {
  children: Record<string, TrieNode> = {};
  isEnd = false;
}

function replaceWords(dictionary: string[], sentence: string): string {
  // 1) Build a trie of the dictionary roots.
  const root = new TrieNode();
  for (const w of dictionary) {
    let node = root;
    for (const c of w) {
      if (!node.children[c]) node.children[c] = new TrieNode();
      node = node.children[c];
    }
    node.isEnd = true;                 // mark end-of-root
  }

  // 2) For each word, walk the trie until the first end-of-root.
  const shortestRoot = (word: string): string => {
    let node = root;
    let prefix = '';
    for (const c of word) {
      if (!node.children[c]) break;    // fell off the trie → no root
      prefix += c;
      node = node.children[c];
      if (node.isEnd) return prefix;   // first root wins (shortest)
    }
    return word;                       // no root matched → keep original
  };

  return sentence.split(' ').map(shortestRoot).join(' ');
}

Reading it block by block

The node. A TrieNode is a children map (char → node) plus an isEnd flag. The character is the edge (the map key), and isEnd marks the last node of a complete dictionary root.
Build the trie. Insert each root by walking from the trie root, creating missing children, then set isEnd = true on the terminal node. Roots that share a prefix (none here, but e.g. "a" and "ab") share nodes.
shortestRoot — the walk. Start at the trie root with an empty prefix. For each character of the word, if the child is missing break (no root prefixes this word); otherwise append the char and descend.
First flag wins. Right after descending, check node.isEnd. Because we go left-to-right, the first isEnd we hit is the shortest root, so we return prefix immediately. If we never hit one, return the original word.
Reassemble. Split the sentence on spaces, map each word through shortestRoot, and join(' ') back into the final sentence.
Complexity → Building the trie is O(D) where D is the total characters across all roots. Each word is one walk of length O(min(word, longest root)), so the whole sentence is O(S) in its characters. Space is O(D) for the trie.

INTERVIEWFollow-ups they'll ask

  • "Multiple roots prefix a word — which wins?" The shortest. Walking left-to-right and returning on the first isEnd guarantees that for free.
  • "Lowercase a–z only — optimize node storage?" Swap the map for a fixed TrieNode[26] indexed by c - 'a'.
  • "Could you avoid a trie?" Put roots in a Set and, for each word, test every prefix length word.slice(0, k) shortest-first — simpler but O(word²) per word.
  • "What if a word equals a root exactly?" You hit isEnd on the last char and return it — the word is replaced by itself, which is correct.
  • "Streaming a huge document?" The trie is built once; each word is an independent O(length) walk, so it streams cleanly word by word.

OPTIMAL Trie

replaceWords.ts
class TrieNode {
  children: Record<string, TrieNode> = {};
  isEnd = false;
}

function replaceWords(dictionary: string[], sentence: string): string {
  // 1) Build a trie of the dictionary roots.
  const root = new TrieNode();
  for (const w of dictionary) {
    let node = root;
    for (const c of w) {
      if (!node.children[c]) node.children[c] = new TrieNode();
      node = node.children[c];
    }
    node.isEnd = true;                 // mark end-of-root
  }

  // 2) For each word, walk the trie until the first end-of-root.
  const shortestRoot = (word: string): string => {
    let node = root;
    let prefix = '';
    for (const c of word) {
      if (!node.children[c]) break;    // fell off the trie → no root
      prefix += c;
      node = node.children[c];
      if (node.isEnd) return prefix;   // first root wins (shortest)
    }
    return word;                       // no root matched → keep original
  };

  return sentence.split(' ').map(shortestRoot).join(' ');
}
Complexity → Building the trie is O(D) where D is the total characters across all roots. Each word is one walk of length O(min(word, longest root)), so the whole sentence is O(S) in its characters. Space is O(D) for the trie.

ALT 1 HashSet of roots — probe each prefix length

O(S · L) time · O(D) space

Skip the trie: store the roots in a Set, and for each word test its prefixes shortest-first, returning the first one that is a known root.

approach-2.ts
function replaceWords(dictionary: string[], sentence: string): string {
  const roots = new Set(dictionary);
  const replace = (word: string): string => {
    for (let k = 1; k <= word.length; k++) {
      const pre = word.slice(0, k);
      if (roots.has(pre)) return pre;   // shortest prefix that is a root
    }
    return word;
  };
  return sentence.split(' ').map(replace).join(' ');
}
Note → Much shorter to write, but each word builds and hashes up to L prefixes (sliceis O(k)), so it's O(word²) per word. The trie walks each character once and short-circuits at the first isEnd.

MNEMONIC The one-liner

"Trie the roots, mark isEnd; walk each word and stop at the first end-flag (shortest root) — fall off, keep the word."

TRIGGERS When you see ___ → reach for ___

replace word with its dictionary root / prefixtrie of roots + walk
shortest matching prefix winsreturn on FIRST isEnd while walking
no root matcheschild missing → keep original word
process every word of a sentencesplit → map → join(" ")

SKELETON The reusable shape

skeleton.ts
class TrieNode { children: Record<string, TrieNode> = {}; isEnd = false; }

function replaceWords(dict: string[], sentence: string): string {
  const root = new TrieNode();
  for (const w of dict) {
    let n = root;
    for (const c of w) n = (n.children[c] ??= new TrieNode());
    n.isEnd = true;
  }
  const root1 = (word: string): string => {
    let n = root, pre = '';
    for (const c of word) {
      if (!n.children[c]) break;
      pre += c; n = n.children[c];
      if (n.isEnd) return pre;
    }
    return word;
  };
  return sentence.split(' ').map(root1).join(' ');
}

FLASHCARDS Tap to flip

What structure makes "shortest root prefix" fast?
A trie of the roots with isEndmarking each root's terminal node. One walk per word answers it.
tap to flip
1 / 6
Answer to reveal explanations. No penalty for retries.Score: 0 / 9
QUESTION 01
If several dictionary roots are prefixes of the same word, which replaces it?
QUESTION 02
While walking a word down the trie, when do you return the accumulated prefix?
QUESTION 03
During a word walk, the next character has no child node. What do you do?
QUESTION 04
For dict=["cat","bat","rat"] and the word "cattle", the output is:
QUESTION 05
Overall time complexity of the trie solution?
QUESTION 06
Why mark isEnd on a root’s terminal node instead of storing the whole root in the node?
QUESTION 07
A word with no matching root, e.g. "was" under dict=["cat","bat","rat"], becomes:
QUESTION 08
#648 · Replace WordsReplace every word in a sentence with the shortest dictionary root that prefixes it. Building the roots into a trie lets each word walk the tree until it hits the first end-of-root, finding the shortest prefix in O(total length).Which algorithmic approach does this primarily use?
QUESTION 09
#648 · Replace WordsReplace every word in a sentence with the shortest dictionary root that prefixes it. Building the roots into a trie lets each word walk the tree until it hits the first end-of-root, finding the shortest prefix in O(total length).Which implementation correctly solves it?