数据结构设计与开发 · 企业项目实训

Day04
DFS遍历与导游线路生成

深度优先搜索 · 递归原理 · 回溯补边算法

景区路径规划系统 · 90分钟

Learning Objectives

今日学习目标

🧠

知识目标

理解DFS"一条路走到黑"的核心思想
掌握递归三要素和调用栈机制
理解五大全局变量的作用
理解DFS生成树的树边与回边

💻

技能目标

独立实现DFS递归函数
正确处理p指针重置问题
实现isEdge和createGuideGraph
能手算DFS完整过程

🔧

工具目标

使用Trae Chat理解递归
使用Trae Builder生成代码
Git branch/merge分支管理
feature/dfs开发流程

今日重点:DFS递归三步曲  |  今日难点:p指针重置、回溯补边算法

Agenda

课程大纲

01

课前回顾

Day03邻接表、8景点9道路

02

DFS概念与思想

走迷宫比喻、生活中的DFS

03

递归基础

递归三要素、调用栈、阶乘例子

04

五大全局变量

visited、path、indexp、p、parray

05

DFS代码实现

三步曲、p重置、DFSTraverse

06

手算DFS全过程

8景点逐步骤推演

07

导游线路生成

isEdge、回溯补边算法

08

Git分支 + 错误调试 + 总结

feature/dfs、常见错误、作业

Part 01

课前回顾

Day03我们建好了什么?

Review · Day03

Day03成果:景区图已经建好!

✅ Day03完成了3个核心函数:

createGraph() — 硬编码创建8个景点、9条道路
locate() — 根据景点名字查找数组下标
printGraph() — 构建邻接矩阵parray并打印

📁 文件结构:

Day04/
  ├── global.h  // 结构体定义
  ├── graph.h/c // 建图、打印、导游图
  ├── travels.h/c // DFS遍历(今天写)
  ├── menu.h/c  // 菜单(Day06写)
  └── main.h/c  // 主函数

🏛️ 核心数据结构:

// 边结点(链表)
typedef struct stCNode {
    int index;           // 邻接点下标
    struct stCNode *next;// 下一条边
    int length;          // 路径长度
} CNode;

// 顶点结点(数组)
typedef struct stHNode {
    char data[10];      // 景点名
    CNode *first;       // 第一条边
} HNode;

// 图结构体
typedef struct {
    HNode roadlist[MAXNUM];//顶点数组
    int nodenum, edgenum; // 顶点数、边数
} ALGraph;

Data

8个景点 & 9条道路

📍 8个景点(索引0-7):

0 大门
1 红叶林
2 白虎岩
3 九曲桥
4 碧玉潭
5 飞云石
6 观月阁
7 赏花园

🛤️ 9条道路(无向边):

道路长度(米)连接道路长度(米)
大门 — 红叶林500九曲桥 — 飞云石200
红叶林 — 白虎岩300飞云石 — 观月阁200
白虎岩 — 九曲桥200碧玉潭 — 赏花园400
九曲桥 — 碧玉潭300观月阁 — 赏花园200
红叶林 — 碧玉潭1300
注意:红叶林-碧玉潭是一条远路(1300米),DFS会优先走红叶林→白虎岩→九曲桥→碧玉潭这条近路

Part 02

DFS深度优先搜索

从"走迷宫"开始理解

Metaphor · 走迷宫

DFS就是"走迷宫"的策略!

起点
终点
走过的路

🧭 走迷宫的策略:

1
从入口出发,选择一条路一直往前走(不回头)
2
遇到岔路口,任选一条继续深入
3
走到死胡同(所有方向都走过了)→ 后退一步
4
后退到岔路口,看有没有没走过的路
5
如果有,走那条新路继续深入;如果没有,继续后退
这就是DFS! 一条路走到黑 → 碰壁 → 回溯 → 换条路 → 再走到黑

Core Idea

DFS核心思想:一条路走到黑

🎯 Depth-First Search 深度优先搜索
从起点出发,访问当前顶点
选择一个未访问的邻居,递归深入
一直深入,直到没有未访问邻居
🔙 回溯!返回上一层,看有没有其他邻居
直到回溯到起点,所有顶点都已访问 → 结束!
🚶
深入

优先往深处走,不撞南墙不回头

🔙
回溯

走不通就后退,回到岔路口

🔄
重复

深入→回溯→深入,直到全部访问

Real World Examples

生活中处处是DFS!

🗂️
找文件

打开文件夹→进入子文件夹→再进入→...→找不到→返回→下一个子文件夹

🕸️
网络爬虫

打开网页→点链接→再点链接→...→到底→返回→点下一个链接

🎮
迷宫游戏

走迷宫就是DFS!左手定则/右手定则就是DFS的具体实现

♟️
棋类AI

下棋时推演:我走这步→对手走那步→我再走→...→评估优劣→回溯

🧩
数独求解

填一个数→递归填下一个→矛盾→回溯→换个数重试

🌲
森林寻宝

沿着一条路找到底→没找到→回来→走另一条路继续找

总结:只要是"尝试→深入→失败→回退→换方案再试"的模式,本质上都是DFS思想!

Comparison

DFS vs BFS:两种遍历策略

🚀 DFS 深度优先

一条路走到黑,再回溯

