CC++ & Algorithm

DFS与BFS比较:两条不同的探索之路

中等9
语言版本:C++Python
概述:深度优先搜索勇往直前,广度优先搜索稳扎稳打,它们各有各的用武之地。

DFS与BFS深度对比:选对搜索策略,解题快人一步

你已经认识了深度优先搜索(DFS)和广度优先搜索(BFS)这两位“搜索员”。它们都是在图或树中寻找目标的算法,但性格完全不同:DFS像冒险家,喜欢一条路走到黑;BFS像稳重的前锋,一圈一圈向前推进。了解它们的区别,能帮助你在解题时选择最合适的策略。

核心思想详解

深度优先搜索(DFS)—— 勇往直前,不撞南墙不回头

DFS 会从起点出发,沿着一条路径尽可能深地探索,直到无法继续(遇到死胡同或找到目标),然后回溯到上一个岔路口,换另一条路径继续。

生活例子:玩迷宫游戏时,你选择一直走右边的墙,碰到死路就退回来换一个方向。你只管往深处钻,直到把整个迷宫走遍。

用到的工具(或递归)。栈记录了“回头路” —— 每当走到一个岔路口,就把当前岔路口压入栈,这样当你走到死路时,可以从栈顶弹出上一个岔路口,回到那里继续探索。递归本质上也使用了系统栈。

简单步骤(以图为例):

  1. 从起点开始,标记已访问。
  2. 对于当前节点的每个未访问的邻居,递归地执行DFS。
  3. 如果所有邻居都已访问,则返回上一个节点(回溯)。

广度优先搜索(BFS)—— 稳扎稳打,步步为营

BFS 从起点出发,先探索所有与起点距离为1的节点,再探索距离为2的节点,以此类推,如同水波一样一圈一圈扩散。

生活例子:你在公园里找走失的小伙伴,会先检查周围的花坛、长椅,然后扩大范围到小树林、游乐场……确保不遗漏近处的地方。

用到的工具队列。队列记录了“下一步该去哪里” —— 把当前层的所有节点依次放入队列,然后逐个取出,再放入它们的下一层邻居,先进先出,保证按层遍历。

简单步骤(以图为例):

  1. 将起点放入队列并标记已访问。
  2. 当队列不为空时,取出队首节点。
  3. 对于该节点的每个未访问的邻居,将其放入队列并标记。
  4. 重复直到队列为空或找到目标。

深入对比:两位探险家的工作方式

项目DFSBFS
核心数据结构栈(递归)队列
搜索顺序沿着一条路走到黑一层一层扩散
空间占用通常较小(只存当前路径上的节点)可能很大(存所有下一层节点,尤其树很宽时)
是否找最短路径否,可能绕远路,不一定最短是,第一个找到的目标一定是路径最短的(在无权图中)
适用场景解数独、全排列、检测连通性、寻找所有解最短路径、网络广播、单词接龙、层次遍历

注意:在有权图中,BFS找的不一定是最短路径(需要Dijkstra算法),但在无权图(所有边代价相同)或迷宫移动一步代价为1时,BFS一定能找到最短路径。

新手常见错误

1. DFS递归导致栈溢出

DFS如果递归深度过大(例如图有几千层),系统栈会爆掉。解决方法:改用显式栈实现DFS,或者增加递归深度限制(不建议暴力增大)。

2. 忘记标记已访问节点

无论是DFS还是BFS,如果不标记已访问节点,会导致无限循环(把同一个节点反复入队/递归)。尤其在有环的图中,这是致命的错误。

错误示例(BFS中):

while (!q.empty()) {
    int cur = q.front(); q.pop();
    for (int neighbor : graph[cur]) {
        q.push(neighbor);  // 忘记检查是否已访问,可能会死循环
    }
}

3. BFS中忘记按层处理

如果需要在BFS中记录路径长度,应该同时维护一个step变量,每处理完一层才增加,否则可能统计出错。

