Algorithm

Word Search II

Trie (Prefix Tree) Pattern

Word Search II

Given an m x n board of lowercase letters and a list of words, return every word from the list that can be formed on the board.

A word is formed by a path of sequentially adjacent cells — horizontally or vertically neighbouring, never diagonally — where no cell is used more than once within a single word. Different words may reuse cells freely, since each word is traced independently.

Each found word appears once in the output regardless of how many distinct paths spell it. The order of the returned words does not matter.

CONSTRAINTS
  • m == board.length, n == board[i].length
  • 1 <= m, n <= 12
  • 1 <= words.length <= 3 × 10⁴
  • 1 <= words[i].length <= 10
  • board and words consist of lowercase English letters only
  • All words in the list are distinct
EXAMPLE 1
Input: board = [["o","a","a","n"],["e","t","a","e"],["i","h","k","r"],["i","f","l","v"]], words = ["oath","pea","eat","rain"]
Output: ["eat","oath"]
"oath" traces o(0,0), a(0,1), t(1,1), h(2,1) — each step to an orthogonal neighbour. "eat" traces e(1,3), a(1,2), t(1,1). "pea" fails because no p appears anywhere on the board. "rain" starts at r(2,3) but that cell's neighbours are e, v and k, with no a among them.
EXAMPLE 2
Input: board = [["a","b"],["c","d"]], words = ["acdb"]
Output: ["acdb"]
The path runs a(0,0) down to c(1,0), right to d(1,1), then up to b(0,1). A path may turn freely and is not required to move in a consistent direction — it only has to step between orthogonal neighbours.
EXAMPLE 3
Input: board = [["a","b"],["c","d"]], words = ["abcd"]
Output: []
After a(0,0) and b(0,1), the letter c sits at (1,0), which is diagonal from (0,1) and therefore not reachable. Diagonal adjacency does not count, which is what makes this different from a simple letter-availability check.
EXAMPLE 4
Input: board = [["a","a"]], words = ["aaa"]
Output: []
Only two cells exist and a single word cannot reuse a cell, so a three-letter word is impossible even though every letter it needs is present. The no-reuse rule is per word, and it is what forces the search to mark and unmark cells as it goes.
Can one cell be used twice within the same word?
No — within a single word each cell is used at most once. Across different words there is no restriction, since each word is searched independently from a clean board. That distinction determines where the visited marks are cleared.
If a word can be formed by several different paths, how many times does it appear in the output?
Once. This needs handling explicitly, because a straightforward search will find every path. Removing the word from the dictionary the moment it is found is the cleanest way, and it also speeds up the rest of the search.
Are diagonal moves allowed?
No, only the four orthogonal directions. Worth confirming, since a variant permitting all eight is common and changes which words are findable.
How large can the word list be relative to the board?
Up to 3 × 10⁴ words on a board of at most 144 cells. That imbalance is the whole point of the problem: the word list dwarfs the board, so any approach whose cost scales with the number of words is the wrong shape.

Look at the constraint shape before anything else, because it tells you what kind of solution is wanted. The board holds at most 144 cells. The word list holds up to 30,000 words. Those numbers are wildly asymmetric, and that asymmetry is the problem.

The natural approach, and why the numbers kill it

Solve it the way the single-word version is solved: for each word, search the board for it. From every cell, try to trace the word letter by letter, backtracking whenever a letter fails to match.

python
def find_words(board, words):
    return [w for w in words if exists(board, w)]     # one full board search per word

Count it. Each board search starts from up to M × N cells and branches up to 4 ways per step for up to L steps, giving O(M × N × 4^L) per word. Multiply by W words: O(W × M × N × 4^L). With W at 30,000 that factor alone makes it hopeless.

But the multiplier is not the real insight. Consider what those 30,000 searches actually do. Suppose the list contains "cat", "car", "care" and "cart". Each one independently walks out to some cell holding c, then looks for a neighbouring a, and each rediscovers the identical partial route. Worse, if the board has no c at all, every one of those searches fails separately, each after its own scan of all 144 cells.

Name the waste: the words share prefixes, and the search treats them as unrelated. Every shared beginning is retraced once per word that has it.

The reframe: walk the board once, against all words at once

Invert the loop. Rather than asking "where does this word appear?" for each word, walk the board once and ask, at each step, "does any word still want this prefix?"

For that question to be cheap, the words must be stored so that shared prefixes are shared structure — which is exactly what a trie provides. Each word becomes a route through a tree, one edge per character, so "cat", "car", "care" and "cart" share their first two nodes and branch only where they genuinely differ.

Now run one search over the board carrying two positions at once: where you are on the board, and where you are in the trie. Stepping to a neighbouring cell is legal only if that cell's letter is an edge out of the current trie node. When it is, both positions advance together.

The trie has stopped being storage and become an oracle. At every step it answers, in one lookup, the question that matters: is any word in the entire dictionary still consistent with the path so far? If not, abandon the path immediately — not for one word, but for all 30,000 simultaneously.

Key Insightthis is the pruning, and it is where all the speed comes from. A path is cut the moment no word shares its prefix, so a board with no c never explores past a single failed lookup, instead of failing 30,000 times independently.
The implementation

One refinement makes the code simpler: store the complete word on its terminal trie node rather than a boolean flag. Then when the search arrives at a word-ending node it can collect the answer directly, without having tracked the characters along the way.

