最长递增子序列 の O(nlogn)


最长递增子序列(Longest Increasing Subsequence, LIS) 是非常经典的一个算法问题。

在计算机科学中,最长递增子序列(longest increasing subsequence)问题是指,在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。最长递增子序列中的元素在原序列中不一定是连续的。许多与数学、算法、随机矩阵理论、表示论相关的研究都会涉及最长递增子序列。

例如,对于以下的原始序列:

0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15

最长递增子序列为

0, 2, 6, 9, 11, 15

值得注意的是原始序列的最长递增子序列并不一定唯一,对于该原始序列,实际上还有以下三个最长递增子序列

0, 4, 6, 9, 11, 15

0, 4, 6, 9, 13, 15

0, 2, 6, 9, 13, 15

【以上摘自 wikipedia

#动态规划求解

动态规划所使用的时间复杂度为 O(n2)O(n^2)

令当前元素为 x,对于之前的每个比其小的元素 y,以 x 结尾的最长递增子序列 LISx\text{LIS}_x 都有可能是在以 y 结尾的 LISy\text{LIS}_y 基础上将 x 执行 append,即 LISx=LISy.append(x)\text{LIS}_x = \text{LIS}_y.\text{append}(x)

若要使 LISx 最长,就必须找到一个最长的 LISy\text{LIS}_y。假设状态 LENx\text{LEN}_x 表示 LISx\text{LIS}_x 的最大长度,那么有

LENx=max(LENyi)+1,yi<x\text{LEN}_x = \max(\text{LEN}_{y_i}) + 1, \quad y_i < x

转换成代码风格就是(以 C++ 代码为例):

LIS_dp.cpp
for (int i = 1; i < n; i++) { int maxLength = 0; for (int j = 0; j < i; j++) { maxLength = max(maxLength, len[y]); } len[x] = maxLength + 1; }

最后遍历所有元素结尾的 LEN,找到最大的那个即可。

#缺点

动态规划方法的一个缺点在于——状态的冗余检查。极端情况下,如果一个序列严格递增,比如 1, 2, 3, 4, 5, 6 ,那么其 LIS 就是本身,但在遍历到元素 3 的时候,其会把 LIS1\text{LIS}_1LIS2\text{LIS}_2 都检查一遍。事实上, LIS2\text{LIS}_2 包含了 LIS1\text{LIS}_1,对后者的检查是完全没有必要的。

更别说后面的 4, 5, 6 了。

需要找到一个方法来消除该冗余,最好是遍历到某一元素 x 时,我们已经知道 LISx\text{LIS}_x 与前面所有元素的关系。

#nlogn

换个思路,将所要存储的状态修改为「长度为 k 的 LIS 的最后一个元素」,看看事情是否有所转机?

以序列 5, 1, 4, 2, 8, 7, 9, 0, 3, 6 为例:

  • i = 0 时,lastElementWithLength[1] 为 5,这很显然;

    lastElementWithLength: 5

  • i = 1 时,1 比 5 小,为了使后续元素更容易得到 append,将 lastElementWithLength[1] 改为 3;

    lastElementWithLength: 1

  • i = 2 时,4 比 1 大,可以作为递增子序列 1, 4 的最后一个元素,所以令 lastElementWithLength[2] 为 4;

    lastElementWithLength: 1, 4

  • i = 3 时,2 比 4 小,但比 1 大,根据前面的理论,将 lastElementWithLength[2] 改为 2。当然这并不是说前面的 4 就消失了,它依然可以作为递增子序列 1, 4 的一部分,但是一旦后续出现数字 3,它会更倾向于比自己小并且长度不低的 2

    lastElementWithLength: 1, 2

以此类推,我们最终可以得到这样一个 lastElementWithLength 数组:

1 2 3 4
0 2 3 6

可以得到 LIS 的长度为 4,并且以 6 结尾。

LIS 为 1, 2, 3, 6

我们并不用在意之前有什么,只需要知道当前的元素必须被尽可能长的子序列 append 就可以了。对于那些末尾元素比自己还小的,肯定能进行 append 操作,而那些末尾元素比自身大的,则有可能取而代之,很典型的就是上面 i=3 时的情况。

事实上,在 lastElementWithLength 数组中,有且仅有第一个比自己大的那个元素 first 能进行取代,毕竟前面的那些再大也不会超过自身,first 也是在前者的基础上加入的数组,尽管取代了,也比之前的所有元素大。而后面的那些更大的元素,也必然是在 first 的基础上建立,其对应的递增子序列必然包含 first(或者更大的元素),一旦取代就会破坏 LIS 的规则。

所以问题又转变成了:在 lastElementWithLength 数组中找到第一个比自己大的元素。这不就二分法么?如果找不到,直接在末尾添加便是!

这样一来,遍历的过程中每个元素执行一次二分法,在最极端(也就是原始数组严格递增)的情况下也仅仅需要 O(nlogn)O(n\log n) 的时间复杂度,比起动态规划,开销大大降低。

#应用

传送门:

  1. >>> LeetCode 354 俄罗斯套娃信封问题(Hard) <<<


 上一篇
Linux 常用命令 の Note Linux 常用命令 の Note
记录常用命令及对应的常用 option,方便查。
2023-09-23
下一篇 
树状数组 树状数组
树状数组,也称作二叉索引树(Binary Indexed Tree)或 Fenwick 树。 它可以在 O(log⁡n)O(\log n)O(logn) 的时间复杂度下实现单点修改与区间查询两个操作。
2023-09-05
  目录