4. DFS回溯时状态恢复不当

在DFS回溯法中(如解数独、走迷宫),修改状态后一定要在递归返回后恢复,否则会影响其他分支的搜索。

完整可运行示例:迷宫找路

假设有一个5×5的迷宫,0表示空地,1表示障碍,2表示起点,3表示终点。我们用DFS找一条路径(任意一条),用BFS找最短路径(步数最少)。

#include <iostream>
#include <vector>
#include <queue>
#include <stack>
#include <cstring>
using namespace std;

int maze[5][5] = {  // 迷宫地图,0空地,1障碍,2起点,3终点
    {2, 0, 1, 0, 0},
    {0, 0, 1, 0, 0},
    {0, 1, 1, 0, 0},
    {0, 0, 0, 0, 0},
    {0, 0, 0, 1, 3}
};

int rows = 5, cols = 5;  // 行数和列数
int dx[] = {-1, 1, 0, 0};  // 上下左右四个方向的行偏移
int dy[] = {0, 0, -1, 1};  // 上下左右四个方向的列偏移

// ---------- DFS:找一条通往终点的路径(不保证最短) ----------
bool dfs(int x, int y, vector<vector<bool>>& visited, vector<pair<int,int>>& path) {
    // x, y:当前位置,visited:访问标记,path:记录路径
    if (x < 0 || x >= rows || y < 0 || y >= cols) return false;  // 出界
    if (maze[x][y] == 1 || visited[x][y]) return false;  // 障碍或已访问
    path.push_back({x, y});  // 记录当前点
    visited[x][y] = true;  // 标记已访问

    if (maze[x][y] == 3) return true;  // 到达终点

    // 尝试四个方向
    for (int i = 0; i < 4; i++) {
        int nx = x + dx[i];
        int ny = y + dy[i];
        if (dfs(nx, ny, visited, path)) return true;
    }

    // 四个方向都没找到终点,回溯:从路径中移除当前点
    path.pop_back();
    // 注意:这里不需要将 visited 重置为 false,因为这条路径不通,但其他路径可能经过这个点吗?
    // 在找任意一条路径时,通常不重置 visited,因为点已经走过,不走回头路。
    // 如果需要找所有路径,则要重置 visited。
    return false;
}

// ---------- BFS:找最短路径(最少步数) ----------
struct Point {
    int x, y;  // 坐标
    int step;  // 从起点到该点的步数
    Point* prev;  // 前驱节点,用于回溯路径(可选)
};

vector<pair<int,int>> bfs(int startX, int startY) {
    vector<vector<bool>> visited(rows, vector<bool>(cols, false));
    queue<Point*> q;  // 队列中存放指向 Point 的指针,方便记录前驱

    // 起点
    Point* start = new Point{startX, startY, 0, nullptr};
    q.push(start);
    visited[startX][startY] = true;

    while (!q.empty()) {
        Point* cur = q.front(); q.pop();
        int x = cur->x, y = cur->y;

        if (maze[x][y] == 3) {  // 到达终点
            // 从终点沿前驱回溯路径
            vector<pair<int,int>> path;
            Point* p = cur;
            while (p != nullptr) {
                path.push_back({p->x, p->y});
                p = p->prev;
            }
            // 反转得到从起点到终点的顺序
            reverse(path.begin(), path.end());
            // 清理队列中剩余的指针(简单起见,此处省略,实际应该统一释放)
            return path;
        }

        // 尝试四个方向
        for (int i = 0; i < 4; i++) {
            int nx = x + dx[i];
            int ny = y + dy[i];
            if (nx < 0 || nx >= rows || ny < 0 || ny >= cols) continue;  // 越界
            if (maze[nx][ny] == 1 || visited[nx][ny]) continue;  // 障碍或已访问
            visited[nx][ny] = true;
            Point* next = new Point{nx, ny, cur->step + 1, cur};
            q.push(next);
        }
    }
    // 没有找到终点,返回空路径
    return {};
}