策略:纵向深入,后访问的先探索
数据结构:栈(递归调用栈)
顺序:0→1→2→3→5→6→7→4...
适合:拓扑排序、连通分量、迷宫
记忆:不撞南墙不回头

🌊 BFS 广度优先

层层递进,先近后远

策略:横向扩展,先访问的先探索
数据结构:队列
顺序:0→1→4→2→3→7→5→6...
适合:最短路径(无权图)、层序遍历
记忆:广撒网,层层递进
我们今天学DFS,因为DFS实现简单(递归),而且导游线路需要"一条路走到底"的效果。BFS会在Day05拓扑排序中接触到。

Part 03

递归基础

理解递归是理解DFS的前提

Recursion

什么是递归?

递归

函数自己调用自己

递归的危险:如果没有终止条件,会无限循环→栈溢出→程序崩溃!
递归的精髓:把大问题分解成同样结构的小问题

🌰 举个通俗的例子:

电影院问排数

你坐在第N排,想知道自己是第几排,但太黑看不见。

→ 你问前面的人:"你是第几排?"

→ 前面的人也看不见,问他前面的人

→ ...

→ 问到第一排的人,他说:"我是第1排!"

→ 第二排的人知道了:"我是第2排!"

→ ...

→ 答案传回来,你知道自己是第N排!

这就是递归:递(问出去)→ 归(传回来)

3 Elements

递归三要素

1

明确的终止条件(Base Case)

什么时候停止递归,直接返回结果?
👉 第一排的人直接回答"第1排",不需要再问别人
👉 DFS中:顶点没有未访问邻居时,自动返回

2

递归调用(Recursive Case)

如何把大问题变小,然后调用自己?
👉 "你在第几排?"→ 前面人的排数 + 1
👉 DFS中:访问邻居 → DFS(邻居)

3

问题规模在缩小

每次递归调用,问题必须比原来更小,最终能到达终止条件
👉 排数每次-1,最终会到第1排
👉 DFS中:visited标记后,未访问顶点越来越少

记住:写递归函数时,先写终止条件,再写递归逻辑!

Example: Factorial

递归例子:求n的阶乘

数学定义:

n! = n × (n-1) × (n-2) × ... × 1

1! = 1  // 终止条件

n! = n × (n-1)!  // 递归公式

以5!为例:

5! = 5 × 4!
4! = 4 × 3!
3! = 3 × 2!
2! = 2 × 1!
1! = 1 ← 终止!

C语言代码:

int factorial(int n) {
    // 1. 终止条件
    if (n == 1) {
        return 1;
    }
    // 2. 递归调用:n × (n-1)!
    return n * factorial(n-1);
}

返回过程:

5! = 5 × 24 = 120 ✓
4! = 4 × 6 = 24
3! = 3 × 2 = 6
2! = 2 × 1 = 2
1! = 1

Call Stack

递归调用栈:后进先出

递归背后是栈(Stack)在工作 —— 每次调用压栈,返回时出栈

📥 递:调用过程(压栈)

factorial(5) 调用 factorial(4)
factorial(4) 调用 factorial(3)
factorial(3) 调用 factorial(2)
factorial(2) 调用 factorial(1)
factorial(1) = 1 (终止)

↑ 栈顶:最后调用的先执行完

📤 归:返回过程(出栈)

factorial(5) 返回 120 ✓
factorial(4) 返回 24
factorial(3) 返回 6
factorial(2) 返回 2
factorial(1) = 1

↓ 从栈顶开始依次返回

栈溢出(Stack Overflow):递归深度太大(比如10000层),栈空间不够用→程序崩溃。我们的图只有8个顶点,递归深度最多8层,完全不用担心!

Part 04

五大全局变量

DFS递归需要它们之间的配合

Global Variables

为什么需要全局变量?

问题:DFS是递归函数,每次递归调用时,局部变量会重新创建。但我们需要在多层递归之间共享一些数据(哪些顶点访问过了?路径记录到哪了?)
解决方案:把需要共享的数据定义为全局变量,所有递归调用访问同一份数据!

📋 Day04需要的五大全局变量:

变量名类型定义位置作用
visitedint数组graph.c标记顶点是否已访问
parraydouble**graph.c邻接矩阵(判断两点间是否有边)
pathchar二维数组travels.c记录DFS访问顺序(景点名)
indexpinttravels.cpath数组的当前下标(游标)
pCNode*travels.c遍历邻接表用的指针(重点难点!)
注意:travels.c中需要用extern声明graph.c中定义的visitedparray

Global 1: visited[]

visited[]:访问标记数组

📝 定义:

// graph.c 中定义
int visited[MAXNUM];  // MAXNUM=8

// travels.c 中声明
extern int visited[MAXNUM];

🎯 作用:

visited[i] = 0 → 景点i 未访问
visited[i] = 1 → 景点i 已访问

👁️ 可视化:

初始状态(全0):

0大门
0红叶林
0白虎岩
0九曲桥
0碧玉潭
0飞云石
0观月阁
0赏花园

访问大门、红叶林后:

1大门
1红叶林
0白虎岩
0九曲桥
0碧玉潭
0飞云石
0观月阁
0赏花园

DFS完成后(全1):

1大门
1红叶林
1白虎岩
1九曲桥
1碧玉潭
1飞云石
1观月阁
1赏花园
类比:visited就像迷宫里的粉笔标记——走过的路口画个叉,避免重复走、绕圈子!

