记一个动态加边数桥问题
RT 啊,并非动态加边动态询问两点是否一个 e-bcc 有几条必经边之类的,纯如题:
给定 \(n\) 个点的图,初始没有任何边。按照顺序加入 \(m\) 条无向边边 \((u ,v)\),求每次加边后该图的割边(桥)的数量。
点的编号为 \(0 \sim n - 1\)。
\(1\le n \le 5\times 10^4\),\(1\le m\le 10^5\)。
这是 nfls 里的一道作业,理解我比较长的时间,遂记录一下,顺便帮助后人理解。
割边容易想到边双缩点,此时是一棵树或一个森林,方便我们分析。
考虑分类讨论对答案的影响:

图中,圆圈内部是边双连通分量,红边是割边,我们考虑新加一条边:

如图,我们新加了一条绿边,由于该边在同一个边双内部,因此对答案无影响。
实际编写代码时,我们可以写函数 finde(x) 表示寻找 \(x\) 节点所在边双,为了方便起见,我们不妨不记录边双编号,而是找这个边双内的任意一个点,作为代表元。
// 类似并查集的找祖先,我们寻找该边双的代表元。
inline int finde(int u) { return fae[u] == u ? u : fae[u] = finde(fae[u]);}
那么这种情况对应代码:
int u = read() + 1 ,v = read() + 1; // 题目 0-index。
int eu = finde(u) ,ev = finde(v);
if(eu == ev) { // 同属一个边双,割边数量不增加。
writeln(ans) ; continue;
}
接下来是第二种情况:

如图,我们增加一条蓝边,它两端属于两个不同的边双。
那么新的边双数是多少呢?

我们发现存在一个紫色的环,此时新的边双数量为 \(2\)。
也就是说,对于原始该环上的所有割边都会变成非割边。
我们只要查的 eu = finde(u) 和 ev = finde(v),那么边双缩点后的树中 \(eu \rightsquigarrow ev\) 的割边贡献都为减一。
只要我们想办法使树高固定在 \(\mathcal O(\log n)\) 以内,暴力删除贡献就是可行的。
注意到第一种情况啥都不改,目前只有这种情况会产生影响并合并点。
对于合并 \(x\) 个边双,一次操作会减少 \(x - 1\) 个边双,最坏复杂度 \(\mathcal O(x)\),总共至多 \(n\) 个边双,均摊下来复杂度是 \(\mathcal O(n)\)。
inline void merge_path(int u ,int v) { // 合并 u ,v 所属边双为一个新的边双。
// pa 是边双树上的父亲。
int lca = 0 ; ++ idx ; // idx 用来标记路径。
vec1.clear() ; vec2.clear(); // vec1 ,vec2 用来记录 u -> lca ,v -> lca 的路径。
while(!lca) {
if(u) {
u = finde(u);
vec1.push_back(u);
if(vis[u] == idx) { lca = u ; break ; }
vis[u] = idx ,u = pa[u];
} if(v) {
v = finde(v);
vec2.push_back(v);
if(vis[v] == idx) {lca = v ; break;}
vis[v] = idx ,v = pa[v];
}
}
for(int i : vec1) { // 删除贡献,注意 vec1 / vec2 中存有 u ,v。
fae[i] = lca ;
if(i == lca) break;
-- ans ;
}
for(int i : vec2) {
fae[i] = lca ;
if(i == lca) break;
-- ans;
}
}
然而还有另外一种情况,因为边双缩点后可能形成森林。

如图,我们添加橙边,显然要把两个森林合并为一棵树,朴素的想法是把 \(5 ,7\) 连边即可。
但这样复杂度可能退化为 \(\mathcal O(n^2)\),并且对于 \(1 ,2 ,3 ,4\) 这些节点,查询 finde 会返回 1 而非 6。
考虑时间复杂度,我们采用启发式合并,用小的合并大的,上图中就是左右互换。

对于后面的问题,我们只要把 \(7\) 提起作根即可。

启发式合并的树高均摊是 \(\mathcal O(\log n)\) 的,因此总时间复杂度均摊为 \(\mathcal O(m \log n)\)。
这个提根有点类似于 splay 呢,splay 用 zig、zag 和提根均摊,这里用启发式合并均摊。
判断是否同一连通块(情况二、三)用并查集即可(数组为 fa)。
inline void makeroot(int u) { // 把 u 提起作根。
// pa 是边双树上的父亲。
u = finde(u);
int rt = u ,child = 0 ;
while(u) {
int f = pa[u];
pa[u] = child ,fa[u] = rt ,child = u ,u = f; // 只需要修改顶到节点的链即可。
} sz[rt] = sz[child]; // 我们只关心代表元,代表元的大小反应的是该边双树的大小。
}
总代码:
inline int finde(int u) { return fae[u] == u ? u : fae[u] = finde(fae[u]);}
inline int find(int u) { return u == fa[u] ? u : fa[u] = find(fa[u]) ;}
inline void makeroot(int u) {
u = finde(u);
int rt = u ,child = 0 ;
while(u) {
int f = pa[u];
pa[u] = child ,fa[u] = rt ,child = u ,u = f;
} sz[rt] = sz[child];
}
inline void merge_path(int u ,int v) {
int lca = 0 ; ++ idx ;
vec1.clear() ; vec2.clear();
while(!lca) {
if(u) {
u = finde(u);
vec1.push_back(u);
if(vis[u] == idx) { lca = u ; break ; }
vis[u] = idx ,u = pa[u];
} if(v) {
v = finde(v);
vec2.push_back(v);
if(vis[v] == idx) {lca = v ; break;}
vis[v] = idx ,v = pa[v];
}
}
for(int i : vec1) {
fae[i] = lca ;
if(i == lca) break;
-- ans ;
}
for(int i : vec2) {
fae[i] = lca ;
if(i == lca) break;
-- ans;
}
}
namespace lolcrying{
signed main(){
n = read() ,m = read();
up(i ,1 ,n) vis[i] = pa[i] = fa[i] = fae[i] = 0 ,sz[i] = 1;
up(i ,1 ,n) fa[i] = fae[i] = i ;
ans = idx = 0 ;
up(i ,1 ,m) {
int u = read() + 1 ,v = read() + 1;
int eu = finde(u) ,ev = finde(v);
if(eu == ev) {
writeln(ans) ; continue;
}
int fau = find(u) ,fav = find(v);
if(fau ^ fav) {
++ ans;
if(sz[fau] > sz[fav]) swap(eu ,ev) ,swap(fau ,fav) ,swap(u ,v);
makeroot(u);
pa[u] = ev ,fa[fau] = fav ,sz[fav] += sz[fau];
} else merge_path(u ,v);
writeln(ans);
}
return 0 ;
}
}
本文来自博客园,作者:2021zjhs005,转载请注明原文链接:https://www.cnblogs.com/2021zjhs005/p/22188729

浙公网安备 33010602011771号