算法设计与分析:实验五 图论——桥问题

2024-09-01 05:36

本文主要是介绍算法设计与分析:实验五 图论——桥问题,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

实验内容:

1. 桥的定义

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

2. 求解问题

找出一个无向图中所有的桥。

1.基准算法

(1)算法思路:

  • 遍历每条边:对于图中的每一条边 (u, v),逐条进行处理。
  • 移除边 (u, v):暂时从图中移除边 (u, v)。
  • 检查连通性:使用广度优先搜索(BFS)或深度优先搜索(DFS)检查移除边 (u, v) 后的图是否仍然连通。如果移除边 (u, v) 后,图变得不连通(即连通块的数量增加),则说明边 (u, v) 是一座桥。
  • 恢复边 (u, v):将边 (u, v) 加回到图中,恢复原始状态。
  • 重复上述步骤:对图中的每一条边重复上述步骤,最终找出所有的桥。

(2)伪代码:

基准算法

bool BFS(src,des,eidx) //BFS用来检查移除一条边后,图是否仍然连通

{

  Queue q  // 定义一个队列,用于广度优先搜索

  Vis[src]=true  // 标记起点为已访问

  For 枚举src的邻点j:

    If i>>1 != eidx-1:  //如果此边不是我们要删掉的就入队

      q.push(j),Vis[j]=1

  While(q.size()):  // 当队列不为空时,继续搜索

    u=q.front(),q.pop()  // 取出队首元素

    For 枚举u的邻点v:

      If v==d return True  //如果删掉src-des这条边,两个点还能连通则不是桥

      If !Vis[v]: Vis[v]=true,q.push(v)

  Return false;  // 如果搜索完毕仍未到达目标节点,返回 false,不连通

}

voids func0()  //找到图中的所有桥

{

  For i=1 to m: //枚举每条边

    u,v //u和v作为每条边的两个端点

    memset(Vis ,false,sizeof(Vis)) //每次删除每条边判连通都要初始化访问数组

    If !BFS(u,v,i):  //如果删掉i这条边后不连通则此条边为桥

    Ans++,edge[i].isbridge=true;

}

(3)时间复杂度:

对于每一条边 (u, v),需要执行一次 BFS,BFS会将所有的点和边遍历一遍,则最坏的时间复杂度为 O(n+m),其中n是顶点数,m是边数。由于需要对每一条边都进行一次这样的操作,算法的总时间复杂度为 O(m*(n+m))=O(m^2+nm)。对于稠密图,这样的复杂度是非常高的。基准算法尽管简单直接,但在实际应用中效率较低,因此通常使用更加优化的算法来降低复杂度。

2.优化算法--使用按秩合并及可撤销并查集

(1)算法分析:

  • 当我们处理图的连通性问题时,每次BFS都需要重新遍历所有的点,但删除或添加一条边后,实际上只有与这条边相关的部分需要重新检查,而不是整个图。这意味着我们可以利用一些数据结构来只处理变化部分,从而提高效率。
  • 使用并查集
  1. 并查集是一种高效处理连通性查询的树型数据结构,常用于处理一些不相交集合的合并及查询两个元素是否同属一个集合,支持快速的合并和查找操作。
  2. 并查集将所有点分成一个一个的集合,对每一个集合都设定一个父节点,集合内所有的点最终都会指向该父节点。因此通过检查两个点所在的集合的父节点是否相同,可以以较快的速度判断得到两个点是否处于同一个集合中,若相同则同属一个集合,若不同则不属同一个集合。
  3. 并查集有查找与合并两种操作:一是查询,查询当前两个元素是否在同一集合中;二是合并,将两个不在同一集合中的两个子集合合并为一个集合。

伪代码如下:

并查集基本操作

  1. int find (int x)  //查找当前元素所属集合的代表元/祖先结点
  2. {
  3.   If fa[x]==x: return x  //如果是本身就直接返回
  4.   Return fa[x]=find(fa[x])  //如果不是则继续递归向下找
  5. }
  6. void connect (int x,int y)  //合并两个元素/子集合
  7. {
  8.   Px=find(x) //找到x元素的祖先结点
  9.   Py=find(y) //找到y元素的祖先结点
  10.   If (Px==Py) return //如果两个元素已经属一个集合,就不进行操作
  11.   fa[Py]=Px  //进行合并
  12. }
  1. 标准的并查集在处理动态添加边时非常高效,但不能处理删除边的情况。
  2. 并查集判断一条边是不是割边:去掉一条边后,使用并查集快速查看此边的两个端点是否在同一个集合(即是否可以相互到达),如果是则不是割边,否则是割边。
  • 并查集合并操作采用按秩合并

  1. 在并查集结构中,每个集合都有一个属性称为rank(秩),代表了以该集合根节点为根的树的高度或近似高度。
  2. 按秩合并指的是在合并两个集合时,总是将秩较小的树的根节点指向秩较大的树的根节点。尽可能地减少树的高度增长,保持树的平衡,进而优化查找操作的效率
  3. 在并查集中,秩的初始值为0。当两个秩相同的树合并时,新的根节点的秩增加1。如下图所示,如果按秩合并将A集合合并到D集合,不难发现,合并后的层数依旧是三层;如果将D合并到A上,那么深度就会变成四层。多一层子节点的搜索就会变多,按秩合并就是通过减少层数来减少搜索次数,从而优化时间复杂度。
  4. 路径压缩防止不平衡状态:如果并查集是左边这种形式那么搜索后面的元素那么就会需要O(n)的复杂度,如果所有的元素全部搜索那么就会达到O(n^2)的复杂度。所以采取按秩合并的方法,合并后的形式,这样搜索每一个元素所需时间复杂度为O(1)。
  5. 按秩合并的过程:
  • 每个节点在初始状态下都是一个单独的集合,rk 为 0。
  • 当两个集合进行合并操作时,根据它们的秩进行合并:如果待合并的两个集合的 秩不同,将秩较小的集合合并到秩较大的集合中,这样不会增加整体树的高度。如果待合并的两个集合的秩相同,则任意选择一个作为新的根节点,但需要将秩加 1,以保持整体平衡。

 在这种优化策略下,操作的时间复杂度能够保持在 O(logm),其中 m 是操作次数。

  • 使用支持删除操作的可撤销并查集

  1. 为了处理删除边的情况,可以使用可撤销并查集。通过记录操作的历史,我们可以在需要时回滚操作,恢复之前的状态。这使得我们可以高效处理边的删除和添加。
  2. 考虑使用栈存储并查集合并时修改的信息。撤销的操作遵循栈后进先出的规律。如果要撤销合并操作,可以将栈中的数据弹出,进行数据复原便完成了合并操作撤销。

(2)算法思路:

  1. 先判断第一条边是不是割边。将除第一条边外的所有边,按照边序号从后到前遍历,将边连接的两个点进行并查集的合并操作。然后查询第一条边连接的两个点是否在一个集合里。
  2. 然后判断第二条边是不是割边。撤销并查集对第二条边的修改。将第一条边连接的两个点进行合并。并查集查询第二条边连接的两个点是否在一个集合里。
  3. 然后判断第三条边是不是割边,撤销并查集对第一三条边的修改。将第一第二条边里连接的两个点进行合并。并查集查询第二条边连接的两个点是否在一个集合里。
  4. 以此类推可以通过并查集判断第四条边是不是割边直到最后一条边是不是割边。

(3)伪代码:

优化算法--使用按秩合并及可撤销并查集

  1. void connect( u , v )  //按秩合并
  2. {
  3.   Pu=find(u),Pv=find(v)  //Pu和Pv为两个元素各自所在集合的根节点
  4.   If Pu==Pv: return  //不用合并
  5.   If rk[Pu]<rk[Pv]: stack[++sp]=Node(Pv,Pu,rk[Pv])  //Pu指向Pv,秩为Pv的秩
  6.   Else stack[++sp]=Node(Pu,Pv,rk[Pu])  //Pv指向Pu,秩为Pu的秩
  7.   If rk[Pu]<rk[Pv]: fa[Pu]=Pv  //秩小的根节点指向秩大的根节点
  8.   Else if rk[Pu]==rk[Pv]: fa[Pv]=Pu,rk[Pu]++;
  9.   Else fa[Pv]=Pu
  10. }
  11. void Pop(size) //撤销操作
  12. {
  13.   Node temp;
  14.   While(sp>size):
  15.     Temp=stack[sp--]  //取出栈顶元素
  16.     rk[temp.u]=temp.rk  //恢复秩
  17.     fa[temp.v]=temp.v   //恢复父节点
  18. }
  19. void Check() //遍历每条边
  20. {
  21.   For i from 1 to m:
  22.     u=edge[i].u, v=edge[i].v
  23.     int size=sp;  //记录当前栈顶位置
  24.     For j from m to 1 dec:  //合并除当前边以外的所有边
  25.       If i==j: continue  
  26.       connect(edge[j].u,edge[j].v)
  27.     If find(u)!=find(v):  //如果当前边两个点不在同一个集合即当前边是桥
  28.       edge[i].isbridge=true,ans++;
  29.     Pop(size)  //撤销操作
  30. }
  31. Int func1()
  32. {
  33.   For i from 1 to n:
  34.     fa[i]=i, rk[i]=0  //并查集初始化操作
  35.   sp=0  //栈顶指针初始为0
  36.   Check()
  37. }

(4)时间复杂度:

  1. 合并除当前边以外的所有边:内层循环遍历所有边,除去当前边,时间复杂度是 O(m)。在按秩合并的并查集操作时间复杂度是O(logm)。总的来说,内层循环的时间复杂度是 O(m*logm)。
  2. 撤销操作:Pop(siz); 的时间复杂度取决于操作数量。在最坏情况下,需要撤销所有的合并操作,时间复杂度是 O(m)。
  3. 因此,Check 函数中每次外层循环的时间复杂度是:O(m*logm)。由于外层循环执行 m 次,总时间复杂度是:O(m^2*logm)
  4. 包括初始化的时间复杂度,总的时间复杂度是:O(n+m^2*logm)
  5. 因此总的时间复杂度为:O(m^2*logm)对于边数 m 非常大的图,性能会下降。

3.优化算法--使用可撤销并查集+线段树分治

(1)算法分析:

在使用可撤销并查集判断割边时,每条边都需要通过多次的合并和撤销操作来确定是否是割边。原始方法是逐条边处理,每处理一条边,需要撤销前面所有边的合并操作,这样会导致前面的边在栈中频繁弹入弹出,而后面的边基本没有太多操作。因此,整体效率较低。因此可以利用线段树的分治思想来优化这个过程,使得整体的弹入弹出次数降低。具体来说,通过分治的方式将问题分成多个子问题,解决子问题时可以一次性处理一个区域的边,减少频繁的单条边操作。使用线段树分治优化栈的弹出弹入方法。可以连续弹入或弹出一片区域中的边。

  1. 线段树:线段树是一种二叉搜索树 。它将一段区间划分为若干单位区间 ,每一个节点都储存着一个区间。线段树支持区间求和,区间最大值,区间修改,单点修改等操作。线段树的思想和分治思想很相像。对边进行编号划分,得到所有的边按序排好作为一个区间标记为一号节点,然后将前面一半的边作为一个区间标记为二号节点,将后面一半的边作为一个区间标记为三号节点,以此类推,得到下图所示的线段树结构。当查询到下图箭头所指区域时,其他边(绿色块)都处于被压入栈的情况,并且深度越大的绿色块,在栈中的高度越高
  2. 利用线段树优化查询:实际运行中,会以深搜的方法遍历每一个叶子节点,而每一个叶子节点就对应一条边,比如以上图为例,首先会经过1,2,4号节点到达8号结点,找到第1号边;然后回溯到4号节点,再来到9号节点,找到第2号边;然后再回溯到4号节点和2号节点,进入5号节点和10号节点,找到第3号边。以此类推。查询完橙色的4号边之后,要查询5号边,首先根据深搜的顺序,回溯到1号节点,途中将10,4,3号节点所囊括的边全部弹出栈中,然后进入3号节点,进入3号节点前,先将2号节点囊括的边压入栈中,然后进入6号节点,进入6号节点前,先将7号节点所囊括的边压入栈中,最后进入12号节点,进入12号节点前,将13号节点所囊括的边压入栈中,得到此时仅剩12号节点对应的5号边没有连接。如果后面要查询6号边操作同理,过程如下图表所示(从上到下从左到右):


再使用并查集查询某边连接的两个点是否在用一个集合,以此来判断此边是否为割边。以此类推遍历每一个叶节点的边就可以完成查询所有边是否为割边(即是否为桥)。

(2)算法实现思路:

  1. 对于每个区间 [l, r],如果区间只有一条边,则直接判断是否为桥边。
  2. 否则,递归处理右半部分 [mid+1, r],合并区间内所有边,再递归处理左半部分 [l, mid],最后撤销合并操作。
  3. 通过分治递归,有效减少了频繁的栈操作,提高了整体的处理效率。

(3)伪代码:

优化算法--使用按秩合并及可撤销并查集+线段树分治

  1. void Push(l , r)
  2. {  //将编号为l到r的边进行合并
  3.   For i from r to l dec:
  4.     connect(edge[i].u,edge[i].v)
  5. }
  6. void DFS(l , r)  //递归函数
  7. {
  8.   If l==r : //基本情况,区间只有一条边,直接判断
  9.     If find(edge[l].u)!=find(edge[l].v) : //如果不连通则是桥
  10.       edge[l].isbridge=true,ans++
  11.     Return
  12.   size=sp  //保存当前栈顶位置
  13.   mid=(l+r)>>1
  14.   Push(mid+1,r) //处理右半部分
  15.   DFS(l,mid)
  16.   Pop(size) //撤销右半部分的合并操作
  17.   Push(l,mid) //处理左半部分
  18.   DFS(mid+1,r)
  19.   Pop(size) //撤销左半部分的合并操作
  20. }
  21. Int func2()
  22. {
  23.   For i from 1 to n:
  24.     fa[i]=i, rk[i]=0  //并查集初始化操作
  25.   sp=0  //栈顶指针初始为0
  26.   DFS(1,m)
  27. }

(4)时间复杂度:

  1. 在按秩合并的可撤销并查集操作时间复杂度下降为O(logm)。
  2. 在线段树分治中,递归深度为O(logm),每次递归中调用Push和Pop操作,分别处理两个子区间,合并和撤销的边数最多为O(m),即为O(mlogm)
  3. 因此,总的时间复杂度为O(m*(logm)^2)
  4. 此外,并查集的操作实际上很多时候并不会到达O(logm)的时间复杂度,所以总体而言这个时间复杂度还是比较不错的。

4.拓展:Tarjan算法验证并查集优化+线段树分治的算法正确性

使用 Tarjan 算法找到所有的桥边后,可以与并查集优化+线段树分治算法优化的结果进行比较,验证两者是否一致。若一致,说明线并查集优化+线段树分治的方法正确无误。

Tarjan算法:通过深度优先搜索(DFS)结合时间戳和回溯时间戳来判断一个边是否是桥边。算法的时间复杂度是O(n+m),其中n是节点数,m是边数。

Tarjan算法思路:

 1.初始化:将 dfn (用于存储每个节点的首次访问时间戳)和 low(用于存储节点的回溯时间戳,即当前节点或其子树能够回溯到的最早的祖先节点的时间戳) 数组清零。初始化时间戳 tstp 为0。

2.遍历所有节点:对每个节点,如果还没有被访问过(即 dfn[i] == 0),调用 tarjan 函数从该节点开始进行DFS。

3.DFS过程:遍历所有邻接节点 v,如果 v 未被访问过,递归调用 tarjan,更新 low[u]。如果 dfn[u] < low[v],说明 u-v 是桥边。如果 v 已被访问,且不是通过父边返回,更新 low[u]。

Tarjan算法伪代码:

Tarjan算法

  1. Int dfn[MAXN],low[MAXN],tstp=0
  2. void tarjan(u , pre)
  3. {
  4.   dfn[u]=low[u]=++tstp //初始化dfn和low
  5.   For 枚举u的邻点v:
  6.     If !dfn[v]://如果v未被访问
  7.       tarjan(v,i)  //递归访问v
  8.       low[u]=min(low[u],low[v])  //更新low[u]
  9.       If dfn[u]<low[v]://判断是否为桥边,如果dfn[u]<low[v]则是桥边
  10.         edge[i/2+1].isbridge=true,ans++
  11.     Else if (i!=(pre^1))://更新low[u](忽略父边)
  12.       low[u]=min(low[u],dfn[v])
  13. }
  14. Int func3()
  15. {
  16.   For i from 1 to n:
  17.     dfn[i]=low[i]=0  
  18.   tstp=0  
  19.   For i from 0 to n-1:
  20.     If !dfn[i]:tarjan( i , -1)
  21. }

这篇关于算法设计与分析:实验五 图论——桥问题的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1126083

相关文章

不懂推荐算法也能设计推荐系统

本文以商业化应用推荐为例,告诉我们不懂推荐算法的产品,也能从产品侧出发, 设计出一款不错的推荐系统。 相信很多新手产品,看到算法二字,多是懵圈的。 什么排序算法、最短路径等都是相对传统的算法(注:传统是指科班出身的产品都会接触过)。但对于推荐算法,多数产品对着网上搜到的资源,都会无从下手。特别当某些推荐算法 和 “AI”扯上关系后,更是加大了理解的难度。 但,不了解推荐算法,就无法做推荐系