Global 2&3: path[] & indexp

path[] + indexp:记录访问路径

📝 定义:

// travels.c 中定义
char path[MAXNUM][10];  // 存景点名
int indexp = 0;        // 数组下标

🎯 作用:

path[] 按顺序记录DFS走过的景点
indexp 指向下一个要填的位置
每访问一个新景点:
  strcpy(path[indexp++], 景点名);

👁️ 可视化(DFS进行中):

已访问5个景点,indexp=5:

大门0
红叶林1
白虎岩2
九曲桥3
飞云石4
?5
6
7

↑ indexp指向这里,下一个景点填这里

DFS完成后:

大门0
红叶林1
白虎岩2
九曲桥3
飞云石4
观月阁5
赏花园6
碧玉潭7

indexp = 8(所有景点都记录了)

注意:DFSTraverse入口函数会把indexp重置为0,visited清零!每次重新DFS都是干净的状态。

Global 4: p (重点难点!)

p指针:遍历邻接表的"手指"

⚠️ p指针是今天的最大难点! 也是新手最容易出错的地方!

📝 定义:

// travels.c 中定义
CNode *p;  // 全局边指针!

🤔 为什么p必须是全局的?

如果p是DFS函数的局部变量
递归深入时,每层DFS都有自己的p
递归返回后,上层的p还停留在之前的位置→混乱!

p是全局的:
所有递归层共享同一个p
递归返回后,我们可以重置p到开头重新遍历!

👁️ p是怎么工作的:

以"红叶林"的邻接表为例:

红叶林
碧玉潭
白虎岩
NULL

p的移动过程:

① p = first → 指向 碧玉潭(已访问?)

② p = p->next → 指向 白虎岩(未访问!→ 递归)

③ 递归返回后 → p = first 重置!

④ 再从头检查还有没有未访问的邻居

类比:p就像你的手指,指着邻接表上当前检查到哪个邻居了。递归回来后,手指可能乱了,所以要重新指向表头!

Global 5: parray

parray:邻接矩阵(判断边)

📝 定义:

// graph.c 中定义
double **parray;  // 二维数组

// travels.c 中声明
extern double **parray;

🎯 作用:

parray[i][j] = 0 → i == j(自己到自己)
parray[i][j] < INF → i和j之间有边,值是长度
parray[i][j] = INF → i和j之间没边

INF是一个很大的数(如99999),代表"无穷大"即不通

👁️ 邻接矩阵示例:

大门红叶林白虎岩九曲桥碧玉潭飞云石观月阁赏花园
大门0500INFINFINFINFINFINF
红叶林5000300INF1300INFINFINF
白虎岩INF3000200INFINFINFINF
九曲桥INFINF2000300200INFINF
碧玉潭INF1300INF3000INFINF400
飞云石INFINFINF200INF0200INF
观月阁INFINFINFINFINF2000200
赏花园INFINFINFINF400INF2000
今天parray的用途:isEdge()函数快速判断两个景点之间有没有直接道路——给导游线路生成时"补边"用!

Part 05

DFS代码实现

三步曲:标记→递归→回溯

DFS in 3 Steps

DFS递归三步曲

1

📍 标记当前顶点

visited[i] = 1;  (标记已访问,防止重复)
strcpy(path[indexp++], graph.roadlist[i].data);  (记录到路径)

2

🚶 遍历邻居,递归深入

for循环遍历邻接表的每个邻居p→index
如果邻居未访问 → DFS(graph, p→index) 递归!
递归返回后 → p = graph.roadlist[i].first; 重置!

3

🔙 自动回溯

for循环结束,没有未访问的邻居了
函数自动返回到上一层递归
不需要写任何代码!

记住三步曲,DFS代码就不会写错!

Full Code

DFS函数完整代码

/* DFS递归函数 —— 从顶点i开始深度优先遍历 */
void DFS(ALGraph graph, int i)
{
    // ======== 第1步:标记当前顶点 ========
    visited[i] = 1;                                    // 标记:已访问
    strcpy(path[indexp++], graph.roadlist[i].data);    // 记录到路径

    // ======== 第2步:遍历邻居,递归深入 ========
    for (p = graph.roadlist[i].first; p != NULL; p = p->next)
    {
        if (!visited[p->index])                         // 邻居未访问?
        {
            DFS(graph, p->index);                       // 🔴 递归深入!
            p = graph.roadlist[i].first;               // 🟡 递归返回后重置p!
        }
    }

    // ======== 第3步:自动回溯(不需要写代码)========
    // for循环结束 → 无未访问邻居 → 函数自动返回
}
红色高亮是递归调用,黄色高亮是p指针重置——这是最容易忘的一行!绝对不能漏!

Entry Function

DFSTraverse:遍历入口函数

📝 为什么需要DFSTraverse?

DFS函数是从某个顶点开始遍历
但图可能有多个连通分量(不连通的部分)
DFSTraverse负责:
① 初始化(清空visited、重置indexp)
② 对每个未访问顶点调用DFS
③ 保证所有顶点都被访问到
我们的景区图是连通的,所以只会调用一次DFS(0)。但代码要写得通用!

💻 DFSTraverse代码:

