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

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

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

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

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

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

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

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

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


最長増加部分列の解法

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

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

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

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

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


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

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

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


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

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

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

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 は「現在の部分列」です。

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

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


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

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=3$ のとき。$0$~$7$ までの数を $2$ 進数で表すと 000, 001, 010, 011, 100, 101, 110, 111 となります。ここで「$1$」となっいる桁に対応する数に丸を付け、「$0$」となっている桁に対応する数には丸をつけない、という感じです。

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


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

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


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

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

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


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

$B_1, B_2, B_3, \cdots, B_{i - 1}$ までの値が求まっているとき、なんと $B_i$ の値も求まってしまいます。


$B_i$ は、$j < i$ かつ $A_j \leq A_i$ であるような $j$ の中での $B_j$ の最大値 (ない場合は $0$) に、$1$ を足したものになります。

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


図で表すと、こんな感じの処理をすることになります。下図では、一番最後の $B_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(N^2)$ で、先ほどの全探索の方法より改善しています。

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