图(二):图的遍历、最小生成树

本文深入讲解图的遍历算法,包括深度优先搜索(DFS)和广度优先搜索(BFS),探讨了图遍历在求解最短路径和生成树等问题中的应用。详细介绍了Prim算法和Kruskal算法在寻找最小生成树(MST)中的实现和优化,对比了两种算法的适用场景和时间复杂度。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

作为数据结构的课程笔记,以便查阅。如有出错的地方,还请多多指正!

图的遍历 Traversing Graph

深度优先搜索 DFS (Depth First Search)

  • 从图的某一顶点V1出发,访问该顶点;然后依次从V1的未被访问的邻接点出发,深度优先遍历图,直至图中所有和V1相通的顶点都被访问到
  • 若此时图中尚有顶点未被访问,则另选图中一个未被访问的顶点作起点,重复上述过程,直至图中所有顶点都被访问为止
    在这里插入图片描述

递归实现

  • 邻接表可以快速找到下一个未被访问到的顶点,因此选用邻接表作为图的存储结构
void Visit(AdjacentListDataType_t data)
{
	printf("%d\r\n", data);
}

// 从顶点 v 开始 深度优先遍历一个连通分量
void DFS(pAdjacentList_t pgraph, int v, int visited[], void (*visit)(AdjacentListDataType_t data))
{
	pArcNode_t edge = (pgraph->vex[v]).firstEdge;

	visit((pgraph->vex[v]).data);
	visited[v] = VISITED;
	
	while (edge)
	{
		if (UNVISITED == visited[edge->head])
		{
			DFS(pgraph, edge->head, visited, visit);
		}
		edge = edge->nextEdge;
	}
}

void DFS_traverse(pAdjacentList_t pgraph, void (*visit)(AdjacentListDataType_t data))
{
	int visited[MAX_VERTEX_NUM];

	for (int i = 0; i < pgraph->vexNum; ++i)
	{
		visited[i] = UNVISITED;
	}

	for (int i = 0; i < pgraph->vexNum; ++i)
	{
		if (UNVISITED == visited[i])
		{
			DFS(pgraph, i, visited, visit);
		}
	}
}

T ( n ) T(n) T(n)

遍历图的过程实质上是对每个顶点查找其邻接点的过程

  • 邻接表:
    T ( n ) = O ( n + e ) T(n)=O(n+e) T(n)=O(n+e)
  • 邻接矩阵:
    T ( n ) = O ( n 2 ) T(n)=O(n^2) T(n)=O(n2)

广度优先搜索 BFS (Broadth First Search)

  • 从图的某一顶点V0出发,访问该顶点后,依次访问V0的所有未曾访问过的邻接点。 然后分别从这些邻接点出发,广度优先遍历图,直至图中所有已被访问的顶点的邻接点都被访问到
  • 若此时图中尚有顶点未被访问,则另选图中一个未被访问的顶点作起点,重复上述过程,直至图中所有顶点都被访问为止
    在这里插入图片描述

算法实现

// 从顶点 v 开始 广度优先遍历一个连通分量
void BFS(pAdjacentList_t pgraph, int v, int visited[], void (*visit)(AdjacentListDataType_t data))
{
	pArcNode_t edge = (pgraph->vex[v]).firstEdge;
	queue<int> q;
	
	// 先访问再入队 (也可以在出队时进行访问)
	visit((pgraph->vex[v]).data);
	// 入队时一定要先更新 visited 标志,否则可能导致一些顶点被重复访问
	visited[v] = VISITED;	
	q.push(v)

	while (!q.empty())
	{
		int i = q.front();
		q.pop();	

		edge = pgraph->vex[i].firstEdge;

		while (edge)
		{
			if (UNVISITED == visited[edge->head])
			{
				visit((pgraph->vex[edge->head]).data);
				visited[edge->head] = VISITED;
				q.push(edge->head);
			}

			edge = edge->nextEdge;
		}
	}
}

void BFS_traverse(pAdjacentList_t pgraph, void (*visit)(AdjacentListDataType_t data))
{
	int visited[MAX_VERTEX_NUM];

	for (int i = 0; i < pgraph->vexNum; ++i)
	{
		visited[i] = UNVISITED;
	}

	for (int i = 0; i < pgraph->vexNum; ++i)
	{
		if (UNVISITED == visited[i])
		{
			BFS(pgraph, i, visited, visit);
		}
	}
}

图的遍历算法应用

