Tarjan 求出初始桥并缩成桥树;加边只会消灭树上路径的桥,用并查集跳链缩点,O(N+M+Q) 维护剩余桥数。
OJ: roj
题目 ID: 3195
难度:提高+/省选-
标签:图论Tarjan算法无向图的双连通分量并查集
创建: 2026-10-02 00:56
更新: 2026-10-10 13:30
形式化题目
给定一张
这里「桥」指删掉后会使图不连通的边;加入的边是累积的,不会撤销。
约束:
注意
正解
思路
第一步:先发现「桥只会减少」。
往图里加边只会制造新的环,不会破坏已有的环。因此一条非桥边永远是非桥边, 一条桥边有可能因为新环覆盖它而失效,但绝不会反过来。所以答案是单调不增的。
更精确地说,新边
第二步:把一般图压缩成树。
把图上所有非桥边构成的连通块各自缩成一个点——这正好是边双连通分量,
记为
缩点得到的图是一棵树,称为桥树。
理由是:如果缩点图里有环,环上每条边都不可能是桥(环上两点总有两条边不相交的路径), 与缩点方式矛盾;又因为原图连通,缩点图连通。连通无环即树。
于是「
维护桥数就变成了维护桥树上的剩余边数:每次询问把树上
下图是样例第 2 组数据(
graph LR
subgraph G["原图"]
A1["1"] === A2["2"]
A2 --- A3["3"]
A1 --- A4["4"]
end
subgraph T["桥树"]
B12["块{1,2}"] --- B3["块{3}"]
B12 --- B4["块{4}"]
end
观察重点:重边 2、0。
第三步:用并查集把「删路径」做成摊还线性。
树上的路径删边不必逐条找。维护一个并查集:若某个节点的父边已经被删,
就把它并进父亲的集合。这样 find(u) 直接给出「沿父边向上的第一个还有桥的点」。
while u != v:
if depth[u] < depth[v]:
u, v = v, u # 让 u 是更深的那一侧
uf[u] = find(uf, parent[u])
u = uf[u]
remaining -= 1为什么取更深的一侧一定对?设
每个树节点至多被合并一次,所以
下面把样例第 2 组的两步询问摊开,看 remaining 怎么走:
| 步骤 | 询问 | 桥树上的路径 | 本次删掉的桥 | 输出 |
|---|---|---|---|---|
| 初始 | — | — | — | 桥数 |
| 第 1 次 | 块 |
无(两端已同块) | 2 |
|
| 第 2 次 | 两条 | 0 |
第 1 次询问两端落在同一个块里,find 结果相等,循环体一次都不执行,
这正是「重边不改变任何东西」在算法里的体现。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-10 13:30
* update_at: 2026-10-10 13:30
*/
// 桥 + 块树询问:先 Tarjan 求桥,删桥后给每个点标出 2-边连通块号,
// 再把块当点、桥当边建出桥森林;每次询问把 u,v 在森林路径上的桥逐一缩掉,
// 输出当前还没被任何一次询问覆盖过的桥数。
#include <cstdio>
#include <vector>
using namespace std;
typedef long long ll;
int n, m;
vector<int> head, nxt, toArr;
struct Frame { // 迭代 Tarjan 的栈帧
int u, e, inArc; // 点、待处理的邻接弧、进入当前点的那条弧
};
vector<char> markBridges() { // 求桥:bridge[e] = 1 表示弧 e 属于某条桥
vector<int> tin(n + 1, 0), low(n + 1, 0); // 0 兼作「没访问过」
vector<char> bridge(toArr.size(), 0);
int timer = 0;
for (int s = 1; s <= n; ++s) {
if (tin[s]) continue;
timer += 1;
tin[s] = low[s] = timer;
vector<Frame> stk;
Frame f;
f.u = s; f.e = head[s]; f.inArc = -1;
stk.push_back(f);
while (!stk.empty()) {
Frame top = stk.back();
if (top.e) {
stk.back().e = nxt[top.e];
if (top.e == (top.inArc ^ 1)) continue; // 配对弧:无向边不能当回边用
int v = toArr[top.e];
if (tin[v]) {
if (tin[v] < low[top.u]) low[top.u] = tin[v];
} else {
timer += 1;
tin[v] = low[v] = timer;
Frame nf;
nf.u = v; nf.e = head[v]; nf.inArc = top.e;
stk.push_back(nf);
}
} else {
stk.pop_back();
if (!stk.empty()) {
int p = stk.back().u;
if (low[top.u] < low[p]) low[p] = low[top.u];
if (low[top.u] > tin[p]) { // 子树回不到 p 之上,这条弧的边是桥
bridge[top.inArc] = 1;
bridge[top.inArc ^ 1] = 1;
}
}
}
}
}
return bridge;
}
int findUF(vector<int> &uf, int x) { // 并查集找根 + 路径压缩(迭代写法)
int r = x;
while (uf[r] != r) r = uf[r];
while (uf[x] != r) {
int old = uf[x];
uf[x] = r;
x = old;
}
return r;
}
int main() {
int caseNo = 0;
while (true) {
if (scanf("%d %d", &n, &m) != 2) break; // 输入结束
if (n == 0 && m == 0) break;
caseNo += 1;
head.assign(n + 1, 0);
nxt.assign(2 * m + 2, 0);
toArr.assign(2 * m + 2, 0);
for (int i = 1; i <= m; ++i) { // 无向边存成两条配对弧 2i 与 2i+1
int a, b;
scanf("%d %d", &a, &b);
nxt[2 * i] = head[a]; toArr[2 * i] = b; head[a] = 2 * i;
nxt[2 * i + 1] = head[b]; toArr[2 * i + 1] = a; head[b] = 2 * i + 1;
}
vector<char> bridge = markBridges();
// 删掉所有桥后给每个点标出它所属的块
vector<int> block(n + 1, 0);
int blocks = 0;
for (int s = 1; s <= n; ++s) {
if (block[s]) continue;
blocks += 1;
block[s] = blocks;
vector<int> stk;
stk.push_back(s);
while (!stk.empty()) {
int u = stk.back();
stk.pop_back();
for (int e = head[u]; e; e = nxt[e]) {
int v = toArr[e];
if (!bridge[e] && !block[v]) {
block[v] = blocks;
stk.push_back(v);
}
}
}
}
int treeEdges = 0; // 桥数:两条配对弧都被标记,除以 2
for (int i = 0; i < (int)bridge.size(); ++i) if (bridge[i]) treeEdges += 1;
treeEdges /= 2;
// 块当点、桥当边建桥森林(端点由配对弧 e 与 e^1 直接给出,只扫偶数弧)
vector<int> thead(blocks + 1, 0), tnxt(2 * treeEdges + 2, 0), tto(2 * treeEdges + 2, 0);
int cnt = 0;
for (int e = 2; e < (int)toArr.size(); e += 2) {
if (!bridge[e]) continue;
int x = block[toArr[e]], y = block[toArr[e ^ 1]];
cnt += 1;
tnxt[cnt] = thead[x]; tto[cnt] = y; thead[x] = cnt;
cnt += 1;
tnxt[cnt] = thead[y]; tto[cnt] = x; thead[y] = cnt;
}
vector<int> tparent(blocks + 1, 0), tdepth(blocks + 1, 0); // 0 = 没访问过,-1 = 树根
for (int s = 1; s <= blocks; ++s) {
if (tparent[s]) continue;
tparent[s] = -1;
vector<int> stk;
stk.push_back(s);
while (!stk.empty()) {
int u = stk.back();
stk.pop_back();
for (int e = thead[u]; e; e = tnxt[e]) {
int v = tto[e];
if (tparent[v] == 0) {
tparent[v] = u;
tdepth[v] = tdepth[u] + 1;
stk.push_back(v);
}
}
}
}
int remaining = treeEdges;
vector<int> uf(blocks + 1);
for (int i = 0; i <= blocks; ++i) uf[i] = i;
int q;
scanf("%d", &q);
if (caseNo > 1) printf("\n"); // 复刻 py 的用例之间空一行
printf("Case %d:\n", caseNo);
for (int i = 0; i < q; ++i) {
int a, b;
scanf("%d %d", &a, &b);
int u = findUF(uf, block[a]), v = findUF(uf, block[b]);
while (u != v) { // 只会往上并
if (tdepth[u] < tdepth[v]) { int t = u; u = v; v = t; }
int up = tparent[u]; // 深的一侧到父亲的桥必然落在路径上
uf[u] = findUF(uf, up);
u = uf[u];
remaining -= 1;
}
printf("%d\n", remaining);
}
}
return 0;
}#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-02 00:56
# update_at: 2026-10-02 01:20
import sys
from array import array
def mark_bridges(n: int, head: array, nxt: array, to: array) -> bytearray:
"""迭代 Tarjan 求桥:bridge[e] = 1 表示弧 e 属于某条桥。
无向边存成两条配对弧 2i 与 2i+1,所以弧 e 的配对弧就是 e ^ 1;
栈帧写成 [当前点, 待处理的邻接弧, 进入当前点的那条弧],
第三条专门用来跳过沿同一条边回到父亲的配对弧。
"""
tin = array('i', bytes(4 * (n + 1))) # 0 兼作“没访问过”
low = array('i', bytes(4 * (n + 1)))
bridge = bytearray(len(to))
timer = 0
for s in range(1, n + 1):
if tin[s]:
continue
timer += 1
tin[s] = low[s] = timer
stack = [[s, head[s], -1]]
while stack:
top = stack[-1]
e = top[1]
if e:
top[1] = nxt[e]
if e == (top[2] ^ 1): # 配对弧:无向边不能当回边用
continue
u, v = top[0], to[e]
if tin[v]:
if tin[v] < low[u]:
low[u] = tin[v]
else:
timer += 1
tin[v] = low[v] = timer
stack.append([v, head[v], e])
else:
stack.pop()
if stack:
p = stack[-1][0]
if low[top[0]] < low[p]:
low[p] = low[top[0]]
if low[top[0]] > tin[p]: # 子树回不到 p 之上,这条弧的边是桥
bridge[top[2]] = bridge[top[2] ^ 1] = 1
return bridge
def label_blocks(n: int, head: array, nxt: array, to: array, bridge: bytearray) -> tuple[array, int]:
"""删掉所有桥后给每个点标出它所属的块,返回块号表与块总数。"""
block = array('i', bytes(4 * (n + 1)))
total = 0
for s in range(1, n + 1):
if block[s]:
continue
total += 1
block[s] = total
stack = [s]
while stack:
u = stack.pop()
e = head[u]
while e:
v = to[e]
if not bridge[e] and not block[v]:
block[v] = total
stack.append(v)
e = nxt[e]
return block, total
def bridge_forest(blocks: int, tree_edges: int, block: array, to: array, bridge: bytearray) -> tuple[array, array]:
"""把块当成点、桥当成边,建出桥森林,返回每个块的父块与深度(根深度 0)。
桥森林的边数等于桥数,而且两个端点由配对弧 e 与 e ^ 1 直接给出,
所以只扫偶数编号的弧就够了。原图连通时它就是一棵树。
"""
thead = array('i', bytes(4 * (blocks + 1)))
tnxt = array('i', bytes(8 * tree_edges + 8))
tto = array('i', bytes(8 * tree_edges + 8))
cnt = 0
for e in range(2, len(to), 2):
if not bridge[e]:
continue
x, y = block[to[e]], block[to[e ^ 1]]
cnt += 1
tnxt[cnt] = thead[x]; tto[cnt] = y; thead[x] = cnt
cnt += 1
tnxt[cnt] = thead[y]; tto[cnt] = x; thead[y] = cnt
parent = array('i', bytes(4 * (blocks + 1))) # 0 = 没访问过,-1 = 所在树的根
depth = array('i', bytes(4 * (blocks + 1)))
for s in range(1, blocks + 1):
if parent[s]:
continue
parent[s] = -1 # 根用 -1 做哨兵,0 留给“没访问过”
stack = [s]
while stack:
u = stack.pop()
e = thead[u]
while e:
v = tto[e]
if parent[v] == 0:
parent[v] = u
depth[v] = depth[u] + 1
stack.append(v)
e = tnxt[e]
return parent, depth
def find(uf: array, x: int) -> int:
"""并查集找根,同时做路径压缩(迭代写法,避免递归深度)。"""
r = x
while uf[r] != r:
r = uf[r]
while uf[x] != r:
uf[x], x = r, uf[x]
return r
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
out: list[str] = []
case_no = 0
while True:
n, m = next(data), next(data)
if n == 0 and m == 0:
break
case_no += 1
head = array('i', bytes(4 * (n + 1)))
nxt = array('i', bytes(8 * m + 8))
to = array('i', bytes(8 * m + 8))
for i in range(1, m + 1):
a, b = next(data), next(data)
nxt[2 * i], to[2 * i], head[a] = head[a], b, 2 * i
nxt[2 * i + 1], to[2 * i + 1], head[b] = head[b], a, 2 * i + 1
bridge = mark_bridges(n, head, nxt, to)
block, blocks = label_blocks(n, head, nxt, to, bridge)
tree_edges = sum(bridge) // 2 # 两条配对弧都被标记,除以 2 才是边数
tparent, tdepth = bridge_forest(blocks, tree_edges, block, to, bridge)
remaining = tree_edges
uf = array('i', range(blocks + 1)) # 已经缩成一团的块用并查集合并(每个块自成一类)
q = next(data)
answers = []
for _ in range(q):
a, b = next(data), next(data)
u, v = find(uf, block[a]), find(uf, block[b])
while u != v: # 只会往上并,最后一次询问前必然停止
if tdepth[u] < tdepth[v]:
u, v = v, u # 让 u 是更深的那一侧
up = tparent[u] # 深的一侧到父亲的桥必然落在路径上
uf[u] = find(uf, up)
u = uf[u]
remaining -= 1
answers.append(remaining)
out.append(f"Case {case_no}:")
out += map(str, answers)
out.append("")
sys.stdout.write("\n".join(out))
if __name__ == "__main__":
solve()复杂度
| 阶段 | 复杂度 |
|---|---|
| Tarjan 求初始桥 | |
| 缩点、建桥树、求父与深度 | |
| 总计 |
空间
实现上有三处值得注意:
- 无向边存成配对弧
与 ,Tarjan 里用 e == (进入弧 ^ 1)跳过「沿原路边返回父亲」; - 桥的两条配对弧都要打标记,缩点时若只标一条,配对弧会被误当成非桥边而错合并分量;
- 三个 DFS(Tarjan、标分量、建树)全部写成迭代,
也不会爆栈。
总结
本题的核心不是 Tarjan,而是把「加边」翻译成树上操作:先在初始图上求一次桥并缩成桥树,
之后每次加边都等价于在桥树上删掉一条路径上的边。因为加边只减不增,桥树的形状永远不变,
父节点和深度一次预处理即可永久使用;再用并查集把「已经删掉的边」合并到父亲,
使得
图示解析
下面这张图串起从建模到输出的全部环节:
输入: N 点 M 边无向连通图 + Q 次加边
`- 观察: 加边只制造环 => 桥只减不增
`- 关键: 新边 (a,b) 只消灭 "所在环上" 的桥
`- 结构: 把非桥边缩成块(边双连通分量) => 桥树(一棵树)
|- 路径之外无影响
`- "消灭 (a,b) 所在环的桥" ⇔ "删桥树上 id(a) -> id(b) 的路径边"
`- 维护: 并查集把"已删父边的点"并入父亲
|- u != v 时取更深一侧上跳, remaining -= 1
`- 每点至多被合并一次 => 总跳数 <= N-1
`- 答案 = remaining (当前桥树剩余边数)顺着读:无向图上的桥问题先被化简成桥树上的删边问题;桥树不变的性质让父节点与深度可以 只算一次;并查集沿用「已缩点集合」的经典技巧,把路径删边摊还到线性。 最后剩下的边数就是答案,全程不需要任何动态树结构。