int main() {
    // 找到起点位置(值为2)
    int startX, startY;
    for (int i = 0; i < rows; i++)
        for (int j = 0; j < cols; j++)
            if (maze[i][j] == 2) { startX = i; startY = j; }

    // ------ DFS ------
    cout << "DFS: 寻找一条路径(不保证最短)" << endl;
    vector<vector<bool>> visited(rows, vector<bool>(cols, false));
    vector<pair<int,int>> dfsPath;
    if (dfs(startX, startY, visited, dfsPath)) {
        cout << "找到路径,经过的坐标:";
        for (auto p : dfsPath)
            cout << "(" << p.first << "," << p.second << ") ";
        cout << endl;
    } else {
        cout << "没有路径!" << endl;
    }

    // ------ BFS ------
    cout << "BFS: 寻找最短路径" << endl;
    vector<pair<int,int>> bfsPath = bfs(startX, startY);
    if (!bfsPath.empty()) {
        cout << "最短路径步数:" << bfsPath.size() - 1 << " 步" << endl;  // 步数 = 节点数-1
        cout << "路径坐标:";
        for (auto p : bfsPath)
            cout << "(" << p.first << "," << p.second << ") ";
        cout << endl;
    } else {
        cout << "没有路径!" << endl;
    }

    return 0;
}

运行结果(示例):

DFS: 寻找一条路径(不保证最短)
找到路径,经过的坐标:(0,0) (1,0) (1,1) (2,1) (3,1) (3,2) (3,3) (4,3) (4,4) 
BFS: 寻找最短路径
最短路径步数:6 步
路径坐标:(0,0) (1,0) (2,0) (3,0) (3,1) (3,2) (3,3) (4,3) (4,4) 

可以看到,DFS找到的路径可能比BFS长(本例中DFS路径9个节点,步数8;BFS路径9个节点,步数8?实际上两者都是8步,但路径不同;如果迷宫更复杂,BFS一定能得到最短的)。

什么时候用谁?—— 决策指南

  • 问题是“有没有路”(连通性):例如迷宫是否有出口,图中两点是否连通。DFS更简单,代码量少,通常用递归即可。
  • 问题是“最短的路是哪里”:例如从学校回家最少走几步,发消息到目标最少经过几个人。BFS是最佳选择(权重相同的图)。
  • 搜索空间巨大,答案在深层:例如一个非常深的树,目标在叶子节点。DFS可能直接一路冲下去快速找到,而BFS可能需要遍历很多上层节点。
  • 需要枚举所有解:例如全排列、八皇后、解数独。DFS配合回溯可以系统地列举所有可能性。
  • 游戏中的逐层扩散:例如“六度空间”理论、感染传播。BFS可以模拟逐层传播的过程。

相关知识点指引

如果你想更深入了解,可以学习以下内容:

  • 图与树的遍历基础:理解图的邻接矩阵、邻接表存储,以及树的层次遍历。
  • 递归与栈:DFS递归实现背后的系统栈原理,以及如何用显式栈模拟递归。
  • 队列:队列的先进先出特性,以及STL中的queue用法。
  • 回溯法:DFS与回溯的结合(如八皇后、数独),注意状态恢复。
  • 最短路径算法进阶:BFS只适用于无权图,对于带权图需要Dijkstra或SPFA。
  • 双向BFS:当搜索空间非常大时,可以同时在起点和终点进行BFS,能大大减少搜索范围。

记住:没有绝对好的算法,只有适合当前问题的算法。多动手画图、写代码,你就能慢慢掌握何时使用DFS,何时使用BFS。

例题精讲

1单选题

下列关于DFS和BFS的说法中,错误的是?

ADFS通常使用栈实现
BBFS通常使用队列实现
CDFS适合求解无权图的最短路径
DBFS适合求解无权图的最短路径