Question: Word search with a word search II follow-up
Companies: Glean, Amazon, Snowflake, Uber
Difficulty: Hard
Problem statement
Find words on a letter grid by adjacent moves, then extend the solution to finding many words at once efficiently. Reported on 1point3acres' Glean bank; reconstructed in our own words.
Clarify the setup
A grid of letters. A word is found if it can be spelled by a path of cells that are horizontally or vertically adjacent, using each cell at most once in that path.
-
Part 1:
exists(board, word) -> bool. - Part 2: given a list of many words, return all that appear.
Ask: are diagonals allowed (no, unless stated)? Case-sensitive (assume yes)? Can the board be empty? In part 2, can the word list contain duplicates (return each found word once)?
Approach
Part 1: backtracking DFS. Start from every cell matching word[0]. From a cell at position i of the word, try the four neighbors for word[i + 1]. Mark the current cell as used (overwrite it with a sentinel such as "#") while exploring and restore it on the way back. That avoids a separate visited set and keeps memory at O(len(word)) for the recursion.
Cheap pruning worth mentioning:
- If the word is longer than the number of cells, or if the board lacks enough copies of some letter (compare letter counts), return
Falsebefore searching. - If the word's last letter is rarer on the board than its first, search the reversed word: fewer starting points, same answer.
Cost: O(R × C × 3^L) in the worst case (four directions for the first step, then at most three, since you cannot go back), with L the word length.
Part 2: one search for all words with a trie. Running part 1 once per word repeats the same exploration for words that share prefixes. Instead:
- Insert every word into a trie; store the full word at its end node.
- DFS from every cell, walking the trie alongside the board. Stop as soon as the path is not a prefix of any word.
- When a node holding a word is reached, record the word and clear it from the node, so it is reported once.
- Prune as you return: if a child node has no word and no children left, delete it from its parent. Over time the trie shrinks to the words not yet found, and searches that can no longer lead anywhere stop early. This is what makes the solution fast on big word lists.
Code
from collections import Counter
from typing import Dict, List
DIRS = ((1, 0), (-1, 0), (0, 1), (0, -1))
def exists(board: List[List[str]], word: str) -> bool:
if not word:
return True
if not board or not board[0]:
return False
rows, cols = len(board), len(board[0])
if len(word) > rows * cols:
return False
have = Counter(ch for row in board for ch in row)
need = Counter(word)
if any(have[ch] < n for ch, n in need.items()):
return False
if have[word[0]] > have[word[-1]]:
word = word[::-1] # start from the rarer end
def dfs(r: int, c: int, i: int) -> bool:
if board[r][c] != word[i]:
return False
if i == len(word) - 1:
return True
saved, board[r][c] = board[r][c], "#"
try:
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and dfs(nr, nc, i + 1):
return True
return False
finally:
board[r][c] = saved # restore even on early return
return any(dfs(r, c, 0) for r in range(rows) for c in range(cols))
class _Node:
__slots__ = ("children", "word")
def __init__(self) -> None:
self.children: Dict[str, "_Node"] = {}
self.word = ""
def find_words(board: List[List[str]], words: List[str]) -> List[str]:
if not board or not board[0]:
return []
root = _Node()
for w in words:
node = root
for ch in w:
node = node.children.setdefault(ch, _Node())
node.word = w
rows, cols = len(board), len(board[0])
found: List[str] = []
def dfs(r: int, c: int, parent: _Node) -> None:
ch = board[r][c]
node = parent.children.get(ch)
if node is None:
return
if node.word:
found.append(node.word)
node.word = "" # report once
board[r][c] = "#"
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] in node.children:
dfs(nr, nc, node)
board[r][c] = ch
if not node.children and not node.word:
del parent.children[ch] # prune the dead branch
for r in range(rows):
for c in range(cols):
if board[r][c] in root.children:
dfs(r, c, root)
return found
The recursion depth is at most the length of the longest word (part 2) or the word (part 1). For words longer than about 1,000 letters, Python's recursion limit becomes a concern; an explicit stack fixes it.
Complexity
- Part 1: O(R × C × 3^L) time worst case, O(L) extra space. The letter-count check is O(R × C + L).
- Part 2: building the trie is O(total letters). The search is O(R × C × 3^Lmax) in the worst case, but each path is explored once for all words sharing it, and pruning removes finished branches. Space O(total letters) for the trie.
- Example: a 12×12 board and 30,000 dictionary words. Calling part 1 per word is 30,000 full searches; the trie version explores each board path once for all words that share it, so the work no longer scales with the number of words.
Test cases
def _board():
return [list("ABCE"), list("SFCS"), list("ADEE")]
def test_exists_examples():
assert exists(_board(), "ABCCED")
assert exists(_board(), "SEE")
assert not exists(_board(), "ABCB") # would reuse B
assert exists(_board(), "")
assert not exists([], "A")
def test_board_is_restored():
b = _board()
exists(b, "ABCCED")
exists(b, "ABCB")
assert b == _board()
def test_pruning_paths():
assert not exists(_board(), "Z") # letter missing
assert not exists([["a", "a"]], "aaa") # not enough copies
assert exists([["a", "b"], ["a", "a"]], "aaab") # searched reversed internally
def test_find_words_example():
board = [list("oaan"), list("etae"), list("ihkr"), list("iflv")]
got = find_words(board, ["oath", "pea", "eat", "rain", "oat", "oath"])
assert sorted(got) == ["eat", "oat", "oath"]
def test_find_words_matches_single_word_search():
import random
rng = random.Random(8)
for _ in range(30):
board = [[rng.choice("abc") for _ in range(4)] for _ in range(4)]
words = list({"".join(rng.choice("abc") for _ in range(rng.randint(1, 6))) for _ in range(40)})
expected = sorted(w for w in words if exists([row[:] for row in board], w))
assert sorted(find_words([row[:] for row in board], words)) == expected
Follow-ups
- Boggle scoring. Return the total score of found words by length: the same trie search, adding points instead of collecting words.
- Very large dictionaries. A 300,000-word trie in Python dicts uses a lot of memory. Compress it (a DAWG, or a radix tree that stores strings on edges) and filter words that use letters absent from the board before inserting them.
- Many boards, one dictionary. Build the trie once and reuse it, but the pruning step mutates it. Copy-on-write or a per-search "found" set keeps the shared trie intact; you lose some pruning benefit.
- Parallelism. Different starting cells are independent apart from the shared "found" state and pruning. Split the board's starting cells across workers, each with its own trie copy, then merge results.
- Search as a product. For a search company, the same pattern (walk a trie of known terms along an input) appears in query autocomplete and in spotting entity names in documents. Mention that the trie-plus-pruning idea transfers.
Interviewer expectations
Mid-level (L4)
- Correct backtracking for part 1 with in-place marking and restoring.
- Handles reuse of cells, empty inputs and boards where the word is absent.
- States the exponential worst case.
Senior (L5)
- Part 2 with a trie, words stored at end nodes, de-duplication, and pruning of dead branches, and can explain why pruning matters.
- Adds cheap early exits (letter counts, starting from the rarer end) in part 1.
- Tests part 2 against repeated part 1 calls.
Staff (L6+)
- Discusses memory for big dictionaries (compressed tries), reusing the trie across boards without breaking pruning, and parallel search.
- Mentions recursion depth limits and an iterative version.
- Relates the technique to search-product problems such as autocomplete and entity spotting.