CSP-J 初赛(以满分为目标):第二十六课 《图的遍历——DFS深度优先搜索——从“树的先序遍历”走进真正的图世界》


第二十六课:图的遍历------DFS深度优先搜索

------从"树的先序遍历"走进真正的图世界

上一课我们解决了一个重要问题:

图到底怎样存进计算机?

我们学习了两种方法:

复制代码
图
│
├── 邻接矩阵
│
└── 邻接表

但是把图存起来还不够。

假如我们有这样一张"城市地图":

复制代码
        A
       / \
      B   C
      |   |
      D---E
           \
            F

现在老师问:

"从 A 出发,把所有城市都访问一遍,而且每个城市只能访问一次,应该怎么办?"

这就是今天要学习的:

DFS------深度优先搜索(Depth First Search)

你会发现:

DFS其实和我们之前学过的二叉树先序遍历非常像。

这也是本课最重要的连接点。

我们把"从图的某个顶点出发,访问所有顶点,并使每个顶点仅被访问一次"称为图的遍历,并把遍历分为深度优先遍历和广度优先遍历;其中 DFS 的基本思想就是"仿树的先序遍历"。


一、什么叫"遍历"?

先不要急着学 DFS。

我们先理解:

遍历到底是什么意思?

比如班里有10个同学:

复制代码
小明
小红
小刚
小丽
......

老师说:

"请每个同学都点名一次,而且不能重复点名。"

这个过程就是:

遍历。

图也是一样。

假设:

复制代码
A ------ B ------ C
|    |
D    E

从A出发,我们希望:

复制代码
A
B
C
D
E

每个点都访问一次。

这就是:

图的遍历


二、为什么图的遍历比树麻烦?

我们以前学习二叉树:

复制代码
        A
       / \
      B   C
     / \
    D   E

树有一个特点:

不会绕回来形成环。

但是图可能有环:

复制代码
A ------ B
|    |
D ------ C

如果从A开始:

复制代码
A → B → C → D → A → B → C → ...

糟糕!

死循环了!

所以图的遍历必须解决一个非常重要的问题:

这个顶点以前来过没有?

这就是:

visited数组


三、DFS的核心思想:一条路走到底

假设我们来到一个迷宫:

复制代码
入口
 ↓
+---+
|   |
|   +---+
|       |
+-------+

你走迷宫的时候,如果采用一种策略:

看到一条路,就一直往里面走。

走到不能继续了:

退回来,再换一条路。

这就是:

深度优先

"深度"两个字非常重要。

它不是:

先把附近的人全部访问完。

而是:

先沿着一条路尽可能走深。


四、给小学生一个最形象的比喻

把图想象成一栋巨大的城堡。

你从:

复制代码
A房间

出发。

A有两个门:

复制代码
       A
      / \
     B   C

你选择B。

B又有门:

复制代码
       A
      / \
     B   C
    /
   D

你继续:

复制代码
A → B → D

D没有新的房间可以走了。

怎么办?

回退!

回到B。

B没有新的路了,再回到A。

然后:

复制代码
A → C

这就是 DFS。


五、DFS最重要的四个字

我特别建议学生们把:

"访问、深入、回退、换路"

记下来。

完整过程:

复制代码
访问当前点
    ↓
找一个没有访问过的邻居
    ↓
走过去
    ↓
继续深入
    ↓
走不动了
    ↓
回退
    ↓
换另一条路

六、先从一个最简单的图开始

我们看:

复制代码
A ------ B ------ D
|
C

从A开始。

假设邻接点按照字母顺序访问。

那么:

复制代码
A

先访问A。

然后发现:

复制代码
A → B

于是访问B:

复制代码
A → B

B发现:

复制代码
B → D

于是:

复制代码
A → B → D

D走不动了。

回退到B。

B没有其他没访问过的点。

回退到A。

A还有C。

于是:

复制代码
A → B → D → C

最终 DFS 顺序:

A,B,D,C


七、为什么要有 visited?

再看一个有环的图:

复制代码
A ------ B
|    |
D ------ C

如果没有 visited:

复制代码
A
↓
B
↓
C
↓
D
↓
A
↓
B
↓
C
↓
...

永远走不完。

所以我们需要:

复制代码
bool visited[100];

它就像每个城市门口的一块牌子:

复制代码
A:来过 ✓
B:来过 ✓
C:还没来过
D:还没来过

八、访问一个点时,第一件事情是什么?

假设我们来到:

复制代码
v

第一件事情:

复制代码
visited[v] = true;

意思:

"我已经来过v了。"

然后:

复制代码
cout << v;