求两个顶点间的一条简单路径

  • 例如:求从顶点 b b b 到顶点 k k k 的一条简单路径: DFS 搜索:可能为 b − c − h − d − a − e − k − f − g b-c-h-d-a-e-k-f-g bchdaekfg,也可能为 b − a − d − h − c − e − k − f − g b-a-d-h-c-e-k-f-g badhcekfg,因此直接使用 DFS 不一定能直接找到简单路径,需要增加一个堆栈 path 来存储路径
    在这里插入图片描述
int DFS_find_simple_path(pAdjacentList_t pgraph, int a, int b, 
						int path_stack[], int& top, int visited[])
{
	pArcNode_t edge;
	bool found = false;

	for (edge = (pgraph->vex[a]).firstEdge; edge && !found; edge = edge->nextEdge)
	{
		if (b == edge->head)
		{
			path_stack[top++] = edge->head;	// 找到路径
			found = true;
		}
		else if (UNVISITED == visited[edge->head]) 
		{
			visited[edge->head] = VISITED;
			path_stack[top++] = edge->head;
			found = DFS_find_simple_path(pgraph, edge->head, b, path_stack, top, visited);
		}
	}

	if (!found)
	{
		--top;	// 简单路径不经过该顶点,出栈
	}

	return found;
}

// 找出图中两个顶点 a b 间的一条简单路径
void Find_simple_path(pAdjacentList_t pgraph, int a, int b)
{
	int* path_stack = new int[pgraph->vexNum];
	int* visited = new int[pgraph->vexNum];
	bool found = false;
	int top = 0;

	if (0 == pgraph->vexNum
		|| a == b
		|| a > pgraph->vexNum
		|| b > pgraph->vexNum)
	{
		printf("No simple path can be found!\r\n");
		return;
	}

	for (int i = 0; i < pgraph->vexNum; ++i)
	{
		visited[i] = UNVISITED;
	}

	path_stack[top++] = a;
	visited[a] = VISITED;

	found = DFS_find_simple_path(pgraph, a, b, path_stack, &top, visited);

	if (0 == found)
	{
		printf("No simple path can be found!\r\n");
	}
	else {
		printf("The simple path is:\r\n");
		for (int i = 0; i < top; ++i)
		{
			printf("%d\r\n", pgraph->vex[path_stack[i]].data);
		}
	}

	delete [] path_stack;
	delete [] visited;
}

求无权图中两个顶点间的最短路径

在这里插入图片描述

  • 利用双链队列进行 BFS
    • 出队时,仅移动队头指针,而不删除队头结点
    • 入队时,令新队尾结点的 prior 域指向队头指针所指结点
      在这里插入图片描述

更简单的方法应该是给每个节点都增加一个数据域,记录 BFS 访问时它们的父结点序号,这样在 BFS 到目标节点后,沿着父结点序号一路逆推过去就行了

生成树, 生成森林

生成树

  • 极小连通子图。它包含图中全部 n n n 个顶点,且只含 n − 1 n-1 n1 条边。因此,在生成树中再加一条边必然形成回路,且生成树中任意两个顶点间的路径是唯一的

生成森林

  • 非连通图每个连通分量的生成树
    • 无向图中,生成森林中树的数量 = 连通分量数
    • 有向图中,生成森林中树的数量可能少于强连通分量 (一个强连通分量可得到对应的生成树,但非强连通分量也可能只需要一棵生成树与之对应)
      在这里插入图片描述

最小生成树

Minimum Cost Spaning Tree (MST)

  • 要在 n n n 个城市间建立通信联络网,顶点表示城市,权表示城市间建立通信线路所需花费代价。希望找到一棵生成树,它的每条边上的权值之和(即建立该通信网所需花费的总代价)最小———最小代价生成树
    在这里插入图片描述

注意到,MST 问题具有最优子结构性质

Prim 算法 (普里姆算法)

  • N = ( V , { E } ) N=(V,\{E\}) N=(V,{E}) 是连通网, G = ( U , { T E } ) G=(U,\{TE\}) G=(U,{TE}) N N N 上最小生成树
    • 初始令 U = { u 0 } , ( u 0 ∈ V ) , T E = ϕ U=\{u_0\},(u_0\in V), TE=\phi U={u0},(u0V),TE=ϕ
    • 在所有 u ∈ U , v ∈ V − U u\in U,v\in V-U uU,vVU 的边 ( u , v ) ∈ E (u,v)\in E (u,v)E 中,找一条代价最小的边 ( u 0 , v 0 ) (u_0,v_0) (u0,v0) 并入集合 T E TE TE,同时 v 0 v_0 v0 并入 U U U
    • 重复上述操作直至 U = V U=V U=V (共重复 n − 1 n-1 n1 次)
      在这里插入图片描述

