数据结构设计与开发 · 企业项目实训
深度优先搜索 · 递归原理 · 回溯补边算法
景区路径规划系统 · 90分钟
Learning Objectives
理解DFS"一条路走到黑"的核心思想
掌握递归三要素和调用栈机制
理解五大全局变量的作用
理解DFS生成树的树边与回边
独立实现DFS递归函数
正确处理p指针重置问题
实现isEdge和createGuideGraph
能手算DFS完整过程
使用Trae Chat理解递归
使用Trae Builder生成代码
Git branch/merge分支管理
feature/dfs开发流程
Agenda
Day03邻接表、8景点9道路
走迷宫比喻、生活中的DFS
递归三要素、调用栈、阶乘例子
visited、path、indexp、p、parray
三步曲、p重置、DFSTraverse
8景点逐步骤推演
isEdge、回溯补边算法
feature/dfs、常见错误、作业
Part 01
Day03我们建好了什么?
Review · Day03
// 边结点(链表) 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
| 道路 | 长度(米) | 连接 | 道路 | 长度(米) |
|---|---|---|---|---|
| 大门 — 红叶林 | 500 | 九曲桥 — 飞云石 | 200 | |
| 红叶林 — 白虎岩 | 300 | 飞云石 — 观月阁 | 200 | |
| 白虎岩 — 九曲桥 | 200 | 碧玉潭 — 赏花园 | 400 | |
| 九曲桥 — 碧玉潭 | 300 | 观月阁 — 赏花园 | 200 | |
| 红叶林 — 碧玉潭 | 1300 |
Part 02
从"走迷宫"开始理解
Metaphor · 走迷宫
Core Idea
优先往深处走,不撞南墙不回头
走不通就后退,回到岔路口
深入→回溯→深入,直到全部访问
Real World Examples
打开文件夹→进入子文件夹→再进入→...→找不到→返回→下一个子文件夹
打开网页→点链接→再点链接→...→到底→返回→点下一个链接
走迷宫就是DFS!左手定则/右手定则就是DFS的具体实现
下棋时推演:我走这步→对手走那步→我再走→...→评估优劣→回溯
填一个数→递归填下一个→矛盾→回溯→换个数重试
沿着一条路找到底→没找到→回来→走另一条路继续找
Comparison
一条路走到黑,再回溯
层层递进,先近后远
Part 03
理解递归是理解DFS的前提
Recursion
递归
函数自己调用自己
电影院问排数:
你坐在第N排,想知道自己是第几排,但太黑看不见。
→ 你问前面的人:"你是第几排?"
→ 前面的人也看不见,问他前面的人
→ ...
→ 问到第一排的人,他说:"我是第1排!"
→ 第二排的人知道了:"我是第2排!"
→ ...
→ 答案传回来,你知道自己是第N排!
这就是递归:递(问出去)→ 归(传回来)
3 Elements
什么时候停止递归,直接返回结果?
👉 第一排的人直接回答"第1排",不需要再问别人
👉 DFS中:顶点没有未访问邻居时,自动返回
如何把大问题变小,然后调用自己?
👉 "你在第几排?"→ 前面人的排数 + 1
👉 DFS中:访问邻居 → DFS(邻居)
每次递归调用,问题必须比原来更小,最终能到达终止条件
👉 排数每次-1,最终会到第1排
👉 DFS中:visited标记后,未访问顶点越来越少
Example: Factorial
n! = n × (n-1) × (n-2) × ... × 1
1! = 1 // 终止条件
n! = n × (n-1)! // 递归公式
int factorial(int n) { // 1. 终止条件 if (n == 1) { return 1; } // 2. 递归调用:n × (n-1)! return n * factorial(n-1); }
Call Stack
递归背后是栈(Stack)在工作 —— 每次调用压栈,返回时出栈
↑ 栈顶:最后调用的先执行完
↓ 从栈顶开始依次返回
Part 04
DFS递归需要它们之间的配合
Global Variables
| 变量名 | 类型 | 定义位置 | 作用 |
|---|---|---|---|
| visited | int数组 | graph.c | 标记顶点是否已访问 |
| parray | double** | graph.c | 邻接矩阵(判断两点间是否有边) |
| path | char二维数组 | travels.c | 记录DFS访问顺序(景点名) |
| indexp | int | travels.c | path数组的当前下标(游标) |
| p | CNode* | travels.c | 遍历邻接表用的指针(重点难点!) |
Global 1: visited[]
// graph.c 中定义 int visited[MAXNUM]; // MAXNUM=8 // travels.c 中声明 extern int visited[MAXNUM];
初始状态(全0):
访问大门、红叶林后:
DFS完成后(全1):
Global 2&3: path[] & indexp
// travels.c 中定义 char path[MAXNUM][10]; // 存景点名 int indexp = 0; // 数组下标
已访问5个景点,indexp=5:
↑ indexp指向这里,下一个景点填这里
indexp = 8(所有景点都记录了)
Global 4: p (重点难点!)
// travels.c 中定义 CNode *p; // 全局边指针!
以"红叶林"的邻接表为例:
p的移动过程:
① p = first → 指向 碧玉潭(已访问?)
② p = p->next → 指向 白虎岩(未访问!→ 递归)
③ 递归返回后 → p = first 重置!
④ 再从头检查还有没有未访问的邻居
Global 5: parray
// graph.c 中定义 double **parray; // 二维数组 // travels.c 中声明 extern double **parray;
INF是一个很大的数(如99999),代表"无穷大"即不通
| 大门 | 红叶林 | 白虎岩 | 九曲桥 | 碧玉潭 | 飞云石 | 观月阁 | 赏花园 | |
|---|---|---|---|---|---|---|---|---|
| 大门 | 0 | 500 | INF | INF | INF | INF | INF | INF |
| 红叶林 | 500 | 0 | 300 | INF | 1300 | INF | INF | INF |
| 白虎岩 | INF | 300 | 0 | 200 | INF | INF | INF | INF |
| 九曲桥 | INF | INF | 200 | 0 | 300 | 200 | INF | INF |
| 碧玉潭 | INF | 1300 | INF | 300 | 0 | INF | INF | 400 |
| 飞云石 | INF | INF | INF | 200 | INF | 0 | 200 | INF |
| 观月阁 | INF | INF | INF | INF | INF | 200 | 0 | 200 |
| 赏花园 | INF | INF | INF | INF | 400 | INF | 200 | 0 |
Part 05
三步曲:标记→递归→回溯
DFS in 3 Steps
visited[i] = 1; (标记已访问,防止重复)
strcpy(path[indexp++], graph.roadlist[i].data); (记录到路径)
for循环遍历邻接表的每个邻居p→index
如果邻居未访问 → DFS(graph, p→index) 递归!
递归返回后 → p = graph.roadlist[i].first; 重置!
for循环结束,没有未访问的邻居了
函数自动返回到上一层递归
不需要写任何代码!
Full Code
/* 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循环结束 → 无未访问邻居 → 函数自动返回 }
Entry Function
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 }
Why Reset p?
DFS(红叶林):p指向碧玉潭
p→碧玉潭已访问?检查后跳过
p=p->next → 指向白虎岩
白虎岩未访问 → 调用DFS(白虎岩)
... 深入递归很多层 ...
DFS(白虎岩)返回
p还停留在NULL!
执行p=p->next → 访问NULL崩溃!💥
或者:漏掉后面的邻居没检查!
DFS(红叶林):p指向碧玉潭
p→碧玉潭已访问?跳过
p=p->next → 指向白虎岩
白虎岩未访问 → 调用DFS(白虎岩)
... 深入递归很多层 ...
DFS(白虎岩)返回
p = first!回到链表开头!
重新遍历:碧玉潭、白虎岩...
检查还有没有其他未访问邻居
没有了 → 正常返回 ✓
Parameter Passing
void DFS(ALGraph graph, int i) // ^^^^^^^^^^^ 传值!不是ALGraph *graph
void createGraph(ALGraph *graph) // ^^^^^^^^^^^^ 传指针!
Part 06
跟着步骤走一遍,你就完全懂了
Adjacency List
Step 0-1
visited全0,path空,indexp=0
visited[0]=1,path[0]="大门",indexp=1
Step 2-3
visited[1]=1,path[1]="红叶林",indexp=2
Important Correction
| # | 边 | 插入到顶点 | 插入后链表(头插法,新节点在最前面!) |
|---|---|---|---|
| 1 | 大门-红叶林 | 大门 / 红叶林 | 大门: 红叶林→NULL | 红叶林: 大门→NULL |
| 2 | 红叶林-白虎岩 | 红叶林 / 白虎岩 | 红叶林: 白虎岩→大门→NULL |
| 3 | 白虎岩-九曲桥 | 白虎岩 / 九曲桥 | 白虎岩: 九曲桥→红叶林→NULL |
| 4 | 九曲桥-碧玉潭 | 九曲桥 / 碧玉潭 | 九曲桥: 碧玉潭→NULL |
| 5 | 九曲桥-飞云石 | 九曲桥 / 飞云石 | 九曲桥: 飞云石→碧玉潭→白虎岩→NULL |
| 6 | 飞云石-观月阁 | 飞云石 / 观月阁 | 飞云石: 观月阁→九曲桥→NULL |
| 7 | 碧玉潭-赏花园 | 碧玉潭 / 赏花园 | 碧玉潭: 赏花园→九曲桥→NULL |
| 8 | 观月阁-赏花园 | 观月阁 / 赏花园 | 观月阁: 赏花园→飞云石→NULL | 赏花园: 观月阁→碧玉潭→NULL |
| 9 | 红叶林-碧玉潭 | 红叶林 / 碧玉潭 | 红叶林: 碧玉潭→白虎岩→大门→NULL | 碧玉潭: 红叶林→赏花园→九曲桥→NULL |
Step 2-3
visited[1]=1,path[1]="红叶林",p指向第一个邻居→碧玉潭
碧玉潭未访问!→ 递归!
visited[4]=1,path[2]="碧玉潭",indexp=3
p指向碧玉潭的第一个邻居→红叶林(已访问)→ p=p->next→赏花园
赏花园未访问!→ 递归!
Step 4-6
visited[7]=1,path[3]="赏花园",p→观月阁(未访问)→ 递归
visited[6]=1,path[4]="观月阁",p→赏花园(已访问)→ p->next→飞云石(未访问)→ 递归
visited[5]=1,path[5]="飞云石",p→观月阁(已访问)→ p->next→九曲桥(未访问)→ 递归
栈深度=6,继续深入!
Step 7-8
visited[3]=1,path[6]="九曲桥",p→飞云石(已访问)→ p->next→碧玉潭(已访问)→ p->next→白虎岩(未访问!)→ 递归
visited[2]=1,path[7]="白虎岩",indexp=8!
p→九曲桥(已访问)→ p->next→红叶林(已访问)→ p->next→NULL
🔙 for循环结束!没有未访问邻居了!开始回溯!
栈深度=8(最大值),所有顶点都访问了!
Backtracking
白虎岩返回九曲桥 → p重置→检查所有邻居:飞云石(✓)、碧玉潭(✓)、白虎岩(✓) → 全访问过→返回
九曲桥返回飞云石 → p重置→检查邻居:观月阁(✓)、九曲桥(✓) → 全访问→返回
飞云石返回观月阁 → p重置→检查邻居:赏花园(✓)、飞云石(✓) → 全访问→返回
观月阁返回赏花园 → p重置→检查邻居:观月阁(✓)、碧玉潭(✓) → 全访问→返回
赏花园返回碧玉潭 → p重置→检查邻居:红叶林(✓)、赏花园(✓)、九曲桥(✓) → 全访问→返回
碧玉潭返回红叶林 → p重置→检查邻居:碧玉潭(✓)、白虎岩(✓)、大门(✓) → 全访问→返回
红叶林返回大门 → p重置→检查邻居:红叶林(✓) → 全访问→返回
大门返回DFSTraverse → DFS完成!🎉
Final Result
Part 07
回溯补边算法:把DFS路径变成完整导游路线
Why Fill Edges?
Helper Function
/* 判断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; // 没边 }
Guide Path Generation
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");
Build Guide Graph
// ④ 初始化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; }
Output
大门- 红叶林- 碧玉潭- 赏花园- 观月阁- 飞云石- 九曲桥- 白虎岩- 九曲桥- 飞云石- 观月阁- 赏花园- 碧玉潭- 红叶林- 大门
Part 08
避开这些坑,代码一次写对
Common Bugs
Debug Tips
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); }
Part 09
用feature/dfs分支开发今天的功能
Git Workflow
开发新功能时不影响主分支稳定代码,写错了可以轻松回退
day01-day10标签标记每天的完成状态,方便回溯查看进度
一个功能完成并测试通过后就commit,不要攒一大堆再提交
AI Assistant
Part 10
今天学了什么?明天学什么?
Summary
深度优先搜索,一条路走到黑,碰壁再回溯
终止条件、递归调用、问题规模缩小
visited、path、indexp、p、parray
标记→遍历邻居递归→自动回溯
递归返回后必须重置p到邻接表开头!
DFS得到path后,回溯补边生成完整路线
Applications
操作系统遍历文件夹:进入子目录→深入→返回→下一个
搜索引擎爬取网页:跟随链接→深入→回溯
迷宫游戏、棋类AI的走法搜索
Day05回路检测就是拓扑排序的应用
找出图中所有互相连通的顶点集合
DFS可以检测有向图/无向图中是否存在环
填数→递归→矛盾→回溯换数
迷宫寻路、地图导航中的路径搜索
AI问题求解中的状态空间遍历
Preview: Day05
Bonus
大门→红叶林→碧玉潭→赏花园
→观月阁→飞云石→九曲桥→白虎岩
赏花园→碧玉潭→红叶林→大门
→白虎岩→九曲桥→飞云石→观月阁
Progress
| Day | 内容 | 状态 | 完成度 |
|---|---|---|---|
| Day01 | C语言回顾 + Trae + Git入门 | ✅ 已完成 | |
| Day02 | 图的存储结构设计 | ✅ 已完成 | |
| Day03 | 图的创建与展示 | ✅ 已完成 | |
| Day04 | DFS遍历与导游线路 | 📍 今天 | |
| Day05 | 回路检测 + Dijkstra | ⏳ 明天 | |
| Day06 | 主菜单整合 | ⏳ | |
| Day07 | Floyd全源最短路径 | ⏳ | |
| Day08 | Prim/Kruskal最小生成树 | ⏳ | |
| Day09 | 景点详情 + 文件操作 | ⏳ | |
| Day10 | 测试与最终整合 | ⏳ |
FAQ
Homework
在作业本上画出我们8景点9道路的图,然后手算DFS全过程:
1. 写出每一步的visited数组状态
2. 画出递归调用栈的变化
3. 写出最终path数组内容
4. 试写出补边后的完整导游路线
在Trae中完成Day04代码:
1. 创建feature/dfs分支
2. 在travels.c中实现DFS、DFSTraverse、isEdge三个函数
3. 编译运行,验证导游路线输出是否正确
4. 提交代码并合并到main分支,打day04标签
1. 如果从赏花园开始DFS,遍历顺序是什么?手算一下
2. 尝试修改代码,用BFS(广度优先)遍历图,看看顺序有什么不同
3. 思考:如果图有1000个顶点,递归DFS会栈溢出吗?
That's All for Today
有什么问题现在可以提问
Day04 · DFS遍历与导游线路生成
提问环节