好题——hdu2522(小数问题:求1/n的第一个循环节)

好喜欢这题,第一次做小数问题,一开始真心没思路,然后参考了网上的一些资料。 知识点***********************************无限不循环小数即无理数,不能写作两整数之比*****************************(一开始没想到,小学没学好) 此题1/n肯定是一个有限循环小数,了解这些后就能做此题了。 按照除法的机制,用一个函数表示出来就可以了,代码如下

hdu1043(八数码问题,广搜 + hash(实现状态压缩) )

利用康拓展开将一个排列映射成一个自然数,然后就变成了普通的广搜题。 #include<iostream>#include<algorithm>#include<string>#include<stack>#include<queue>#include<map>#include<stdio.h>#include<stdlib.h>#include<ctype.h>#inclu

康拓展开(hash算法中会用到)

康拓展开是一个全排列到一个自然数的双射(也就是某个全排列与某个自然数一一对应) 公式: X=a[n]*(n-1)!+a[n-1]*(n-2)!+...+a[i]*(i-1)!+...+a[1]*0! 其中,a[i]为整数,并且0<=a[i]<i,1<=i<=n。(a[i]在不同应用中的含义不同); 典型应用: 计算当前排列在所有由小到大全排列中的顺序,也就是说求当前排列是第

性能分析之MySQL索引实战案例

文章目录 一、前言二、准备三、MySQL索引优化四、MySQL 索引知识回顾五、总结 一、前言 在上一讲性能工具之 JProfiler 简单登录案例分析实战中已经发现SQL没有建立索引问题,本文将一起从代码层去分析为什么没有建立索引? 开源ERP项目地址:https://gitee.com/jishenghua/JSH_ERP 二、准备 打开IDEA找到登录请求资源路径位置

csu 1446 Problem J Modified LCS (扩展欧几里得算法的简单应用)

这是一道扩展欧几里得算法的简单应用题,这题是在湖南多校训练赛中队友ac的一道题,在比赛之后请教了队友,然后自己把它a掉 这也是自己独自做扩展欧几里得算法的题目 题意:把题意转变下就变成了:求d1*x - d2*y = f2 - f1的解,很明显用exgcd来解 下面介绍一下exgcd的一些知识点:求ax + by = c的解 一、首先求ax + by = gcd(a,b)的解 这个

综合安防管理平台LntonAIServer视频监控汇聚抖动检测算法优势

LntonAIServer视频质量诊断功能中的抖动检测是一个专门针对视频稳定性进行分析的功能。抖动通常是指视频帧之间的不必要运动,这种运动可能是由于摄像机的移动、传输中的错误或编解码问题导致的。抖动检测对于确保视频内容的平滑性和观看体验至关重要。 优势 1. 提高图像质量 - 清晰度提升:减少抖动,提高图像的清晰度和细节表现力,使得监控画面更加真实可信。 - 细节增强:在低光条件下,抖

【数据结构】——原来排序算法搞懂这些就行,轻松拿捏

前言:快速排序的实现最重要的是找基准值,下面让我们来了解如何实现找基准值 基准值的注释:在快排的过程中,每一次我们要取一个元素作为枢纽值,以这个数字来将序列划分为两部分。 在此我们采用三数取中法,也就是取左端、中间、右端三个数,然后进行排序,将中间数作为枢纽值。 快速排序实现主框架: //快速排序 void QuickSort(int* arr, int left, int rig

购买磨轮平衡机时应该注意什么问题和技巧

在购买磨轮平衡机时,您应该注意以下几个关键点: 平衡精度 平衡精度是衡量平衡机性能的核心指标,直接影响到不平衡量的检测与校准的准确性,从而决定磨轮的振动和噪声水平。高精度的平衡机能显著减少振动和噪声,提高磨削加工的精度。 转速范围 宽广的转速范围意味着平衡机能够处理更多种类的磨轮,适应不同的工作条件和规格要求。 振动监测能力 振动监测能力是评估平衡机性能的重要因素。通过传感器实时监

poj 3974 and hdu 3068 最长回文串的O(n)解法(Manacher算法)

求一段字符串中的最长回文串。 因为数据量比较大,用原来的O(n^2)会爆。 小白上的O(n^2)解法代码:TLE啦~ #include<stdio.h>#include<string.h>const int Maxn = 1000000;char s[Maxn];int main(){char e[] = {"END"};while(scanf("%s", s) != EO