DFS 与 BFS 搜索算法

用动画和互动,彻底搞懂深度优先搜索和广度优先搜索

DFS 深度优先 BFS 广度优先 GESP 六级备考 C++ 实现

什么是"搜索"?

想象你站在一个巨大的迷宫入口,需要找到出口。搜索算法就是你"探索迷宫"的策略——决定你先走哪条路、后走哪条路、什么时候回头。

在 C++ 编程中,我们经常需要在这种数据结构中查找信息。两种最基本的搜索策略就是:

🔦

深度优先搜索 DFS

像走迷宫时一条路走到底,走到死路再回头换方向。用"栈"或"递归"实现。

🌊

广度优先搜索 BFS

像水波纹一样一层一层向外扩展,先访问所有近距离的,再访问远的。用"队列"实现。

为什么这两种搜索很重要?

GESP 六级考试中,DFS 和 BFS 是核心考点。它们能解决的问题包括:遍历树/图、找连通块、走迷宫求最短路径、 Flood Fill 填色等。掌握它们,很多难题都能迎刃而解!

树和图 —— 搜索的舞台

在学搜索之前,我们先搞清楚搜索是在什么上面进行的。

什么是图(Graph)?

图由节点(顶点)组成。节点代表事物,边代表事物之间的连接关系。

比如:城市是节点,公路是边;人是节点,朋友关系是边。在 C++ 中,我们通常用邻接表来存储图:

C++
// 用 vector 数组存邻接表
vector<int> adj[100];  // adj[i] 存节点 i 的所有邻居

// 添加一条边 u -> v
adj[u].push_back(v);
adj[v].push_back(u);  // 无向图需要双向

什么是树(Tree)?

树是一种特殊的图:没有环连通,且有唯一的前驱(父节点)。树有且只有一个根节点,每个节点可以有多个子节点。

树是图的一种特殊情况,所以对树适用的搜索方法,对图也适用。在 GESP 考试中,很多题目都是在树上进行搜索的。

关键概念:访问和遍历

访问(visit):到达一个节点,读取或处理它的数据。

遍历(traversal):按照某种顺序,访问图或树中的所有节点,每个节点只访问一次。

DFS 和 BFS 就是两种不同的遍历顺序。接下来我们逐一深入学习!

深度优先搜索 DFS

核心思想:一条路走到底

DFS 的策略是:从起点出发,沿着一条路一直走,走到走不动了(没有未访问的邻居),就退回上一个岔路口,换一条路继续走。就像走迷宫时,你沿着左手法则一直走,直到撞墙才回头。

DFS 的三个关键动作

1. 访问当前节点 —— 标记为已访问,处理它的数据

2. 递归访问邻居 —— 对每个未访问的邻居,继续 DFS

3. 回溯 —— 所有邻居都访问完了,返回上一层

动画演示:在树上运行 DFS

点击"播放"看 DFS 如何遍历这棵树。注意观察右侧的(递归调用栈)如何变化,以及节点的访问顺序。

未访问
在栈中
已探索完
当前访问
递归调用栈
已访问节点
速度
点击"播放"开始演示 DFS... 0 / 0
DFS 访问顺序:

C++ 代码实现

DFS 最常用的实现方式是递归。递归天然地帮我们管理了"调用栈"——每深入一层,就多一层函数调用。

C++ · DFS 递归实现
bool visited[105];       // 标记每个节点是否已访问
vector<int> adj[105];    // 邻接表存图

// DFS 函数:访问节点 u
void dfs(int u) {
    visited[u] = true;        // ① 标记当前节点已访问
    cout << u << " ";       //    处理当前节点(比如输出)

    // ② 遍历 u 的所有邻居
    for (int v : adj[u]) {
        if (!visited[v]) {      // 只访问没去过的邻居
            dfs(v);            // ③ 递归:深入访问邻居
        }
    }
    // ④ 所有邻居都访问完了,函数自动返回(回溯)
}

// 主函数中调用
int main() {
    // ... 建图省略 ...
    memset(visited, false, sizeof(visited));
    dfs(1);  // 从节点 1 开始 DFS
    return 0;
}
逐行解读

