300. 最长上升子序列
题目
给定一个无序的整数数组,找到其中最长上升子序列的长度。
示例:
输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。
说明:
- 可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
- 你算法的时间复杂度应该为 O ( n 2 ) O(n^2) O(n2) 。
思路
首先看到这道题,刷题比较少的同学可能上来就是两眼一抹黑,除了用暴力解法完全没有思路。不过我可以告诉你遇到这种数组类型的中等难度的题目,并且要求得到的结果只是一个数字的题目,基本上思路都可以往动态规划方面去想,最典型的例子就是最大子序列和问题,有没有感觉很类似!
首先是需要介绍以下子串和子序列的区别:
- 子串:substring,是原字符串里面的连续的字符串,注意是连续的
- 子序列:subsequence,是由原字符串中的字符组成的一个序列,并不一定是连续的,但是相对的顺序是需要保证的
比如helloworld
里面,ello
或者oworl
是一个子串,而hld
这种就是一个子序列,子序列不要求在原字符串是连起来的。
这道题还特别细心的提醒了你时间复杂度应该是 O ( n 2 ) O(n^2) O(n2)及以下的,所以基本排除了暴力法,所以这个时候我们想想应该怎么用动态规划的方法解决问题。
解法一 动态规划
OK,现在我们的解题的方向已经有了,就像是高考数学题已经知道应该用什么公式就差找到公式的用法了。我们知道应用动态规划必须要使问题具有最优子问题和子问题重叠性,只有满足这两个条件才能用动态规划来解。我们先来定义我们要用到的变量,也是动态规划题目里面的一个套路,就是定义状态:
max[i]
表示从第1
个元素到第i+1
个元素范围内包含第i+1
个元素的最长上升子序列的长度。
好了我相信很多想到动态规划的人已经料到了我们会定义这个状态,但是这个公式接下去怎么发展呢?怎么用这个状态组成我们的状态转移方程呢?max[i]
和max[<i]
的元素是什么关系?
回顾一下题目发现,这个子序列必须是上升的,所以在计算max[i]
的时候,对于两个位置i
和j
,如果j < i
,我们有两种情况:
- 如果
num[j] >= num[i]
,那么这两个元素肯定不能存在于同一个子序列里面的,所以直接跳过不用任何操作 - 如果
num[j] < num[i]
,这两个元素可以存在于同一个子序列里面,于是可能存在max[j] + 1 = max[i]
对于第二个结论,我们并不确定等式是否成立,因为这个时候只考虑了原来以位置j
结尾的子序列和i
位置元素构成的子序列,其他的以i
结尾的子序列有可能比这个大,我们并不确定,所以需要遍历所有的满足j < i
的j
的值,所以这个状态转移公式可以这样表示:
max[i] = max( max[0], max[1] ... max[i-1] ) + 1
这样得到的值确保是i
之前的最长序列和当前元素组成子序列,也就是以nums[i]
结尾的最长序列。
代码如下:
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length < 1) {
return 0;
} else if (nums.length == 1) {
return 1;
}
int[] max = new int[nums.length];
int globalMax = 1; // 保存到当前位置为止最大长度
Arrays.fill(max, 1); // 初始化的最大值就是单个元素的长度1
for (int i = 0; i < nums.length; i++) { // 外层循环遍历从 0 到 num.length
for (int j = 0; j < i; j++) { // 内层循环遍历从 0 到 i
if (nums[i] > nums[j]) { // 只有小于nums[i]的元素才考虑进来
max[i] = Math.max(max[i], max[j] + 1);
}
}
globalMax = Math.max(globalMax, max[i]); //更新最大长度
}
return globalMax;
}
这个答案里面有一个两层的循环,所以时间复杂度是 O ( n 2 ) O(n^2) O(n2)。只用了一个数组,所以空间复杂度是 O ( n ) O(n) O(n)。遗憾的是在提交了答案之后,时间只击败了31%的用户,相比来说不是特别理想,还有没有其他的解法呢。这个时候我们可以把目光投向最经典的分治法。
解法二 分治法+动态规划
说实话这个方法一般人是真心想不到,更别说在在面试的时候能够在30分钟内想出这个解决方案。在网上已经有很多人讲过了这个方法,特别是动态规划设计方法&&纸牌游戏讲解二分解法这篇文章用一个叫patience game的纸牌游戏来模拟了整个计算的过程,非常通俗易懂,只要按照它的纸牌分发的逻辑来写代码就可以得到最后的结果,遗憾的时候这篇文章并没有写证明的过程。在接下来我会尽量讲解清楚整个推导模拟过程。
首先我们要先定义一个状态也就是一个数组:
tails[k]
代表所有长度为k+1
的最小子序列的最后一个元素的最小值,这里有三个限制条件,1.长度为k+1
的子序列,2.最后一个元素,3.最小值。比如数组[4,5,6,3]
,所对应的状态是:
k = 0 : [4], [5], [6], [3] => tail[0] = 3
k = 1 : [4, 5], [5, 6] => tail[1] = 5
k = 2 : [4, 5, 6] => tail[2] = 6
观察一下这个tail
数组发现这个数组是按照升序排列的,为了证明所有的情况下tail
数组都是升序排列,我们用反证法证明一下这个数组的确是升序排列的。
假设存在这样的一组a < b
有tail[a] >= tail[b]
,那么在以tail[b]
结尾的长度为b+1
的子序列中必然存在一个长度为a+1
的子序列,并且这个子子序列的最后一个元素小于tail[b]
也必然小于tail[a]
(因为这个序列也是递增的),这样就出现了长度为a+1
的子序列的最后元素最小值小于tail[a]
这样的矛盾,所以这样的假设就是不成立的。
在遍历的过程中,假设当前的发现的最大长度是k+1
,我们有元素nums[i]
:
-
如果
nums[i]
比tail
数组里面所有的元素都大,也就是比长度为k+1
的子序列的最后一个元素还要大,所以把这个元素加上去就可以得到一个长度为k+2
的子序列,这个时候我们创建了一个新的值tail[k+1] = nums[i]
,并且有tail[k+1] > 任何tail[<k+1]
,也就是说tail[k+1]
是数组里面最大的。每次添加新元素比当前数组里面所有的元素大,所以添加操作会保持数组的升序。 -
如果
nums[i]
不是最大的,我们就需要更新这个数组,找到j
使得tail[j-1] < num[i] <= tail[j]
,我们把这个tail[j]
的值设置成num[i]
。在这一步中,我们发现了num[i]
比长度为j
的数组的最后一个元素要大,所以可以添加nums[i]
到长度为j
的子序列的最后变成了长度为j+1
的子序列,然而这个nums[i]
比长度为j+1
子序列的最后一个元素的最小值也就是tail[j]
还要小,所以就更新tail[j]
为nums[i]
。这个操作很明显不会改变这个tail
序列的升序性。
经过遍历之后,得到最终的tail
的数组,这个数组的最后的长度就是我们需要的最长子序列的长度了。
在上面的寻找nums[i]
在tail
数组位置的过程中,我们采用二分查找的方法,可以大大提高效率,把更新操作的时间复杂度提高到
O
(
l
o
g
n
)
O(logn)
O(logn)水平。一次遍历以及每次遍历的二分查找,得到最后的时间复杂度就是
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)。
用一个简单的例子来模拟一下整个的更新的过程:
数组[10,9,2,5,3,7,101,18]
,从左往右进行遍历,每一步都是经过上面的步骤进行计算:
nums | 10 | 9 | 2 | 5 | 3 | 7 | 101 | 18 |
---|---|---|---|---|---|---|---|---|
tail[0] | 10 | 9 | 2 | 2 | 2 | 2 | 2 | 2 |
tail[1] | 5 | 3 | 3 | 3 | 3 | |||
tail[2] | 7 | 7 | 7 | |||||
tail[3] | 101 | 18 |
通过上面的讲解,整个解题的思路已经基本清楚了,不过在这道题里面还有一个隐藏的考点就是二分查找法查找右侧边界,这个也可以单开一篇文章,这里就不多说了,可以参考这篇题解
代码如下:
public int lengthOfLIS(int[] nums) {
int[] tail = new int[nums.length];
int size = 0; // 数组大小计数
for (int x : nums) {
int i = 0, j = size;
while (i != j) { // 二分查找右边界
int m = (i + j) / 2;
if (tail[m] < x)
i = m + 1;
else
j = m;
}
tail[i] = x;
if (i == size) ++size;
}
return size;
}
总结
其实对于大多数人只要知道了第一种解法就基本掌握了动态规划的思路,核心要点就是找到正确的状态转移方程,然后进行遍历。第二种解法可以作为拓展思维,供大家学习娱乐参考。
更多内容请看我的个人博客
参考
动态规划 、贪心算法 + 二分
动态规划设计方法&&纸牌游戏讲解二分解法
[Lintcode] Longest Increasing Subsequence 最长上升序列
Java/Python Binary search O(nlogn) time with explanation