无向图求桥(深大算法实验5)报告+代码

本文探讨了图论中的桥边检测问题,介绍了基准算法、并查集优化、生成树优化以及LCA(最近公共祖先)算法在求解桥边中的应用。通过算法原理描述和复杂度分析,展示了不同方法在稀疏图和稠密图中的效率差异,最终得出Tarjan算法是最快的方法。此外,文章还强调了代码简洁性和数据结构选择的重要性。
摘要由CSDN通过智能技术生成
实验代码 + 报告资源:
链接: https://pan.baidu.com/s/1CuuB07rRFh7vGQnGpud_vg 
提取码: ccuq 

写在前面

期末终于算法课快要完结了。

这学期算法课可谓是最难顶的课程了,又正好是线上上课,提问互动的机会相对较少,老师上课抛砖引玉,实验内容又比较难,我花了大部分的时间在找算法,实现算法,改算法bug上。

我也参考过很多往届师兄的报告,但是大多都比较抽象晦涩,而且没有代码只讲方法,比较难以理解具体实现的细节。

所以我打算记录一下自己的报告+代码,前人coding后人copying ,希望让大家少走弯路。。。

注意:不要直接copy代码,这是冲塔行为!查重系统鲨疯辣。

问题描述

  1. 桥的定义
    在图论中,一条边被称为“桥”代表这条边一旦被删除,这张图的连通块数量会增加。等价地说,一条边是一座桥当且仅当这条边不在任何环上。一张图可以有零或多座桥。
    在这里插入图片描述
    在这里插入图片描述

求解问题的算法原理描述

基准算法

穷举边并且删除这条边,然后计算连通分支数目,使用dfs计算连通分支数目
在这里插入图片描述
在这里插入图片描述
复杂度分析:其中 n为顶点数,e为边数
穷举删除的边需要e次,每次删除都要dfs判断连通分支数目,需要O(n+e),复杂度O(e)
对于稀疏图(e=n),复杂度为O(n2)
对于稠密图(e=n2),复杂度为O(n4)
在这里插入图片描述
分别使用稀疏图和稠密图测量其运行时间,其中横坐标是顶点数n,纵坐标是运行时间
在这里插入图片描述
在这里插入图片描述
可以看到稀疏图中,基准法符合O(n2)的复杂度,而稠密图中,基准法符合O(n4)的复杂度

基准法+并查集

和基准法思路相同,通过删除边并计算连通分支数目来查找桥,只是计算连通分支时使用并查集而不是dfs

并查集计算连通分支数目:
枚举边,对每个边上的两点v1和v2,查询v1和v2所属的集合f1,f2,如果v1和v2不在同一个集合则合并v1和v2所属的两个集合,最后统计集合的个数,即为连通分支数目

在这里插入图片描述
使用路径压缩策略,使得并查集的查询复杂度均摊下来为O(1)
在这里插入图片描述
在这里插入图片描述
复杂度分析:其中 n为顶点数,e为边数
穷举删除的边需要e次,每次删除都用并查集判断连通分支数目,需要O(e),复杂度O(e)
对于稀疏图(e=n),复杂度为O(n2),对于稠密图(e=n2),复杂度为O(n4)

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
可以看到稀疏图中,算法符合O(n2)的复杂度,而稠密图中,算法符合O(n4)的复杂度

引入生成树优化

因为桥边一定会出现在生成树上,所以对于基准法,我们只需要枚举生成树上的边,而不需要枚举所有的边,一样能找到答案
在这里插入图片描述
生成树优化能够使得枚举边的代价从O(e)变为O(n),我们可以利用这个特性优化基准法

基准法+生成树优化
生成树优化能够使得枚举边的代价从O(e)变为O(n),对于稀疏图,复杂度为O(n2),对于稠密图,复杂度为O(n3)

在这里插入图片描述

在这里插入图片描述
可以看到在图像和数据上,稀疏图的情况下算法时间复杂度符合O(n2)增长,在稠密图的情况下算法的时间复杂度符合O(n3)增长

基准法+并查集+生成树优化

生成树优化能够使得枚举边的代价从O(e)变为O(n),对于稀疏图,复杂度为O(n2),对于稠密图,复杂度为O(n3)
在这里插入图片描述
在这里插入图片描述
可以看到在图像和数据上,稀疏图的情况下算法时间复杂度符合O(n2)增长,在稠密图的情况下算法的时间复杂度基本符合O(n3)增长

Lca求环边求桥

逆向思维:我们不去主动找桥,因为定义【一条边是一座桥当且仅当这条边不在任何环上】,我们把环边找出来,然后剩下的不是环边的边,就是桥了

引入lca问题:最近公共祖先
最近公共祖先,顾名思义即两个节点的最近的能找到的公共的祖先
在这里插入图片描述

先利用dfs建立生成树,对于图中那些不在生成树上的边,我们叫它【重边】

对于重边上的两点v1和v2,我们找v1和v2的最近公共祖先,并且把从v1和v2出发,到他们的最近公共祖先的路径上的边,全部标记为环边

