相关资源下载
(62条消息) 算法设计与分析-5图论桥报告.docx-算法与数据结构文档类资源-CSDN文库
(62条消息) 算法设计与分析-5图论桥preppt.pptx-算法与数据结构文档类资源-CSDN文库
(62条消息) 算法设计与分析-5图论桥源代码.cpp-算法与数据结构文档类资源-CSDN文库
目录
基准法1:深度优先dfs实现的 DFScoutArea()函数
寻找最小公共祖先lca(edge)函数(参数为加入的非桥边edge)
概览
桥
在图论中,一条边被称为“桥”代表这条边一旦被删除,这张图的连通块数量会增加。等价地说,一条边是一座桥当且仅当这条边不在任何环上。一张图可以有零或多座桥。
图 1 没有桥的无向连通图 图 2 这是有16个顶点和6个桥的图(桥以红色线段标示)
求解问题
找出一个无向图中所有的桥。
基准算法
For every edge (u, v), do following
a) Remove (u, v) from graph
b) See if the graph remains connected (We can either use BFS or DFS)
c) Add (u, v) back to the graph.
查并集
它是1个树型数据结构,即获得连通块的集合。往往表现为森林,通过更新维护父亲结点实现。需要整型数组father[vn](vn为结点数),初始为自身;查找函数find(),查找每个结点的父亲(可用路径压缩);合并函数join(),将边加入并用find()更新father。
查找函数find()伪代码
find(x):
if father[x]==x:
return x //本身就是祖先直接返回
return father[x]=find(father[x]) //调用查找函数查找祖先,同时路径压缩
合并函数join()伪代码(基本实现)
join(x,y): //将断点为x,y的2条边加入,同时更新祖先
fx=find(x) //查找端点x的祖先
fy=find(y) //查找端点y的祖先
if fx!=fy:
father[fx]=fy //不同属1个家族需要合并
-
数据获取
我对每个点的邻点进行记录,避免边稀疏导致较多的空间浪费。同时是动态分配邻接表的大小,我采用集合容器set进行记录,能实现排序和去重功能。
-
基准算法
连通块获取
-
基准法1:深度优先dfs实现的 DFScoutArea()函数
可通过dfs获取,设置father数组标记相同块,每次新标记代表一个新块。
dfs连通块获取伪代码
dfs(node) //深度优先搜索
while j in node.adj:
if father[j] == father[node]:
continue
father[j] = father[node]
dfs(j)
DFScoutArea():
res = 0
while i = 0 to vn:
if father[i]==i:
dfs(i)
res++
return res
-
基准法2:查并集dsu实现的DSUcoutArea()函数
针对原理处进一步展开实现细节如下:
查找find()函数
这个算法实现可以借鉴一个故事,如果几个家族(同一连通块)在只知道自己父亲的情况下想找祖先(根节点),他们可以一层一层向上问直到找到祖先。祖先相同为同一家族。同时为了避免他的后代在找祖先时有重复了相同的路径,我们干脆在father数组记录他找到的祖先,这样子他的后代追溯到他这里时就立即可以知道祖先,而不用重复在继续找下去。(路径压缩)
find()函数伪代码
find(x): //找x的祖先
if x == father[x]: //找到祖先(没有父亲,父亲就是本身)
return x //返回祖先
return father[x] = find(father[x]) //否则一路向上找祖先,且找到后一路都返回标记祖先(路径压缩)
合并join()函数
如果有边连通2个独立的连通块,那么他们将合并并归为同一祖先,为了提高算法效率,我们需要让小连通块加入大的连通块。
- 为了实现这一目标,一种方法是用一个大小为结点数vn的数组num存储连通块里点数,下标为祖先。每次有点加入连通块时对应点数增加,2连通块合并时检查大小再更新。
查找join()函数优化伪代码
join(x, y) { //边的2端点x y
xf = find(x) //记录x的根节点
yf = find(y) //记录y的根节点
if xf != yf: //非同一连通块时合并
if num[xf] >= num[yf]: //判断连通块大小
father[xf] = yf //小家族加入大家族
num[xf]+= num[yf] //连通块里点数更新
else:
father[yf] = xf
num[yf]+= num[xf]
- 另外是在查找祖先find()函数时记录更新各节点的深度,各节点深度应该是其子节点的深度加1或本身结点深度,2者取最大。即加入:
depth[father[x]] = max(depth[father[x]], depth[x] + 1)
在合并函数join()也做相应更新。
查并集连通块数获取
先初始化父亲数组,用并查集将全部边加入,接着计算家族数。
dsu():
for e in edge: //遍历全部边加入
join(e.head, e.tail)
DSUcoutArea():
unitFather() //初始化父亲数组,自身为祖先
dsu() //并查集
return familyNum() //返回家族数
- 查桥FindBridge()函数
如果删掉边之后连通块增加,说明为桥。
查桥伪代码
FindBridge():
area = DFScoutArea()
while i = 0 to vn:
while j in i.adj: // ⽤迭代器遍历集合s⾥⾯的每⼀个元素
i.erase(j)
j.erase(i)
area2 = coutArea()
if (area2> area) {
bridge++
show(i,j)
node[i].insert(j);
node[j].insert(i);
- 时间复杂度分析
dfs基准法时间复杂度分析
设点数为n,边数为e,需要遍历全部点检查是否已经标记祖先O(n),又需要遍历全部边标记祖先O(e)。则dfs算法时间复杂度为O(n+e)。查桥需要遍历全部边调用e次dfs,则总的时间复杂度为
O(e(n+e))=O(e*n+e*e)。
并查集查桥时间复杂度分析
由于有路径压缩,即每次将节点直接连到对应的祖先节点上,所以每次查询祖先操作时我们最多通过2次向上搜寻即可找到祖先节点。所以单次查询祖先操作的时间复杂度可近似认为O(1),设边数为e,则共有e次加边过程时间复杂度为O(e)。
另外在该过程时间开销比较大的就是对父亲结点的更新了。由《算法导论》一书中定理21.1的证明可知,一个有n个点的集合,每个点的父亲记录最多被更新logn次。则该方面时间复杂度为O(nlogn)。
所以单次并查集连通块数获取的时间复杂度为
O(e+nlogn)。
查桥需要遍历全部边调用e次dsu,则总的时间复杂度为
O(e(e+nlogn))=O(e* e+nlogn *e)
明显可见在基准算法深度优先dfs效率会优于并查集dsu。
高效算法
-
高效算法1:dfs基准算法优化(判断可达)
如果删除某条边,可以从该边一端进行深度优先搜索,看能否达到另一端点。如果可达则连通,非桥。查桥思维依旧和基准算法一样,但无需每次重新计算连通块数,而是直接判断删边后的这2点的连通性有无改变。
最坏情况是每次判断都需要遍历这张图,那么耗费时间会等同简单基准算法,时间复杂度同样为
O(e*n+e*e)。
基准算法优化伪代码
dfsBridge(x, y) {
father[x] = 1
for i in x.adj:
if i == y:
return true
else if father[i]==1
continue
else if dfsBridge(i, y)==true
return true
-
高效算法2:查并集+最小公共祖先
最小公共祖先简称LCA(Lowest Common Ancestor),即2个节点离根最远的祖先节点。我们可以先通过并查集生成树,记录树边(初始为桥边)和非树边。遍历全部非树边,通过LCA找到形成的环并把在该环的树边从桥边去除,桥数减少。
-
查并集生成树细节
对于上面实现的查并集,需要在增加记录树边和非树边的过程。对于二者的记录,我先创建struct结构体类型edge进行边存储,包括头尾结点head tail的记录、构造函数、以及后续操作所需的等于运算符重载。我采用vector容器记录,且统一head<tail。因为其生成树为等效连通图,所以对树边的记录也需要是等效树边,即原树边的两端点更新为父亲结点。
-
寻找最小公共祖先lca(edge)函数(参数为加入的非桥边edge)
对于每一非树边的加入,我们需要对其两端点进行最小公共祖先的确定其所在环,并把环上树边从桥中去除,桥数对应减少。具体实现细节是先将深度较大结点进行溯源删桥至两端点深度相同,再进行两端点同时溯源删桥直到其找到的最小公共祖先。
寻找最小公共祖先lca(edge)函数(参数为加入的非桥边edge)伪代码
lca(e): //寻找最小公共祖先lca(edge)函数(参数为加入的非桥边edge)
x = e.head
y = e.tail
if depth[x] < depth[y]:
swap(x, y) //使x深度大
while depth[x]>depth[y]://先将深度大点进行溯源删桥至两端点深度相同
z = father[x]
k = x
if k > z:
swap(k, z)
if e in tree: //找到桥删除
tree.erase(e)
bridge--
x = father[x] //溯源更新
while x!=y: //再进行两端点同时溯源删桥直到其找到的最小公共祖先
z = father[x]
k=x
if k > z:
swap(k, z)
if e in tree:
tree.erase(e)
bridge--
x = father[x]
z = father[y]
k=y
if k > z:
swap(k, z)
if e in tree
tree.erase(e)
bridge--
y = father[y]
-
查并集+最小公共祖先法确定桥数lcaCountBridge()
需要先调用查并集生成树,将桥数初始为树边数,再遍历非树边,依次加入对其利用最小公共祖先删除非桥树边。
lcaCountBridge()伪代码
lcaCountBridge()伪代码如下
lcaCountBridge():
unitFather() //初始祖先为自身
dsu() //查并集生成树
bridge = tree.size()
for e in notree
lca(e)
-
时间复杂度
仅调用1次查并集,接下来的最小共同祖先查找遍历全部边,所以时间复杂度为O(e+nlogn)。
数据处理
为了减少误差我采取测试100次取平均值,以上算法运行时间如下:
图表 1 各种算法运行时间
算法 | 基准dfs | 基准dsu | 基准dsu(路径压缩) | dfs可达 | dsu+lca |
Time/ms | 2.93 | 9.01 | 8.24 | 1.74 | 0.38 |
图表 2 各种算法运行时间
总结
由上面的数据可以看出:
- 在基准算法里深度优先DFS比并查集DSU效率高。
- 在小规模数据由于深度不大,所以路径压缩效果不明显。
- 将基准算法改为判断可达后时间可以缩短40%,效果较明显。
- 通过查并集dsu+最近公共祖先lca的方法,可以避免大量的冗余计算,效果明显。
通过本次实验,我加深对图的连通性的理解和运用,直到如何利用深度优先DFS算法、广度优先BFS算法、查并集DSU算法生成生成树并确定连通性。掌握并查集的基本原理和应用,通过父亲数组father、查找find()、合并join()实现并查集,以确定图的连通性。同时也了解到通过路径压缩和按秩合并的并查集优化方法。路径压缩在图规模较大、树深度较大时效果会比较好。