Start learning free

LESSON 430 OF 539 · TRIES

Word Search II in C#: Trie and Backtracking DFS on the Board

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

    C# · TRIES

    Word Search Ii in C#, explained

    Problem overview

    FindWords receives a grid of letters, char[][] board, and a string[] words. It returns, as an IList<string>, 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. A cell can be used only once within one word. Each word is reported once, however many paths spell it. The lesson's tests list the answer in alphabetical order.

    On the board with rows oaan, etae, ihkr, iflv and the words oath, pea, eat, rain, the answer is eat and oath. There is no p on the board, and no a is next to the r, so the other two can't be traced. On the board ab / cd, the word abdb fails because it would reuse the cell b, and ad fails because a and d touch only at a corner.

    Approach: one search for all the words

    The simple plan is to search the board once per word: start a depth-first search at every cell and try to spell that word. It works, but words that begin the same way, like see, seen and set, repeat the same paths again and again, and the lesson allows up to 30,000 words.

    A trie (prefix tree) stores all the words so that words with a common beginning share one path from the root. Walk the board and the trie together: a search from a cell may step onto a neighbour only if the trie has a child for that neighbour's letter. Paths that start no word are cut off immediately, and one search from each cell looks for every word at once. With words of at most L letters, a search goes at most L cells deep and branches at most 4 ways, so the board work is O(R * C * 4^L) in the worst case, whatever the number of words. Building the trie costs time proportional to the total number of letters in the words.

    Step by step

    1. Trie node field children: a public Dictionary<char, TrieNode>, created empty. It maps each next letter to its child node. Fields are private unless marked public, and Solution must reach it.
    2. Trie node field word: a public string, set to null. On the node where a word ends it holds the word itself, so the search can add it to the answer the moment it gets there.
    3. Create the root: TrieNode root = new TrieNode(). It stands for the empty prefix.
    4. Insert every word with a foreach over words. For each one, start node at root and walk its letters with an inner foreach.
    5. For each letter, if node.children has no key c (ContainsKey), store a new TrieNode under it; then move down with node = node.children[c].
    6. After the letters, set node.word = word.
    7. Collect results in List<string> found, a new empty list. A List<string> is an IList<string>, so it can be returned as is.
    8. Start a search from every cell: loop row from 0 while row < board.Length, and col from 0 while col < board[0].Length, calling Dfs(board, row, col, root, found). Return found at the end.
    9. In Dfs, leave the board: if row or col is outside the grid, return. The four neighbour calls below don't check their targets, so edge cells send some calls off the board.
    10. Read the letter: char letter = board[row][col]. It is needed again at the end.
    11. Stop if the trie can't continue: if node.children has no key letter, return. This check keeps the search small.
    12. Step down the trie: TrieNode child = node.children[letter].
    13. A word ends here: if child.word is not null, Add it to found and set child.word back to null. A word can often be traced along several paths, and clearing it means it is added only once.
    14. Mark the cell as used by setting board[row][col] to '#'. No word contains #, so a path that comes back to this cell stops at the children check.
    15. Search the four neighbours (row + 1, row - 1, col + 1, col - 1), each time passing child, not node, because the path now includes this cell.
    16. Put the letter back: board[row][col] = letter. The cell is free again for other paths. Undoing the mark on the way out is what makes this backtracking.

    The solution in C#

    class TrieNode {
        public Dictionary<char, TrieNode> children = new Dictionary<char, TrieNode>();
        public string word = null;
    }
    
    class Solution {
        public IList<string> FindWords(char[][] board, string[] words) {
            TrieNode root = new TrieNode();
            foreach (string word in words) {
                TrieNode node = root;
                foreach (char c in word) {
                    if (!node.children.ContainsKey(c)) {
                        node.children[c] = new TrieNode();
                    }
                    node = node.children[c];
                }
                node.word = word;
            }
            List<string> found = new List<string>();
            for (int row = 0; row < board.Length; row++) {
                for (int col = 0; col < board[0].Length; col++) {
                    Dfs(board, row, col, root, found);
                }
            }
            return found;
        }
    
        void Dfs(char[][] board, int row, int col, TrieNode node, List<string> found) {
            if (row < 0 || row >= board.Length || col < 0 || col >= board[0].Length) {
                return;
            }
            char letter = board[row][col];
            if (!node.children.ContainsKey(letter)) {
                return;
            }
            TrieNode child = node.children[letter];
            if (child.word != null) {
                found.Add(child.word);
                child.word = null;
            }
            board[row][col] = '#';
            Dfs(board, row + 1, col, child, found);
            Dfs(board, row - 1, col, child, found);
            Dfs(board, row, col + 1, child, found);
            Dfs(board, row, col - 1, child, found);
            board[row][col] = letter;
        }
    }
    

    C# details worth knowing:

    • char[][] is a jagged array: an array of rows, each row a char[], indexed board[row][col]. board.Length is the number of rows, and board[0].Length the number of columns of the first row. Don't confuse it with char[,], which is indexed board[row, col].
    • char is a single UTF-16 code unit, written with single quotes ('#'). A foreach over a string yields its char values, which is how the insert loop reads a word.
    • Reading a missing key throws. node.children[c] on an absent key raises KeyNotFoundException. Both lookups in the code come right after a ContainsKey check or the insertion of that key. TryGetValue would do the check and the lookup in one step.
    • The board is changed in place and restored. Arrays are reference types, so Dfs works on the caller's board. Every '#' is replaced by its letter before the call returns, so after FindWords the board is as it was.
    • The trie is consumed. Setting child.word to null changes the trie, which is fine because it is local to one FindWords call.
    • The result order follows the search, not the alphabet, so a program that wants sorted output must sort. found.Sort() sorts the list in place; string.CompareOrdinal or StringComparer.Ordinal sort lowercase letters in plain alphabetical order.

    Complexity

    Time. Building the trie costs O(total letters in words). Each search starts at one of R * C cells and goes down paths of at most L cells, where L is the longest word, with at most 4 choices at the first cell and 3 afterwards (the cell just left is marked). That bounds the board work by O(R * C * 4^L). In practice the trie cuts most branches early, since a path continues only while it is a prefix of some word.

    Space. The trie takes O(total letters in words) in the worst case. The recursion is at most L deep. The answer list holds at most one entry per word.

    Edge cases and common mistakes

    • Reusing a cell in one word. On the board ab / cd, abdb returns nothing and the result is empty, because b is marked '#' when the path comes back to it.
    • Diagonal neighbours. ad on the same board is not traced: only four directions count.
    • The same word along several paths. With the board see / net, the word see has two paths, yet it appears once, thanks to clearing child.word.
    • A word that is a prefix of another. see and seen both come out: the search reports see at its node and keeps going down the same path.
    • Forgetting to restore the cell. If board[row][col] keeps the '#', other paths can no longer cross that cell and words are missed.
    • Passing node instead of child to the neighbour calls. The next cell would be matched against the same level of the trie, so the search would follow the wrong letters.
    • A word longer than the number of cells. On a board with one row aa, the word aaa can't be traced, because it would need a third cell.
    • Single cell. On a 1x1 board holding a, the word a is found.

    Testing

    Running FindWords on the boards below (answers sorted alphabetically) gives:

    board rowswordsResult
    oaan, etae, ihkr, iflvoath, pea, eat, raineat, oath
    ab, cdabdb, adempty
    see, netsee, seen, set, net, tee, teennet, see, seen, tee
    aaa
    aaaaaempty

    After searching ab / cd for the word abdc, the board still reads ab / cd.

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