贪心算法,依次得到包含 u 0 u_0 u0 的一个顶点、两个顶点 … n n n 个顶点的最小代价生成树

算法实现

const int MAXV = 1000; 		// 最大顶点数
const int INF = 0x3fffffff; // 设 INF 为一个很大的数
邻接矩阵
  • O ( n 2 ) O(n^2) O(n2)
int n, G[MAXV][MAXV]; 		// n 为顶点数, MAXV 为最大顶点数
int d[MAXV]; 				// 顶点与集合 S 的最短距离
bool vis[MAXV] = {false}; 	// 标记数组,vis[i] == true 表示已访问。初值均为 false
int prim() { // 函数返回最小生成树的边权之和
	fill(d, d + MAXV, INF); 	// fill 函数将整个 d 数组赋为 INF
	d[0] = 0; 					// 默认 0 号为初始点
	int ans = 0; 				// 存放最小生成树的边权之和
	
	for(int i = 0; i < n; i++) {
		int u = -1, MIN = INF; // u 使 d[u] 最小, MIN 存放该最小的 d[u]
		for(int j = 0; j < n; j++) { // 找到未访问的顶点中 d[] 最小的
			if (vis[j] == false && d[j] < MIN) {
				u = j;
				MIN = d[j];
			}
		}
		// 找不到小于 INF 的 d[u], 则剩下的顶点和集合 s 不连通
		if(u == -1) 
			return -1;
		vis[u] = true;
		ans += d[u];	// 将与集合 s 距离最小的边加入最小生成树
		for(int v = 0; v < n; v++) {
			if(vis[v] == false && G[u][v] != INF && G[u][v] < d[v]) {
				d[v] = G[u][v];
			}
		}
	}
	return ans;
}
邻接表
struct Node{
	int v, dis; // v 为边的目标顶点, dis 为边权
};
vector<Node> Adj[MAXV]; // 邻接表;Adj[u] 存放从顶点 u 出发可以到达的所有顶点
int n; 					// n 为顶点数
int d[MAXV]; 			// 顶点与集合 s 的最短距离
bool vis[MAXV] = {false}; // 标记数组,vis[i] == true 表示已访问。初值均为 false
// O(n^2)
int prim() { 	// 函数返回最小生成树的边权之和
	fill(d, d + MAXV, INF);
	d[0] = 0; 		// 默认 0 号为初始点
	int ans = 0; 	// 存放最小生成树的边权之和
	
	for(int i = 0; i < n; i++) {
		int u = -1, MIN = INF;
		for(int j = 0; j < n; j++) {
			if(vis[j] == false && d[j] < MIN) {
				u = j;
				MIN = d[j];
			}
		}
		if(u == -1) 
			return -1;
		vis[u] = true;
		ans += d[u];		// 将与集合 s 距离最小的边加入最小生成树
		// 只有下面这个 for 与邻接矩阵的写法不同
		for(int j = 0; j < Adj[u].size(); j++) {
			int v = Adj[u][j].v; // 通过邻接表直接获得 u 能到达的顶点 v
			if(vis[v] == false && Adj[u][j].dis < d[v]) {
				d[v] = Adj[u][j].dis;
			}
		}
	}
	return ans;
}

  • 通过堆优化可以进一步将时间复杂度降为 O ( n log ⁡ n + e ) O(n\log n+e) O(nlogn+e)
    • 不太明白这个时间复杂度 O ( n log ⁡ n + e ) O(n\log n+e) O(nlogn+e) 是怎么得出来的;似乎是忽略了最小堆 push 的时间
struct Node {
    int v, dis; // v 为边的目标顶点, dis 为边权
	friend bool operator>(const Node& lhs, const Node& rhs)
	{
		return lhs.dis > rhs.dis;
	}
};
int prim() { 	// 函数返回最小生成树的边权之和
	priority_queue<Node, vector<Node>, greater<Node>> heap;	// 最小堆
	heap.push({ 0, 0 });
	fill(d, d + MAXV, INF);
	d[0] = 0; 		// 默认 0 号为初始点
	int ans = 0; 	// 存放最小生成树的边权之和
	int num = 0;	// 添加进生成树的顶点数

	while(!heap.empty() && num < n) {
		Node edge = heap.top();
		int u = edge.v;
		heap.pop();
		if (vis[edge.v])
		{
			continue;
		}
		vis[u] = true;
		ans += edge.dis;		// 将与集合 s 距离最小的边加入最小生成树
		// 只有下面这个 for 与邻接矩阵的写法不同
		for (int j = 0; j < Adj[u].size(); j++) {
			int v = Adj[u][j].v; // 通过邻接表直接获得 u 能到达的顶点 v
			if (vis[v] == false && Adj[u][j].dis < d[v]) {
				d[v] = Adj[u][j].dis;
				heap.push({ v, d[v] });
			}
		}
	}
	return ans;
}

