[动态规划 二分查找] 300. 最长上升子序列(动态规划 → 动态规划 + 二分查找)
300. 最长上升子序列
题目链接:https://leetcode-cn.com/problems/longest-increasing-subsequence/
分类:
- 动态规划:dp[i]表示以nums[i]为结尾的最长上升子序列长度,线性查找“nums[0~i-1]内小于nums[i]的元素中的dp最大值”,思路1;
- 二分查找:tail[i]表示长度为i+1的上升序列的最小尾部元素值,在当前上升序列里二分查找nums[i]插入的合适位置,思路2;
题目分析
这题要我们寻找的是最长上升子序列,注意,子序列不要求是连续的,只要保持在数组中的相对位置即可。
比较容易想到的是暴力解(回溯法),但是写起来太过繁琐且效率低,所以这里不再赘述。
思路1:动态规划
设dp[i]表示以nums[i]为结尾的最长上升子序列长度。
例如:nums=[10,9,2,5,3,7,101,18]
dp[0]=1;
dp[1]=1,遍历nums[0~i-1]都没有找到小于nums[1]的数,说明以它结尾的最长上升序列里只有它本身;
dp[2]=1,原因同上;
dp[3]=2,遍历nums[0~2],可以找到nums[2]<nums[3],所以nums[3]是以nums[2]为结尾的上升序列的延续,dp[3]=dp[2]+1=2;
dp[4]=2,遍历nums[0~3],可以找到nums[2]<nums[4],所以dp[3]=dp[2]+1;
以此类推。
dp数组的状态转移方程可以写作:
dp[i] = [0~i-1]小于nums[i]的元素中对应dp值的最大值 + 1;
如果[0~i-1]不存在小于nums[i]的元素,或i==0,则dp[i]=1;
实现遇到的问题:
上升序列要求是严格上升序列,相等元素不能算上升。
例如:[2,2]
预期输出:1
实现代码:
class Solution {
public int lengthOfLIS(int[] nums) {
if(nums == null || nums.length == 0) return 0;
int max = 1;
int[] dp = new int[nums.length];
dp[0] = 1;
for(int i = 1; i < nums.length; i++){
int tempMax = 0;
//在0~i-1上寻找小于nums[i]的元素,取它们的dp最大值
for(int j = 0; j < i; j++){
if(nums[j] < nums[i]){//上升序列是严格上升
tempMax = Math.max(dp[j], tempMax);
}
}
dp[i] = tempMax + 1;
max = Math.max(dp[i], max);
}
return max;
}
}
- 时间复杂度:O(N^2)
思路2:动态规划 + 二分查找 + 贪心
这个思路推荐阅读官方题解,它将来龙去脉梳理的很好:官方题解。
在思路1构造dp数组的过程中可以发现,时间主要消耗在线性查找“在nums[0~i-1]内小于nums[i]的元素中的dp最大值”,每查找一次需要花费O(N),如果我们改为二分查找就能将时间降到O(logN)。
二分查找要建立在有序数组上,但nums是无序的,所以我们要寻找一个有序数组。
首先明确二分查找的目的是:在nums[0 ~ i-1]内寻找小于nums[i]的元素中的dp最大值,本质上是在[0~i-1]上寻找nums[i]可以加入的最长上升序列的末尾。
例如:[10,9,2,5,3,7,101,18]
计算dp[5]时,nums[5]=7,它可以加入到上升序列2,5或2,3后面;
可以发现当前的上升序列{2,3}或{2,5}就是一个有序数组,我们的目的就是在这个有序数组上找到5或3,就能确定nums[5]的插入位置。
在当前有序序列上查找我们就可以使用二分查找了,因此我们设置一个数组tail保存当前构造中的上升序列,tail[i]表示长度为i+1的上升序列的最小尾部元素值,例如本例中tail[0] = 2,tail[1] = 3,对应上升序列为{2,3}。
为什么要选择最小尾部元素值(贪心思想)?这是因为,为了尽可能得到更长的上升序列,我们就需要让这个序列的数值尽可能增长的慢,所以每次都选择当前长度下可选的最小元素加入tail[i],因此上面例子里上升序列取{2,3}而不取{2,5}。
为什么tail数组是严格升序的?反证法,见官方题解。
由于根据tail[i]就可以得到以它为结尾的最长上升序列长度i+1,所以dp数组就没有设置的必要了。
直接用一个例子来理解tail的工作流程:
例如:[2,5,3,7,101,18]
在遍历到nums[0]=2时,上升序列{}为空,2可以加入序列末尾得到长度为1的上升子序列{2},所以置tail[0]=2(tail的初始值);
在遍历到nums[1]=5时,上升序列为{2},5比当前上升序列{2}的最大元素都大,所以把5加入到该序列末尾的下一个位置,得到长度为2的上升子序列{2,5},所以置tail[1]=5;
在遍历到nums[2]=3时,上升序列为{2,5},在{2,5}内查找到3可以加入的位置在2的下一位(这里就需要用到二分查找),即插入到tail[1]处,因为3<5,所以把tail[1]=5替换为tail[1]=3,得到的仍然是长度为2的上升序列{2,3};
...以此类推。
由此可以得到思路2基于tail数组的算法流程:
1、开辟tail数组存放当前的最长上升序列,tail[i]表示长度为i+1的上升序列的最小尾部元素,再设置一个count记录当前上升序列的最大长度,初始时tail[0]=nums[0],count=1。
2、对于新到来的nums[i],我们先比较它和当前上升序列的最大值tail[count-1]的大小关系:
- 如果nums[i]>tail[count-1],说明nums[i]比当前序列的所有元素都大,直接插入到序列末尾的下一位,整个序列的长度count+1,即tail[count++]=nums[i];
- 如果nums[i]<=tail[count-1],说明nums[i]会加入到当前序列内部,替换其中的某一元素,所以我们需要在tail[0~count-1]上用二分查找把合适的位置找出来,合适的位置就是:tail[j-1]<nums[i]<tail[j],找出j,用nums[i]覆盖tail[j]。nums[i]是替换序列内部的一个元素,所以替换后整个序列的长度是不变的。
3、nums所有元素处理完毕后,count记录的就是最长上升序列的长度。
如何在tail[0~count-1]上二分查找满足tail[j-1]<nums[i]<tail[j]的j?
left=0,right=count-1,闭区间查找,所以退出循环的条件是left>right;
取中位点mid=(left+right)/2,比较tail[mid]和nums[i]:
- 如果tail[mid]<nums[i],说明nums[i]要加入的位置在mid的右侧,令left=mid+1;
- 如果tail[mid]>=nums[i],说明nums[i]要加入的位置在mid处或mid的左侧,令right=mid-1;
当left>right退出循环时,left指向的就是nums[i]要替换的位置,令tail[left]=nums[i]。
实现代码:
class Solution {
public int lengthOfLIS(int[] nums) {
if(nums == null || nums.length == 0) return 0;
int[] tail = new int[nums.length];
tail[0] = nums[0];
int count = 1;//记录tail中的最大上升序列长度
for(int i = 1; i < nums.length; i++){
//如果nums[i]比当前序列的最大值还大,就插入到序列尾部的下一位,序列长度count+1
if(nums[i] > tail[count - 1]) tail[count++] = nums[i];
//如果nums[i]小于当前序列的最大值,则需要在当前序列[0~count-1]内部寻找一个合适的位置进行覆盖更新
else{
//在tail[0~count-1]中二分查找nums[i]合适的位置:即查找最后一个<nums[i]的元素
int left = 0, right = count - 1;
while(left <= right){
int mid = left + (right - left) / 2;
if(tail[mid] < nums[i]) left = mid + 1;
else right = mid - 1;;
}
//退出循环时left指向nums[i]要覆盖的位置
tail[left] = nums[i];
}
}
return count;
}
}