LeetCode刷题------动态规划

什么是动态规划

动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心(贪心算法的学习链接  代补上),贪心没有状态推导,而是从局部直接选最优的。如果某一问题有很多重叠子问题,使用动态规划是最有效的。

动态规划的解题步骤

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

动态规划应该如何debug

找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的。做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。

LeetCode刷题

基础题1:LeetCode509:斐波那契数​​​​​​​​​​​​

var fib = function(n) {
    let dp = [0, 1];     //步骤三:dp数组初始化
    for (let i = 2; i <= n; i++) {   //步骤三:确定遍历顺序
        dp[i] = dp[i-1] + dp[i-2];   //步骤二:确定递推公式
    }

    return dp[n];

};



// 另一种写法
// 动规状态转移中,当前结果只依赖前两个元素的结果,所以只要两个变量代替dp数组记录状态过程。将空间复杂度降到O(1)
var fib = function(n) {
    let pre1 = 0;
    let pre2 = 1;
    let temp;
    if (n === 0) {
        return 0;
    }
    if (n === 1) {
        return 1;
    }
    for (let i = 2; i <= n; i++) {
        temp = pre2;
        pre2 = pre1 + pre2;
        pre1 = temp;
    }
    return pre2;
};

基础题2:LeetCode70:爬楼梯

题目的理解问题:dp数组如何初始化?题目只考虑n=正整数,所以我们不考虑dp[0]如何初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义。而对dp[0]等于多少的解释=》从dp数组定义的角度上来说,dp[0] = 0 也能说得通,跑到第0层,方法就是0啊,一步只能走一个台阶或者两个台阶,然而楼层是0,直接站楼顶上了,就是不用方法,dp[0]就应该是0。另一种说法,爬到第0层,也有一种方法,什么都不做也就是一种方法即:dp[0] = 1,相当于直接站在楼顶,在递推的过程中i从2开始遍历本题就能过,然后就往结果上靠去解释dp[0] = 1。

var climbStairs = function(n) {
    let dp = [1, 2];
    for (let i = 2; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n - 1];
};

<扩展>如果一步一个台阶,两个台阶,三个台阶,直到 m个台阶,有多少种方法爬到n阶楼顶。这道题目leetcode上并没有原题。

<扩展>logn 矩阵快速幂算法   代补上

基础题3:LeetCode746:使用最小花费爬楼梯

你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯” 也就是相当于 跳到 下标 0 或者 下标 1 是不花费体力的, 从 下标 0 下标1 开始跳就要花费体力了。

1.确定dp数组以及下标的含义:使用动态规划,就要有一个数组来记录状态,本题只需要一个一维数组dp[i]就可以了。dp[i]的定义:到达第i台阶所花费的最少体力为dp[i]对于dp数组的定义,大家一定要清晰!

2.确定递推公式:可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]。dp[i - 1] 跳到 dp[i] 需要花费 dp[i - 1] + cost[i - 1]。dp[i - 2] 跳到 dp[i] 需要花费 dp[i - 2] + cost[i - 2]。那么究竟是选从dp[i - 1]跳还是从dp[i - 2]跳呢?一定是选最小的,所以dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);

3.dp数组如何初始化:看一下递归公式,dp[i]由dp[i - 1],dp[i - 2]推出,既然初始化所有的dp[i]是不可能的,那么只初始化dp[0]和dp[1]就够了,其他的最终都是dp[0]dp[1]推出。那么 dp[0] 应该是多少呢? 根据dp数组的定义,到达第0台阶所花费的最小体力为dp[0],那么有同学可能想,那dp[0] 应该是 cost[0],例如 cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] 的话,dp[0] 就是 cost[0] 应该是1。这里就要说明本题力扣为什么改题意,而且修改题意之后 就清晰很多的原因了。新题目描述中明确说了 “你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。” 也就是说 从 到达 第 0 个台阶是不花费的,但从 第0 个台阶 往上跳的话,需要花费 cost[0]。所以初始化 dp[0] = 0,dp[1] = 0;

4.确定遍历顺序:最后一步,递归公式有了,初始化有了,如何遍历呢?因为是模拟台阶,而且dp[i]由dp[i-1]dp[i-2]推出,所以是从前到后遍历cost数组就可以了。

var minCostClimbingStairs = function(cost) {
    const dp = [cost[0], cost[1]];  //到达下标0或下标1是不费体力的
    for (let i = 2; i < cost.length; i++) {
        dp[i] = Math.min(dp[i-1]+cost[i], dp[i-2]+cost[i]);
    }
    return Math.min(dp[cost.length-1], dp[cost.length-2]);
};

基础题4:LeetCode62:不同路径

1.确定dp数组(dp table)以及下标的含义

dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。

2.确定递推公式。想要求dp[i][j],只能有两个方向来推导出来,即dp[i - 1][j] 和 dp[i][j - 1]。此时在回顾一下 dp[i - 1][j] 表示啥,是从(0, 0)的位置到(i - 1, j)有几条路径,dp[i][j - 1]同理。那么很自然,dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来。

3.dp数组的初始化。如何初始化呢,首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。

4.确定遍历顺序。这里要看一下递推公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1],dp[i][j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。这样就可以保证推导dp[i][j]的时候,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值的。

var uniquePaths = function(m, n) {
    const dp = Array(m).fill().map(item => Array(n));   //创建一个二维数组
    //初始化向下走
    for (let i = 0; i < m; i++) {
        dp[i][0] = 1;  
    }
    //初始化向右走
    for (let i = 0; i < n; i++) {
        dp[0][i] = 1;
    }
    for (let i = 1; i<m; i++) {
        for (let j = 1; j<n; j++) {
            dp[i][j] = dp[i-1][j] + dp[i][j-1];
        }
    }
    return dp[m-1][n-1];
};

基础题5:LeetCode63:不同路径 II

有障碍的话,其实就是标记对应的dp table(dp数组)保持初始值(0)就可以了。

动规五部曲:

1.确定dp数组(dp table)以及下标的含义:dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。

2.确定递推公式:递推公式和62.不同路径一样,dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。但这里需要注意一点,因为有了障碍,(i, j)如果就是障碍的话应该就保持初始状态(初始状态为0)。

3.dp数组如何初始化:如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i][0]应该还是初始值0。

var uniquePathsWithObstacles = function(obstacleGrid) {
    const m = obstacleGrid.length
    const n = obstacleGrid[0].length
    const dp = Array(m).fill().map(item => Array(n).fill(0))
    
    for (let i = 0; i < m && obstacleGrid[i][0] === 0; ++i) {
        dp[i][0] = 1
    }
    
    for (let i = 0; i < n && obstacleGrid[0][i] === 0; ++i) {
        dp[0][i] = 1
    }
    
    for (let i = 1; i < m; ++i) {
        for (let j = 1; j < n; ++j) {
            dp[i][j] = obstacleGrid[i][j] === 1 ? 0 : dp[i - 1][j] + dp[i][j - 1]
        }
    }
        
    return dp[m - 1][n - 1]
};

// 版本二:内存优化,直接以原数组为dp数组
var uniquePathsWithObstacles = function(obstacleGrid) {
    const m = obstacleGrid.length;
    const n = obstacleGrid[0].length;
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (obstacleGrid[i][j] === 0) {
                // 不是障碍物
                if (i === 0) {
                    // 取左边的值
                    obstacleGrid[i][j] = obstacleGrid[i][j - 1] ?? 1;
                } else if (j === 0) {
                    // 取上边的值
                    obstacleGrid[i][j] = obstacleGrid[i - 1]?.[j] ?? 1;
                } else {
                    // 取左边和上边的和
                    obstacleGrid[i][j] = obstacleGrid[i - 1][j] + obstacleGrid[i][j - 1];
                }
            } else {
                // 如果是障碍物,则路径为0
                obstacleGrid[i][j] = 0;
            }
        }
    }
    return obstacleGrid[m - 1][n - 1];
};