算法评价

  • 邻接矩阵 (适合稠密图):
    T ( n ) = O ( n 2 ) T(n)=O(n^2) T(n)=O(n2)
  • 邻接表 (适合稀疏图):
    T ( n ) = O ( n log ⁡ n + e ) T(n)=O(n\log n+e) T(n)=O(nlogn+e)

Kruskal 算法

  • 设连通网 N = ( V , E ) N=(V,E) N=(V,E),令最小生成树
    • 初始状态为只有 n n n 个顶点而无边的非连通图 T = ( V , ϕ ) T=(V,\phi) T=(V,ϕ),每个顶点自成一个连通分量
    • E E E 中选取代价最小的边,若该边依附的顶点落在 T T T 中不同的连通分量上,则将此边加入到 T T T 中;否则,舍去此边,选取下一条代价最小的边
  • 依此类推,直至 T T T 中所有顶点都在同一连通分量上为止

贪心算法;每一步都添加一条边都使连通块个数减一,并且由于选择的是最短的边,因此每一步得到的都是使连通块数个数减一的所有方案中的最优解;这样重复 n − 1 n-1 n1 次 (添加 n − 1 n-1 n1 条边) 之后,图中就只有一个连通块,也就得到了一棵最小生成树


伪代码

int kruskal() {
	令最小生成树的边权之和为 ans, 最小生成树的当前边数 Num_Edge;
	将所有边按边权从小到大排序;
	for (从小到大枚举所有边) {
		if (当前测试边的两个端点在不同的连通块中) {
			将该测试边加入最小生成树中;
			ans += 测试边的边权;
			最小生成树的当前边数 Num_Edge 加 1;
			当边数 Num_Edge 等于顶点数减 1 时结束循环;
		}
	}
	return ans;
}
  • 其中,判断两个端点是否在同一连通块中可以通过并查集来实现

算法实现

struct edge {
	int u, v; 	// 边的两个端点编号, 用于判定它们是否属于同一个连通块
	int cost;	// 边权
}E[MAXE]; 		// 最多有 MAXE 条边
// 并查集
int father[N];
int findFather(int v) {
	if (v  == father[v]) 
		return v; // 找到根结点
	else {
		int F = findFather(father[v]);
		father[v] = F;
		return F;
	}
}
// kruskal 函数返回最小生成树的边权之和
// 参数 n 为顶点个数, m 为图的边数
int kruskal(int n, int m) {
	// ans 为所求边权之和, Num_Edge 为当前生成树的边数
	int ans = 0, Num_Edge = 0;
	for(int i = 1; 1 <= n; i++) ( // 假设顶点范围是 [1,n]
		father[i] = i;	 // 并查集初始化 -> 初始时所有端点都属于不同的连通块
	}
	// 所有边按边权从小到大排序
	sort(E, E + m, [](const &edge lhs, const &edge rhs) -> bool {
		return lhs.cost < rhs.cost;
	}); 
	
	for(int i = 0; i < m; i++) { // 枚举所有边
		int faU = findFather(E[i].u); // 查询测试边两个端点所在集合的根结点
		int faV = findFather(E[i].v);
		if (faU != faV) { 	// 如果不在一个集合中
			father[faU] = faV; 	// 合并集合(即把测试边加入最小生成树中)
			ans += E[i].cost; 	// 边权之和增加测试边的边权
			Num_Edge++; 		// 当前生成树的边数加1
			if(Num_Edge == n - 1) 
				break; 			// 边数等于顶点数减 1 时结束算法
		}
	}		
	if(Num_Edge != n - 1) 
		return -1; 	// 无法连通时返回-1
	else 
		return ans; // 返回最小生成树的边权之和
}

算法评价

  • 可以看到,kruskal 算法的时间复杂度主要来源于对边进行排序, 因此其时间复杂度是
    O ( e log ⁡ e ) O(e\log e) O(eloge)显然 kruskal 适合顶点数较多、边数较少的情况, 这和 prim 算法恰好相反。因此,如果是稠密图(边多), 则用 prim 算法;如果是稀疏图(边少), 则用 kruskal 算法
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值