Longest Increasing Subsequence
整数のリスト nums が与えられます。部分列は、要素の一部を元の順序のまま残し、残りを取り除いたものです。残す要素は隣り合っている必要はありません。左から右へ値が厳密に増加する最長の部分列の長さを返してください。同じ値が連続していても、増加とは見なしません。
関数
- numsinteger-array
- 選択元となる整数のリスト
- 戻り値integer
- 最長の狭義単調増加部分列の長さ
制約
1 ≤ nums.length ≤ 2500-104 ≤ nums[i] ≤ 104
例
- 入力
- nums = [3, 1, 8, 2, 5, 9, 4, 7]
- 出力
- 4
- 説明
- 1、2、5、9を選ぶと長さ4の増加部分列になり、1、2、5、7や1、2、4、7も同様です。5つの値を選んで増加し続けるものはないため、答えは4です。
- 入力
- nums = [7, 7, 7, 7]
- 出力
- 1
- 説明
- 値は厳密に増加しなければならないため、同じ部分列に7を2つ含めることはできません。要素が1つだけでも数えられるので、答えは1です。
- 入力
- nums = [12, -4, 0, 25, -10, 3, 16, 5]
- 出力
- 4
- 説明
- -4, 0, 3, 16 の長さは4です(-4, 0, 3, 5 も同様です)。最初の要素である12から始めると、12, 25のように値は2つしか得られません。最長の部分列は先頭から始まる必要はありません。
提出時に隠しテスト+20件
発展問題
長さだけでなく、最長増加部分列そのものを返し、なおかつ O(n log n) 時間で実行できますか?
ヒント
1つずつ開いてください。開くたびに少しずつ答えに近づきます。
リスト全体の最良の部分列を直接説明するのは困難です。各インデックス
iについて、より範囲を絞った質問をしてみましょう。nums[i]でちょうど終わる最長増加部分列は何でしょうか?nums[i]で終わる部分列は、nums[i]単独であるか、以前のあるnums[j] < nums[i]で終わる最良の部分列を続けたものです。そのようなjのうち最良のものを選び、1を加えます。答えは、終わる位置にかかわらず、これらの値の最大値です。O(n²)より小さい計算量にするには、各長さについて、その長さの部分列が終わり得る値のうち最小のものだけを保持します。これらの値はソートされた状態を保つため、二分探索によって、新しい数値が最長の部分列を延長するのか、あるいは末尾の値を置き換えるのかを判定できます。
解説
部分列では任意の要素を飛ばせるため、n個の数を持つリストには2^n個の部分列があり、すべて確認するには多すぎます。動的計画法では、各インデックスについて、より絞った問いを考えます。つまり、ここでちょうど終わる最長の増加部分列の長さはいくつか、という問いです。これによりO(n²)の表が得られます。最速の方法では、各長さについて、その長さの部分列が終わり得る最小値を1つずつ保持し、新しい要素を二分探索で配置します。
各要素を取るかスキップする
正しいが、最大のテストでは終わらない
考え方
リストを順に見て、各要素について採用するか除外するかを決めます。nums[i]を採用できるのは、最後に採用した値より大きい場合だけです。再帰関数longest(i, prev)は、最後に採用した要素のインデックスがprev(まだ何も採用していない場合は-1)のとき、インデックスi以降からさらに何個の要素を追加できるかを答えます。
スキップする場合はlongest(i+1, prev)です。採用できる場合、採用すると1 + longest(i+1, i)になります。答えはこの2つのうち大きい方です。リストの末尾を過ぎると追加できる要素はないため、その場合の結果は0です。すべての増加部分列は、採用とスキップの選択からなる1つの経路なので、この探索で最良のものを見逃すことはありません。
値が増加している間は、両方の分岐を探索し続けるため、処理が遅くなります。1, 2, 3, ..., nのようなリストでは、要素を1つ処理するたびに呼び出し回数が2倍になります。2の40乗はすでに約10^12回の呼び出しであり、大きなテストケースでは要素数が2500あります。しかし、longest(i, prev)は組(i, prev)だけに依存するため、異なる問いは最大でもn²個です。それぞれの問いを1回だけ解くのが、次の方法です。
アルゴリズム
longest(i, prev)を記述します。prevは最後に保持した要素のインデックス、または-1です。iが末尾を過ぎていたら、0 を返します。nums[i]をスキップします。best = longest(i+1, prev)。prevが-1であるか、nums[i] > nums[prev]の場合は、その要素を保持します。best = max(best, 1 + longest(i+1, i))。bestを返します。答えはlongest(0, -1)です。
def lengthOfLIS(nums):
def longest(i, prev):
# The longest increasing subsequence of nums[i:] whose values all exceed nums[prev].
# prev is -1 while nothing has been taken.
if i == len(nums):
return 0
best = longest(i + 1, prev) # skip nums[i]
if prev == -1 or nums[i] > nums[prev]:
best = max(best, 1 + longest(i + 1, i)) # take nums[i]
return best
return longest(0, -1)各インデックスで終わる最長の部分列
考え方
状態。 ending[i]を、最後の要素がnums[i]である最長増加部分列の長さとします。最後の要素を固定すると問題をきれいに分割できます。部分列がどこで終わるかが分かれば、その後に続く可能性のある値が分かるからです。
漸化式。 nums[i]で終わる部分列が複数の要素を持つ場合、nums[i]の直前の要素は、j < iかつnums[j] < nums[i]を満たすあるnums[j]であり、そこまでの部分はできるだけ長いものにする必要があります。したがって、該当するjについてending[i] = 1 + max(ending[j])となります。基本ケース: どの要素も単独で部分列になるため、ending[i]は1から始まります。順序: ending[i]が参照するのはより小さいインデックスだけなので、左から右へ埋めていきます。
[3, 1, 8, 2, 5, 9, 4, 7]の場合、表は[1, 1, 2, 2, 3, 4, 3, 4]となります。たとえば5の前には3、1、または2が続くことができ、そのうち最良なのはending = 2の2なので、ending[4] = 3です。答えは最後の値ではなく、最大の値である4です。最良の部分列はどこで終わってもよいためです。
各インデックスは、それより前のすべてのインデックスを1回ずつ調べるため、処理量はn(n-1)/2回の比較となり、n = 2500の場合は約3.1 × 10^6です。
アルゴリズム
- すべての要素を1に設定して
endingを作成します。 - 左から右へ各
iについて、すべてのj < iを確認します。 nums[j] < nums[i]の場合、より大きくなるならending[i]をending[j] + 1に設定します。endingの最大値を返します。
def lengthOfLIS(nums):
n = len(nums)
# ending[i]: the longest increasing subsequence that ends with nums[i]
ending = [1] * n
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i] and ending[j] + 1 > ending[i]:
ending[i] = ending[j] + 1
return max(ending)二分探索による最小の末尾
考え方
上の表では、各インデックスにつき1つの長さを記録します。もっと少ない情報だけを記録することもできます。各長さについて、その長さの増加部分列が末尾に取れる値のうち最小のものだけを記録します。長さ k+1 に対して、これを tails[k] と呼びます。末尾の値は小さいほど常に有利です。末尾が9の部分列の後に続けられる値なら、末尾が5の部分列の後にも続けられるからです。
tails は常に厳密な昇順に並んでいます。末尾が t の長さ k+2 の部分列には、末尾が t より小さい長さ k+1 の部分列が含まれるからです。そこで、新しい値 x ごとに、≥ x となる最初の末尾を二分探索します。該当するものがなければ、x はすべての末尾より大きく、最長の部分列を伸ばすので、末尾に追加します。該当するものがあれば、その末尾を x に置き換えます。1つ短い部分列の末尾は x より小さいため、x を追加すると、末尾の値がより小さい同じ長さの部分列が得られます。
[3, 1, 8, 2, 5, 9, 4, 7] の場合、tails は [3]、[1]、[1, 8]、[1, 2]、[1, 2, 5]、[1, 2, 5, 9]、[1, 2, 4, 9]、[1, 2, 4, 7] と変化し、その長さ4が答えです。[1, 2, 4, 9] の段階では、入力中で4は9より後に現れるため、tails 自体は部分列ではありません。意味があるのはその長さだけです。この方法はペイシェンスソートとも呼ばれます。各末尾が山札の一番上のカードにあたるカードゲームに由来します。
各要素について、最大で n 個の末尾を対象に二分探索を1回行います。最大の入力では、およそ 2500 × 12 = 30,000 回の処理です。
アルゴリズム
- 空のリスト
tailsから始めます。 numsの各xについて、tails[k] ≥ xとなる最初のインデックスkを二分探索します。≥ xとなる末尾要素がなければ、xを追加します。- それ以外の場合は、
tails[k] = xに設定します。 tailsの長さを返します。
from bisect import bisect_left
def lengthOfLIS(nums):
# tails[k]: the smallest last value of any increasing subsequence of length k + 1
tails = []
for x in nums:
k = bisect_left(tails, x) # the first tail that is >= x
if k == len(tails):
tails.append(x) # x extends the longest subsequence so far
else:
tails[k] = x # x is a smaller ending for length k + 1
return len(tails)
落とし穴と境界ケース
誤答の多くは、テーブルに何が格納されているかを取り違えたり、等しい値を増加として扱ったりすることが原因です。
- 最大の要素ではなく、
ending[n-1]を返してしまう。[1, 2, 3, 0]では最後の要素は1ですが、答えは3です。 <ではなく≤で比較してしまう。[7, 7, 7, 7]の答えは4ではなく1です。- tailsを使う方法で、最初の末尾が
≥ xとなる位置ではなく、最初の末尾が> xとなる位置を探してしまう。重複がある場合、最初の7の後に2つ目の7が追加され、等しい値がより長い部分列として数えられます。 tailsを部分列そのものとして扱ってしまう。そこに含まれる値は異なる部分列に由来することがあるため、親を別途追跡している場合に限り、これを出力してください。- 誤って連続する部分列の問題を解いてしまう。
[3, 1, 8, 2, 5, 9, 4, 7]では、隣り合う要素が増加する最長の区間は2, 5, 9(長さ3)ですが、答えは4です。 - LuaとRでは配列のインデックスは1から始まるため、0始まりの
prev = -1というマーカーは0になり、二分探索はインデックス1から現在のサイズまでを対象にします。
よくある質問4
最長増加部分列の時間計算量はどのくらいですか?
tails メソッドは O(n log n) 時間、O(n) 空間で実行されます。要素ごとに二分探索を1回行います。すべてのインデックスの組に対する動的計画法の表は O(n²) 時間かかり、すべての部分列を試す方法は O(2ⁿ) かかります。n = 2500 の場合、約30,000、300万、そして天文学的な数のステップになります。
なぜ patience sorting 法は正しい長さを与えるのでしょうか?
各要素の処理後、tails[k] には、これまでに見つかった長さ k+1 の任意の増加部分列が終端として取り得る最小の値が格納されます。x がすべての末尾要素より大きい場合にのみ追加が行われます。これは、それまでのどの部分列よりも1つ長い部分列が新たに存在することを意味します。置き換えでは長さは変わらず、終端の値が小さくなるだけなので、リストの長さは常に最長増加部分列の長さになります。
長さだけでなく、実際の最長増加部分列をどのように求めますか?
各要素の親を記録します。O(n²)の表では、iの親はending[i]に値を与えたjです。tailsメソッドでは、各tailの後ろにある要素のインデックスを保存し、要素を配置するときに、その1つ左の位置に保存されているインデックスをその要素の親に設定します。その後、最長の部分列の末尾から親をたどり、結果を逆順にします。
代わりに、最長非減少部分列をどのように見つけますか?
等しい隣接要素を許容します。表では、nums[j] ≤ nums[i]を使います。tails法では、最初の末尾要素としてxより厳密に大きいものを、以上ではなく検索します。これにより、等しい値は末尾要素を置き換えるのではなく、リストを延長します。[7, 7, 7, 7]は4を返します。
似た問題
同じ考え方を使う問題です。2〜3問解くとパターンが身につきます。
Python
def lengthOfLIS(nums):
# ここにコードを書いてくださいケース1
ケース2
ケース3
入力
nums = [3, 1, 8, 2, 5, 9, 4, 7]
期待値
4