問題
最初は空の品物の集合がある。$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(方向性)
ナップサック 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 解説
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)$ でできる。最大化にはこれが無い。
品物 $i$ が時刻 $[s_i, e_i)$ に存在する(削除されなければ $e_i = Q$)。これはセグメント木の区間加算と同じ要領で、$O(\log Q)$ 個の「区間を覆うノード」に分解できる。入力例1では品物1が $[0,3)$、品物2が $[1,7)$、品物3が $[5,9)$。
根から葉への経路上のノードに載っている品物の集合 = その葉の時刻に生きている品物の集合(区間分解の性質)。経路を下るときは追加しかしないので、DP の追加更新だけで済む。上るときは親の DP に戻ればよく、これは「子に渡す前の 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 を上書きしない」という要件も同時に満たせる。
$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(上記は実行結果の転記)