LeetCode 79 单词搜索:Java 回溯模板、网格 DFS 与剪枝优化

一句话说明核心方法

从网格的每个格子出发,沿上下左右做 DFS,字符匹配就深入、不匹配就剪枝;为避免重复使用同一格子,用原地标记 + 回溯撤销 来记录访问状态,任意一条路径走到 index == word.length() 即成功。


思路推导

题意转化 :在二维网格里找一条路径,顺序访问若干相邻格子,拼出 word。每个格子只能用一次。

关键观察:

  • 起点不固定 ------ word[0] 可能在网格的任意位置,所以必须遍历每个格子作为起点
  • 路径顺序固定 ------ 一旦确定从 (i,j) 出发,下一步只能去 (i±1,j)(i,j±1),不是任选,这与子集/排列"自由选择下一个"完全不同。
  • 状态维度是"格子是否已访问" ------ 用 boolean 数组太占空间,而直接修改 boardij 为特殊字符,递归结束再还原,是最经济的做法。

算法选择:DFS + 回溯。每层决策"从当前格子往四个方向中的某一个走",剪枝条件 = 越界 OR 已访问 OR 字符不匹配。

与之前回溯题的区别 :子集/组合/字母组合的"决策树"是在抽象数据结构上展开,而本题的决策树是真实的空间路径,每一步还可能走回头路(因为标记撤销后会"释放"格子),所以终止条件更微妙。

回溯路径示意

(以 word = "ABCCED" 为例)网格中的成功路径:

复制代码
    col:  0    1    2    3
   row 0: A  → B  → C    E
                ↓
   row 1: S    F   C ↑  S
                      ↓
   row 2: A  ← D ← E    E
                ↑

成功路径: (0,0)A → (0,1)B → (0,2)C → (1,2)C → (2,2)E → (2,1)D

对应的 DFS 关键调用链(省略大量失败分支):

复制代码
dfs(0,0, 0)  board[0][0]='A' = 'A' ✓
  标记 [0][0]
  └─ dfs(0,1, 1)  'B' = 'B' ✓
        标记 [0][1]
        └─ dfs(0,2, 2)  'C' = 'C' ✓
              标记 [0][2]
              ├─ dfs(-1,2, 3)  越界 ✗
              ├─ dfs(0,1, 3)  已标记 ✗
              ├─ dfs(0,3, 3)  'E' ≠ 'C' ✗
              └─ dfs(1,2, 3)  'C' = 'C' ✓
                    标记 [1][2]
                    ├─ dfs(0,2, 4)  已标记 ✗
                    ├─ dfs(1,1, 4)  'F' ≠ 'E' ✗
                    ├─ dfs(1,3, 4)  'S' ≠ 'E' ✗
                    └─ dfs(2,2, 4)  'E' = 'E' ✓
                          标记 [2][2]
                          ├─ dfs(1,2, 5)  已标记 ✗
                          ├─ dfs(3,2, 5)  越界 ✗
                          ├─ dfs(2,3, 5)  'E' ≠ 'D' ✗
                          └─ dfs(2,1, 5)  'D' = 'D' ✓
                                标记 [2][1]
                                └─ dfs(..., 6)  index == len  →  return TRUE ✓

注意:每条失败路径都会撤销标记,所以同一格子在另一条路径中可被重新使用。

Java 完整代码

复制代码
class Solution {
    public boolean exist(char[][] board, String word) {
        int m = board.length;
        int n = board[0].length;
        // 任意格子都可以作为起点,必须遍历所有 (i,j)
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (dfs(board, word, i, j, 0)) {
                    return true;
                }
            }
        }
        return false;
    }

    private boolean dfs(char[][] board, String word, int i, int j, int index) {
        // 成功:已经匹配完 word 的所有字符
        if (index == word.length()) {
            return true;
        }
        // 失败:越界 / 字符不匹配(已访问的情况也被这一行兜住,因为标记后 board[i][j] = '0')
        if (i < 0 || i >= board.length || j < 0 || j >= board[0].length
                || board[i][j] != word.charAt(index)) {
            return false;
        }

        // 关键:原地标记,避免 visited 数组
        char tmp = board[i][j];
        board[i][j] = '0';

        // 四个方向任一成功即可
        boolean res = dfs(board, word, i - 1, j, index + 1)
                   || dfs(board, word, i + 1, j, index + 1)
                   || dfs(board, word, i, j - 1, index + 1)
                   || dfs(board, word, i, j + 1, index + 1);

        // 回溯:撤销标记,让其他路径能再次使用该格子
        board[i][j] = tmp;
        return res;
    }
}

关键代码逐行解释

  • 双重 for 遍历起点 :word[0] 可能在网格任意位置,只从一个格子出发是错的 。外层枚举 (i,j),任何一个起点成功就立即返回 true。
  • 第一个 if (index == word.length()) return true :成功终止。注意这里 index 是"下一个要匹配的字符下标",所以当 index 等于 word.length() 时,说明前一步已经匹配完最后一个字符。
  • 第二个 if 同时处理越界和字符不匹配 :board[i][j] != word.charAt(index) 这一行还顺带兜住了"已访问"的判断 ------因为已经访问的格子被改成了 '0',而 word 里没有 '0',自然不等,等价于 visited 数组的效果。
  • char tmp = board[i][j]; board[i][j] = '0' :原地标记的精髓。省掉了 boolean\[\]\[\] visited ,空间复杂度从 O(mn) 降到 O(1) 额外。'0' 是占位符,选它是因为题目保证 board 元素是大写或小写字母,不会和真实字符冲突。
  • 四个 dfs 用 || 串接 :短路求值,一旦某个方向返回 true,后续三个方向立即不再递归 ,既省时间也省栈。写成 || 是性能关键,不要写成 if-else 链或者全部算完再 ||
  • board[i][j] = tmp 撤销标记 :这一行决定整个算法是否正确 。如果漏掉,后续路径都会以为这个格子已访问,合法路径会被错误剪掉,导致误判 false。加入-撤销 必须严格配对,这是回溯的核心约束。

时间、空间复杂度

  • 时间复杂度:O(m · n · 4^L) (L = word.length())
    • 外层遍历 m·n 个起点,每个起点 DFS 最多 4^L 个节点(每个格子 4 个方向)。实际运行中字符不匹配和已访问都会剪枝 ,有效分支远少于 4^L;m,n ≤ 6L ≤ 15,规模很小。
    • 进阶提示:对更大的 board,可以用 Trie(LeetCode 212 单词搜索 II)把多个查询合并剪枝,单次查询仍可用本方法。
  • 空间复杂度:O(L) (不计原 board)
    • 递归栈深度 = word 长度,无额外 visited 数组。原 board 本身不计在额外空间里。

易错点

  • 忘了撤销标记 :board[i][j] = tmp 这一行删掉的话,DFS 跑完后整张网格大部分格子都会被改成 '0',下一条路径根本无路可走,误判 false。这是本题最高频的 bug。
  • 只从一个起点开始 :只检查 (0,0)word[0] 第一次出现的位置,忽略了 word 路径起点可能在网格任意位置,会漏判。必须双重 for
  • 判断"已访问"用 == 字符比较 :写成 if (board[i][j] == '0') return false 单独加一行判断也能跑,但和"字符不匹配"合并到一行更简洁,逻辑也更不容易出错(因为 '0' 永远不等于 word 里的合法字母)。
  • 没有用 || 的短路特性 :写成 4 个变量分别存结果再 res = up || down || left || right; 也能跑,但失去了短路求值的好处------找到 true 后还白白递归完其余三个方向,在大网格上会显著变慢。
  • 起点 i, j 写反 :双重 for 里 i 是行(board.length),j 是列(board[0].length),传给 dfs 时不要把行列搞反,否则边界判断会错位。

可复用模板

网格 DFS 通用框架(单词搜索、岛屿、路径类问题都适用):

复制代码
class Solution {
    // 四方向偏移量(上、下、左、右)
    private static final int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};

    public boolean exist(char[][] board, String word) {
        for (int i = 0; i < board.length; i++) {
            for (int j = 0; j < board[0].length; j++) {
                if (dfs(board, word, i, j, 0)) return true;
            }
        }
        return false;
    }

    private boolean dfs(char[][] board, String word, int i, int j, int index) {
        if (index == word.length()) return true;                                    // 成功
        if (outOfBounds(board, i, j) || board[i][j] != word.charAt(index)) {        // 越界 / 不匹配
            return false;
        }

        char tmp = board[i][j];
        board[i][j] = '#';                                                          // 原地标记
        boolean found = false;
        for (int[] d : DIRS) {                                                      // 四方向
            if (dfs(board, word, i + d[0], j + d[1], index + 1)) {
                found = true;
                break;                                                              // 短路
            }
        }
        board[i][j] = tmp;                                                          // 撤销
        return found;
    }

    private boolean outOfBounds(char[][] board, int i, int j) {
        return i < 0 || i >= board.length || j < 0 || j >= board[0].length;
    }
}

变体提示:

  • 岛屿 / 路径覆盖类 (无需还原)→ 删掉最后一行 board[i][j] = tmp,且不关心返回值,只关心是否访问过。
  • 多个单词同时搜 → 入口加 Trie 节点,dfs 里传 TrieNode 推进,见 LeetCode 212。
  • 统计路径条数 → 把返回类型从 boolean 换成 int,四个方向的结果相加。

相似题及区别

  • LeetCode 200 岛屿数量 :同样是网格 DFS + 标记已访问,但不撤销标记(因为是求连通块而不是路径),且不需要 word 字符串,只数连通区域个数。
  • LeetCode 212 单词搜索 II:本题的多查询版本,字典里有多个 word。用 Trie 把多查询合并,dfs 时同步推进 Trie 节点,效率远高于"每个 word 跑一遍 exist"。
  • LeetCode 980 不同路径 III:网格 DFS + 状态回溯,但要"走遍所有空格且只用一次",需要记录剩余步数。本题是"沿 word 走"路径搜索,只要路径长度匹配即可。
  • LeetCode 1091 二进制矩阵中的最短路径:BFS 找最短路,不是回溯(因为需要"最短");本题只问"是否存在",DFS 即可。
相关推荐
白远山1 小时前
智慧场馆解决方案小程序系统开发实战与架构设计指南
java·开发语言·小程序·架构
ECT-OS-JiuHuaShan1 小时前
哲学是迭代学,数学是拓扑学
开发语言·人工智能·学习·算法·机器学习·php·拓扑学
All for pursuit.1 小时前
【数组-5】560.和为K的子数组
数据结构·c++·算法·leetcode
霸道流氓气质1 小时前
Spring AI Alibaba vs LangChain4j:Java AI 框架选型深度指南
java·人工智能·spring
敲代码的嘎仔1 小时前
从零实现视频续播 + 学习进度统计:前端心跳、条件更新、GROUP BY 统计全链路拆解
java·前端·数据库·学习·面试·职场和发展·音视频
一个有温度的技术博主1 小时前
深入理解 Spring Boot 自动装配
java·spring boot·后端
TT哇1 小时前
我把两个 Java 项目和一个 Python AI 服务部署到 2C2G:一次低成本 Docker 容器化实践
java·人工智能·python
星子yu1 小时前
【Java数据结构】二叉树 好像就这样?
java·数据结构
Irene19911 小时前
Flink 学习需要具备的 Java 基础知识总结
java·flink