表示:

"访问v。"

所以最基本的 DFS 框架是:

复制代码
void dfs(int v)
{
    visited[v] = true;

    cout << v << " ";

    // 再访问邻居
}

九、接下来找邻居

如果使用邻接矩阵:

复制代码
int g[100][100];

那么:

复制代码
g[v][i] == 1

表示:

v和i之间有边。

所以我们可以:

复制代码
for (int i = 0; i < n; i++)
{
    if (g[v][i] == 1)
    {
        // i是v的邻居
    }
}

十、但是邻居不能乱走

我们需要判断:

复制代码
if (g[v][i] == 1 && !visited[i])

意思是:

v和i有边,而且i以前没有访问过。

那么:

复制代码
dfs(i);

进入i继续搜索。

于是完整代码:

复制代码
void dfs(int v)
{
    visited[v] = true;

    cout << v << " ";

    for (int i = 0; i < n; i++)
    {
        if (g[v][i] == 1 && !visited[i])
        {
            dfs(i);
        }
    }
}

这就是我们今天最重要的一段代码。


十一、把代码解析一下

这一段:

复制代码
void dfs(int v)

意思:

"从v这个点出发,进行一次深度优先搜索。"


这一句:

复制代码
visited[v] = true;

意思:

"我来过v了,打个标记。"


这一句:

复制代码
cout << v << " ";

意思:

"访问v。"


这一段:

复制代码
for (int i = 0; i < n; i++)

意思:

"把v的所有可能邻居检查一遍。"


这一句:

复制代码
if (g[v][i] == 1 && !visited[i])

意思:

"如果i是v的邻居,而且我还没去过i......"


最后:

复制代码
dfs(i);

意思:

"那我就走到i那里,继续往深处探索!"


十二、这就是"递归"

同学们注意:

这里出现了:

复制代码
dfs(i);

而这个函数本身叫:

复制代码
dfs

也就是说:

DFS使用了递归!

这和我们之前学习的二叉树遍历产生了非常漂亮的联系。


十三、还记得二叉树先序遍历吗?

以前我们写:

复制代码
void preorder(Node* root)
{
    if (root == nullptr)
        return;

    cout << root->value << " ";

    preorder(root->left);

    preorder(root->right);
}

顺序:

复制代码
访问根
 ↓
左边深入
 ↓
右边深入

也就是:

根 → 左 → 右


十四、DFS和二叉树先序遍历有什么关系?

看二叉树:

复制代码
        A
       / \
      B   C
     / \
    D   E

先序:

复制代码
A → B → D → E → C

DFS也是:

访问当前点,然后选择一个没有访问过的邻居继续深入。

所以我们特别强调:

DFS的基本思想是"仿树的先序遍历过程"。

这其实是非常重要的学习桥梁:

复制代码
递归
  ↓
二叉树先序遍历
  ↓
DFS

十五、但是DFS和二叉树遍历有一个关键区别

二叉树中:

复制代码
root
├── left
└── right

最多两个孩子。

而图中:

复制代码
A
├── B
├── C
├── D
├── E
└── F

一个顶点可能有很多邻居。

所以:

复制代码
for (...)

就非常重要。

也就是说:

二叉树:递归访问左右孩子

图:循环寻找所有邻居,再递归访问


十六、用一个完整例子手算DFS

图:

复制代码
        0
       / \
      1   2
     / \   \
    3   4---5

我们规定:

邻居按照编号从小到大访问。

从0开始。


第一步:访问0

复制代码
0

visited:

复制代码
0 ✓
1
2
3
4
5

第二步:0找到1

复制代码
0 → 1

访问:

复制代码
0 1

第三步:1找到3

复制代码
0 → 1 → 3

访问:

复制代码
0 1 3

3没有新的邻居。

于是:

回退到1


第四步:1找到4

复制代码
0 → 1 → 4

现在:

复制代码
0 1 3 4

第五步:4找到5

复制代码
0 → 1 → 4 → 5

得到:

复制代码
0 1 3 4 5

第六步:5附近有什么?

5连接:

复制代码
2
4

4已经访问过。

2还没访问。

所以:

复制代码
0 → 1 → 4 → 5 → 2

最终:

0,1,3,4,5,2

这就是 DFS 的一种遍历结果。


十七、为什么说"DFS结果可能不唯一"?

这是初赛中很值得注意的一个问题。

假设:

复制代码
      A
     / \
    B   C

从A开始。

你可以先走:

复制代码
A → B → C

也可能先走:

复制代码
A → C → B

如果题目没有规定:

邻接点按照编号从小到大访问