基础题6:LeetCode343:整数拆分

1.确定dp数组(dp table)以及下标的含义:dp[i]:分拆数字i,可以得到的最大乘积为dp[i]。dp[i]的定义将贯彻整个解题过程,下面哪一步想不懂了,就想想dp[i]究竟表示的是啥!

2.确定递推公式:可以想 dp[i]最大乘积是怎么得到的呢?其实可以从1遍历j,然后有两种渠道得到dp[i]。一个是j * (i - j) 直接相乘。一个是j * dp[i - j],相当于是拆分(i - j),对这个拆分不理解的话,可以回想dp数组的定义。j怎么就不拆分呢?j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了。那么从1遍历j,比较(i - j) * j和dp[i - j] * j 取最大的。递推公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));也可以这么理解,j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘。如果定义dp[i - j] * dp[j] 也是默认将一个数强制拆成4份以及4份以上了。所以递推公式:dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});那么在取最大值的时候,为什么还要比较dp[i]呢?因为在递推公式推导的过程中,每次计算dp[i],取最大的而已。

3.dp的初始化:不少同学应该疑惑,dp[0] dp[1]应该初始化多少呢?有的题解里会给出dp[0] = 1,dp[1] = 1的初始化,但解释比较牵强,主要还是因为这么初始化可以把题目过了。严格从dp[i]的定义来说,dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。拆分0和拆分1的最大乘积是多少?这是无解的。这里我只初始化dp[2] = 1,从dp[i]的定义来说,拆分数字2,得到的最大乘积是1,这个没有任何异议!

4.确定遍历顺序:确定遍历顺序,先来看看递归公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。

var integerBreak = function(n) {
    let dp = new Array(n+1).fill(0);
    dp[2] = 1;

    for (let i = 3; i <= n; i++) {
        for (let j = 1; j <= i/2; j++) {
            dp[i] = Math.max(dp[i], dp[i-j]*j, (i-j)*j);
        }
    }
    return dp[n];
};

<扩展>贪心算法

基础题7:LeetCode96:不同的二叉搜索树

1.确定dp数组(dp table)以及下标的含义:dp[i] : 1到i为节点组成的二叉搜索树的个数为dp[i]。也可以理解是i个不同元素节点组成的二叉搜索树的个数为dp[i] ,都是一样的。

2.确定递推公式:在上面的分析中,其实已经看出其递推关系, dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]。j相当于是头结点的元素,从1遍历到i为止。所以递推公式:dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量

3.dp数组如何初始化:初始化,只需要初始化dp[0]就可以了,推导的基础,都是dp[0]。那么dp[0]应该是多少呢?从定义上来讲,空节点也是一棵二叉树,也是一棵二叉搜索树,这是可以说得通的。从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。所以初始化dp[0] = 1

4.确定遍历顺序:首先一定是遍历节点数,从递归公式:dp[i] += dp[j - 1] * dp[i - j]可以看出,节点数为i的状态是依靠 i之前节点数的状态。那么遍历i里面每一个数作为头结点的状态,用j来遍历。

var numTrees = function(n) {
    let dp = new Array(n+1).fill(0);
    dp[0] = 1;
    dp[1] = 1;

    for (let i = 2; i <= n; i++) {
        for(let j = 1; j <= i; j++) {
            dp[i] += dp[j-1]*dp[i-j];
        }
    }
    return dp[n];

};

背包问题

分类:

416.分割等和子集1

对于面试的话,其实掌握01背包和完全背包,完全背包又是也是01背包稍作变化而来。

***01背包***

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

【暴力解法】每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是$o(2^n)$,这里的n表示物品数量。

【动态规划解法】动态规划五部曲

一、二维数组dp

1、确定dp数组,下标含义:二维数组dp,dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少

2、 递推公式:有2个方向可以得到dp[i][j]

  • 不放物品i:由dp[i - 1][j]推出,即背包容量为j=里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以被背包内的价值依然和前面相同。)
  • 放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值

所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

3、初始化dp

首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0

dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。

当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。

从递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出dp[i][j] 是由左上方数值推导出来了,那么 其他下标初始为什么数值都可以,因为都会被覆盖。只不过一开始就统一把dp数组统一初始为0,更方便一些。

4、确定遍历顺序

先遍历物品

5、举例推导dp数组

二、一维滚动数组

1、确定dp数组的定义:在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。

2、一维dp数组的递推公式

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

3、初始化

4、遍历顺序

二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

LeetCode:416. 分割等和子集(给定背包容量,求能否装满)

分析:

  • 将这个数组分割成两个子集,使得两个子集的元素和相等。=》将背包的体积分成相等的2份:sum/2
  • 集合里的元素,就是要放入背包的商品,元素的具体数值就是商品的价值
  • 背包正好装满,就找到了总和为sum/2的子集
  • 背包的每一个元素是不可以重复放入的。=》01背包

五部曲:

1、确定dp和下标含义:dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]

2、确定递推公式:01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);物品i的重量是nums[i],其价值也是nums[i]。所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])

3、初始化dp数组:dp[0]一定是0。如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷

4、遍历顺序:如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历

5、举例推断

var canPartition = function(nums) {
    const sum = (nums.reduce((p,v)=>p+v));  // 定义数组之和
    if (sum !== 0) {  // 如果和不为偶数,就没有办法分割成两个元素和相等的数组
        return false;
    }
    const dp = Array(sum/2+1).fill(0);  // 定义dp数组,初始化为0,长度比和+1
    // 遍历物品
    for (let i=0; i<nums.length; i++) {
        // 倒序遍历价值
        for (let j=sum/2; j>=nums[i]; j--) {
            dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]);
            if (dp[j] === sum/2) {
                return true;
            }
        }
    }
    return dp[sum/2] === sum/2;
};

[扩展]可以用回溯算法,如果用则有关题目如下:

  • 698.划分为k个相等的子集
  • 473.火柴拼正方形

LeetCode:1049. 最后一块石头的重量 II(给定背包容量,求尽量装满能装多少)

var lastStoneWeightII = function(stones) {
    let sum = stones.reduce((s,n)=>s+n)  // reduce用于遍历,s是初始值,n是当前元素
    let dpLength = Math.floor(sum / 2);  // 把一堆石头分成两堆,求两堆石头重量差最小值 进一步分析:要让差值小,两堆石头的重量都要接近sum/2
    let dp = new Array(dpLength + 1).fill(0);

    for (let i=0; i< stones.length; ++i) {
        for (let j=dpLength; j>=stones[i]; --j){
            dp[j] = Math.max(dp[j], dp[j-stones[i]] + stones[i]);
        }
    }
    return sum - dp[dpLength] - dp[dpLength]
};

LeetCode:494. 目标和(给定背包容量,求装包的方法)

var findTargetSumWays = function(nums, target) {
    const sum = nums.reduce((a, b) => a+b);
    
    if(Math.abs(target) > sum) { // target>数组的和,肯定没办法
        return 0;
    }

    if((target + sum) % 2 !== 0) {  // 
        return 0;
    }

    const halfSum = (target + sum) / 2;  // 加法的总和,也就是要装满的背包的容量

    let dp = new Array(halfSum+1).fill(0);  // 定义dp
    dp[0] = 1;  // 初始化dp

    for(let i = 0; i < nums.length; i++) {
        for(let j = halfSum; j >= nums[i]; j--) {
            dp[j] += dp[j - nums[i]];
        }
    }

    return dp[halfSum];
};

