- 教程
【免费下载链接】Learn-Algorithms
算法学习笔记
DFS(深度优先搜索)与 BFS(广度优先搜索)是图与树结构中最基础、应用最广泛的两类遍历算法,也是面试与工程实践中高频出现的核心考点。本文以 Learn-Algorithms 仓库中 5 Graph/DFS 和 BFS.md 文档为骨架,结合仓库内二叉树、堆等源码实现,系统讲解两种算法的核心思想、数据结构差异、代码模板、应用场景与典型面试题,帮助读者从原理到实战一次性掌握 DFS/BFS。
一、算法总览:两条截然不同的搜索路径
在 Learn-Algorithms 仓库的图论笔记中(见 5 Graph/README.md),图的遍历被明确划分为两类:
遍历:广度优先 BFS、深度优先 DFS
这两类算法解决的是同一个问题——如何系统地访问图中的所有顶点——但采取了完全相反的访问顺序策略。
1.1 DFS:深度优先,一条路走到黑
DFS(Depth-First Search,深度优先搜索)以"深度"为准则:从一个起点出发,先沿着一条分支一直往下走,直到走到目标节点;如果走到了尽头(没有达到目标)且无路可走,就**回溯(backtrack)**到上一步的状态,换一条没走过的路继续探索。
这正如原文档所述:
DFS:深度优先搜索,以深度为准则,先一条路走到底,直到达到目标;没有达到目标又无路可走了,那么则退回到上一步的状态,走其他路。这便是回溯上来。
核心关键词是"回溯":DFS 天然携带回溯特性,这是它能够用于"穷举所有路径""寻找可行解"的根本原因。
1.2 BFS:广度优先,层层推进
BFS(Breadth-First Search,广度优先搜索)在面临一个路口时,会把所有岔路口都记下来,然后选择其中一个进入,记录它的分支情况,再返回去进入下一个岔路,如此反复,直到所有可达节点都被访问。
BFS:广度优先搜索,在面临一个路口时,把所有的岔路口都记下来,然后选择其中一个进入,然后将它的分路情况记录下来,然后再返回来进入另外一个岔路,并重复这样的操作。
核心特征是"按层扩散":BFS 总是先访问距离起点最近的一层节点,再逐层向外推进。
1.3 一句话对比(原文档要点)
DFS 用递归的形式,用到了栈结构,先进后出;BFS 选取状态用队列的形式,先进先出。
| 维度 | DFS(深度优先) | BFS(广度优先) |
|---|---|---|
| 访问顺序 | 沿一条分支深入,再回溯 | 逐层向外扩散 |
| 底层数据结构 | 栈(Stack),递归调用栈天然是栈 | 队列(Queue),先进先出 |
| 空间复杂度(节点数 N) | 最坏 O(N),但通常较小(只保存路径上的节点) | 最坏 O(N),需保存整层节点 |
| 是否天然找到最短路径 | 否 | 是(无权图下首次访问即最短) |
| 典型实现 | 递归 / 显式栈 | 显式队列循环 |
| 适用场景 | 连通性判断、路径枚举、回溯、拓扑排序、DFS 序 | 最短路径、层序遍历、状态扩散、多源搜索 |
二、核心差异的根源:栈与队列的选择
原文档一句话点破了两种算法的本质区别——DFS 用栈(递归调用栈),BFS 用队列。这一差异直接决定了算法的行为特征。
2.1 为什么 DFS 需要栈:先进后出(LIFO)
DFS 需要"后进入的分支先处理"。递归函数执行时,系统调用栈天然满足"先进后出":函数 A 调用 B,B 必须先返回,A 才能继续。因此 DFS 用递归实现时无需显式建栈,调用栈就是它的栈。
如果用显式栈模拟 DFS,核心模板如下:
// 显式栈版 DFS void dfs_iterative(int start) { Stack s; push(&s, start); visited[start] = 1; while (!stack_empty(&s)) { int u = pop(&s); process(u); // 访问节点 for (int v : neighbors(u)) { if (!visited[v]) { visited[v] = 1; push(&s, v); // 后进先出,实现深度优先 } } } }注意:栈的 LIFO 特性决定了"越晚压入的节点越先被访问",这正是沿一条分支深入下去的驱动力。
2.2 为什么 BFS 需要队列:先进先出(FIFO)
BFS 需要"先记录的分支先处理",只有队列的 FIFO 语义能满足:先入队的节点(距离更近的一层)先被出队访问,从而保证按层扩散。仓库中 4 Tree/1-二叉树 /btree/队列.h 实现了一个链式队列,其注释与实现精确对应了 BFS 对数据结构的要求:
只在一段进行插入,另一端删除元素
该队列用head(队头)与tail(队尾)两个指针维护结构,enQueue在队尾插入新节点、deQueue从队头弹出节点,isEmpty通过q->head == q->tail判断队列是否为空,O(1) 完成入队出队操作:
// 入队(加入到队尾) Status enQueue(Queue *q, ElemType e) { LinkQueue *newNode = (LinkQueue *)malloc(sizeof(LinkQueue)); if (!newNode) return ERROR; newNode->elem = e; newNode->next = NULL; q->tail->next = newNode; q->tail = newNode; // 队尾插入 q->length++; return OK; } // 出队(队头弹出) Status deQueue(Queue *q, ElemType *e) { LinkQueue *p = q->head->next; if (!p) return ERROR; // 队列空 if (e) *e = p->elem; LinkQueue *temp = p->next; q->head->next = temp; if (p == q->tail) // 只有一个元素时防止队尾指针丢失 q->tail = q->head; free(p); q->length--; return OK; }源码中还注意到顺序存储队列的经典缺陷(4 Tree/1-二叉树 /btree/队列.h 注释):
在队列采用顺序存储时,有一个毛病,就是队列操作一段时间后,头指针到了队列容器的尾部,而头指针前面的容器内存不可用了,造成内存极大的浪费,这个问题可以通过循环队列来解决。但是在链式队列上则不存在这样的问题
这一细节对理解 BFS 工程实现很有价值:大规模图遍历时若用数组队列会频繁扩容浪费内存,链式队列或循环队列是更稳妥的选择。
三、二叉树上的 DFS 与 BFS:仓库源码实战
Learn-Algorithms 仓库在二叉树部分给出了 DFS 与 BFS 的完整 C 语言实现,是理解两种算法最直观的"练武场"。
3.1 二叉树结构定义
仓库 4 Tree/1-二叉树 /btree/bintree.c 使用链表方式构建二叉树:
typedef struct BiTNode { char item; struct BiTNode *lChild, *rChild; } BiTNode, *BiTree;节点只有左右两个分支,这让 DFS 和 BFS 的实现变得极其清晰。
3.2 DFS 在二叉树上的三种体现:前序、中序、后序遍历
二叉树的先序、中序、后序遍历都是 DFS——它们只是改变了"访问节点"与"递归进入子树"的先后顺序。仓库 bintree.c 中三种遍历全部采用递归(即调用栈)实现:
// 前序遍历:根 -> 左 -> 右(先访问,后递归) int PreOrderTraverse(BiTree T) { if (T) { printf("%c\n", T->item); PreOrderTraverse(T->lChild); PreOrderTraverse(T->rChild); } return 0; } // 中序遍历:左 -> 根 -> 右 int InOrderTraverse(BiTree T) { if (T) { InOrderTraverse(T->lChild); printf("%c\n", T->item); InOrderTraverse(T->rChild); } return 0; } // 后序遍历:左 -> 右 -> 根(先递归,最后访问) int PostOrderTraverse(BiTree T) { if (T) { PostOrderTraverse(T->lChild); PostOrderTraverse(T->rChild); printf("%c\n", T->item); } return 0; }这三个函数印证了原文档的论断:"DFS 用递归的形式,用到了栈结构"。每一次递归调用进入更深一层,返回时自然完成回溯。
3.3 BFS 在二叉树上的体现:层序遍历
二叉树的层序遍历(Level Order)就是 BFS。仓库 bintree.c 中LevelOrderTraverse用上述链式队列实现了真正的"先进先出"逐层访问:
// 广度优先遍历(队列实现) int LevelOrderTraverse(BiTree T) { if (T) { Queue queue; initQueue(&queue); BiTree u = (BiTree)malloc(sizeof(BiTNode)); enQueue(&queue, T); // 根节点先入队 while (!isEmpty(&queue)) { deQueue(&queue, &u); // 队头出队并访问 printf("%c", u->item); if (u->lChild) enQueue(&queue, u->lChild); // 左孩子入队 if (u->rChild) enQueue(&queue, u->rChild); // 右孩子入队 } } return 0; }对照队列实现(队列.h 与 bintree.c)可清晰看到 BFS 的完整调用链:enQueue入队 →isEmpty判空 →deQueue出队 → 访问 → 孩子节点入队。由于先入队的上一层节点总是先出队,访问顺序天然按层展开。
仓库 bintree.c 的main函数还给出了完整的可运行流程:先按先序方式创建二叉树(输入空格表示空节点),随后依次打印前序、中序、后序与层序遍历结果:
printf("创建二叉树,输入\"空格\"创建空节点(先序方式建立二叉树):\n"); binaryTree = CreateBiTree(); printf("前序遍历:\n"); PreOrderTraverse(binaryTree); printf("中序遍历:\n"); InOrderTraverse(binaryTree); printf("后序遍历:\n"); PostOrderTraverse(binaryTree); printf("层序遍历:\n"); LevelOrderTraverse(binaryTree);四、通用图上的 DFS 与 BFS 模板
图比二叉树复杂在于:可能存在环,且一个节点可能有多个邻居。因此通用模板必须引入visited数组防止重复访问。
4.1 通用 DFS 模板(递归版)
void dfs(int u, int visited[], Graph *g) { visited[u] = 1; // 标记已访问,防环 process(u); // 访问/处理节点 for (int i = 0; i < g->degree[u]; i++) { int v = g->adj[u][i]; if (!visited[v]) { dfs(v, visited, g); // 深入下一层 } } }4.2 通用 BFS 模板
void bfs(int start, int visited[], Graph *g) { Queue q; initQueue(&q); enQueue(&q, start); visited[start] = 1; while (!isEmpty(&q)) { int u; deQueue(&q, &u); process(u); // 访问节点 for (int i = 0; i < g->degree[u]; i++) { int v = g->adj[u][i]; if (!visited[v]) { visited[v] = 1; enQueue(&q, v); // 未访问邻居入队 } } } }要点:visited标记应在入队/压栈时设置而非出队时设置,否则同一节点可能被重复入队,破坏复杂度 O(V+E) 的保证。
五、应用场景:何时用 DFS,何时用 BFS
5.1 DFS 的典型场景
- 连通性判断:判断两个节点是否连通、统计连通分量个数;
- 路径枚举与回溯:全排列、N 皇后、数独、组合问题(仓库 8 Algorithms Analysis/回溯法.md 有专门讲解);
- 拓扑排序:以 DFS 后序顺序逆序输出可得拓扑序(见仓库 5 Graph/拓扑排序.md);
- 二分图判定、强连通分量、桥与割点:Tarjan 系算法均基于 DFS;
- 树上的 DFS 序、子树统计:将子树问题转化为区间问题。
5.2 BFS 的典型场景
- 无权图最短路径:BFS 第一次访问到某节点时走过的步数即最短步数;
- 层序遍历:二叉树按层输出、逐层统计节点数;
- 状态空间搜索(迷宫、拼图):用状态作为节点、操作作为边,BFS 保证找到步数最少的解;
- 多源扩散问题:多起点同时入队,如"感染扩散""腐烂橘子"类问题;
- 拓扑排序(Kahn 算法):基于入度与队列实现。
仓库 5 Graph/README.md 列出了图的工业界应用场景(导航路径规划、任务调度、社区发现、匹配等),这些场景背后的路径搜索与连通性分析,本质上都由 DFS/BFS 支撑:
导航路径规划:用户选择开始地点和目的地之后,导航软件给出路程最短、不走高速、时长最短等方案
其中"路程最短"正是 BFS 类算法(加权场景延伸为 Dijkstra)的应用,而"连通性、社区发现"则依赖 DFS 遍历能力。
六、复杂度分析与优化技巧
6.1 时间复杂度
- DFS:每个节点访问一次、每条边检查一次,时间复杂度O(V + E)(V 为顶点数,E 为边数)。
- BFS:同样每个节点入队出队各一次、每条边检查一次,时间复杂度也是O(V + E)。
两者渐进复杂度相同,差异体现在空间与结果特性上。
6.2 空间复杂度与优化
- DFS(递归):空间 O(树高),最坏退化为链时 O(N);显式栈版可避免系统调用栈溢出风险,在深图中更安全;
- BFS:空间为"最宽一层"的节点数,星型图中可达 O(N);
- 优化技巧:
- 用
visited位图/哈希代替布尔数组,降低空间; - BFS 双向扩展(从起点和终点同时 BFS,相遇即停止)可将搜索空间从 b^d 降到约 2·b^(d/2);
- DFS 加剪枝(在递归前判断是否满足约束)大幅减少无效分支,是回溯法的核心优化(见 8 Algorithms Analysis/回溯法.md)。
- 用
七、面试高频题与 LeetCode 对照
| 类型 | 算法 | 经典题目 |
|---|---|---|
| 二叉树遍历 | DFS | 前序/中序/后序遍历(递归+迭代两种写法) |
| 层序遍历 | BFS | 二叉树层序遍历、锯齿形层序遍历 |
| 图连通性 | DFS/BFS | 岛屿数量、省份数量、被围绕的区域 |
| 最短路径 | BFS | 单词接龙、打开转盘锁、二进制矩阵中的最短路径 |
| 回溯/枚举 | DFS | 全排列、子集、组合总和、N 皇后 |
| 拓扑排序 | DFS/BFS | 课程表(判断有向图是否有环) |
仓库 9 Algorithms Job Interview 目录收录了大量面试代码,其中 codes/7 bianrytree/binary_search.c 等文件可作为练习素材。面试中常考的核心考点包括:
- 递归版与迭代版互转:能写出 DFS 的显式栈版本和 BFS 的队列版本;
- visited 标记时机:解释为何入队时标记优于出队时标记;
- 复杂度分析:准确说出 O(V+E) 与空间占用;
- 场景判别:最短路径/逐层扩散优先 BFS,枚举/回溯/连通性优先 DFS。
八、小结
| 对比项 | DFS | BFS |
|---|---|---|
| 搜索策略 | 深度优先,走到底再回溯 | 广度优先,逐层扩散 |
| 数据结构 | 栈(递归调用栈) | 队列(FIFO) |
| 是否适合找最短路径 | 否 | 是(无权图) |
| 是否适合枚举/回溯 | 是 | 否 |
| 复杂度 | O(V+E) | O(V+E) |
| 空间 | O(树高/路径长) | O(最宽层节点数) |
DFS 与 BFS 是算法学习的基石,也是无数高级算法(Dijkstra、A*、Tarjan、拓扑排序、最大流)的共同底座。掌握本节内容后,可进一步阅读仓库中的 5 Graph/README.md 了解图论整体框架、5 Graph/最短路径.md 学习 BFS 在加权图上的延伸(Dijkstra),并通过 4 Tree/1-二叉树 /btree/bintree.c 亲手运行一遍完整代码,完成从理论到实战的闭环。
- 教程
【免费下载链接】Learn-Algorithms
算法学习笔记
相关推荐
InternVL Stage2预训练原理:对比学习+生成任务的双路对齐
InternVL Stage2预训练原理:对比学习+生成任务的双路对齐 InternVL 是一个接近 GPT 4o 表现的开源多模态大模型系列,其 Stage
LeetCode 搜索算法指南:DFS、BFS、双向搜索与状态空间实战解析
LeetCode 搜索算法指南:DFS、BFS、双向搜索与状态空间实战解析 导读 本文是 leetcode 题解仓库中《搜索篇(上)》的完整技术指南,聚焦算法面
文档教程知识库ta4j指标系统深度剖析:从简单移动平均到复杂艾略特波浪分析
ta4j指标系统深度剖析:从简单移动平均到复杂艾略特波浪分析 ta4j是一个强大的Java技术分析库,提供了从基础到高级的完整指标系统,帮助开发者构建专业的交易
金融科技
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考