那么 DFS 的具体访问顺序可能不同。

所以做程序阅读题时,一定要注意:

"邻居的访问顺序"是否被题目规定。

一般来说,不同起点下,会得到不同的 DFS 搜索结果。


十八、邻接矩阵版DFS

这是初学者适合掌握的版本:

复制代码
#include <iostream>
using namespace std;

const int N = 100;

int n;
int g[N][N];
bool visited[N];

void dfs(int v)
{
    visited[v] = true;

    cout << v << " ";

    for (int i = 0; i < n; i++)
    {
        if (g[v][i] == 1 && !visited[i])
        {
            dfs(i);
        }
    }
}

int main()
{
    cin >> n;

    // 读入图
    // ...

    dfs(0);

    return 0;
}

十九、如果使用邻接表呢?

上一课我们学习了:

复制代码
0:1 2
1:0 3 4
2:0
3:1
4:1

C++中可以写:

复制代码
vector<int> g[N];

那么 DFS 就变成:

复制代码
void dfs(int v)
{
    visited[v] = true;

    cout << v << " ";

    for (int u : g[v])
    {
        if (!visited[u])
        {
            dfs(u);
        }
    }
}

是不是比邻接矩阵更自然?

因为:

复制代码
for (int u : g[v])

直接就是:

"遍历v的所有邻居。"


二十、邻接矩阵版和邻接表版的区别

邻接矩阵

复制代码
for (int i = 0; i < n; i++)
{
    if (g[v][i] && !visited[i])
        dfs(i);
}

含义:

把所有顶点都检查一遍,看看谁是v的邻居。


邻接表

复制代码
for (int u : g[v])
{
    if (!visited[u])
        dfs(u);
}

含义:

只看v真正存在的邻居。

所以在实际图论算法中:

邻接表 + DFS

是非常常见的组合。


二十一、DFS为什么必须"标记访问"?

这是初学者容易忘记的一步。

错误代码:

复制代码
void dfs(int v)
{
    cout << v << " ";

    for (int i = 0; i < n; i++)
    {
        if (g[v][i] == 1)
        {
            dfs(i);
        }
    }
}

看起来好像没问题。

但是:

复制代码
A ------ B

从A:

复制代码
A → B

然后B又发现A:

复制代码
B → A

然后:

复制代码
A → B → A → B → A → B → ...

无限递归!

所以:

复制代码
visited[v] = true;

是DFS的生命线。


二十二、一定要先标记,再递归

正确:

复制代码
void dfs(int v)
{
    visited[v] = true;

    for (int u : g[v])
    {
        if (!visited[u])
        {
            dfs(u);
        }
    }
}

为什么?

因为:

复制代码
我一到达v
↓
马上宣布"v来过了"
↓
以后别人再遇到v
↓
就知道不能再进去

二十三、DFS的"递归栈"到底在干什么?

这部分可以和之前课的"函数调用栈"连接起来。

还是:

复制代码
0
|
1
|
3

执行:

复制代码
dfs(0);

然后:

复制代码
dfs(1);

然后:

复制代码
dfs(3);

此时函数栈大概是:

复制代码
┌─────────┐
│ dfs(3)  │ ← 当前
├─────────┤
│ dfs(1)  │
├─────────┤
│ dfs(0)  │
└─────────┘

3没有路了:

复制代码
dfs(3) return;

于是:

复制代码
┌─────────┐
│ dfs(1)  │ ← 回来了
├─────────┤
│ dfs(0)  │
└─────────┘

这就是:

DFS中的"回退"

所以我们之前学习函数调用栈,并不是白学的。

现在它真正派上用场了。


二十四、DFS = 走进去 + 递归 + 回退

可以画成:

复制代码
                 dfs(0)
                   │
                   ↓
                 dfs(1)
                   │
                   ↓
                 dfs(3)
                   │
                无路可走
                   │
                   ↓
                return
                   │
                   ↓
                 dfs(1)
                   │
                继续找路
                   │
                   ↓
                 dfs(4)

所以:

DFS的"深度"是递归造成的

不是说:

DFS一定要使用递归。

实际上DFS也可以用栈模拟。

但是对于初学者:

递归版DFS是最容易理解的。


二十五、DFS与"栈"的关系

这一点非常重要。

我们已经学过:

复制代码
栈 Stack

特点:

后进先出

DFS的回退过程:

复制代码
先进入:
A
B
D

回退:
D
B
A

也是:

后进去的先回来。

因此:

DFS天然具有栈的特点。

递归程序的函数调用栈,实际上就在帮助我们完成这件事。

以后如果学习"非递归DFS",就可以直接使用:

复制代码
stack<int>

来模拟。


二十六、DFS可以解决什么问题?

DFS不是只为了"输出一个遍历顺序"。

它非常强大。

例如:

1. 判断两个点是否连通

复制代码
A ------ B ------ C

从A DFS。

如果能访问到C:

A和C连通。


2. 统计连通块数量

复制代码
A---B---C       D---E       F

有:

复制代码
3个连通块

我们可以:

复制代码
从A DFS → 第一块
从D DFS → 第二块
从F DFS → 第三块

后面我们会专门学习。


3. 迷宫搜索

复制代码
S . . #
# . . #
# . . T

DFS可以尝试寻找:

S能不能到T?


4. Flood Fill

我们学习过泛洪算法:

复制代码
一块区域
↓
不断寻找相邻位置
↓
把整个连通区域找出来

本质上也是 DFS/BFS。

所以:

Flood Fill其实就是DFS/BFS思想在网格上的应用。


二十七、DFS最重要的程序模板

这个模板,同学们逐渐要做到"看到就认识"。

复制代码
void dfs(int v)
{
    visited[v] = true;

    // 访问 v
    cout << v << " ";

    for (int u : graph[v])
    {
        if (!visited[u])
        {
            dfs(u);
        }
    }
}

把它解析下:

复制代码
进入一个点
↓
标记"来过"
↓
处理这个点
↓
寻找所有邻居
↓
如果邻居没来过
↓
递归进入邻居

二十八、DFS的万能思维框架

以后看到DFS题,不要背代码。

先问自己4个问题:

问题1:从哪里开始?

复制代码
dfs(start)

问题2:怎么表示"来过"?

复制代码
visited[v]

问题3:一个点的邻居是谁?

可能是:

复制代码
邻接矩阵

也可能是:

复制代码
邻接表

问题4:没访问过的邻居怎么办?

复制代码
dfs(u);

只要这4个问题想清楚:

DFS就已经掌握了一大半。


二十九、CSP-J初赛常见考法

考法1:判断DFS顺序

给一张图:

复制代码
A ------ B
|    |
C ------ D

告诉你:

从A开始,邻接点按照字母顺序访问。

让你写DFS序列。

一定要:

  1. 标记A

  2. 找A的第一个未访问邻居

  3. 一路深入

  4. 无路后回退

  5. 再找其他邻居


三十、考法2:问某个点什么时候会被访问

重点观察:

DFS是不是已经深入到另一条分支。

千万不要把DFS理解成:

复制代码
A
B
C
D

简单从小到大扫描。

它不是普通循环。

它的核心是:

"先深入,再回退。"


三十一、考法3:程序阅读

例如:

复制代码
void dfs(int v)
{
    visited[v] = true;

    cout << v << " ";

    for (int u : g[v])
    {
        if (!visited[u])
            dfs(u);
    }
}

题目问:

输出的是什么?

马上识别:

复制代码
DFS遍历序列

三十二、考法4:为什么不会死循环?

看到:

复制代码
visited[v] = true;

以及:

复制代码
if (!visited[u])

就应该想到:

防止图中存在环导致无限递归。


三十三、一个特别重要的陷阱

"访问过"不等于"只调用一次函数"吗?

正确理解:

一个顶点:

复制代码
visited[v] = true

之后,其他地方再发现它:

复制代码
if (!visited[v])

条件不成立。

所以不会再次进入:

复制代码
dfs(v)

因此:

每个顶点最多真正进入DFS一次。

这也与" 每个顶点仅被访问一次 "的遍历定义相对应。


三十四、如果图不是连通图怎么办?

例如:

复制代码
A ------ B ------ C

D ------ E

F

从A开始:

复制代码
dfs(A);

只能访问:

复制代码
A B C

因为:

复制代码
D E

和:

复制代码
F

根本到不了。

所以:

一次DFS不一定能访问整个图。

如果题目要求:

"访问整个图中的所有顶点"

就可以:

复制代码
for (int i = 0; i < n; i++)
{
    if (!visited[i])
    {
        dfs(i);
    }
}

这段代码非常重要。


三十五、它实际上在干什么?

第一次:

复制代码
dfs(0);

找到第一块连通区域。

然后:

复制代码
for

继续寻找:

有没有哪个点还没访问?

如果找到:

复制代码
D

就:

复制代码
dfs(D);

找到第二块。

再找到:

复制代码
F

再:

复制代码
dfs(F);

于是:

复制代码
整个图
↓
一个一个连通块
↓
DFS全部扫完

这就是学习:

连通分量

的基础。


