Day 158-Q5 — 削除できるナップサック(時間軸セグメント木によるオフライン化)

2026-09-23 赤色 Master / Phase 8+ ★★★★★★★★★ 「追加は簡単・削除は不可能」なデータ構造を、生存区間の分解で削除なしに変える

問題

最初は空の品物の集合がある。$Q$ 個の操作を順に処理せよ。

  • 1 w v : 重さ $w$・価値 $v$ の品物を追加する。追加された品物には、追加された順に $1, 2, 3, \dots$ と番号が付く
  • 2 i : 番号 $i$ の品物を取り除く(その時点で集合に入っていることが保証される)
  • 3 c : 現在の集合から、重さの合計が $c$ 以下になるように品物を選ぶ(各品物は高々1回)。価値の合計の最大値を出力する

入力形式

Q W
op_1
...
op_Q

制約

#条件
1$1 \le Q \le 5000$
2$1 \le W \le 1000$(すべての 3 c について $0 \le c \le W$)
3$1 \le w \le 1000$, $1 \le v \le 10^9$

入出力例

入力例1

9 7
1 3 4
1 4 5
3 7
2 1
3 7
1 2 3
3 6
2 2
3 5

出力例1

9
5
8
3

(品物1(3,4)・品物2(4,5)で容量7 → 9。品物1を消すと 5。品物3(2,3)を足して容量6 → 5+3=8。品物2を消すと品物3だけで 3)

概念図:時間軸セグメント木への区間分解

生存区間を時間軸セグメント木のノードに分解(入力例1、Q=9)t=0t=1t=2t=3t=4t=5t=6t=7t=8品物1 [0,3)node 8node 18品物2 [1,7)node 17node 9node 10node 22品物3 [5,9)node 21node 11node 24?7→9?7→5?6→8?5→3葉 t への根からの経路上のノードに載った品物 = 時刻 t に生きている品物

ヒント(クリックで段階的に開示)

ヒント1(方向性)

ナップサック DP は品物を 追加 するのは $O(W)$ だが、max には逆演算が無いので 削除 ができない(数え上げ型の DP なら引き算で消せるが、最大化では消せない)。クエリを 先に全部読んでよい(オフライン) ことを使う。

ヒント2(アプローチ)

各品物は「追加された時刻から削除された時刻まで」の区間 $[s, e)$ にだけ存在する。時刻 $0..Q-1$ を葉とするセグメント木を作り、区間 $[s,e)$ を $O(\log Q)$ 個のノードに分解してそこに品物を載せる。根から葉へ DFS しながら、通ったノードの品物を DP に足していく と、葉 $t$ に着いた時点の DP は「時刻 $t$ に生きている品物全部」を足したものになる。

ヒント3(誘導)

DFS で子から親に戻るとき品物を「外す」必要があるが、外す操作はできない。代わりに 親の DP をコピーして子に渡す(子で変更しても親の DP は残る)。深さは $O(\log Q)$ なので、同時に持つ DP は $O(\log Q)$ 本で済む。

stack = [(1, [0] + [-1] * W)]
while stack:
    node, dp = stack.pop()
    for w, v in seg[node]:
        dp = 新しいリスト(dp に (w, v) を足したもの)   # dp を上書きしない
    ...
    stack.append((2*node+1, dp)); stack.append((2*node, dp))

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys


def main():
    data = sys.stdin.buffer.read().split()
    q, W = int(data[0]), int(data[1])
    pos = 2
    items = []      # [w, v, 追加時刻, 削除時刻]
    query_cap = {}  # 時刻 -> 容量
    for t in range(q):
        typ = data[pos]
        if typ == b'1':
            items.append([int(data[pos + 1]), int(data[pos + 2]), t, q])
            pos += 3
        elif typ == b'2':
            items[int(data[pos + 1]) - 1][3] = t
            pos += 2
        else:
            query_cap[t] = int(data[pos + 1])
            pos += 2

    size = 1
    while size < q:
        size *= 2
    seg = [[] for _ in range(2 * size)]
    for w, v, s, e in items:            # 生存区間 [s, e) を O(log Q) 個のノードに載せる
        if w > W or s >= e:
            continue
        l, r = s + size, e + size
        while l < r:
            if l & 1:
                seg[l].append((w, v)); l += 1
            if r & 1:
                r -= 1; seg[r].append((w, v))
            l >>= 1; r >>= 1

    NEG = -1
    ans = {}
    stack = [(1, [0] + [NEG] * W)]      # dp[c] = 重さちょうど c での価値最大(NEG は到達不能)
    while stack:
        node, dp = stack.pop()
        for w, v in seg[node]:          # このノードに載った品物を 0/1 ナップサックで追加
            shifted = [x + v if x >= 0 else NEG for x in dp[:W + 1 - w]]
            dp = dp[:w] + list(map(max, dp[w:], shifted))
        if node >= size:
            t = node - size
            if t in query_cap:
                ans[t] = max(dp[:query_cap[t] + 1])
        else:                            # 子には「自分の dp」をそのまま渡す(子は新しいリストを作るので共有して安全)
            stack.append((2 * node + 1, dp))
            stack.append((2 * node, dp))

    sys.stdout.write("\n".join(str(ans[t]) for t in sorted(ans)) + ("\n" if ans else ""))


main()

計算量: 品物1つはセグメント木の $O(\log Q)$ 個のノードに載り、各ノードで $O(W)$ の更新 → 全体 $O(Q \, W \log Q)$。空間は DFS の深さ分の DP で $O(W \log Q)$、セグメント木に $O(Q \log Q)$。

Step-by-Step 解説

1なぜ削除できないのか