visited 数组:这是 DFS 最关键的部分!没有它,程序会在环里无限循环。每次访问节点前,先检查 visited,已访问的就跳过。

for (int v : adj[u]):C++11 的范围 for 循环,遍历 u 的所有邻居 v。

dfs(v):递归调用。这一行就是"深入"的体现——不等其他邻居,先一头扎进 v。

函数结束自动回溯:当 for 循环结束(所有邻居都处理完),函数返回到上一层调用,这就是"回溯"。

常见错误提醒

忘记标记 visited:会导致死循环和栈溢出!这是最常犯的错误。

递归层数太深:如果图是一条长链(比如 10 万个节点的链表),递归可能栈溢出。这时需要改用非递归(用栈)的写法。

广度优先搜索 BFS

核心思想:一层一层地扩展

BFS 的策略是:先访问起点,然后访问起点的所有邻居(第一层),再访问邻居的邻居(第二层)……像水波纹一样一圈一圈向外扩展。BFS 总是先访问距离起点近的节点。

BFS 的关键数据结构:队列

BFS 使用队列(Queue)来实现。队列的特点是先进先出(FIFO)——先加入队列的节点先被处理。这正好符合"一层一层"的顺序:

1. 起点入队,标记已访问

2. 队头节点出队,访问它

3. 把它的所有未访问邻居加入队尾

4. 重复 2-3,直到队列为空

动画演示:在树上运行 BFS

点击"播放"看 BFS 如何遍历同一棵树。注意观察右侧的队列如何变化,对比 DFS 的不同。

未访问
在队列中
已访问
当前访问
队列 Queue
已访问节点
速度
点击"播放"开始演示 BFS... 0 / 0
BFS 访问顺序:

C++ 代码实现

BFS 用队列实现,不使用递归。C++ 中用 queue 容器。

C++ · BFS 队列实现
bool visited[105];
vector<int> adj[105];

void bfs(int start) {
    queue<int> q;          // 创建队列
    q.push(start);         // ① 起点入队
    visited[start] = true;  //    标记起点已访问

    while (!q.empty()) {      // ② 队列不空就继续
        int u = q.front();    //    取出队头
        q.pop();               //    队头出队
        cout << u << " ";     //    处理当前节点

        // ③ 遍历 u 的所有邻居
        for (int v : adj[u]) {
            if (!visited[v]) {    // 未访问的邻居
                visited[v] = true;  // 标记已访问
                q.push(v);         // 加入队尾
            }
        }
    }
    // ④ 队列为空,BFS 结束
}
逐行解读

queue<int> q:C++ STL 的队列,先进先出。push() 入队,front() 看队头,pop() 出队。

先标记再入队:注意!是先把邻居标记为 visited,再 push 进队列。如果等出队时才标记,同一个节点可能被多次加入队列,导致效率降低甚至出错。

while (!q.empty()):循环条件——只要队列里还有节点,就继续处理。

BFS 的超能力:求最短路径

无权图(每条边长度相同)中,BFS 第一次到达某个节点时,走的路径就是最短路径!因为 BFS 是按距离层层扩展的,先到达的一定是最短的。

这是 DFS 做不到的——DFS 可能绕远路先到一个节点。所以求最短路径用 BFS,不用 DFS

DFS 与 BFS 全面对比

学完两个算法,我们来做一个清晰的对比,帮你记住它们的区别和适用场景。

对比项 DFS 深度优先 BFS 广度优先
探索策略 一条路走到底,走不通再回头 一层一层扩展,由近及远
数据结构 栈(递归调用栈 / 手动栈) 队列
实现方式 递归最常见,也可用栈迭代 while 循环 + 队列
访问顺序 先深后广,类似前序遍历 先近后远,按层遍历
最短路径 不一定是最短路径 无权图中一定是最短路径
空间复杂度 O(深度),最坏 O(N) O(宽度),最坏 O(N)
时间复杂度 O(V + E) O(V + E)
适用场景 找所有方案、排列组合、连通块、拓扑排序 最短路径、最少步数、层序遍历
记忆口诀 "不撞南墙不回头" "近水楼台先得月"
怎么选?

