最长子序列问题看得最懂的一集!(Java版)

近期做十四届蓝桥杯真题,遇到好多类似子序列的题目,发现还是不会做,于是乎回来再做一遍代码随想录相关题目,回来总结一下这几道题子序列问题(300. 最长递增子序列674. 最长连续递增序列718. 最长重复子数组)。接下来的总结,先给出代码,代码中有详细注释,根据代码随想录中的动态规划五部曲,对每一步做出自己的理解,最后来个总的汇总。

300.最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1:

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

示例 2:

输入:nums = [0,1,0,3,2,3]
输出:4

示例 3:

输入:nums = [7,7,7,7,7,7,7]
输出:1

提示:

  • 1 <= nums.length <= 2500
  • -104 <= nums[i] <= 104

1.  dp数组的定义

关于dp数组的定义,首先应该确定维度

确定dp维度

一般根据所给数据的维度确定dp的维度。那这道题明显dp为1维。这毕竟是普遍的规律,当然,其实还得看题目问什么。那到底怎么看呢?这里我总结的规律是:

先分析问题,将问题简单化,然后抓住所给数据的某个数,对它进行提问,去思考解决刚刚那个简单的问题:“它跟哪几个维度的数据匹配能得到(简单问题的)答案”。有几维的数据,就有几维dp。

有点抽象,例如本题,简单化,其实困扰我们的无非就是如何找递增的子序列长度,最大的问题可以先不管,拿样例1中的nums[3](数字5)进行提问:5与谁匹配能成为递增的子序列?是不是要么跟5前面小于5的数匹配成为一个递增的子序列,要么跟5后面小于5的数匹配成为一个递增的子序列(往往不往后匹配),那不管往前往后,都还是与5同一个维度吧,所以dp只需要一维就足够了。

确定dp[i]的定义

上面还只是确定维度,那dp[i]如何定义呢?根据一般子序列的规律,都是:

“以xxx结尾的,最大的xxx。”都是以某几个数字结尾的xxx

那到底应该怎么定义的呢?我的理解是(尽力解释了,这个东西是真的抽象):其实在确定维度的时候,就已经把dp[i]的计算方法确定了:就是定住nums[i]去比较它前面的第j个(j < i)是否有小于它小于它则dp[i]等于dp[j] + 1。即dp[i]定义为:dp[i]表示的就是 0~i中,以nums[i]结尾的最大子序列长度。

2.  递推公式的确定

其实在上面思考dp应该如何定义的时候,就也解释了,就是:思考简化后的问题,与哪几个维度的数据有关。那么本题,简化之后是如何找递增的子序列长度。那这道题,刚刚分析了,是跟nums[i]前面的j个数有关,即:先确定一个nums[i],然后遍历它前面所有的nums[j],如果nums[j]小于nums[i],说明,nums[i]加上这个nums[j]是递增的,那么长度就是dp[j] + 1,即公式为:

if(nums[i] > nums[j])  dp[i] = dp[j] + 1;

但是有个问题,我们求的是“最长的”子序列长度,那么这样遍历过来,最终dp[i]的长度就有可能只由dp[i - 1]决定了啊(因为每个dp[j]都有覆盖dp[i]的可能)如何理解呢?举个例子:

nums = [4, 5, 6 , 1, 7]当我将nums[4]定住之后,从0开始遍历到3,即下面是它的遍历过程:

dp[4] = dp[0] + 1 =2;

dp[4] = dp[1] + 1 = dp[0] + 1 + 1 = 3;

dp[4] = dp[2] + 1 = dp[1] + 1 + 1= dp[0] + 1 + 1 + 1 = 4;

dp[4] = dp[3] + 1 = 1 + 1 = 2;

最终等到dp[4] = 2,为什么?因为刚刚从0~2累计到的长度4,被遇到dp[3]之后覆盖了!所以,应该加一个限定条件,就是,把每次遍历过程中最大的保留下来,即公式为:

if(nums[i] > nums[j])  dp[i] = Math.max(dp[i], dp[j] + 1);

 其实也能总结一个规律:

当所要求的dp[i]会被反复覆盖时,若求的是最优问题,往往都需要加上一个max/min的判断,把最优的保留下来。

