公式

F - Useless for LIS 解説 by Cyanmond


前提知識: LIS (最長増加部分列) の高速な計算

与えられた列 $A$ に対して、  LIS (最長増加部分列) は以下のようにして求められます。

$A$ を座標圧縮して $1 \leq A_i \leq N$ を仮定します。動的計画法を用います。$\mathrm{dp}[i][j]$ を $A$ の $i$ 番目までの要素を考えて最後に用いた要素の値が $j$ である LIS の長さと定義します。初期状態は $\mathrm{dp}[0][0] = 0, \mathrm{dp}[0][1] = \mathrm{dp}[0][2] = \dots = \mathrm{dp}[0][N] = \mathrm{-inf} $ とし、遷移は、 $i = 1, 2, \dots ,N$ について順に、

  • $j = A_i$ について $\displaystyle \mathrm{dp}[i][j] = \max_{0 \leq k < j} \mathrm{dp}[i - 1][k] + 1$
  • $j \neq A_i$ について $\displaystyle \mathrm{dp}[i][j] = \mathrm{dp}[i - 1][j]$
とします。$\displaystyle \max_{0 \leq k \leq N} \mathrm{dp}[N][k]$ が LIS の長さです。列自体を復元することもできます。

愚直に実装すると計算量 $O(N^2)$ ですが、 $O(N \log N)$ にできます。大きく二つの方法があると思いますが、 Segment Tree を用いる方法を紹介します。

先程の DP を疑似コードにすると、非常に単純になります:

dp = [N + 1] * [N + 1]
dp.fill(-inf)
dp[0][0] = 0
for (int i : (1, N))
    max_value = 0
    for (int j : (0, A[i] - 1))
        max_value = max(max_value, dp[i - 1][j])
    dp[i][j] = max_value + 1

DP を in-place 化すると、区間の最大値を求められる Segment Tree を用いてこれは以下のように書けます。

RangeMaxSegmentTree dp(N + 1, -inf)
dp[0] = 0
for (int i : (1, N))
    dp[a[i]] = dp.calc_max(0, A[i] - 1) + 1

Segment Tree での区間 max は $O(\log N)$ の計算量なので、 LIS の長さが $O(N \log N)$ で求められることになります。補助的な配列を用いれば LIS を復元することもできます。

最後の要素が \(A_i\) であるという制約付きの \(A\) の LIS の長さを \(l_i\) とし、最初の要素が \(A_i\) であるという制約付きの \(A\) の LIS の長さを \(r_i\) とします。\(A_i\) を含むという制約付きの \(A\) の LIS の長さは \(l_i + r_i - 1\) です。そのため、問題に答えるには \(A\) の LIS の長さを \(L\) として \(l_i + r_i - 1 = L\) かどうか判定すればよいです。 LIS を求める DP の途中で情報をメモすることで \(l\) を求めることができます。\(r\) も逆から同様にすればよいです。

よくある LIS の DP での実装例 (C++) / Segment Tree の DP で LIS を求める実装例 (C++)

競技プログラミングでは、前からと後ろからで同じような手続きをする、といったような、少し形は違うが本質的には同じコードを書きたいことがよくあります。このような場合、

  • 一連の手続きを関数化して、引数の配列に反転をはじめとしたなんらかの変換を行って関数を適用する。(今回の問題など)
  • 「少し形は違う」部分を変数としたコードを書く。(グリッド上の BFS など)
  • GitHub Copilot を用いる。

といった方法で労力を節約し、バグの原因を減らすことができるかもしれません。

Bonus? : \(A\) の全ての LIS に \(A_i\) が含まれるような \(i\) の集合を高速に求めてください。

投稿日時:
最終更新: