数据结构:深度优先搜索 (Depth-First Search, DFS)

目录

DFS的诞生——“不撞南墙不回头”

DFS的核心机制——如何实现“回溯”?

DFS算法流程图解(递归版)

C/C++代码实现

DFS的应用


上一节我们学习了广度优先搜索 (BFS),它像水面的波纹一样,一层一层地向外探索。今天,我们要学习它的“兄弟”算法——深度优先搜索 (Depth-First Search, DFS),它体现了完全不同的探索哲学。

DFS的诞生——“不撞南墙不回头”

再次回到那个巨大的迷宫入口。上次,我们的策略是“由近及远”。但还有一种同样符合直觉的策略,那就是“一条路走到黑”。

  1. 你面前有多条路,随便选 一条 走下去。

  2. 在下一个路口,你再随便选一条路,继续 深入

  3. 你就这样一直走,直到前面是死胡同(没有新的路可走),或者回到了之前走过的路口。

  4. 这时,你怎么办?你会 原路返回 到上一个路口,看看那个路口还有没有 其他没走过的路

  5. 如果有,就选那条新路继续深入。如果也没有,就再退一步...

这种“勇往直前,走不通再退回来换条路”的探索策略,就是 深度优先搜索 (DFS)。“深度”指的就是它优先向“纵深方向”探索,直到无法再前进了,才回溯(backtrack)。

这个过程就像探险家在探索洞穴系统,他会沿着一个分支一直走到最深处,然后才返回来探索其他分支。

1️⃣:DFS探索路径的直观感受

       (A) --1--> (B) --2--> (C) --3--> (D)  <-- 到达死胡同,开始回溯|          ^          ^|          |          '--5-- 回溯到C|          '--6-- 回溯到B'--4--> (E) ...

1, 2, 3是深入探索的路径,4是回溯后尝试的新路径。


DFS的核心机制——如何实现“回溯”?

BFS的核心是队列,因为它要保存“先来的先处理”。那么DFS呢?我们来分析一下“回溯”这个行为。

当你从 A -> B -> C,最后在 C 碰壁时,你需要返回到 B。当 B 的所有路都探索完后,你需要返回到 A

这个返回的顺序是 C -> B -> A。而你前进的顺序是 A -> B -> C

我们发现,这个路径记录有一个鲜明的特点:

最后记录的路径点,最先被拿出来用于回溯 (Last-In, First-Out)。这不就是 栈 (Stack) 的定义吗!

所以,栈就是实现深度优先搜索“深入”和“回溯”特性的核心数据结构。

💡一个更优雅的实现:递归 (Recursion)

虽然我们可以手动维护一个栈来实现DFS,但在编程中,有一个更简洁、更强大的工具天然就是用栈实现的——那就是函数调用

当你调用一个函数 Func(A),它内部又调用 Func(B)Func(B) 内部又调用 Func(C) 时,计算机内部的“调用栈 (Call Stack)”会帮你记录下这个调用链。

  • Func(C) 执行完后,会自动返回到 Func(B) 的断点处。

  • Func(B) 执行完后,会自动返回到 Func(A) 的断点处。

这不就完美地模拟了我们想要的“回溯”行为吗?因此,递归是实现DFS最自然、最优雅的方式。我们可以把系统自带的函数调用栈当作我们的“回溯”工具。


DFS算法流程图解(递归版)

我们还是用上一节的图,这样你就可以清晰地对比BFS和DFS的差异。

      (A) --------- (B)|           /|          /|         /(D)       (C) --------- (E)|           /|          /'--------- (F)

我们同样用邻接表来表示它,并从顶点 A 开始遍历。

准备工作:

  • visited数组: [A:F, B:F, C:F, D:F, E:F, F:F] (F代表False)


第1步: DFS(A)

  1. 主程序调用 DFS(A)

  2. 访问 A,标记 visited[A] = T

  3. 查看 A 的邻居:BD

  4. 选择第一个邻居 BB 未被访问。

  5. 递归调用 DFS(B)DFS(A) 在这里暂停,等待 DFS(B) 返回。

