Initial commit: C++ courseware
This commit is contained in:
commit
4a4ee7c287
1789 files changed
+373489
No files matched your search
@@ -0,0 +1,134 @@
|
||||
### **C++ 算法:图论(一)- 图的基本概念与模型 讲义**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入:从校园地图到数据结构**
|
||||
|
||||
我们已经学习了线性结构(如数组、链表)和树形结构(如二叉树)。前者模拟了“一对一”的关系,后者模拟了“一对多”的层次关系。但现实世界充满了更为复杂的联系。
|
||||
|
||||
例如校园地图:教学楼、食堂、图书馆、体育馆等建筑散布其间,复杂的道路网将它们连接起来。现在,我们面临一些实际问题:
|
||||
* 如何找到从大门口到图书馆的**最短路径**?
|
||||
* 如何规划一条路线,可以**不重复地走完**所有关键道路?
|
||||
* 如果某个路口正在施工,从A到B**是否还有其他路径**可以到达?
|
||||
|
||||
这些问题,都无法用简单的线性或树形结构来描述。我们需要一种新的数据结构,它能专门模拟这种“多对多”的复杂网络关系。这个结构,就是我们今天要学习的——**图(Graph)**。
|
||||
|
||||
##### **1.2 价值阐述:为什么图论很重要?**
|
||||
|
||||
图,是描述“多对多”关系的最强模型。它的应用无处不在:
|
||||
* **社交网络**:每个人是一个点,好友关系是一条线,构成一张巨大的社交图。
|
||||
* **交通网络**:城市是点,公路、铁路、航线是线,构成了交通图。导航软件的核心就是图算法。
|
||||
* **计算机网络**:路由器、服务器是点,网线是线,构成了计算机网络拓扑图。
|
||||
|
||||
---
|
||||
|
||||
#### **二、 核心思想:从具象到抽象**
|
||||
|
||||
##### **2.1 思路铺垫**
|
||||
|
||||
对于校园地图的例子。
|
||||
* 地图上的每一个关键**地点**(大门、教学楼、食堂...),我们把它抽象成一个**点**。
|
||||
* 连接两个地点之间的每一条**道路**,我们把它抽象成一条**线**。
|
||||
|
||||
通过这种方式,一张现实地图,就被简化成了一个由“点”和“线”组成的数学图形。这就是图的本质。
|
||||
|
||||
##### **2.2 图的数学定义**
|
||||
|
||||
在数学和计算机科学中,我们对图有更精确的定义:
|
||||
一个图 `G` 是一个二元组 `(V, E)`,记为 `G = (V, E)`。
|
||||
|
||||
* **`V` (Vertex Set)**:一个非空的**顶点**集合。每个顶点代表一个实体或对象。
|
||||
* **`E` (Edge Set)**:一个**边**的集合。每条边连接 `V` 中的两个顶点,代表它们之间存在某种连接关系。
|
||||
|
||||
**示例:**
|
||||
|
||||
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260529171113949.png" width="20%" alt="image-20260529171113949">
|
||||
|
||||
对于上图 `G`:
|
||||
* 顶点集合 V = {1, 2, 3, 5}
|
||||
* 边集合 E = {(1, 2), (1, 3), (2, 3), (2, 5)}
|
||||
|
||||
##### **2.3 图的核心组成元素**
|
||||
|
||||
1. **顶点 (Vertex)**
|
||||
* 也称为**节点**。在图中,它就是那些独立的“点”,用于代表现实世界中的事物。
|
||||
* **例如**:校园地图中的“建筑物”,社交网络中的“用户”,交通网络中的“城市”。
|
||||
|
||||
2. **边 (Edge)**
|
||||
* 连接两个顶点的“线”,用于表示两个顶点(事物)之间的特定关系。
|
||||
* **例如**:地图中的“道路”,社交网络中的“好友关系”,交通网络中的“航线”。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 图的分类与属性**
|
||||
|
||||
现实世界的关系是多种多样的,因此图也有不同的类型和属性来描述它们。
|
||||
|
||||
##### **3.1 无向图**
|
||||
|
||||
* **定义**:图中的每条边都是**双向**的。如果从顶点u到顶点v有一条边,那么也必然可以从v回到u。边的表示用圆括号 `(u, v)`,且 `(u, v)` 与 `(v, u)` 代表同一条边。
|
||||
* **现实类比**:双向通行的马路、互为好友的社交关系。
|
||||
* **相关术语:度**
|
||||
* 一个顶点的**度**,是指与该顶点**直接相连**的边的数量。
|
||||
* **示例**:在 讲义 **2.2 节** 的图中,顶点2的度为3,因为它连接了1、3、5三个顶点。
|
||||
|
||||
##### **3.2 有向图**
|
||||
|
||||
* **定义**:图中的每条边都是**单向**的,具有明确的方向性。边的表示用括号来表示 `<u, v>`,它表示一条从u出发,指向v的边,这与 `<v, u>` 是两条完全不同的边。
|
||||
* **类比**:单行道、社交网络中的“关注”关系(A关注B,不代表B一定关注A)。
|
||||
* **相关术语:入度 与 出度**
|
||||
* 一个顶点的**入度**:指**指向**该顶点的边的数量。
|
||||
* 一个顶点的**出度**:指由该顶点**出发**指向其他顶点的边的数量。
|
||||
|
||||
##### **3.3 带权图**
|
||||
|
||||
* **定义**:图中的每条边都被赋予一个数值,这个数值称为**权重 或**成本。这个权重可以代表各种现实意义。
|
||||
* **边权**:边的权重。
|
||||
* **例如**:地图中道路的**长度**或通过所需**时间**;网络中传输数据的**带宽**或**延迟**;交通网络中的**票价**或**距离**。
|
||||
* **点权**:类似地,顶点也可以被赋予权重,代表该点自身的某种属性值(例如,一个城市的人口,一个景点的好评度)。
|
||||
|
||||
##### **3.4 简单图**
|
||||
|
||||
在大多数算法问题中,我们处理的图都是**简单图**,它满足两个条件:
|
||||
1. 不包含**自环**:即一条连接顶点和其自身的边。
|
||||
2. 不包含**重边**:即连接同一对顶点的多条边。
|
||||
|
||||
我们后续的讨论,如无特殊说明,都将基于简单图。
|
||||
|
||||
---
|
||||
|
||||
#### **四、 图的拓扑属性与概念**
|
||||
|
||||
除了基本的分类,图还具有一些描述其整体结构与连接特性的概念。
|
||||
|
||||
##### **4.1 连通性 **
|
||||
|
||||
* **连通**:若图中从顶点u到顶点v存在一条通路(可以经过若干中间顶点),则称 u 和 v 是**连通**的。
|
||||
* **连通图**:如果一个**无向图**中,**任意两个顶点**都是连通的,那么这个图被称为**连通图**。反之,如果图中存在至少一个“孤岛”,则为**非连通图**。
|
||||
* **强连通图**:这个概念专属于**有向图**。如果一个有向图中,对**任意两个顶点**u和v,都**同时存在**从u到v的路径 **和** 从v到u的路径,则称该图为**强连通图**。
|
||||
|
||||
##### **4.2 完全图**
|
||||
|
||||
* **定义**:图中**任意两个不同的顶点之间都存在一条边**的图,称为完全图。完全图是边数达到最大值的最“稠密”的图。
|
||||
* **无向完全图**:一个有n个顶点的无向完全图,其边数为 `e = n * (n - 1) / 2`。
|
||||
* **有向完全图**:一个有n个顶点的有向完全图,任意两点间都存在方向相反的两条边,其边数为 `e = n * (n - 1)`。
|
||||
|
||||
##### **4.3 路径**
|
||||
|
||||
* **路径**:在图中,从一个起点到一个终点所经过的**顶点序列**,就是一条路径。
|
||||
* **最短路径**:在一个带权图中,连接两个顶点的所有路径中,**路径上各边权重之和最小**的那一条路径,被称为最短路径。这是图论算法中一个非常核心、经典的问题。
|
||||
|
||||
---
|
||||
|
||||
#### **五、 总结**
|
||||
|
||||
* **图的本质**:图是用于描述事物间“**多对多**”关系的数学模型,由**顶点(Vertex)**和**边(Edge)**构成,即 `G = (V, E)`。
|
||||
* **核心分类**:
|
||||
* **无向图**:边是双向的,关系是相互的。顶点的核心属性是**度**。
|
||||
* **有向图**:边是单向的,关系有明确方向。顶点的核心属性是**入度**和**出度**。
|
||||
* **重要属性**:
|
||||
* **带权图**:边上附有数值(**权重**),代表成本、距离、时间等。
|
||||
* **拓扑概念**:
|
||||
* **连通性**:描述了图中顶点间路径的可达性,分为**连通图**、**强连通图**等。
|
||||
* **完全图**:一种边最稠密的特殊图,任意两点间都直接相连。
|
||||
* **路径**:是图的遍历基础,而**最短路径**是图算法的核心应用之一(之后的课程会详细讲解)。
|
||||
Binary file not shown.
@@ -0,0 +1,194 @@
|
||||
### **C++ 算法:图论(二)- 图的存储结构 讲义**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入**
|
||||
|
||||
我们已经知道,一张校园地图可以被抽象为一个由顶点(建筑)和边(道路)组成的图。但计算机不认识“点”和“边”,我们必须找到一种方式,用代码来描述“哪个顶点和哪个顶点相连?”以及“连接它们的道路有多长?”这个问题。
|
||||
|
||||
换言之,我们需要一个**数据结构**,来充当理论模型与算法实现之间的桥梁。
|
||||
|
||||
而存储图有多种办法,不同的方法会影响后续图算法的**时间复杂度**和**空间复杂度**。
|
||||
|
||||
---
|
||||
|
||||
#### **二、 存储方法一:邻接矩阵 (Adjacency Matrix)**
|
||||
|
||||
##### **2.1 核心思想:关系地图**
|
||||
|
||||
邻接矩阵:我们创建一个二维数组,用它来记录**每一对顶点之间**是否存在边。
|
||||
|
||||
例如我们有一张城市间的航班距离表:
|
||||
|
||||
| | 北京 | 上海 | 广州 |
|
||||
| :------- | :----- | :----- | :----- |
|
||||
| **北京** | 0 | 1200km | 2100km |
|
||||
| **上海** | 1200km | 0 | 1400km |
|
||||
| **广州** | 2100km | 1400km | 0 |
|
||||
|
||||
这个表格就是一个邻接矩阵。行和列都代表图的顶点,表格中的`[行, 列]`位置的数值,就代表了对应两个顶点间的关系(边的信息)。
|
||||
|
||||
##### **2.2 实现细节**
|
||||
|
||||
在C++中,我们通常使用一个二维数组 `G[N][N]` 来表示邻接矩阵,其中 `N` 是顶点的最大数量。`G[u][v]` 的值代表了从顶点 `u` 到顶点 `v` 的边的信息。
|
||||
|
||||
* **对于无权图**:
|
||||
* `G[u][v] = 1` 表示从 `u`到 `v` 存在一条边。
|
||||
* `G[u][v] = 0` 表示从 `u`到 `v` 不存在边。
|
||||
|
||||
* **对于带权图**:
|
||||
* `G[u][v] = w` 表示从 `u`到 `v` 存在一条边,且其权重为 `w`。
|
||||
* 如果边不存在,我们可以用一个特殊值来表示,通常是 **0, -1 或 无穷大**。如果用一个很大的整数(可以用 `0x3f3f3f3f`)来模拟无穷大,这样如果需要进行一些计算的时候的时候不易溢出。
|
||||
|
||||
* **对于无向图**:
|
||||
* 图中的边是双向的,所以矩阵是**对称**的。即 `G[u][v]` 和 `G[v][u]` 的值总是相等。我们在存边时,需要同时设置这两个值。
|
||||
|
||||
##### **2.3 代码实现**
|
||||
|
||||
我们实现储存一个顶点数量为 n , 边的数量为 m 的一个有向带权图(权重定义为顶点之间的距离)。
|
||||
|
||||
```cpp
|
||||
#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
|
||||
int main() {
|
||||
int n, m; // n个顶点, m条边
|
||||
cin >> n >> m;
|
||||
|
||||
// vector的大小为 n x n,初始全部设置为 0,代表不存在任何边
|
||||
vector<vector<int> > G(n, vecotr<int>(n, 0));
|
||||
|
||||
for (int i = 0; i < m; i++) {
|
||||
int u, v, w;
|
||||
cin >> u >> v >> w; // 读入一条从u到v,权重为w的边
|
||||
|
||||
// 如果是无向图,需要存两条边
|
||||
G[u][v] = w;
|
||||
G[v][u] = w; // 若是有向图,则没有这一行
|
||||
}
|
||||
|
||||
// 查询从顶点2到顶点3的距离(权值)
|
||||
if (G[2][3] == 0) {
|
||||
cout << "顶点2和3没有边相连" << endl;
|
||||
} else {
|
||||
cout << "顶点2到3的距离是: " << G[2][3] << endl;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
##### **2.4 优劣分析**
|
||||
|
||||
* **优点**:
|
||||
* **实现简单**:非常直观,易于理解和编写。
|
||||
* **查询效率高**:判断两个顶点 `u` 和 `v` 是否有边只需访问 `G[u][v]` 即可,时间复杂度为 **O(1)**。
|
||||
* **缺点**:
|
||||
* **空间复杂度高**:无论图中有多少条边,都需要一个 `N*N` 的空间,空间复杂度为 **O(N²)**。
|
||||
* **对于稀疏图(边数远小于顶点数的平方)来说,空间浪费巨大**。例如,1万个点但只有100条边的图,矩阵中绝大部分空间都是储存的 0,代表没有边相连,被浪费了。
|
||||
* **遍历邻居效率低**:要找到一个顶点的所有邻居,必须遍历一整行,时间复杂度为 **O(N)**。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 存储方法二:邻接表 (Adjacency List)**
|
||||
|
||||
##### **3.1 核心思想:记录我的邻居是谁**
|
||||
|
||||
邻接矩阵像一张全国地图,记录了所有城市两两之间的关系,信息很全但很占地方。而邻接表则更像每个人的通讯录,只记录与自己有直接关系的人。
|
||||
|
||||
具体来说,我们为图中的**每一个顶点**,都维护一个**列表**,这个列表里只存放与该顶点**直接相连**的其他顶点(以及边的权重,如果是带权图)。
|
||||
|
||||
##### **3.2 通过`struct`封装实现邻接表**
|
||||
|
||||
为了更好地组织和管理图的数据,我们可以使用一个自定义的结构体 `Graph` 来表示和存储一个图。这种方式将图的所有关键信息打包在一起,让代码结构更清晰。
|
||||
|
||||
**1. 定义图的结构体**
|
||||
```cpp
|
||||
// 定义图的结构体
|
||||
struct Graph {
|
||||
int V = -1, E = -1; // V:顶点数量, E:边的数量
|
||||
vector<vector<int>> adjList; // 邻接表
|
||||
};
|
||||
```
|
||||
* `V` 和 `E` 分别存储图的顶点数和边数。
|
||||
* `adjList` 是一个 `vector` 的 `vector`。一个动态的二维数组。`adjList[u]` 本身就是一个 `vector`,它将作为顶点 `u` 的“邻居通讯录”,里面存放所有与 `u` 直接相连的顶点编号。
|
||||
|
||||
**2. 初始化图的结构**
|
||||
在使用这个图之前,我们需要一个初始化函数来设置其规模,并准备好存放邻居列表的空间。
|
||||
|
||||
```cpp
|
||||
struct Graph {
|
||||
int V = -1, E = -1; // V:顶点数量, E:边的数量
|
||||
vector<vector<int>> adjList; // 邻接表
|
||||
|
||||
// 初始化函数,设置顶点和边的数量
|
||||
void setCount(int new_V, int new_E) {
|
||||
V = new_V; E = new_E;
|
||||
// 一共有 V 个顶点,故需要 V 个对应顶点的通讯录来储存直接相连的顶点编号
|
||||
adjList.resize(new_V);
|
||||
}
|
||||
};
|
||||
```
|
||||
* `setCount` 函数接收顶点的数量 `new_V`,然后调用 `resize` 方法,将 `adjList` 变成一个包含 `new_V` 个空 `vector` 。
|
||||
|
||||
**3. 添加边**
|
||||
图的结构准备好后,我们需要一个方法来添加边,以描述顶点间的连通情况。
|
||||
|
||||
```cpp
|
||||
struct Graph {
|
||||
// 省略之前的结构体内容...以下为添加边的函数
|
||||
void addEdge(int src, int dest) {
|
||||
if (V == -1) return; // 确保图已被初始化
|
||||
|
||||
// 代表存在一条边src->sest, 在起点 src 的相连顶点数组中添加顶点 dest
|
||||
// 注意如果是无向图,还需要添加从 dest 到 src 的反向边
|
||||
adjList[src].push_back(dest);
|
||||
}
|
||||
};
|
||||
```
|
||||
* `addEdge` 函数就是在起点 `src` 的相连顶点列表 `adjList[src]` 的末尾,添加上终点 `dest`。
|
||||
* **注意**:对于**无向图**,由于边是双向的,添加一条 `(u, v)` 的边,意味着 `v` 是 `u` 的邻居,同时 `u` 也是 `v` 的邻居。因此需要**双向添加**。
|
||||
|
||||
##### **3.3 代码实现:使用`Graph`结构体**
|
||||
|
||||
下面我们通过一个完整的例子,演示如何使用这个 `Graph` 结构体来存储一个**有向无权图**。
|
||||
|
||||
```cpp
|
||||
int main() {
|
||||
// 创建一个图对象
|
||||
Graph G;
|
||||
int V, E;
|
||||
cin >> V >> E;
|
||||
|
||||
// 调用初始化函数,准备好邻接表的结构
|
||||
G.setCount(V, E);
|
||||
|
||||
// 读取 E 条边,并添加到图中
|
||||
for (int i = 0; i < E; ++i) {
|
||||
int src, dest;
|
||||
cin >> src >> dest;
|
||||
G.addEdge(src, dest);
|
||||
}
|
||||
|
||||
// 此时,图G中已经存好了所有的顶点和边信息,可以进行操作,例:打印顶点3的所有邻居
|
||||
for (int neighbor : G.adjList[3]) {
|
||||
cout << neighbor << " ";
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.4 优劣分析**
|
||||
|
||||
* **优点**:
|
||||
* **空间效率高**:邻接表只存储实际存在的边,空间复杂度为 **O(N + M)**(N为顶点数,M为边数)。对于使用邻接矩阵的的 **O(N²)**,节省了大量空间。
|
||||
* **遍历邻居效率高**:邻接表要找到一个顶点的所有邻居,只需遍历其对应的 `vector` 即可,时间复杂度为 **O(出度(u))**,其中 `出度(u)` 是顶点u的度。优于邻接矩阵的遍历邻居时间复杂度**O(N)** 。
|
||||
* **缺点**:
|
||||
* **查询效率较低**:邻接表判断两个顶点 `u` 和 `v` 是否有边,需要遍历 `adj[u]` 这个列表,时间复杂度为 **O(出度(u))**,不如邻接矩阵直接查询的 O(1) 快。
|
||||
|
||||
#### **四、 总结**
|
||||
|
||||
* 我们学习了两种主流的图存储方法:**邻接矩阵**和**邻接表**。
|
||||
* **邻接矩阵**是一个二维数组,用 `G[u][v]` 存储边的信息,它像一张**完整的地图**,优点是查询快,缺点是浪费空间。
|
||||
* **邻接表**是一个 `vector` 数组,`adj[u]` 存储了 `u` 的所有邻居,它像一本**通讯录**,优点是节省空间,缺点是查询稍慢。选择主要在于分析图的**疏密程度**:**稠密用矩阵,稀疏用表**。
|
||||
* **在信息学竞赛和实际应用中,由于图的稀疏性,其实邻接表的使用频率会稍高于邻接矩阵。**
|
||||
Binary file not shown.
@@ -0,0 +1,185 @@
|
||||
### **C++ 算法:图论(三)- 图的遍历 讲义**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入**
|
||||
|
||||
我们已经熟悉了数组和树的遍历。数组的遍历是一条直线,从头到尾即可。树的遍历虽有岔路,但结构清晰,从父到子,不会回头。
|
||||
|
||||
但是,图的遍历要复杂得多。我们面临着两个新的问题:
|
||||
1. **连接的顶点多**:在树中,一个节点最多连接少数几个子节点。但在图中,一个顶点可能连接着任意数量的其他顶点,我们该如何选择前进的方向?
|
||||
2. **存在“环”**:在树中,我们绝不会走回祖先节点。但在图中,从A出发,经过B、C,完全可能再回到A,形成一个“环”。如果程序处理不当,就会陷入无限循环。
|
||||
|
||||
让我们通过书本中的**【例18-3 查找文献】**案例,来学习如何解决这些问题。
|
||||
|
||||
小K正在阅读一篇编号为1的博客文章。这篇文章引用了若干其他文章作为参考文献,而这些参考文献又各自引用了更多的文章,形成了一个庞杂的引用网络。小K求知欲旺盛,他决定:**只要读了一篇文章,就必须把它引用的所有没读过的文章都读一遍。**
|
||||
|
||||
现在,我们拥有这个引用网络的完整数据(`n`篇文章,`m`条引用关系)。问题是:我们如何设计一个策略,能帮助小K**不重复、不遗漏**地看完所有他从文章1出发能接触到的文章?
|
||||
|
||||
我们可以对问题进行**抽象**为图论问题:
|
||||
|
||||
- 每一篇**文章**,都可以看作一个**顶点**。
|
||||
- 每一条从文章X到文章Y的**引用关系**,都可以看作一条从X指向Y的**有向边**。
|
||||
|
||||
因此,这个问题被成功地转化为了一个图论问题:**从图的某个起始顶点出发,遍历所有可达的顶点。**
|
||||
|
||||
---
|
||||
|
||||
#### **二、 核心思想:如何做到“不重复、不遗漏”?**
|
||||
|
||||
##### **2.1 用`visited`数组避免重复**
|
||||
|
||||
为了解决图遍历中可能出现的“环”和“重复访问”问题,我们引入一个关键工具:**`visited`布尔数组**。
|
||||
|
||||
* **定义**:`bool visited[N];`
|
||||
* **作用**:用于记录对应的顶点我们是否已经访问过。`visited[i] = true` 表示顶点 `i` 我们已经访问过这个顶点了。
|
||||
* **规则**:
|
||||
1. 每当我们**首次**访问一个顶点时,立刻在“笔记”上记录下来:`visited[i] = true`。
|
||||
2. 在尝试访问任何一个顶点之前,检查**:`if (!visited[i])`**。我们只访问还没有到过的顶点。
|
||||
|
||||
这个`visited`数组是保证遍历**不重复**的关键,是DFS和BFS都必须使用的基础工具。
|
||||
|
||||
##### **2.2 DFS 与 BFS** 遍历图
|
||||
|
||||
解决了不重复的问题,我们如何保证不遗漏呢?有两种策略:
|
||||
|
||||
* **策略一:深度优先(DFS)- “一条路走到底”**
|
||||
* 当你读了文章A,发现它引用了B和C。你选择立刻深入进去,先把B以及B的所有参考文献、B的参考文献的参考文献……全部看完,直到这条链条的尽头。然后才“回溯”过来看文章C。
|
||||
* 一条路走到黑,不撞南墙不回头。
|
||||
|
||||
* **策略二:广度优先(BFS)- “逐层拓展”**
|
||||
* 当你读完文章A,发现它引用了B和C。你不会立刻深入,而是先把同一层级的B和C都看完。然后,再去看B和C共同引用的下一层级的所有文章。
|
||||
* 一层一层地看,像水波一样由近及远地扩散。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 深度优先遍历 (DFS)**
|
||||
|
||||
##### **3.1 算法精讲**
|
||||
|
||||
DFS体现的是一条路走到底的策略。其过程符合**递归**的定义:要访问顶点`u`的所有可达点,我们先访问`u`,然后对`u`的每一个邻居`v`,再去递归地执行“访问`v`的所有可达点”这一相同的任务。它的两个重要动作是**深入探索**和**回溯**。
|
||||
|
||||
##### **3.2 步骤拆解(以查找文献的样例数据为例子)**
|
||||
|
||||
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260602155335576.png" alt="image-20260602155335576" style="zoom:33%;" />
|
||||
|
||||
DFS的遍历过程为:
|
||||
1. **从1出发**:访问**1**,标记`visited[1]=true`。1的邻居有2, 3, 4。
|
||||
2. **深入2**:选择邻居2,递归调用DFS(2)。访问**2**,标记`visited[2]=true`。2的邻居有5, 6。
|
||||
3. **深入5**:选择邻居5,递归调用DFS(5)。访问**5**,标记`visited[5]=true`。5没有邻居,已到达尽头。
|
||||
4. **回溯到2**:从5的调用中返回。检查2的下一个邻居6。
|
||||
5. **深入6**:选择邻居6,递归调用DFS(6)。访问**6**,标记`visited[6]=true`。6没有邻居。
|
||||
6. **回溯到2**:从6返回。2的所有邻居都已探索完毕。
|
||||
7. **回溯到1**:从2返回。检查1的下一个邻居3。
|
||||
8. **深入3 -> 7 -> 8**:重复上述过程,一路深入到8,再层层回溯到1。
|
||||
9. **探索4**:检查1的下一个邻居4,递归调用DFS(4)。访问**4**,标记`visited[4]=true`。
|
||||
10. **检查4的邻居**:尝试访问7和8,但检查发现`visited[7]`和`visited[8]`都已为`true`,故跳过。
|
||||
11. **回溯**:从4返回1。1的所有邻居探索完毕。遍历结束。
|
||||
|
||||
##### **3.3 代码实现**
|
||||
|
||||
```cpp
|
||||
// DFS函数,v是当前访问的顶点, adj: 邻接表, visited: 访问数组
|
||||
void DFS(int v) {
|
||||
visited[v] = true;
|
||||
cout << v << " "; // 访问当前节点
|
||||
|
||||
// 遍历v的所有邻居
|
||||
for (int neighbor : adj[v]) {
|
||||
// 如果邻居未被访问过,则递归深入
|
||||
if (!visited[neighbor]) {
|
||||
DFS(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 题目要求中邻居按小编号优先,所以在注意在主函数中对邻接表排序
|
||||
for (int i = 1; i <= n; i++) {
|
||||
sort(adj[i].begin(), adj[i].end());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **四、 广度优先遍历 (BFS)**
|
||||
|
||||
##### **4.1 算法思路**
|
||||
|
||||
BFS是“逐层扩散”的思路。它必须先处理完最早发现的节点,再处理新发现的节点。这种“先进先出”的规则,契合了**队列(Queue)**的数据结构。
|
||||
|
||||
**算法流程**:
|
||||
1. 创建一个队列,并将起始顶点放入队列。标记起始顶点为已访问。
|
||||
2. 当队列不为空时,循环执行以下操作:
|
||||
a. 从**队头**取出一个顶点`u`。
|
||||
b. 访问`u`。
|
||||
c. 遍历`u`的所有邻居`v`,如果`v`未被访问过,则标记`v`并将其放入**队尾**。
|
||||
|
||||
##### **4.2 步骤拆解(以查找文献的样例数据为例子)**
|
||||
|
||||
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260602155335576.png" alt="image-20260602155335576" style="zoom:33%;" />
|
||||
|
||||
| 步骤 | 操作 | 队列状态 (Queue) | 已访问 (Visited) | 输出 |
|
||||
| :--- | :---------------------------- | :--------------- | :---------------- | :--- |
|
||||
| 1 | 初始状态,1入队 | `[1]` | {1} | |
|
||||
| 2 | 1出队,访问1,其邻居2,3,4入队 | `[2, 3, 4]` | {1,2,3,4} | 1 |
|
||||
| 3 | 2出队,访问2,其邻居5,6入队 | `[3, 4, 5, 6]` | {1,2,3,4,5,6} | 2 |
|
||||
| 4 | 3出队,访问3,其邻居7入队 | `[4, 5, 6, 7]` | {1,2,3,4,5,6,7} | 3 |
|
||||
| 5 | 4出队,访问4,其邻居8入队 | `[5, 6, 7, 8]` | {1,2,3,4,5,6,7,8} | 4 |
|
||||
| ... | ... | ... | ... | ... |
|
||||
| 10 | 队列为空,结束 | - | - | |
|
||||
|
||||
**最终访问顺序:1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8**
|
||||
|
||||
##### **4.3 代码实现**
|
||||
|
||||
```cpp
|
||||
// DFS函数,v是当前访问的顶点, adj: 邻接表, visited: 访问数组
|
||||
void BFS(int v) {
|
||||
queue<int> q;
|
||||
|
||||
q.push(v); // 起始点入队
|
||||
visited[v] = true;
|
||||
|
||||
while (!q.empty()) {
|
||||
int u = q.front(); q.pop(); // 取出队头
|
||||
|
||||
cout << u << " "; // 访问
|
||||
|
||||
// 根据邻接表遍历所有邻居
|
||||
for (int v : adj[u]) {
|
||||
if (!visited[v]) {
|
||||
visited[v] = true;
|
||||
q.push(v); // 新发现的顶点入队
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// main函数与DFS版本只是调用函数不同而已
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **五、 总结与应用**
|
||||
|
||||
##### **5.1 如何将现实问题抽象为图?**
|
||||
|
||||
图的表达能力极强,世界上绝大多数涉及“物体”和“连接”的问题,都可以抽象为图。只需问自己两个问题:
|
||||
1. **物体是什么?** -> 它们成为图的**顶点**。
|
||||
2. **连接是什么?** -> 它们成为图的**边**。
|
||||
|
||||
* **社交网络**:用户是顶点,好友关系是边。找“朋友的朋友”就是图的遍历。
|
||||
* **城市导航**:路口是顶点,道路是边。找“最短路线”就是图的最短路径搜索。
|
||||
|
||||
##### **5.2 拓展:非连通图的遍历**
|
||||
|
||||
在本题中,我们只需从顶点1开始遍历。但如果一个图由多个互不相连的部分组成(非连通图),我们该如何遍历所有顶点?
|
||||
|
||||
**解决办法**:在 `main` 函数中使用一个 `for` 循环,从 1 到 n 检查每一个顶点 `i`。如果发现 `!visited[i]`,就说明我们发现了一个新的、独立的连通块,此时再以 `i` 为起点,调用DFS或BFS来探索这个新部分。
|
||||
|
||||
```cpp
|
||||
for (int i = 1; i <= n; i++) {
|
||||
if (!visited[i]) { // 如果还没有被访问过,从点i开始遍历这个新的连通图
|
||||
DFS(i); // 或 BFS(i)
|
||||
}
|
||||
}
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,157 @@
|
||||
### **C++ 算法:图论(四)- 反向图**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入:寻找可达的最大顶点**
|
||||
|
||||
【例18-4】 图的遍历
|
||||
|
||||
给出一个 N 个点、M 条边的有向图。对于图中的每一个顶点 v,我们需要求出 A(v),它表示**从点 v 出发,沿着有向边能到达的所有顶点中,编号最大的那一个**。
|
||||
|
||||
例如,在一个图中,如果从顶点3开始出发只能到达 {3, 5, 8, 2},那么 A(3) 的值就是8。我们需要为图中所有的点(从1到N)都计算出这个值。
|
||||
|
||||
---
|
||||
|
||||
#### **二、 解题思路**
|
||||
|
||||
##### **2.1 思路一:暴力遍历**
|
||||
|
||||
面对这个问题,我们的第一反应通常是:对每一个点 `u`(从1到N),都从它出发做一次完整的 DFS 或 BFS,记录下所有能访问到的顶点,然后从中找出一个最大值。
|
||||
|
||||
* **复杂度分析**:
|
||||
* 一次完整的图遍历,复杂度为 O(N+M)。
|
||||
* 我们需要对 N 个点都执行一次。
|
||||
* 总时间复杂度 = N * O(N+M) = O(N² + N*M)。
|
||||
* **结论**:题目给出的数据范围 N, M 高达 10⁵。N² 级别的复杂度会达到 10¹⁰,程序必然超时。**暴力方法虽然思路正确,但效率过低,不是可行解。**
|
||||
|
||||
##### **2.2 思路二:记忆化搜索**
|
||||
|
||||
既然暴力不行,我们来优化。分析 A(u) 的答案构成:
|
||||
`A(u) = max(u, A(v₁), A(v₂), ...)`,其中 `v₁`, `v₂`... 是 `u` 的所有邻居。
|
||||
|
||||
这是一个递归的结构,但纯粹的递归会产生海量的**重复计算**。例如,计算A(0)时可能需要算A(2),而计算A(1)时可能也需要算A(2)。A(2)会被重复计算多次。
|
||||
|
||||
**优化方案:记忆化搜索**
|
||||
我们创建一个`Answer`数组,`Answer[i]` 用来存储 A(i) 的计算结果。
|
||||
```cpp
|
||||
int A(int u) {
|
||||
if (Answer[u] != 0) { // 如果算过了
|
||||
return Answer[u]; // 直接返回记好的答案
|
||||
}
|
||||
int ans = u;
|
||||
for (int v : adj[u]) {
|
||||
ans = max(ans, A(v));
|
||||
}
|
||||
Answer[u] = ans; // 算出来后,记在笔记本上
|
||||
return ans;
|
||||
}
|
||||
```
|
||||
|
||||
##### **2.3 缺陷:有环图中的循环依赖**
|
||||
|
||||
记忆化搜索看似能解决问题,但在有向有环图中,它会陷入死循环。
|
||||
考虑这样一个环:`2 -> 4 -> 3 -> 2`
|
||||
|
||||
* 为了计算 `A(2)`,程序需要先知道 `A(4)`。
|
||||
* 为了计算 `A(4)`,程序需要先知道 `A(3)`。
|
||||
* 为了计算 `A(3)`,程序需要先知道 `A(2)`。
|
||||
|
||||
这就形成了一个逻辑上的循环:一个尚未完成计算的函数,被它自己的调用链反向依赖。就像老板问经理要项目结果,经理问员工,员工却反过来问老板结果是什么。程序会因无限递归而崩溃。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 解决方案:反向图**
|
||||
|
||||
既然正向求解(我能到哪?)的思路在有环时碰壁了,我们不妨**逆转思维**。
|
||||
|
||||
##### **3.1 从“我去哪”到“谁来找我”**
|
||||
|
||||
* **正向思考:我(顶点`u`)出发,能到达的最大编号是多少?
|
||||
* **反向思考:对于一个编号很大的顶点 `k`,有哪些点可以到达它?对于所有能到达`k`的点`u`来说,`A(u)`的候选答案里就必然包含了`k`。
|
||||
|
||||
##### **3.2 反向图的定义与构建**
|
||||
|
||||
为了实现“谁能到我”的查询,我们引入**反向图**。
|
||||
|
||||
* **定义**:对于一个有向图 G,其反向图 G_rev 拥有和 G 完全相同的顶点。对于 G 中的任意一条边 `u -> v`,在 G_rev 中都对应一条方向相反的边 `v -> u`。
|
||||
* **构建**:在读入原图的边 `u -> v` 时,我们不执行 `adj[u].push_back(v)`,而是执行 `rev_adj[v].push_back(u)`,即在**终点v**的邻接表中加入**起点u**。
|
||||
* **功能**:在**反向图**上从顶点 `k` 开始遍历,所有能被访问到的点,就是在**原图**中所有能到达 `k` 的点。
|
||||
|
||||
##### **3.3 结合贪心思想**
|
||||
|
||||
我们的目标是求最大值,一个很自然的**贪心**想法是:让编号越大的顶点,越有优先权去宣告自己是答案。
|
||||
|
||||
**算法流程**:
|
||||
1. **反向建图**:读入 `m` 条边 `u -> v`,构建反向图(添加边 `v -> u`)。
|
||||
2. **初始化**:创建一个 `ans` 数组,全部初始化为-1,表示所有点的答案待定。
|
||||
3. **倒序遍历与广播**:
|
||||
* 我们从大到小遍历所有顶点 `i` (从 `N` 到 `1`)。
|
||||
* 对于当前的顶点 `i`,检查 `ans[i]`。
|
||||
* **如果 `ans[i]` 仍为-1**:这说明 `i` 没有被任何比它更大的顶点“覆盖”过。它就是它所在“可达区域”的最大顶点。此时,我们从 `i` 出发,在**反向图**上进行一次遍历(DFS或BFS),将所有能访问到且`ans`值仍为-1的顶点`u`,都标记上答案:`ans[u] = i`。
|
||||
* **如果 `ans[i]` 不为-1**:这说明 `i` 已经被一个比它更大的顶点(比如 `j > i`)“覆盖”了,那么 `i` 就不可能是它自己的最终答案。我们直接跳过,什么也不做。
|
||||
4. **输出结果**:倒序循环结束后,`ans`数组中就存储了所有点的最终答案。
|
||||
|
||||
---
|
||||
|
||||
#### **四、 代码实现**
|
||||
|
||||
```cpp
|
||||
vector<vector<int> > rev_adj; // 存储反向图的邻接表
|
||||
vector<int> ans; // 存储最终答案
|
||||
|
||||
// 在反向图上进行DFS,将答案i“广播”出去
|
||||
void dfs(int u, int max_node_val) {
|
||||
ans[u] = max_node_val; // 标记当前点的答案
|
||||
|
||||
for (int v : rev_adj[u]) {
|
||||
if (ans[v] == 0) { // 如果邻居还未被广播过
|
||||
dfs(v, max_node_val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
int n, m;
|
||||
cin >> n >> m;
|
||||
rev_adj.resize(n + 1);
|
||||
ans.resize(n + 1);
|
||||
// 反向建图
|
||||
for (int i = 0; i < m; i++) {
|
||||
int u, v;
|
||||
cin >> u >> v;
|
||||
rev_adj[v].push_back(u); // 建立 v -> u 的反向边
|
||||
}
|
||||
|
||||
// 倒序遍历与广播
|
||||
for (int i = n; i >= 1; i--) {
|
||||
if (ans[i] == 0) { // ans[i]为0代表未被访问过
|
||||
// i没有被更大的节点覆盖,它就是自己区域的最大顶点
|
||||
// 开始以i为答案,在反向图上传播
|
||||
dfs(i, i);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 1; i <= n; i++) {
|
||||
cout << ans[i] << " ";
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **五、 反向图的应用与总结**
|
||||
|
||||
##### **5.1 什么时候应该用反向图?**
|
||||
|
||||
反向图本质上是一种**视角转换工具**。当遇到以下情况时,可以考虑使用它:
|
||||
|
||||
1. **反向可达性查询**:当问题关心的不是“从A能到哪”,而是“谁能到A”时。
|
||||
2. **正向求解遇阻**:当正向的递归或递推因为“循环依赖”或需要“未来状态”而陷入困境时,可以尝试从确定的终点或最优解出发,反向地去影响和更新前端节点。
|
||||
|
||||
##### **5.2 总结**
|
||||
|
||||
* 面对一个复杂的图问题,直接的暴力解法往往因为效率问题而不可行。
|
||||
* **记忆化搜索**是优化递归、避免重复计算的强大技巧,但要警惕它在**有环图**中可能导致的**循环依赖**问题。
|
||||
* **反向图**提供了一种全新的解题视角,能将“A能到哪”的问题转化为“谁能到A”,从而将一个复杂的依赖问题转化为标准的图遍历问题。
|
||||
Binary file not shown.
Reference in new issue
Block a user