LeetCode:474. 一和零(给定背包容量,求装包最多装几个物品)

本题中strs 数组里的元素就是物品,每个物品都是一个!而m 和 n相当于是一个背包,两个维度的背包。不同长度的字符串就是不同大小的待装物品。

1、确定dp数组(dp table)以及下标的含义:dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。

2、递推公式:dp[i][j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1。dp[i][j] 就可以是 dp[i - zeroNum][j - oneNum] + 1。在遍历的过程中,取dp[i][j]的最大值。dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1)

3、dp初始化:01背包的dp数组初始化为0就可以。因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖。

4、遍历顺序:01背包一定是外层for循环遍历物品内层for循环遍历背包容量且从后向前遍历

5、举例论证推理正确

var findMaxForm = function(strs, m, n) {
    let dp = new Array(m+1).fill(0).map(item => new Array(n+1).fill(0));
    // 记录字符串中0和1的数量
    let numOfZeros, numOfOnes;
    // 遍历strs
    for (let str of strs) {
        numOfOnes = 0;
        numOfZeros = 0;
        // 记录strs中的每个str的0和1数量
        for (let c of str) {
            if (c === '0') {
                numOfZeros++;
            } else {
                numOfOnes ++;
            }
        }
        // 遍历背包容量,都是从后向前。这题不涉及遍历物品容量,而且m和n被看成是两个背包,每个背包都看成是01背包
        // 从后向前遍历容量为m的背包
        for (let i=m; i >= numOfZeros; i--) {
            // 从后向前遍历容量为n的背包
            for (let j=n; j >= numOfOnes; j--) {
                // 确定dp来源
                dp[i][j] = Math.max(dp[i][j], dp[i-numOfZeros][j-numOfOnes] + 1)
            }
        }
    }
    return dp[m][n]; 
};

***完全背包***

1、问题描述

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

2、与01背包的区别:每种物品有无限件。在做题五部曲中,01背包和完全背包唯一不同就是体现在遍历顺序

  • 01背包的内层遍历背包容量是从后向前(从大到小)遍历,完全背包是从前向后(从小到大)遍历;
  • 01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!

3、举例说明如何解决此类问题

在做题五部曲中,01背包和完全背包唯一不同就是体现在遍历顺序

  • 01背包:外层遍历物品,从前向后(从小到大)遍历;内层遍历背包容量,从后向前(从大到小)遍历
  • 完全背包:外层遍历物品,同01背包;内层遍历背包容量,也是从前向后(从小到大)遍历

该例子的解题代码:

// 先遍历物品,再遍历背包容量
function test_completePack1() {
    let weight = [1, 3, 5]
    let value = [15, 20, 30]
    let bagWeight = 4 
    let dp = new Array(bagWeight + 1).fill(0)
    for(let i = 0; i <= weight.length; i++) {
        for(let j = weight[i]; j <= bagWeight; j++) {
            dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])
        }
    }
    console.log(dp)
}

// 先遍历背包容量,再遍历物品
function test_completePack2() {
    let weight = [1, 3, 5]
    let value = [15, 20, 30]
    let bagWeight = 4 
    let dp = new Array(bagWeight + 1).fill(0)
    for(let j = 0; j <= bagWeight; j++) {
        for(let i = 0; i < weight.length; i++) {
            if (j >= weight[i]) {
                dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i])
            }
        }
    }
    console.log(2, dp);
}

注意!!以上说的都是纯纯完全背包的理论和解题方法,LeetCode上无纯纯的完全背包,全是有变化的题,那么两个for循环的先后顺序就很不一样了

LeetCode:518. 零钱兑换 II

一看到钱币数量不限,就知道这是一个完全背包。但本题和纯完全背包不一样,纯完全背包是凑成背包最大价值是多少,而本题是要求凑成总金额的物品组合个数!

1、确定dp和下标含义:凑成总金额j的货币组合数为dp[j]

2、递推公式:dp[j] 就是所有的dp[j - coins[i]](考虑coins[i]的情况)相加,dp[j] += dp[j - coins[i]]

3、dp初始化:dp[0]一定要为1,dp[0] = 1是 递归公式的基础。如果dp[0] = 0 的话,后面所有推导出来的值都是0了。后台测试数据是默认amount = 0 的情况,组合数为1的。

4、遍历顺序:纯完全背包求得装满背包的最大价值是多少,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行。本题要求凑成总和的组合数,元素之间明确要求没有顺序。

在求装满背包有几种方案的时候,认清遍历顺序是非常关键的。

  • 如果求组合数就是外层for循环遍历物品,内层for遍历背包。
  • 如果求排列数就是外层for遍历背包,内层for循环遍历物品。

在本题中,求组合数,所以只能是外层遍历物品,内层遍历背包

var change = function(amount, coins) {
    // 定义dp
    let dp = new Array(amount+1).fill(0);
    // 初始化dp
    dp[0] = 1;
    // 外层遍历物品
    for (let i=0; i<coins.length; i++) {
        // 内层遍历背包容量
        for (let j=coins[i]; j<=amount; j++) {
            // dp的每一项,代表组成的方法数量
            dp[j] += dp[j - coins[i]];
        }
    }
    return dp[amount];
};

LeetCode:377. 组合总和 Ⅳ

target就是背包容量,nums中的元素就是物品

本题中,顺序不同的序列被视作不同的组合,其实就是求排列

1、确定dp及下标含义:dp[i]表示凑成目标数i的排列个数

2、递推公式:dp[i] += dp[i - nums[j]]

3、dp初始化:dp[0]要初始化为1,非0下标的dp[i]初始化为0

4、遍历顺序:因为是求排列,外层遍历背包,内层遍历物品

5、举例证明推理正确

var combinationSum4 = function(nums, target) {
    // 定义dp
    let dp = new Array(target+1).fill(0);
    // 初始化dp
    dp[0] = 1;
    // 外层遍历背包,target是背包容量
    for (let i=0; i<=target; i++) {
        // 内层遍历物品,nums中的元素就是物品
        for (let j=0; j<nums.length; j++) {
            // 当背包容量大于等于元素大小时,才可以有组合方式
            if (i >= nums[j]) {
                // 背包的每个容量dp[i]上的值,表示要达到该容量,可以有几种组合方式
                dp[i] += dp[i-nums[j]];
            }
        }
    }
    return dp[target];
};

LeetCode:70. 爬楼梯(改题版)

将题目改成:一步一个台阶,两个台阶,三个台阶,.......,直到 m个台阶。问有多少种不同的方法可以爬到楼顶呢?

1阶,2阶,.... m阶就是物品,楼顶就是背包。每一阶可以重复使用,例如跳了1阶,还可以继续跳1阶。问跳到楼顶有几种方法其实就是问装满背包有几种方法。

1、dp及下标含义:dp[i]表示要达到楼层i,有dp[i]种方法

2、递推公式:dp[i] += dp[i - j],j=本次的台阶数值

3、初始化:dp[0]=1,其余为0

4、遍历顺序:外层遍历背包容量(楼顶数值),内层遍历物品(台阶数)

5、举例论证推导

var climbStairs = function(n) {
    const dp = new Array(n + 1).fill(0);
    const m = 2;
    dp[0] = 1;
    for(let i = 1; i <= n; i++){
        for(let j = 1; j <= m; j++){
            if(i >= j) {
	    	dp[i] += dp[i - j];
	     }
        }
    }
    return dp[n];
};

LeetCode:322. 零钱兑换

每种硬币的数量是无限的,是典型的完全背包问题。

1、dp及下标含义:dp[i]表示要满足金额i,最少需要dp[i]个硬币

