問題
$N$ 頂点のグラフに $Q$ 個のクエリが与えられる。各クエリは:
1 u v: 辺 $(u, v)$ を追加2 u v: 辺 $(u, v)$ を削除(必ず存在する)3 u v: 頂点 $u, v$ が連結かどうか答える
辺の追加・削除を含む動的グラフの連結性クエリをオフラインで処理せよ。
制約
| パラメータ | 範囲 |
|---|---|
| $N$ | $2 \le N \le 10^5$ |
| $Q$ | $1 \le Q \le 2 \times 10^5$ |
| 辺の存在保証 | 削除クエリは必ず現在グラフに存在する辺に対して行う |
入出力例
入力例 1
4 7
1 1 2
1 2 3
3 1 3
2 1 2
3 1 3
1 1 4
3 1 4
出力例 1
Yes
No
Yes
概念図: 時刻軸セグメント木 + Undo DSU
ヒント(段階的開示)
ヒント1: 方向性
各辺の「存在する時間区間 $[t_{add}, t_{del})$」を求め、時刻軸のセグメント木の各ノードに辺を割り当てる。セグメント木を DFS しながら Undo 可能な DSU(Union by rank、パス圧縮なし)で union/undo を行う。計算量 $O(Q \log^2 Q)$。
ヒント2: アプローチ
- 各辺の
(追加時刻, 削除時刻)を求める(存在区間) - セグメント木(時刻軸 $[0, Q)$)の各ノードに区間をカバーするように辺を追加($O(\log Q)$ ノード)
- セグメント木を DFS。ノードに入るとき辺を union(Undo DSU)、抜けるとき rollback
- 葉ノードに対応する時刻がクエリ3なら、連結性を判定して出力
ヒント3: コード骨格
# Undo DSU: Union by rank, NO path compression
class UndoDSU:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
self.history = []
def find(self, x):
while self.parent[x] != x: x = self.parent[x]
return x
def union(self, x, y):
x, y = self.find(x), self.find(y)
if x == y: self.history.append(None); return
if self.rank[x] < self.rank[y]: x, y = y, x
self.history.append((x, y, self.rank[x]))
self.parent[y] = x
if self.rank[x] == self.rank[y]: self.rank[x] += 1
def rollback(self):
op = self.history.pop()
if op is None: return
x, y, rk = op
self.parent[y] = y; self.rank[x] = rk
# DFS on segment tree
def dfs(node, l, r):
for u, v in seg[node]: dsu.union(u, v)
if r - l == 1:
if queries[l][0] == 3: # answer query
...
else:
mid = (l + r) // 2
dfs(2*node, l, mid); dfs(2*node+1, mid, r)
for _ in seg[node]: dsu.rollback()
模範解答 (Python)
import sys
from collections import defaultdict
input = sys.stdin.readline
def solve():
N, Q = map(int, input().split())
queries = []
for i in range(Q):
queries.append(list(map(int, input().split())))
edge_intervals = []
active = defaultdict(list)
for t, q in enumerate(queries):
if q[0] == 1:
e = (min(q[1], q[2]), max(q[1], q[2]))
active[e].append(t)
elif q[0] == 2:
e = (min(q[1], q[2]), max(q[1], q[2]))
add_t = active[e].pop()
edge_intervals.append((e[0]-1, e[1]-1, add_t, t))
for e, times in active.items():
for add_t in times:
edge_intervals.append((e[0]-1, e[1]-1, add_t, Q))
SIZE = 1
while SIZE < Q: SIZE <<= 1
seg = [[] for _ in range(2 * SIZE)]
def seg_add(l, r, val):
l += SIZE; r += SIZE
while l < r:
if l & 1: seg[l].append(val); l += 1
if r & 1: r -= 1; seg[r].append(val)
l >>= 1; r >>= 1
for u, v, l, r in edge_intervals:
if l < r: seg_add(l, r, (u, v))
parent = list(range(N))
rank = [0] * N
history = []
def find(x):
while parent[x] != x: x = parent[x]
return x
def union(x, y):
x, y = find(x), find(y)
if x == y: history.append(None); return
if rank[x] < rank[y]: x, y = y, x
history.append((x, y, rank[x]))
parent[y] = x
if rank[x] == rank[y]: rank[x] += 1
def rollback():
op = history.pop()
if op is None: return
x, y, rk = op
parent[y] = y; rank[x] = rk
answers = []
def dfs(node, l, r):
for u, v in seg[node]: union(u, v)
if r - l == 1:
if l < Q and queries[l][0] == 3:
u, v = queries[l][1]-1, queries[l][2]-1
answers.append((l, "Yes" if find(u) == find(v) else "No"))
else:
mid = (l + r) // 2
dfs(2*node, l, mid); dfs(2*node+1, mid, r)
for _ in seg[node]: rollback()
sys.setrecursionlimit(600000)
dfs(1, 0, SIZE)
answers.sort()
sys.stdout.write('\n'.join(a for _, a in answers) + '\n')
solve()
Step-by-Step 解説
1辺の存在区間の計算
各辺の追加クエリ時刻と削除クエリ時刻を記録。辺の存在区間 $[t_{add}, t_{del})$ を求める。削除されなかった辺は $[t_{add}, Q)$。
各辺の追加クエリ時刻と削除クエリ時刻を記録。辺の存在区間 $[t_{add}, t_{del})$ を求める。削除されなかった辺は $[t_{add}, Q)$。
2セグメント木への辺の登録
時刻軸 $[0, Q)$ のセグメント木(2倍展開、配列ベース)。辺の存在区間を $O(\log Q)$ 個のノードに分解して登録(通常のセグメント木の区間更新と同じ手順)。
時刻軸 $[0, Q)$ のセグメント木(2倍展開、配列ベース)。辺の存在区間を $O(\log Q)$ 個のノードに分解して登録(通常のセグメント木の区間更新と同じ手順)。
3DFS + Undo DSU
セグメント木を DFS。ノードに入るとき全辺を union。葉でクエリ3に回答。ノードを抜けるとき同数の rollback。rollback は history スタックで管理。
セグメント木を DFS。ノードに入るとき全辺を union。葉でクエリ3に回答。ノードを抜けるとき同数の rollback。rollback は history スタックで管理。
4Undo DSU の正確性
Path compression を使わず Union by rank のみ。
Path compression を使わず Union by rank のみ。
find が $O(\log N)$ になるが、rollback が可能。全体の計算量は $O(Q \log^2 Q)$。
計算量
辺区間計算: $O(Q)$
セグメント木登録: $O(Q \log Q)$(各辺を $O(\log Q)$ ノードに登録)
DFS + DSU: $O(Q \log Q \cdot \log N)$ = $O(Q \log^2 Q)$($N \le Q$ の場合)
全体: $O(Q \log^2 Q)$
セグメント木登録: $O(Q \log Q)$(各辺を $O(\log Q)$ ノードに登録)
DFS + DSU: $O(Q \log Q \cdot \log N)$ = $O(Q \log^2 Q)$($N \le Q$ の場合)
全体: $O(Q \log^2 Q)$
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| Path compression を使用 | undo が不可能になる | Union by rank のみ(compress なし) |
| rollback 回数の不一致 | union で変化なしでも history に push が必要 | union 変化なしのとき history.append(None) |
| セグメント木のサイズ不足 | SIZE < Q の場合 | while SIZE < Q: SIZE <<= 1 |
| 再帰深さ超過 | セグメント木 DFS の最大深さが $\log Q \approx 18$ | sys.setrecursionlimit(600000) |
次のステップ
- 発展問題: 辺の追加・削除 + 連結成分のサイズ最大値クエリ(DSU に size を持たせる)
- 関連: オンライン動的連結性(Link-Cut Tree で解くアプローチとの比較)
- 応用: オフライン動的最小全域木(辺削除後の MST 維持)