Day 157-Q3 — 分岐永続化Union-Find(Branching Persistent DSU)

2026-09-18 赤色 Master / Phase 8+ ★★★★★★★★★ 不変二分木の永続配列 + Union by Size

問題

$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から分岐する版数の木

同じv0から2方向に分岐し、さらにv3から2方向目の分岐も起きる v0 全員孤立・不変 v1 union(0,1) v2 union(2,3) v3 union(1,2) v4 union(1,2) v3から v5=union(4,5) がさらに分岐

ヒント(段階的開示)

ヒント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)$ に抑えられる。
2永続配列(不変二分木)の仕組み
添字空間に静的な二分木を1本構築し、1点更新は根から葉までの経路 $O(\log N)$ 個だけ新規作成、それ以外は古い木と共有する。
3分岐が自然に実現される理由
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の検討

自己評価

自分の回答

気づき・メモ