前端刷题笔记(一)动态规划

222 阅读10分钟

系列文章:

前端刷题笔记(二)贪心+二分算法 - 掘金 (juejin.cn)

参考:

dp(动态规划)

一般形式:求最值

核心问题:穷举,并存在「重叠子问题」,暴力穷举效率低下,因此一定存在「最优子结构」

优化方法:借助「备忘录」或者「DP table」,列出 「状态转移方程」

重叠子问题: 指的是同一个问题如果用暴力穷举的方法来计算的话,那么同一个问题会被多次重复计算,比如用递归的方法计算斐波那契数列;

image.png 因此,很自然的想法是借助 「备忘录(数组)」 记录子问题的答案,那么在遇到这个子问题的时候,首先去备忘录中找一找,就不需要重复计算了; 这是一种自顶向下的思路,即从最上层的大问题逐渐分解,直到找到base case,然后逐层返回答案;

「DP table」 :即自底向上的思路,从base case出发,通过状态转移方程逐渐向上计算,得到最终答案; 解题思路:

  1. 确定base case;
  2. 确定「状态」,也就是原问题和子问题中会变化的变量;
  3. 确定「选择」,也就是导致「状态」产生变化的行为,因此这一步要确定状态转移方程
  4. 明确 dp 函数/数组的定义。

动态规划核心套路:动态规划算法本质上就是穷举「状态」,然后在「选择」中选择最优解。

image.png 【例题】: 509. 斐波那契数

var fib = function(n) {
    dp = new Array(n+1).fill(0);
    dp[0] = 0;
    dp[1] = 1;
    for(let i=2;i<=n;i++) {
        dp[i] = dp[i-1]+dp[i-2];
    }
    return dp[n];
};

70. 爬楼梯

var climbStairs = function(n) {
// 1.base case: n=1时,返回1,n=2,返回2,dp[0]=0;
// 2.状态:台阶数n
// 3.选择:你可以选择走1阶,那么就是dp[n-1];选择走两阶就是 dp[n-2],因此所有的方法就是它们之和
// 4.dp的含义:dp[n]记录台阶数为n的时候,有几种方法能够到达n
    let dp = new Array(n+1).fill(0);
    // dp[0] = 0;
    dp[1] = 1;
    dp[2] = 2;
    for(let i=3;i<=n;i++) {
        dp[i] = dp[i-1]+dp[i-2]; //状态转移方程
    }
    return dp[n];
};

221. 最大正方形

931. 下降路径最小和

var minFallingPathSum = function(matrix) {
    // 1.base case: dp[0][0] = matrix[0][0],dp[0][1] = matrix[0][1],dp[0,2] = matrix[0,2]
    // 2.状态:行、列
    // 3.dp table:记录从第一行下降到matrix[i][j]的下降路径最小和
    // 4.选择:可以选择(row + 1, col - 1)、(row + 1, col) 或者 (row + 1, col + 1)  
    //   dp[i][j] = matrix[i][j]+Math.min(dp[i-1][j-1],dp[i-1][j],dp[i-1][j+1]) --i,j的细节处理
    
    // matrix:
    // 2,1,3   
    // 6,5,4
    // 7,8,9

    // dp table:
    // 2,1,3
    // 7,6,5
    // 13,13,15
    //初始化一个二维dp
    let n = matrix[0].length;
    let dp = new Array(n);
    dp[0] = new Array(n).fill(Infinity);
    

    //定义base case
    for(let j = 0; j<n; j++) {
        dp[0][j] = matrix[0][j];
    }
    // console.log(dp)

    //状态转移方程
    for(let i=1; i<n; i++){
        dp[i] = new Array(n).fill(Infinity);
        for(let j=0;j<n;j++) {
            if(j===0) {
                dp[i][j] = matrix[i][j] + Math.min(dp[i-1][j],dp[i-1][j+1])
            } 
            else if(j===n-1) {
                 dp[i][j] = matrix[i][j]+Math.min(dp[i-1][j-1],dp[i-1][j])
            }
            else {
                dp[i][j] = matrix[i][j]+Math.min(dp[i-1][j-1],dp[i-1][j],dp[i-1][j+1])
            }
        }
    }
    // console.log(dp[n-1]);
    return Math.min.apply(null,dp[n-1]); 
};

