Day 044-Q2 — 永続Union-Find(Persistent DSU)

2026-05-28 赤色 Master / Phase 8+ ★★★★★★★★★ 永続配列 + union by rank

問題

$N$ 頂点の素集合データ構造に対し $Q$ 個のクエリをオンラインで処理せよ。過去の任意のバージョンを参照できる完全永続版である。

種別形式意味
併合1 v a bバージョン $v$ から $a,b$ を併合し新バージョンを作る
判定2 v a bバージョン $v$ で $a,b$ が同集合か(1/0)

バージョン0は初期状態(全孤立)。$i$ 番目の更新クエリで作られるバージョンは $i$ 番。

制約

$1 \le N \le 2 \times 10^5$
$1 \le Q \le 2 \times 10^5$
$0 \le v \le$ 既存バージョン番号
$1 \le a, b \le N$

入出力例

入力例 1

4 5
1 0 1 2
1 1 3 4
2 1 1 2
2 1 3 4
2 2 3 4

出力例 1

1
0
1

概念図: 永続配列(Copy-on-Write 完全二分木)

parent[] の1点更新 = 根→葉のパスのみ新規生成、他は共有 root v0 root v1(新) 共有L 新R 葉0 葉1 葉2(旧) 葉2'(新) 経路圧縮を使わず union by rank のみ → 木の高さ O(log N)、find も O(log N)、合計 O(log² N)/クエリ

ヒント(段階的開示)

ヒント1: 方向性
経路圧縮は parent を破壊的に書き換えるため永続化と両立しない。union by rank のみで木の高さを $O(\log N)$ に保証し、配列を永続配列(完全二分木)で表現する。
ヒント2: アプローチ
  • parent[]rank[] を永続配列で管理
  • find: 圧縮せず根まで辿る($O(\log N)$ 段 × アクセス $O(\log N)$)
  • union: 2根の rank 比較、永続配列の1点更新で新バージョン生成
各クエリ $O(\log^2 N)$。
ヒント3: 実装骨格
# 永続配列: 完全二分木、1点更新は根→葉パスのみコピー
def update(node, pos, v, lo, hi): ...  # 新ノードid返す
def get(node, pos, lo, hi): ...

# union by rank
ra, rb = find(pr, a), find(pr, b)
if rank[ra] < rank[rb]: ra, rb = rb, ra
npr = update(pr, rb, ra)   # rb の親を ra に

模範解答 (Python)

import sys

def solve():
    data = sys.stdin.buffer.read().split()
    idx = 0
    N = int(data[idx]); idx += 1
    Q = int(data[idx]); idx += 1

    size = 1
    while size < N:
        size <<= 1

    lc = [0]; rc = [0]; val = [0]  # node 0 = dummy

    def build(arr):
        def rec(lo, hi):
            if hi - lo == 1:
                val.append(arr[lo] if lo < len(arr) else 0)
                lc.append(0); rc.append(0)
                return len(val) - 1
            mid = (lo + hi) >> 1
            l = rec(lo, mid); r = rec(mid, hi)
            val.append(0); lc.append(l); rc.append(r)
            return len(val) - 1
        return rec(0, size)

    def get(node, pos, lo, hi):
        while hi - lo > 1:
            mid = (lo + hi) >> 1
            if pos < mid:
                node = lc[node]; hi = mid
            else:
                node = rc[node]; lo = mid
        return val[node]

    def update(node, pos, v, lo, hi):
        if hi - lo == 1:
            val.append(v); lc.append(0); rc.append(0)
            return len(val) - 1
        mid = (lo + hi) >> 1
        if pos < mid:
            nl = update(lc[node], pos, v, lo, mid)
            val.append(0); lc.append(nl); rc.append(rc[node])
        else:
            nr = update(rc[node], pos, v, mid, hi)
            val.append(0); lc.append(lc[node]); rc.append(nr)
        return len(val) - 1

    par_roots = [build(list(range(size)))]
    rnk_roots = [build([0] * size)]

    def find(pr, x):
        while True:
            p = get(pr, x, 0, size)
            if p == x:
                return x
            x = p

    out = []
    for _ in range(Q):
        t = int(data[idx]); v = int(data[idx+1])
        a = int(data[idx+2]) - 1; b = int(data[idx+3]) - 1
        idx += 4
        pr = par_roots[v]; rr = rnk_roots[v]
        if t == 1:
            ra = find(pr, a); rb = find(pr, b)
            if ra == rb:
                par_roots.append(pr); rnk_roots.append(rr)
            else:
                rka = get(rr, ra, 0, size); rkb = get(rr, rb, 0, size)
                if rka < rkb:
                    ra, rb = rb, ra; rka, rkb = rkb, rka
                npr = update(pr, rb, ra, 0, size)
                nrr = update(rr, ra, rka + 1, 0, size) if rka == rkb else rr
                par_roots.append(npr); rnk_roots.append(nrr)
        else:
            out.append('1' if find(pr, a) == find(pr, b) else '0')

    sys.stdout.write('\n'.join(out) + ('\n' if out else ''))

solve()

Step-by-Step 解説

1なぜ経路圧縮を使えないか
圧縮は find のたびに parent を破壊的更新し過去バージョンを壊す。永続化と両立しないため union by rank のみで高さ $O(\log N)$ を保証。
2永続配列
配列を完全二分木で表現。1点更新は根→葉のパス $O(\log N)$ ノードだけ新規コピー、他は旧ノード共有。
3find
圧縮なしで根まで辿る。rank併合で高さ $O(\log N)$、各 get が $O(\log N)$ なので find は $O(\log^2 N)$。
4union
2根の rank を比較、小さい木を大きい木にぶら下げる。parent(+同rankなら rank)の1点更新で新バージョン。
5クエリ処理
更新は新バージョンを push、判定は参照のみ。全体 $O(Q \log^2 N)$。

計算量

find: $O(\log^2 N)$(高さ $\log N$ × アクセス $\log N$)
union: $O(\log^2 N)$
全体: $O(Q \log^2 N)$
空間: $O((N + Q)\log N)$(差分ノードのみ)

よくあるミス

ミス原因正しい書き方
経路圧縮を使う過去バージョン破壊rank/size併合のみ
同集合unionで新バージョン未発行バージョン番号ずれ変化なしでも根を複製push
再帰深さ超過size=2^18 で再帰get は反復化 / setrecursionlimit
rank更新条件ミス等rank時のみ+1if rka == rkb: rank++

次のステップ

  • 発展問題: 永続Union-Findを用いた並列二分探索・オフライン動的MST
  • 類題: Persistent Segment Tree、関数型データ構造
  • 応用: バージョン管理、undo可能なグラフ連結性

自己評価