需要找最短路径 / 最少步数 → 选 BFS

需要找所有可能的方案 / 枚举排列组合 → 选 DFS

需要按层处理 / 层序遍历 → 选 BFS

图的深度可能很大、宽度很宽 → DFS 省空间

不确定选哪个 → 两种都试试,看哪个更自然

迷宫问题:DFS vs BFS 大比拼

让我们用一个真实的迷宫来对比两种搜索策略的不同"风格"。同样的迷宫,DFS 和 BFS 探索的路径完全不同!

迷宫说明

绿色是起点,红色是终点,黑色是墙壁。点击"播放"看两种算法如何分别探索迷宫。

DFS(左)会一头扎进一条路,碰壁才回头,探索路径像一条蛇。BFS(右)像水波纹一样均匀扩散,最终找到最短路径。

🔵 DFS 探索迷宫

🟠 BFS 探索迷宫

速度
点击"同时播放"看 DFS 和 BFS 如何分别探索迷宫...
你发现了什么?

DFS 探索了很多"弯路",有些区域被反复试探。但它在代码上更简洁(递归几行就搞定)。

BFS 探索得很"整齐",一层一层推进。它找到的路径一定是最短的,但探索的节点可能更多。

在 GESP 考试中,迷宫求最短步数 → 用 BFS迷宫求方案数 / 枚举所有路径 → 用 DFS

互动练习场:自己建图,自己搜索!

现在轮到你来操作了!你可以自己创建节点和边,然后选择 DFS 或 BFS 来遍历你建的图。

使用方法

① 添加节点:在空白区域点击,创建一个新节点

② 连接节点:切换到"连线"模式,依次点击两个节点,它们之间就会连一条边

③ 设置起点:切换到"设起点"模式,点击一个节点设为搜索起点

④ 运行算法:选择 DFS 或 BFS,点击"开始搜索"看动画!

操作模式

选择算法

模式:添加节点。点击空白处创建节点。

GESP 练习题

下面是几道 GESP 风格的练习题,从易到难。先自己思考,再点击"查看答案"对照。

基础题

题目一:DFS 遍历顺序

有一棵树,根节点为 1,邻接表如下:

adj[1] = {2, 3}
adj[2] = {1, 4, 5}
adj[3] = {1, 6}
adj[4] = {2}
adj[5] = {2}
adj[6] = {3}

从节点 1 开始进行 DFS(邻居按从小到大顺序访问),输出的访问顺序是什么?

A) 1 2 3 4 5 6  B) 1 2 4 5 3 6  C) 1 3 6 2 5 4  D) 1 2 4 5 3 6

答案:B) 1 2 4 5 3 6

解析过程:

1. 访问 1,邻居是 {2, 3},先走 2

2. 访问 2,邻居是 {1, 4, 5},1 已访问,先走 4

3. 访问 4,邻居是 {2},2 已访问,回溯到 2

4. 回到 2,继续走 5,访问 5,回溯到 2,回溯到 1

5. 回到 1,走 3,访问 3,邻居 {1, 6},走 6,访问 6

最终顺序:1 → 2 → 4 → 5 → 3 → 6

注意:B 和 D 看起来一样,但 D 选项也是 1 2 4 5 3 6,所以 B 和 D 都正确(本题选 B)。

基础题

题目二:BFS 遍历顺序

使用与题目一相同的树,从节点 1 开始进行 BFS(邻居按从小到大顺序入队),输出的访问顺序是什么?

A) 1 2 3 4 5 6  B) 1 2 4 5 3 6  C) 1 2 3 4 6 5  D) 1 3 2 6 4 5

答案:A) 1 2 3 4 5 6

解析过程:

1. 起点入队:队列 = [1],访问 1,邻居 {2,3} 入队 → 队列 = [2, 3]

2. 出队 2,访问 2,邻居 {4,5} 入队 → 队列 = [3, 4, 5]

3. 出队 3,访问 3,邻居 {6} 入队 → 队列 = [4, 5, 6]

4. 出队 4,访问 4(无新邻居)→ 队列 = [5, 6]