经典动态规划问题

300. 最长递增子序列

/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function(nums) {
    // 1.base case: 当数组长度为1时,返回1
    // 2.状态:最长严格递增子序列的长度
    // 3.选择: 如果num[i]>num[j]的话,那么dp[i] = Math.max(dp[i],dp[j+1])
    // 4.dp table:记录到num[i]这个数为结尾的最长严格递增子序列的长度
    //nums=[10,9,2,5,3,7,101,18]
    //dp  =[ 1,1,1,2,2,3, 4 ,4]
    let n = nums.length;
    let dp = new Array(n).fill(1);
    dp[0] = 1;
    for(let i=1;i<n;i++){
        for(let j=0;j<i;j++) {
            if(nums[i]>nums[j]) {
                dp[i] = Math.max(dp[i],dp[j]+1);
            }
        }
    }
    // console.log(dp);
    return Math.max.apply(null,dp);
};

53. 最大子数组和

/**
 * @param {number[]} nums
 * @return {number}
 */
var maxSubArray = function(nums) {
    // 1.base case: dp[0] = nums[0],dp初始为0
    // 2.状态:以num[i]为结尾的连续子数组最大和
    // 3.选择:dp[i] = Math.max(dp[i-1]+nums[i],nums[i]) //要么和前面的数组组成一队,要么自己一队
    // 4.dp table:dp[i]中存储的是以num[i]为结尾的连续子数组最大和

    // nums = [-2,1,-3,4,-1,2,1,-5,4]
    //   dp = [-2,1,-2,4, 3,5,6, 1,5]
    let n = nums.length;
    let dp = new Array(n).fill(0);
    dp[0] = nums[0];
    for(let i=1;i<n;i++) {
        dp[i] = Math.max(dp[i-1]+nums[i],nums[i]);
    }
    // console.log(dp);
    return Math.max.apply(null,dp);

};

「最大子数组和」就和「最长递增序列」⾮常类似,dp 数组的定义是 「以 nums[i] 为结尾的最大子数组和/最长递增序列为 dp[i]」。因为只有这样定义才能将 dp[i+1] 和 dp[i] 建立起联系,利用数学归纳法写出状态转移方程,最后返回的是dp数组中的最大值。

1143. 最长公共子序列

/**
 * @param {string} text1
 * @param {string} text2
 * @return {number}
 */
var longestCommonSubsequence = function(text1, text2) {
    // 1.base case: 当text1.length=0或text2.length=0时,最长公共子序列(maxLength)=0
    // 2.状态:text1中的字符位置i 和text2中的字符位置j
    // 3.dp table:dp[i][j]记录的是text1中子长度为i 和text1中子长度为j 的最长公共子序列长度 
    // 4.选择:如果text1[i]===text2[j],说明这个字符肯定在字符串中: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 = "abcde", text2 = "ace" 
    // dp = [[0,0,0,0],
    //       [0,1,1,1],
    //       [0,1,1,1],
    //       [0,1,2,2],
    //       [0,1,2,2],
    //       [0,1,2,3],
    //     ]

    let len1 = text1.length;
    let len2 = text2.length;
    let dp = new Array(len1+1);
    dp[0] = new Array(len2+1).fill(0);
    for(let i=1;i<=len1;i++) {
        dp[i] = new Array(len2+1).fill(0);
        for(let j=1;j<=len2;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]);
            }
        }
    }
    // console.log(dp);
    return dp[len1][len2];
};

背包问题

image.png

var bag = function(weight, val, W, N) {
// 1.base case: 当i=0时,那么dp[0][w]=0,w=0时,dp[i][0]=0
// 2.状态:背包可以选择的物体,背包的容量 ===> 二维dp[i][w]
// 3.选择: 将第i件物品装入背包,那么dp[i][w] = dp[i-1][w-wt[i-1]]+val[i-1]
//   选择: 没有将第i件物品装入背包:dp[i][w] = dp[i-1][w]
// 4.dp[i][w]: 将前i个物品,当背包容量为w时,能够装的最大价值


// wt=[2,1,3],val=[4,2,3],N=3,W=4
//  dp=[[0,0,0,0,0],
//      [0,0,4,4,4],
//      [0,2,4,6,6],
//      [0,2,4,6,6]]
    let dp = new Array(N+1);
    dp[0] = new Array(W+1).fill(0);

    for(let i = 1;i<=N;i++) {
        dp[i] = new Array(W+1).fill(0);
        for(let j=1;j<=W;j++) {
            // 背包的剩余容量小于这个物品的重量
            if(w-wt[i-1]<0){
                dp[i][w] = dp[i-1][w];
            }else {
                dp[i][w] = Math.max(dp[i-1][w],dp[i-1][w-wt[i-1]]+val[i-1]);
            }
        }
    }
    return dp[N][W];

};

322. 零钱兑换

var coinChange = function(coins, amount) {
// 1.base case: 当amount=0时,返回0;dp的初始值,设置为amount+1,因为选择的是更小的
// 2.状态:数额总量
// 3.选择:当你选择了某一个面值的硬币之后,会导致你的状态amount发生变化
// 4.dp的含义:dp[n]记录amount为n的时候,所需的最少硬币
    let dp = new Array(amount+1).fill(amount+1);
    dp[0] = 0;
    for(let i=1;i<=amount;i++){
        for(coin of coins) {
            if(i-coin<0) continue;
            dp[i] = Math.min(dp[i],1+dp[i-coin]); //状态转移方程
        }
    }
    return dp[amount] === amount+1 ? -1 : dp[amount];
};

518. 零钱兑换 II,完全背包问题

/**
 * @param {number} amount
 * @param {number[]} coins
 * @return {number}
 */
var change = function(amount, coins) {
    // 1.base case: dp[0][...]=0,dp[..][0]=1,
    // 2.状态:可选择的硬币i,总金额amount的值j
    // 3.选择:不选择第i个硬币,则dp[i][j] = dp[i-1][j], 
    //         选择第i个硬币,则dp[i][j] = dp[i][j-coin[i-1]](由于 i 是从 1 开始的,所以 coins 的索引是 i-1 时表示第 i 个硬币的⾯值)
    // 你想⽤⾯值为 2 的硬币凑出⾦额 5,那么如果你知道了凑出⾦额 3 的⽅法,再加上⼀枚⾯额为 2 的硬币,不就可以凑出 5 了嘛

    // 4.dp的含义:dp[i][n]记录选择前i个硬币凑成amount=n时,最多的凑法
    let N = coins.length;
    let dp = new Array(N+1);
    dp[0] = new Array(amount+1).fill(0);
    dp[0][0] = 1;

    for(let i=1; i<=N;i++) {
        dp[i] = new Array(amount+1).fill(0);
        dp[i][0] = 1;
        for(let j=1;j<=amount;j++) {
            // 总金额的值小于当前要放的那枚硬币的值,所以就不放
            if(j-coins[i-1]<0) {
                dp[i][j] = dp[i-1][j];
            }else {
                dp[i][j] = dp[i-1][j]+dp[i][j-coins[i-1]];
            }
        }
    }
    return dp[N][amount];
};

416. 分割等和子集--⼦集背包问题

/**
 * @param {number[]} nums
 * @return {boolean}
 */
var canPartition = function(nums) {
    //将其转化为问题:给你一个可装sum/2的背包,能否刚好装满?
    // 1.状态:背包的容量W=sum/2,选择的物品
    // 2.dp[i][j]:表示选择前i个物体,是否刚好能够把背包容量为j的装满
    // 3.base case:dp[0][..] = false; dp[..][0]=true;
    // 4.选择:不装这个物品,dp[i][j] = dp[i-1][j],
    // 装这个物品:dp[i][j] = dp[i][j-nums[i-1]];即用前i个物品能否刚好将j-nums[i-1]的容量的背包装满
    let sum = 0;
    for(let i=0;i<nums.length;i++) sum+=nums[i];
    if(sum %2 !== 0) return false;
    let W = sum/2;
    let n = nums.length;
    let dp = new Array(n+1);
    dp[0] = new Array(W+1).fill(false);
    dp[0][0] = true;
    for(let i=1; i<=nums.length; i++) {
        dp[i] = new Array(W+1).fill(false);
        dp[i][0] = true;
        for(let j=1;j<=W;j++){
            if(j-nums[i-1]<0){
                dp[i][j] = dp[i-1][j];
            }else {
                dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i-1]]; 
            }
        }
    }
    // console.log(dp)
    return dp[nums.length][W];
};

