Start learning free

LESSON 431 OF 540 · TRIES

Word Search II in TypeScript: Trie and Backtracking in O(R · C · 4^L)

Practice O(R · C · 4L) Read the full explanation

Given a grid of letters board with R rows and C columns, and an array of strings words, return every word of words that can be traced on the board.

A word is traced by a path of cells: its first letter is in some cell, and each next letter is in a cell next to the previous one, horizontally or vertically (not diagonally). A cell can be used only once in the same word.

Return each word found once, in any order: the output lists the words in alphabetical order. If no word can be traced, the answer is empty.

 

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"]
Explanation: "oath" starts at the top-left "o", goes right to "a", down to "t" and down again to "h".
"eat" starts at the "e" at the right end of the second row, then goes left to "a" and left again to "t".
The board has no "p", so "pea" can't be traced, and no "a" is next to the "r", so neither can "rain".

Example 2:

Input: board = [
  ["a", "b"],
  ["c", "d"]
], words = ["abdb", "ad"]
Output: []
Explanation: "abdb" would go a → b → d and then back to "b", but that cell is already used in this word.
"a" and "d" touch only at a corner, so "ad" can't be traced either.

Example 3:

Input: board = [
  ["s", "e", "e"],
  ["n", "e", "t"]
], words = ["see", "seen", "set", "net", "tee", "teen"]
Output: ["net", "see", "seen", "tee"]
Explanation: "see" can be traced in two ways (its last "e" is either to the right or below), but it is listed once.
"seen" goes s → e → e (the one below) → n, so the path of "see" is the start of the path of "seen".
"set" can't be traced: the only "e" next to "s" is not next to "t". Neither can "teen": in every path t → e → e, the second "e" is the top middle one, which is not next to "n".

 

Constraints:

  • 1 <= R, C <= 12
  • Every cell of board holds a lowercase English letter.
  • 1 <= words.length <= 3 * 104
  • 1 <= words[i].length <= 10
  • words[i] consists of lowercase English letters, and all the words are different.

Note:

Searching the board once for every word repeats the same work for words that start the same way. Put the words in a trie instead, and search the board once: a depth-first search from each cell follows only the trie's paths. With words of at most L letters, each search goes at most L cells deep, so the time is O(R * C * 4L), plus the time to build the trie, whatever the number of words.

