競技プログラミングでは、「最長増加部分列(LIS)」や「最長共通部分列(LCS)」を求めるものが出題されることがある。
これらは動的計画法により高速に求めることができる。
ある数列や文字列 $A=(a_1,a_2,...,a_N)$ がある時、部分列(Subsequence)とは、$A$ からいくつかの要素を抽出した列。
飛び飛びでもよいが、順番は元から変わってはいけない。
別の言い方をすると、以下の手順で構築できる列 $C$ が部分列である。
空の列 $()$ を部分列に含むかどうかは定義による。
$A$ の部分列のうち単調増加なものを $A$ の「増加部分列」という。
同じ値を許すかは定義によるが、ここでは許さない(狭義単調増加)とする。
増加部分列のうち、長さが最も長いものを $A$ の「最長増加部分列」(Longest Increase Subsequence, LIS)という。
LISの具体的な要素は複数通り考えられる場合もあるが、長さは $A$ により一意に決まる。
元の数列 | $(5,3,7,4,1,4,6)$ |
増加部分列の例 | $(5,7),(3,6),(3,4,6)$ |
最長増加部分列の例 | $(3,4,6),(1,4,6)$ |
2つの列 $A$ と $B$ に共通する部分列を、$A$ と $B$ の「共通部分列」という。
そのうち長さが最長のものを「最長共通部分列」(Longest Common Subsequence, LCS)という。
LCSの具体的な要素は複数通り考えられる場合もあるが、長さは一意に決まる。
動的計画法で求める。このアルゴリズムでは、最長増加部分列の内容自体は求まらない。
計算量 $O(N \log{N})$ で求めることができる。
狭義単調増加の場合はbisect_left(同じ値がある場合は最も左に挿入できるindexを取得)、広義単調増加の場合はbisect_right(同じ値がある場合は最も右に挿入できるindexを取得)を用いる。
from bisect import bisect_left def lis(A: list): L = [A[0]] for a in A[1:]: if a > L[-1]: # Lの末尾よりaが大きければ増加部分列を延長できる L.append(a) else: # そうでなければ、「aより小さい最大要素の次」をaにする # 該当位置は、二分探索で特定できる L[bisect_left(L, a)] = a return len(L) # ----------------------------------------- print(lis([3, 4, 5, 1, 6, 3, 2, 7])) # => 5
二分探索を用いる方法は、実装がシンプルではあるが、拡張性の面ではやや低い。
最長増加部分列を求めるのにはセグメント木を使う方法もあり、そちらは多少、ひねったバリエーション問題にも対応できる。
以下のDPを定義する。
$DP[i]=0$($i=1,...,N$)で初期化した上で、このDP配列をセグメント木に載せる。
$i$ ではなく、$a_i$ が小さい順に処理する。同率の場合は、indexが後の方から処理する。
$a_i$ について処理する際、$DP$ 配列の「$[1,i)$ の区間における最大値 + 1」が、$DP[i]$ となる。
区間最大値はセグメント木やBITで取得できるので、これで $DP[i]$ を計算し、更新する。
最終的に、$DP$ 全体の中での最大値が答えとなる。
こちらも計算量のオーダーは $O(N \log{N})$ となる。
二分探索と比べて定数倍や必要なメモリは重くなるが、一般的な競技プログラミングの制約の前ではまぁ、だいたい気にしなくていい。
具体的な最長増加部分列の1つを求める場合。
二分探索を用いる方法で、$L$ の他に、以下の情報も持ってDPする。
最後までDPしたら、復元する。
prevの記録・LISの復元は $O(N)$ でできるので、計算量は変わらず $O(N \log{N})$ となる。
セグメント木を使う方法を拡張する。
セグメント木なら、これは適切にマージできる。(長さが同じなら個数を合算し、どちらかが大きいならそちらを継承する)
$a_i$ について処理する際、$[0,i)$ の区間を求め、(最長の長さ+1, 個数) が $DP[i]$ となる。
計算量のオーダーは $O(N \log{N})$ となる。
2つの文字列A,Bがある時、「Aの部分列でもあり、Bの部分列でもある」文字列を共通文字列という。
そのうち最長のものを最長共通部分列(Longest Common Subsequence)という。
A | abcbdabc |
B | bdcaba |
LCS | bcba,bdabなど |
この長さを求めましょう、という問題。(このアルゴリズムでは、最長共通部分列自体は求まらない)
def lcs(a: str, b: str): L = [] for bk in b: bgn_idx = 0 # 検索開始位置 for i, cur_idx in enumerate(L): # ※1 chr_idx = a.find(bk, bgn_idx) + 1 if not chr_idx: break L[i] = min(cur_idx, chr_idx) bgn_idx = cur_idx else: # ※2 chr_idx = a.find(bk, bgn_idx) + 1 if chr_idx: L.append(chr_idx) return len(L) # --------------------------------------- print(lcs('>abcbdabc', 'bdcaba')) # => 4
bgn_idx
に入っているのは、$L[i-1]$、つまり「$AT(A$と$B'_{k-1}$ の長さ $i$ の共通部分列$)+1$」chr_idx
)bgn_idx
に入っているのは、「$AT(T_{max})+1$」bgn_idx
より右に文字 $b_k$ が存在する ⇔ 最長共通部分列“$T_{max}+b_k$“が存在し、暫定最長共通部分列長を$|T_{max}|+1$に延長できる$(|S|+1) \times (|T|+1)$ の配列上でDPを行い、途中の計算結果を保存しておくことで、最長共通部分列と共に、その具体例を構築することが出来る。
$S_{1 ... i}$ と $T_{1 ... j}$ のLCSの長さを $L_{i,j}$ とする。
$L_{0,j}=0,L_{i,0}=0$ とする。
LCS長は1文字拡張できる。$L_{i+1,j+1}=L_{i,j}+1$
LCS長は、片方の末尾を1文字削った2通りの長い方となる。 $L_{i+1,j+1}=max(L_{i+1,j},L_{i,j+1})$
これで、$i=1 .. |S|$、$j=1 .. |T|$ の2重ループでDPテーブルを小さい方から埋めていくことで、$L_{|S|,|T|}$ が最終的なLCS長となる。
具体的な文字列は、DPテーブルから逆向きに復元する。$(i,j)=(|S|,|T|)$ とする。
同じである数字の方に$(i,j)$を移動する。両方同じ場合は、具体例が1つ構築できればいい場合は適当に1つ選んで移動する。
$S_i=T_j$ であるはずなので、それをLCSに加え、$(i-1,j-1)$ に移動する。
0 1 2 3 4 5 6 7 M Z J A W X U 0 | 0 0 0 0 0 0 0 0 1 X |⓪ 0 0 0 0 0 1 1 2 M | 0❶① 1 1 1 1 1 3 J | 0 1 1❷ 2 2 2 2 4 Y | 0 1 1② 2 2 2 2 5 A | 0 1 1 2❸③③ 3 6 U | 0 1 1 2 3 3 3❹ 丸付きの数字を辿っていき、 7 Z | 0 1 2 2 3 3 3④ 黒丸の数字を拾っていくと、LCS(の1つ)は MJAU と復元できる