状态:

  • 访问顺序: A

  • visited: [A:T, B:F, ...]

  • 调用栈: [ main, DFS(A) ]


第2步: DFS(B)

  1. DFS(B) 开始执行。

  2. 访问 B,标记 visited[B] = T

  3. 查看 B 的邻居:ACA 已被访问,跳过。

  4. 选择下一个邻居 CC 未被访问。

  5. 递归调用 DFS(C)DFS(B) 在此暂停。

状态:

  • 访问顺序: A, B

  • visited: [A:T, B:T, C:F, ...]

  • 调用栈: [ main, DFS(A), DFS(B) ]


第3步: DFS(C)

  1. DFS(C) 开始执行。

  2. 访问 C,标记 visited[C] = T

  3. 查看 C 的邻居:B, E, FB 已被访问,跳过。

  4. 选择下一个邻居 EE 未被访问。

  5. 递归调用 DFS(E)DFS(C) 在此暂停。

状态:

  • 访问顺序: A, B, C

  • visited: [A:T, B:T, C:T, E:F, ...]

  • 调用栈: [ main, DFS(A), DFS(B), DFS(C) ]


第4步: DFS(E)

  1. DFS(E) 开始执行。

  2. 访问 E,标记 visited[E] = T

  3. 查看 E 的邻居:C, FC 已被访问。

  4. 选择下一个邻居 FF 未被访问。

  5. 递归调用 DFS(F)DFS(E) 在此暂停。

状态:

  • 访问顺序: A, B, C, E

  • visited: [..., E:T, F:F]

  • 调用栈: [..., DFS(C), DFS(E) ]


第5步: DFS(F) 与回溯

  1. DFS(F) 开始执行。

  2. 访问 F,标记 visited[F] = T

  3. 查看 F 的邻居:C, E。都已被访问。

  4. DFS(F) 没有可递归的调用了,执行完毕返回。

  5. 程序回到 DFS(E) 的暂停点。

状态:

  • 访问顺序: A, B, C, E, F

  • visited: [..., E:T, F:T]

  • 调用栈: [..., DFS(C), DFS(E) ] -> [..., DFS(C) ] (DFS(F)返回, DFS(E)恢复)


第6步: 继续回溯

  1. DFS(E) 恢复执行。它已经检查完所有邻居,执行完毕,返回

  2. 程序回到 DFS(C) 的暂停点。C 检查过 E,现在检查下一个邻居 FF 已被访问。

  3. DFS(C) 检查完所有邻居,执行完毕,返回

  4. 程序回到 DFS(B) 的暂停点。B 检查完所有邻居,执行完毕,返回

  5. 程序回到 DFS(A) 的暂停点。

状态:

  • 调用栈: [ main, DFS(A) ] (一层层返回)


第7步: 探索新分支

  1. DFS(A) 恢复执行。它上次探索了邻居 B。现在看下一个邻居 D

  2. D 未被访问。

  3. 递归调用 DFS(D)

状态:

  • 访问顺序: A, B, C, E, F, D

  • visited: [..., D:T, ...]

  • 调用栈: [ main, DFS(A), DFS(D) ]


DFS(D) 检查其邻居 A,已被访问。DFS(D) 返回。DFS(A) 检查完所有邻居,返回 main。 所有顶点均被访问,遍历结束。

最终访问顺序: A -> B -> C -> E -> F -> D

对比BFS的 A -> B -> D -> C -> E -> F,你会发现DFS的路径明显更“深”。


C/C++代码实现

我们将使用上一节的邻接表结构。

第一步: 核心递归函数 DFS 的框架

这个函数负责处理从单个顶点 v 开始的深度优先搜索。它需要知道整个图 g,当前顶点 v,以及一个全局的 visited 数组来共享访问状态。