2、递推公式:dp[i] = min(dp[i], dp[i-coins[i]]+1)

3、dp初始化:dp[0]=0,其余为无限大,因为题目要求的是硬币的最小值

4、遍历顺序:本题不要求组合还是排列,那么可以按组合来,外层循环物品(硬币数组),内层循环背包容量(amount)

5、举例论证推导正确

var coinChange = function(coins, amount) {
    // 定义dp
    let dp = new Array(amount+1).fill(Infinity);
    // 初始化dp
    dp[0] = 0;
    // 外层循环物品(硬币)
    for (let i=0; i<coins.length; i++) {
        // 内层循环背包(amount)
        for (let j=coins[i]; j<=amount; j++) {
            dp[j] = Math.min(dp[j], dp[j-coins[i]]+1)
        }
    }
    // 判断最终能否有方案能组成amount,如果没有就返回-1
    return dp[amount] === Infinity ? -1 : dp[amount]
};

LeetCode:279. 完全平方数

完全平方数就是物品(可以无限件使用),凑个正整数n就是背包,问凑满这个背包最少有多少物品?

1、dp及下标含义:dp[i]表示正整数i,最少有dp[i]个完全平方数,dp[i]个完全平方数的和等于正整数i

2、递推公式:dp[i] = min(dp[i], dp[i - j*j]),j=上一个完全平方数

3、dp初始化:dp[0]=0,其余为正无穷

4、遍历顺序:不涉及排列,内外层遍历顺序无所谓,还是使用外层遍历物品(完全平方数),内层遍历背包(正整数n)

5、举例论证推导正确

var numSquares = function(n) {
    // 定义dp
    let dp = new Array(n+1).fill(Infinity);
    // 初始化dp
    dp[0] = 0;
    // 外层循环物品(完全平方数,从1开始)
    for (let i=1; i**2<=n; i++){
        // 内层循环背包(正整数n)
        for (let j=i**2; j<=n; j++){
            dp[j] = Math.min(dp[j], dp[j-i**2]+1)
        }
    }
    return dp[n]
};

LeetCode:139. 单词拆分

单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满。拆分时可以重复使用字典中的单词,说明就是一个完全背包

1、dp及下标含义:dp[i]表示字符串长度为i,可以拆分成dp[i]个单词,dp[i]处的值就是true

2、递推公式: if([j, i] 这个区间的子串出现在字典里 && dp[j]是true) 那么 dp[i] = true

3、初始化dp:dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递推的根基,dp[0]一定要为true,否则递推下去后面都都是false了。下标非0的dp[i]初始化为false

4、遍历顺序:本题其实我们求的是排列数。 拿 s = "applepenapple", wordDict = ["apple", "pen"] 举例。"apple", "pen" 是物品,那么我们要求 物品的组合一定是 "apple" + "pen" + "apple" 才能组成 "applepenapple"。"apple" + "apple" + "pen" 或者 "pen" + "apple" + "apple" 是不可以的,那么我们就是强调物品之间顺序。所以说,本题一定是先遍历背包,再遍历物品

5、举例论证推导正确

var wordBreak = function(s, wordDict) {
    // 定义dp
    let dp = new Array(s.length+1).fill(false);
    // 初始化dp
    dp[0] = true;
    // 外层循环背包(字符串s)
    for (let i=0; i<=s.length; i++) {
        // 内层循环物品(wordDict)
        for (let j=0; j<wordDict.length; j++) {
            // 当s长度大于wordDict中单词的长度,才有可能s是由wordDict中的单词组成的长单词
            if (i >= wordDict[j].length) {
                // 剪裁字符串s,看是否含有wordDict里的单词
                if (s.slice(i - wordDict[j].length, i) === wordDict[j] && dp[i - wordDict[j].length]) {
                    // s含有wordDict的单词,就将该处的dp设为true
                    dp[i] = true;
                }
            }
        }
    }
    return dp[s.length];
};

[扩展]升级版,困难题140. 单词拆分 II - 力扣(LeetCode)

***多重背包***

LeetCode暂无有关题

1、题目描述

有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。

2、举例说明

 多重背包和01背包相似,因为将多重背包展开,就可以用01背包解决问题:

 从遍历顺序来看,多重背包与01背包的区别是,在最内层加一层for循环,遍历物品数量

function testMultiPack() {
  const bagSize: number = 10;
  const weightArr: number[] = [1, 3, 4],
    valueArr: number[] = [15, 20, 30],
    amountArr: number[] = [2, 3, 2];
  const goodsNum: number = weightArr.length;
  const dp: number[] = new Array(bagSize + 1).fill(0);
  // 遍历物品
  for (let i = 0; i < goodsNum; i++) {
    // 遍历物品个数
    for (let j = 0; j < amountArr[i]; j++) {
      // 遍历背包容量
      for (let k = bagSize; k >= weightArr[i]; k--) {
        dp[k] = Math.max(dp[k], dp[k - weightArr[i]] + valueArr[i]);
      }
    }
  }
  console.log(dp);
}
testMultiPack();

打家劫舍问题

LeetCode:198. 打家劫舍

当前房屋偷与不偷取决于 前一个房屋和前两个房屋是否被偷了

1、dp及下标的确定:i为房屋,最多可以偷窃的金额为dp[i]

2、递推公式:dp[i] = max(dp[i-1], dp[i-2] + nums[i])

3、dp初始化:从递推公式看,递推公式的基础就是dp[0] 和 dp[1],dp[0] = nums[0],dp[1] = max(nums[0], nums[1]);

4、遍历顺序:dp[i] 是根据dp[i - 2] 和 dp[i - 1] 推导出来的,那么一定是从前到后遍历

5、举例说明推理正确

var rob = function(nums) {
    let dp = new Array(nums).fill(0);
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);
    for (let i=2; i<nums.length; i++) {
        dp[i] = Math.max(dp[i-1], dp[i-2]+nums[i])
    }
    return dp[nums.length-1]
};

LeetCode:213. 打家劫舍 II

这道题目和198.打家劫舍 (opens new window)是差不多的,唯一区别就是成环了。对于一个数组,成环的话主要有如下三种情况:

  • 情况一:考虑不包含首尾元素
  • 情况二:考虑包含首元素,不包含尾元素
  • 情况三:考虑包含尾元素,不包含首元素

情况二 和 情况三 都包含了情况一了,所以只考虑情况二和情况三就可以了

var rob = function(nums) {
    if (nums.length === 0) {
        return 0
    };
    if (nums.length === 1) {
        return nums[0]
    };
    // 因为房屋群是一个圈,所以偷窃开始和结束的地方,不能是首尾元素,因为这俩挨着的,会报警
    // 所以res1从0开始,只能偷到倒数第二个,不能偷倒数第一个
    // res2从1开始,就可以偷到最后一个
    let res1 = robRange(nums, 0, nums.length-2);
    let res2 = robRange(nums, 1, nums.length-1);
    // 最终取这两个最大的作为返回值
    return Math.max(res1, res2);
};

const robRange = (nums, start, end) => {
    // 只有2个房屋时,只能是其中的一个作为返回值
    if (start === end) {
        return nums[start];
    }
    // 定义dp
    let dp = new Array(nums.length).fill(0);
    // 初始化dp
    dp[start] = nums[start];
    dp[start + 1] = Math.max(nums[start], nums[start+1]);
    for (let i=start+2; i<=end; i++) {
        dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1])
    }
    return dp[end]
}

LeetCode:337.打家劫舍 III(树形dp的入门)

树形DP就是在树上进行递归公式的推导

对于树的话,首先就要想到遍历方式,前中后序(深度优先搜索)还是层序遍历(广度优先搜索)。本题一定是要后序遍历,因为通过递归函数的返回值来做下一步计算