股票买卖

121. 买卖股票的最佳时机

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
    // 状态:是否持有股票、天数
    // dp含义:dp[i][0]:表示在第i天不持有股票的最大利润;dp[i][1]:持有股票的最大利润;dp[n][0/1]
    // 选择:买入、持有、卖出
    // dp[i][0] = Math.max(dp[i-1][1]+prices[i],dp[i-1][0]);
    // dp[i][1] = Math.max(dp[i-1][0]-prices[i],dp[i-1][1]);
    // base case:dp[0][0] = 0, dp[0][1]=-prices[i];   dp[i[j] = -Inifity;  
    let n = prices.length;
    let dp = new Array(n);
    for(let i=0;i<n;i++){
        dp[i] = new Array(2).fill(-Infinity);
        if(i===0){
            dp[i][0] = 0;
            dp[i][1] = -prices[0]; 
        }else {
            dp[i][0] = Math.max(dp[i-1][1]+prices[i],dp[i-1][0]);
            dp[i][1] = Math.max(-prices[i],dp[i-1][1]);
        }
    }
    // console.log(dp)
    return dp[n-1][0];

};

122. 买卖股票的最佳时机 II

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
    // 状态:是否持有股票、天数
    // dp[i][0]:表示在第i天不持有股票的最大利润;dp[i][1]:持有股票的最大利润
    // 选择:买入、持有、卖出
    // dp[i][0] = Math.max(dp[i-1][1]+prices[i],dp[i-1][0]);
    // dp[i][1] = Math.max(dp[i-1][0]-prices[i],dp[i-1][1]);
    // base case:dp[0][0] = 0, dp[0][1]=-prices[i];
    let n = prices.length;
    let dp = new Array(n);
    for(let i=0;i<n;i++){
        dp[i] = new Array(2).fill(-Infinity);
        if(i===0){
            dp[i][0] = 0;
            dp[i][1] = -prices[0]; 
        }else {
            dp[i][0] = Math.max(dp[i-1][1]+prices[i],dp[i-1][0]);
            dp[i][1] = Math.max(dp[i-1][0]-prices[i],dp[i-1][1]);
        }
    }
    // console.log(dp)
    return dp[n-1][0];

};

309. 最佳买卖股票时机含冷冻期

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
    // 状态:是否持有股票、天数
    // dp[i][0]:表示在第i天不持有股票的最大利润;dp[i][1]:持有股票的最大利润
    // 选择:买入、持有、卖出
    // 今天不持有股票的两种情况:
        // 1是我昨天不持有股票,今天无操作;2是我昨天持有股票:今天卖掉了;
        // dp[i][0] = Math.max(dp[i-1][1]+prices[i],dp[i-1][0]);
    // 今天持有股票的两种情况:
        // 1是我昨天持有股票,今天无操作;2是我昨天不持有股票:今天买入了;
        // dp[i][1] = Math.max(dp[i-2][0]-prices[i],dp[i-1][1]);
    // base case:dp[0][0] = 0, dp[0][1]=-prices[i];
    let n = prices.length;
    let dp = new Array(n);
    for(let i=0;i<n;i++){
        dp[i] = new Array(2).fill(-Infinity);
        if(i===0){
            dp[i][0] = 0;
            dp[i][1] = -prices[0]; 
        } else if(i===1) {
             dp[i][0] = Math.max(dp[i-1][1]+prices[i],dp[i-1][0]);
             dp[i][1] = Math.max(-prices[i],dp[i-1][1]);
        }
        else {
            dp[i][0] = Math.max(dp[i-1][1]+prices[i],dp[i-1][0]);
            dp[i][1] = Math.max(dp[i-2][0]-prices[i],dp[i-1][1]);
        }
    }
    // console.log(dp)
    return dp[n-1][0];
};

