問題
$N$ 頂点の動的フォレスト(初期は全て孤立頂点)に対し、以下の $Q$ クエリをオンラインで処理せよ。
L u v: 辺 $(u, v)$ を追加する(フォレスト性が保たれる)C u v: 辺 $(u, v)$ を削除する(この辺は存在する)D u: 頂点 $u$ の次数(現在接続されている辺の本数)を出力するE v: 頂点 $v$ が属する木の辺の本数を出力する
制約
| パラメータ | 範囲 |
|---|---|
| $N$ | $1 \le N \le 10^5$ |
| $Q$ | $1 \le Q \le 2 \times 10^5$ |
| 頂点番号 | 1-indexed |
| フォレスト性 | 全操作を通じて保証 |
入出力例
入力例 1
5 8
L 1 2
L 2 3
L 3 4
D 2
E 1
C 2 3
D 2
E 1
出力例 1
2
3
1
1
辺追加後: {1-2, 2-3, 3-4}。D 2=2(辺1-2, 2-3)。E 1=3辺。C 2-3 削除後: {1-2}と{3-4}。D 2=1。E 1=1辺。
概念図: Link-Cut Tree の仮想辺管理
ヒント(段階的開示)
ヒント1: 方向性
Link-Cut Tree (LCT) はフォレストの動的辺追加・削除・パスクエリを $O(\log N)$ amortized で処理できる。
「木のサイズ」は LCT のルートノードの
size から取得できる。
「次数」は別の配列で管理するのが簡単。
ヒント2: アプローチ
- 次数クエリ: 配列
deg[u]で管理。辺追加で +1、削除で -1 - 辺数クエリ: LCT の各ノードに
vsize(仮想辺の子サイズ合計)を持たせる。expose(v)後のsize[v]= 木の頂点数 → 辺数 = 頂点数 - 1 expose時に仮想辺から実辺に変える際vsizeを更新
ヒント3: コード骨格
def link(u, v):
expose(v); expose(u)
par[v] = u
vsize[u] += size[v]
upd(u)
def tree_size(v):
expose(v)
return size[v] # 頂点数(辺数 = これ - 1)
# クエリ E v の処理:
# out.append(str(tree_size(v) - 1))
模範解答 (Python)
import sys
input = sys.stdin.readline
def solve():
sys.setrecursionlimit(300000)
N, Q = map(int, input().split())
ch = [[0, 0] for _ in range(N+1)]
par = [0] * (N+1)
rev = [False] * (N+1)
sz = [1] * (N+1)
vsize = [0] * (N+1)
deg = [0] * (N+1)
def is_root(v):
p = par[v]
return p == 0 or (ch[p][0] != v and ch[p][1] != v)
def push(v):
if rev[v]:
l, r = ch[v]
ch[v][0], ch[v][1] = r, l
if l: rev[l] ^= True
if r: rev[r] ^= True
rev[v] = False
def upd(v):
l, r = ch[v]
sl = sz[l] if l else 0
sr = sz[r] if r else 0
sz[v] = sl + sr + vsize[v] + 1
def rotate(v):
p = par[v]; g = par[p]
d = 1 if ch[p][1] == v else 0
c = ch[v][1-d]
if not is_root(p):
if ch[g][0] == p: ch[g][0] = v
else: ch[g][1] = v
par[v] = g
ch[v][1-d] = p; par[p] = v
ch[p][d] = c
if c: par[c] = p
upd(p); upd(v)
def splay(v):
stack = [v]; u = v
while not is_root(u):
u = par[u]; stack.append(u)
while stack: push(stack.pop())
while not is_root(v):
p = par[v]
if not is_root(p):
g = par[p]
if (ch[g][0]==p) == (ch[p][0]==v): rotate(p)
else: rotate(v)
rotate(v)
def expose(v):
last = 0; u = v
while u:
splay(u)
c = ch[u][1]
if c: vsize[u] += sz[c]; ch[u][1] = 0; par[c] = u; upd(u)
if last: vsize[u] -= sz[last]; ch[u][1] = last; par[last] = u; upd(u)
last = u; u = par[u]
splay(v); return last
def link(u, v):
expose(v); expose(u)
par[v] = u; vsize[u] += sz[v]; upd(u)
def cut_edge(u, v):
expose(v); splay(u); expose(v)
ch[v][0] = 0; par[u] = 0; upd(v)
def tree_size(v):
expose(v); return sz[v]
out = []
for _ in range(Q):
line = input().split()
op = line[0]
if op == 'L':
u, v = int(line[1]), int(line[2])
link(u, v); deg[u] += 1; deg[v] += 1
elif op == 'C':
u, v = int(line[1]), int(line[2])
cut_edge(u, v); deg[u] -= 1; deg[v] -= 1
elif op == 'D':
out.append(str(deg[int(line[1])]))
elif op == 'E':
out.append(str(tree_size(int(line[1])) - 1))
print('\n'.join(out))
solve()
Step-by-Step 解説
Step 1: Link-Cut Tree の基本構造
各木を「補助木(splay tree)のチェーン」に分解。expose(v) は $v$ から根への経路を一本の補助木に整理し、パス上のクエリを $O(\log N)$ で処理可能にする。
Step 2: 仮想辺サイズ管理
各ノードの vsize に「仮想辺で繋がっている子のサイズ合計」を蓄積することで、tree_size(v) = expose(v) 後の size[v] が木全体の頂点数になる。辺数 = 頂点数 - 1。
Step 3: 次数管理
LCT の構造から次数を取り出すのは複雑なため、別の配列 deg[u] で管理し link/cut 時に増減させる。
Step 4: cut 操作
expose(v) で $v$ を根の状態にした後、v の左子が切断対象 $u$ になるよう splay し、子を切り離す。
計算量
| 操作 | 計算量 |
|---|---|
| Link / Cut | $O(\log N)$ amortized |
| 次数クエリ(D) | $O(1)$ |
| 辺数クエリ(E) | $O(\log N)$ amortized |
| 全体(Q クエリ) | $O(Q \log N)$ |
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
is_root の判定 | par[v] != 0 だけでは不十分 | ch[par[v]][0] != v and ch[par[v]][1] != v も確認 |
push の順序 | splay 前に上から下へ push が必要 | スタックを使って根から下方向に push |
vsize の整合性 | link/cut 時に vsize 更新忘れ | expose の各ステップで vsize を更新 |
| 木の辺数 | tree_size は頂点数 | tree_size(v) - 1 が辺数 |
次のステップ
- 発展: LCT + パス上の最小辺重み(動的最小全域木の管理)
- 類題: AIZU JUDGE 2667 "Tree"、Codeforces 117E
- 応用: LCT で木のパス上の XOR 和クエリ
自己評価
自分の回答:
気づき・メモ: