[动态规划 二分查找] 300. 最长上升子序列(动态规划 → 动态规划 + 二分查找)

[动态规划 二分查找] 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;
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值