問題
$N$ 頂点のグラフに対して、$Q$ 個のクエリが与えられる。各クエリは以下のいずれか:
add i: 辺 $e_i$ をグラフに追加するremove i: 辺 $e_i$ をグラフから削除する(必ず以前に追加済み)query u v: 頂点 $u$ と頂点 $v$ が連結かどうか答える
辺の存在期間(追加〜削除)を時刻軸の区間として管理し、各 query クエリに O(log Q) で答えよ。
制約
$2 \le N \le 3 \times 10^4$
$1 \le M \le 10^5$(辺の総種類数)
$1 \le Q \le 10^5$(クエリ総数)
自己ループ・多重辺なし
時間制限: 3秒
入出力例
入力例 1
5 3 7
1 2
2 3
3 4
add 0
add 1
query 1 3
remove 0
add 2
query 1 4
query 2 4
出力例 1
Yes
Yes
No
概念図: 時刻軸セグメント木と辺の配置
ヒント(段階的開示)
ヒント1: 方向性
「辺の存在期間」を時刻軸上の区間として捉えましょう。各辺が存在する時刻区間 $[l, r)$ を、時刻軸上のセグメント木の対応ノードに登録します。これがオフライン動的連結性の核心です。
ヒント2: アプローチ
- 時刻軸セグメント木(サイズ Q)を構築し、辺の存在区間を O(log Q) ノードに分配
- セグメント木を再帰的にDFSし、各ノードで辺を Union-Find でマージ
- リーフ(各クエリ時刻)で query 操作に回答
- DFS の戻りがけに Union を Undo(ランクベース Union のみ使用)
- Path compression は Undo 不可能なので禁止
ヒント3: Undo DSU の実装骨格
class UndoDSU:
def __init__(self, n):
self.parent = list(range(n+1))
self.rank = [0] * (n+1)
self.history = []
def find(self, x): # path compression 禁止
while self.parent[x] != x:
x = self.parent[x]
return x
def union(self, u, v):
u, v = self.find(u), self.find(v)
if u == v:
self.history.append(None)
return
if self.rank[u] < self.rank[v]:
u, v = v, u
self.history.append((u, self.rank[u], v, self.parent[v]))
self.parent[v] = u
if self.rank[u] == self.rank[v]:
self.rank[u] += 1
def undo(self):
op = self.history.pop()
if op is None: return
u, ru, v, pv = op
self.parent[v] = pv
self.rank[u] = ru
模範解答 (Python)
import sys
from sys import stdin
def solve():
data = stdin.read().split()
idx = 0
N, M, Q = int(data[idx]), int(data[idx+1]), int(data[idx+2]); idx += 3
edges = []
for _ in range(M):
u, v = int(data[idx]), int(data[idx+1]); idx += 2
edges.append((u, v))
queries = []
for _ in range(Q):
line = []
line.append(data[idx]); idx += 1
if line[0] in ('add', 'remove'):
line.append(int(data[idx])); idx += 1
else:
line.append(int(data[idx])); idx += 1
line.append(int(data[idx])); idx += 1
queries.append(line)
seg_edges = [[] for _ in range(4 * Q + 4)]
def seg_add(node, l, r, ql, qr, edge_id):
if ql > r or qr < l or ql > qr:
return
if ql <= l and r <= qr:
seg_edges[node].append(edge_id)
return
mid = (l + r) >> 1
seg_add(2*node, l, mid, ql, qr, edge_id)
seg_add(2*node+1, mid+1, r, ql, qr, edge_id)
edge_start = {}
query_ptr = {}
time = 0
for q in queries:
if q[0] == 'add':
edge_start[q[1]] = time
elif q[0] == 'remove':
eid = q[1]
start = edge_start.pop(eid)
seg_add(1, 0, Q-1, start, time-1, eid)
else:
query_ptr[time] = (q[1], q[2])
time += 1
for eid, start in edge_start.items():
seg_add(1, 0, Q-1, start, Q-1, eid)
class UndoDSU:
def __init__(self, n):
self.parent = list(range(n+1))
self.rank = [0] * (n+1)
self.history = []
def find(self, x):
while self.parent[x] != x:
x = self.parent[x]
return x
def union(self, u, v):
u, v = self.find(u), self.find(v)
if u == v:
self.history.append(None)
return
if self.rank[u] < self.rank[v]:
u, v = v, u
self.history.append((u, self.rank[u], v, self.parent[v]))
self.parent[v] = u
if self.rank[u] == self.rank[v]:
self.rank[u] += 1
def undo(self):
op = self.history.pop()
if op is None: return
u, ru, v, pv = op
self.parent[v] = pv
self.rank[u] = ru
def connected(self, u, v):
return self.find(u) == self.find(v)
dsu = UndoDSU(N)
answers = {}
def dfs(node, l, r):
cnt = len(seg_edges[node])
for eid in seg_edges[node]:
u, v = edges[eid]
dsu.union(u, v)
if l == r:
if l in query_ptr:
u, v = query_ptr[l]
answers[l] = 'Yes' if dsu.connected(u, v) else 'No'
else:
mid = (l + r) >> 1
dfs(2*node, l, mid)
dfs(2*node+1, mid+1, r)
for _ in range(cnt):
dsu.undo()
if Q > 0:
sys.setrecursionlimit(300000)
dfs(1, 0, Q-1)
for t, _, _ in [(t, u, v) for t, (u, v) in sorted(query_ptr.items())]:
print(answers.get(t, 'No'))
solve()
Step-by-Step 解説
1辺の存在区間の計算
各辺の
各辺の
add 時刻を記録し、remove 時刻が来たら区間 [add_time, remove_time - 1] を確定。削除されなかった辺は [add_time, Q-1]。
2セグメント木への辺の分配
時刻軸セグメント木の区間更新と同様に、各辺の存在区間を O(log Q) 個のノードに分配する。各ノードに辺リストを持たせる。
時刻軸セグメント木の区間更新と同様に、各辺の存在区間を O(log Q) 個のノードに分配する。各ノードに辺リストを持たせる。
3DFS + Union
セグメント木をDFSし、各ノードで対応する辺を Union。リーフで
セグメント木をDFSし、各ノードで対応する辺を Union。リーフで
query に回答。
4Undo によるロールバック
DFS の戻りがけに Union 操作を Undo(スタックから取り出してロールバック)。ランクベース Union のみ使用し、路径圧縮は禁止。
DFS の戻りがけに Union 操作を Undo(スタックから取り出してロールバック)。ランクベース Union のみ使用し、路径圧縮は禁止。
5計算量の確認
各辺は O(log Q) ノードに分配。各ノードでの Union/Undo は O(log N)。全体 $O((M + Q) \log Q \cdot \log N)$。
各辺は O(log Q) ノードに分配。各ノードでの Union/Undo は O(log N)。全体 $O((M + Q) \log Q \cdot \log N)$。
計算量
前処理(辺の区間分配): $O(M \log Q)$
DFS(Union + Undo): $O((M + Q) \log Q \cdot \log N)$
全体: $O((M + Q) \log Q \log N)$
空間: $O(N + (M + Q) \log Q)$
DFS(Union + Undo): $O((M + Q) \log Q \cdot \log N)$
全体: $O((M + Q) \log Q \log N)$
空間: $O(N + (M + Q) \log Q)$
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| path compression を使う | Undo が不可能になる | ランクベース Union のみ使用 |
| 残存辺を登録し忘れる | 削除されなかった辺が無視される | ループ後に残存辺を [start, Q-1] で登録 |
| undo の回数がずれる | None をスタックに積み忘れ | 同一成分の union でも history に None を push |
| 再帰上限に達する | Q=10^5 でセグメント木深さ約17 | sys.setrecursionlimit を十分大きく設定 |
次のステップ
- 発展問題: Link-Cut Tree を使ったオンライン動的連結性(辺追加・削除・連結判定をオンラインで O(log N))
- 関連: Day029 Q1(Offline LCT の辺削除版)の復習
- 応用: 動的グラフのコンポーネント数維持、時刻付きクエリ処理