0/1 ナップサックの更新 $dp'[c] = \max(dp[c], dp[c-w]+v)$ から $dp$ を復元するには、「どちらが max を取ったか」の情報が要るが、それは捨てられている。数え上げ(「重さ $c$ になる選び方の個数 mod p」)なら $dp'[c] = dp[c] + dp[c-w]$ なので、小さい $c$ から $dp[c] = dp'[c] - dp[c-w]$ と逆算でき、削除は $O(W)$ でできる。最大化にはこれが無い。

2生存区間をセグメント木に載せる

品物 $i$ が時刻 $[s_i, e_i)$ に存在する(削除されなければ $e_i = Q$)。これはセグメント木の区間加算と同じ要領で、$O(\log Q)$ 個の「区間を覆うノード」に分解できる。入力例1では品物1が $[0,3)$、品物2が $[1,7)$、品物3が $[5,9)$。

3DFS で「追加だけ」の世界にする

根から葉への経路上のノードに載っている品物の集合 = その葉の時刻に生きている品物の集合(区間分解の性質)。経路を下るときは追加しかしないので、DP の追加更新だけで済む。上るときは親の DP に戻ればよく、これは「子に渡す前の DP を捨てずに持っている」ことで実現する。

4Python での DP 更新の書き方

1 品物の追加を for c in range(W, w-1, -1) の Python ループで書くと、ノード・品物のペア数($Q=5000$ で 19,223 個、実測)× $W=1000$ ≈ $1.9 \times 10^7$ 回のループになる。本解はスライスと map(max, ...) で C レベルに寄せ、同じ入力で 1.23 秒(実測)。新しいリストを作るので「親の DP を上書きしない」という要件も同時に満たせる。

5到達不能の扱い

$dp[c]$ を「重さ ちょうど $c$」の最大価値、到達不能を $-1$ とし、答えは $\max(dp[0..c])$。「重さ $c$ 以下」で持つ(初期値すべて 0)方が簡単だが、本解の書き方でも正しい。どちらを選ぶかは好みで、本解は「ちょうど」版を素朴解との突き合わせで確認した。

迷った他案と採らなかった理由

案計算量採否理由
クエリごとに DP を作り直す$O(Q \cdot n \cdot W)$比較用のみ$Q=5000$・$W=1000$ で 15.8〜16.4 秒(実測、2 回計測)
逆演算で削除(数え上げ DP の手法)1 操作 $O(W)$使えない最大化の max に逆元が無い(Step 1)。問題が「選び方の個数」に変わったらこちらが最適でオンラインでも解ける
品物を「追加順のスタック」とみなし、削除を undo で処理削除が LIFO なら $O(W)$使えない本問の削除は任意の順番。LIFO が保証される問題なら DP をスタックに積むだけでよい
SWAG 風に 2 本のスタックで持つ(本日 Q1)削除が FIFO なら償却 $O(W)$、ただし 2 本の DP の合成に $O(W^2)$ か $O(W)$ の工夫が必要使えない削除が「一番古いものから」のときの手法。本問は任意順
時間軸セグメント木(本解)$O(Q W \log Q)$採用削除の順番を問わず、オフラインなら常に使える。Day033 Q3・Day055 Q2 のオフライン動的連結性と同じ骨格で、載せる構造が Union-Find(rollback)から DP(コピー)に変わっただけ

前提が変わったら: オンライン(クエリに答えてから次の操作が来る)が必須なら本解は使えない。$\sqrt{Q}$ 個ごとに作り直す平方分割などを検討する。

よくあるミス

ミス原因正しい書き方
子の DFS で親の DP を上書きする(dp[c] = max(...) をその場で)兄弟ノードが汚れた DP を受け取る品物を足すたびに新しいリストを作る(本解)か、明示的に dp[:] でコピー
削除されない品物の終了時刻を $Q-1$ にする半開区間と閉区間の混同$[s, Q)$。最後の操作の時刻 $Q-1$ まで含まれる
$w > W$ の品物もセグメント木に載せるスライス dp[:W+1-w] が空・負長になる最初に除外する(どの容量にも入らない)
再帰 DFS で書き、DP を引数で渡してメモリが増える再帰そのものは深さ $\log Q$ で問題ないが、参照を持ち続けやすい明示スタックにすると、処理済みノードの DP は自然に解放される
答えに dp[c] をそのまま使う「ちょうど $c$」で持っていることを忘れるmax(dp[:c+1])(または「以下」版の DP にする)

次のステップ

  • 発展1: 同じ骨格で「削除できる線形基底(XOR 基底)」を作る——XOR 基底も要素の削除ができない構造
  • 発展2: 問題を「重さちょうど $c$ になる選び方の数 mod 998244353」に変え、逆演算によるオンライン削除と本解の速度を比較する
  • 扱わなかった範囲: オンライン化(平方分割や、削除順が FIFO のときの 2 スタック法の DP 版)

検証ログ(本セッションで実行した数値)

  • 入力例1: 本解(標準入力版)9 5 8 3、素朴解 [9, 5, 8, 3] で一致
  • ランダムテスト: $Q \le 40$、$W \le 20$ の関数版 500 件+模範解答そのもの(標準入力版)150 件で、クエリごとに DP を作り直す素朴解と比較 → 不一致 0 件
  • 性能(実測): $Q=5000$、$W=1000$(品物 2,267 個、クエリ 1,480 回、ノード・品物のペア 19,223 個)で本解 1.23〜1.27 秒 / 素朴解 15.8〜16.4 秒(答え一致)
  • 上限を超えた参考値(実測): $Q=20000$、$W=1000$ で 6.02 秒 → 制約を $Q \le 5000$ にした根拠
  • 検証スクリプト: スクラッチパッドの q5.py・q5_main.py(上記は実行結果の転記)

自己評価

自分の回答

気づき・メモ