void DFSTraverse(ALGraph graph)
{
    int i;

    // ① 初始化
    indexp = 0;
    for (i = 0; i < graph.nodenum; i++)
        visited[i] = 0;

    // ② 遍历每个顶点
    for (i = 0; i < graph.nodenum; i++)
        if (!visited[i])
            DFS(graph, i);  // 未访问→DFS
}
注意传值:参数是ALGraph graph(传值),不是指针——和原始源码保持一致!

Why Reset p?

为什么递归返回后必须重置p?

这是最关键的问题! 90%的新手bug都来源于忘记重置p!

❌ 如果不重置p(错误):

DFS(红叶林):p指向碧玉潭

  p→碧玉潭已访问?检查后跳过

  p=p->next → 指向白虎岩

  白虎岩未访问 → 调用DFS(白虎岩)

  ... 深入递归很多层 ...

  DFS(白虎岩)返回

  p还停留在NULL!

  执行p=p->next → 访问NULL崩溃!💥

  或者:漏掉后面的邻居没检查!

✅ 重置p(正确):

DFS(红叶林):p指向碧玉潭

  p→碧玉潭已访问?跳过

  p=p->next → 指向白虎岩

  白虎岩未访问 → 调用DFS(白虎岩)

  ... 深入递归很多层 ...

  DFS(白虎岩)返回

  p = first!回到链表开头!

  重新遍历:碧玉潭、白虎岩...

  检查还有没有其他未访问邻居

  没有了 → 正常返回 ✓

原因:递归深入过程中,全局指针p被一层层递归函数修改得面目全非。返回后p指向哪里谁也不知道,必须重新定位到邻接表开头,才能正确检查剩余邻居!

Parameter Passing

为什么DFS传值而不是传指针?

📋 参数写法:

void DFS(ALGraph graph, int i)
//     ^^^^^^^^^^^ 传值!不是ALGraph *graph

🤔 为什么不传递指针?

DFS只是读取图的结构,不修改图
传值是安全的,不会意外修改原图
和原始源码保持一致
结构体拷贝虽然有开销,但8个顶点很小,无所谓

⚖️ 对比:createGraph为什么传指针?

void createGraph(ALGraph *graph)
//              ^^^^^^^^^^^^ 传指针!

因为createGraph要修改图:

需要给graph->nodenum赋值
需要给roadlist[i].first分配内存
需要修改图的内容 → 必须传指针
只读传值,修改传指针——这是C语言惯例!
原则:如果函数不需要修改参数,就传值;如果要修改,就传指针。DFS只是遍历读取,所以传值。

Part 06

手算DFS全过程

跟着步骤走一遍,你就完全懂了

Adjacency List

头插法导致的邻接表实际顺序

重要提醒:createGraph用头插法添加边!后插入的边在链表前面!所以邻接表的顺序和边的输入顺序相反
0 大门
红叶林
NULL
1 红叶林
碧玉潭
白虎岩
大门
NULL
2 白虎岩
九曲桥
红叶林
NULL
3 九曲桥
飞云石
碧玉潭
白虎岩
NULL
4 碧玉潭
赏花园
九曲桥
红叶林
NULL
5 飞云石
观月阁
九曲桥
NULL
6 观月阁
赏花园
飞云石
NULL
7 赏花园
观月阁
碧玉潭
NULL
每个顶点的第一个邻居(橙色高亮)就是p初始指向的位置,DFS会优先访问它!

Step 0-1

手算DFS:开始 & 访问大门

Step 0:初始状态

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited全0,path空,indexp=0

(空)

Step 1:调用DFS(大门=0)

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited[0]=1,path[0]="大门",indexp=1

大门0
1
2
3
4
5
6
7
DFS(大门) p=红叶林
p指向大门的第一个邻居→红叶林,红叶林未访问→递归!

Step 2-3

手算DFS:深入红叶林→白虎岩

Step 2:递归到DFS(红叶林=1)

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited[1]=1,path[1]="红叶林",indexp=2

大门0
红叶林1
2
3
4
5
6
7
DFS(大门)
DFS(红叶林) p=碧玉潭
p先指向碧玉潭?但碧玉潭有边(1300米)→等等,碧玉潭未访问吗?先看p的顺序:红叶林邻接表是碧玉潭→白虎岩→大门。p先到碧玉潭,碧玉潭确实未访问?
不对!让我们仔细看——实际上,让我们继续:碧玉潭未访问的话应该递归碧玉潭?但等一下我们看实际运行结果...

等等!实际上头插法的顺序是:

边的插入顺序是:大门-红叶林→红叶林-白虎岩→...→最后插入红叶林-碧玉潭。头插法会把最后插入的边放最前面!所以p第一个遇到的是碧玉潭
但是让我们看实际代码运行逻辑——碧玉潭未访问,应该先去碧玉潭?不对,我们需要仔细推演。

Important Correction

重要:邻接表顺序详细推演

让我们重新仔细推导邻接表的构建过程——这是手算DFS的基础!

边的插入顺序(共9条,按代码顺序):