在这里插入图片描述

dfs配合并查集求解lca问题:
lca[x]表示集合x最高顶点的编号,father[]为并查集数组

收集所有lca查询并且使用dfs建立生成树,然后按照生成树顺序对图做dfs

每次递归开始前:x节点自成一个集合

递归所有子树

一个子树递归结束:将子树并到x节点集合

x所有子树递归结束时:遍历和x有关的查询,并尝试回答这些查询

假设查询为 (x, y) 如果y节点及其所有子树已经完成递归,那么答案是y所在的集合的最高点编号,如果y未完成所有的递归,那么先搁置这个查询(等一下y完成递归的时候会再次尝试回答)

ps:这里lac[x]其实就是father[x]
在这里插入图片描述
在这里插入图片描述
复杂度分析:
以dfs为模板求lca的时间复杂度是O(n+e+q)其中q为查询次数,对于稀疏图,查询次数q为n,对于稠密图,查询次数q为n2,因为除了生成树上的n-1条边,其他的都要查询

对于每个查询,都需要沿着生成树,往回找他们的最近公共祖先,并且标记沿途的边,这个操作的时间复杂度是不确定的,但是其上界是O(n),因为走完所有的顶点,故整个算法的时间复杂度上界是:稀疏图O(n2),稠密图O(n3)

在这里插入图片描述

在这里插入图片描述
可以看到在图像和数据上,稀疏图的情况下算法时间复杂度符合O(n2)增长,在稠密图的情况下算法的时间复杂度基本符合O(n3)增长

Lca求环边:路径压缩策略

注:
这里的最近公共祖先算法好像有点问题,根据大佬反应,情况如下:


如果一开始重边里的两个点的祖先就是一样的情况 跑当时老师给的小数据集跑出了和蛮力法不一样的效果(就是蛮力法是没有桥的但是压缩出现了桥) 加上这个条件后就对了

我当时做的时候好像没有注意到这个情况。。。总之就开始的时候是多一步判断两个点的 father 是不是一样的特殊判断

如图,因为边非常多,我们有时候可能会走很多冤枉路
在这里插入图片描述
我们在沿着生成树向上找lca的时候,找到lca并记录它,然后不妨将路径上的所有节点挂到lca上面(即查找的终点),表示该点和lca点之间的路径被记录过了,在下次向上查找的时候,我们就会直接跳到lca上,不必走冤枉路
在这里插入图片描述
Lca+路径压缩:复杂度分析:
因为加入了路径压缩策略,和并查集类似,我们最多把n个顶点挂到父节点上面,每次找路径都要挂,总共找e次路径,均摊下来使得找路径的复杂度为O(1),于是我们算法的复杂度变为:稀疏图:O(e)*O(1) = O(n) 稠密图:O(e)*O(1) = O(n2)

注:我们也可以直接将环边上的两个点先调整至同一高度,然后一起向上找公共祖先,然后找到后再沿路标记环边。不必像上面所说的那样先找lca再标记。这样更快

/*	
 *	@function bridge_bcj_lca : lca求环边 + 路径压缩 
 *	@explain 			     : ---- 
 */
int tcnt=0;
void bridge_bcj_lca2()
{
   
	init_all();
	// 生成生成树 
	for(int i=0; i<n; i++) if(vis[i]==0) dfs_getTree_relation_dep(i, i, 0); 
	visit_init();
	for(int i=0; i<n; i++) tree_edges.insert(vector<int>{
   tree[i],i});
	for(int i=0; i<edges.size(); i++)
	{
   
		if(tree_edges.find(edges[i])==tree_edges.end())
		{
   
			int p1=edges[i][0], p2=edges[i][1], pre,pre1,pre2;
			cir_edges.insert(vector<int>{
   p1, p2});	// 标记边 
			if(depth[p1]<depth[p2]) swap(p1, p2);	// 默认p1为深的点 
			while(depth[p1]>depth[p2]) 				// 同步高度 
				pre1=p1,p1=tree[p1],cir_edges.insert(vector<int>{
   pre1, p1});
			while(tree[p1]!=tree[p2])				// 找lca 
			{
   
				pre1=p1, pre2=p2;
				p1=tree[p1], p2=tree[p2];
				cir_edges.insert(vector<int>{
   pre1, p1});
				cir_edges.insert(vector<int>{
   pre2, p2});
			}
			// 压缩策略 沿途将路径上所有点挂到lca上 
			int lca = tree[p1];
			p1=edges[i][0], p2=edges[i][1];
			while(tree[p1] != lca)
			{
   
				int pf = tree[p1];
				tree[p1] = lca;
				depth[p1] = depth[lca] + 1;
				p1 = pf;
			}
			while(tree[p2] != lca)
			{
   
				int pf = tree[p2];
				tree[p2] = lca;
				depth[p2] = depth[lca] + 1;
				p2 = pf;
			}
			//tcnt++;	// 调试进度 
			// if(tcnt%100000==0) cout<<tcnt<<endl;
		}
	}
	/*
	for(int i=0; i<e; i++)
		if(cir_edges.find(edges[i])==cir_edges.end())
			cout<<edges[i][0]<<" -- "<<edges[i][1]<<" 是桥"<<endl;
	*/
}

