Day 028-Q1 — 永続セグメント木(Persistent Segment Tree)

2026-05-11 赤色 Master / Phase 8+ ★★★★★★★★★ 永続セグメント木

問題

整数列 $A = (A_1, A_2, \ldots, A_N)$ が与えられる。以下の $Q$ クエリに答えよ。

  • クエリ型1: 1 t i x — 時刻 $t$ の状態の配列において $A_i$ を $x$ に変更した新しいバージョンを作る(元のバージョンは保持)
  • クエリ型2: 2 t l r — 時刻 $t$ の状態の配列において $A_l + \cdots + A_r$ の和を出力する

入力形式

N Q
A_1 A_2 ... A_N
クエリ形式

制約

$1 \leq N \leq 2 \times 10^5$
$1 \leq Q \leq 2 \times 10^5$
$1 \leq A_i, x \leq 10^9$
$1 \leq l \leq r \leq N$

入出力例

入力例 1

5 5
1 2 3 4 5
1 0 3 10
2 0 1 5
2 1 1 5
1 1 1 7
2 3 1 5

出力例 1

15
22
29

バージョン0: [1,2,3,4,5]、バージョン1: [1,2,10,4,5](A[3]←10)

ヒント (段階的開示)

ヒント1: 方向性
永続データ構造の本質は「変更されないノードは共有、変更されたパス上のノードだけ新規作成」。セグメント木の場合、1回の点更新で更新されるのは $O(\log N)$ ノードだけ。
ヒント2: アプローチ
各バージョンは「ルートノードへのポインタ」で識別。更新時は変更パス上のノードを新規作成し、変更されないサブツリーは古いノードをそのまま共有。動的にノードを割り当て、left[node], right[node], sum[node] を配列で管理。
ヒント3: 誘導
MAX_NODES = (N + Q) * 40
left = [0] * MAX_NODES
right = [0] * MAX_NODES
total = [0] * MAX_NODES
node_cnt = [1]  # 0はNULLノード

def update(prev, l, r, pos, val):
    node = new_node()
    if l == r:
        total[node] = val
        return node
    mid = (l + r) // 2
    if pos <= mid:
        left[node] = update(left[prev], l, mid, pos, val)
        right[node] = right[prev]  # 共有
    else:
        left[node] = left[prev]   # 共有
        right[node] = update(right[prev], mid+1, r, pos, val)
    total[node] = total[left[node]] + total[right[node]]
    return node

模範解答 (Python)

import sys
input = sys.stdin.readline

def solve():
    N, Q = map(int, input().split())
    A = list(map(int, input().split()))
    A = [0] + A  # 1-indexed

    MAX_NODES = (N + Q) * 40
    lc = [0] * MAX_NODES   # left child
    rc = [0] * MAX_NODES   # right child
    sm = [0] * MAX_NODES   # sum
    cnt = [1]              # 0 = null node

    def new_node():
        n = cnt[0]
        cnt[0] += 1
        return n

    def build(l, r):
        nd = new_node()
        if l == r:
            sm[nd] = A[l]
            return nd
        mid = (l + r) // 2
        lc[nd] = build(l, mid)
        rc[nd] = build(mid + 1, r)
        sm[nd] = sm[lc[nd]] + sm[rc[nd]]
        return nd

    def update(prev, l, r, pos, val):
        nd = new_node()
        if l == r:
            sm[nd] = val
            return nd
        mid = (l + r) // 2
        if pos <= mid:
            lc[nd] = update(lc[prev], l, mid, pos, val)
            rc[nd] = rc[prev]
        else:
            lc[nd] = lc[prev]
            rc[nd] = update(rc[prev], mid + 1, r, pos, val)
        sm[nd] = sm[lc[nd]] + sm[rc[nd]]
        return nd

    def query(nd, l, r, ql, qr):
        if nd == 0:
            return 0
        if ql <= l and r <= qr:
            return sm[nd]
        mid = (l + r) // 2
        res = 0
        if ql <= mid:
            res += query(lc[nd], l, mid, ql, qr)
        if qr > mid:
            res += query(rc[nd], mid + 1, r, ql, qr)
        return res

    roots = [0] * (Q + 2)
    roots[0] = build(1, N)
    ver_cnt = 0

    results = []
    for _ in range(Q):
        line = list(map(int, input().split()))
        if line[0] == 1:
            _, t, i, x = line
            ver_cnt += 1
            roots[ver_cnt] = update(roots[t], 1, N, i, x)
        else:
            _, t, l, r = line
            results.append(query(roots[t], 1, N, l, r))

    print('\n'.join(map(str, results)))

solve()

Step-by-Step 解説

1永続データ構造の概念
通常のセグメント木は1つの状態しか保持できない。永続セグメント木は「全バージョンの状態」を $O(N + Q \log N)$ メモリで保持。鍵はパス圧縮による共有
2ノードプールの管理
動的割り当てをシミュレートするため、大きな配列を確保し cnt カウンタでノード発行。ノード0は NULL として予約。MAX_NODES = (N + Q) * 40 で余裕を持つ。
3build(初期構築)
通常のセグメント木と同じ再帰構造。違いは動的ノード割り当てのみ。$O(N)$ 時間・空間。
4update(永続的点更新)
変更パス上の $O(\log N)$ ノードを新規作成し、他はすべて旧ノードを共有。
5query(バージョン指定区間和)
ルートノードをバージョンごとの roots[t] で切り替えるだけで、指定バージョンの状態での区間和が得られる。
6バージョン管理
roots[] 配列でバージョン番号 → ルートノードIDを管理。型1で新バージョン発行、型2でバージョン指定。

よくあるミス

ミス原因正しい書き方
MAX_NODES が小さすぎる$O(N + Q \log N)$ の見積もり不足(N + Q) * 40 など余裕を持つ
ノード0をNULLとして未予約cnt = 0 から始めてNULL判定が壊れるcnt = [1] で0番をNULL予約
バージョン番号のオフセットミス型1と型2の番号体系の混乱入力の $t$ をそのままインデックスに使う
再帰深さ超過$N = 2 \times 10^5$ で深さ $\approx 18$ 段Pythonでは sys.setrecursionlimit(10**6)
区間が1始まりと0始まりの混在A = [0] + A を忘れる1-indexedに統一して扱う

次のステップ

  • 発展問題1: 永続セグメント木を用いた静的区間 k 番目(マージソート木の代替)
  • 発展問題2: 永続 Union-Find(Union 操作を永続化、Find 操作に path compression を使わない実装)
  • 発展問題3: 部分的永続・完全永続の概念の違いと実装コスト比較

自己評価

自分の回答

気づき・メモ