#插入到顶点插入后链表(头插法,新节点在最前面!)
1大门-红叶林大门 / 红叶林大门: 红叶林→NULL  |  红叶林: 大门→NULL
2红叶林-白虎岩红叶林 / 白虎岩红叶林: 白虎岩→大门→NULL
3白虎岩-九曲桥白虎岩 / 九曲桥白虎岩: 九曲桥→红叶林→NULL
4九曲桥-碧玉潭九曲桥 / 碧玉潭九曲桥: 碧玉潭→NULL
5九曲桥-飞云石九曲桥 / 飞云石九曲桥: 飞云石→碧玉潭→白虎岩→NULL
6飞云石-观月阁飞云石 / 观月阁飞云石: 观月阁→九曲桥→NULL
7碧玉潭-赏花园碧玉潭 / 赏花园碧玉潭: 赏花园→九曲桥→NULL
8观月阁-赏花园观月阁 / 赏花园观月阁: 赏花园→飞云石→NULL  |  赏花园: 观月阁→碧玉潭→NULL
9红叶林-碧玉潭红叶林 / 碧玉潭红叶林: 碧玉潭→白虎岩→大门→NULL  |  碧玉潭: 红叶林→赏花园→九曲桥→NULL
结论:第9条边(红叶林-碧玉潭)是最后插入的!所以红叶林的第一个邻居是碧玉潭,碧玉潭的第一个邻居是红叶林!那DFS顺序是大门→红叶林→碧玉潭?让我们继续推演!

Step 2-3

手算DFS:大门→红叶林→碧玉潭

Step 2:DFS(红叶林=1)

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited[1]=1,path[1]="红叶林",p指向第一个邻居→碧玉潭

碧玉潭未访问!→ 递归!

DFS(大门)
DFS(红叶林) p→碧玉潭

Step 3:DFS(碧玉潭=4)

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited[4]=1,path[2]="碧玉潭",indexp=3

p指向碧玉潭的第一个邻居→红叶林(已访问)→ p=p->next→赏花园

赏花园未访问!→ 递归!

大门0
红叶林1
碧玉潭2
3
4
5
6
7
DFS(大门)
DFS(红叶林)
DFS(碧玉潭) p→赏花园

Step 4-6

手算DFS:赏花园→观月阁→飞云石

Step 4:DFS(赏花园=7)

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited[7]=1,path[3]="赏花园",p→观月阁(未访问)→ 递归

Step 5:DFS(观月阁=6)

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited[6]=1,path[4]="观月阁",p→赏花园(已访问)→ p->next→飞云石(未访问)→ 递归

Step 6:DFS(飞云石=5)

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited[5]=1,path[5]="飞云石",p→观月阁(已访问)→ p->next→九曲桥(未访问)→ 递归

大门0
红叶林1
碧玉潭2
赏花园3
观月阁4
飞云石5
6
7
DFS(大门)
DFS(红叶林)
DFS(碧玉潭)
DFS(赏花园)
DFS(观月阁)
DFS(飞云石) p→九曲桥

栈深度=6,继续深入!

Step 7-8

手算DFS:九曲桥→白虎岩(到达最深!)

Step 7:DFS(九曲桥=3)

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited[3]=1,path[6]="九曲桥",p→飞云石(已访问)→ p->next→碧玉潭(已访问)→ p->next→白虎岩(未访问!)→ 递归

Step 8:DFS(白虎岩=2) 🎯 最深层!

大门
红叶林
白虎岩
九曲桥
碧玉潭
飞云石
观月阁
赏花园

visited[2]=1,path[7]="白虎岩",indexp=8!

p→九曲桥(已访问)→ p->next→红叶林(已访问)→ p->next→NULL

🔙 for循环结束!没有未访问邻居了!开始回溯!

大门0
红叶林1
碧玉潭2
赏花园3
观月阁4
飞云石5
九曲桥6
白虎岩7
DFS(大门)
DFS(红叶林)
DFS(碧玉潭)
DFS(赏花园)
DFS(观月阁)
DFS(飞云石)
DFS(九曲桥)
DFS(白虎岩) 无邻居!返回!

栈深度=8(最大值),所有顶点都访问了!

Backtracking

手算DFS:开始回溯!

回溯开始:白虎岩返回九曲桥→九曲桥返回飞云石→飞云石返回观月阁→观月阁返回赏花园→赏花园返回碧玉潭→碧玉潭返回红叶林→红叶林返回大门

回溯过程中每层都要做的事:

1
递归返回后,执行 p = graph.roadlist[i].first 重置p
2
继续for循环:p=p->next,检查下一个邻居
3
如果所有邻居都已访问(visited=1),for循环结束,继续返回上一层

📋 我们的例子中:

白虎岩返回九曲桥 → p重置→检查所有邻居:飞云石(✓)、碧玉潭(✓)、白虎岩(✓) → 全访问过→返回
九曲桥返回飞云石 → p重置→检查邻居:观月阁(✓)、九曲桥(✓) → 全访问→返回
飞云石返回观月阁 → p重置→检查邻居:赏花园(✓)、飞云石(✓) → 全访问→返回
观月阁返回赏花园 → p重置→检查邻居:观月阁(✓)、碧玉潭(✓) → 全访问→返回
赏花园返回碧玉潭 → p重置→检查邻居:红叶林(✓)、赏花园(✓)、九曲桥(✓) → 全访问→返回
碧玉潭返回红叶林 → p重置→检查邻居:碧玉潭(✓)、白虎岩(✓)、大门(✓) → 全访问→返回
红叶林返回大门 → p重置→检查邻居:红叶林(✓) → 全访问→返回
大门返回DFSTraverse → DFS完成!🎉

Final Result

DFS遍历结果

🎉 最终访问顺序:

0 大门
1 红叶林
4 碧玉潭
7 赏花园
6 观月阁
5 飞云石
3 九曲桥
2 白虎岩
path数组内容
["大门", "红叶林", "碧玉潭", "赏花园", "观月阁", "飞云石", "九曲桥", "白虎岩"]
注意:这是DFS生成的访问顺序,但还不是完整的导游线路!因为有些相邻景点在path中不挨着(比如白虎岩后面没有路直接回去),需要回溯补边才能形成完整的导游路线!

Part 07

导游线路生成

回溯补边算法:把DFS路径变成完整导游路线

Why Fill Edges?

为什么需要"回溯补边"?

🤔 问题:

DFS的path数组是:
大门→红叶林→碧玉潭→赏花园→观月阁→飞云石→九曲桥→白虎岩

但问题是:
❌ path中相邻的两个景点之间不一定有直接道路
例如:走完白虎岩就结束了?游客怎么回到大门?
例如:回溯时我们是"走回来"的,但path没有记录回来的路!

✅ 解决方案:回溯补边

在DFS回溯的过程中,"走过的回头路"也要记录下来!

算法思路:
① 从path[i]到path[i+1],如果有直接边→直接走
② 如果没直接边→后退(k--)到之前经过的景点
③ 每退一步就把这个景点加入guidePath
④ 直到找到和path[i+1]有直接边的景点
⑤ 这样就把回溯的路径也记录下来了!
类比:DFS就像你去深山探险,一路往里走。但你要给别人画路线图,不能只画进去的路,还要画怎么退回来的路,这样才能走完整条环线回到起点!

Helper Function

isEdge函数:判断两点间有没有边

💻 isEdge代码:

/* 判断e1和e2之间是否有直接道路 */
int isEdge(ALGraph graph, char *e1, char *e2)
{
    int i = locate(graph, e1);
    int j = locate(graph, e2);
    if (parray[i][j] < INF)
        return 1;  // 有边
    return 0;      // 没边
}

🎯 工作原理:

① 先用locate()把景点名转成下标i,j
② 查邻接矩阵parray[i][j]
③ 如果值 < INF → 有边,返回1
④ 如果值 == INF → 没边,返回0

📖 例子:

isEdge(graph, "大门", "红叶林") → parray[0][1]=500 < INF → 返回1(有边)
isEdge(graph, "白虎岩", "红叶林") → parray[2][1]=300 < INF → 返回1(有边)
isEdge(graph, "白虎岩", "碧玉潭") → parray[2][4]=INF → 返回0(没直接边)
注意:isEdge是补边算法的工具函数——createGuideGraph用它来判断path中相邻景点是否需要补回溯路径。

Guide Path Generation

createGuideGraph:第一步 补全guidePath

void createGuideGraph(ALGraph *graph, ALGraph *guideGraph)
{
    int i=0, j=0, k=0, n=0, flag=0, edgenum=0, m=0;
    CNode *node1, *node2;

    // ① 先做DFS,得到path数组
    DFSTraverse(*graph);

    // ② 回溯补边:填充guidePath数组
    for (i = 0; i < graph->nodenum - 1; i++)
    {
        k = 0;
        flag = 1;
        while (flag)
        {
            guidePath[n++] = path[i + k];  // 加入当前景点
            if (isEdge(*graph, path[i+k], path[i+1]))
                flag = 0;  // 有直接边,停止回溯
            else
                k--;         // 没边,后退一步(回溯)
        }
    }
    guidePath[n] = path[i];  // 加入最后一个景点

    // ③ 打印导游路线
    printf("导游路线为:\n");
    for (i = 0; i <= n; i++)
        printf("%s-", guidePath[i]);
    printf("\n");
补边逻辑核心:对path中每一步path[i]→path[i+1],如果有边就直接走;如果没边,就往回走(k--),每走一步就加入guidePath,直到找到能直接到path[i+1]的点。这就把回溯路径也记录下来了!

Build Guide Graph

createGuideGraph:第二步 构建导游图

    // ④ 初始化guideGraph顶点
    for (i = 0; i <= n; i++)
        visited[i] = 0;
    for (i = 0; i < graph->nodenum; i++)
    {
        strcpy(guideGraph->roadlist[i].data, graph->roadlist[i].data);
        guideGraph->roadlist[i].first = NULL;
    }

    // ⑤ 根据guidePath构建导游图的边
    for (k = 0; k < n; k++)
    {
        i = locate(*graph, guidePath[k]);
        j = locate(*graph, guidePath[k+1]);
        if (visited[j])
        {
            // j已访问:这是回边,跳过?不,找近路...
            m = k + 2;
            if (m <= n)
            {
                while (visited[locate(*graph, guidePath[m])])
                {
                    if (isEdge(*graph, guidePath[k], guidePath[m]))
                    {
                        j = locate(*graph, guidePath[m]);
                        // 添加边 i→j
                        node1 = (CNode*)malloc(sizeof(CNode));
                        node1->index = j;
                        node1->length = (int)parray[i][j];
                        node1->next = guideGraph->roadlist[i].first;
                        guideGraph->roadlist[i].first = node1;
                        edgenum++;
                    }
                    if (++m >= n) break;
                }
            }
        }
        else
        {
            // j未访问:这是树边(DFS前进的边)
            visited[i] = 1;
            visited[j] = 1;
            node1 = (CNode*)malloc(sizeof(CNode));
            node1->index = j;
            node1->length = (int)parray[i][j];
            node1->next = guideGraph->roadlist[i].first;
            guideGraph->roadlist[i].first = node1;
            edgenum++;
        }
    }
    guideGraph->nodenum = graph->nodenum;
    guideGraph->edgenum = edgenum;
}
导游图(guideGraph)是有向图,代表推荐的游览方向。树边是前进方向,回边是回溯时的近路。

