Start learning free

LESSON 431 OF 540 · TRIES

Word Search II in Scala: Trie and Backtracking, 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

    SCALA · TRIES

    Word Search Ii in Scala, explained

    Problem overview

    findWords receives a grid of lowercase letters, board, as an Array[Array[Char]], and an Array[String] of words. It returns, as a List[String], every word that can be traced on the board. A word is traced along 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. Every word is reported once, however many paths spell it.

    On the board oaan / etae / ihkr / iflv with the words oath, pea, eat and rain, the words found are eat and oath. There is no p on the board, and no a touches the r. On the board ab / cd, abdb would need the b twice, and ad would need a diagonal step, so nothing is found.

    Approach: search the board once, guided by a trie

    The direct way is to search the board separately for each word. With up to 30,000 words, many of them starting alike (see, seen), that repeats the same path-following over and over.

    A trie fixes this. It is a tree in which each node stands for a prefix: a node's children are keyed by the next letter, and the path from the root spells a prefix. All the words go into one trie first. Then a depth-first search starts from each cell and moves to a neighbour only if the trie has a child for that cell's letter. One search from one cell looks for every word at once, and a path that doesn't continue any word is cut off immediately.

    Two details keep the answer correct:

    • Marking cells. While a path is being extended, its cells must not be reused. The search overwrites the current cell with '#', which no word contains, and writes the letter back when it leaves the cell. This is backtracking.
    • Reporting once. The node where a word ends stores the word itself. When a path reaches that node, the search adds the word to the answer and clears the stored word, so a second path to the same word finds nothing to add.

    Step by step

    1. The trie node. TrieNode gets a val children, a mutable.Map[Char, TrieNode]() from the next letter to the child node. A val only means the field always refers to the same map, and the map itself can grow.
    2. The word slot. It also gets var word: String = null, which holds the whole word on the node where a word ends. The search needs the word itself to add it to the answer.
    3. The root. In findWords, root is a new TrieNode. It stands for the empty prefix, and every word starts there.
    4. Insert every word. A for loop goes over word <- words.
    5. Start at the root. Inside it, var node = root.
    6. Walk the letters. A second loop goes over each character c of word.
    7. Create missing paths. If node.children doesn't contain c, store a new TrieNode under it.
    8. Move down: node = node.children(c).
    9. Mark the end. After the letter loop, set node.word = word.
    10. The answer buffer. After the insertion loop, create found, an empty mutable.ArrayBuffer[String](). Every search adds to this one list.
    11. Every row. A loop with row <- 0 until board.length.
    12. Every column. Inside it, col <- 0 until board(0).length. All rows have the same length, so the first row gives the number of columns.
    13. Start a search with dfs(board, row, col, root, found).
    14. Return the result with found.toList.
    15. The helper's first check. dfs returns at once when the cell is off the board. The four recursive calls step to the neighbours without checking them, so this check catches the ones that land outside.
    16. Read the cell: val letter = board(row)(col). It is needed again at the end to put the cell back.
    17. Prune. If node.children doesn't contain letter, return: no word continues this way. A used cell holds '#', which no node has as a child, so this check also stops a path from reusing a cell.
    18. Go down the trie: val child = node.children(letter).
    19. A word ends here? If child.word is not null, the path spells it.
    20. Record it with found += child.word.
    21. Clear it with child.word = null, so other paths to the same word add nothing.
    22. Mark the cell by setting board(row)(col) to '#'.
    23. Down and up. Call dfs with row + 1 and row - 1, the same col, and child as the node. The path now includes this cell, so its neighbours continue from child.
    24. Right and left. Call dfs with col + 1 and col - 1.
    25. Undo the mark: board(row)(col) = letter, so the cell is free for other paths.

    The solution in Scala

    The lesson's file already has import scala.collection.mutable at the top, which is why the code can write mutable.Map. In a file of your own, add that import first.

    class TrieNode {
        val children = mutable.Map[Char, TrieNode]()
        var word: String = null
    }
    
    class Solution {
        def findWords(board: Array[Array[Char]], words: Array[String]): List[String] = {
            val root = new TrieNode()
            for (word <- words) {
                var node = root
                for (c <- word) {
                    if (!node.children.contains(c)) {
                        node.children(c) = new TrieNode()
                    }
                    node = node.children(c)
                }
                node.word = word
            }
            val found = mutable.ArrayBuffer[String]()
            for (row <- 0 until board.length) {
                for (col <- 0 until board(0).length) {
                    dfs(board, row, col, root, found)
                }
            }
            return found.toList
        }
    
        def dfs(board: Array[Array[Char]], row: Int, col: Int, node: TrieNode, found: mutable.ArrayBuffer[String]): Unit = {
            if (row < 0 || row >= board.length || col < 0 || col >= board(0).length) {
                return
            }
            val letter = board(row)(col)
            if (!node.children.contains(letter)) {
                return
            }
            val child = node.children(letter)
            if (child.word != null) {
                found += 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
        }
    }
    

    Scala details worth knowing:

    • Arrays of arrays. board(row)(col) reads a cell, and board(row)(col) = '#' writes one. A Char is written with single quotes, '#', and a string with double quotes. Array is mutable, which is what lets the search mark and restore cells in place.
    • Looping over a string. for (c <- word) visits the characters of a String as Char values.
    • Map access. node.children(c) = new TrieNode() stores a value, and node.children(c) reads one. Reading a missing key throws a NoSuchElementException; the code always checks contains first. children.get(c) would return an Option instead.
    • null as "no word". var word: String = null is a normal String reference that points at nothing, and child.word != null compares it safely.
    • Result order. found is filled in the order the search meets the words, so the list isn't sorted: on the first example it is List(oath, eat). Sort it with .sorted if you need alphabetical order.
    • The board is restored. Every cell is set back to its letter on the way out, so after findWords the board is the same as before the call.

    Complexity

    Time: O(R * C * 4^L) plus the trie build. With L the length of the longest word, a search goes at most L cells deep and tries up to four neighbours at each step, and it starts from each of the R * C cells. The bound doesn't depend on the number of words, because the trie prunes every path that doesn't start some word. Building the trie costs one step per letter of all the words together.

    Space: the trie holds at most one node per letter of all the words, and the recursion is at most L frames deep. The answer list holds at most one entry per word.

    Edge cases and common mistakes

    • The same word along several paths. On see / net, see can be traced two ways, yet it appears once. Clearing child.word is what does that.
    • A word that is the start of another. see and seen share a path. The search records see when it reaches the second e and keeps going to find seen. Returning right after a hit would lose the longer word.
    • Forgetting to restore the cell. If the letter isn't written back, the board is damaged for every later search, and words are missed. Restoring has to happen on every exit after the mark, which here is the single line at the end.
    • Reusing a cell. Without the '#' mark, a path could go back to a cell it already used: on ab / cd, abdb would wrongly be found.
    • Diagonal steps. Only the four direct neighbours count. In ab / cd, a and d touch only at a corner, so ad isn't found.
    • One-cell boards and repeated letters. On the board a, the word a is found. On aa, the words aaa, a and aa give a and aa: the third letter would need a third cell.
    • Checking the bounds after reading. Reading board(row)(col) first and testing the indexes afterwards throws an ArrayIndexOutOfBoundsException on the edges. The bounds check comes first.

    Testing

    Results from running the solution, sorted alphabetically:

    boardwordsResult
    oaan / etae / ihkr / iflvoath, pea, eat, raineat, oath
    ab / cdabdb, adnone
    see / netsee, seen, set, net, tee, teennet, see, seen, tee
    aaa
    aaaaa, a, aaa, aa
    abab, baab, ba
    Keep going with Scala500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free