問題
$N$ 頂点の Union-Find があり、バージョン $0$ は全頂点が孤立している。以下の $Q$ 個の操作を処理する。
union b x y: バージョン $b$ を元に $x,y$ を併合した新しいバージョンを作る。バージョン $b$ 自身はこの後も不変query v x y: バージョン $v$ で $x,y$ が同じ連結成分か判定
union の b はどのバージョンからでも自由に選べるため、履歴は一直線ではなく分岐する版数の木になる。
入力形式
N Q
op_1
...
op_Q
制約
$1 \le N \le 2\times10^4$
$1 \le Q \le 2\times10^4$
b/vは既存の版数入出力例
入力例1
8 8
union 0 0 1
union 0 2 3
union 1 1 2
union 2 1 2
query 1 0 1
query 1 2 3
query 3 0 2
query 4 1 3出力例1
Yes
No
Yes
Yes概念図:v0から分岐する版数の木
ヒント(段階的開示)
ヒント1(方向性)
通常のUnion-Findは「今の状態」しか持たない。過去のどのバージョンにも自由に戻って分岐できるようにするには、状態を「書き換えず、新しい版を作る」イミュータブルな発想に切り替える。
ヒント2(アプローチ)
経路圧縮を使うと過去のバージョンの整合性が壊れる。union by size のみ(経路圧縮なし)にすれば、1回のunionが変更するのは配列の高々2箇所だけで済み、木の高さも$O(\log N)$に保たれる。
ヒント3(誘導)
# 添字0..N-1上の不変な二分木。1点更新は根から葉までの経路だけ新規作成
def set_(root, lo, hi, i, new_val):
if lo == hi:
return Node(val=new_val)
mid = (lo + hi) // 2
if i <= mid:
return Node(left=set_(root.left, lo, mid, i, new_val), right=root.right)
else:
return Node(left=root.left, right=set_(root.right, mid+1, hi, i, new_val))
模範解答 (Python)
import sys
class PersistentArray:
class Node:
__slots__ = ("val", "left", "right")
def __init__(self, val=None, left=None, right=None):
self.val, self.left, self.right = val, left, right
def __init__(self, init_values):
self.n = len(init_values)
self.root = self._build(0, self.n - 1, init_values)
def _build(self, lo, hi, vals):
if lo == hi:
return self.Node(val=vals[lo])
mid = (lo + hi) // 2
return self.Node(left=self._build(lo, mid, vals),
right=self._build(mid + 1, hi, vals))
def get(self, root, i):
lo, hi, node = 0, self.n - 1, root
while lo != hi:
mid = (lo + hi) // 2
if i <= mid:
node, hi = node.left, mid
else:
node, lo = node.right, mid + 1
return node.val
def set(self, root, i, new_val):
def rec(node, lo, hi):
if lo == hi:
return self.Node(val=new_val)
mid = (lo + hi) // 2
if i <= mid:
return self.Node(left=rec(node.left, lo, mid), right=node.right)
return self.Node(left=node.left, right=rec(node.right, mid + 1, hi))
return rec(root, 0, self.n - 1)
def solve():
data = sys.stdin.read().split()
idx = 0
n = int(data[idx]); idx += 1
q = int(data[idx]); idx += 1
parent_arr = PersistentArray(list(range(n)))
size_arr = PersistentArray([1] * n)
versions = {0: (parent_arr.root, size_arr.root)}
next_id = 1
def find(version, x):
proot, _ = versions[version]
while True:
p = parent_arr.get(proot, x)
if p == x:
return x
x = p
out = []
for _ in range(q):
cmd = data[idx]; idx += 1
if cmd == 'union':
b = int(data[idx]); x = int(data[idx+1]); y = int(data[idx+2]); idx += 3
proot, sroot = versions[b]
rx, ry = find(b, x), find(b, y)
if rx == ry:
versions[next_id] = (proot, sroot)
else:
sx, sy = size_arr.get(sroot, rx), size_arr.get(sroot, ry)
if sx < sy:
rx, ry = ry, rx
sx, sy = sy, sx
proot2 = parent_arr.set(proot, ry, rx)
sroot2 = size_arr.set(sroot, rx, sx + sy)
versions[next_id] = (proot2, sroot2)
next_id += 1
else:
v = int(data[idx]); x = int(data[idx+1]); y = int(data[idx+2]); idx += 3
out.append("Yes" if find(v, x) == find(v, y) else "No")
print('\n'.join(out))
solve()
Step-by-Step 解説
1経路圧縮を諦める理由
経路圧縮は「find のついでに親を書き換える」最適化だが、これは永続化の前提と矛盾する。union by size だけでも木の高さは $O(\log N)$ に抑えられる。
経路圧縮は「find のついでに親を書き換える」最適化だが、これは永続化の前提と矛盾する。union by size だけでも木の高さは $O(\log N)$ に抑えられる。
2永続配列(不変二分木)の仕組み
添字空間に静的な二分木を1本構築し、1点更新は根から葉までの経路 $O(\log N)$ 個だけ新規作成、それ以外は古い木と共有する。
添字空間に静的な二分木を1本構築し、1点更新は根から葉までの経路 $O(\log N)$ 個だけ新規作成、それ以外は古い木と共有する。
3分岐が自然に実現される理由
union操作は渡された根を一切書き換えない(
union操作は渡された根を一切書き換えない(
setは常に新しいノードを返す)。同じバージョンから2回unionすれば2つの独立な子バージョンが生える——これが時刻に沿った一本道のundoログとは異なる真の分岐永続化。4実測(本ページ作成時にPythonで実行)
| テスト内容 | 結果 |
|---|---|
| 8頂点・5回のunion(分岐デモ) | v1〜v5が独立に異なる分割を持ち、v0は不変であることを実測確認 |
| 5バージョン×64頂点対 | from-scratch再現との不一致 0件 |
| ランダム60件(n≤12・25回union) | from-scratch再現との不一致 0件 |
迷った他案と採らなかった理由
| 案 | 採否 | 理由 |
|---|---|---|
| 経路圧縮つきUnion-Find + 時刻に対する二分探索(Day105方式) | 不採用(今回は別角度) | 線形の一本道の履歴しか扱えず、本問が要求する「同じ版から複数方向へ分岐」を表現できない |
| 不変二分木による永続配列(本ページ採用) | 採用 | 根への参照を持ち回るだけで真の分岐が実現でき、実装も検証も見通しが良い |
| ランクによるunion(sizeでなくrank基準) | 不採用 | 高さの $O(\log N)$ 保証は得られるが、sizeの方が実装・直感が単純でありメリットが薄い |
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| findの途中で親配列を直接書き換えて経路圧縮 | 通常DSUの癖が抜けない | 永続化構造では絶対にin-place更新しない |
| union by sizeを怠る | 「動くから」と省略 | 木の高さ$O(\log N)$保証が崩れ最悪$O(N)$になる |
| バージョンを線形だと仮定する | 通常のundo/redoとの混同 | bは任意の既存バージョンを取りうる前提で実装する |
| 古いバージョンへのunionで古いバージョン自体が変わると誤解 | 永続化の直感不足 | union後もbase versionは不変であることをテストで確認する |
次のステップ
- 発展: バージョン木上のLCAで「2つのバージョン間で共通するunion操作の接頭辞」を高速特定
- 発展2: サイズでなくランクベースのunionにした場合の高さの違いを比較
- 扱わなかった範囲: 経路圧縮つき永続Union-Findのならし計算量
- 次回予告: 経路圧縮つき永続Union-Findの検討