Output

导游线路输出结果

编译运行Day04代码后,输出的导游路线为:

大门- 红叶林- 碧玉潭- 赏花园- 观月阁- 飞云石- 九曲桥- 白虎岩- 九曲桥- 飞云石- 观月阁- 赏花园- 碧玉潭- 红叶林- 大门

去程(DFS深入)
到达最远点
回溯返回
去程:大门→红叶林→碧玉潭→赏花园→观月阁→飞云石→九曲桥→白虎岩(DFS深入,共8个景点7段路)
回程:白虎岩→九曲桥→飞云石→观月阁→赏花园→碧玉潭→红叶林→大门(回溯补边,记录回来的路)
完整路线 = 去程(DFS) + 回程(回溯补边),游客可以走完整条路线回到起点!

Part 08

常见错误与调试

避开这些坑,代码一次写对

Common Bugs

新手常犯的5大错误

1
忘记重置p指针!
DFS递归返回后没有写p = graph.roadlist[i].first;→程序崩溃或遍历顺序错误
解决:写完递归调用DFS(graph,p→index)后,下一行立刻写p重置!
2
visited[i]=1写在递归后面!
如果先递归再标记,会导致重复访问,死循环→栈溢出
解决:进入DFS函数第一件事就是visited[i]=1!
3
DFSTraverse忘记初始化indexp和visited!
多次调用DFS时,上次的结果会干扰这次→数据混乱
解决:DFSTraverse开头必须把indexp=0,visited全清零!
4
p指针定义为局部变量而非全局!
递归返回后上层p和下层p是不同变量,导致遍历混乱
解决:CNode *p; 必须定义在函数外面(全局)!
5
忘记extern声明全局变量!
travels.c用visited和parray时没有extern→链接错误
解决:travels.c开头写上extern int visited[MAXNUM];extern double **parray;

Debug Tips

递归调试技巧

🔍 方法1:打印进入/离开日志

void DFS(ALGraph g, int i){
    printf("→ 进入DFS(%d)=%s\n", i,
           g.roadlist[i].data);
    visited[i]=1;
    strcpy(path[indexp++], g.roadlist[i].data);
    for(p=g.roadlist[i].first;p;p=p->next){
        if(!visited[p->index]){
            DFS(g, p->index);
            p = g.roadlist[i].first;
        }
    }
    printf("← 离开DFS(%d)=%s\n", i,
           g.roadlist[i].data);
}
通过"进入/离开"配对可以清晰看到递归和回溯过程!

🧪 方法2:小数据测试

先用2-3个景点的小图测试:
大门-红叶林-白虎岩(3景点2边)
确认DFS顺序正确后再用8景点!

🤖 方法3:用Trae AI帮忙

Chat模式提问
"帮我看看这个DFS为什么崩溃?"
"p指针为什么要重置?"
Builder模式
"帮我写DFS函数,注意p要重置"

✅ 检查清单

□ visited[i]=1在最开头?
□ p是全局变量?
□ 递归返回后p重置了?
□ DFSTraverse初始化了indexp和visited?
□ extern声明写了吗?

Part 09

Git分支开发

用feature/dfs分支开发今天的功能

Git Workflow

feature/dfs 分支开发流程

git checkout main  &  git pull
git checkout -b feature/dfs  创建并切换分支
💻 编写代码:travels.c中实现DFS、DFSTraverse、isEdge
编译运行,测试DFS顺序是否正确
git add .  &  git commit -m "feat: 实现DFS遍历和导游线路生成"
git checkout main  &  git merge feature/dfs  合并回主分支
git tag -a day04 -m "Day04完成:DFS与导游线路"  打标签
🌿
为什么用分支?

开发新功能时不影响主分支稳定代码,写错了可以轻松回退

🏷️
为什么打标签?

day01-day10标签标记每天的完成状态,方便回溯查看进度

💾
什么时候commit?

一个功能完成并测试通过后就commit,不要攒一大堆再提交

AI Assistant

用Trae AI辅助学习DFS

💬 Chat模式(问问题)

适合问:
"DFS递归的原理是什么?"
"为什么p指针要重置?"
"帮我解释一下回溯补边算法"
"递归调用栈是怎么工作的?"
"这段代码为什么崩溃?"
技巧:把代码粘贴给AI,让它逐行解释,比自己看书效率高!

🔨 Builder模式(写代码)

适合做:
"帮我实现DFS递归函数"
"给DFSTraverse加上初始化代码"
"帮我写isEdge函数"
"修复这个p指针的bug"
"给代码加注释"
技巧:Builder生成代码后一定要自己看懂再运行,不要盲目复制粘贴!
重要提醒:AI是辅助工具,不是替代品!你必须理解每一行代码的作用,否则Day05-Day10更复杂的算法你会跟不上!

Part 10

总结与展望

今天学了什么?明天学什么?

Summary

今日知识点总结

🧠
DFS思想

深度优先搜索,一条路走到黑,碰壁再回溯

🔄
递归三要素

终止条件、递归调用、问题规模缩小

📍
五大全局变量

visited、path、indexp、p、parray

🚶
DFS三步曲

标记→遍历邻居递归→自动回溯

⚠️
p指针重置

递归返回后必须重置p到邻接表开头!

🗺️
导游线路

DFS得到path后,回溯补边生成完整路线

核心记忆口诀:标记访问、记录路径、循环邻居、未访递归、递归完重置p、没邻居就返回!

Applications

DFS的广泛应用

🗂️
文件系统遍历

操作系统遍历文件夹:进入子目录→深入→返回→下一个

🕸️
网络爬虫

搜索引擎爬取网页:跟随链接→深入→回溯

🎮
游戏AI

迷宫游戏、棋类AI的走法搜索

🔗
拓扑排序

Day05回路检测就是拓扑排序的应用

🧩
连通分量

找出图中所有互相连通的顶点集合

🔄
环检测

DFS可以检测有向图/无向图中是否存在环

🧮
数独求解

填数→递归→矛盾→回溯换数

🗺️
路径寻找

迷宫寻路、地图导航中的路径搜索

⚛️
状态空间搜索

AI问题求解中的状态空间遍历

记住:DFS是图算法的基石,掌握DFS等于掌握了图算法的半壁江山!

Preview: Day05

Day05预告:回路检测与最短路径

🔍 明天学什么?

1
回路检测(loopTest)
检查导游图有没有回路(会不会绕圈子)
2
拓扑排序(Kahn算法)
用BFS思想实现回路检测
3
Dijkstra最短路径
求从大门到任意景点的最短路径

🔗 Day04→Day05衔接:

Day04: createGuideGraph生成导游图
Day05: loopTest检测是否有回路
有回路→导游线路不合法
无回路→是合法的DAG
Dijkstra求最短路径
课后作业
1. 手算我们8景点的DFS全过程,写出每一步visited数组和调用栈
2. 完成travels.c的DFS、DFSTraverse、isEdge三个函数
3. 编译运行,看输出的导游路线是否和我们上课讲的一致

Bonus

拓展思考:不同起点DFS结果一样吗?

思考:我们的DFS从大门(索引0)开始。如果从赏花园开始,遍历顺序会一样吗?

从大门(0)开始:

大门→红叶林→碧玉潭→赏花园
→观月阁→飞云石→九曲桥→白虎岩

从赏花园(7)开始:

赏花园→碧玉潭→红叶林→大门
→白虎岩→九曲桥→飞云石→观月阁

结论
① 不同起点→不同遍历顺序→不同DFS生成树
② 但都会访问所有8个景点(因为图是连通的)
③ 邻接表中邻居的顺序也影响DFS走向(头插法导致逆序)
④ 我们选择大门作为起点,因为游客从大门进入景区!

Progress

项目进度回顾

Day内容状态完成度
Day01C语言回顾 + Trae + Git入门✅ 已完成
Day02图的存储结构设计✅ 已完成
Day03图的创建与展示✅ 已完成
Day04DFS遍历与导游线路📍 今天
Day05回路检测 + Dijkstra⏳ 明天
Day06主菜单整合
Day07Floyd全源最短路径
Day08Prim/Kruskal最小生成树
Day09景点详情 + 文件操作
Day10测试与最终整合
🎉 今天完成后,景区图已经能"走"起来了!我们已经完成了40%的项目!

FAQ

常见问题解答

Q
DFS和递归是什么关系?
A: DFS是算法思想,递归是实现手段。DFS也可以用栈(非递归)实现,但递归写起来最简单直观。
Q
p指针为什么不用参数传递?
A: 如果p是参数,递归返回后上层的p还是旧值,因为C语言参数是传值的。用全局变量才能让所有递归层共享同一个p。
Q
为什么邻接表顺序和边输入顺序相反?
A: 因为createGraph用的是头插法,新边插在链表头部,所以后插入的边在前面。
Q
回溯补边补出来的路是原路返回吗?
A: 是的,回溯就是沿着来的路一步步退回去,所以补的边就是来时的反向路径。
Q
DFS一定能遍历所有顶点吗?
A: 如果图是连通的,从任意顶点DFS都能访问所有顶点。如果不连通,DFSTraverse的for循环会处理每个连通分量。

Homework

课后作业

📝 作业1:手算DFS(必做)

在作业本上画出我们8景点9道路的图,然后手算DFS全过程:
1. 写出每一步的visited数组状态
2. 画出递归调用栈的变化
3. 写出最终path数组内容
4. 试写出补边后的完整导游路线

💻 作业2:代码实现(必做)

在Trae中完成Day04代码:
1. 创建feature/dfs分支
2. 在travels.c中实现DFS、DFSTraverse、isEdge三个函数
3. 编译运行,验证导游路线输出是否正确
4. 提交代码并合并到main分支,打day04标签

🌟 作业3:拓展思考(选做)

1. 如果从赏花园开始DFS,遍历顺序是什么?手算一下
2. 尝试修改代码,用BFS(广度优先)遍历图,看看顺序有什么不同
3. 思考:如果图有1000个顶点,递归DFS会栈溢出吗?

That's All for Today

谢谢大家

有什么问题现在可以提问

Day04 · DFS遍历与导游线路生成

Q & A

提问环节