After each character the user types, return up to 3 products that share the current prefix, in lexicographic order. Sort once, then either narrow a window with two pointers or build a trie that caches the 3 smallest suggestions on every prefix node.
As the user types searchWord one letter at a time, after each keystroke return up to 3 products that start with the prefix typed so far — the three lexicographically smallest if more than three match. For products = ["mobile","mouse","moneypot","monitor","mousepad"] and searchWord = "mouse", typing m, mo → ["mobile","moneypot","monitor"], then mou, mous, mouse → ["mouse","mousepad"].
products.sort()puts everything in global lexicographic order, so "smallest three" becomes "first three encountered."suggestions list has fewer than 3 entries, push the product. Sorted insertion order guarantees these are the smallest.searchWordone char at a time from the root. If the child exists, emit that node's cached suggestions; once the path falls off the trie, every remaining keystroke emits [].searchWord.length lists.isEnd flag here. Because the global sort already orders everything, the first three products to pass through a node are its answer — caching them during insertion is the whole trick. Once the walk hits a missing child, do not reset to the root; the rest of the keystrokes are all empty.With n products of length up to m and a search word of length L: the sort is O(n·m·log n) (string compares cost up to m), building the trie is O(n·m), and answering all keystrokes is O(L) since each node already holds its top-3. The sort+two-pointer variant skips the trie entirely and is often the cleaner interview answer.
5 products and searchWord = "mouse". First, sort the products lexicographically.1class TrieNode {2 children: Record<string, TrieNode> = {};3 // Up to 3 lexicographically smallest products passing through this node.4 suggestions: string[] = [];5}67▶function suggestedProducts(products: string[], searchWord: string): string[][] {8 products.sort(); // global lexicographic order910 const root = new TrieNode();11 for (const product of products) { // insert each product...12 let node = root;13 for (const c of product) {14 if (!node.children[c]) node.children[c] = new TrieNode();15 node = node.children[c];16 if (node.suggestions.length < 3) // ...caching it on every prefix node17 node.suggestions.push(product); // products are inserted in sorted18 } // order, so the first 3 are the answer19 }2021 const result: string[][] = [];22 let node: TrieNode | null = root;23 for (const c of searchWord) { // type one character at a time24 node = node ? node.children[c] ?? null : null;25 result.push(node ? node.suggestions : []);26 }27 return result;28}
class TrieNode {
children: Record<string, TrieNode> = {};
// Up to 3 lexicographically smallest products passing through this node.
suggestions: string[] = [];
}
function suggestedProducts(products: string[], searchWord: string): string[][] {
products.sort(); // global lexicographic order
const root = new TrieNode();
for (const product of products) { // insert each product...
let node = root;
for (const c of product) {
if (!node.children[c]) node.children[c] = new TrieNode();
node = node.children[c];
if (node.suggestions.length < 3) // ...caching it on every prefix node
node.suggestions.push(product); // products are inserted in sorted
} // order, so the first 3 are the answer
}
const result: string[][] = [];
let node: TrieNode | null = root;
for (const c of searchWord) { // type one character at a time
node = node ? node.children[c] ?? null : null;
result.push(node ? node.suggestions : []);
}
return result;
}TrieNode has the usual children map plus a suggestions array holding up to three product names — the cached answer for the prefix that node represents.products.sort()orders everything lexicographically. This is what lets us treat "three smallest matches" as "first three inserted" later on.suggestions.length < 3, push the product. Because products arrive sorted, the first three to reach a node are its three lexicographically smallest descendants.searchWord from the root. Following child c lands on the node for the current prefix; emit its cached suggestions. The ?? null guard means once the path falls off, node stays null.node is null, every remaining keystroke pushes [] — no product has this prefix, and none ever will as it grows.O(n·m·log n) and dominates; building the trie is O(n·m); answering all L keystrokes is O(L) because each node caches its top-3. Space is O(n·m) for the trie (each node stores up to 3 string references).O(3) per node.O(1), which is why real typeahead uses this shape.class TrieNode {
children: Record<string, TrieNode> = {};
// Up to 3 lexicographically smallest products passing through this node.
suggestions: string[] = [];
}
function suggestedProducts(products: string[], searchWord: string): string[][] {
products.sort(); // global lexicographic order
const root = new TrieNode();
for (const product of products) { // insert each product...
let node = root;
for (const c of product) {
if (!node.children[c]) node.children[c] = new TrieNode();
node = node.children[c];
if (node.suggestions.length < 3) // ...caching it on every prefix node
node.suggestions.push(product); // products are inserted in sorted
} // order, so the first 3 are the answer
}
const result: string[][] = [];
let node: TrieNode | null = root;
for (const c of searchWord) { // type one character at a time
node = node ? node.children[c] ?? null : null;
result.push(node ? node.suggestions : []);
}
return result;
}O(n·m·log n) and dominates; building the trie is O(n·m); answering all L keystrokes is O(L) because each node caches its top-3. Space is O(n·m) for the trie (each node stores up to 3 string references).After the same sort, you don't need a trie at all: for each prefix binary-search the first product ≥ the prefix, then take up to three from there that still start with it. Often the cleaner interview answer.
function suggestedProducts(products: string[], searchWord: string): string[][] {
products.sort();
const result: string[][] = [];
let prefix = '';
let lo = 0; // window can only shrink rightward
for (const ch of searchWord) {
prefix += ch;
// advance lo to the first product still >= prefix
while (lo < products.length && products[lo] < prefix) lo++;
const row: string[] = [];
for (let i = lo; i < products.length && row.length < 3; i++) {
if (products[i].startsWith(prefix)) row.push(products[i]);
else break; // sorted ⇒ no more matches
}
result.push(row);
}
return result;
}lonever moves backward because each new character only makes the prefix larger, so matching products can only start later in the sorted array. You could swap the linear advance for a true binary search, but with the monotonic pointer it's already near-linear across all keystrokes.| suggestions / autocomplete after each typed char | trie with cached top-k per node |
| "k lexicographically smallest matches" | sort first → first-k = smallest-k |
| prefix queries over a fixed dictionary | trie (prefix tree) |
| sorted array + find window for a prefix | binary search + two pointers |
products.sort();
const root: Node = { children: {}, sug: [] };
for (const p of products) {
let n = root;
for (const c of p) {
n = (n.children[c] ??= { children: {}, sug: [] });
if (n.sug.length < 3) n.sug.push(p); // sorted insert ⇒ first 3 = answer
}
}
const res: string[][] = [];
let n: Node | null = root;
for (const c of searchWord) {
n = n ? n.children[c] ?? null : null;
res.push(n ? n.sug : []);
}
return res;