加载中...

二分求解LIS

用暴力或树状数组求解 LIS 长度的方法都很直观易懂,而还有一种利用二分求解 LIS 的算法,经典但抽象,这里作记录以备忘。

暴力 / 树状数组求 LIS 的本质思想都是固定末尾元素位置,最大化链的长度;而二分求解的思路却相反——它的本质思想是固定链的长度,最小化末尾元素的值

显然对于末尾元素更小的序列,更有利于 LIS 长度的扩展。

状态定义

\(dp_{i}\):考虑长度为 \(i\) 的链(严格上升子序列),末尾元素的最小值。

状态转移

一个比较关键的发现:\(dp\) 数组是严格单调递增的。因此我们可以在 \(dp\) 数组上二分以加速递推。

我们仍然按照原序列顺序依次遍历每个 \(a_{i}\),考虑 \(a_{i}\) 可以更新哪些 \(dp_{x}\)。由于 \(a_{i}\) 是当前序列中最靠后的元素,因此对于当前任何前 \(i-1\) 个元素构成的子序列,后面都可以接上 \(a_{i}\)。于是,对于前面的所有严格上升子序列,只要末尾元素 \(< a_{i}\),就一定可以将 \(a_{i}\) 接到其后面以实现 LIS 长度的扩展。我们先二分找到当前 \(dp\) 数组中第一个 \(\ge a_{i}\) 的位置 \(j\)\(dp_{j} \ge a_{i}\)) ,对于 \(dp\) 数组的更新,我们分几种情况讨论:

  • 未找到 \(dp_{k} \ge a_{i}\):即所有 \(dp_{k} < a_{i}\),说明对于当前所有的上升子序列,都可以往后添加 \(a_{i}\)。此时 LIS 的长度会因为 \(a_{i}\) 的添加而增加 \(1\),故:\(dp_{len(LIS)+1} \leftarrow a_{i}\)

否则说明 \(\exists k\),使得 \(dp_{k} \ge a_{i}\),二分可查找到结果。

  • 对于 \(\forall k < j\)\(dp_{k}\)\(dp_{k} < a_{i}\) 均成立,显然 \(a_{i}\) 在序列中的出现不会影响它们的值,因此无需作任何修改。
  • 对于 \(\forall k = j\)\(dp_{k}\):对于长度恰好为 \(j\) 的严格上升子序列,此时第 \(j-1\) 个元素一定 \(< a_{i}\)(否则就不是二分到这个位置了);而 \(dp_{k} \ge a_{i}\),将 \(dp_{k}\) 替换为 \(a_{i}\) 一定不劣,故 \(dp_{k} \leftarrow a_{i}\)
  • 对于 \(\forall k > j\)\(dp_{k}\):由于这些上升子序列的第 \(k-1\) 个元素值一定 \(\ge a_{i}\),无法用 \(a_{i}\) 替换这些上升子序列的末尾元素,因此无需作任何修改。

综上,我们会发现,二分查找到相应位置后,我们最多只会添加 / 修改 \(1\) 个值,故总复杂度为 \(O(n\log n)\)

code:

# 找 a[1~n] 的最长严格上升子序列
n = 6
a = [1, 3, 2, 4, 4, 5]
dp = []
for i in range(n):
    if len(dp) == 0 or dp[-1] < a[i]: 
        dp.append(a[i])
    else:
        pos = bisect.bisect_left(dp, a[i])
        dp[pos] = a[i]
print(len(dp)) # 4

若求解的是最长不下降子序列,只需要略做修改:

# 找 a[1~n] 的最长非严格上升子序列
n = 6
a = [1, 3, 2, 4, 4, 5]
dp = []
for i in range(n):
    if len(dp) == 0 or dp[-1] <= a[i]: #upd1 
        dp.append(a[i])
    else:
        pos = bisect.bisect_right(dp, a[i]) #upd2
        dp[pos] = a[i]
print(len(dp)) # 5
posted @ 2026-05-11 18:09  小橘奏  阅读(22)  评论(0)    收藏  举报