与198.打家劫舍,213.打家劫舍II一样,关键是要讨论当前节点抢还是不抢:

  • 考虑抢当前节点,就不能抢该节点的左右孩子
  • 考虑不抢当前孩子,就可以抢其左右孩子

动态规划其实就是使用状态转移容器来记录状态的变化,这里可以使用一个长度为2的数组,记录当前节点偷与不偷所得到的的最大金钱。这道题目算是树形dp的入门题目,因为是在树上进行状态转移,在讲解二叉树的时候说过递归三部曲,那么下面以递归三部曲为框架,其中融合动规五部曲的内容来进行讲解

递归三部曲 + 动态规划五部曲:

1、确定递归函数的参数和返回值:求的是一个节点偷与不偷两个状态所得到的总金额,返回值是一个长度为2的数组dp

dp及下标的含义:dp[0] = 不偷该节点时得到的最大金额,dp[1] = 偷该节点时得到的最大金额

2、确定终止条件:如果遇到空节点,return 0;相当于dp的初始化

3、确定遍历顺序:后序遍历,通过递归左节点,得到左节点偷与不偷的金钱。通过递归右节点,得到右节点偷与不偷的金钱。

4、单层递归逻辑:如果是偷当前节点,那么左右孩子就不能偷,val1 = cur->val + left[0] + right[0];如果不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,所以:val2 = max(left[0], left[1]) + max(right[0], right[1]);最后当前节点的状态就是{val2, val1}; 即:{不偷当前节点得到的最大金钱,偷当前节点得到的最大金钱}

5、举例论证推理正确

var rob = function(root) {
    // 单层递归函数的逻辑
    const postOrder = node => {
        // 递归终止条件,如果没有节点,就输出[0,0],也是dp的初始化
        if (!node) {
            return [0, 0];
        };
        // 遍历左子树
        const left = postOrder(node.left);
        // 遍历右子树
        const right = postOrder(node.right);
        // 不偷当前节点,左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,0表示不偷,1表示偷
        const notDoIt = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
        // 偷当前节点
        const doIt = node.val + left[0] + right[0];   
        // 返回两种情况中,偷窃金额最大的金额
        return [notDoIt, doIt];
    };
    const res = postOrder(root);
    // 最终结果就是递归遍历后,偷窃金额最大的金额
    return Math.max(...res);
};

股票问题

LeetCode:121. 买卖股票的最佳时机

1、确定dp及下标含义:dp[i][0] 表示第i天持有股票所得最多现金,dp[i][1] 表示第i天不持有股票所得最多现金。“持有”不代表当天买入,可能是之前买入的 

2、递推公式:如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来

  • 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
  • 第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]

dp[i][0]应该选所得现金最大的,所以dp[i][0] = max(dp[i - 1][0], -prices[i]);

如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来

  • 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
  • 第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即:prices[i] + dp[i - 1][0]

同样dp[i][1]取最大的,dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);

3、dp初始化:由递推公式知,最终结果从dp[0][0]和dp[0][1]推导出来。dp[0][0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0][0] -= prices[0];dp[0][1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0;

4、遍历顺序:从前向后

5、举例证明推导正确

var maxProfit = function(prices) {
    const len = prices.length;
    // 定义dp
    const dp=new Array(len).fill([0,0]);
    // dp初始化
    dp[0] = [-prices[0],0];
    for(let i=1; i<len; i++){
        dp[i]=[
            Math.max(dp[i-1][0],-prices[i]),
            Math.max(dp[i-1][1],prices[i] + dp[i-1][0])
        ]
    }
    return dp[len-1][1];
};

[扩展]贪心算法

var maxProfit = function(prices) {
    let lowerPrice = prices[0];// 重点是维护这个最小值(贪心的思想) 
    let profit = 0;
    for(let i = 0; i < prices.length; i++){
        lowerPrice = Math.min(lowerPrice, prices[i]);// 贪心地选择左面的最小价格
        profit = Math.max(profit, prices[i] - lowerPrice);// 遍历一趟就可以获得最大利润
    }
    return profit;
};

子序列问题

LeetCode300:最长递增子序列

1.dp[i]的定义:本题中,正确定义dp数组的含义十分重要。dp[i]表示i之前包括i的以nums[i]结尾的最长递增子序列的长度。为什么一定表示 “以nums[i]结尾的最长递增子序” ,因为我们在 做 递增比较的时候,如果比较 nums[j] 和 nums[i] 的大小,那么两个递增子序列一定分别以nums[j]为结尾 和 nums[i]为结尾, 要不然这个比较就没有意义了,不是尾部元素的比较那么 如何算递增呢。

2.状态转移方程:位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。所以:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1)。注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值

3.dp[i]的初始化:每一个i,对应的dp[i](即最长递增子序列)起始大小至少都是1.

4.确定遍历顺序:dp[i] 是有0到i-1各个位置的最长递增子序列 推导而来,那么遍历i一定是从前向后遍历。j其实就是遍历0到i-1,那么是从前到后,还是从后到前遍历都无所谓,只要吧 0 到 i-1 的元素都遍历了就行了。 所以默认习惯 从前向后遍历。

var lengthOfLIS = function(nums) {
    let dp = Array(nums.length).fill(1);  //dp[i]表示i之前包括i的以nums[i]结尾的最长递增子序列的长度,令长度与默认为1
    let result = 1;   //子序列长度也默认为1
    for(let i= 1; i <nums.length; i++){
        for (let j = 0; j<i ; j++) {
            if (nums[i] > nums[j]) {
                dp[i] = Math.max(dp[i], dp[j]+1);
            }
        }
        result = Math.max(result,dp[i])
    }
    return result;
};

<扩展>追问:输出最长上升子序列

可以单独开一个数组,保存每个状态转移的来源,最后反着推回去就行了。

LeetCode674:最长序列递增序列

相对于动态规划:300.最长递增子序列 (opens new window)最大的区别在于“连续”。

动规五部曲分析如下:

1.确定dp数组(dp table)以及下标的含义。dp[i]:以下标i为结尾的连续递增的子序列长度为dp[i]。注意这里的定义,一定是以下标i为结尾,并不是说一定以下标0为起始位置。

2.确定递推公式。如果 nums[i] > nums[i - 1],那么以 i 为结尾的连续递增的子序列长度 一定等于 以i - 1为结尾的连续递增的子序列长度 + 1 。即:dp[i] = dp[i - 1] + 1。注意这里就体现出和动态规划:300.最长递增子序列 (opens new window)的区别!因为本题要求连续递增子序列,所以就必要比较nums[i]与nums[i - 1],而不用去比较nums[j]与nums[i] (j是在0到i之间遍历)。既然不用j了,那么也不用两层for循环,本题一层for循环就行,比较nums[i] 和 nums[i - 1]。

3.dp数组如何初始化。以下标i为结尾的连续递增的子序列长度最少也应该是1,即就是nums[i]这一个元素。所以dp[i]应该初始1;

4.确定遍历顺序。从递推公式上可以看出, dp[i + 1]依赖dp[i],所以一定是从前向后遍历。

var findLengthOfLCIS = function(nums) {
    let dp = new Array(nums.length).fill(1);
    let res = 1;
    for (let i=0; i<nums.length; i++) {
        if (nums[i+1] > nums[i]){
            dp[i+1] = dp[i] + 1;
        }
    }
    res = Math.max(...dp);
    return res
};

<扩展>贪心

const findLengthOfLCIS = (nums) => {
    if(nums.length === 1) {
        return 1;
    }

    let maxLen = 1;
    let curMax = 1;
    let cur = nums[0];

    for(let num of nums) {
        if(num > cur) {
            curMax += 1;
            maxLen =  Math.max(maxLen, curMax);
        } else {
            curMax = 1;
        }
        cur = num;
    }

    return maxLen;
};

