系列文章:
前端刷题笔记(二)贪心+二分算法 - 掘金 (juejin.cn)
参考:
dp(动态规划)
一般形式:求最值
核心问题:穷举,并存在「重叠子问题」,暴力穷举效率低下,因此一定存在「最优子结构」
优化方法:借助「备忘录」或者「DP table」,列出 「状态转移方程」;
重叠子问题: 指的是同一个问题如果用暴力穷举的方法来计算的话,那么同一个问题会被多次重复计算,比如用递归的方法计算斐波那契数列;
因此,很自然的想法是借助 「备忘录(数组)」 记录子问题的答案,那么在遇到这个子问题的时候,首先去备忘录中找一找,就不需要重复计算了; 这是一种自顶向下的思路,即从最上层的大问题逐渐分解,直到找到base case,然后逐层返回答案;
「DP table」 :即自底向上的思路,从base case出发,通过状态转移方程逐渐向上计算,得到最终答案; 解题思路:
- 确定base case;
- 确定「状态」,也就是原问题和子问题中会变化的变量;
- 确定「选择」,也就是导致「状态」产生变化的行为,因此这一步要确定状态转移方程
- 明确 dp 函数/数组的定义。
动态规划核心套路:动态规划算法本质上就是穷举「状态」,然后在「选择」中选择最优解。
【例题】:
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];
};
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];
};
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]);
};
经典动态规划问题
/**
* @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);
};
/**
* @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数组中的最大值。
/**
* @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];
};
背包问题
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];
};
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];
};
/**
* @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];
};
/**
* @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];
};
股票买卖
/**
* @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];
};
/**
* @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];
};
/**
* @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];
};
/**
* @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];
};
打家劫舍问题
/**
* @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];
};
// 环形数组的问题,用两个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]);
};
/**
* 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;
}