3.  数组的初始化

往往数组的初始化是根据公式和定义来的,一般初始化的步骤,就是思考第一个数据应该是什么,如:当i等于0时,第一个数据dp[0]就一个数据,根据定义:dp[i]表示的是 0~i中,以nums[i]结尾的最大子序列长度。那i等于0时,nums[0]单独也能成为一个递增的子序列,所以应该要为1,而显然如果是下样例1:nums = [10,9,2,5,3,7,101,18],遍历到nums[2]时,dp[2]肯定也是一样道理,为1,所以,初始话便是:所有的dp[i]默认为1,因为自己本身能组成一个子序列

4.  数组的遍历

其实在前面基本把数组应该怎么遍历清楚了,即:先确定一个nums[i](设置一层for循环),然后把nums[i]前面的数(0~i-1)都遍历判断一遍(再设置一个for循环),是否比它小,是则要在比它小的那个dp[j]上加1。这部分上代码好理解:

        for(int i = 0; i < nums.length; i++) {
        	for(int j = 0; j < i; j++) {
        		if(nums[i] > nums[j]) {
        			dp[i] = Math.max(dp[i], dp[j] + 1);
        		}
        	}
        }

代码

最后~上代码!

​
class Solution {
    public int lengthOfLIS(int[] nums) {
        // 1. dp[i]:以nums[i]结尾的最长递增子序列长度
        int[] dp = new int[nums.length];
        // 2. 如果nums[i]>nums[j] dp[i] = Math.max(dp[i], dp[j] + 1);
        // 3. 初始化:所有数字本身就是一个序列,所以每个dp默认为1
        Arrays.fill(dp, 1);
        // 4. 遍历顺序:正常从左到右,并且要比较0 - i之间所有的子序列,故要两个for循环
        int max = 0;
        for(int i = 0; i < nums.length; i++) {
        	for(int j = 0; j < i; j++) {
        		if(nums[i] > nums[j]) {
        			dp[i] = Math.max(dp[i], dp[j] + 1);
        		}
        	}
            if(dp[i] > max) {// 取最大的,不一定是最后一个元素,根据定义,以最后一个结尾的递增子序列,不一定是最长的
                max = dp[i];
            }
        }
        // 5. 打印dp模拟过程
        // System.out.print(Arrays.toString(dp));
      return max;  
    }
}

​

千言万语,还是不如一段代码来得直接,不懂可以再根据这个代码模拟一遍。其实五部曲还有一步就是:5. 模拟。我觉得这一步需要读者自己用纸模拟一遍效果更好,用代码打印模拟效果没那么好。

674. 最长连续递增序列

给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。

连续递增的子序列 可以由两个下标 l 和 rl < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。

示例 1:

输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。 

示例 2:

输入:nums = [2,2,2,2,2]
输出:1
解释:最长连续递增序列是 [2], 长度为1。

提示:

  • 1 <= nums.length <= 104
  • -109 <= nums[i] <= 109

1.  dp数组的定义

与上一题类似,只是问题变成连续了,dp的定义思考过程与上一题一样,所以就不过多解释了,直接是:dp[i]表示以nums[i]结尾的“连续的”最长连续递增序列

2.  递推公式的确定

由于是连续的,所以dp[i]只与前一个有关,若nums[i-1]>nums[i]那dp[i]依旧等于最初的。

即公式为:if(nums[i - 1] < nums[i]) dp[i] = dp[i - 1] + 1;

3.  数组的初始化

和上题一样,默认为1

4.  数组的遍历

由于只与前一个有关,只需要一层for循环就够了。而且也不需要用max进行限制,因为只与上一个有关,若上一个不小于,那也没用,或者之前的必须是连续过来的才算数。

代码

最终代码