LeetCode718:最长重复子数组

注意题目中说的子数组,其实就是连续子序列。要求两个数组中最长重复子数组,如果是暴力的解法 只需要先两层for循环确定两个数组起始位置,然后再来一个循环可以是for或者while,来从两个起始位置开始比较,取得重复子数组的长度。

动规五部曲分析如下:

1.确定dp数组(dp table)以及下标的含义。dp[i][j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]。 (特别注意: “以下标i - 1为结尾的A” 标明一定是 以A[i-1]为结尾的字符串 )。此时细心的同学应该发现,那dp[0][0]是什么含义呢?总不能是以下标-1为结尾的A数组吧。其实dp[i][j]的定义也就决定着,我们在遍历dp[i][j]的时候i 和 j都要从1开始。那有同学问了,我就定义dp[i][j]为 以下标i为结尾的A,和以下标j 为结尾的B,最长重复子数组长度。不行么?行倒是行! 但实现起来就麻烦一点,需要单独处理初始化部分,在本题解下面的拓展内容里,我给出了 第二种 dp数组的定义方式所对应的代码和讲解,大家比较一下就了解了。

2.确定递推公式。根据dp[i][j]的定义,dp[i][j]的状态只能由dp[i - 1][j - 1]推导出来。即当A[i - 1] 和B[j - 1]相等的时候,dp[i][j] = dp[i - 1][j - 1] + 1。根据递推公式可以看出,遍历i 和 j 要从1开始!

3.dp数组如何初始化。根据dp[i][j]的定义,dp[i][0] 和dp[0][j]其实都是没有意义的!但dp[i][0] 和dp[0][j]要初始值,因为 为了方便递归公式dp[i][j] = dp[i - 1][j - 1] + 1。所以dp[i][0] 和dp[0][j]初始化为0。举个例子A[0]如果和B[0]相同的话,dp[1][1] = dp[0][0] + 1,只有dp[0][0]初始为0,正好符合递推公式逐步累加起来。

4.确定遍历顺序。外层for循环遍历A,内层for循环遍历B。那又有同学问了,外层for循环遍历B,内层for循环遍历A。不行么?也行,一样的,我这里就用外层for循环遍历A,内层for循环遍历B了。同时题目要求长度最长的子数组的长度。所以在遍历的时候顺便把dp[i][j]的最大值记录下来。

718.最长重复子数组

var findLength = function(nums1, nums2) {
    const m = nums1.length, n = nums2.length;
    let dp = new Array(m+1).fill(0).map(x=>new Array(n+1).fill(0));
    let res = 0;
    for(let i=1;i<=m; i++){
        for (let j=1; j<=n; j++){
            if(nums1[i-1] === nums2[j-1]){
                dp[i][j] = dp[i-1][j-1] + 1;
            }
            res = dp[i][j] > res ? dp[i][j] : res; 
        }
    }
    return res;
};

<滚动数组>

const findLength = (nums1, nums2) => {
    let len1 = nums1.length, len2 = nums2.length;
    // dp[i][j]: 以nums1[i-1]、nums2[j-1]为结尾的最长公共子数组的长度
    let dp = new Array(len2+1).fill(0);
    let res = 0;
    for (let i = 1; i <= len1; i++) {
        for (let j = len2; j > 0; j--) {
            if (nums1[i-1] === nums2[j-1]) {
                dp[j] = dp[j-1] + 1;
            } else {
                dp[j] = 0;
            }
            res = Math.max(res, dp[j]);
        }
    }
    return res;
}

LeetCode1143:最长公共子序列

这里不要求是连续的了,但要有相对顺序,即:"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。

动态规划五部曲:

1.确定dp数组(dp table)以及下标的含义。dp[i][j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i][j]。有同学会问:为什么要定义长度为[0, i - 1]的字符串text1,定义为长度为[0, i]的字符串text1不香么?这样定义是为了后面代码实现方便,如果非要定义为长度为[0, i]的字符串text1也可以,我在 动态规划:718. 最长重复子数组 (opens new window)中的「拓展」里 详细讲解了区别所在,其实就是简化了dp数组第一行和第一列的初始化逻辑。

2.确定递推公式。主要就是两大情况: text1[i - 1] 与 text2[j - 1]相同,text1[i - 1] 与 text2[j - 1]不相同。如果text1[i - 1] 与 text2[j - 1]相同,那么找到了一个公共元素,所以dp[i][j] = dp[i - 1][j - 1] + 1。如果text1[i - 1] 与 text2[j - 1]不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最长公共子序列 和 text1[0, i - 1]与text2[0, j - 2]的最长公共子序列,取最大的。即:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);

3.dp数组如何初始化。先看看dp[i][0]应该是多少呢?test1[0, i-1]和空串的最长公共子序列自然是0,所以dp[i][0] = 0。同理dp[0][j]也是0。其他下标都是随着递推公式逐步覆盖,初始为多少都可以,那么就统一初始为0。

4.确定遍历顺序。有三个方向可以推出dp[i][j],这三个方向都是经过计算的数值,所以要从前向后,从上到下来遍历这个矩阵。

1143.最长公共子序列