python
def find_words(board, words):
    root = {}
    for word in words:                       # build the trie
        node = root
        for ch in word:
            node = node.setdefault(ch, {})
        node['$'] = word                     # '$' marks an end and stores the word

    rows, cols = len(board), len(board[0])
    found = []

    def dfs(r, c, node):
        ch = board[r][c]
        nxt = node.get(ch)
        if nxt is None:
            return                           # no word wants this prefix: prune

        word = nxt.pop('$', None)            # found and removed in one step
        if word:
            found.append(word)

        board[r][c] = '#'                    # mark this cell used for THIS word
        for nr, nc in ((r-1,c), (r+1,c), (r,c-1), (r,c+1)):
            if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] != '#':
                dfs(nr, nc, nxt)
        board[r][c] = ch                     # restore: other words may use this cell

        if not nxt:                          # subtree exhausted
            node.pop(ch)                     # prune it so nothing walks here again

    for r in range(rows):
        for c in range(cols):
            dfs(r, c, root)
    return found
Crucial Noteboard[r][c] = '#' before recursing and board[r][c] = ch after is the backtracking pair, and both halves are required. The mark enforces no-reuse within the current word. Restoring it afterwards is what allows a different word — or a different path for the same word — to use that cell later. Omit the restore and the board is progressively destroyed; omit the mark and a word like "aaa" can be spelled by bouncing between two cells forever.
Crucial Notenxt.pop('$', None) both retrieves the word and deletes the marker, so a word found by several distinct paths is collected exactly once. This is called sinking, and it is a deduplication step and an optimisation at the same time — later paths reaching the same node find no word there and do not report it again.
Crucial Noteif not nxt: node.pop(ch) removes trie branches that have been fully consumed. Once every word through a node has been found and sunk, the node is dead weight, and deleting it means future paths prune at that point instead of walking a subtree that can never yield anything. On adversarial inputs this is the difference between passing and timing out.

The bound is O(M × N × 4^L) — one search per starting cell, branching 4 ways for at most L steps — with the number of words gone entirely from the expression. Space is proportional to the total characters across all words, for the trie.

Worked example

Board:

text
o a a n
e t a e
i h k r
i f l v

Words: "oath", "pea", "eat", "rain".

The trie has four root branches: o, p, e and r.

Starting at (0,0), which holds 'o'. The root has an o edge, so descend; the trie is now at the prefix "o". No word ends here. Mark (0,0) as used and examine its neighbours: (0,1) holds a and (1,0) holds e. The "o" node's only child is a, so the e neighbour is rejected by a single dictionary lookup — no scanning, no per-word check.

Descend into (0,1), trie at "oa". Its neighbours are (0,0), already marked, (0,2) holding a, and (1,1) holding t. The "oa" node's only child is t, so the a neighbour fails instantly. Move to (1,1), trie at "oat". Its unmarked neighbours are (1,0) e, (1,2) a, (2,1) h. Only h is a child of "oat", so descend to (2,1), trie at "oath" — and this node carries a stored word. Collect "oath" and pop the marker so no later path can report it twice. The recursion then unwinds, restoring every cell it marked.

Starting anywhere holding 'p'. There is no p on the board, so this never arises — and note what did happen instead: the p branch of the trie was simply never entered. The naive approach would have scanned all 16 cells looking for "pea" before giving up. Here the cost of rejecting it was zero.

Starting at (1,3), which holds 'e'. The root has an e edge, so descend to "e". Neighbours are (0,3) n, (2,3) r, (1,2) a. The "e" node's only child is a, so only (1,2) survives. Descend, trie at "ea". Its unmarked neighbours include (1,1) holding t, which is a child of "ea". Descend to "eat" — a word node. Collect "eat".

"rain". The only r sits at (2,3). Its neighbours are (1,3) e, (3,3) v and (2,2) k. The "r" node's only child is a, and no neighbour holds one, so the path dies after a single step.

Result: ["oath", "eat"], in either order.

The transferable move

The idea worth extracting is loop inversion driven by the constraint shape. When one input is enormous and another is small, and the obvious solution loops over the large one running work proportional to the small one, try turning it inside out — traverse the small structure once, and consult a shared representation of the large one at each step.

The requirement is that the large collection be organised so the "is anything still consistent with what I have so far?" question is answerable in one operation. A trie does that for strings; a hash set of prefixes would do it more clumsily; other problems use sorted arrays or interval trees.

The second idea is the trie as a pruning oracle inside another search. It is not doing the searching — the depth-first walk over the board is. It is answering, at each step, whether continuing could possibly pay off. Any backtracking search with a large candidate set can be accelerated the same way, provided the candidates can be indexed by their prefixes.

Recognition signals: many patterns matched against one text or structure simultaneously. Multi-pattern string matching, censoring a document against a blocklist, autocomplete over a partially typed path, crossword solving. If the alternative is running one search per pattern, a trie over the patterns usually collapses the whole outer loop.

Train the reflex: when a search would be repeated once per item in a large collection, stop and ask whether the collection can be folded into a single structure the search consults as it goes. Searching for 30,000 words at once costs barely more than searching for one.

Interactive Strategy Visualization

Initialization

Trie starts with just a Root node.

ROOT
O(W × M × N × 4^L) Independent Search Per Word
O(M × N × 4^L) Single Traversal Against A Trie
O(M × N × 4^L) With Word Sinking And Leaf Pruning