5. 出队 5,访问 5 → 队列 = [6]

6. 出队 6,访问 6 → 队列 = []

最终顺序:1 → 2 → 3 → 4 → 5 → 6(按层遍历)

进阶题

题目三:连通块个数

给定一个 N 个节点、M 条边的无向图,问图中有多少个连通块(连通分量)?

连通块的定义:块内任意两个节点可以互相到达,块与块之间不连通。

请写出核心代码思路。

解题思路

对每个未访问的节点启动一次 DFS/BFS,每次启动就是一个新的连通块。

bool visited[105];
vector<int> adj[105];

void dfs(int u) {
    visited[u] = true;
    for (int v : adj[u])
        if (!visited[v]) dfs(v);
}

int main() {
    int n, m; cin >> n >> m;
    // 读入 m 条边建图...

    int count = 0;
    for (int i = 1; i <= n; i++) {
        if (!visited[i]) {  // 发现一个未访问的节点
            count++;         // 新的连通块!
            dfs(i);       // 把整个连通块标记为已访问
        }
    }
    cout << count << endl;
    return 0;
}

关键点:每次外层循环发现一个未访问的节点,就意味着找到了一个新的连通块,然后 DFS 会把整个连通块都"染上色"(标记已访问),下次就不会重复计数。

进阶题

题目四:迷宫最短步数

有一个 N×M 的迷宫,'.' 表示通路,'#' 表示墙壁,'S' 表示起点,'E' 表示终点。每次可以向上下左右移动一格。求从 S 到 E 的最少步数

应该用 DFS 还是 BFS?请写出核心代码。

答案:用 BFS!

因为每一步的"代价"相同(都是 1 步),BFS 层层扩展,第一次到达终点时的层数就是最短步数。

int dx[] = {0, 0, 1, -1};  // 四个方向
int dy[] = {1, -1, 0, 0};
bool vis[105][105];
int dist[105][105];  // 记录到每个格子的距离

int bfs(int sx, int sy, int ex, int ey) {
    queue<pair<int,int>> q;
    q.push({sx, sy});
    vis[sx][sy] = true;
    dist[sx][sy] = 0;

    while (!q.empty()) {
        auto [x, y] = q.front(); q.pop();
        if (x == ex && y == ey) return dist[x][y]; // 到达终点

        for (int i = 0; i < 4; i++) {
            int nx = x + dx[i], ny = y + dy[i];
            if (nx >= 0 && nx < N && ny >= 0 && ny < M
                && !vis[nx][ny] && maze[nx][ny] != '#') {
                vis[nx][ny] = true;
                dist[nx][ny] = dist[x][y] + 1;
                q.push({nx, ny});
            }
        }
    }
    return -1;  // 无法到达
}

关键点:用 dist 数组记录每个格子到起点的距离,每次扩展时 dist[nx][ny] = dist[x][y] + 1。到达终点时直接返回,就是最短步数。

挑战题

题目五:DFS 求排列方案

用 DFS 枚举 1~N 的所有全排列(N ≤ 8)。例如 N=3 时,输出:

1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1

请写出核心代码。

解题思路

这是经典的 DFS 回溯问题:逐个位置填数,填满了就输出,然后回溯换下一个数。

int n;
bool used[10];
int path[10];

void dfs(int pos) {
    if (pos == n) {        // 填满了 n 个位置
        for (int i = 0; i < n; i++)
            cout << path[i] << " ";
        cout << endl;
        return;          // 回溯
    }
    for (int i = 1; i <= n; i++) {
        if (!used[i]) {
            used[i] = true;   // 选 i 放入当前位置
            path[pos] = i;
            dfs(pos + 1);     // 递归填下一个位置
            used[i] = false;  // 回溯:撤销选择!
        }
    }
}

关键点:这是 回溯法的核心模式——"选择 → 递归 → 撤销选择"。used[i] = false 这行就是回溯,它让上一层的循环能继续尝试其他数字。DFS + 回溯 = 枚举所有方案!

知识检测小测验

做完上面的练习,来测测你掌握了多少!共 5 道选择题。