以下の内容はhttps://pydocument.hatenablog.com/entry/2023/04/01/185559より取得しました。


Pythonで最長増加部分列問題を解く方法

最長増加部分列問題 (Longest Increasing Subsequence, LIS) は、与えられた数列の中で、順序を変えずに要素を取り出して作る部分列のうち、増加列(各要素が前の要素より大きい)の中で最も長いものを求める問題です。例えば、数列 [3, 1, 4, 1, 5, 9, 2, 6] の最長増加部分列は [1, 4, 5, 9] や [1, 2, 6] などで、その最長の長さは 4 です。

最長増加部分列問題を解くことの意義

LIS は、一見単純な問題に見えますが、以下のような応用があり、アルゴリズムの基礎を学ぶ上で重要です。

  • バイオインフォマティクス: DNA 配列の解析など、配列データにおけるパターン発見に利用されます。
  • データ圧縮: データの変化パターンを捉えるために利用されることがあります。
  • 株価分析: 株価のトレンド分析など、時系列データの解析に利用されることがあります。
  • アルゴリズム競技: 動的計画法や二分探索といった、より高度なアルゴリズムを学ぶための基礎となります。

この問題は、動的計画法または二分探索を使って解くことができます。Python での実装例とともに、それぞれの解法を解説します。

1. 動的計画法による解法

動的計画法では、部分問題を解いてその結果を記録し、それを利用してより大きな問題を解きます。

考え方

  • dp[i]: i 番目の要素を最後尾とする最長増加部分列の長さ と定義します。

  • dp[i] を求めるには、i より前の要素 j について、nums[j] < nums[i] ならば、dp[j] + 1dp[i] の候補となります。

    • これは、j番目の要素を最後尾とする最長増加部分列に、i番目の要素を付け加えることができる場合、その長さは dp[j] + 1 になる、ということを意味します。
  • dp[i] は、これらの候補の中で最大のものです。

    • つまり、i番目の要素を最後尾とする最長増加部分列は、iより前の要素を最後尾とする最長増加部分列の中で、i番目の要素を付け加えることができるもののうち、最も長いものにi番目の要素を付け加えたものになります。

初期状態

  • すべての i に対して dp[i] = 1 (自分自身のみからなる部分列の長さは 1)

実装例

まず、最もシンプルなコードを示します。

def lis_dp_simple(nums):
    n = len(nums)
    dp = [1] * n  # 初期値は全て1

    #  i を 0 から n-1 まで動かす
    for i in range(n):
        # j を 0 から i-1 まで動かす
        for j in range(i):
            # nums[j] < nums[i] ならば、dp[i] を更新する候補
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)  # より大きい方で更新

    return max(dp)  # dp の最大値が LIS の長さ

# 使用例
nums = [3, 1, 4, 1, 5, 9, 2, 6]
length = lis_dp_simple(nums)
print(f"最長増加部分列の長さ: {length}") # 出力: 最長増加部分列の長さ: 4

上記のlis_dp_simple関数では、リストdpを用意し、全ての要素を1で初期化しています。 外側のループでnumsの各要素nums[i]について、内側のループでそれより前の要素nums[j]を調べ、nums[j] < nums[i]であればdp[i]dp[j] + 1と比較し、大きい方の値で更新します。 最終的にdpの中の最大値が最長増加部分列の長さになります。

次に、最長増加部分列そのものを返すようにコードを改良します。

def lis_dp_full(nums):
    n = len(nums)
    dp = [1] * n
    prev = [-1] * n # 直前の要素のインデックスを記録

    for i in range(n):
        for j in range(i):
            if nums[j] < nums[i] and dp[i] < dp[j] + 1:
                dp[i] = dp[j] + 1
                prev[i] = j # 更新時に直前の要素を記録

    # 最長増加部分列の最後尾のインデックスを見つける
    max_len = max(dp)
    end_index = dp.index(max_len)

    # 最長増加部分列を復元
    lis = []
    while end_index != -1:
        lis.append(nums[end_index])
        end_index = prev[end_index]

    return lis[::-1]  # 逆順にして返す

# 使用例
nums = [3, 1, 4, 1, 5, 9, 2, 6]
result = lis_dp_full(nums)
print(f"最長増加部分列: {result}") # 出力例: 最長増加部分列: [1, 4, 5, 9]

