Implement wordFinder() — a factory that returns a small data structure for storing words and looking them up, where a query may contain the wildcard character . that matches any single letter. It is the classic autocomplete / spell-check building block: you load a dictionary once, then run many membership queries against it, some of which have "holes" you want filled by any letter. This is not a grid or path problem — there is no board and no adjacency. You are designing the container and its two operations.
// wordFinder() returns an object with two methods:
interface WordFinder {
// Insert a word into the structure. Words are non-empty, lowercase a–z.
// The same word may be added more than once; that's a no-op.
addWord(word: string): void;
// Return true if some stored word matches `query` exactly (same length).
// A '.' in `query` matches ANY single letter. Other chars match literally.
search(query: string): boolean;
}
function wordFinder(): WordFinder;
const wf = wordFinder();
wf.addWord('bad');
wf.addWord('dad');
wf.addWord('mad');
wf.search('pad'); // → false (no stored word "pad")
wf.search('bad'); // → true (exact literal match)
wf.search('.ad'); // → true ('.' matches b/d/m → "bad"/"dad"/"mad")
wf.search('b..'); // → true ("bad")
// The match is length-exact: '.' is exactly one letter, never zero or many.
const wf = wordFinder();
wf.addWord('a');
wf.search('a'); // → true
wf.search('.'); // → true (one wildcard, one stored letter)
wf.search(''); // → false (no empty word was added)
wf.search('..'); // → false ("a" has length 1, query has length 2)
. matches exactly one letter — never zero, never a run. search('..') only matches stored words of length two. There is no *-style "any number of letters" wildcard here.search('ba') against a stored 'bad' is false. The query length must equal the matched word's length.search on anything before any addWord returns false, including search('').a–z in addWord; queries are lowercase a–z plus .. You do not need to validate input, handle uppercase, or handle other punctuation.addWord is called many times, then search many times. Favour a structure that makes wildcard search fast over one that re-scans the whole dictionary per query — see the solution.* wildcard. Those are out of scope.You'll build a small dictionary you can load once and then query many times, where a query may carry the wildcard . that stands in for any single letter.
Picture a spell-checker or a crossword helper. You have a pile of valid words, and you keep asking "is this a word?" — except some of your letters are missing. You know the answer is three letters, ends in ad, and the first letter could be anything: .ad. You want a true/false back, fast, and you'll ask thousands of these. The wildcard . matches exactly one letter — .ad matches bad, dad, mad, but never ad (too short) or bbad (too long). The match is whole-word: the query has to line up with a stored word end to end.
Two operations: addWord loads a word into the structure, search answers a (possibly wildcarded) membership query. The interesting part is making the wildcard fast — a . can match many letters, so the search has to be able to fan out.
The right structure is a trie (a "prefix tree"). Each node holds a map from a letter to a child node, plus a boolean flag isWord marking "a stored word ends exactly here." A word is a path from the root: bad is root → b → a → d, and the final d node has isWord = true. Words that share a prefix share that part of the path — bad and be both start at the same b node, then split.
Searching a literal word is just walking the path: follow b, then a, then d, and check isWord at the end. The wildcard is the twist. At a ., you don't know which edge to take — so you try all of them, and the query succeeds if any branch reaches a word-ending node with the rest of the query consumed. That "try all children" step is what turns a linear walk into a recursive, branching search.
Attempt 1 — keep a flat list and scan it. The obvious structure is just an array of the words you've added. To search, compile the query into something that respects . and test each word against it:
function wordFinder() {
const words = [];
return {
addWord(word) {
words.push(word);
},
search(query) {
return words.some((w) => {
if (w.length !== query.length) return false; // length must match
for (let i = 0; i < query.length; i++) {
if (query[i] !== '.' && query[i] !== w[i]) return false;
}
return true;
});
},
};
}
This is actually correct. It returns the right answers for every example. The problem is cost. Every search walks the entire dictionary — O(N × L) for N words of length L, on every single query. The whole point of the problem (and of a dictionary structure) is that you load once and query a lot; a per-query full scan throws away all the structure that overlapping words give you. With 100,000 words and thousands of queries, this crawls.
Attempt 2 — a trie, but loop the search instead of recursing. So you build the trie. addWord is a clean walk. But then you reach search and try to write it as a plain loop, advancing one node pointer per character:
search(query) {
let node = root;
for (const c of query) {
if (c === '.') {
// ??? which child do we follow? there could be several
node = firstChildOf(node); // pick one and hope
} else {
if (!node.children[c]) return false;
node = node.children[c];
}
}
return node.isWord;
}
A single node pointer can only be in one place at a time. At a . there may be several children, and you can't know which one leads to a match without looking ahead. Commit to the first child and you'll return false for .ad whenever the alphabetically-first branch happens to dead-end before the others. The wildcard fundamentally needs to explore multiple positions at once — which a single pointer in a loop can't express. That's the missing piece: at a ., recurse into every child and let any success bubble up.
function wordFinder() {
// A trie node: a map of letter → child node, plus an end-of-word flag.
function makeNode() {
return { children: Object.create(null), isWord: false };
}
const root = makeNode();
function addWord(word) {
let node = root;
for (const c of word) {
// Create the child lazily the first time we walk through this letter.
if (!node.children[c]) node.children[c] = makeNode();
node = node.children[c];
}
node.isWord = true; // mark where the word ends
}
// Recursive matcher. `i` is how many chars of `query` we've consumed so far.
function match(node, query, i) {
if (i === query.length) {
// Whole query consumed — this is a hit only if a word ends right here.
return node.isWord;
}
const c = query[i];
if (c === '.') {
// Wildcard: succeed if ANY child can match the rest of the query.
for (const key in node.children) {
if (match(node.children[key], query, i + 1)) return true;
}
return false;
}
// Literal letter: the path only continues if this exact child exists.
const next = node.children[c];
return next ? match(next, query, i + 1) : false;
}
function search(query) {
return match(root, query, 0);
}
return { addWord, search };
}
module.exports = { wordFinder };
The shift from the naive attempts is the recursion. match takes a node and a position in the query, and answers "can the rest of the query be matched starting here?" Three cases drive it, and the next diagram is exactly those three cases.
A few choices are worth calling out. Object.create(null) for children makes a bare map with no prototype, so a child keyed 'constructor' or 'toString' can't collide with an inherited property — and if (!node.children[c]) can't be fooled by a method living on Object.prototype. isWord is separate from "has children." A node can be a word and have children (be and bee): be's final e node has isWord = true and still has a b→e→e continuation under it. Checking children-emptiness instead of the flag would get this wrong. The base case checks isWord, not "did we run out of nodes." Running out of query at a non-word node (the ba prefix of bad) must return false — the flag is the only thing that says "a real word ends here."
Trace search('.ad') against a trie holding bad, dad, mad. The call is match(root, '.ad', 0).
match(root, '.ad', 0) i=0, c='.' → wildcard: try every child of root
root.children = { b, d, m }
├─ match(node_b, '.ad', 1) i=1, c='a' → literal: node_b has child 'a'? yes
│ └─ match(node_ba, '.ad', 2) i=2, c='d' → literal: child 'd'? yes
│ └─ match(node_bad, '.ad', 3) i=3 === len → return node_bad.isWord = TRUE
│ ← bubbles up true
└─ first branch returned true → the '.' loop short-circuits, returns true
search('.ad') === true
The wildcard at i=0 fans out into b, d, m. We happen to try b first; it walks b→a→d, reaches i === 3 at the bad node, finds isWord === true, and returns true. That true propagates straight back up — the for...in loop over root's children stops at the first success and never even tries d or m. Had b dead-ended (say only dad and mad were stored), the loop would have moved on to the next child and tried again.
Now contrast search('..') on a trie holding only a. match(root, '..', 0): c='.', try every child — root's only child is a, so recurse match(node_a, '..', 1). Now c='.' again, but node_a has no children (the word was just a), so the for...in loop runs zero times and returns false. Back at the top, that was the only branch, so the whole search is false. The length guard falls out for free: a query longer than every path can't consume itself at a word-ending node.
isWord as "node has no children." A node can end a word and continue to longer words — be and bee share the b→e path, so the e node is both a word end and a parent. If search returns node.isWord but you set word-ends by checking "is this a leaf," be becomes unfindable once bee is added. Always store an explicit isWord flag and set it on the final node of every addWord.true at end-of-query without checking the flag. If the base case is if (i === query.length) return true, then search('ba') against bad wrongly returns true — you consumed the query at a prefix node that isn't a word. The end of the query is necessary but not sufficient; the node must also have isWord === true... This is the Attempt-2 bug. A . must for...in over every child and return true if any recursion succeeds. Commit to one child and you'll miss matches whenever an earlier branch dead-ends before a later one would have matched (.ad returns false if b… is explored first and only dad/mad exist).'.' is exactly one letter, so a correct matcher never lets a query of length 3 match a stored word of length 2 (or vice versa). With the recursive match, this is automatic — you only return node.isWord when i has reached the full query length — but a hand-rolled comparison must special-case it (the naive list does, with w.length !== query.length).{} for children and keying with reserved names. node.children['constructor'] on a plain object is truthy before you ever add it (it's inherited from Object.prototype), so if (node.children[c]) can lie. Inputs here are a–z so it won't bite in practice, but Object.create(null) (or a Map) removes the trap entirely and is the habit worth keeping.wordFinder() call must build its own root. If root lived in module scope instead of inside the factory, two wordFinder() objects would see each other's words. Keep all mutable state captured in the closure, created fresh per call.....) over a large dictionary explores every path of that length — worst-case O(26^k) for k dots. Production engines bound this by tracking the set of valid lengths up front (skip the search entirely if no stored word has the query's length) or by switching to a DAWG / radix tree that merges shared suffixes too, shrinking the branching.* wildcard (zero or more letters). That changes the matcher from "advance exactly one node per query char" to a small regex engine: at * you'd try matching zero letters (skip it) and one-or-more (consume a child and stay on *). It's the jump from .-glob to full pattern matching — and the reason real engines lean on a compiled NFA.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement wordFinder() — a factory that returns a small data structure for storing words and looking them up, where a query may contain the wildcard character . that matches any single letter. It is the classic autocomplete / spell-check building block: you load a dictionary once, then run many membership queries against it, some of which have "holes" you want filled by any letter. This is not a grid or path problem — there is no board and no adjacency. You are designing the container and its two operations.
// wordFinder() returns an object with two methods:
interface WordFinder {
// Insert a word into the structure. Words are non-empty, lowercase a–z.
// The same word may be added more than once; that's a no-op.
addWord(word: string): void;
// Return true if some stored word matches `query` exactly (same length).
// A '.' in `query` matches ANY single letter. Other chars match literally.
search(query: string): boolean;
}
function wordFinder(): WordFinder;
const wf = wordFinder();
wf.addWord('bad');
wf.addWord('dad');
wf.addWord('mad');
wf.search('pad'); // → false (no stored word "pad")
wf.search('bad'); // → true (exact literal match)
wf.search('.ad'); // → true ('.' matches b/d/m → "bad"/"dad"/"mad")
wf.search('b..'); // → true ("bad")
// The match is length-exact: '.' is exactly one letter, never zero or many.
const wf = wordFinder();
wf.addWord('a');
wf.search('a'); // → true
wf.search('.'); // → true (one wildcard, one stored letter)
wf.search(''); // → false (no empty word was added)
wf.search('..'); // → false ("a" has length 1, query has length 2)
. matches exactly one letter — never zero, never a run. search('..') only matches stored words of length two. There is no *-style "any number of letters" wildcard here.search('ba') against a stored 'bad' is false. The query length must equal the matched word's length.search on anything before any addWord returns false, including search('').a–z in addWord; queries are lowercase a–z plus .. You do not need to validate input, handle uppercase, or handle other punctuation.addWord is called many times, then search many times. Favour a structure that makes wildcard search fast over one that re-scans the whole dictionary per query — see the solution.* wildcard. Those are out of scope.You'll build a small dictionary you can load once and then query many times, where a query may carry the wildcard . that stands in for any single letter.
Picture a spell-checker or a crossword helper. You have a pile of valid words, and you keep asking "is this a word?" — except some of your letters are missing. You know the answer is three letters, ends in ad, and the first letter could be anything: .ad. You want a true/false back, fast, and you'll ask thousands of these. The wildcard . matches exactly one letter — .ad matches bad, dad, mad, but never ad (too short) or bbad (too long). The match is whole-word: the query has to line up with a stored word end to end.
Two operations: addWord loads a word into the structure, search answers a (possibly wildcarded) membership query. The interesting part is making the wildcard fast — a . can match many letters, so the search has to be able to fan out.
The right structure is a trie (a "prefix tree"). Each node holds a map from a letter to a child node, plus a boolean flag isWord marking "a stored word ends exactly here." A word is a path from the root: bad is root → b → a → d, and the final d node has isWord = true. Words that share a prefix share that part of the path — bad and be both start at the same b node, then split.
Searching a literal word is just walking the path: follow b, then a, then d, and check isWord at the end. The wildcard is the twist. At a ., you don't know which edge to take — so you try all of them, and the query succeeds if any branch reaches a word-ending node with the rest of the query consumed. That "try all children" step is what turns a linear walk into a recursive, branching search.
Attempt 1 — keep a flat list and scan it. The obvious structure is just an array of the words you've added. To search, compile the query into something that respects . and test each word against it:
function wordFinder() {
const words = [];
return {
addWord(word) {
words.push(word);
},
search(query) {
return words.some((w) => {
if (w.length !== query.length) return false; // length must match
for (let i = 0; i < query.length; i++) {
if (query[i] !== '.' && query[i] !== w[i]) return false;
}
return true;
});
},
};
}
This is actually correct. It returns the right answers for every example. The problem is cost. Every search walks the entire dictionary — O(N × L) for N words of length L, on every single query. The whole point of the problem (and of a dictionary structure) is that you load once and query a lot; a per-query full scan throws away all the structure that overlapping words give you. With 100,000 words and thousands of queries, this crawls.
Attempt 2 — a trie, but loop the search instead of recursing. So you build the trie. addWord is a clean walk. But then you reach search and try to write it as a plain loop, advancing one node pointer per character:
search(query) {
let node = root;
for (const c of query) {
if (c === '.') {
// ??? which child do we follow? there could be several
node = firstChildOf(node); // pick one and hope
} else {
if (!node.children[c]) return false;
node = node.children[c];
}
}
return node.isWord;
}
A single node pointer can only be in one place at a time. At a . there may be several children, and you can't know which one leads to a match without looking ahead. Commit to the first child and you'll return false for .ad whenever the alphabetically-first branch happens to dead-end before the others. The wildcard fundamentally needs to explore multiple positions at once — which a single pointer in a loop can't express. That's the missing piece: at a ., recurse into every child and let any success bubble up.
function wordFinder() {
// A trie node: a map of letter → child node, plus an end-of-word flag.
function makeNode() {
return { children: Object.create(null), isWord: false };
}
const root = makeNode();
function addWord(word) {
let node = root;
for (const c of word) {
// Create the child lazily the first time we walk through this letter.
if (!node.children[c]) node.children[c] = makeNode();
node = node.children[c];
}
node.isWord = true; // mark where the word ends
}
// Recursive matcher. `i` is how many chars of `query` we've consumed so far.
function match(node, query, i) {
if (i === query.length) {
// Whole query consumed — this is a hit only if a word ends right here.
return node.isWord;
}
const c = query[i];
if (c === '.') {
// Wildcard: succeed if ANY child can match the rest of the query.
for (const key in node.children) {
if (match(node.children[key], query, i + 1)) return true;
}
return false;
}
// Literal letter: the path only continues if this exact child exists.
const next = node.children[c];
return next ? match(next, query, i + 1) : false;
}
function search(query) {
return match(root, query, 0);
}
return { addWord, search };
}
module.exports = { wordFinder };
The shift from the naive attempts is the recursion. match takes a node and a position in the query, and answers "can the rest of the query be matched starting here?" Three cases drive it, and the next diagram is exactly those three cases.
A few choices are worth calling out. Object.create(null) for children makes a bare map with no prototype, so a child keyed 'constructor' or 'toString' can't collide with an inherited property — and if (!node.children[c]) can't be fooled by a method living on Object.prototype. isWord is separate from "has children." A node can be a word and have children (be and bee): be's final e node has isWord = true and still has a b→e→e continuation under it. Checking children-emptiness instead of the flag would get this wrong. The base case checks isWord, not "did we run out of nodes." Running out of query at a non-word node (the ba prefix of bad) must return false — the flag is the only thing that says "a real word ends here."
Trace search('.ad') against a trie holding bad, dad, mad. The call is match(root, '.ad', 0).
match(root, '.ad', 0) i=0, c='.' → wildcard: try every child of root
root.children = { b, d, m }
├─ match(node_b, '.ad', 1) i=1, c='a' → literal: node_b has child 'a'? yes
│ └─ match(node_ba, '.ad', 2) i=2, c='d' → literal: child 'd'? yes
│ └─ match(node_bad, '.ad', 3) i=3 === len → return node_bad.isWord = TRUE
│ ← bubbles up true
└─ first branch returned true → the '.' loop short-circuits, returns true
search('.ad') === true
The wildcard at i=0 fans out into b, d, m. We happen to try b first; it walks b→a→d, reaches i === 3 at the bad node, finds isWord === true, and returns true. That true propagates straight back up — the for...in loop over root's children stops at the first success and never even tries d or m. Had b dead-ended (say only dad and mad were stored), the loop would have moved on to the next child and tried again.
Now contrast search('..') on a trie holding only a. match(root, '..', 0): c='.', try every child — root's only child is a, so recurse match(node_a, '..', 1). Now c='.' again, but node_a has no children (the word was just a), so the for...in loop runs zero times and returns false. Back at the top, that was the only branch, so the whole search is false. The length guard falls out for free: a query longer than every path can't consume itself at a word-ending node.
isWord as "node has no children." A node can end a word and continue to longer words — be and bee share the b→e path, so the e node is both a word end and a parent. If search returns node.isWord but you set word-ends by checking "is this a leaf," be becomes unfindable once bee is added. Always store an explicit isWord flag and set it on the final node of every addWord.true at end-of-query without checking the flag. If the base case is if (i === query.length) return true, then search('ba') against bad wrongly returns true — you consumed the query at a prefix node that isn't a word. The end of the query is necessary but not sufficient; the node must also have isWord === true... This is the Attempt-2 bug. A . must for...in over every child and return true if any recursion succeeds. Commit to one child and you'll miss matches whenever an earlier branch dead-ends before a later one would have matched (.ad returns false if b… is explored first and only dad/mad exist).'.' is exactly one letter, so a correct matcher never lets a query of length 3 match a stored word of length 2 (or vice versa). With the recursive match, this is automatic — you only return node.isWord when i has reached the full query length — but a hand-rolled comparison must special-case it (the naive list does, with w.length !== query.length).{} for children and keying with reserved names. node.children['constructor'] on a plain object is truthy before you ever add it (it's inherited from Object.prototype), so if (node.children[c]) can lie. Inputs here are a–z so it won't bite in practice, but Object.create(null) (or a Map) removes the trap entirely and is the habit worth keeping.wordFinder() call must build its own root. If root lived in module scope instead of inside the factory, two wordFinder() objects would see each other's words. Keep all mutable state captured in the closure, created fresh per call.....) over a large dictionary explores every path of that length — worst-case O(26^k) for k dots. Production engines bound this by tracking the set of valid lengths up front (skip the search entirely if no stored word has the query's length) or by switching to a DAWG / radix tree that merges shared suffixes too, shrinking the branching.* wildcard (zero or more letters). That changes the matcher from "advance exactly one node per query char" to a small regex engine: at * you'd try matching zero letters (skip it) and one-or-more (consume a child and stay on *). It's the jump from .-glob to full pattern matching — and the reason real engines lean on a compiled NFA.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.