var longestCommonSubsequence = function(text1, text2) {
    let dp = new Array(text1.length+1).fill(0).map(x=>new Array(text2.length+1).fill(0));
    for (let i=1; i<=text1.length; i++){
        for (let j=1; j<=text2.length; j++){
            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[text1.length][text2.length]
};

LeetCode1035:不相交的线

绘制一些连接两个数字 A[i] 和 B[j] 的直线,只要 A[i] == B[j],且直线不能相交!直线不能相交,这就是说明在字符串A中 找到一个与字符串B相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,链接相同数字的直线就不会相交。本题说是求绘制的最大连线数,其实就是求两个字符串的最长公共子序列的长度!与LeetCode1143.最长公共子序列,是一样的

var maxUncrossedLines = function(nums1, nums2) {
    let dp = new Array(nums1.length+1).fill(0).map(x=>new Array(nums2.length+1).fill(0))
    for (let i=1; i<=nums1.length; i++){
        for (let j=1; j<=nums2.length; j++){
            if (nums1[i-1]===nums2[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[nums1.length][nums2.length]
};

LeetCode53:最大子序和

1.确定dp数组(dp table)以及下标的含义。dp[i]:包括下标i(以nums[i]为结尾)的最大连续子序列和为dp[i]

2.确定递推公式。dp[i]只有两个方向可以推出来:

  • dp[i - 1] + nums[i],即:nums[i]加入当前连续子序列和
  • nums[i],即:从头开始计算当前连续子序列和

一定是取最大的,所以dp[i] = max(dp[i - 1] + nums[i], nums[i]);

3.dp数组如何初始化。从递推公式可以看出来dp[i]是依赖于dp[i - 1]的状态,dp[0]就是递推公式的基础。dp[0]应该是多少呢?根据dp[i]的定义,很明显dp[0]应为nums[0]即dp[0] = nums[0]。

4.确定遍历顺序。递推公式中dp[i]依赖于dp[i - 1]的状态,需要从前向后遍历。

53.最大子序和(动态规划)

var maxSubArray = function(nums) {
    let dp = new Array(nums.length).fill(0);
    let res = 0;
    dp[0] = nums[0];
    let max = dp[0];
    for (let i=1; i<nums.length; i++){
        dp[i] = Math.max(dp[i-1]+nums[i], nums[i]);
        //更新最大值
        max = Math.max(max, dp[i]);
    }
    return max;
};

<扩展>贪心

var maxSubArray = function(nums) {
    let result = -Infinity
    let count = 0
    for(let i = 0; i < nums.length; i++) {
        count += nums[i]
        if(count > result) {
            result = count
        }
        if(count < 0) {
            count = 0
        }
    }
    return result
};

LeetCode392:判断子序列

这道题应该算是编辑距离的入门题目,因为从题意中我们也可以发现,只需要计算删除的情况,不用考虑增加和替换的情况。所以掌握本题的动态规划解法是对后面要讲解的编辑距离的题目打下基础

动态规划五部曲分析如下:

1.确定dp数组(dp table)以及下标的含义。dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。注意这里是判断s是否为t的子序列。即t的长度是大于等于s的。有同学问了,为啥要表示下标i-1为结尾的字符串呢,为啥不表示下标i为结尾的字符串呢?为什么这么定义我在 718. 最长重复子数组 (opens new window)中做了详细的讲解。其实用i来表示也可以!但我统一以下标i-1为结尾的字符串来计算,这样在下面的递归公式中会容易理解一些,如果还有疑惑,可以继续往下看。

2.确定递推公式。在确定递推公式的时候,首先要考虑如下两种操作,整理如下:

  • if (s[i - 1] == t[j - 1])
    • t中找到了一个字符在s中也出现了
  • if (s[i - 1] != t[j - 1])
    • 相当于t要删除元素,继续匹配

if (s[i - 1] == t[j - 1]),那么dp[i][j] = dp[i - 1][j - 1] + 1;,因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1][j-1]的基础上加1(如果不理解,在回看一下dp[i][j]的定义

if (s[i - 1] != t[j - 1]),此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2]的比较结果了,即:dp[i][j] = dp[i][j - 1];

其实这里 大家可以发现和 1143.最长公共子序列 (opens new window)的递推公式基本那就是一样的,区别就是 本题 如果删元素一定是字符串t,而 1143.最长公共子序列 是两个字符串都可以删元素。

3.dp数组如何初始化。从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],所以dp[0][0]和dp[i][0]是一定要初始化的。这里大家已经可以发现,在定义dp[i][j]含义的时候为什么要表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。因为这样的定义在dp二维矩阵中可以留出初始化的区间,如图。如果要是定义的dp[i][j]是以下标i为结尾的字符串s和以下标j为结尾的字符串t,初始化就比较麻烦了。dp[i][0] 表示以下标i-1为结尾的字符串,与空字符串的相同子序列长度,所以为0. dp[0][j]同理

392.判断子序列

4.确定遍历顺序。同理从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],那么遍历顺序也应该是从上到下,从左到右

392.判断子序列1

392.判断子序列2

var isSubsequence = function(s, t) {
    let dp = new Array(s.length+1).fill(0).map(x=> new Array(t.length+1).fill(0));
    for (let i=1; i<=s.length; i++){
        for (let j=1; j<=t.length; j++){
            if (s[i-1] === t[j-1]){
                dp[i][j] = dp[i-1][j-1] + 1;
            } else {   //没有相同的字符则要删掉t中的这个字符
                dp[i][j] = dp[i][j-1];
            }
        }
    }
    // 遍历结束,判断dp右下角的数是否等于s的长度
    return dp[s.length][t.length] === s.length? true: false;
};

<扩展>双指针

LeetCode115:不同子序列

动规五部曲分析如下:

1.确定dp数组(dp table)以及下标的含义。dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]。为什么i-1,j-1 这么定义我在 718. 最长重复子数组 (opens new window)中做了详细的讲解。

2.确定递推公式。这一类问题,基本是要分析两种情况

  • s[i - 1] 与 t[j - 1]相等
  • s[i - 1] 与 t[j - 1] 不相等

当s[i - 1] 与 t[j - 1]相等时,dp[i][j]可以有两部分组成。一部分是用s[i - 1]来匹配,那么个数为dp[i - 1][j - 1]。即不需要考虑当前s子串和t子串的最后一位字母,所以只需要 dp[i-1][j-1]。一部分是不用s[i - 1]来匹配,个数为dp[i - 1][j]。这里可能有录友不明白了,为什么还要考虑 不用s[i - 1]来匹配,都相同了指定要匹配啊。例如: s:bagg 和 t:bag ,s[3] 和 t[2]是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。所以当s[i - 1] 与 t[j - 1]相等时,dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];当s[i - 1] 与 t[j - 1]不相等时,dp[i][j]只有一部分组成,不用s[i - 1]来匹配(就是模拟在s中删除这个元素),即:dp[i - 1][j]。所以递推公式为:dp[i][j] = dp[i - 1][j]。这里可能有录友还疑惑,为什么只考虑 “不用s[i - 1]来匹配” 这种情况, 不考虑 “不用t[j - 1]来匹配” 的情况呢。这里大家要明确,我们求的是 s 中有多少个 t,而不是 求t中有多少个s,所以只考虑 s中删除元素的情况,即 不用s[i - 1]来匹配 的情况。

3.dp数组如何初始化。从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][j] 是从上方和左上方推导而来,如图:,那么 dp[i][0] 和dp[0][j]是一定要初始化的。每次当初始化的时候,都要回顾一下dp[i][j]的定义,不要凭感觉初始化。dp[i][0]表示什么呢?dp[i][0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。那么dp[i][0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。再来看dp[0][j],dp[0][j]:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。那么dp[0][j]一定都是0,s如论如何也变成不了t。最后就要看一个特殊位置了,即:dp[0][0] 应该是多少。dp[0][0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。

4.确定遍历顺序。从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][j]都是根据左上方和正上方推出来的。

var numDistinct = function(s, t) {
    let dp = new Array(s.length+1).fill(0).map(x=>new Array(t.length+1).fill(0));
    for (let i=0; i<=s.length; i++){
        dp[i][0] = 1;
    }
    for (let i=1; i<=s.length; i++){
        for (let j=1; j<=t.length; j++){
            if (s[i-1] === t[j-1]) {
                dp[i][j] = dp[i-1][j-1] + dp[i-1][j];
            } else {
                dp[i][j] = dp[i-1][j];
            }
        }
    }
    return dp[s.length][t.length];
};

LeetCode583:两个字符串的删除操作

本题和动态规划:115.不同的子序列 (opens new window)相比,其实就是两个字符串都可以删除了,情况虽说复杂一些,但整体思路是不变的。

这次是两个字符串可以相互删了,这种题目也知道用动态规划的思路来解,动规五部曲,分析如下:

1.确定dp数组(dp table)以及下标的含义。dp[i][j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数。这里dp数组的定义有点点绕,大家要撸清思路。

2.确定递推公式

  • 当word1[i - 1] 与 word2[j - 1]相同的时候
  • 当word1[i - 1] 与 word2[j - 1]不相同的时候

当word1[i - 1] 与 word2[j - 1]相同的时候,dp[i][j] = dp[i - 1][j - 1];

当word1[i - 1] 与 word2[j - 1]不相同的时候,有三种情况:情况一:删word1[i - 1],最少操作次数为dp[i - 1][j] + 1。情况二:删word2[j - 1],最少操作次数为dp[i][j - 1] + 1。情况三:同时删word1[i - 1]和word2[j - 1],操作的最少次数为dp[i - 1][j - 1] + 2。那最后当然是取最小值,所以当word1[i - 1] 与 word2[j - 1]不相同的时候,递推公式:dp[i][j] = min({dp[i - 1][j - 1] + 2, dp[i - 1][j] + 1, dp[i][j - 1] + 1})。因为 dp[i][j - 1] + 1 = dp[i - 1][j - 1] + 2,所以递推公式可简化为:dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1);这里可能不少录友有点迷糊,从字面上理解 就是 当 同时删word1[i - 1]和word2[j - 1],dp[i][j-1] 本来就不考虑 word2[j - 1]了,那么我在删 word1[i - 1],是不是就达到两个元素都删除的效果,即 dp[i][j-1] + 1。

3.dp数组如何初始化。从递推公式中,可以看出来,dp[i][0] 和 dp[0][j]是一定要初始化的。dp[i][0]:word2为空字符串,以i-1为结尾的字符串word1要删除多少个元素,才能和word2相同呢,很明显dp[i][0] = i。

4.确定遍历顺序。从递推公式 dp[i][j] = min(dp[i - 1][j - 1] + 2, min(dp[i - 1][j], dp[i][j - 1]) + 1); 和dp[i][j] = dp[i - 1][j - 1]可以看出dp[i][j]都是根据左上方、正上方、正左方推出来的。所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。

583.两个字符串的删除操作1

//动态规划一
var minDistance = function(word1, word2) {
    let dp = new Array(word1.length+1).fill(0).map(x=>new Array(word2.length+1).fill(0));
    //初始化
    for (let i=1; i<=word1.length; i++){
        dp[i][0] = i;
    }
    for (let j=1; j<=word2.length; j++){
        dp[0][j] = j;
    }
   
    for (let i=1; i<=word1.length; i++){
        for (let j=1; j<=word2.length; j++){
            if (word1[i-1] === word2[j-1]){
                dp[i][j] = dp[i-1][j-1];
            } else {
                dp[i][j] = Math.min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+2);
            }
        }
    }
    return dp[word1.length][word2.length];
};


//动态规划二
var minDistance = function (word1, word2) {
  let dp = new Array(word1.length + 1).fill(0).map((_) => new Array(word2.length + 1).fill(0));

  for (let i = 1; i <= word1.length; i++)
    for (let j = 1; j <= word2.length; j++)
      if (word1[i - 1] === word2[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 word1.length + word2.length - dp[word1.length][word2.length] * 2;
};

LeetCode72:编辑距离

LeetCode647:回文子串

动规五部曲:

1.确定dp数组(dp table)以及下标的含义。如果大家做了很多这种子序列相关的题目,在定义dp数组的时候 很自然就会想题目求什么,我们就如何定义dp数组。绝大多数题目确实是这样,不过本题如果我们定义,dp[i] 为 下标i结尾的字符串有 dp[i]个回文串的话,我们会发现很难找到递归关系。dp[i] 和 dp[i-1] ,dp[i + 1] 看上去都没啥关系。我们在判断字符串S是否是回文,那么如果我们知道 s[1],s[2],s[3] 这个子串是回文的,那么只需要比较 s[0]和s[4]这两个元素是否相同,如果相同的话,这个字符串s 就是回文串。那么此时我们是不是能找到一种递归关系,也就是判断一个子字符串(字符串的下表范围[i,j])是否回文,依赖于,子字符串(下表范围[i + 1, j - 1])) 是否是回文。所以为了明确这种递归关系,我们的dp数组是要定义成一位二维dp数组。布尔类型的dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。

2.确定递推公式。在确定递推公式时,就要分析如下几种情况。整体上是两种,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。当s[i]与s[j]不相等,那没啥好说的了,dp[i][j]一定是false。当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况:

  • 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
  • 情况二:下标i 与 j相差为1,例如aa,也是回文子串
  • 情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true。

result就是统计回文子串的数量。注意这里我没有列出当s[i]与s[j]不相等的时候,因为在下面dp[i][j]初始化的时候,就初始为false。

3.dp数组如何初始化。dp[i][j]可以初始化为true么? 当然不行,怎能刚开始就全都匹配上了。

所以dp[i][j]初始化为false。

4.确定遍历顺序。遍历顺序可有有点讲究了。首先从递推公式中可以看出,情况三是根据dp[i + 1][j - 1]是否为true,在对dp[i][j]进行赋值true的。dp[i + 1][j - 1] 在 dp[i][j]的左下角,如果这矩阵是从上到下,从左到右遍历,那么会用到没有计算过的dp[i + 1][j - 1],也就是根据不确定是不是回文的区间[i+1,j-1],来判断了[i,j]是不是回文,那结果一定是不对的。所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]都是经过计算的。有的代码实现是优先遍历列,然后遍历行,其实也是一个道理,都是为了保证dp[i + 1][j - 1]都是经过计算的。

var countSubstrings = function(s) {
    let dp = new Array(s.length).fill(false).map(x=>new Array(s.length).fill(false));
    let res = 0;
    for (let j=0; j< s.length; j++){
        for(let i=0; i<=j; i++){
            if (s[i] === s[j]){
                if ((j-i) < 2){
                    dp[i][j] = true;
                } else {
                    dp[i][j] = dp[i+1][j-1];
                }
                res += dp[i][j] ? 1: 0;
            }
        }
    }
    return res;
};

<扩展>双指针

const countSubstrings = (s) => {
    const strLen = s.length;
    let res = 0;

    for(let i = 0; i < 2 * strLen - 1; i++) {
        let left = Math.floor(i/2);
        let right = left + i % 2;

        while(left >= 0 && right < strLen && s[left] === s[right]){
            res++;
            left--;
            right++;
        }
    }

    return res;
}

<扩展>暴力解法:两层for循环,遍历区间起始位置和终止位置,然后还需要一层遍历判断这个区间是不是回文。所以时间复杂度:O(n^3)

LeetCode516:最长回文子序列

动态规划:回文子串 (opens new window),求的是回文子串,而本题要求的是回文子序列, 要搞清楚这两者之间的区别。回文子串是要连续的,回文子序列可不是连续的! 回文子串,回文子序列都是动态规划经典题目。动规五部曲分析如下:

1.确定dp数组(dp table)以及下标的含义。dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]