STEP 1 OF 25
    Ctrl + Enter to check

    TYPESCRIPT · TRIES

    Word Search Ii in TypeScript, explained

    Problem overview

    findWords receives a grid of lowercase letters, board, and an array of strings, words. It returns every word that can be traced on the board. A word is traced by a path of cells: the first letter is in some cell, and each next letter is in a cell directly above, below, left or right of the previous one (no diagonals). A cell can be used only once within one word. Each word is reported once, in any order, and the result is empty when nothing can be traced.

    For the board oaan / etae / ihkr / iflv and the words ["oath", "pea", "eat", "rain"], the answer is ["eat", "oath"] once sorted. "oath" goes from the top-left o right to a, then down twice to t and h. There is no p on the board, and no a touches the r.

    Approach: one search for all the words

    The direct method runs a path search from every cell for every word. That repeats a lot: "oath" and "oat" share their first three cells, but each gets its own walk. With up to 30,000 words, the repeated work adds up.

    A trie removes the repetition. It stores all the words as paths of letters from a shared root, so words with the same beginning share the same nodes. We then run one depth-first search from each cell, and at every step follow the trie as well as the board. If the current letter isn't a child of the current trie node, no word continues this way and the search backs off immediately. When we reach a node where a word ends, we report it.

    Words are at most L letters long, so a path goes at most L cells deep and branches in at most four directions: O(R · C · 4^L) in the worst case, plus the time to build the trie, however many words there are.

    Step by step

    1. Trie node. TrieNode has children, a Record<string, TrieNode> (an object from letters to nodes) that starts as {}, and word, a string | null that starts as null. A node where a word ends stores the word itself instead of a flag, so the search can report it the moment a path arrives.
    2. Build the trie. Create root, the node of the empty prefix. For each word, start node at root and walk down one letter at a time. If the letter isn't in node.children, create a child for it. Then move to that child. After the last letter, set node.word = word.
    3. The answer list. Create found, an empty string[] that all the searches add to.
    4. Start everywhere. Loop over every row and every col, using board[0].length for the number of columns, and call this.dfs(board, row, col, root, found). Since root holds all the words, one call looks for all of them. After the loops, return found.
    5. Off the board. In dfs, return when the row or column is out of range. The recursive calls step to all four neighbours without checking, so some of them land outside.
    6. Follow the trie. Read the cell's letter. If it isn't in node.children, return: no word continues this way. Otherwise move down: child = node.children[letter].
    7. Report a word. If child.word !== null, push it onto found and set child.word back to null. A word can often be traced along several paths, and clearing it means the next path adds nothing, so each word appears once.
    8. Mark the cell. Set board[row][col] = '#'. No word contains #, so a path that comes back to this cell stops at the check on node.children. That enforces "each cell once per word".
    9. Explore. Call dfs for the cell below, above, right and left, each time passing child as the node, because the path now includes this cell.
    10. Backtrack. Put the letter back: board[row][col] = letter. The cell is free for other paths.

    The solution in TypeScript

    class TrieNode {
        children: Record<string, TrieNode> = {};
        word: string | null = null;
    }
    
    class Solution {
        findWords(board: string[][], words: string[]): string[] {
            const root: TrieNode = new TrieNode();
            for (const word of words) {
                let node: TrieNode = root;
                for (const c of word) {
                    if (!(c in node.children)) {
                        node.children[c] = new TrieNode();
                    }
                    node = node.children[c];
                }
                node.word = word;
            }
            const found: string[] = [];
            for (let row = 0; row < board.length; row++) {
                for (let col = 0; col < board[0].length; col++) {
                    this.dfs(board, row, col, root, found);
                }
            }
            return found;
        }
    
        dfs(board: string[][], row: number, col: number, node: TrieNode, found: string[]): void {
            if (row < 0 || row >= board.length || col < 0 || col >= board[0].length) {
                return;
            }
            const letter: string = board[row][col];
            if (!(letter in node.children)) {
                return;
            }
            const child: TrieNode = node.children[letter];
            if (child.word !== null) {
                found.push(child.word);
                child.word = null;
            }
            board[row][col] = '#';
            this.dfs(board, row + 1, col, child, found);
            this.dfs(board, row - 1, col, child, found);
            this.dfs(board, row, col + 1, child, found);
            this.dfs(board, row, col - 1, child, found);
            board[row][col] = letter;
        }
    }
    

    TypeScript details

    • Record<string, TrieNode> is an ordinary object used as a dictionary. The in operator tests whether a key exists, and it also sees keys inherited from Object.prototype. That is harmless here because every key is a single letter or #, and no inherited property has a one-character name.
    • for (const c of word) walks a string character by character. For lowercase English letters, that is one letter per step.
    • child.word !== null compares with null explicitly. An empty string would be falsy, but the words here are never empty, and the explicit test says what it means.
    • The method modifies board while searching and restores every cell it changes, so the grid is the same after the call. Running the solution on the lesson's examples leaves each board unchanged.
    • Types are erased at run time. string[][] checks nothing about what is in the grid; the solution relies on the cells being one-letter strings.
    • found lists words in the order the search meets them, which is not alphabetical. For the first example the raw result is ["oath", "eat"]. Sort the result, for instance with [...found].sort() (strings sort correctly with the default comparator), if you need alphabetical order.

    Complexity

    Time: building the trie takes O(W · L) for W words of up to L letters. The search starts from R · C cells. Each path is at most L cells long and each step has at most four directions to try (three after the first, since the cell we came from is marked), so O(R · C · 4^L) is a safe bound. In practice the trie cuts most branches early, because a path survives only while it spells the start of some word.

    Space: the trie has at most W · L nodes. The recursion goes at most L deep, and the marking trick needs no extra visited grid.

    Edge cases and common mistakes

    • Forgetting to restore the cell. Without board[row][col] = letter, cells stay marked as # and later paths fail.
    • Reusing a cell in a word. On the board ab / cd, the word abdb would need the b twice. The marker blocks this, so the result is empty.
    • Diagonal neighbours. On that same board, a and d touch only at a corner, so ad is not found.
    • Duplicates in the answer. On the board see / net, the word see has two paths. Clearing child.word after the first report keeps it in the result once.
    • A word that is a prefix of another. see and seen share nodes, and both are found: the word is reported when the path reaches its node, and the search continues past it.
    • Very short boards. On a 1 by 1 board holding a, the word a is found. On the board aa, the words aaa, aa and a give aa and a, since only two cells exist.

    Testing

    The solution's results, sorted, for a few boards (rows written as strings):

    BoardWordsResult
    oaan, etae, ihkr, iflvoath, pea, eat, rain["eat","oath"]
    ab, cdabdb, ad[]
    see, netsee, seen, set, net, tee, teen["net","see","seen","tee"]
    aa["a"]
    aaaaa, aa, a["a","aa"]

    A 12 by 12 board of a with the words aaaaaaaaaa (ten letters) and aaaaaaaaa (nine) also finishes in about a tenth of a second and finds both.

    Keep going with TypeScript500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free