三十六、今天的知识地图

复制代码
                 图的遍历
                    │
          ┌─────────┴─────────┐
          │                   │
         DFS                 BFS
          │                   │
      深度优先              广度优先
          │                   │
       仿先序遍历          仿层序遍历
          │
       递归 / 栈
          │
     ┌────┼─────┐
     │    │     │
 visited 邻居  回退
     │
 防止重复访问

而整个知识链条是:

复制代码
二叉树
  ↓
先序遍历
  ↓
递归
  ↓
函数调用栈
  ↓
DFS
  ↓
图的遍历
  ↓
连通性 / 连通块 / 迷宫 / Flood Fill

三十七、课堂练习

练习1:判断DFS

图:

复制代码
    0
   / \
  1   2
 / \
3   4

从0开始,邻接点按照编号从小到大访问。

问:

DFS顺序是什么?

答案:

0,1,3,4,2


练习2:找出错误

下面DFS有什么问题?

复制代码
void dfs(int v)
{
    cout << v << " ";

    for (int u : g[v])
    {
        dfs(u);
    }
}

答案:

没有visited数组。

图中有环时可能无限递归。


练习3:填空

复制代码
void dfs(int v)
{
    ____________;

    cout << v << " ";

    for (int u : g[v])
    {
        if (____________)
        {
            dfs(u);
        }
    }
}

答案:

复制代码
visited[v] = true;

以及:

复制代码
!visited[u]

练习4:判断

下面说法正确的是:

A. DFS一定按照编号从小到大访问

B. DFS的核心思想是尽可能向深处搜索

C. DFS不需要visited数组

D. DFS只能用于树

答案:

B


三十八、第28课"必背知识卡"

① DFS是什么?

深度优先搜索(Depth First Search)


② DFS的核心思想

一条路尽可能走深,走不通再回退。


③ DFS与什么树遍历相似?

二叉树的先序遍历


④ DFS最重要的数据

复制代码
bool visited[N];

⑤ 为什么需要visited?

防止重复访问,避免图中有环时无限递归。


⑥ DFS的基本代码

复制代码
void dfs(int v)
{
    visited[v] = true;

    for (int u : g[v])
    {
        if (!visited[u])
        {
            dfs(u);
        }
    }
}

⑦ DFS和什么数据结构有关?

递归调用本质上利用了:

函数调用栈。


⑧ DFS的口诀

标记 → 访问 → 找邻居 → 没访问 → 递归 → 回退


三十九、在脑中留下一个重要的画面

让脑子里留下这个画面:

复制代码
              A
             / \
            B   C
           / \
          D   E

DFS不是:

复制代码
A
B
C
D
E

这样机械地扫。

而是:

复制代码
A
 ↓
B
 ↓
D
 ↑
B
 ↓
E
 ↑
B
 ↑
A
 ↓
C

一句话:

"先往深处走,走不动了就回来,再换一条路。"

这就是 DFS。


下一课 图的遍历------BFS广度优先搜索,正好与DFS形成最重要的一组对比:

复制代码
DFS                         BFS
深度优先                    广度优先
一条路走到底                一层一层扩散
递归 / 栈                   队列
像树的先序                  像树的层序
先深入                      先横向

学完这两个以后,再进入连通分量、最短路、迷宫、Flood Fill,大家会真正开始理解"图算法"为什么如此强大。


相关推荐
程序猿编码1 小时前
基于GGML的C++17轻量化语音推理引擎:说话人识别与语音分析技术全解析
开发语言·c++·pytorch·深度学习·神经网络·大模型
影视飓风TIM1 小时前
C++哈希表深度剖析:冲突处理、rehash、迭代器与unordered容器封装
c++·哈希算法·散列表
C++ 老炮儿的技术栈2 小时前
Qt5 使用 QPainter 绘制阿基米德螺线
开发语言·c++·windows·qt·代码化
xxwxx__2 小时前
深入理解 C++ STL:stack、queue 与 deque 从使用到底层实现全解析
开发语言·c++·算法
wuminyu2 小时前
JVM锁膨胀与Futex源码解析
java·linux·c语言·jvm·c++
郝学胜-神的一滴2 小时前
C++11 工程级应用 08:Lambda表达式与Tuple元组
开发语言·jvm·c++·python·程序人生·开源
水饺编程4 小时前
第5章,[Win32 章节] :圆角矩形教学插图绘制程序
c语言·c++·windows·visual studio
Existence_Time4 小时前
CSP 考前复习
c++
zerwave4 小时前
从零实现简化 DDS 完整技术文档(含模块作用与详细注释)
linux·c++·机器人