lis_dp_full関数では、最長増加部分列を復元するために、prevリストを追加しています。 prev[i]にはnums[i]を最後尾とする最長増加部分列で、nums[i]の直前の要素のインデックスを記録します。 dpリストの最大値を持つ要素のインデックスend_indexから始めて、prevを辿ることで最長増加部分列を復元できます。

計算量

  • 時間計算量: O(n2) (二重ループのため)
  • 空間計算量: O(n) (dp 配列のため)

2. 二分探索による解法

二分探索を用いると、より効率的に LIS を求めることができます。

考え方

  • tails[i]: 長さが i+1 である増加部分列の最後尾の要素のうち、最小のもの と定義します。
  • tails は常に増加列になります。
  • nums の各要素 x について、
    • xtails のどの要素よりも大きければ、xtails に追加します。(LIS の長さが 1 増える)
    • そうでなければ、tails 中で x 以上の最小の要素を x で置き換えます。(LIS の長さは変わらないが、より小さい値で終わる LIS を見つけたことになる)

実装例

import bisect

def lis_binary_search(nums):
    tails = []  # 各長さの増加部分列の最後尾の最小値を格納

    for x in nums:
        # x が tails のどの要素よりも大きい場合、末尾に追加
        if not tails or x > tails[-1]:
            tails.append(x)
        else:
            # tails の中で x 以上の最小の要素を二分探索で探し、x で置き換える
            idx = bisect.bisect_left(tails, x)  # x を挿入すべき位置
            tails[idx] = x

    return len(tails) # tailsの長さがLIS

# 使用例
nums = [3, 1, 4, 1, 5, 9, 2, 6]
length = lis_binary_search(nums)
print(f"最長増加部分列の長さ: {length}")  # 出力: 最長増加部分列の長さ: 4

上記のlis_binary_search関数では、tailsリストを用いて増加部分列の最後尾の最小値を管理します。 numsの各要素xについて、xtailsの最後の要素より大きければtailsに追加します。 そうでない場合は、bisect.bisect_left関数を用いてtailsの中でx以上の最小の要素を見つけ、xで置き換えます。 最終的にtailsの長さが最長増加部分列の長さになります。

bisect.bisect_left(a, x)は、ソート済みのリストaに対して、xを挿入できる最も左の位置を返します。

最長増加部分列の要素を復元するコードを以下に示します。

import bisect
def lis_binary_search_full(nums):
    tails = []
    predecessors = {}

    for x in nums:
        if not tails or x > tails[-1]:
            if tails:
                predecessors[x] = tails[-1]
            tails.append(x)
        else:
            idx = bisect.bisect_left(tails, x)
            if idx > 0:
                predecessors[x] = tails[idx - 1]
            tails[idx] = x

    # 最長増加部分列を復元する
    longest_subsequence = []
    current = tails[-1]
    while current is not None:
        longest_subsequence.append(current)
        current = predecessors.get(current)

    return longest_subsequence[::-1]  # 逆順にして返す
# 使用例
nums = [3, 1, 4, 1, 5, 9, 2, 6]
result = lis_binary_search_full(nums)
print(f"最長増加部分列: {result}") # 出力例: 最長増加部分列: [1, 2, 5, 9]

lis_binary_search_full 関数では、predecessors ディクショナリを使用して、各要素がどの要素の後に続くかを記録しています。 tails リストを更新する際に、新しい要素が既存の要素を置き換える場合や、新しい要素が tails に追加される場合に、predecessors を更新します。 最長増加部分列を復元する際は、tails の最後の要素から始めて、predecessors を辿って前の要素を順に追加していくことで復元します。

計算量

  • 時間計算量: O(n log n) (nums の各要素について二分探索を行うため)
  • 空間計算量: O(n) (tails 配列のため)

まとめ

解法 時間計算量 空間計算量 特徴
動的計画法 O(n2) O(n) 実装が比較的簡単。最長増加部分列自体も復元しやすい。
二分探索 O(n log n) O(n) 高速。n が大きい場合に有利。最長増加部分列自体の復元は、工夫が必要(上記コード参照)。

Python では bisect モジュールを使うことで、二分探索を簡潔に記述できます。最長増加部分列問題は、アルゴリズムの理解を深めるのに役立つ問題です。

[PR]

click.linksynergy.com

click.linksynergy.com

click.linksynergy.com

click.linksynergy.com




以上の内容はhttps://pydocument.hatenablog.com/entry/2023/04/01/185559より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14