图论是计算机科学中的一个重要分支,广泛应用于网络设计、路径规划、社交网络分析等领域。图论相关问题有很多,常见的包括最短路径问题、最小生成树问题、图的遍历等。
1. 最短路径问题
单源最短路径(Single Source Shortest Path)
从一个源点到图中其他所有顶点的最短路径。
- Dijkstra算法
适用于边权非负的图。使用优先队列实现,可以达到O((V + E)logV)的时间复杂度。
Dijkstra算法是一种经典的单源最短路径算法,适用于图中边权非负的情况。它的基本思想是通过贪心策略,逐步扩展最短路径集,从源点开始,逐步找到到其他所有顶点的最短路径。
适用条件
- 图中的边权必须是非负的。如果图中存在负权边,Dijkstra算法将无法保证找到正确的最短路径。
- 适用于有向图和无向图。
算法步骤
-
初始化:
- 创建一个距离数组
dist
,初始化为无限大,表示从源点到各个顶点的距离。dist[source]
设为0,因为源点到自身的距离为0。 - 使用一个优先队列(最小堆)来选择当前距离最小的顶点。初始时,将源点(距离为0)加入优先队列。
- 创建一个布尔数组
visited
,初始为false
,表示各个顶点是否已经确定了最短路径。
- 创建一个距离数组
-
松弛操作:
- 每次从优先队列中取出一个顶点
u
,表示当前已确定最短路径的顶点。标记u
为已访问。 - 遍历
u
的所有邻接顶点v
,如果通过u
到达v
的路径比当前已知的最短路径更短,则更新v
的最短路径,并将v
加入优先队列。
- 每次从优先队列中取出一个顶点
-
重复上述步骤:
- 重复步骤2,直到优先队列为空,此时所有顶点的最短路径都已确定。
代码示例(C++)
#include <iostream>
#include <vector>
#include <queue>
#include <utility>
#include <functional>
using namespace std;
typedef pair<int, int> P; // first是最短距离,second是顶点编号
void dijkstra(int source, vector<vector<P>>& graph) {
int n = graph.size();
vector<int> dist(n, INT_MAX); // 初始化距离数组
priority_queue<P, vector<P>, greater<P>> pq; // 优先队列(最小堆)
dist[source] = 0;
pq.push({0, source}); // 将源点加入优先队列
```cpp
#include <iostream>
#include <vector>
#include <queue>
#include <utility>
#include <functional>
using namespace std;
typedef pair<int, int> P; // first是最短距离,second是顶点编号
void dijkstra(int source, vector<vector<P>>& graph) {
int n = graph.size();
vector<int> dist(n, INT_MAX);
priority_queue<P, vector<P>, greater<P>> pq;
dist[source] = 0;
pq.push({0, source});
while (!pq.empty()) {
int d = pq.top().first;
int u = pq.top().second;
pq.pop();
if (d > dist[u]) continue;
for (auto edge : graph[u]) {
int v = edge.first;
int weight = edge.second;
if (dist[u] + weight < dist[v]) {
dist[v] = dist[u] + weight;
pq.push({dist[v], v});
}
}
}
// 输出结果
for (int i = 0; i < n; ++i) {
cout << "Vertex " << i << " distance from source " << source << ": " << dist[i] << endl;
}
}
- Bellman-Ford算法
适用于有负权边的图,但不适用于含负权回路的图。时间复杂度为O(VE)。
是一种用于计算单源最短路径的算法,特别适用于包含负权边的图。与Dijkstra算法不同,Bellman-Ford算法能够处理负权边,但不适用于含有负权回路(即总权重为负的循环路径)的图。如果图中存在负权回路,Bellman-Ford算法可以检测到。
适用条件
- 图中可以有负权边,但不能有负权回路。
- 适用于有向图和无向图。
算法步骤
-
初始化:
- 创建一个距离数组
dist
,初始化为无限大(表示从源点到各个顶点的距离)。dist[source]
设为0,因为源点到自身的距离为0。 - 创建一个边列表
edges
,包含图中所有的边(包括起点、终点和权重)。
- 创建一个距离数组
-
松弛操作:
- 对于每一条边(u, v, w),如果
dist[u] + w < dist[v]
,则更新dist[v] = dist[u] + w
。 - 重复上述步骤V-1次,其中V是顶点的数量。这是因为最短路径最多包含V-1条边。
- 对于每一条边(u, v, w),如果
-
检测负权回路:
- 再次遍历所有边,如果存在边(u, v, w)使得
dist[u] + w < dist[v]
,则图中存在负权回路。
- 再次遍历所有边,如果存在边(u, v, w)使得
算法复杂度
- 时间复杂度:O(VE),其中V是顶点数,E是边数。
- 空间复杂度:O(V),用于存储距离数组。
代码示例(C++)
#include <iostream>
#include <vector>
#include <limits.h>
using namespace std;
struct Edge
```cpp
#include <iostream>
#include <vector>
using namespace std;
struct Edge {
int u, v, weight;
};
void bellmanFord(int source, int V, vector<Edge>& edges) {
vector<int> dist(V, INT_MAX);
dist[source] = 0;
for (int i = 0; i < V - 1; ++i) {
for (auto edge : edges) {
if (dist[edge.u] != INT_MAX && dist[edge.u] + edge.weight < dist[edge.v]) {
dist[edge.v] = dist[edge.u] + edge.weight;
}
}
}
// 检测负权回路
for (auto edge : edges) {
if (dist[edge.u] != INT_MAX && dist[edge.u] + edge.weight < dist[edge.v]) {
cout << "Graph contains a negative-weight cycle" << endl;
return;
}
}
// 输出结果
for (int i = 0; i < V; ++i) {
cout << "Vertex " << i << " distance from source " << source << ": " << dist[i] << endl;
}
}
全源最短路径(All Pairs Shortest Path)
计算图中任意两点之间的最短路径。
- Floyd-Warshall算法
时间复杂度为O(V^3),适用于稠密图。
Floyd-Warshall算法是一种用于计算全源最短路径的动态规划算法,能够在多项式时间内找到图中每对顶点之间的最短路径。它适用于稠密图,即边数接近于顶点数平方的图。
适用条件
- 适用于有向图和无向图。
- 可以处理负权边,但要求图中没有负权回路。
- 适用于稠密图,因为其时间复杂度较高。
算法步骤
-
初始化:
- 创建一个二维数组
dist
,其中dist[i][j]
表示从顶点i到顶点j的最短路径长度。初始化为图中对应的边权重,如果没有直接边相连则初始化为无穷大(表示不可达)。对角线上的元素dist[i][i]
初始化为0。
- 创建一个二维数组
-
动态规划:
- 使用一个三重循环,依次将每个顶点作为中间点来更新最短路径。
- 具体步骤是,对于每对顶点
i
和j
,通过中间点k
来更新它们之间的最短路径。如果dist[i][j] > dist[i][k] + dist[k][j]
,则更新dist[i][j] = dist[i][k] + dist[k][j]
。 - 该过程需要遍历所有的顶点组合,因此算法复杂度为O(V^3)。
-
检测负权回路:
- 在算法结束后,检查数组
dist
的对角线元素dist[i][i]
是否有负值。如果存在负值,说明图中存在负权回路。
- 在算法结束后,检查数组
时间复杂度
- 时间复杂度:O(V^3),因为有三重嵌套循环,分别遍历所有的顶点。
- 空间复杂度:O(V^2),因为需要存储每对顶点之间的最短路径长度。
算法优势
- 能够找到所有顶点对之间的最短路径,适用于全源最短路径问题。
- 可以处理负权边,只要图中不含负权回路。
应用场景
- 网络路由:计算网络中任意两点之间的最短路径。
- 图的闭包计算:确定任意两个顶点之间是否存在路径。
- 动态规划问题:许多动态规划问题可以转化为最短路径问题。
Floyd-Warshall算法虽然时间复杂度较高,但对于顶点数相对较少且边数较多(稠密图)的情况,是一种有效的全源最短路径算法。由于其简洁性和普适性,它在图论算法中占有重要地位。
#include <iostream>
#include <vector>
using namespace std;
const int INF = 1e9;
void floydWarshall(vector<vector<int>>& graph) {
int V = graph.size();
vector<vector<int>> dist = graph;
for (int k = 0; k < V; ++k) {
for (int i = 0; i < V; ++i) {
for (int j = 0; j < V; ++j) {
if (dist[i][k] != INF && dist[k][j] != INF) {
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
}
// 输出结果
for (int i = 0; i < V; ++i) {
for (int j = 0; j < V; ++j) {
if (dist[i][j] == INF)
cout << "INF ";
else
cout << dist[i][j] << " ";
}
cout << endl;
}
}
2. 最小生成树问题(Minimum Spanning Tree, MST)
- Kruskal算法:
时间复杂度为O(ElogE),适用于稀疏图。
Kruskal算法是一种用于寻找加权无向图的最小生成树(MST)的贪心算法。该算法通过逐步选择权重最小的边,确保每次选择的边不会形成环,从而构建出最小生成树。它特别适用于稀疏图,即边数相对较少的图。
适用条件
- 适用于加权无向图。
- 图可以是连通的也可以是非连通的。对于非连通图,Kruskal算法会找到最小生成森林。
算法步骤
-
初始化:
- 创建一个边列表
edges
,包含图中所有的边(包括起点、终点和权重)。 - 对边列表按权重进行升序排序。
- 创建一个边列表
-
并查集初始化:
- 使用并查集(Union-Find)数据结构来检测和避免环的产生。初始化每个顶点为一个独立的集合。
-
选择边构建MST:
- 遍历排序后的边列表,依次选择权重最小的边。如果该边连接的两个顶点属于不同的集合,则将这两个集合合并,并将这条边加入到最小生成树中。
- 如果边连接的两个顶点属于同一个集合,则忽略这条边,避免形成环。
-
终止条件:
- 重复上述步骤直到最小生成树包含的边数等于
V-1
,其中V
是图中的顶点数。
- 重复上述步骤直到最小生成树包含的边数等于
时间复杂度
- 排序边列表:时间复杂度为O(ElogE),其中E是边数。由于对于连通图,E = O(V^2),所以也可以认为是O(ElogV)。
- 并查集操作:在路径压缩和按秩合并的优化下,单次操作的时间复杂度接近于O(1),总体复杂度为O(ElogV)。
- 综合时间复杂度:O(ElogE)。
算法优势
- 适用于稀疏图:在边数远小于顶点数平方的稀疏图中,Kruskal算法性能较优。
- 简单易实现:通过排序和并查集操作,算法逻辑清晰明了。
- 灵活性:适用于连通图和非连通图,能够找到最小生成森林。
应用场景
- 网络设计:构建最经济的网络连接,如通信网络、交通网络和电力网络。
- 聚类分析:在数据分析中,将数据点聚类为多个类。
- 图像处理:用于图像分割等操作。
Kruskal算法在计算最小生成树时,通过选择权重最小的边并使用并查集避免环的产生,以贪心策略确保最终生成的树的权重最小。其高效的性能和广泛的适用性,使其成为解决最小生成树问题的经典算法之一。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Edge {
int u, v, weight;
bool operator<(const Edge& other) const {
return weight < other.weight;
}
};
struct UnionFind {
vector<int> parent, rank;
UnionFind(int n) : parent(n), rank(n, 0) {
for (int i = 0; i < n; ++i) parent[i] = i;
}
int find(int u) {
if (u != parent[u]) parent[u] = find(parent[u]);
return parent[u];
}
void unite(int u, int v) {
int rootU = find(u), rootV = find(v);
if (rootU != rootV) {
if (rank[rootU] > rank[rootV]) parent[rootV] = rootU;
else if (rank[rootU] < rank[rootV]) parent[rootU] = rootV;
else {
parent[rootV] = rootU;
rank[rootU]++;
}
}
}
};
int kruskal(int V, vector<Edge>& edges) {
sort(edges.begin(), edges.end());
UnionFind uf(V);
int mstWeight = 0;
for (auto edge : edges) {
if (uf.find(edge.u) != uf.find(edge.v)) {
uf.unite(edge.u, edge.v);
mstWeight += edge.weight;
cout << "Edge (" << edge.u << ", " << edge.v << ") with weight " << edge.weight << " included in MST." << endl;
}
}
return mstWeight;
}
- Prim算法
时间复杂度为O(V^2),使用优先队列优化后为O(ElogV)。Prim算法是一种用于计算加权无向图的最小生成树(MST)的贪心算法。它通过逐步扩展已包含的顶点集合,选择权重最小的边,将新的顶点加入生成树,直到包含所有顶点。相比Kruskal算法,Prim算法在稠密图中表现较优。
适用条件
- 适用于加权无向图。
- 图必须是连通的。如果图不连通,Prim算法只能找到连通部分的生成树。
算法步骤
-
初始化:
- 选择一个起始顶点,初始化一个顶点集合
MST
,包含起始顶点。 - 初始化一个权重数组
key
,用于记录每个顶点到当前生成树的最小权重边。所有元素初始值为无穷大,起始顶点的权重设为0。 - 初始化一个父节点数组
parent
,用于记录每个顶点的父节点。起始顶点的父节点设为-1。
- 选择一个起始顶点,初始化一个顶点集合
-
构建MST:
- 从
key
数组中选择权重最小的顶点u
,将其加入MST
集合,并标记为已访问。 - 更新
u
的邻接顶点的key
值和parent
值。如果某个邻接顶点v
尚未包含在MST
集合中,且通过u
到v
的边权重小于当前记录的key[v]
,则更新key[v]
为该边权重,并将parent[v]
设为u
。
- 从
-
重复上述步骤:
- 重复步骤2,直到所有顶点都包含在
MST
集合中。
- 重复步骤2,直到所有顶点都包含在
时间复杂度
- 基本实现:如果使用邻接矩阵和简单的线性扫描方式,时间复杂度为O(V^2)。每次选择权重最小的顶点需要O(V)时间,总共进行V次操作。
- 优先队列优化:如果使用邻接表和二叉堆(优先队列)来优化选择最小权重顶点的过程,时间复杂度可以降低到O(ElogV)。每次选择最小权重顶点需要O(logV)时间,总共进行V次操作。每次更新邻接顶点权重需要O(logV)时间,总共进行E次操作。
算法优势
- 适用于稠密图:在边数接近于顶点数平方的稠密图中,Prim算法性能较优,特别是在使用优先队列优化的情况下。
- 逐步构建:算法逐步构建最小生成树,每一步选择当前权重最小的边,易于理解和实现。
- 空间复杂度低:如果使用邻接表表示图,空间复杂度较低。
应用场景
- 网络设计:用于设计最经济的网络连接,如通信网络、交通网络和电力网络。
- 图像处理:用于图像分割和物体识别。
- 地理信息系统(GIS):用于生成最优的道路网和管道网。
Prim算法在构建最小生成树时,通过逐步扩展顶点集合,并选择权重最小的边,将新的顶点加入生成树。其逐步构建的特点和优先队列优化的高效性,使其在稠密图中表现优异,是解决最小生成树问题的经典算法之一。
#include <iostream>
#include <vector>
#include <queue>
#include <utility>
using namespace std;
typedef pair<int, int> P; // first是权重,second是顶点编号
int prim(int V, vector<vector<P>>& graph) {
priority_queue<P, vector<P>, greater<P>> pq;
vector<bool> inMST(V, false);
vector<int> key(V, INT_MAX);
int mstWeight = 0;
key[0] = 0;
pq.push({0, 0});
while (!pq.empty()) {
int u = pq.top().second;
pq.pop();
if (inMST[u]) continue;
inMST[u] = true;
mstWeight += key[u];
for (auto edge : graph[u]) {
int v = edge.first;
int weight = edge.second;
if (!inMST[v] && weight < key[v]) {
key[v] = weight;
pq.push({key[v], v});
}
}
}
return mstWeight;
}
3. 图的遍历
图的遍历是图论中的一个基本操作,主要包括深度优先搜索(DFS)和广度优先搜索(BFS)。这两种算法各有优劣,适用于不同的应用场景。
深度优先搜索(DFS)
深度优先搜索(Depth-First Search, DFS)是一种图遍历算法,通过尽可能深地探索图的分支节点,直到无法继续,然后回溯并探索下一个分支。DFS在许多图相关的应用中起着关键作用,如路径查找、连通分量检测、拓扑排序等。
适用条件
- 适用于有向图和无向图。
- 可以处理连通图和非连通图。
算法步骤
-
初始化:
- 创建一个布尔数组
visited
,初始化为false
,表示所有顶点都未被访问。 - 对于每个顶点
u
,如果它未被访问,则从u
开始调用DFS递归函数。
- 创建一个布尔数组
-
DFS递归函数:
- 将当前顶点标记为已访问,并进行相关处理(如输出顶点、记录路径等)。
- 遍历当前顶点的所有邻接顶点
v
,如果v
未被访问,则递归调用DFS函数,继续从v
进行深度优先搜索。
-
回溯:
- 当所有邻接顶点都被访问或没有未访问的邻接顶点时,递归返回到上一个顶点,继续探索其他未访问的邻接顶点。
算法复杂度
- 时间复杂度:O(V + E),其中V是顶点数,E是边数。每个顶点和边都会被访问一次。
- 空间复杂度:O(V),主要用于递归栈和存储访问状态。
应用场景
- 路径查找:在迷宫问题或路径查找问题中,通过DFS可以找到从起点到终点的路径。
- 连通分量检测:在无向图中,通过DFS可以找到所有的连通分量。
- 拓扑排序:在有向无环图(DAG)中,通过DFS可以进行拓扑排序。
- 检测环:通过DFS可以检测图中是否存在环。
- 图的强连通分量:在有向图中,通过DFS可以找到图的强连通分量(如Kosaraju算法)。
广度优先搜索(BFS)
广度优先搜索(Breadth-First Search, BFS)是一种图遍历算法,通过逐层扩展图的顶点,先访问距离源点较近的顶点,再逐步访问距离较远的顶点。BFS常用于计算无权图的最短路径和层次遍历。
适用条件
- 适用于有向图和无向图。
- 可以处理连通图和非连通图。
算法步骤
-
初始化:
- 创建一个布尔数组
visited
,初始化为false
,表示所有顶点都未被访问。 - 创建一个队列
queue
,将源点加入队列,并标记为已访问。
- 创建一个布尔数组
-
BFS循环:
- 从队列中取出一个顶点
u
,进行相关处理(如输出顶点、记录路径等)。 - 遍历顶点
u
的所有邻接顶点v
,如果v
未被访问,则将其加入队列,并标记为已访问。
- 从队列中取出一个顶点
-
重复上述步骤:
- 重复步骤2,直到队列为空。
算法复杂度
- 时间复杂度:O(V + E),其中V是顶点数,E是边数。每个顶点和边都会被访问一次。
- 空间复杂度:O(V),主要用于队列和存储访问状态。
应用场景
- 最短路径:在无权图中,通过BFS可以找到从源点到其他顶点的最短路径。
- 层次遍历:在图的层次遍历中,通过BFS可以按层次顺序访问所有顶点。
- 连通性检测:在无向图中,通过BFS可以检测所有连通分量。
- 双连通分量:在有向图中,通过BFS可以找到图的双连通分量。
DFS与BFS的比较
-
DFS(深度优先搜索):
- 遍历顺序:尽可能深地访问顶点,直到无路可走再回溯。
- 实现方式:可以使用递归或显式栈。
- 应用场景:路径查找、连通分量检测、拓扑排序、环检测、强连通分量。
- 时间复杂度:O(V + E)。
- 空间复杂度:O(V)(主要用于递归栈)。
-
BFS(广度优先搜索):
- 遍历顺序:逐层访问顶点,从距离源点最近的顶点开始,逐步扩展。
- 实现方式:使用队列。
- 应用场景:无权图的最短路径、层次遍历、连通性检测、双连通分量。
- 时间复杂度:O(V + E)。
- 空间复杂度:O(V)(主要用于队列)。
注意事项
- DFS递归深度限制:在递归实现DFS时,需要注意递归深度限制。在图的深度较深时,可能会导致栈溢出。这种情况下,可以选择迭代实现。
- 访问标记:确保每个顶点只被访问一次,避免无限循环。在递归或迭代过程中,及时更新访问状态。
DFS和BFS是图遍历的两种基本算法,各自适用于不同的应用场景。理解和掌握这两种算法的原理和实现,对于解决复杂的图论问题至关重要。
深度优先搜索(DFS)
DFS用于遍历或搜索图中的所有顶点。时间复杂度为O(V + E)。
#include <iostream>
#include <vector>
using namespace std;
void DFS(int u, vector<vector<int>>& adj, vector<bool>& visited) {
visited[u] = true;
cout << u << " ";
for (int v : adj[u]) {
if (!visited[v]) {
DFS(v, adj, visited);
}
}
}
int main() {
int V = 5;
vector<vector<int>> adj(V);
adj[0] = {1, 2};
adj[1] = {0, 3, 4};
adj[2] = {0};
adj[3] = {1};
adj[4] = {1};
vector<bool> visited(V, false);
cout << "DFS traversal: ";
DFS(0, adj, visited);
return 0;
}
广度优先搜索(BFS)
BFS用于遍历或搜索图中的所有顶点。时间复杂度为O(V + E)。
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
void BFS(int u, vector<vector<int>>& adj) {
vector<bool> visited(adj.size(), false);
queue<int>
q;
visited[u] = true;
q.push(u);
while (!q.empty()) {
int v = q.front();
q.pop();
cout << v << " ";
for (int w : adj[v]) {
if (!visited[w]) {
visited[w] = true;
q.push(w);
}
}
}
}
int main() {
int V = 5;
vector<vector<int>> adj(V);
adj[0] = {1, 2};
adj[1] = {0, 3, 4};
adj[2] = {0};
adj[3] = {1};
adj[4] = {1};
cout << "BFS traversal: ";
BFS(0, adj);
return 0;
}
4. 拓扑排序(Topological Sorting)
拓扑排序适用于有向无环图(DAG),是一种线性排序,使得对于每一条有向边(u, v),顶点u在顶点v之前。
-
Kahn算法:基于入度的拓扑排序。时间复杂度为O(V + E)。
#include <iostream> #include <vector> #include <queue> using namespace std; void topologicalSort(int V, vector<vector<int>>& adj) { vector<int> inDegree(V, 0); for (int u = 0; u < V; ++u) { for (int v : adj[u]) { inDegree[v]++; } } queue<int> q; for (int i = 0; i < V; ++i) { if (inDegree[i] == 0) { q.push(i); } } int count = 0; vector<int> topoOrder; while (!q.empty()) { int u = q.front(); q.pop(); topoOrder.push_back(u); for (int v : adj[u]) { if (--inDegree[v] == 0) { q.push(v); } } count++; } if (count != V) { cout << "There exists a cycle in the graph" << endl; return; } for (int i : topoOrder) { cout << i << " "; } cout << endl; }
-
DFS算法:基于DFS的拓扑排序。时间复杂度为O(V + E)。
#include <iostream> #include <vector> #include <stack> using namespace std; void topologicalSortDFS(int u, vector<vector<int>>& adj, vector<bool>& visited, stack<int>& Stack) { visited[u] = true; for (int v : adj[u]) { if (!visited[v]) { topologicalSortDFS(v, adj, visited, Stack); } } Stack.push(u); } void topologicalSort(int V, vector<vector<int>>& adj) { stack<int> Stack; vector<bool> visited(V, false); for (int i = 0; i < V; ++i) { if (!visited[i]) { topologicalSortDFS(i, adj, visited, Stack); } } while (!Stack.empty()) { cout << Stack.top() << " "; Stack.pop(); } cout << endl; }
5. 强连通分量(Strongly Connected Components, SCC)
SCC适用于有向图,用于找到图中的强连通分量,每个强连通分量是一个子图,其中任意两个顶点是互相可达的。
- Kosaraju算法
时间复杂度为O(V + E)。Kosaraju算法是一种用于找到有向图中所有强连通分量(SCC)的高效算法。其时间复杂度为O(V + E),其中V是顶点数,E是边数。该算法基于两次深度优先搜索(DFS)。
算法步骤
-
第一次DFS,记录顶点的完成时间:
- 对图进行第一次DFS遍历,并在每个顶点完成时将其记录在栈中。这一步的目的是获取顶点的完成时间顺序,用于后续步骤。
-
反转图(Transpose Graph):
- 构建原图的转置图,将所有边的方向进行反转。转置图的边(u, v)变为(v, u)。
-
第二次DFS,确定SCC:
- 对转置图进行DFS遍历,但这次是按照栈中的顶点顺序(即根据第一次DFS的完成时间,从最后完成的顶点开始)。每次遍历得到的顶点集即为一个强连通分量。
详细步骤
-
第一次DFS:
- 初始化一个布尔数组
visited
,表示顶点是否已被访问。 - 遍历所有顶点,对未访问的顶点执行DFS。在DFS过程中,记录每个顶点的完成时间,并将其压入栈中。
- 初始化一个布尔数组
-
构建转置图:
- 创建转置图的邻接表,将原图中的每条边(u, v)反转为(v, u)。
-
第二次DFS:
- 再次初始化一个布尔数组
visited
,表示顶点是否已被访问。 - 按照第一次DFS完成时间的逆序,从栈顶开始对转置图进行DFS。每次遍历得到的顶点集是一个强连通分量,将其输出或存储。
- 再次初始化一个布尔数组
算法复杂度
- 时间复杂度:O(V + E)。第一次DFS、构建转置图和第二次DFS都需要O(V + E)的时间。
- 空间复杂度:O(V + E)。需要额外的空间来存储图的转置、访问状态以及栈。
应用场景
- 程序优化:在编译器中,通过识别程序中的强连通分量,可以进行代码优化。
- 网络分析:在社交网络中,识别用户群体(强连通分量),分析群体内部的相互影响和传播路径。
- 电路设计:在电路设计中,通过识别强连通分量,可以优化电路布局和设计。
Kosaraju算法通过两次深度优先搜索(DFS)有效地找到了有向图中的所有强连通分量。第一次DFS记录顶点的完成时间,转置图反转所有边的方向,第二次DFS按照顶点的完成时间逆序来确定强连通分量。其时间复杂度为O(V + E),适用于各种实际应用中需要识别强连通分量的问题。
#include <iostream>
#include <vector>
#include <stack>
using namespace std;
void DFS(int u, vector<vector<int>>& adj, vector<bool>& visited, stack<int>& Stack) {
visited[u] = true;
for (int v : adj[u]) {
if (!visited[v]) {
DFS(v, adj, visited, Stack);
}
}
Stack.push(u);
}
void reverseDFS(int u, vector<vector<int>>& adj, vector<bool>& visited) {
visited[u] = true;
cout << u << " ";
for (int v : adj[u]) {
if (!visited[v]) {
reverseDFS(v, adj, visited);
}
}
}
void findSCCs(int V, vector<vector<int>>& adj) {
stack<int> Stack;
vector<bool> visited(V, false);
// 1. 按照DFS完成时间顺序,将顶点压入栈中
for (int i = 0; i < V; ++i) {
if (!visited[i]) {
DFS(i, adj, visited, Stack);
}
}
// 2. 反转图
vector<vector<int>> adjRev(V);
for (int u = 0; u < V; ++u) {
for (int v : adj[u]) {
adjRev[v].push_back(u);
}
}
fill(visited.begin(), visited.end(), false);
// 3. 按照栈中元素的顺序,对反转图进行DFS
while (!Stack.empty()) {
int u = Stack.top();
Stack.pop();
if (!visited[u]) {
reverseDFS(u, adjRev, visited);
cout << endl;
}
}
}
总结
图论中的常见问题和算法主要包括最短路径问题、最小生成树问题、图的遍历、拓扑排序和强连通分量。每种问题都有适合的算法,理解这些算法的原理和应用场景是解决图论问题的关键。