class Solution {
    public int findLengthOfLCIS(int[] nums) {
    	// 1. dp[i]:表示以nums[i]结尾的“连续”递增子序列的最大长度
    	int[] dp = new int[nums.length];
    	// 2. 如果nums[i] > nums[i-1] dp[i] = dp[i - 1] + 1;
    	// 3. 初始化,本身是一个递增序列,长度为1
    	int max = 1;    // Max至少是一个
    	Arrays.fill(dp, 1);
    	// 4. 遍历顺序只需要一层,不需要把i之前的子序列都遍历一遍,因为之前的就算是,已经不满足“连续”这一要求了,故一层循环足以
        // 但注意递推公式里有i - 1故起始点位1
    	for(int i = 1; i < nums.length; i++) {
    		if(nums[i] > nums[i-1]) {
    			dp[i] = dp[i - 1] + 1;
    		}
    		if(dp[i] > max) {
    			max = dp[i];
    		}
    	}
    	return max;
    }
}

718. 最长重复子数组

给两个整数数组 nums1 和 nums2 ,返回 两个数组中 公共的 、长度最长的子数组的长度 

示例 1:

输入:nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7]
输出:3
解释:长度最长的公共子数组是 [3,2,1] 。

示例 2:

输入:nums1 = [0,0,0,0,0], nums2 = [0,0,0,0,0]
输出:5

提示:

  • 1 <= nums1.length, nums2.length <= 1000
  • 0 <= nums1[i], nums2[i] <= 100

1.  dp数组的定义

这道题与上面两道题不一样了,明显有两个数组了。

确定维度

首先由一般规律,盲猜是二维,那为什么呢?其实这个问题问的是比较简单的,就是实现起来很困难,但也一样,思考这个问题的解决,由哪几个维度的数据决定。首先定住一个维度nums1中的某一序列,和上面一样,以nums1[i]结尾的子序列。然后呢?是不是很自然的就是去nums2中去找,以nums2[j]结尾的重复子序列啊,所以说,二维是实锤的。

确定dp[i][j]的定义

根据思路,先定住nums1中以nums1[i]结尾的“连续”的子数组,去找,nums2中相同的子数组。所以dp[i][j]表示:以nums1[i]结尾的连续的子数组与以nums2[j]结尾的连续子数组重复的长度。那会问了,为什么没有最大?因为最大不好操作了,如同上面的思路,要求最大,要判断每一个以nums1[i]结尾的最大长度,本来就要去遍历nums2找重复的,再回去找最大的已经不好操作了,而且这里的子序列是要求“连续”,用最大容易导致不连续,所以就是简单的求长度即可。

2.  递推公式的确定

当两个数组子序列的结尾相同之后,应该怎么计算重复的长度呢?很简单,两个子序列前面的长度加1嘛,所以递推公式就是:if(nums1[i] == nums2[j]) dp[i][j] = dp[i-1][j-1] + 1;如果不相等,那就什么都不做,和上题连续的一样,结尾的都不连续了,那后面也没意义了。

3.  数组的初始化

数组的初始化可就不好弄了,首先根据递推公式,遍历的时候肯定是从i = 1时,j = 1时,那如果nums1[1] == nums2[1] dp[1][1] = dp[0][0] + 1,dp[0][0]应该等于多少呢?,根据定义,这个得判断nums1[0]是否等于nums2[0]啊,拿示例1举例:

nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7]

由于nums1[0] != nums2[0],所以dp[1][1] = dp[0][0] + 1 = 1

但如果遇到下面这两种情况呢:

(1). 当nums[2] == nums[1]时

dp[2][1] = dp[1][0] + 1此时应该等于2,意味着dp[1][0]得初始化为1

(2). 当nums1[1] == nums2[3]时

dp[1][3] = dp[0][2] + 1此时应该等于2,意味着dp[0][2]得初始化为1

观察发现,dp[0][j]和dp[i][0]都应该特殊处理,即分别遍历两个数组,判断nums1[0]与nums2[j]相等情况,如果相等要将dp[0][j]赋值为1,同理dp[i][0]也是这样操作,代码如下:

for(int i = 0; i < nums1.length; i++) {
    		if(nums2[0] == nums1[i]) {
    			dp[i][0] = 1;
    		}
    	}
    	for(int j = 0; j < nums2.length; j++) {
    		if(nums1[0] == nums2[j]) {
    			dp[0][j] = 1;
    		}
    	}

可能还是有点抽象,其实这一类二维的题,遍历顺序都可以抽象为一个二维矩阵,如下图:

通过遍历图我们也可以知道,每个dp[i][j]都只与右上角有关,如果右上角没有合理的初始化,最终得到的dp就会出问题,所以说,dp[0][j]和dp[i][0]是需要特殊处理的。

4.  数组的遍历

根据上面我给出的图也可以看出来,遍历就是两层for循环,从上倒下,代码如下:

for(int i = 1; i < nums1.length; i++) {
    		for(int j = 1; j < nums2.length; j++) {
    			if(nums1[i] == nums2[j]) {
    				dp[i][j] = dp[i - 1][j - 1] + 1;
    			}
}

代码

别忘了我们这题是求最长的,根据上面的遍历图我们可以发现,其实这题所谓的动态规划,就是把所有情况打印出来了,我们只需要用一个max变量来储存最大的结果就行了。最终代码如下:

class Solution {
		// 方法1 dpdp[i][j]定义为:以nums[i]结尾的”连续的“子数组与nums[j]结尾的...
    public int findLength(int[] nums1, int[] nums2) {
    	// 1. dp[i][j]:以nums[i]结尾的”连续的“子数组与nums[j]结尾的(子数组中) 重复子数组长度
    	// 由于这里有两个数组,是否重复肯定是跟两个数组都有关的,即两个维度,dp用二维,
    	// 注意这里子数组,是连续的!!
    	int[][] dp = new int[nums1.length][nums2.length];
    	// 2. 递推公式:如果 nums[i] == nums[j] dp[i][j] = dp[i - 1][j - 1] + 1
    	// 解释:由于是连续的,当两个子数组结尾相同时,那就在前面的基础上加一,如果nums[i]和nums[j]两个数字前面的也相等,那么这个dp[i][j]就是2,不是那就是1
    	// 3. 初始化:由递推公式我们可以知道,i和j只能从1开始,但是这样就有个问题:
    	// 如果先遍历nums1,那nums2[0]要与nums1中的每一个数字都比较一下,看看是否相同,相同则dp[i][0] = 1
    	/* 例如:示例1 nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7]
    	 *    当i = 1, j = 1时,dp[1][1] = dp[0][0] + 1如果把nums2[0]改成1,那dp[0][0]不就应该初始化为1了吗,如果全部无脑初始化为0,岂不是就出问题了
    	 */
    	int max = 0;
    	for(int i = 0; i < nums1.length; i++) {
    		if(nums2[0] == nums1[i]) {
    			dp[i][0] = 1;
    			max = 1;
    		}
    	}
    	for(int j = 0; j < nums2.length; j++) {
    		if(nums1[0] == nums2[j]) {
    			dp[0][j] = 1;
    			max  = 1;	// 由于下面的遍历中,dp[i][j]可能都是0,那么最大值就可能是1了,所以要在这里把max赋值为1
    		}
    	}
    	// 4. 两个for循环遍历,初始都从1开始
    	for(int i = 1; i < nums1.length; i++) {
    		for(int j = 1; j < nums2.length; j++) {
    			if(nums1[i] == nums2[j]) {
    				dp[i][j] = dp[i - 1][j - 1] + 1;
    			}
    			// 如果不相等,那就啥也不干,dp[i][j]=0,为什么?因为这里是连续的,如果结尾的都不相等了,那整个子数组肯定不相等,长度为0
    			if(dp[i][j] > max) {
    				max = dp[i][j];
    			}
    		}
    		
    	}
    	return max;
    }
}

 其实我这样的dp定义,对于初始化的处理是有点麻烦的,代码随想录里给出的给出了一种更常见的定义方式,我也不多讲了,我觉得这样更适合新手理解,链接如下:代码随想录 (programmercarl.com)

总结

想着简单写写,结果写了这么多,最终总结一下吧。

截止我目前做过的这三道子序列动态规划问题,关键在于:

1. 确定dp维度:思考跟哪几个维度数据有关

2. 子序列问题大多也都是一种遍历先定住A维度中的某个数去遍历B维度中其他的数(或者A维度前面的数)。而往往,仔细分析,发现,下一对维度中的数据,是靠上一对维度中的数据推导出来的。

  • 29
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值