递归专题4 - 网格DFS与回溯

递归专题4 - 网格DFS与回溯

本文是递归算法系列的第4篇,完整体系包括:

  1. 递归基础与思维方法
  2. 二叉树DFS专题
  3. 回溯算法十大类型
  4. 网格DFS与回溯 (本文)
  5. 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的题就能快速解决。


系列文章

  1. 递归基础与思维方法
  2. 二叉树DFS专题
  3. 回溯算法十大类型
  4. 网格DFS与回溯 (本文)
  5. FloodFill算法专题
相关推荐
程序猿20237 小时前
Python每日一练---第一天:买卖股票的最佳时机
算法
夏鹏今天学习了吗8 小时前
【LeetCode热题100(56/100)】组合总和
算法·leetcode·职场和发展
ZPC82108 小时前
opencv 获取图像中物体的坐标值
人工智能·python·算法·机器人
颇有几分姿色8 小时前
密码学算法分类指南
算法·密码学
绝无仅有9 小时前
某游戏大厂的 Redis 面试必问题解析
后端·算法·面试
微笑尅乐9 小时前
三种方法解开——力扣3370.仅含置位位的最小整数
python·算法·leetcode
MMjeaty9 小时前
查找及其算法
c++·算法
寂静山林9 小时前
UVa 1597 Searching the Web
数据结构·算法
云泽8089 小时前
排序算法实战:从插入排序到希尔排序的实现与性能对决
算法·排序算法