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

📋 目录
- 前言
- 一、网格DFS的特点
- [1.1 什么是网格DFS](#1.1 什么是网格DFS)
- [1.2 网格DFS vs 全排列](#1.2 网格DFS vs 全排列)
- 二、网格DFS的两种形态
- [形态1: 找一条路径就停 (bool返回值)](#形态1: 找一条路径就停 (bool返回值))
- [形态2: 找所有路径/最优解 (void返回值)](#形态2: 找所有路径/最优解 (void返回值))
- 两种形态对比
- [三、形态1详解: 单词搜索 (bool返回值)](#三、形态1详解: 单词搜索 (bool返回值))
- [3.1 题目: LeetCode 79 单词搜索](#3.1 题目: LeetCode 79 单词搜索)
- [3.2 代码实现](#3.2 代码实现)
- [3.3 关键点](#3.3 关键点)
- [四、形态2详解: 黄金矿工 (void返回值)](#四、形态2详解: 黄金矿工 (void返回值))
- [4.1 题目: LeetCode 1219 黄金矿工](#4.1 题目: LeetCode 1219 黄金矿工)
- [4.2 代码实现](#4.2 代码实现)
- [4.3 关键点](#4.3 关键点)
- [五、bool vs void的本质区别](#五、bool vs void的本质区别)
- 六、两种实现方式对比
- 七、常见错误
- 八、总结
前言
网格DFS是回溯算法里比较特殊的一类。做单词搜索那道题的时候,一开始一直超时,后来发现是返回值用错了。网格DFS有两种形态:找一条路径用bool,找所有路径/最优解用void。搞清楚这两种的区别后,网格题就容易多了。
这篇文章主要讲网格DFS的两种形态、bool vs void的本质区别,以及常见的坑。
一、网格DFS的特点
1.1 什么是网格DFS
网格DFS就是在二维网格中搜索路径,每步可以往上下左右四个方向走。
cpp
// 四个方向
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返回值)
特征:
- 题目问"是否存在"、"能否找到"
- 找到一条满足条件的路径就返回
- 不需要遍历所有路径
模板:
cpp
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返回值)
特征:
- 题目问"最大值"、"所有可能"
- 需要遍历所有路径
- 用全局变量记录答案
模板:
cpp
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 代码实现
cpp
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];
// 只要有一个方向找到就返回true
if (dfs(board, x, y, word, pos + 1)) {
vis[i][j] = false; // 找到了也要恢复
return true;
}
}
// 恢复现场
vis[i][j] = false;
return false;
}
};
3.3 关键点
1. bool返回值的剪枝:
cpp
if (dfs(board, x, y, word, pos + 1)) {
vis[i][j] = false; // 找到了也要恢复!
return true;
}
注意:找到答案后也要恢复vis,因为可能有其他起点会用到这个格子。
2. 递归出口的顺序:
cpp
// 先判断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数组的使用:
之前可能这样写:
cpp
// 传统写法: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更简洁:
cpp
// 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 代码实现
cpp
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;
// 出口:已访问或是0
if (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是全局变量(引用),如果不恢复,会影响其他路径的计算。
cpp
// 进入节点:tmp += grid[i][j]
tmp += grid[i][j];
// 离开节点:tmp -= grid[i][j]
tmp -= grid[i][j];
这和恢复vis[i][j]是一样的道理。
4. void没有返回值,怎么回溯?
很多人疑惑:void函数没有返回值,怎么回溯?
答案:函数结束自动返回上一层,这就是回溯。
cpp
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返回值:
- 需要全局变量记录状态
- 全局变量本身就是"状态传递"的机制
- 不需要通过返回值传递信息
关键理解:
全局变量自动记录状态,所以不需要返回值传递
示例:
cpp
// 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 |
代码对比:
cpp
// 单词搜索 (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
cpp
// 错误:单词搜索用void
void dfs(board, i, j, word, pos) {
if (pos == word.size()) {
找到了 = true; // 用全局变量?
return;
}
for (四个方向) {
dfs(...); // 找到了还继续搜?
}
}
问题:找到一个答案后还在继续搜索,浪费时间。
7.2 void题忘记恢复tmp
cpp
// 错误:忘记恢复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 只在叶子节点更新答案
cpp
// 错误:只在叶子更新
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 递归出口判断顺序错
cpp
// 错误:先判断越界
bool dfs(board, i, j, word, pos) {
if (i < 0 || i >= m ...) return false;
if (pos == word.size()) return true; // 太晚了
}
问题:如果最后一个字符匹配了,pos变成word.size(),应该立即返回true,不应该先判断越界。
正确顺序:
cpp
// 正确:先判断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算法专题