直面动态规划之实训篇(一)

1,564 阅读3分钟

这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战

上一篇讲了动态规划的理论之后,这一次我们来实践一波叭,今天是几道经典的子序列问题。这一类问题给我的感觉就是状态和选择不太好说,没有那么清晰,我们主要学习一下dp数组的定义(套路🤤)。

最长递增子序列(LIS)

力扣第300题 题目描述如下:

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。其中 子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

我一开始接触了一点动态规划,然后第一次做这道题的时候,定义出了如下的dp数组:

dp[i]代表的就是由nums[0]到nums[i]的最长递增子序列的长度。果然还是太年轻了,觉得这样只要我一把dp[n-1]求出来就能得到答案了。但是呢,我想不出状态转移方程,so let's quit.

别走别走,当我们把dp数组定义为:

dp[i]表示以nums[i]结尾的最长递增子序列的长度,就会发现“柳暗花明又一村”。这样当来到nums[i]的时候,由于是递增子序列,所以我们只要找到前面以比nums[i]小的元素结尾的序列,把nums[i]接到后面就可以形成一个新的递增子序列,且这个新的子序列长度加1。选出其中长度最长的,作为dp[i]的值。 而base case就是以nums[i]结尾的最长递增子序列至少包括它本身,所以长度为1。 通过以上分析,我们也可以写出状态转移方程

dp[i]=Math.max(dp[i],dp[j]+1);//其中nums[j]<nums[i]

直接上一波代码(家人们,跑力扣敲了一波,一遍过,蚌埠住了😭)

var lengthOfLIS = function(nums) {
    let dp=Array(nums.length).fill(1);
    for(let i=0;i<nums.length;i++){
        for(let j=0;j<i;j++){
            if(nums[j]<nums[i]){
                dp[i]=Math.max(dp[i],dp[j]+1);
            }
        }
    }
    let res=0;
    //因为我们的dp数组的定义,所以最后我们还得遍历一波dp数组,找出长度最大的那一个
    for(let i=0;i<dp.length;i++){
        res=Math.max(res,dp[i]);
    }
    return res;
};

最长公共子序列(LCS)

剑指offerII 95题 题目描述如下:

给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。

这一道题涉及了两个字符串,我们应该定义一个二维的dp数组:

dp[i][j]表示text1[0...i]和text2[0...j]的最长公共子序列的长度。base case为text1或text2为空串的情况,空串和任何字符串都没有公共子序列,所以长度为0。

我们可以画出以下的DP table:

image.png

那么如何推出我们的dp[i][j]呢?不难想到,我们需要判断text1[i]和text2[j]是否相等,如果相等的话,dp[i][j]=dp[i-1][j-1]+1,如果不相等的话,就将text1或text2往前减掉一位,再和对方比较,看谁的公共子序列更长就选谁,可以写出状态转移方程如下:

dp[i][j]=dp[i-1][j-1]+1//text1[i]==text2[j]
dp[i][j]=Math.max(dp[i-1][j],dp[i][j-1])//text1[i]!==text2[j]

翻译成代码如下(两遍过,为啥呢,看注释~):

var longestCommonSubsequence = function(text1, text2) {
    let m=text1.length;
    let n=text2.length;
    //初始化一波数组
    let dp=Array(m+1);
    for(let i=0;i<=m;i++){
        dp[i]=Array(n+1).fill(0);
    } 
    for(let i=1;i<=m;i++){
        for(let j=1;j<=n;j++){
        //注意这个地方text1的第i个字符索引是i-1,而text2的第j个字符索引是j-1。
            if(text1[i-1]==text2[j-1]){
                dp[i][j]=dp[i-1][j-1]+1;
            }
            else{
                dp[i][j]=Math.max(dp[i-1][j],dp[i][j-1]);
            }
        }
    }
    return dp[m][n];
};

最长回文子序列

力扣第516题 题目描述如下:

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。

虽然还是一个字符串的问题,但如果我们把dp数组定义成一维的,如”dp[i]表示以nums[i]结尾的最长会回文子序列的长度“,会发现状态转移方程根本无从下手。这时候就可以考虑下升维。我们把dp数组定义如下:

dp[i][j]代表子串s[i...j]中最长回文子序列的长度,base case就是当i和j相等时,就是单个字符最长回文子序列就是它本身,所以是1。 那如何推出我们的dp[i][j]呢?需要看s[i]和s[j]的情况,如果s[i]和s[j]相等的话,dp[i][j]=dp[i+1][j-1]+2(将s[i]和s[j]加入回文子序列中),如果不相等的话,我们就看去掉s[i]或s[j]的情况,哪一种情况得到的回文子序列最长,其长度就作为dp[i][j]的值。即状态方程如下:

dp[i][j]=dp[i+1][j-1]+2//s[i]==s[j]
dp[i][j]=Math.max(dp[i+1][j],dp[i][j-1])//s[i]!==s[j]

这个状态转移方程决定了我们需要反向遍历dp数组,因为在计算dp[i][j]的时候我们需要用到dp[i+1][j-1]或dp[i+1][j]。

代码如下:

var longestPalindromeSubseq = function(s) {
     let str0=s.split('');
     let dp=Array(str0.length);
    for(let i=0;i<str0.length;i++){
        dp[i]=Array(str0.length).fill(0);
    }
     for(let i=0;i<str0.length;i++){
         dp[i][i]=1;
     }
     for(let i=str0.length-2;i>=0;i--){
         for(let j=i+1;j<str0.length;j++){
             if(str0[i]==str0[j]){
                 dp[i][j]=dp[i+1][j-1]+2;
             }else{
                 dp[i][j]=Math.max(dp[i+1][j],dp[i][j-1]);
             }
         }
     }
     return dp[0][str0.length-1];
};

这一道写了好几次才通过,通过居然是在初始化数组时犯了错,给你看看这两种初始化的方法:

//第一种初始化方法,bingo
let dp1=Array(5);
    //console.log(dp[1][1]);
    for(let i=0;i<5;i++){
        dp1[i]=Array(5).fill(0);
    }
console.log(dp1);
dp1[1][1]=1;
console.log(dp1);
//第二种初始化方法,也就是我一开始写错的,想偷懒,没想到踩了细节坑。
let dp2=Array(5).fill(Array(5).fill(0));
console.log(dp2);
dp2[1][1]=1;
console.log(dp2);

看出错误了吗😅

今天就讲到这里啦,学习了几种子序列dp数组的定义套路,你学fei了吗?还有你会发现当我们定义对了dp数组,找好base case,再推导出状态转移方程,写代码一般很快,有时需要抠抠边界。