2.确定递推公式。在判断回文子串的题目中,关键逻辑就是看s[i]与s[j]是否相同。如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2;

516.最长回文子序列

 如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子序列的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。加入s[j]的回文子序列长度为dp[i + 1][j]。加入s[i]的回文子序列长度为dp[i][j - 1]。那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);516.最长回文子序列1

3.dp数组如何初始化。首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2; 可以看出 递推公式是计算不到 i 和j相同时候的情况。所以需要手动初始化一下,当i与j相同,那么dp[i][j]一定是等于1的,即:一个字符的回文子序列长度就是1。其他情况dp[i][j]初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); 中dp[i][j]才不会被初始值覆盖。

4.确定遍历顺序。从递归公式中,可以看出,dp[i][j] 依赖于 dp[i + 1][j - 1] ,dp[i + 1][j] 和 dp[i][j - 1],所以遍历i的时候一定要从下到上遍历,这样才能保证下一行的数据是经过计算的。j的话,可以正常从左向右遍历。

var longestPalindromeSubseq = function(s) {
    let dp = new Array(s.length).fill(0).map(x=>new Array(s.length).fill(0));
    
    for(let i = 0; i < s.length; i++) {
        dp[i][i] = 1;
    }

    for(let i = s.length - 1; i >= 0; i--) {
        for(let j = i + 1; j < s.length; j++) {
            if(s[i] === s[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][s.length - 1];
};

LeetCode5:最长回文子串

const longestPalindromeSubseq = (s) => {
    const strLen = s.length;
    let dp = Array.from(Array(strLen), () => Array(strLen).fill(0));
    
    for(let i = 0; i < strLen; i++) {
        dp[i][i] = 1;
    }

    for(let i = strLen - 1; i >= 0; i--) {
        for(let j = i + 1; j < strLen; j++) {
            if(s[i] === s[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][strLen - 1];
};

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值