714. 买卖股票的最佳时机含手续费

/**
 * @param {number[]} prices
 * @param {number} fee
 * @return {number}
 */

var maxProfit = function(prices,fee) {
    // 状态:是否持有股票、天数
    // dp[i][0]:表示在第i天不持有股票的最大利润;dp[i][1]:持有股票的最大利润
    // 选择:买入、持有、卖出
    // dp[i][0] = Math.max(dp[i-1][1]+prices[i],dp[i-1][0]);
    // dp[i][1] = Math.max(dp[i-1][0]-prices[i],dp[i-1][1]);
    // base case:dp[0][0] = 0, dp[0][1]=-prices[i];
    let n = prices.length;
    let dp = new Array(n);
    for(let i=0;i<n;i++){
        dp[i] = new Array(2).fill(-Infinity);
        if(i===0){
            dp[i][0] = 0;
            **dp[i][1] = -prices[0]-fee; **
        }else {
            dp[i][0] = Math.max(dp[i-1][1]+prices[i],dp[i-1][0]);
            **dp[i][1] = Math.max(dp[i-1][0]-prices[i]-fee,dp[i-1][1]);**
        }
    }
    // console.log(dp)
    return dp[n-1][0];

};

打家劫舍问题

198. 打家劫舍

/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
// 1.状态:房间号
// 2.dp[i]:表示偷到第i间房为止,能够偷到的最大数额
// 3.选择:要么不偷,那么dp[i] = dp[i-1]; 如果偷的话,因为不能连续偷,所以dp[i] = dp[i-2]+nums[i]
// 4.base case:dp[0] = nums[0];

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

213. 打家劫舍 II

// 环形数组的问题,用两个dp数组记录
/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
// 1.状态:房间号
// 3.选择:
// 由于第一间房和最后一间是紧邻的,因此三种情况: 
    // 要么偷第一间,最后一间不偷;dp1[i(0~n-2)]
    // 要么偷最后一间,第一间不偷; dp2[i(1~n-1)]
    // 要么第一间最后一间都不偷
    // 要么不偷,那么dp[i] = dp[i-1]; 如果偷的话,因为不能连续偷,所以dp[i] = dp[i-2]+nums[i]
// 4.base case:dp1[0] = nums[0],dp1[1] = Math.max(nums[0],nums[1]);
//             dp2[0] = nums[1],dp1[2] = Math.max(nums[1],nums[2]);

    let n = nums.length;
    if(n===1) {
        return nums[0];
    } else if(n===2) {
        return Math.max(nums[0],nums[1]);
    }
    let dp1 = new Array(n-1).fill(0);
    let dp2 = new Array(n-1).fill(0);
    dp1[0] = nums[0];
    dp1[1] = Math.max(nums[0],nums[1]);
    dp2[0] = nums[1];
    dp2[1] = Math.max(nums[1],nums[2]);

    for(let i=2;i<n-1;i++) {
        dp1[i] = Math.max(dp1[i-1],dp1[i-2]+nums[i]);
        dp2[i] = Math.max(dp2[i-1],dp2[i-2]+nums[i+1]);
    }
    console.log(dp1,dp2)
    return Math.max(dp1[n-2],dp2[n-2]);

};

337. 打家劫舍 III

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */
/**
 * @param {TreeNode} root
 * @return {number}
 */
var rob = function(root) {
    // 1.当前节点选择不偷,那么返回的就是左孩子+右孩子能偷到的最多的钱;
    // 2.当前节点能偷到的最大钱数 = 左孩子选择自己不偷时能得到的钱 + 右孩子选择不偷时能得到的钱 + 当前节点的钱数
    // 3. dp[0]代表不偷,dp[1]代表偷 
    let dp = robInternal(root);
    return Math.max(dp[0],dp[1]);
};

var robInternal = function(root) {
    if(root===null) return new Array(2).fill(0);
    let dp = new Array(2).fill(0);
    let left = robInternal(root.left);
    let right = robInternal(root.right);
    dp[0] = Math.max(left[0],left[1])+Math.max(right[0],right[1]);
    dp[1] = left[0]+right[0]+root.val;
    return dp;
}