はじめに
コード上では変数名としてNDP
であったりNextDP
と名付けられやすいものです。
正式な名称はよく分かっていないですし、解説されたことがあるのかすら分かっていないテクニックなのですが、どの層でも役に立つもので、使い方やメリットを整理しておこうと思いました。
説明はPythonで行います。ChatGPT-4を使用してC++やRustに置き換えてもらっても動作したので、基本的には別の言語でも使用できるテクニックのはずです。
使い方について
まずは基本的なナップサック問題とそのコードを記載します。
実行速度の差をみたいため制約はすこし大きめです。
問題
$N$ 個の品物があります。それぞれ重さが $w_i$, 価値が $v_i$ です。
重さの総和が $W$ を超えないように選んだ時の価値の総和の最大値を求めなさい。
制約
- $1 \le N \le 10000$
- $1 \le W \le 10000$
- $1 \le w_i \le 10000$
- $1 \le v_i \le 10^9$
- 入力は全て整数
一般的なコード(実行時間2000msほど)
import random
random.seed(998244353)
#NとWが最大のパターンの代わり
N=10000
W=10000
VW = []
for _ in range(N):
VW.append([random.randint(1, 50),random.randint(1, 50)])
#基本的な解き方
DP = [[0]*(W+1) for _ in range(N+1)] #10001*10001の配列を作る
for i in range(N):
v,w=VW[i]
for j in range(W):
DP[i+1][j]=max(DP[i+1][j],DP[i][j]) #i個目を入れないパターン
if DP[i][j]>0 and j+w<=W: #i個目を入れるか試すパターン
DP[i+1][j+w]=max(DP[i+1][j+w],DP[i][j]+v)
if w<=W:
DP[i+1][w]=max(DP[i+1][w],v)
print(max(DP[N]))
$DP[i$個目までの商品$][$重さ$]$で、2次元配列で価値の総和の最大を格納しています。
$i$個目までの商品を格納していますが、この遷移にて必要なのは毎回 $i - 1$ の情報のみです。
ずっと全ての情報を保持する必要はないということを考えると以下のように書き換えられます。
NDPを使ったコード(実行時間700msほど)
import random
random.seed(998244353)
#NとWが最大のパターンの代わり
N=10000
W=10000
VW = []
for _ in range(N):
VW.append([random.randint(1, 50),random.randint(1, 50)])
#NDPを使用した解き方
DP = [0]*(W+1) #初期配列を作る
for i in range(N):
v,w=VW[i]
NDP = [0]*(W+1) #次の遷移先の配列を作成する
for j in range(W):
NDP[j]=max(NDP[j],DP[j]) #i個目を入れないパターン
if DP[j]>0 and j+w<=W: #i個目を入れるか試すパターン
NDP[j+w]=max(NDP[j+w],DP[j]+v)
if w<W:
NDP[w]=max(NDP[w],v)
DP=NDP #遷移先の配列を現在の配列扱いにする
print(max(DP))
添え字のi
とi+1
で遷移するのではなく、DP
とNDP
で遷移していきます。
このような書き方だと2次元配列を使うこともありません。
for i in range(N):
v,w=VW[i]
は、以下のように書き換えると添え字i
もいらなくなります。
for v,w in VW:
NDPを使う場面について
ナップサックは2次元DPになりがちでi個目まで見た値を格納していきますが、2つ前の情報などは気にする必要は基本的にありません。
このように1つ前(2つ前もやろうと思えば可能…)の情報からしか遷移せず、それより前の情報を見ることがない場合のDPに使いがちかなと思います。
難易度が高い問題では大小両方向からアクロバティックに遷移するタイプのDPもあり、そういったDPに対しては使用することは出来ないので注意してください。
効果
- 添え字を気にする必要が1か所なくなるのでバグが減りやすい
- DPの次元を1つ落とせるので処理が早くなりTLEしなくなりやすい
- 巨大な配列を用意する必要が無くなるのでMLEしなくなりやすい
特に2次元配列や3次元配列のアクセスは遅くなりやすいPythonではお勧めの書き方です。
効果の例1
特にPythonで効果があるのでPythonを例にしてみます。
Pythonで競プロを行ってる人が基本躓く問題で確認して見ましょう。
(2020年の問題を未だに擦り続けてるのは老害みたいなものですが…)
Pythonの提出はもちろんTLE、Pypyでは言語アップデートによりギリギリ間に合わないくらいになりましたが、MLEも起きてしまいます。
R,C,K = map(int, input().split())
MAP = [[0] * (C+1) for _ in range(R+1)]
DP = [[[0] * 4 for _ in range(C+1)] for _ in range(R+1)]
RCV = [list(map(int, input().split())) for _ in range(K)]
for r, c, v in RCV:
MAP[r-1][c-1] = v
for i in range(R):
for j in range(C):
DP[i][j][3]=max(DP[i][j][3],DP[i][j][2]+MAP[i][j])
DP[i][j][2]=max(DP[i][j][2],DP[i][j][1]+MAP[i][j])
DP[i][j][1]=max(DP[i][j][1],DP[i][j][0]+MAP[i][j])
DP[i][j+1][0]=max(DP[i][j+1][0],DP[i][j][0])
DP[i][j+1][1]=DP[i][j][1]
DP[i][j+1][2]=DP[i][j][2]
DP[i][j+1][3]=DP[i][j][3]
DP[i+1][j][0]=max(DP[i][j])
print(max(DP[R-1][C-1]))
- Pythonでの提出(TLE)
- Pypyでの提出(TLEとMLE)
ではこれをNextDPを使用したコードに置き換えてみました。
R,C,K = map(int, input().split())
MAP = [[0] * (C+1) for _ in range(R+1)]
DP = [[0] * 4 for _ in range(C+1)]
RCV = [list(map(int, input().split())) for _ in range(K)]
for r, c, v in RCV:
MAP[r-1][c-1] = v
for i in range(R):
NDP = [[0] * 4 for _ in range(C+1)]
for j in range(C):
DP[j][3]=max(DP[j][3],DP[j][2]+MAP[i][j])
DP[j][2]=max(DP[j][2],DP[j][1]+MAP[i][j])
DP[j][1]=max(DP[j][1],DP[j][0]+MAP[i][j])
DP[j+1][0]=max(DP[j+1][0],DP[j][0])
DP[j+1][1]=DP[j][1]
DP[j+1][2]=DP[j][2]
DP[j+1][3]=DP[j][3]
NDP[j][0]=max(DP[j])
DP = NDP
print(max(DP[C-1]))
- NextDPを使用したPypyでの提出 (2000msほど改善)
効果の例2
1/14にあったE問題の桁DPも1つ前のみからの遷移となるためNDP
に置き換えることが可能です。
元が4次元のため、がっつり改善されるわけではないですが、ギリギリTLEしてるパターンについては役立つテクニックだと思います。
- 元コード (5252ms)
-
NDP
コード(4427ms)
ほかの例
set
などでも使えます。直近の問題では以下のような使い方もできます。
N = int(input())
D = []
for i in range(1,13):
D.append(int('1'*i))
S = set()
S.add(0)
for _ in range(3):
NS = set()
for s in S:
for d in D:
NS.add(s+d)
S = NS
print(sorted(S)[N-1])
このテクニックを使ってない方は是非使ってみてください!
追記
この記事公開後にいくつか反応をいただきました。
他に使用される変数名ではdp2
、pd
、NewDp
、PrevDp
など様々な種類があるようです。
C++の挙動など細かいことを理解していなかったこともあり、興味深かったり面白いポストがいくつかあったので以下に並べます。
「Next DP」という言葉は実は今日初知り(調べたら、変数名ではけっこう使われてはいそうだった。用語として使われるのはすごく珍しい)
— はちじ@競プロ (@sad_eight) January 17, 2024
こないだの ABC 336-E の桁 DP で MLE やらかして、これをやりました……!! https://t.co/21s0kGyBXy
— けんちょん (@drken1215) January 17, 2024
この言語仕様の違い、 Python → C++ に移行する人がいたら罠になりうると思うので知っておくと良いかもです pic.twitter.com/XwIiLLtKQs
— ごりちゃん🦍 (@prd_xxx) January 17, 2024
ndp.assign(n, 0);
— 熨斗袋 (@noshi91) January 17, 2024
// 遷移
swap(dp, ndp);
とすると再確保が走らない
業プロだとswap(dp,ndp)よりdp=move(ndp)の方が良さそうに見える
— ゴジラ@競プロ (@gojira_kyopro) January 17, 2024
Rust だと所有権システム的にもこの書き方が使いやすいので良い
— ま (@0x3b800001) January 17, 2024
dpとnext_dpだけ持つやつ、ヒュの定数倍高速化典型っぽさある(AHC028でもやった)。あとは配列をstaticに確保して使い回したりとか
— TERRY (@terry_u16) January 17, 2024
ndp pdp の高速化で困るシーンよりも現代では復元で困るシーンがあるので配列沢山持ちがち。
— もすーん (@Mo_SoooN) January 17, 2024
DP テーブルを直前と現在だけで使い回すの、自分は dp[prev][…]と dp[cur][…] として prev, cur を 0, 1 で交互に入れ替えながらやってるhttps://t.co/9ETXhM3dEh
— ⚫️Y.Y.⚪️ (@ygussany) January 17, 2024
蟻本に「DPにおける配列の再利用」として解説されてるので、名前はそれでいいかなという気がします。swapで実装するメリットはメモリ確保が減る点かな(定数倍の重い操作なので)。 https://t.co/p2CvDb7oHx
— not (@not_522) January 17, 2024
JOIの問題はC++でもちょいちょいndp使わないと厳しいTLなことあるイメージ
— 趣味のキノコ (@Kinoko_kyoupro) January 17, 2024
nextDP のやつ、 X と nX だなー。
— きり (@kiri8128) January 18, 2024
dp はアルゴリズムの名前だから変数名に使うのは違和感ある
DPの更新先をNDPにする話で思い出したけど, 順列Pを更新していくタイプの問題だとP=NPとか書きがち.
— 仮の人数学+競プロ (@kari_math) January 17, 2024