本記事ではオンライン・オフライン変換と呼ばれる動的計画法(DP)の高速化テクニックの1つを解説していこうと思います。お気持ち理解重視で書くので、冗長かもしれませんがお付き合いください。
高速化テクニックと言いましたが、これ自体は高速化テクニックではなく、「オンラインDP」と呼ばれる種類のDPを、「オフラインDP」と呼ばれる種類のDPに帰着させるテクニックです。一般に前者より後者の方が扱いやすく、結果的に高速化できる場合があるという話になります。
オンラインDPとは
まずオンラインDPとは何か説明していきます。厳密に定義付けられた言葉なのか知りませんが、簡単に言えば「DPテーブルの計算にDPテーブルを用いるDP」ということができます。例えば一般的な2項間漸化式のようなDP
のようなものはオンラインDPです。またLCSを求めるDP
などもオンラインDPです。
オンラインDPの特徴は、一般項などを見つけない限りは、DPテーブルを順番にしたがって埋めていかなければならないということです。この制限は強く、高速化することが難しくなってしまいます。
オフラインDPとは
次にオフラインDPとは何かを説明していきます。簡単に言えば、オンラインDPでないDP、すなわち「DPテーブルの計算にDPテーブルを用いないDP」です。例えば以下のような問題はオフラインDPの一種です。
これを見て、これはDPなのか?ただ関数を計算しているだけではないのか?と思うかもしれませんが、極論突き詰めるとオフラインDPとはこういうものです。こんな問題は出ないと思うかもしれませんが、例えば典型的な0-1ナップザック問題をで解くDPの遷移は
ですが、これは実は(部分的に)オフラインDPになっています。つまりなるテーブルを埋めるのに、
しか使っていないため、すでに
を埋めている状況に於いて、これはオフラインDPと言えます。
オフラインDPの特徴は、テーブルを埋める順序を気にせずに計算して良いということです。これにより、都合よい順番で埋めていくことによって、高速化できることがあります。
お気持ち
ナップザックの2次元DPなどは、DPテーブルに依存していると言えば依存しているので、あくまで部分的にオフラインという話なので、オンラインと言えばオンラインと言えます。つまりこれはオフラインDPを繰り返し適用しているということになります。
これは極論を言えば、任意のオンラインDPも1項だけ埋めるオフラインDPを繰り返してると解釈することもできて、じゃあ何が違うねんみたいな気持ちになるかもしれません。なのでちょっとDPを一般論っぽく話をしてみたいと思います。
DPの添字集合を (有限集合)、DPのとる値の集合を
として、各
に対して
と
が定まっているとする*1。このとき
上の半順序が
で定まるとする(この順序による有向グラフが閉路を持たなければ良い)。このとき、半順序にしたがった順番で
を計算することをDPと呼ぶことにする(が極小元(つまり
)のときは、
が一点集合からの定値関数となってます)。
このときオンラインDPとは、半順序のトポロジカルソートの自由度が低い(一意乃至ほぼ一意)DP、オフラインDPはその自由度が高いDPと言い換えることができる。
オンライン・オフライン変換
オンライン・オフライン変換というのは、一部のオンラインDPをオフラインDPに変換して計算の自由度を上げようという試みです。オフラインに変換できるならそれは元々自由度が高いじゃないかみたいな気持ちになると思うのですが、まあこれは特殊なDPにしか適用できません(とはいえ、一般的なDPには大体適用できますが)ので、許してください。
条件を整理する前に、前項で定義した語を、簡単のために言い換えておくことにします。以後はトポロジカルソートされている*2ものとします。また分かりづらいので、
として*3、
とします*4。
条件というのは、がモノイドで、任意の
について、
が存在して
(モノイドとしての積)となることです。*5
仰々しく書いていますが、要は参照するDPテーブルを途中で切って、それぞれについて計算した後マージすることができれば良いということです。つまりよく出てくるようなについてなんやかんや計算して、足したり
を取ったりするものに関してはOKということです*6。
オンラインDPも細かく区切ればオフラインDPという話をしましたが、答えをマージできるがために、うまい具合に区切って、小さなオフラインDPの集まりと考えることができるようになるということです。
では具体的な方法を与えます。を
と定義します。このときを
に対して
を計算する関数、
を
に対して
とします。求めたいのは
に気をつけて、
となります。
ここで次のような疑似コードでを呼ぶことを考えます(ただし
は
の単位元)。
dp = [e] * N
induce(l, m, r):
#hoge
solve(l, r):
if l + 1 == r:
return
m = (l + r) // 2
solve(l, m)
A = induce(l, m, r)
for i in [m, r):
dp[i] *= A[i]
solve(m, r)
return
これが正しく回っていることの確認は各自に任せます*7。
今がオフラインDPとなっています。これは
が呼ばれる時点で
に対して
がすでに求まっており、
はそれにのみ依存しているためです。よって
を求める際は、律儀に
の順番で求めずとも、適当な順番で求めても良いということになります。
以上でいくつかのオフラインDPに分解することができました。ここで計算量を考える。として、
の計算量を
,
の計算量を
とすると
となり、となります。*8
一つ気を付けたいのは、オンラインDPをなんでもかんでもオフライン化しても、が抑えられない限り高速化はされないということです。
具体例
これだけ見てもよくわからないかもしれないので、簡単な問題を解いてみます*9。
整数と長さ
の整数列
について、
として、
により計算される配列を求めよ。
という問題を考えます。愚直にやるとかかり間に合いませんが、前からsetに
]を突っ込んでいって、set上の二分探索とかで
]に一番近いやつを求めてやれば
で十分高速に求めることができます。
…で終わりだとなんの記事だよって話なんですがこれはオフライン・オンライン変換でも求めることができます。
まずオフラインへの変換は問題によらず変わらないので、を考えます。
をソートして、
について、ソートした数列上を
で二分探索すれば、簡単に求めることができます。これのオーダーは
なので、全体で
で解くことが出来、十分高速です。
C++で実装したものが以下となります。
#include<bits/stdc++.h> using namespace std; using ll=long long; void chmin(ll&x,ll y){ if(x>y) x=y; } int N; vector<int> X; vector<ll> dp; void induce(size_t l, size_t m, size_t r){ vector<ll> tmp(m - l); copy(dp.begin() + l, dp.begin() + m, tmp.begin()); sort(tmp.begin(), tmp.end()); for(size_t i{m}; i != r; ++i){ size_t j = lower_bound(tmp.begin(), tmp.end(), X[i]) - tmp.begin(); if(j > 0) chmin(dp[i], abs(tmp[j - 1] - X[i])); if(j < m - l) chmin(dp[i], abs(tmp[j] - X[i])); } } void solve(size_t l, size_t r){ if(l + 1 == r) return; size_t m = (l + r) / 2; solve(l, m); induce(l, m, r); solve(m, r); } int main(){ cin >> N; X.resize(N); for(auto &i : X) cin>>i; dp.assign(N,LLONG_MAX); dp[0] = 0; solve(0,N); for(auto &i : dp) cout << i << " "; cout << endl; }
またこのままでも十分高速ですが、をマージソートっぽくソートして、
も予めマージソートした途中経過を全部覚えておくと、二分探索パートを尺取法っぽくすることで、
の計算量を
に抑えられて、全体で
に抑えることができ、setに劣っていないことが分かりますね!*10
まとめ
以上長くなりましたがオフライン・オンライン変換のお話でした。本質パートはかなり単純なんですが、使い所がややこしい影響で、理解しにくいような気がしたので、本質だけ切り抜いて説明しようとしたんですが、なんかごちゃごちゃしすぎて逆に分かりづらいかもしれません。
再掲すると「答えをマージできるがために、うまい具合に区切って、小さなオフラインDPの集まりと考えることができるようになるということです。」←これが本質だと思っています。
なんかわからなかったらTwitterでもコメントでもいいので書いてくれると助かります。以上、ご精読ありがとうございました。
参考文献
動的計画法高速化テクニック(オフライン・オンライン変換) - Qiita
大体これで学んだし、半分以上これのパクリですすいません。
*1:このは
を用いて記述されることが多いが、それは
が複数乃至全ての
について同時に定義されるがためにそうなっているだけで、単一の
については、そのような
は定数となるから
のみの関数として与えられることに注意してください
*2:オンラインなら一意なソートと考えていいです
*3:ソート済の有限集合なのでこうして良い
*4:であり、定義域を広げる分には、広げた分だけ無視すればいいのでOKです
*5:まあ実際はもうちょい弱くてもできますが、こんなもんでしょうて、を用いて
くらいはOKです。まあつまり初期化をいじったり、最後に追加で作用する分にはいいということです。こうしとか無いと初期値が単位元で固定されてよく無いですね(後述の具体例では
でだけ初期値をいじってます)。
*6:分かりづらければ、下の具体例の問題を想像していてください
*7:solve(l,r)が呼ばれる時点で、に対して
=\mathrm{DP} _ {i,0,l}]となっていることなどを、帰納法の中で帰納法回したりすれば示せます。もっと簡単な方法もあるかも
*8:コメントで議論があったので、注釈を入れておきます。まずを仮定しています。これはどちらにせよinduceの後に今までの結果とmergeするパートがあるので、それを合わせれば
にはなるからです。ただ更新する必要がある場所が疎な場合(またその場合に限り)
となる場合もありますが、その場合でも
にはなりますので注意してください。また例えば
の場合は、二乗の木DPと同じ計算量で
となりますが
なので、先の評価でちゃんと上から抑えることはできてます(ところでオーダー表記の等号が非可換なのどうにかならんかね)。
*9:大体オンライン・オフライン変換はMonotone-Minimaがセットでそっちが難しいから難しく感じてしまう気がする
*10:実装はめんどいのでしません()