「戻す DP」または「FPS」で!
問題概要
最初、箱は空である。以下の操作を 回行う。各操作後において、以下の値を答えよ。
箱に入っているボールをいくつか選ぶ方法であって、ボールに書かれた数値の総和が となる方法の個数を 998244353 で割った余り
各操作は次の 2 タイプある。
+ v
:整数値の書かれたボールを 1 つ箱に追加する
- v
:整数値の書かれたボールを 1 つ箱から取り出す (1 個以上存在することが保証される)
制約
解法 (1):戻す DP
部分和問題 (数え上げ ver.) の復習
今回の問題は、もし最後の状態の箱について問題を解くだけなら、いつもの DP でできる。部分和問題を数え上げ問題にしたような問題となる。
ここで、一旦、部分和問題 (数え上げ ver.) に対する DP を復習してみよう。
個の要素
からいくつか選ぶ方法のうち、総和が
となるものの個数を求めよ
この問題は次のような DP で解ける。
dp[i][j]
← 個の要素のうち最初の
個の要素のみからいくつか選ぶ方法のうち、総和が
となるものの個数
そして、次の更新式を組める。
dp[i+1][j] = dp[i][j] + (j >= v[i] ? dp[i][j-v[i]] : 0)
この更新式は見方を変えると、サイズ の配列
dp[i]
に対して、要素 を用いて、サイズ
の配列
dp[i+1]
へと変換したことを表している。
この変換は、 次元ベクトルから
次元ベクトルへの関数とみなすこともできる。これを具体的に表現してみよう。
DP の更新式を関数で表す
要素 に対して、
次元ベクトル
から
次元ベクトルへの関数
を次のように定義する。
このとき、DP の更新式は次のように書ける。
dp[i+1]
= (
dp[i]
)
戻す DP
いよいよ、「戻す DP」の核心に迫る。一般に、
個の要素
からいくつかを選んで総和を
にする方法を
と表す
個の要素
からいくつかを選んで総和を
にする方法を
と表す
ことにする。 は
次元ベクトルとなる。このとき、
と表せる。ここで関数 の逆関数
を求めることができれば、
と表すことができる。これが戻す DP だ。つまり、「 個の要素
に関する部分和問題の結果」から、「
個の要素
に関する部分和問題の結果」を得ることができるのだ。
逆関数
を求める
よって、残りは「関数 の逆変換
を求める」ことができればよい。高校数学でもやった通り、
を入れ替えてみる。
において を入れ替えると、
(
)
(
)
となる。 を主役にすると
(
)
(
)
となる。これは、for
文で実装すると、次のようになる。
for (int j = 0; j <= K; ++j) { y[j] = x[j]; if (j >= v) y[j] -= y[j - v]; }
実装上の整理
ここまでの結果をまとめよう。
配列 dp
に対して、要素 を追加して、配列
dp2
を得る操作は次のように書ける (普通の部分和 DP)。
for (int j = 0; j <= K; ++j) { dp2[j] = dp[j]; if (j >= v) dp2[j] += dp[j - v]; }
そして、配列 dp
に対して、要素 を削除して、配列
dp2
を得る操作は次のように書ける (戻す DP)。
for (int j = 0; j <= K; ++j) { dp2[j] = dp[j]; if (j >= v) dp2[j] -= dp2[j - v]; }
解答例コード
これらをまとめて、次のコードで AC できる。計算量は となる。
#include <bits/stdc++.h> #include <atcoder/modint> using namespace std; using mint = atcoder::modint998244353; int main() { int Q, K; cin >> Q >> K; vector<mint> dp(K+1, 0); dp[0] = 1; while (Q--) { char c; int v; cin >> c >> v; if (c == '+') { vector<mint> dp2(K+1, 0); for (int i = 0; i <= K; ++i) { dp2[i] = dp[i]; if (i >= v) dp2[i] += dp[i-v]; } dp = dp2; } else { vector<mint> dp2(K+1, 0); for (int i = 0; i <= K; ++i) { dp2[i] = dp[i]; if (i >= v) dp2[i] -= dp2[i-v]; } dp = dp2; } cout << dp[K].val() << endl; } }
実装を洗練化:in-place 化
上のコードでは、配列 dp
を変換したものを、新たな配列 dp2
に格納して、それを dp
に戻すように実装している。
しかし、配列 dp2
を用いずに配列 dp
を直接弄る実装もできる。次のように書ける。とても簡潔になると同時に、「 を追加」「
を削除」が互いに逆変換であることがハッキリ見て取れるようになる。
#include <bits/stdc++.h> #include <atcoder/modint> using namespace std; using mint = atcoder::modint998244353; int main() { int Q, K; cin >> Q >> K; vector<mint> dp(K+1, 0); dp[0] = 1; while (Q--) { char c; int v; cin >> c >> v; if (c == '+') { for (int i = K; i >= v; --i) { dp[i] += dp[i-v]; } } else { for (int i = v; i <= K; ++i) { dp[i] -= dp[i-v]; } } cout << dp[K].val() << endl; } }
解法 (2):FPS
解法 (1) で考えた DP は、FPS で考えると意味合いがより明確になる。それについては、公式解説を参照。