Lca+路径压缩:时间测试:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
可以看到算法在稀疏图下的复杂度和我们预想的一样保持在O(n),稠密图则为O(n2)

Tarjan(塔扬)算法直接求桥:

Tarjan算法基于dfs,一次dfs即可求解问题。

在一次dfs中维护两个数组,分别是dfn[]即深度优先数,和min_ancestor[]即最小可达祖先

dfn[]数组描述了dfs进行的顺序,而min_ancestor[x]则记录了x及其所有子孙节点能够到达的【dfn值最小的祖先节点(不包括x的直接父节点)】的dfn数值

在这里插入图片描述

如果一条边的两个顶点v1和v2,满足dfn[v1] < min_ancestor[v2],那么这条边是桥

可以显然的从定义得知这个事实:因为除了和x相连的边,没有任何途径可以到达x之前的节点(体现在最小可达祖先大于x直接父节点的dfn值),也就是说x是唯一通路,是桥

图片描述:
在这里插入图片描述
在这里插入图片描述
Tarjan算法时间复杂度分析:
共需要一次dfs,故稀疏图中复杂度为O(n),稠密图中复杂度为O(n2)
在这里插入图片描述在这里插入图片描述在这里插入图片描述

总览

(注:tarjan算法在这个规模过于快了,以至于测出来都是0或者0.0001不好对比)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

结论:

稀疏图
复杂度:
Tarjan = lca+压缩 < lca = 基准+生成树 = 基准+并查集+生成树 = 基准 = 基准+并查集

用时:
Tarjan < lca+压缩 < lca < 基准+生成树 = 基准+并查集+生成树 = 基准+并查集 = 基准

稠密图
复杂度:
Tarjan = lca+压缩 < lca = 基准+生成树 = 基准+并查集+生成树 < 基准 = 基准+并查集

用时:
Tarjan < lca+压缩 < 基准+生成树 = 基准+并查集+生成树 < lca < 基准+并查集 = 基准

结论:

  1. 一次dfs解决,tarjan(塔扬)算法是最快的算法
  2. 引入路径压缩的lca第二快(复杂度低),但是操作语句较为繁琐所以慢于tarjan
  3. Lca求环边和带生成树优化的基准法远远地快于普通基准法
  4. Lca求环边在边情况少的图中速度远远快于基准法,而在稠密图中,慢于基准法+生成树优化,虽然同为O(n3)的复杂度,但是因为代码的语句数量明显增多(先生成生成树,然后反选重边,然后查询重边的lca,递归沿着路径标记环边,然后通过环边反选桥),而且使用多种复杂的数据结构,比如并查集,哈希set,vector,并且沿着环边标记路径使用的是递归操作,导致算法的常数开销过大,在边多的情况下,不理想
  5. Lca求环边在稠密图中达到最坏情况,即复杂度上限O(n3),从图中曲线也可以看出
  6. 基准法用并查集计算连通分支数目,虽然有路径压缩策略,但总体执行语句数量多于dfs法,因此代码的常数开销过大,导致并查集慢于dfs,但其实它们拥有相同的复杂度

给定数据测试

在这里插入图片描述
在测试数据样例和mediumDG中测试时间,因为规模过小导致时间不好测量

在largeG里面测试,只有lca+压缩和tarjan可以跑完整个过程,但是因为tarjan算法代码实现和语句较为简单,而相对来说lca+压缩语句比较复杂,所以很慢

Lca+压缩,用到较多的复杂数据结构,并且未使用任何针对C++STL的编译优化,所以是代码常数开销很大慢于tarjan

总结:
并查集数据结构要尽量使用路径压缩策略,能够大大加快查询的速度
在求解大规模问题的时候尽量选择简单的数据结构
编写代码时要注意代码语句不能过长,否则同样复杂度的算法,执行用时会有差距
在大规模递归的时候要修改编译器的栈空间,否则数据容量一大,容易爆栈

代码

在这里插入图片描述

#include <bits/stdc++.h>

using namespace std;

/*
 *	@edge_hash : 边哈希函数 哈希set反取边及去重用 
 */
struct edge_hash
{
   
	size_t operator () (const vector<int> e1) const
	{
   
		int aa=(e1[0]<e1[1])?(e1[0]):(e1[1]), bb=(e1[0]>e1[1])?(e1[0]):(e1[1]);
		return aa*114 + bb;
	}	
};

/*
 *	@edge_cmp : 边比较函数 哈希set反取边及去重用
 */
struct edge_cmp
{
   
	bool operator () (const vector<int> e1, vector<int> e2) const
	{
   
		if(e1[0]==e2[0] && e1[1
  • 12
    点赞
  • 49
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值