洛谷线性动态规划训练(1):leetcode 300.最长上升子段、P1020 导弹拦截

leetcode 300.最长上升子段

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

输入: [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

分析

这个是属于求最大值的动态规划,一个非常典型的问题,值得深思。

一开始想到的是dp[i]定义为:在[0,i]这个区间内的最大上升子序列的长度

那么我们就需要考虑如何将它进行子问题的划分。一种是这个最大上升子序列只能分为包含num[i],也可能不包含num[i]。如果包含了就加一就可以了,但是这样还不够具体。更加具体的思考这个问题,应该这么转化,也就是说:假设我们以及知道了dp[j]|j=1:i-1了,那么我们是否有可能根据dp[j]的信息(或者加上少量的辅助信息)就足够我们进行求解dp[i]了呢? 我们发现似乎是不够的,因为我们如果要求出dp[i],我们需要把num[i]和每个dp[j]的对应的序列里面的最大值去比较,如果比dp[j]的对应的序列里的最大值要大,那么dp[j]+1才能成为备选。 如果是这样的话,那就要求我们把每个dp[j]对应的得到最大值的序列给记录下来,这个记录具体序列的过程已经违反了动态规划的初衷了,因此这个定义并不是一个好的定义。(动态规划只需要之前的结果,并不关心之前的结果是如何具体得到的!)

后来想到的dp[i]定义为:以元素num[i]结尾的,最大的上升的子序列的长度

这个思想其实来自于最大子段和的思想。 在leetcode的题解中有3种遍历,一种是以头为节点进行遍历;一种是按长度遍历;一种是按照尾节点进行遍历。而动态规划很多时候就是按照尾节点进行遍历。这里按照尾节点进行定义,显然可以保证所有方案都是独立的,而且是完全的(因为任何一个上升子序列它总有结尾的,那么这个结尾一定是以某个元素结尾,因此最后要得到结果只需要遍历dp就可以了)。

那么在求dp[i]得状态转移方程时,我们知道它必须以num[i]结尾,因此我们可以对dp[0]~dp[i-1]进行遍历,如果num[i]>num[j]|j=0:i-1的话,那么这个时候,num[j]对应得dp[j]就可以作为备选;并且由于num[i]比num[j]大,因此dp[i]的备选应该是dp[j]+1,即比dp[j]要大1。

因此,备选的方案就是

S={dp[j]+1|当num[i]>num[j]时}

而dp[i]只要求上面这个集合S里面的最大值就可以了,即

dp[i]=max{dp[j]+1|当num[i]>num[j]时};

计算顺序是很简单的从左往右,初始化为1,因为自己本身就是一个子序列,最小就是1。复杂度为n^2。

O(n^2)复杂度代码
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
int lengthOfLIS(vector<int>& nums) {
	int n = nums.size();
	vector<int> dp(n,1);//定义dp[i]为以第i个元素结尾的最长上升子序列的长度
	dp[0] = 1;
	for (int i = 1; i < n; i++) {
		for (int j = 0; j < i; j++) {
			if (nums[i] > nums[j])
				dp[i] = max(dp[i], dp[j] + 1);
		}
	}
	int res = dp[0];
	for (int i = 0; i < n; i++) {
		res = max(res, dp[i]);
	}
	return res;
}

int main() {
	vector<int> vec = { 10, 9, 2, 5, 3, 7, 101, 18 };
	cout << lengthOfLIS(vec);
	return 0;
}
O(nlogn)复杂度代码
动态规划思想分析:

这里的动态规划的想法比上面那个要巧妙很多。我们知道遍历有3种方法,以头元素遍历(以某个元素开头),按照子序列长度遍历,按照尾部元素遍历(以某个元素结尾)。其中尾部元素遍历是动态规划常用的想法,在上面已经说了。那么这个方法其实结合了2,3两种方法。
它的dp[i]的定义为:所有i+1长度的上升序列,它的末尾元素的最小值。什么意思呢?

就以示例 [10,9,2,5,3,7,101,18] 为例,过程如下:
初始时,nums=[10,9,2,5,3,7,101,18],res=[]
i=0, res=[10]
i=1, res=[9]
i=2, res=[2]
i=3, res=[2,5]
i=4, res=[2,3]
i=5, res=[2,3,7]
i=6, res=[2,3,7,101]
i=7, res=[2,3,7,18]

直观的来说,如果你拿到了一个很大的数,可以直接加到尾端,那么很明显的,最长上升子序列的长度就会+1,这是没有问题的。进一步的思考,如果我拿到了一个比较大的数13,此时原本的上升序列为{5,10,15},那么我把序列最后一个15替换为13好像也不错啊,因为{5,10,13}仍然是个上升序列,而且它更容易产生更长的上升子序列啊。因此我们就直接替换好了,因此这个时候我们就不再考虑{5,10,15},而是专门来考虑{5,10,13}这个序列了。

但是上面这个例子是针对最后一个元素的,那么如果是倒数第二个元素怎么办呢?比如我拿到了个8,此时上升序列为{5,10,15}好像没什么用啊。但是我们可以注意到,**这个时候,长度恰好为2的上升序列就从{5,10}变成了{5,8}了,注意我们只取尾端的最小值,这是很有意义的!**通过这样不断的修改,就能得到最长的上升序列。

那么我们最后直接返回这个dp的长度,就是上升序列的长度。我觉得当时想到这个方法的人,一定是注意到了对于一个固定长度的序列,前面的元素都是没有用的,只有最后一个元素起决定作用,而且最后一个元素越小越好。另外就是想到这个方法的人也一定用了按照子序列长度遍历的方法来思考,因此设计状态的时候就设计为
dp[i]={长度恰好为i+1的子序列的最后一个元素的最小值}

考虑到所有子序列都有最后一个元素,而且子序列肯定是有不同的长度的,既然有最长,那么肯定也有次长的,因此这个序列基本上涵盖所有的方案。并且由于所有子序列长度不同,因此方案直接互相不重复。

代码分析

这个问题中,使用了二分查找,寻找左边界(第一个大于target的值进行替换)。一开始没有元素,right==0,右边界指向超尾0 left==超尾,说明元素比所有都大,直接push_back。如果能够查找到元素的话,那么直接替换就可以了。当然leetcode上也有直接开一个数组,然后用cnt来进行模拟的,我觉得也差不多。

相关资料链接

https://leetcode-cn.com/problems/longest-increasing-subsequence/
https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array/solution/er-fen-cha-zhao-suan-fa-xi-jie-xiang-jie-by-labula/

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        vector<int> tails;
        for(int i=0;i<nums.size();i++){
            int left=0;
            int right=tails.size();
            int target=nums[i];
            while(left<right){
                int mid=(left+right)/2;
                if(tails[mid]==target){
                    right=mid;
                }
                else if(tails[mid]>target){
                    right=mid;
                }
                else if(tails[mid]<target){
                    left=mid+1;
                }
            }//跳出循环时,left==right
            
            if(left==tails.size())
                tails.push_back(target);//取超尾,说明这个数很大
            else
                tails[left]=target;//不取超尾,此时left指向第一个比target大的数,将其替换
        }
        return tails.size();
    }
};
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值