递归专题4 - 网格DFS与回溯
递归专题4 - 网格DFS与回溯
本文是递归算法系列的第4篇,完整体系包括:
- 递归基础与思维方法
- 二叉树DFS专题
- 回溯算法十大类型
- 网格DFS与回溯 (本文)
- FloodFill算法专题

📋 目录
- 前言
- 一、网格DFS的特点
- 1.1 什么是网格DFS
- 1.2 网格DFS vs 全排列
- 二、网格DFS的两种形态
- 形态1: 找一条路径就停 (bool返回值)
- 形态2: 找所有路径/最优解 (void返回值)
- 两种形态对比
- 三、形态1详解: 单词搜索 (bool返回值)
- 3.1 题目: LeetCode 79 单词搜索
- 3.2 代码实现
- 3.3 关键点
- 四、形态2详解: 黄金矿工 (void返回值)
- 4.1 题目: LeetCode 1219 黄金矿工
- 4.2 代码实现
- 4.3 关键点
- 五、bool vs void的本质区别
- 六、两种实现方式对比
- 七、常见错误
- 八、总结
前言
网格DFS是回溯算法里比较特殊的一类。做单词搜索那道题的时候,一开始一直超时,后来发现是返回值用错了。网格DFS有两种形态:找一条路径用bool,找所有路径/最优解用void。搞清楚这两种的区别后,网格题就容易多了。
这篇文章主要讲网格DFS的两种形态、bool vs void的本质区别,以及常见的坑。
一、网格DFS的特点
1.1 什么是网格DFS
网格DFS就是在二维网格中搜索路径,每步可以往上下左右四个方向走。
// 四个方向
int dx[4] = {-1, 1, 0, 0}; // 行变化:上 下 左 右
int dy[4] = {0, 0, -1, 1}; // 列变化:上 下 左 右void dfs(int i, int j) {// 递归出口if (越界 || 不满足条件) return;// 标记vis[i][j] = true;// 四个方向探索for (int k = 0; k < 4; k++) {int x = i + dx[k];int y = j + dy[k];dfs(x, y);}// 恢复现场 (回溯)vis[i][j] = false;
}
1.2 网格DFS vs 全排列
网格DFS和全排列很相似:
| 特性 | 全排列 | 网格DFS |
|---|---|---|
| 空间 | 一维数组 | 二维网格 |
| 选择 | 选哪个数 | 往哪个方向走 |
| 标记 | check[i] | vis[i][j] |
| 恢复 | check[i]=false | vis[i][j]=false |
| 关心顺序 | ✅ | ✅ |
可以把网格DFS理解成"在二维空间中的全排列"。
二、网格DFS的两种形态
做了几道网格DFS后发现,有两种不同的形态,返回值不一样。
形态1:找一条路径就停 (bool返回值)
特征:
- 题目问"是否存在"、“能否找到”
- 找到一条满足条件的路径就返回
- 不需要遍历所有路径
模板:
bool vis[m][n];bool dfs(grid, int i, int j, 条件) {// 出口1:越界/已访问/不符合if (越界 || vis[i][j] || 不符合条件) return false;// 出口2:找到目标if (找到目标) return true;// 标记vis[i][j] = true;// 四个方向for (int k = 0; k < 4; k++) {int x = i + dx[k];int y = j + dy[k];if (dfs(grid, x, y, 条件)) return true; // 找到就返回}// 恢复现场vis[i][j] = false;return false;
}
关键:
- 返回值bool
if (dfs(...)) return true;实现剪枝- 找到一个就停,不继续搜索
典型题目:LeetCode 79 单词搜索
形态2:找所有路径/最优解 (void返回值)
特征:
- 题目问"最大值"、“所有可能”
- 需要遍历所有路径
- 用全局变量记录答案
模板:
bool vis[m][n];
int ret = 0; // 全局记录答案
int tmp = 0; // 当前路径的值void dfs(grid, int i, int j) {// 出口:越界/已访问/不符合if (越界 || vis[i][j] || 不符合条件) return;// 累加当前格子tmp += grid[i][j];ret = max(ret, tmp); // 更新最大值// 标记vis[i][j] = true;// 四个方向for (int k = 0; k < 4; k++) {int x = i + dx[k];int y = j + dy[k];dfs(grid, x, y);}// 恢复现场vis[i][j] = false;tmp -= grid[i][j]; // 恢复tmp
}
关键:
- 返回值void
- 用全局变量
ret记录答案 - 用全局变量
tmp记录当前路径的值 - 必须恢复
tmp(因为是引用,不是值传递)
典型题目:LeetCode 1219 黄金矿工
两种形态对比
| 特性 | 找一条路径 (bool) | 找最优解 (void) |
|---|---|---|
| 返回值 | bool | void |
| 目标 | 找到就停 | 遍历所有 |
| 剪枝 | if (dfs()) return true | 不能提前停 |
| 全局变量 | 不需要 | 需要(ret, tmp) |
| 典型题目 | 单词搜索 | 黄金矿工 |
| 关键字 | “是否”、“能否” | “最大”、“所有” |
判断技巧:
题目问"是否存在" -> bool
题目问"最大值" -> void + 全局变量
三、形态1详解:单词搜索 (bool返回值)
3.1 题目:LeetCode 79 单词搜索
给定一个网格和一个单词,判断网格中是否存在该单词的路径。
示例:
board = [['A','B','C','E'],['S','F','C','S'],['A','D','E','E']]
word = "ABCCED"返回:true (路径:A->B->C->C->E->D)
3.2 代码实现
class Solution {
public:bool vis[7][7];int m, n;int dx[4] = {-1, 1, 0, 0};int dy[4] = {0, 0, -1, 1};bool exist(vector<vector<char>>& board, string word) {m = board.size();n = board[0].size();memset(vis, false, sizeof(vis));// 遍历所有格子作为起点for (int i = 0; i < m; i++) {for (int j = 0; j < n; j++) {if (dfs(board, i, j, word, 0)) {return true;}}}return false;}bool dfs(vector<vector<char>>& board, int i, int j, string& word, int pos) {// 出口1:找到完整单词if (pos == word.size()) return true;// 出口2:越界if (i < 0 || i >= m || j < 0 || j >= n) return false;// 出口3:已访问或字符不匹配if (vis[i][j] || board[i][j] != word[pos]) return false;// 标记当前格子vis[i][j] = true;// 四个方向搜索for (int k = 0; k < 4; k++) {int x = i + dx[k];int y = j + dy[k];// 只要有一个方向找到就返回trueif (dfs(board, x, y, word, pos + 1)) {vis[i][j] = false; // 找到了也要恢复return true;}}// 恢复现场vis[i][j] = false;return false;}
};
3.3 关键点
1. bool返回值的剪枝:
if (dfs(board, x, y, word, pos + 1)) {vis[i][j] = false; // 找到了也要恢复!return true;
}
注意:找到答案后也要恢复vis,因为可能有其他起点会用到这个格子。
2. 递归出口的顺序:
// 先判断pos (必须最先判断)
if (pos == word.size()) return true;// 再判断越界
if (i < 0 || i >= m ...) return false;// 最后判断vis和字符匹配
if (vis[i][j] || board[i][j] != word[pos]) return false;
为什么pos == word.size()要最先判断?
因为如果最后一个字符匹配了,pos会变成word.size(),这时候已经找到完整单词,应该立即返回true,而不是继续判断越界(这时候i,j可能还合法)。
3. dx/dy数组的使用:
之前可能这样写:
// 传统写法:4个if
if (i-1 >= 0) if (dfs(board, i-1, j, ...)) return true;
if (i+1 < m) if (dfs(board, i+1, j, ...)) return true;
if (j-1 >= 0) if (dfs(board, i, j-1, ...)) return true;
if (j+1 < n) if (dfs(board, i, j+1, ...)) return true;
用dx/dy更简洁:
// dx/dy写法:1个for
for (int k = 0; k < 4; k++) {int x = i + dx[k];int y = j + dy[k];if (dfs(board, x, y, ...)) return true;
}
四、形态2详解:黄金矿工 (void返回值)
4.1 题目:LeetCode 1219 黄金矿工
给定一个网格,每个格子有金子,从某个格子出发,每步可以走上下左右,不能重复走,求能收集的最大金子数。
示例:
grid = [[0,6,0],[5,8,7],[0,9,0]]最大值:24 (路径:9->8->7 或 5->8->7->6)
4.2 代码实现
class Solution {
public:bool vis[16][16];int m, n;int dx[4] = {-1, 1, 0, 0};int dy[4] = {0, 0, -1, 1};int ret = 0; // 全局最大值int tmp = 0; // 当前路径的金子数int getMaximumGold(vector<vector<int>>& grid) {m = grid.size();n = grid[0].size();memset(vis, false, sizeof(vis));// 遍历所有格子作为起点for (int i = 0; i < m; i++) {for (int j = 0; j < n; j++) {if (grid[i][j] != 0) {dfs(grid, i, j);}}}return ret;}void dfs(vector<vector<int>>& grid, int i, int j) {// 出口:越界if (i < 0 || i >= m || j < 0 || j >= n) return;// 出口:已访问或是0if (vis[i][j] || grid[i][j] == 0) return;// 累加当前格子的金子tmp += grid[i][j];ret = max(ret, tmp); // 更新最大值// 标记vis[i][j] = true;// 四个方向搜索for (int k = 0; k < 4; k++) {int x = i + dx[k];int y = j + dy[k];dfs(grid, x, y);}// 恢复现场vis[i][j] = false;tmp -= grid[i][j]; // 必须恢复tmp!}
};
4.3 关键点
1. 为什么用void不用bool?
因为要找最大值,必须遍历所有可能的路径。如果用bool,找到一条路径就停了,可能不是最优的。
2. 为什么每个节点都更新ret?
刚开始我以为只有"叶子节点"(四个方向都走不了了)才更新ret,但这是错的。
反例:
grid = [[1,2],[3,4]]
从1出发:1->2->4->3,路径上每个节点都可能是某条路径的终点:
- 路径1: 1 (金子=1)
- 路径1->2: 1->2 (金子=3)
- 路径1->2->4: 1->2->4 (金子=7)
- 路径1->2->4->3: 1->2->4->3 (金子=10)
所以每个节点都要更新ret = max(ret, tmp)。
3. 为什么要恢复tmp?
因为tmp是全局变量(引用),如果不恢复,会影响其他路径的计算。
// 进入节点:tmp += grid[i][j]
tmp += grid[i][j];// 离开节点:tmp -= grid[i][j]
tmp -= grid[i][j];
这和恢复vis[i][j]是一样的道理。
4. void没有返回值,怎么回溯?
很多人疑惑:void函数没有返回值,怎么回溯?
答案:函数结束自动返回上一层,这就是回溯。
void dfs(i, j) {vis[i][j] = true;tmp += grid[i][j];dfs(i+1, j); // 往下走// 这里自动回溯到(i, j)dfs(i, j+1); // 往右走// 这里自动回溯到(i, j)vis[i][j] = false;tmp -= grid[i][j];
} // 函数结束,回到上一层
五、bool vs void的本质区别
做了这两道题后,终于理解了bool和void的本质区别。
5.1 有没有全局变量记录状态
bool返回值:
- 不需要全局变量
- 通过返回值传递信息
- 找到答案就返回true
void返回值:
- 需要全局变量记录状态
- 全局变量本身就是"状态传递"的机制
- 不需要通过返回值传递信息
关键理解:
全局变量自动记录状态,所以不需要返回值传递
示例:
// bool:通过返回值传递"是否找到"
bool dfs(...) {if (找到) return true; // 返回值传递信息if (dfs(...)) return true;return false;
}// void:通过全局变量记录"找到了什么"
int ret = 0;
void dfs(...) {ret = max(ret, 当前值); // 全局变量记录信息dfs(...); // 不需要返回值
}
5.2 是否需要提前终止
bool返回值:
- 需要提前终止
if (dfs(...)) return true;实现剪枝- 找到一个答案就停
void返回值:
- 不能提前终止
- 必须遍历所有可能
- 全局变量记录最优解
5.3 决策流程
面对一道题,怎么选择返回值?
题目要求"是否存在"、"能否找到"↓
需要提前终止↓
用bool返回值题目要求"最大值"、"所有可能"↓
需要遍历所有↓
用void + 全局变量
六、两种实现方式对比
单词搜索和黄金矿工的对比:
| 特性 | 单词搜索 (bool) | 黄金矿工 (void) |
|---|---|---|
| 返回值 | bool | void |
| 目标 | 找一条路径 | 找最优路径 |
| 全局变量 | 不需要 | ret, tmp |
| 剪枝 | if (dfs()) return true | 不能剪枝 |
| 更新答案 | 出口处return true | 每个节点更新ret |
| 恢复状态 | vis | vis + tmp |
代码对比:
// 单词搜索 (bool)
bool dfs(board, i, j, word, pos) {if (pos == word.size()) return true; // 出口:找到if (...) return false;vis[i][j] = true;for (四个方向) {if (dfs(...)) return true; // 找到就返回}vis[i][j] = false;return false;
}// 黄金矿工 (void)
int ret = 0, tmp = 0;
void dfs(grid, i, j) {if (...) return;tmp += grid[i][j];ret = max(ret, tmp); // 每个节点更新vis[i][j] = true;for (四个方向) {dfs(...); // 不返回,继续遍历}vis[i][j] = false;tmp -= grid[i][j]; // 恢复tmp
}
七、常见错误
7.1 bool题用了void
// 错误:单词搜索用void
void dfs(board, i, j, word, pos) {if (pos == word.size()) {找到了 = true; // 用全局变量?return;}for (四个方向) {dfs(...); // 找到了还继续搜?}
}
问题:找到一个答案后还在继续搜索,浪费时间。
7.2 void题忘记恢复tmp
// 错误:忘记恢复tmp
void dfs(grid, i, j) {tmp += grid[i][j];ret = max(ret, tmp);vis[i][j] = true;for (四个方向) dfs(...);vis[i][j] = false;// 忘记tmp -= grid[i][j]了!
}
结果:tmp一直累加,后面的路径都不对。
7.3 只在叶子节点更新答案
// 错误:只在叶子更新
void dfs(grid, i, j) {tmp += grid[i][j];vis[i][j] = true;// 判断是否是叶子bool isLeaf = true;for (四个方向) {if (可以走) isLeaf = false;}if (isLeaf) ret = max(ret, tmp); // 只在叶子更新?
}
问题:中间节点也可能是某条路径的终点。
正确做法:每个节点都更新。
7.4 递归出口判断顺序错
// 错误:先判断越界
bool dfs(board, i, j, word, pos) {if (i < 0 || i >= m ...) return false;if (pos == word.size()) return true; // 太晚了
}
问题:如果最后一个字符匹配了,pos变成word.size(),应该立即返回true,不应该先判断越界。
正确顺序:
// 正确:先判断pos
if (pos == word.size()) return true;
if (i < 0 || i >= m ...) return false;
八、总结
网格DFS的两种形态:
形态1:找一条路径 (bool)
- 目标:找到就停
- 返回值:bool
- 剪枝:
if (dfs()) return true - 典型题:单词搜索
形态2:找最优解 (void)
- 目标:遍历所有
- 返回值:void
- 全局变量:ret, tmp
- 每个节点都更新答案
- 必须恢复tmp
- 典型题:黄金矿工
判断技巧:
"是否存在"、"能否" -> bool
"最大值"、"所有" -> void + 全局变量
核心理解:
- void不需要返回值,因为全局变量自动记录状态
- 函数结束自动返回上一层,这就是回溯
- 有全局变量记录状态 -> 不需要返回值传递
掌握这两种形态和它们的区别,网格DFS的题就能快速解决。
系列文章
- 递归基础与思维方法
- 二叉树DFS专题
- 回溯算法十大类型
- 网格DFS与回溯 (本文)
- FloodFill算法专题