// 假设 MAX_VERTICES 和 AdjListGraph 结构体已经定义
#include <stdio.h>// v: 当前开始遍历的顶点
// visited: 访问标记数组
void DFS(AdjListGraph *g, int v, int visited[]) {// 核心逻辑将在这里实现
}

第二步: 访问当前节点并探索邻居

DFS的第一件事就是访问当前节点,并标记它。然后,它需要遍历当前节点的所有邻居。

void DFS(AdjListGraph *g, int v, int visited[]) {// 1. 访问并标记当前顶点printf("访问顶点: %d\n", v);visited[v] = 1; // 1 表示已访问// 2. 遍历邻接链表,准备对邻居进行递归EdgeNode *p = g->adj_list[v].first_edge;while (p != NULL) {int w = p->neighbor_index; // 获取邻居顶点w// ... 对w进行处理 ...p = p->next;}
}

第三步: 加入递归调用

这是最关键的一步。在遍历邻居时,如果发现邻居 w 没有被访问过,就对它发起递归调用,深入探索。

void DFS(AdjListGraph *g, int v, int visited[]) {printf("访问顶点: %d\n", v);visited[v] = 1; EdgeNode *p = g->adj_list[v].first_edge;while (p != NULL) {int w = p->neighbor_index;// 核心:如果邻居w未被访问,就从w开始继续深度优先搜索if (!visited[w]) {DFS(g, w, visited);}p = p->next;}
}

这个递归函数 DFS 已经完整了,非常简洁,因为它巧妙地利用了函数调用栈。

第四步: 处理非连通图的封装函数

DFSTraverse 和BFS一样,如果图不连通,我们需要一个外部循环来确保每个连通分量都被遍历到。

void DFSTraverse(AdjListGraph *g) {// 1. 初始化访问标记数组int visited[MAX_VERTICES];for (int i = 0; i < g->num_vertices; i++) {visited[i] = 0; // 0 表示未访问}// 2. 遍历所有顶点for (int i = 0; i < g->num_vertices; i++) {// 如果顶点i还没有在之前的搜索中被访问过// 说明它属于一个新的连通分量,从它开始新的DFSif (!visited[i]) {DFS(g, i, visited);}}
}

这个 DFSTraverse 就是我们提供给用户调用的最终接口。


DFS的应用

DFS“不撞南墙不回头”的特性,使得它特别适合解决与 “路径”、“连通性”和“依赖关系” 相关的问题。

  • 寻找路径: DFS可以非常方便地找到从A到B的 一条 路径(但不一定是最短的)。它探索的过程本身就在构建一条路径。

  • 检测环 (Cycle Detection): 在有向图中,如果在DFS的探索路径上(即在当前的递归调用栈中)遇到了一个已经访问过的顶点,那就说明发现了一个环。这是判断程序或任务是否存在循环依赖的关键算法。

  • 拓扑排序 (Topological Sort): 对于一个有向无环图(比如课程的先修关系、项目的任务依赖),DFS可以输出一个线性的序列,保证所有的依赖关系都得到满足。

  • 寻找连通分量 (Finding Connected Components): 我们上面写的 DFSTraverse 实际上就在做这件事。每当外层循环启动一次新的 DFS 调用,就意味着发现了一个新的连通分量。

DFS和BFS是图论算法的左膀右臂,掌握了它们,你就真正地入门了图的世界。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/diannao/98350.shtml
繁体地址,请注明出处:http://hk.pswp.cn/diannao/98350.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Spring Boot中策略模式结合依赖注入的实现方式

在Spring Boot项目开发中&#xff0c;常常会遇到根据不同的业务场景执行不同逻辑的需求&#xff0c;策略模式就是一种很好的设计模式来应对这种情况。同时&#xff0c;Spring Boot强大的依赖注入机制可以方便地将不同的策略类进行管理和调用。 1. 定义策略接口 定义一个策略接口…

深入剖析Spring Boot中Spring MVC的请求处理流程

对于任何使用Spring Boot进行Web开发的开发者而言&#xff0c;深入理解Spring MVC的执行流程都是至关重要的。这不仅有助于我们编写更清晰、更高效的代码&#xff0c;更是我们排查诡异问题、进行高级定制开发的知识基石。今天&#xff0c;我们将一起深入Spring Boot应用的内核&…

X448 算法签名验签流程深度解析及代码示例

一、引言&#xff1a;X448 算法的定位与价值在椭圆曲线密码学&#xff08;ECC&#xff09;体系中&#xff0c;X448 是基于蒙哥马利曲线&#xff08;Curve448&#xff09;的密钥交换算法&#xff0c;但其底层数学原理也可支撑签名验签功能&#xff08;实际工程中常与 Ed448 签名…

2025-2026单片机物联网毕业设计题目推荐(定稿付款)

51.基于单片机的非接触式防疫自动门系&#xff08;1&#xff09;人员检测&#xff1a;利用超声波模块进行人员检测&#xff0c;检测到人员靠近门体时触发相应的操作&#xff1b;&#xff08;2&#xff09;门控制&#xff1a;通过舵机实现自动门的开闭控制&#xff0c;当检测到有…

一文详解大模型强化学习(RLHF)算法:PPO、DPO、GRPO、ORPO、KTO、GSPO

一、 引言 大模型强化学习的核心目标是让模型的输出与人类目标、真实场景需求对齐。在工作和学习中&#xff0c;大模型强化学习训练经常会遇到各种算法&#xff0c;各种O&#xff0c;在强化学习训练选型过程中经常容易混淆&#xff0c;也分不清各种训练算法的使用场景和优缺点。…

C++ 常见面试题汇总

基础知识 一、C 基础语法C 和 C 的区别&#xff1f; C 支持面向对象&#xff08;封装、继承、多态&#xff09;。C 引入模板、STL、异常处理。值传递、指针传递、引用传递的区别&#xff1f; 值传递&#xff1a;拷贝一份副本。指针传递&#xff1a;传地址&#xff0c;可修改原数…

ES06-SpringData集成

ES06-SpringData集成 文章目录ES06-SpringData集成1-参考网址2-知识整理3-Spring Data Elasticsearch 9.0.0 完整示例4-知识补充1-Elasticsearch JAVA操作有三种客户端:1. TransportClient&#xff08;已废弃&#xff09;2. JestClient&#xff08;第三方 HTTP 客户端&#xff…

对于链表相关经典算法题:环形链表的约瑟夫问题的解析

开篇介绍&#xff1a; Hello 大家&#xff0c;在上一篇博客中&#xff0c;我们一同拆解了「206. 反转链表」和「876. 链表的中间结点」这两道单链表经典题目&#xff0c;通过对指针操作的细致打磨&#xff0c;相信大家对单链表的特性与算法设计思路有了更深入的理解。而在今天…

MySQL集群——主从复制

目录 一、环境搭建、部署 1. RHEL7.9、9.3的搭建 二、主从复制 1. 环境说明 2. 环境准备 1&#xff09;克隆RHEL79_mysql_master 2&#xff09;改名为 “RHEL79_mysql_slave” 并修改IP 3&#xff09;修改主机名 3. 部署MySQL主从同步 1&#xff09;主库(mysql-master) 2&…

《用 asyncio 构建异步任务队列:Python 并发编程的实战与思考》

《用 asyncio 构建异步任务队列:Python 并发编程的实战与思考》 一、引言:并发编程的新时代 在现代软件开发中,性能已不再是锦上添花,而是产品成功的基石。尤其在 I/O 密集型场景中,如网络爬虫、实时数据处理、微服务通信等,传统的同步编程模式往往力不从心。 Python …

【Linux】yum工具篇

目录一、软件包管理器1.1 什么是软件包1.2 Linux软件生态二、yum具体操作2.1 查找软件包2.2 安装软件包2.3 卸载软件配置文件所在路径个人主页<—请点击 Linux专栏<—请点击 一、软件包管理器 1.1 什么是软件包 在Linux下安装软件, 一个通常的办法是下载到程序的源代码…

撬动制造全场景增效,开利空调找到了怎样的“通关密码”?

由深圳软件协会指导、法大大和信息侠联合出品的《制造行业合同数智化升级白皮书》&#xff08;以下简称“白皮书”&#xff09;首次提出了 “电子签法律AI” 双轮驱动模型。在制造行业面临供应链协同、合规风控及全球化出海等多重挑战的当下&#xff0c;法大大依托丰富的制造企…

[Android]RecycleView的item用法

RecyclerView 是 Android 提供的一个强大的列表控件&#xff0c;用来显示大量数据。RecyclerView 的主要特点 1. 高性能的视图复用机制 Recycle就是循环的意思&#xff0c;那么recycleview的特点也很鲜明了&#xff0c;它只会创建出在屏幕内和一定缓存的itemview,当view滑出屏幕…

AI驱动的软件测试:革命性的自动化、缺陷检测与实验优化

引言在当今快节奏的软件开发生命周期&#xff08;SDLC&#xff09;中&#xff0c;传统测试方法已逐渐无法满足对速度、覆盖面和准确性的极高要求。人工智能&#xff08;AI&#xff09;和机器学习&#xff08;ML&#xff09;技术的融入&#xff0c;正在从根本上重塑软件测试的格…

继续优化基于树状数组的cuda前缀和

在之前的博客《借助树状数组的思想实现cuda版前缀和》中&#xff0c;我们用三个kernel实现了基于树状数组的cuda版前缀和&#xff0c;但是在数据量较大时速度不如传统的reduce-then-scan方法&#xff0c;主要原因在于跨block的reduce阶段没有充分利用所有的cuda核心。在本博客中…

Qt图片资源导入

右键项目&#xff0c;点击添加新文件 选择Qt -> Qt Resource File 资源文件起名 如&#xff1a;res 生成res.qrc文件 在项目的同级目录下创建文件夹res&#xff0c;并将准备好的资源粘贴进去 右键qrc文件&#xff0c;选中Open in Editor 添加前缀 前缀是各种类型图片的分类&…

嵌入式第四十六天(51单片机(中断,定时器))

一.独立按键设置1.#include "key.h"void init_key(void) {P1 | (0x0F << 4); }int key_pressed(void) {static int ret 0;if((P1 & (1 << 4)) 0){ret 1;}else if((P1 & (1 << 5)) 0){ret 2;}else if((P1 & (1 << 6)) 0){r…

Visual Studio Code2024安装包及安装教程

一、软件下载软件名称&#xff1a;Visual Studio Code 2024安装环境&#xff1a;window10及以上系统下载链接&#xff1a;https://pan.quark.cn/s/d9831b28c69a解压软件Bandizip下载链接&#xff1a;https://pan.quark.cn/s/a54e79b5d553二、软件安装1、下载后&#xff0c;先解…

fps:游戏玩法

能帮到你的话&#xff0c;就给个赞吧 &#x1f618; 文章目录游戏玩法倒计时僵尸潮游戏成功&失败计时玩法&#xff1a;玩家在计时内存活&#xff0c;成功&#xff1b;反之失败Game界面&#xff1a;由关卡调用计时系统计时完成&#xff1a;调用结果界面结果界面玩家死亡&…

如何建立针对 .NET Core web 程序的线程池的长期监控

如何建立针对 .NET Core web 程序的线程池的长期监控 建立针对 .NET Core Web 应用程序线程池的长期监控是一个系统性的工程&#xff0c;它涉及代码集成、指标收集、存储、可视化和告警。 核心思路 线程池监控不是孤立的&#xff0c;它必须与应用程序的整体性能指标&#xff08…