最長増加部分列 (LIS) について

Latest Author square1001 /Date 2019-07-24 07:51:46 / Views 7622
4 (Favした一覧ページはユーザーページから)

最長増加部分列問題 (LIS) について

最長増加部分列問題とは?

最長増加部分列問題は、次のような有名問題です。

  • 長さ NN の数列 A1,A2,A3,,ANA_1, A_2, A_3, \dots, A_N が入力されるとき、そのうちいくつかの値を順番を変えずに取り出したときに、これが増加列となる中で最大の長さのものを求めなさい。
  • 数列 b1,b2,b3,,bkb_1, b_2, b_3, \dots, b_k が「増加列」であるとは、b1b2b3bNb_1 \leq b_2 \leq b_3 \leq \cdots \leq b_N が成り立つことである。

例えば、次のような例があります。

  • N=7,A=(3,5,2,6,7,1,4)N = 7, A = (3, 5, 2, 6, 7, 1, 4) のとき、最長増加部分列は (3,5,6,7)(3, 5, 6, 7)
  • N=9,A=(3,6,9,2,5,8,1,4,7)N = 9, A = (3, 6, 9, 2, 5, 8, 1, 4, 7) のとき、最長増加部分列は (3,6,9)(3, 6, 9)(2,5,7)(2, 5, 7)(3,5,8)(3, 5, 8) などたくさんあります。

この記事では、最長増加部分列問題を通して、いろいろな問題と解法に触れていこうと思います。


最長増加部分列の解法

最長増加部分列は計算量 O(NlogN)O(N \log N) で解くことができます。しかし、それに至るまでにいろいろなアプローチがあるので順に説明していきます。

ここでは、最長増加部分列の長さを求める問題について考えます。

解法 #1: 2N2^N 通り全探索 - 計算量 O(2N×N)O \left(2^N \times N \right)

長さ NN の数列の「部分列」(数列のいくつかの値を順番を変えずに取り出した数列) は、"長さ 00 の数列" を含めて 2N2^N 通りあります。

例えば、数列 (2,6,1)(2, 6, 1) の部分列は、(),(2),(6),(1),(2,6),(2,1),(6,1),(2,6,1)(), (2), (6), (1), (2, 6), (2, 1), (6, 1), (2, 6, 1)8=238 = 2^3 通りあります。


なぜ部分列は 2N2^N 通りあるのでしょうか?

数列のいくつかの値に丸を付けて、丸がつけられた数を左から読んだのが「部分列」というイメージで考えます。

それぞれの値は丸をつけられるかつけられないかの 22 通りなので、全部で 2×2××2=2N2 \times 2 \times \cdots \times 2 = 2^N 通りです。


どのように 2N2^N 通り全探索することができるのでしょうか?これには 22 つの方法があります。

  • 再帰を使った全探索
  • 22 進数を使った全探索

まず、再帰を使った全探索について説明します。実装すると、下のようなコードになります。

ans = 0
function solve(pos, seq):
    if pos == N:
        if seq が増加部分列:
            ans := max(ans, seq の長さ)
        return
    solve(pos + 1, seq)
    solve(pos + 1, seq の末尾に A[pos] を追加したもの)

solve(0, []) を呼び出すと、計算が始まります。pos は「現在選ぶかどうか決めようとしている数が A[pos]」、seq は「現在の部分列」です。

その擬似コードの 4466 行目は、選ぶ部分列が決まった場合の処理です。つまり、4466 行目を 2N2^N 回通ります。

7, 8 行目の意味は、A[pos] を選ぶか選ばないかで枝分かれする、という意味です。その第 11 引数が pos+1 になっているのは、A[pos] を選ぶかどうか決める次に A[pos+1] を選ぶかどうか決める、からである。


次に、22 進数を利用した全探索について説明します。実装すると、下のようなコードになります。

ans = 0
for i = 0 to 2^N-1:
    seq = []
    for j = 0 to N-1:
        if (i を 2 進数で表したときの 2^j の位が 1 である):
            seq := (seq の末尾に A[j] を追加したもの)
    if seq が増加部分列:
        ans := max(ans, seq の長さ)

例えば、N=3N=3 のとき。0077 までの数を 22 進数で表すと 000, 001, 010, 011, 100, 101, 110, 111 となります。ここで「11」となっいる桁に対応する数に丸を付け、「00」となっている桁に対応する数には丸をつけない、という感じです。

すると、ありうる 2N2^N 通りすべての部分列 seq について調べることができます。


計算量は、両者ともに 2N2^N 通りすべてに対して増加部分列判定をするので、O(2N×N)O \left(2^N \times N \right) です。

初心者にとって再帰を理解するのは難しいと思うので、22 進数を利用した全探索の方が分かりやすいでしょう。実装量は両方そんなに変わりません。


解法 #2: 動的計画法 (DP) で計算量改善! - 計算量 O(N2)O \left(N^2 \right)

この問題は、動的計画法 (DP) で解くことができます。最長増加部分列問題を DP を使って解く方法を説明します。

DP を理解していない人も、方法を読むことによって理解が進む良い問題の一例なので、ぜひ読んでみましょう。


左から順に丸を付けるかどうか決めることを考えます。BiB_i を「最後に丸を付けた値が AiA_i のときの、最長増加部分列の長さ」とします。

B1,B2,B3,,Bi1B_1, B_2, B_3, \cdots, B_{i - 1} までの値が求まっているとき、なんと BiB_i の値も求まってしまいます。


BiB_i は、j<ij < i かつ AjAiA_j \leq A_i であるような jj の中での BjB_j の最大値 (ない場合は 00) に、11 を足したものになります。

なぜなら、BjB_j での答えとなる増加部分列の末尾に AiA_i を追加したものが、最適になる数少ない候補だからです。


図で表すと、こんな感じの処理をすることになります。下図では、一番最後の BiB_i の値を、今までに求めた Bj (j<i)B_j \ (j < i) の値を使って求めています。矢印を見ると DP が何なのか分かりやすいかもしれません。

image

実装は次のようになります。

for i = 0 to N-1:
    B[i] = 1  // 増加部分列が A[i] だけになるパターン
    for j = 0 to i-1:
        if A[j] < A[i]:
            B[i] := max(dp[i], dp[j] + 1) // 増加部分列を A[j] から A[i] に「つなげる」パターン
ans = 0
for i = 0 to N-1:
    ans := max(ans, B[i])

計算量は明らかに O(N2)O(N^2) で、先ほどの全探索の方法より改善しています。

このように、問題を「順番を決めて」前に求まった結果を利用して答えを求め、全探索の計算量を改善するみたいな方法を、「動的計画法」と言います。