Problem overview
The method exist(board, word) receives a grid of letters, an array of rows where each row is an array of one-letter strings, and a string word. It returns true if the word can be traced through the grid and false otherwise. To trace it, start at any cell and move one step at a time to a cell directly above, below, left or right, never diagonally. The cells visited must spell the word in order, and no cell may be used twice. Letters are case-sensitive.
Take the board [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]. For "ABCCED" the path is A (0,0), B (0,1), C (0,2), C (1,2), E (2,2), D (2,1), so the answer is true. For "ABCB" the first three letters follow the top row, but the only B next to that C is the one already used, so the answer is false.
Approach: depth-first search with backtracking
The word can start anywhere, so the search tries every cell as the first letter. From a cell that matches, it moves to each of the four neighbours and asks the same question for the next letter. That is a depth-first search: follow one path as deep as it goes, and when it fails, step back and try another.
The path must not reuse a cell, so the search needs to remember which cells are on the current path. Instead of a separate table, this solution overwrites the cell with '#', which is never a letter and so never matches any letter of the word. When the search comes back from that cell, it restores the letter. This undoing is the "back" in backtracking: a path tried later is free to use the cell again.
A path stops at its first wrong letter, so most branches die after a step or two. The worst case is still large, because every cell can start a search that branches up to four ways for each letter of the word.
Step by step
exist: try every start
- Loop over rows with
rowfrom 0 whilerow < board.length. - Loop over columns inside it, with
colup toboard[0].length. - Call
this.dfswith the board, the word, the cell and index 0, the first letter. If it returnstrue, returntrueat once: one start that works is enough. - Return
falseafter both loops: no cell could start the word.
dfs: match one letter and ask the neighbours
The helper dfs(board, word, row, col, index) answers whether the letters of word from index on can be traced starting at cell (row, col).
- All letters matched. If
index === word.length, returntrue. This test comes first on purpose: the cell before this one matched the last letter, so the current cell may even lie outside the board. - Outside the board. Return
falseifroworcolis below 0 or past the last row or column. Four conditions are joined with||. Checking here spares every caller from checking its neighbours. - Wrong letter. Return
falseifboard[row][col] !== word[index]. A cell already on the path holds'#', so it fails here too. - Mark the cell by setting it to
'#'. - Prepare the answer. Create
foundwithlet, set tofalse. The cell has to be unmarked before returning, so the function can't return the moment a neighbour succeeds. - Write the moves as data:
directionsholds the pairs[-1, 0],[1, 0],[0, -1],[0, 1]: up, down, left, right as changes in row and column. - Try each neighbour. A
for...ofloop unpacks each pair into[dr, dc], andfound = found || this.dfs(..., row + dr, col + dc, index + 1). The next letter is the next index. - Unmark the cell by putting
word[index]back. This is the letter the cell held, because it matched. - Return
found.
The solution in TypeScript
class Solution {
exist(board: string[][], word: string): boolean {
for (let row = 0; row < board.length; row++) {
for (let col = 0; col < board[0].length; col++) {
if (this.dfs(board, word, row, col, 0)) {
return true;
}
}
}
return false;
}
dfs(board: string[][], word: string, row: number, col: number, index: number): boolean {
if (index === word.length) {
return true;
}
if (row < 0 || row >= board.length || col < 0 || col >= board[0].length) {
return false;
}
if (board[row][col] !== word[index]) {
return false;
}
board[row][col] = '#';
let found = false;
const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [dr, dc] of directions) {
found = found || this.dfs(board, word, row + dr, col + dc, index + 1);
}
board[row][col] = word[index];
return found;
}
}
Details worth knowing:
- The board is shared.
boardis an array of arrays, which JavaScript passes by reference, so every call works on the same one, so writing'#'in one call is visible in all the deeper calls. That is what makes the marking work, and it is why the letter must be restored. Afterexistreturns, the board holds its original letters again. - Destructuring.
const [dr, dc] of directionstakes each two-number array apart. Sincedirectionsis an array of arrays of numbers,dranddcare numbers. - Short-circuit
||. The right side offound || this.dfs(...)runs only whilefoundis stillfalse. Once one neighbour completes the word, the remaining neighbours are skipped. Becausefoundis assigned and returned only after the unmarking, the board is restored either way. - Order of the tests. The bounds test must come before
board[row][col]. Readingboard[-1]givesundefined, and thenboard[-1][0]throws aTypeError. - Types are erased.
string[][]andbooleanare checked by the compiler and disappear in the generated JavaScript.
Complexity
Time: O(R · C · 4^L) in the worst case, where R and C are the numbers of rows and columns and L is the length of the word. Each of the R·C cells can start a search. Each call has up to four neighbours (three after the first, since the cell it came from is marked), and the path is at most L letters deep. In practice it is much faster, because a path ends at its first wrong letter.
Space: O(L) for the recursion, one call per letter matched so far. The board itself is reused, with no visited table.
Edge cases and common mistakes
- Forgetting to unmark. If the letter isn't restored, a cell stays
'#'after one failed path, and later paths that need it fail. The board would also be left damaged for the caller. - Reusing a cell. Without the marking,
[["a","a"]]with the word"aaa"would wrongly succeed by going back and forth. With it the answer isfalse. - Checking the word's end too late. If the
index === word.lengthtest came after the bounds test, a word ending at an edge cell would fail, because its last neighbour falls outside the board. - Case.
[["a"]]with"A"givesfalse: the letters differ. - Single cell. A one-cell board works for a one-letter word, and any longer word fails.
- Diagonal moves. Only the four directions in
directionsare allowed. Adding diagonal pairs would accept paths the problem forbids.
Testing
On the board [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]:
"ABCCED"givestrue."SEE"givestrue."ABCB"givesfalse."ABCCEDA"givestrue: the path continues from D (2,1) left to the A at (2,0)."a"givesfalse, because the board holds only capital letters.
On small boards: [["a"]] with "a" gives true, with "aa" gives false, and [["a","a"]] with "aaa" gives false.
