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

实验内容:

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. }
  • 18
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值