动态规划
基础
基本思想
- 将问题分解为子问题;
- 通过存储子问题的解,避免重复计算;
基本术语
- dp 表:记录子问题的解;
- 初始状态:最小子问题的状态;
- 状态转移公式:dp 的递推公式;
dp 问题特性
最优子结构
- 原问题的最优解,是从子问题的最优解构建得来;
无后效性
- 给定确定状态;
- 未来状态只和当前状态有关,与过去状态无关;
- 存在严重后效性的问题,难以使用动态规划解决;
dp 解题思路
- 划分阶段;
- 将问题按照特定顺序分解为互相联系的阶段,即子问题的求解;
- 完成前一阶段才可进行下一阶段;
- 定义状态:根据问题变量设置状态,建立 dp 表;
- 状态转移:建立不同阶段之间的状态转移公式;
- 初始条件和边界条件:确定初始条件和边界条件;
动态规划基础题目
斐波那契数
题目
- 509;
思路
- 划分阶段:根据整数顺序进行划分;
- 定义状态:dp[i] 为第 i 个斐波那契数;
- 状态转移方程:dp[i] = dp[i-1] + dp[i-2];
- 初始条件:dp[0]=0,dp[1]=1;
- 边界条件:i===n;
/**
* @param {number} n
* @return {number}
*/
var fib = function (n) {
const dp = new Array(n + 1);
dp[0] = 0;
dp[1] = 1;
for (let i = 2; i < n + 1; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
};
复杂度
- 时间:n;
- 空间:n;
爬楼梯
题目
- 70;
思路
- 划分阶段:根据楼梯阶层划分为 0 - n;
- 定义状态:dp[i] 为爬到第 i 阶的方案数;
- 状态转移方程;
- 设爬到第 i 阶有 dp[i] 种方案;
- 由于只能上 1 阶或 2 阶;
- 因此第 i 阶的上一轮只能在 i - 1 阶或 i-2 阶上;
- 故 dp[i] = dp[i-1] + dp[i-2];
- 初始条件:dp[1]=1,dp[2]=2
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function (n) {
const dp = new Array(n + 1);
dp[1] = 1;
dp[2] = 2;
for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
};
复杂度
- 时间:n;
- 空间:n;
圆环回原点问题
题目
- 圆环上有 10 个点,编号为 0~9,从 0 点出发,每次可以逆时针和顺时针走一步,问走 n 步回到 0 点共有多少种走法;
思路
- 划分阶段:根据步数和到达位置划分;
- 定义状态:dp[n][index] 为走 n 步到达 index 位置的走法数量;
- 状态转移方程;
- index 只能从 index-1 和 index+1 走来;
- dp[n][index] = dp[n-1][(index+1)%len]+dp[n-1][(index-1+len)%len];
- 初始条件;
- dp[0][0]=1;
- 终止条件:到达 [m-1,n-1];
class Solution:
def backToOrigin(self,n):
length = 10
dp = [[0 for i in range(length)] for j in range(n+1)]
dp[0][0] = 1
for i in range(1,n+1):
for j in range(length):
dp[i][j] = dp[i-1][(j-1+length)%length] + dp[i-1][(j+1)%length]
return dp[n][0]
复杂度
- 时间:n2;
- 空间:n2;
不同路径 1
题目
- 62;
思路
- 划分阶段:根据行列号进行划分;
- 定义状态:dp[row][col] 为到达该点不同路径数量;
- 状态转移方程;
- 只能向下,向右移动;
- dp[row][col] = dp[row-1][col] + dp[row][col-1];
- 初始条件;
- 左上角至 [0,0] 数量为 1,故 dp[0][0] = 1;
- 左上角至首行首列数量为 1,故 dp[0][:] = dp[:][0] =- 1;
- 终止条件:到达 [m-1,n-1];
/**
* @param {number} m
* @param {number} n
* @return {number}
*/
var uniquePaths = function (m, n) {
const dp = new Array(m).fill(0).map(() => new Array(n).fill(1));
for (let row = 1; row < m; row++) {
for (let col = 1; col < n; col++) {
dp[row][col] = dp[row - 1][col] + dp[row][col - 1];
}
}
return dp[m - 1][n - 1];
};
复杂度
- 时间:mn;
- 空间:mn;
不同路径 2
题目
- 63;
思路
- 划分阶段:根据行列号进行划分;
- 定义状态:dp[row][col] 为到达该点不同路径数量;
- 状态转移方程;
- 只能向下,向右移动;
- dp[row][col] = dp[row-1][col] + dp[row][col-1];
- 如果 dp[row][col]为障碍物,赋值为 0;
- 初始条件;
- 左上角至 [0,0] 数量为 1,故 dp[0][0] = 1;
- 左上角至首行首列数量为 1,故 dp[0][:] = dp[:][0] = 1;
- 如果在首行首列遇到障碍,跳出循环;
- 终止条件:到达 [m-1,n-1];
/**
* @param {number[][]} obstacleGrid
* @return {number}
*/
var uniquePathsWithObstacles = function (obstacleGrid) {
const m = obstacleGrid.length;
const n = obstacleGrid[0].length;
const dp = Array.from(
{
length: m,
},
() => new Array(n).fill(0)
);
for (let i = 0; i < m; i++) {
if (obstacleGrid[i][0] === 1) break;
dp[i][0] = 1;
}
for (let i = 0; i < n; i++) {
if (obstacleGrid[0][i] === 1) break;
dp[0][i] = 1;
}
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
if (obstacleGrid[i][j] === 1) continue;
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
};
复杂度
- 时间:mn;
- 空间:mn;
不同的二叉搜索树
题目
- 96;
思路
- 数学题,建议死记硬背;
- f[i] 表示以 i 为根的二叉搜索树的个数,g[i] 表示 i 个节点可以构成的个数;
- g[i]=f[1]+f[2]+。。。+f[i];
- i 为根节点时,使用 i - 1 个节点构造左树,n- i 个节点构造右树,f[i]=g[i-1]*g[n-i];
- 综上
- 划分阶段:根据节点索引划分;
- 定义状态:dp[i]表示 i 个节点可以构成的二叉树数量;
- 状态转移公式:g[i] 换 dp[i];
- 起始条件:dp[0]=1,即空树;
- 终止条件,返回 dp[n];
/**
* @param {number} n
* @return {number}
*/
var numTrees = function (n) {
const dp = new Array(n + 1).fill(0);
dp[0] = 1;
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= i; j++) {
dp[i] += dp[j - 1] * dp[i - j];
}
}
return dp[n];
};
复杂度
- 时间:n^2;
- 空间:n;
记忆化搜索
记忆化搜素
- 存储已经遍历过的状态信息;
- 计算子问题时判断对应状态是否存储;
记忆化搜素和递推
- 记忆化搜索;
- 自顶向下,自然的递归方式;
- 简单易懂;
- 存在栈溢出问题;
- 递推;
- 自底向上;
- 不存在栈溢出问题;
- 无法处理复杂的状态转移方程;
解题步骤
- 定义状态和状态转移方程;
- 定义数组缓存子问题解;
- 定义递归函数,首先检查缓存是否存在对应的解;
记忆化搜索题目
矩阵中的最长递增路径
题目
- 329;
思路
- 对于每个单元格,可以上下左右移动;
- 遍历过的单元格不在遍历;
- 二维数组存储当前单元格的最大路径长度;
/**
* @param {number[][]} matrix
* @return {number}
*/
var longestIncreasingPath = function (matrix) {
const dirctionList = [
[0, 1],
[0, -1],
[1, 0],
[-1, 0],
];
const dfs = (matrix, row, col, rows, cols, memo) => {
if (memo[row][col] !== 1) return memo[row][col];
let max = 1;
for (const [dx, dy] of dirctionList) {
const curRow = row + dx;
const curCol = col + dy;
if (
curCol < 0 ||
curCol >= cols ||
curRow < 0 ||
curRow >= rows ||
matrix[curRow][curCol] <= matrix[row][col]
)
continue;
max = Math.max(max, 1 + dfs(matrix, curRow, curCol, rows, cols, memo));
}
memo[row][col] = max;
return max;
};
if (matrix.length === 0) return 0;
const m = matrix.length;
const n = matrix[0].length;
const memo = Array.from(
{
length: m,
},
() => new Array(n).fill(1)
);
let res = 1;
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
res = Math.max(res, dfs(matrix, i, j, m, n, memo));
}
}
return res;
};
复杂度
- 时间:n^2;
- 空间:n^2;
线性 dp
线性 dp
- 具有线性阶段划分的动态规划方法;
- 根据输入方法划分;
- 单串线性 dp;
- 双串线性 dp;
- 矩阵线性 dp;
- 无串线性 dp;
单串线性 dp
概述
- 问题输入为单个数组或单个字符串;
- 状态一般定义为 dp[i];
- nums[i] 结尾的子数组的相关状态;
- nums[i-1] 结尾的子数组的相关状态;
子数组和子序列
- 子序列:不改变数组原有顺序的元素序列,可不连续;
- 状态一般是前 i 个元素;
- 子数组:数组中的连续子序列;
- 状态一般是以 nums[i] 结尾;
双串线性 dp 问题
- 问题输入为两个数组或两个字符串;
- 状态一般定义为 dp[i][j];
- nums1[i] 和 nums2[j] 结尾的子数组的相关状态;
- nums2[i-1] 和 nums2[j] 结尾的子数组的相关状态;
矩阵线性 dp 问题
- 问题输入为二维矩阵;
- 状态一般定义为 dp[i][j],表示从 [0,0] 到 [i,j] 的相关状态;
无串线性 dp 问题
- 问题输入不是显式的数组,字符串或矩阵;
- 但可以分解为若干子问题的线性 dp 问题;
线性 dp 题目
最长递增子序列
题目
- 300;
思路
- 划分阶段:根据子序列末端索引划分;
- 定义状态:dp[i] 即以 nums[i] 结尾的最长递增子序列长度;
- 状态转移方程;
- 对于任意
0 <= j < i
; - 若 nums[i]>nums[j],dp[i] = dp[j] + 1,反之为 1;
- 即
dp[i] = max(dp[i],dp[j]+1), (0 <= j < i, nums[i]>nums[j])
;
- 对于任意
- 初始条件:默认每个状态皆为长度为 1 的递增子序列;
- 终止条件:返回 max(dp);
/**
* @param {number[]} nums
* @return {number}
*/
var lengthOfLIS = function (nums) {
const dp = new Array(nums.length).fill(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);
}
}
}
return Math.max(...dp);
};
复杂度
- 时间:n^2;
- 空间:n;
最长递增子序列的个数
题目
- 673;
思路
- 求最长递增子序列思路同最长递增子序列;
- 定义状态:额外定义 count[i],表示第 i 个字符前,最长递增子序列的个数;
- 状态转移公式;
- 如果 dp[j]+1>dp[i],表示第一次找到 dp[j]+1 长度的子序列,count[i]=count[j];
- 如果 dp[j]+1=dp[i],表示当前长度已经找到,组合数相加,count[i]+=count[j];
- 起始条件:count[i]=1;
- 终止条件:到达数组末端;
- 首先获取最长递增子序列长度;
- 获得该长度的组合数;
/**
* @param {number[]} nums
* @return {number}
*/
var findNumberOfLIS = function (nums) {
const dp = new Array(nums.length).fill(1);
const count = new Array(nums.length).fill(1);
for (let i = 1; i < nums.length; i++) {
for (let j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
if (dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1;
count[i] = count[j];
} else if (dp[j] + 1 === dp[i]) {
count[i] += count[j];
}
}
}
}
let res = 0;
const max = Math.max(...dp);
for (let i = 0; i < count.length; i++) {
if (dp[i] === max) res += count[i];
}
return res;
};
复杂度
- 时间:n^2;
- 空间:n;
俄罗斯套娃信封问题
题目
- 354;
思路
- 根据宽度对信封进行升序排列,相同宽度根据高度降序排列;
- 然后问题就转变为对信封高度求最长递增子序列;
- 使用动态规划会超时,所以使用二分查找方法;
/**
* @param {number[][]} envelopes
* @return {number}
*/
var maxEnvelopes = function (envelopes) {
envelopes = envelopes.sort((a, b) => {
if (a[0] === b[0]) return b[1] - a[1];
return a[0] - b[0];
});
const minStack = [envelopes[0][1]];
for (let i = 1; i < envelopes.length; i++) {
const num = envelopes[i][1];
if (num > minStack[minStack.length - 1]) {
minStack.push(num);
} else {
let left = 0;
let right = minStack.length;
while (left < right) {
const mid = Math.floor(left + (right - left) / 2);
if (num < minStack[mid]) right = mid;
else if (num === minStack[mid]) right = mid;
else if (num > minStack[mid]) left = mid + 1;
}
minStack[left] = num;
}
}
return minStack.length;
};
复杂度
- 时间:n^2;
- 空间:n;
最大子数组和
题目
- 53;
思路
- 划分状态:根据子数组末端索引划分;
- 定义状态:dp[i] 为第 i 个数结尾的连续子数组的最大和;
- 状态转移方程;
- 如果
dp[i-1]<0
,dp[i-1]+nums[i] 必然小于 nums[i]; - 反之必然大于 nums[i];
- 如果
- 初始条件:dp[0] = 0;
- 终止条件:达到数组末端,返回 dp[i];
/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function (nums) {
const dp = new Array(nums.length + 1);
dp[0] = 0;
for (let i = 0; i < nums.length; i++) {
if (dp[i] > 0) dp[i + 1] = dp[i] + nums[i];
else dp[i + 1] = nums[i];
}
dp.shift();
return Math.max(...dp);
};
复杂度
- 时间:n;
- 空间:n;
最长公共子序列
题目
- 1143;
思路
- 划分阶段:根据两个字符串结尾索引划分;
- 定义状态:dp[i][j] 表示为 t1 前 i 个元素和 t2 前 j 个元素的最长公共子序列长度;
- 状态转移方程;
- 双重遍历两个字符串;
- 维护最大 dp[i][j],记作 res;
- 若 t1[i-1] === t2[j-1],即两个字符串最后一位相同 dp[i][j] = dp[i-j][j-1]+1;
- 反之两个字符串最后一位不相同,dp[i][j] = max(dp[i-1][j],dp[i][j-1])
- 初始条件:d[0][j] = dp[j][0] = 0;
- 终止条件:双重遍历结束,返回 res;
/**
* @param {string} text1
* @param {string} text2
* @return {number}
*/
var longestCommonSubsequence = function (text1, text2) {
const dp = new Array(text1.length + 1)
.fill(0)
.map(() => new Array(text2.length + 1).fill(0));
for (let i = 1; i < text1.length + 1; i++) {
for (let j = 1; j < text2.length + 1; 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];
};
复杂度
- 时间:mn;
- 空间:mn;
最长重复子数组
题目
- 718;
思路
- 划分阶段:根据两个字符串结尾索引划分;
- 定义状态:dp[i][j] 表示为 t1 第 i 个元素为末尾项和 t2 第 j 个元素为末尾项的最长公共子数组长度;
- 状态转移方程;
- 双重遍历两个字符串;
- 维护最大 dp[i][j],记作 res;
- 若 t1[i-1] === t2[j-1],即两个字符串最后一位相同 dp[i][j] = dp[i-j][j-1]+1;
- 反之两个字符串最后一位不相同,dp[i][j] = 0;
- 初始条件:d[0][j] = dp[j][0] = 0;
- 终止条件:双重遍历结束,返回 res;
/**
* @param {number[]} nums1
* @param {number[]} nums2
* @return {number}
*/
var findLength = function (nums1, nums2) {
const m = nums1.length;
const n = nums2.length;
const dp = Array.from({ length: m + 1 }, () => 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;
} else {
dp[i][j] = 0;
}
res = Math.max(res, dp[i][j]);
}
}
return res;
};
复杂度
- 时间:mn;
- 空间:mn;
编辑距离
题目
- 72;
思路
- 划分阶段:根据两个字符串结尾索引划分;
- 定义状态:dp[i][j] 表示为 s 的前 i 个字符修改为 t 的前 j 个字符,需要的最小编辑步数;
- 状态转移方程;
dp[i,j]
中,对应字符为s[i-1],t[j-1]
;- 若 s[i-1] === t[j-1],无需任何操作,dp[i,j]=dp[i-1,j-1];
- 反之,三种操作对应三种情况;
- 添加:对应状态为
dp[i-1,j] + 1
; - 删除:对应状态为
dp[i,j-1] + 1
; - 替换:对应状态为
dp[i-1,j-1] + 1
;
- 添加:对应状态为
- 初始条件;
- word1 前 0 个字符插入 j 次变为 word2 前 j 个字符:dp[0][j] = j;
- 同理删除 i 次 dp[i][0]=i;
- 终止条件:双层遍历完毕,返回 dp[-1][-1];
动态规划
/**
* @param {string} word1
* @param {string} word2
* @return {number}
*/
var minDistance = function (word1, word2) {
const dp = Array.from(
{ length: word1.length + 1 },
() => new Array(word2.length + 1)
);
for (let i = 0; i < word1.length + 1; i++) {
dp[i][0] = i;
}
for (let i = 0; i < word2.length + 1; i++) {
dp[0][i] = i;
}
for (let i = 1; i < word1.length + 1; i++) {
for (let j = 1; j < word2.length + 1; 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 - 1][j], dp[i][j - 1]) + 1;
}
}
}
return dp[word1.length][word2.length];
};
复杂度
- 时间:mn;
- 空间:mn;
最小路径和
题目
64;
思路
- 划分阶段:根据行列号进行划分;
- 定义状态:dp[row][col] 为到达该点的最小路径和;
- 状态转移方程;
- 只能向下,向右移动;
- dp[row][col] = min(dp[row-1][col],dp[row][col-1]) + grid[row][col];
- 初始条件;
- 显然 dp[0][0] = grid[0][0];
- 左上角至首行首列只能向左或向下;
- dp[row][0] = dp[row-1][0] + grid[row][col];
- dp[0][col] = dp[0][col-1] + grid[row][col];
- 终止条件:到达 [m-1,n-1];
/**
* @param {number[][]} grid
* @return {number}
*/
var minPathSum = function (grid) {
const rows = grid.length;
const cols = grid[0].length;
const dp = Array.from({ length: rows }, () => new Array(cols));
dp[0][0] = grid[0][0];
for (let i = 1; i < rows; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for (let i = 1; i < cols; i++) {
dp[0][i] = dp[0][i - 1] + grid[0][i];
}
for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[rows - 1][cols - 1];
};
复杂度
- 时间:mn;
- 空间:mn;
最大正方形
题目
- 221;
思路
- 划分阶段:根据正方形右下角坐标进行划分;
- 定义状态:dp[i][j] 表示以 [i,j] 为右下角的最大正方形边长;
- 状态转移方程;
- 维护最大边长 res;
- 若 matrix[i][j] === 0,不可能为正方形,dp[i][j] = 0;
- 反之,dp[i][j] 由该位置的上方,左侧,左上方的最小值约束;
- 即 min(dp[i-1][j-1],dp[i-1][j],dp[i][j-1]) + 1;
- 起始条件:首行首列,有 1 为 1,反之为 0;
- 终止条件:遍历矩阵,返回 res;
/**
* @param {character[][]} matrix
* @return {number}
*/
var maximalSquare = function (matrix) {
const rows = matrix.length;
const cols = matrix[0].length;
const dp = Array.from({ length: rows }, () => new Array(cols).fill(0));
let res = 0;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
if (matrix[i][j] === "0") continue;
if (i === 0 || j === 0) dp[i][j] = 1;
else
dp[i][j] = Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]) + 1;
res = Math.max(res, dp[i][j]);
}
}
return res * res;
};
复杂度
- 时间:mn;
- 空间:mn;
整数拆分
题目
- 343;
思路
- 划分阶段:根据正整数 n 划分 0 - n;
- 定义状态:dp[i] 为 n 划分后的最大乘积;
- 状态转移方程;
- 当 i >= 2,i 拆分的第一个正整数假设为 j (
1<=j<i
); - 若只拆分两个整数,乘积为 j * (i-j);
- 反之,乘积为 j * dp[i-j];
- 遍历 j,dp[i] =
- 当 i >= 2,i 拆分的第一个正整数假设为 j (
- 初始条件,0 和 1 无法拆分,dp[0]=dp[1]=0;
- 终止条件:dp[n];
/**
* @param {number} n
* @return {number}
*/
var integerBreak = function (n) {
const dp = new Array(n + 1).fill(0);
for (let i = 2; i < n + 1; i++) {
for (let j = 1; j < i; j++) {
dp[i] = Math.max((i - j) * j, dp[i - j] * j, dp[i]);
}
}
return dp[n];
};
复杂度
- 时间:n^2;
- 空间:n;
乘积最大子数组
题目
- 152;
思路
- 划分阶段:根据数组元素划分;
- 定义状态:dp[i] 即以 nums[i] 结尾的乘积最大子数组;
- 状态转移方程;
- 由于数组存在负数,正数与负数相乘,会让最大乘积变最小乘积,反之依然,故维护数组最大值和最小值;
- dpMax[i]=max(dpMax[i-1]*nums[i-1],nums[i-1],dpMin[i-1]*nums[i-1]);
- dpMin[i]=min(dpMin[i-1]*nums[i-1],nums[i-1],dpMax[i-1]*nums[i-1]);
- 初始条件:dpMax[0]=dpMin[0]=nums[0];
- 终止条件:遍历数组,返回 dpMax[i];
/**
* @param {number[]} nums
* @return {number}
*/
var maxProduct = function (nums) {
const dpMax = new Array(nums.length);
const dpMin = new Array(nums.length);
dpMax[0] = dpMin[0] = nums[0];
for (let i = 1; i < nums.length; i++) {
dpMax[i] = Math.max(
dpMax[i - 1] * nums[i],
nums[i],
dpMin[i - 1] * nums[i]
);
dpMin[i] = Math.min(
dpMin[i - 1] * nums[i],
nums[i],
dpMax[i - 1] * nums[i]
);
}
return Math.max(...dpMax);
};
复杂度
- 时间:n;
- 空间:1;
打家劫舍 1
题目
- 198;
思路
- 划分阶段:根据房屋简述划分;
- 定义状态:dp[i] 表示前 i 间为止能偷窃的最高金额;
- 状态转移方程;
- 当偷窃第 i 间房屋时;
- 如果偷窃该房屋,则房屋 i-1 间无法偷窃,dp[i]=dp[i-2]+nums[i-1];
- 反之 dp[i]=dp[i-1];
- 初始状态:dp[0] = 0,dp[1]=nums[0];
- 终止条件:遍历数组,返回 dp[-1];
/**
* @param {number[]} nums
* @return {number}
*/
var rob = function (nums) {
const dp = new Array(nums.length + 1);
dp[0] = 0;
dp[1] = nums[0];
for (let i = 2; i < nums.length + 1; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}
return dp[nums.length];
};
复杂度
- 时间:n;
- 空间:1;
打家劫舍 2
题目
- 213;
思路
- 偷第一间,就不能偷最后一件;
- 故将问题分解为两个子问题,无视第一间和无视最后一件;
- 即偷窃 [0,-2] 和 [1,-1] 范围内首尾不相连的房屋;
- 转变为 2 个打家劫舍 1;
/**
* @param {number[]} nums
* @return {number}
*/
var rob = function (nums) {
const helper = (nums) => {
const dp = new Array(nums.length + 1);
dp[0] = 0;
dp[1] = nums[0];
for (let i = 2; i < nums.length + 1; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}
return dp[nums.length];
};
if (nums.length === 1) return nums[0];
const v1 = helper(nums.slice(1));
const v2 = helper(nums.slice(0, -1));
return Math.max(v1, v2);
};
复杂度
- 时间:n;
- 空间:n;
解码方法
题目
- 091;
思路
- 划分阶段:根据前 i 个字符串划分;
- 定义状态:dp[i] 表示前 i 个字符串可能构成的方案数;
- 状态转移方程;
- 使用一个字符:
dp[i] = dp[i] + dp[i-1] (s[i-1]!=0)
; - 使用两个字符:
dp[i] = dp[i] + dp[i-2] (s[i-2]!=0, s[i-2:i-1]<=26)
;
- 使用一个字符:
- 起始条件:dp[0]=1,其余为 0;
- 终止条件:遍历数组,返回 dp[n];
/**
* @param {string} s
* @return {number}
*/
var numDecodings = function (s) {
const n = s.length;
const dp = new Array(s.length + 1).fill(0);
dp[0] = 1;
for (let i = 1; i <= n; i++) {
if (s[i - 1] !== "0") dp[i] += dp[i - 1];
if (i > 1 && s[i - 2] !== "0" && Number(s.slice(i - 2, i)) <= 26)
dp[i] += dp[i - 2];
}
return dp[n];
};
复杂度
- 时间:n;
- 空间:n;
正则表达式匹配
题目
- 10;
思路
- 划分阶段:根据 s 和 p 前 i 个字符串划分;
- 定义状态:dp[i][j] 表示 s 的前 i 个字符与 p 的前 j 个字符是否匹配;
- 状态转移方程;
- 如果 s[i-1]===p[j-1] 或者 p[j-1] = 。,说明对应字符匹配,dp[i][j]=dp[i-1][j-1];
- 如果 p[j-1]=*,对 p 的第 j -1 个字符进行若干次匹配;
- 如果 s[i-1]!==p[j-2] 且 p[j-2] != 。,说明第 i 个字符和第 j-1 个字符无法匹配;
- dp[i][j]=dp[i][j-2];
- 反之匹配;
- 0 个:dp[i][j]=dp[i][j-2];
- 1 个:dp[i][j]=dp[i-1][j-2];
- 2 个:dp[i][k]=dp[i-2][j-2];
- 。。。
- 1 - n 个优化公式:dp[i][j]=dp[i-1][j],直接死记硬背吧;
- 如果 s[i-1]!==p[j-2] 且 p[j-2] != 。,说明第 i 个字符和第 j-1 个字符无法匹配;
- 起始条件;
- dp[0][0]=true,其余为 false;
- p[j-1]="*",取决于 j-2 是否也为 *,即 dp[0][j]=dp[0][j-2];
- 终止条件:遍历数组,返回 dp[m][n];
/**
* @param {string} s
* @param {string} p
* @return {boolean}
*/
var isMatch = function (s, p) {
const m = s.length;
const n = p.length;
const dp = Array.from(
{
length: m + 1,
},
() => new Array(n + 1).fill(false)
);
dp[0][0] = true;
for (let i = 1; i <= n; i++) {
if (p[i - 1] === "*") dp[0][i] = dp[0][i - 2];
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (s[i - 1] === p[j - 1] || p[j - 1] === ".")
dp[i][j] = dp[i - 1][j - 1];
else if (p[j - 1] === "*")
if (s[i - 1] !== p[j - 2] && p[j - 2] !== ".") dp[i][j] = dp[i][j - 2];
else dp[i][j] = dp[i][j - 2] || dp[i - 1][j];
}
}
return dp[m][n];
};
复杂度
- 时间:mn;
- 空间:mn;
通配符匹配
题目
- 44;
思路
- 思路类似于正则表达式匹配;
- 划分阶段:根据 s 和 p 前 i 个字符串划分;
- 定义状态:dp[i][j] 表示 s 的前 i 个字符与 p 的前 j 个字符是否匹配;
- 状态转移方程;
- 如果 s[i-1]===p[j-1] 或者 p[j-1] = ?,说明对应字符匹配,dp[i][j]=dp[i-1][j-1];
- 如果 p[j-1]=*,则 p[j-1] 匹配 s 任意数量字符;
- 0 个:dp[i][j]=dp[i][j-1];
- 1 个:dp[i][j]=dp[i-1][j-1];
- 2 个:dp[i][k]=dp[i-2][j-1];
- 。。。
- 1 - n 个优化公式:dp[i][j]=dp[i-1][j],直接死记硬背吧;
- 起始条件;
- dp[0][0]=true,其余为 false;
- s 为空,p 以若干个 * 前导,dp[0][j]=true;
- 终止条件:遍历数组,返回 dp[m][n];
/**
* @param {string} s
* @param {string} p
* @return {boolean}
*/
var isMatch = function (s, p) {
const m = s.length;
const n = p.length;
const dp = Array.from(
{
length: s.length + 1,
},
() => new Array(p.length + 1).fill(false)
);
dp[0][0] = true;
for (let i = 1; i <= n; i++) {
if (p[i - 1] !== "*") break;
dp[0][i] = true;
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (s[i - 1] === p[j - 1] || p[j - 1] === "?")
dp[i][j] = dp[i - 1][j - 1];
else if (p[j - 1] === "*") dp[i][j] = dp[i - 1][j] || dp[i][j - 1];
}
}
return dp[m][n];
};
复杂度
- 时间:mn;
- 空间:mn;
跳跃游戏 1
题目
- 55;
思路
- 划分阶段:根据数组索引划分;
- 定义状态:dp[i] 表示从位置 0 出发,到达位置 i,可以跳出的最大距离;
- 状态转移公式;
- 如果经过 [0,i-1],可以到达 i:dp[i-1]>=i,则 dp[i]=max(dp[i-1],i+nums[i]);
- 反之 dp[i-1] < i,则 dp[i]=dp[i-1];
- 初始条件:dp[0]=nums[0];
- 终止条件:返回 dp[n-1];
/**
* @param {number[]} nums
* @return {boolean}
*/
var canJump = function (nums) {
const n = nums.length;
const dp = new Array(n).fill(0);
dp[0] = nums[0];
for (let i = 1; i < n; i++) {
if (dp[i - 1] >= i) dp[i] = Math.max(dp[i - 1], nums[i] + i);
else dp[i] = dp[i - 1];
}
return dp[n - 1] >= n - 1;
};
复杂度
- 时间:n;
- 空间:n;
跳跃游戏 2
题目
- 45;
思路
- 划分阶段:根据数组索引划分;
- 定义状态:dp[i] 表示从位置 0 出发,到达位置 i,需要的最少步骤;
- 状态转移公式;
- 对于位置 i,如果可以从位置 j 调到;
- j+nums[j]>=i;
- dp[i]=min[dp[i],dp[j]+1];
- 遍历位置 j;
- 对于位置 i,如果可以从位置 j 调到;
- 初始条件:dp[i]=Infinity;
- 终止条件:返回 dp[n-1];
/**
* @param {number[]} nums
* @return {number}
*/
var jump = function (nums) {
const n = nums.length;
const dp = new Array(n).fill(Infinity);
dp[0] = 0;
for (let i = 0; i < n; i++) {
for (let j = 0; j < i; j++) {
if (j + nums[j] >= i) dp[i] = Math.min(dp[i], dp[j] + 1);
}
}
return dp[n - 1];
};
复杂度
- 时间:n^2;
- 空间:n;
单词拆分
题目
- 139;
思路
- 划分阶段:根据前 i 个字符划分;
- 定义状态:dp[i] 表示 s[0:i] 是否能拆分为单词;
- 状态转移方程;
- 遍历 j (
1<=j<i
); - s[j-1:i] 在字典中,dp[i]=dp[j-1];
- j-1 是第 j 个,所以 j 前面的 dp 为 dp[j-1];
- 反之 dp[i]=false;
- 遍历 j (
- 起始条件:dp[0]=true,其余为 false;
- 终止条件:返回 dp[n];
/**
* @param {string} s
* @param {string[]} wordDict
* @return {boolean}
*/
var wordBreak = function (s, wordDict) {
const n = s.length;
const dp = new Array(n + 1).fill(false);
dp[0] = true;
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= i; j++) {
if (dp[i] === true) break;
const str = s.slice(j - 1, i);
if (wordDict.includes(str)) dp[i] = dp[j - 1];
}
}
return dp[n];
};
复杂度
- 时间:n^2;
- 空间:n;
三角形最小路径和
题目
- 120;
思路
- 划分阶段:根据行数和列数划分;
- 定义状态:dp[i][j] 表示移动至第 i 行,第 j 列的最小路径和;
- 状态转移方程;
- 上一步行数为 i-1 行,列数为 j-1 列或 j 列;
- dp[i][j]=min(dp[i-1][j-1],dp[i-1][j])+triangle[i-1][j-1]
- 起始条件:dp[1][1]=triangle[0][0];
- 终止条件:返回 min(dp[i]);
/**
* @param {number[][]} triangle
* @return {number}
*/
var minimumTotal = function (triangle) {
const n = triangle.length;
const dp = Array.from(
{
length: n + 1,
},
() => new Array(n + 1).fill(Infinity)
);
dp[1][1] = triangle[0][0];
for (let i = 2; i <= n; i++) {
for (let j = 1; j <= i; j++) {
dp[i][j] =
Math.min(dp[i - 1][j], dp[i - 1][j - 1]) + triangle[i - 1][j - 1];
}
}
return Math.min(...dp[n]);
};
复杂度
- 时间:n^2;
- 空间:n^2;
鸡蛋掉落
题目
- 887;
思路
- 逆向思路;
- 已知 k 个鸡蛋,最多扔 x 次,求最多可以检测多少层;
- 划分阶段:根据鸡蛋个数和扔鸡蛋次数划分;
- 定义状态:dp[i][j] 表示一共 i 个鸡蛋,最多扔 j 次,最多可以监测的楼层个数;
- 因为一共 n 层楼,所以 j 最多是 n;
- 状态转移方程;
- 在 1 - n 层任意一层 x 扔鸡蛋;
- 如果鸡蛋没碎,i=i,j=j-1,最多可以检查 dp[i][j-1] 层楼层;
- 如果碎了,i=i-1,j=j-1,最多可以检查 dp[i-1][j-1] 层楼层;
- 加上第 x 层,dp[i][j]=dp[i][j-1]+dp[i-1][j-1]+1;
- 初始条件:dp=0;
- 终止条件:k 个鸡蛋全部使用且 dp[i][j] >= n,返回最小的 j;
/**
* @param {number} k
* @param {number} n
* @return {number}
*/
var superEggDrop = function (k, n) {
const dp = Array.from(
{
length: k + 1,
},
() => new Array(n + 1).fill(0)
);
for (let i = 1; i <= k; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = dp[i][j - 1] + dp[i - 1][j - 1] + 1;
if (i === k && dp[i][j] >= n) return j;
}
}
return n;
};
复杂度
- 时间:nk;
- 空间:nk;
交错字符串
题目
- 97;
思路
- 划分阶段:根据 s1 的前 i 个字符和 s2 的前 j 个字符划分;
- 定义状态:dp[i][j] 为 s1 的前 i 个字符和 s2 的前 j 个字符能否组成 s3 的前 i+j 个字符;
- 状态转移方程;
- 若 s3 最后一个字符为 i:dp[i][j]=dp[i-1][j];
- 反之:dp[i][j]=dp[i][j-1];
- 起始条件;
- dp[0][0]=true;
- 若 s1 或 s2 为空,遍历 s1/s2,其余 s3 相同的子字符串对应 dp 为 true;
- 终止条件:返回 dp[m][n];
/**
* @param {string} s1
* @param {string} s2
* @param {string} s3
* @return {boolean}
*/
var isInterleave = function (s1, s2, s3) {
const m = s1.length;
const n = s2.length;
if (m + n !== s3.length) return false;
const dp = Array.from(
{
length: m + 1,
},
() => new Array(n + 1).fill(false)
);
dp[0][0] = true;
for (let i = 1; i <= m; i++) {
if (s1[i - 1] !== s3[i - 1]) break;
dp[i][0] = true;
}
for (let i = 1; i <= n; i++) {
if (s2[i - 1] !== s3[i - 1]) break;
dp[0][i] = true;
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (s1[i - 1] === s3[i + j - 1]) dp[i][j] = dp[i - 1][j];
if (s2[j - 1] === s3[i + j - 1]) dp[i][j] = dp[i][j] || dp[i][j - 1];
}
}
return dp[m][n];
};
复杂度
- 时间:n^2;
- 空间:n^2;
买卖股票的最佳时机 3
题目
- 123;
思路
- 划分阶段:根据索引和持有股票状态划分;
- 定义状态:dp[i][j] 表示 [0,i]区间,不同持有股票的状态,所获得的最大价值;
- 四种股票状态:不进行任何操作 + 第一次持有股票 + 第一次不持有股票 + 第二次持有股票 + 第二次不持有股票;
- 状态转移方程;
- dp[i][0]:dp[i][0]=dp[i-1][0];
- dp[i][1]:具有第一次持有股票的状态;
- 不操作:dp[i][1]=dp[i-1][1];
- 买入股票:dp[i][1]=dp[i-1][0]-prices[i];
- 选取两者最大值;
- dp[i][2]:具有第一次未持有股票状态;
- 不操作:dp[i][2]=dp[i-1][2];
- 卖出股票:dp[i][1]=dp[i-1][1]+prices[i];
- 选取两者最大值;
- 3 和 4 状态同理;
- 初始条件:dp[0][0/2/4] = 0,dp[0][1/3]=-prices[0];
- 终止条件:返回 dp[n][4];
/**
* @param {number[]} prices
* @return {number}
*/
var maxProfit = function (prices) {
const n = prices.length;
const dp = Array.from(
{
length: n,
},
() => new Array(5).fill(0)
);
dp[0][1] = dp[0][3] = -prices[0];
for (let i = 1; i < n; i++) {
dp[i][0] = dp[i - 1][0];
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
dp[i][2] = Math.max(dp[i - 1][2], dp[i - 1][1] + prices[i]);
dp[i][3] = Math.max(dp[i - 1][3], dp[i - 1][2] - prices[i]);
dp[i][4] = Math.max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
}
return Math.max(...dp[n - 1]);
};
复杂度
- 时间:n;
- 空间:n^2;
背包问题
0-1 背包
问题概述
- 给定 n 个物品,重量为 cap 的背包;
- 物品重量为
wgt[i]
,价值为val[i]
; - 每个物品只能选择一次;
- 求背包能够放入物品的最大值;
基本思路
- 划分状态:根据物品序号和当前背包重量划分;
- 定义状态:dp[i][c] 为前 i 件物品放入一个 c 重量的背包,可获得的最大价值;
- 状态转移方程;
- 只考虑第 i 件物品,只有放入和不放入两个选项;
- 不放入物品,dp[i][c] = dp[i-1][c];
- 放入物品,dp[i][c] = dp[i-1][c-wgt[i-1]] + val[i-1];
- i - 1 即前 i - 1 件物品;
- c - wgt[i-1] 即去除第 i 件物品重量;
- val[i-1] 即第 i 件物品价值;
- 根据背包重量上限判断第 i 件物品能否放入;
- 只考虑第 i 件物品,只有放入和不放入两个选项;
- 初始条件:无物品或背包为 0,价值为 0,即 dp[i][0] 和 dp[0][i] 为 0;
- 终止条件:遍历到 dp 表末端,返回 dp[n][c];
/* 0-1 背包: 动态规划 */
function knapsackDP(wgt, val, cap) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array(n + 1)
.fill(0)
.map(() => Array(cap + 1).fill(0));
// 状态转移
for (let i = 1; i <= n; i++) {
for (let c = 0; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包重量, 则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = Math.max(
dp[i - 1][c],
dp[i - 1][c - wgt[i - 1]] + val[i - 1]
);
}
}
}
return dp[n][cap];
}
滚动数组优化
- 每个状态只和左上方和上方状态和有关,即只和 dp 表当前行和上一行有关 (第 i 件物品和 i - 1 物品);
- 只使用一个一维数组保存上一状态和当前状态 (背包重量);
- 使用倒序方式遍历更新一维数组;
- 上方状态:不会改变,不用考虑;
- 左上方状态:倒序遍历避免当前状态覆盖上一状态;
function knapsackDPComp(wgt, val, cap) {
const n = wgt.length;
const dp = Array(cap + 1).fill(0);
for (let i = 1; i <= n; i++) {
// 倒序遍历
for (let c = cap; c >= 0; c--) {
if (wgt[i - 1] <= c) {
dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}
完全背包
问题概述
- 给定 n 个物品,重量为 cap 的背包;
- 物品重量为
wgt[i]
,价值为val[i]
; - 每个物品数量没有限制;
- 求背包能够放入物品的最大值;
基本思路
- 状态转移方程;
- 只考虑第 i 件物品,只有放入和不放入两个选项;
- 不放入物品,dp[i][c] = dp[i-1][c];
- 放入物品,dp[i][c] = dp[i][c-wgt[i-1]] + val[i-1];
- i 即前 i 件物品,因为完全背包没有物品限制;
- c - wgt[i-1] 即去除第 i 件物品重量;
- val[i-1] 即第 i 件物品价值;
- 根据背包重量上限判断第 i 件物品能否放入;
- 只考虑第 i 件物品,只有放入和不放入两个选项;
/* 完全背包: 动态规划 */
function unboundedKnapsackDP(wgt, val, cap) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array.from({ length: n + 1 }, () =>
Array.from({ length: cap + 1 }, () => 0)
);
// 状态转移
for (let i = 1; i <= n; i++) {
for (let c = 0; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包重量, 则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = Math.max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[n][cap];
}
空间优化
- 每个状态只和左方状态和上方有关,即只和 dp 表当前行和上一行有关 (第 i 件物品和 i - 1 物品);
- 只使用一个一维数组保存上一状态和当前状态 (背包重量);
- 使用正序方式遍历更新一维数组;
- 上方状态:不会改变,不用考虑;
- 左方状态:正序遍历,保证左方状态的更新;
/* 完全背包: 空间优化后的动态规划 */
function unboundedKnapsackDPComp(wgt, val, cap) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array.from({ length: cap + 1 }, () => 0);
// 状态转移
for (let i = 1; i <= n; i++) {
for (let c = 0; c <= cap; c++) {
if (wgt[i - 1] <= c) {
// 不选和选物品 i 这两种方案的较大值
dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}
多重背包
问题概述
- 给定 n 个物品,重量为 cap 的背包;
- 物品重量为
wgt[i]
,价值为val[i]
,件数为count[i]
; - 求背包能够放入物品的最大值;
基本思路
- 01 背包转换;
- 对于第 i 件物品,可以最多选择 count[i] 次;
- 从 0 - count[i] 进行遍历,转换为 01 背包问题;
- 划分状态:根据物品序号和当前背包重量划分;
- 定义状态:dp[i][c] 为前 i 件物品放入一个 c 重量的背包,可获得的最大价值;
- 状态转移方程;
- 只考虑第 i 件物品,只有放入和不放入两个选项;
- 不放入物品,dp[i][c] = dp[i-1][c];
- 放入物品 k 次,dp[i][c] = dp[i][c-k*wgt[i-1]] + k*val[i-1];
- i 即前 i 件物品,因为完全背包没有物品限制;
- c - k*wgt[i-1] 即去除第 i 件物品重量;
- k*val[i-1] 即第 i 件物品价值;
- 根据背包重量上限判断 k 个第 i 件物品能否放入;
- 只考虑第 i 件物品,只有放入和不放入两个选项;
- 初始条件:无物品或背包为 0,价值为 0,即 dp[i][0] 和 dp[0][i] 为 0;
- 终止条件:遍历到 dp 表末端,返回 dp[n][c];
/* 多重背包: 动态规划 */
function unboundedKnapsackDP(wgt, val, cap, count) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array.from({ length: n + 1 }, () =>
Array.from({ length: cap + 1 }, () => 0)
);
// 状态转移
for (let i = 1; i <= n; i++) {
for (let c = 0; c <= cap; c++) {
for (let k = 0; k <= Math.min(count[i - 1]); k++) {
if (k * wgt[i - 1] > c) {
// 若超过背包重量, 则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = Math.max(
dp[i - 1][c],
dp[i - 1][c - k * wgt[i - 1]] + k * val[i - 1]
);
}
}
}
}
return dp[n][cap];
}
空间优化
- 参考 01 背包;
分组背包
问题概述
- 01 背包变种;
- 给定 n 组物品,重量为 cap 的背包;
- 第 i 组物品件数为
groupCount[i]
,第 n 组第 i 个物品重量为wgt[i][j]
,价值为val[i][j]
; - 每组物品只能选择一个;
- 求背包能够放入物品的最大值;
基本思路
- 划分状态:根据物品组数和当前背包重量划分;
- 定义状态:dp[i][c] 为前 i 组物品放入一个 c 重量的背包,可获得的最大价值;
- 状态转移方程;
- 只考虑第 i 组物品,只有放入和不放入两个选项;
- 不放入该组物品,dp[i][c] = dp[i-1][c];
- 放入该组第 j 个物品,dp[i][c] = dp[i-1][c-wgt[i-1][j]] + val[i-1][j];
- i - 1 即前 i - 1 件物品;
- c - wgt[i-1][j] 即去除第 i 件物品重量;
- val[i-1][j] 即第 i 件物品价值;
- 根据背包重量上限判断 k 个第 i 件物品能否放入;
- 只考虑第 i 组物品,只有放入和不放入两个选项;
- 初始条件:无物品或背包为 0,价值为 0,即 dp[i][0] 和 dp[0][i] 为 0;
- 终止条件:遍历到 dp 表末端,返回 dp[n][c];
/* 0-1 背包: 动态规划 */
function knapsackDP(wgt, val, cap, groupCount) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array(n + 1)
.fill(0)
.map(() => Array(cap + 1).fill(0));
// 状态转移
for (let i = 1; i <= n; i++) {
for (let c = 0; c <= cap; c++) {
for (let j = 0; i < groupCount[i - 1]; j++) {
if (wgt[i - 1][j] > c) {
// 若超过背包重量, 则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = Math.max(
dp[i - 1][c],
dp[i - 1][c - wgt[i - 1][j]] + val[i - 1][j]
);
}
}
}
}
return dp[n][cap];
}
空间优化
- 同 01 背包;
二维背包问题
问题概述
- 给定 n 个物品,重量为 cap 的背包,容量为 v;
- 物品重量为
wgt[i]
,价值为val[i]
,容量为vol[i]
; - 每个物品只能选择一次;
- 求背包能够放入物品的最大值;
基本思路
- 划分状态:根据物品序号和当前背包重量划分;
- 定义状态:dp[i][c][v] 为前 i 件物品放入一个 c 重量,v 容量的背包,可获得的最大价值;
- 状态转移方程;
- 只考虑第 i 件物品,只有放入和不放入两个选项;
- 不放入物品,dp[i][c][v] = dp[i-1][c][v];
- 放入物品,dp[i][c] = dp[i-1][c-wgt[i-1]][v-volume[i-1] + val[i-1];
- i - 1 即前 i - 1 件物品;
- c - wgt[i-1] 即去除第 i 件物品重量;
- v - vol[i-1] 即去除第 i 件物品容量;
- val[i-1] 即第 i 件物品价值;
- 根据背包重量上限和容量上限判断第 i 件物品能否放入;
- 只考虑第 i 件物品,只有放入和不放入两个选项;
- 初始条件:无物品,背包重量为 0,背包容量为 0,价值为 0,即 dp[i][0][0],dp[0][i][0] 和 dp[0][0][i] 为 0;
- 终止条件:遍历到 dp 表末端,返回 dp[n][c][v];
/* 0-1 背包: 动态规划 */
function knapsackDP(wgt, val, vol, cap, v) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array(n + 1)
.fill(0)
.map(() =>
Array(cap + 1)
.fill(0)
.map(() => Array(v + 1).fill(0))
);
// 状态转移
for (let i = 1; i <= n; i++) {
for (let c = 0; c <= cap; c++) {
for (let v = 0; v <= vol; v++) {
if (wgt[i - 1] > c || vol[i - 1] > v) {
// 若超过背包重量, 则不选物品 i
dp[i][c][v] = dp[i - 1][c][b];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c][v] = Math.max(
dp[i - 1][c][v],
dp[i - 1][c - wgt[i - 1]][v - vol[i - 1]] + val[i - 1]
);
}
}
}
}
return dp[n][cap][v];
}
滚动数组优化
- 参考 01 背包;
混合背包
问题概述
- 给定 n 个物品,重量为 cap 的背包;
- 物品重量为
wgt[i]
,价值为val[i]
,件数为count[i]
;- 0:无穷件;
- i:i 件;
- 求背包能够放入物品的最大值;
基本思路
- 01 背包,完全背包和多重背包的混合;
- 根据 count 的不同分别使用不同背包的解决方法;
/* 多重背包: 动态规划 */
function unboundedKnapsackDP(wgt, val, cap, count) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array.from({ length: n + 1 }, () =>
Array.from({ length: cap + 1 }, () => 0)
);
// 状态转移
for (let i = 1; i <= n; i++) {
if (count[i - 1] === 1) {
// 01 背包
} else if (count[i - 1] === 0) {
// 无穷背包
} else {
// 多重背包
}
}
return dp[n][cap];
}
背包问题变种
背包问题第二层循环 0 和 1
- 第二层循环是否以 0 或 1 为起点/终点;
- 关键在于
dp[0][i] (i!=0)
是否恒定不变;- 大部分最值问题和存在问题,恒定不变,所以 1 即可;
- 但是组合问题,可能发生变化,所以需要 0;
最值问题
问题概述
- 背包问题变种;
- 求各种最大值,最小值;
基本思路
- 背包问题默认 dp;
- 状态转移方程;
- 01 背包:dp[i][c] = Math.max(dp[i - 1][c],dp[i-1]c - wgt[i - 1]] + value);
- 完全背包:dp[i][c] = Math.max(dp[i - 1][c],dp[i]c - wgt[i - 1]] + value);
- value;
- 物品价值:val[i - 1];
- 常数:1;
- 。。。;
组合问题
问题概述
- 背包问题变种;
- 求总重量不超过重量上限的情况下,可以选择的方案数/组合;
基本思路
- 状态转移方程;
- 只考虑第 i 件物品,根据背包重量上限判断第 i 件物品能否放入;
- 无法放入物品物品,dp[i][c] = dp[i-1][c];
- 可以放入物品,有不放入和放入两个选择,将背包问题的求最大值改为相加;
- 01 背包:dp[i][c] = dp[i-1][c] + dp[i-1][c-wgt[i-1]];
- 完全背包 dp[i][c] = dp[i-1][c] + dp[i][c-wgt[i-1]];
- 只考虑第 i 件物品,根据背包重量上限判断第 i 件物品能否放入;
- 初始条件:dp[i][0] 或 dp[0][i] = 1,其余为 0,根据具体问题分析;
是否存在问题
问题概述
- 背包问题变种;
- 背包是否存在某个物品组合,满足某个特定条件;
基本思路
- 状态转移方程;
- 只考虑第 i 件物品,根据背包重量上限判断第 i 件物品能否放入;
- 无法放入物品物品,dp[i][c] = dp[i-1][c];
- 可以放入物品,判断上一状态是否存在;
- 01 背包:dp[i][c] = dp[i-1][c] || dp[i-1][c-wgt[i-1]];
- 完全背包 dp[i][c] = dp[i-1][c] || dp[i][c-wgt[i-1]];
- 只考虑第 i 件物品,根据背包重量上限判断第 i 件物品能否放入;
- 初始条件:dp[i][0] 或 dp[0][i] = true,其余状态默认为 false,根据具体问题分析;
求恰好装满背包
问题概述
- 背包问题变种;
- 求背包恰好装满的最值/组合数量/方案数;
基本思路
- 涉及初始条件和终止条件的变化;
- 初始条件;
- 最值问题;
- 背包上限为 0,必然装满背包,即 dp[i][0] = 0;
- 其余初始状态假设都不能放入背包,定义为 -Infinity/Infinity;
- 组合问题;
- 背包上限为 0,必然装满背包,即 dp[i][0] = 1;
- 其余初始状态假设方案均为 0;
- 存在问题;
- 背包上限为 0,必然装满背包,即 dp[i][0] = true;
- 其余初始状态假设方案均为 false;
- 最值问题;
- 终止条件:遍历到 dp 表末端,根据具体问题,返回 dp[n][c];
- 最值问题:判断 dp[n][c] 是否等于 -Infinity/Infinity,根据问题返回对应值;
- 其余问题:直接返回;
背包问题题目
分割等和子集
题目
- 416;
思路
- 转换问题描述,即从数组选择一些元素,使子集和恰好等于整个数组元素和的一般;
- 转换为 01 背包问题;
- nums 代表物品价值,nums 总和的一半代表背包特定价值;
- 判断背包是否存在若干物品组合,使得背包价值等于特定值;
- 状态转移方程:01 背包 + 存在问题;
- 物品数量 nums.length;
- 物品价值:nums[i];
/**
* @param {number[]} nums
* @return {boolean}
*/
var canPartition = function (nums) {
const sum = nums.reduce((a, b) => a + b, 0);
if (sum % 2 === 1) return false;
const n = nums.length;
const cap = sum / 2;
const dp = Array.from(
{
length: n + 1,
},
() => new Array(cap + 1).fill(false)
);
for (let i = 0; i <= n; i++) {
dp[i][0] = true;
}
for (let i = 1; i <= n; i++) {
for (let j = 0; j <= cap; j++) {
if (nums[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
}
}
}
return dp[n][cap];
};
复杂度
- 时间:n * target;
- 空间:target;
目标和
题目
- 494;
思路
- 问题分析;
- 数组所有元素和为 sum,添加正号的和为 x,添加负号的和为 s,x + s = sum;
- 又因为 x-s = target,x = (sum+target)/2;
- 又因为 x 必然是整数,所以 sum+target 必然是偶数,且 target 必然小于 sum;
- 转换为 01 背包问题;
- nums 代表物品价值,x 代表背包特定价值;
- 计算 nums 若干物品组合的方案,使得背包价值等于特定值;
- 状态转移方程:01 背包 + 组合问题;
- 物品数量 nums.length;
- 物品价值/重量:nums[i];
/**
* @param {number[]} nums
* @param {number} target
* @return {number}
*/
var findTargetSumWays = function (nums, target) {
const sum = nums.reduce((a, b) => a + b, 0);
const temp = sum + target;
if (temp % 2 === 1 || temp < 0) return 0;
const W = temp / 2;
const n = nums.length;
const value = nums;
const weight = nums;
const dp = Array.from(
{
length: n + 1,
},
() => new Array(W + 1).fill(0)
);
for (let i = 0; i <= n; i++) {
dp[i][0] = 1;
}
for (let i = 1; i <= n; i++) {
for (let j = 0; j <= W; j++) {
if (weight[i - 1] > j) dp[i][j] = dp[i - 1][j];
else dp[i][j] = dp[i - 1][j] + dp[i - 1][j - weight[i - 1]];
}
}
return dp[n][W];
};
复杂度
- 时间:2^n;
- 空间:n;
零钱兑换 1
题目
- 322;
思路
- 转换为完全背包问题;
- 即 n 种不同硬币,每种硬币无线选取,恰好凑成指定金额,求最少硬币数量;
- 完全背包问题 + 恰好装满背包
- 状态转移方程:完全背包;
- 初始条件:恰好装满背包;
/**
* @param {number[]} coins
* @param {number} amount
* @return {number}
*/
var coinChange = function (coins, amount) {
const n = coins.length;
const value = coins;
const dp = Array.from(
{
length: n + 1,
},
() => new Array(amount + 1).fill(Infinity)
);
for (let i = 0; i <= n; i++) {
dp[i][0] = 0;
}
for (let i = 1; i <= n; i++) {
for (let j = 0; j <= amount; j++) {
if (value[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - value[i - 1]] + 1);
}
}
}
return dp[n][amount] === Infinity ? -1 : dp[n][amount];
};
复杂度
- 时间:amount * size;
- 空间:amount * size;
零钱兑换 2
题目
- 518;
思路
- 转换为完全背包问题;
- 即 n 种不同硬币,每种硬币无线选取,恰好凑成指定金额,求组合方案数;
- 完全背包问题 + 刚好凑满 + 组合问题
- 状态转移方程:完全背包 + 组合问题;
- 相加 + 第二层循环从 0 开始;
- 初始条件:组合问题 + 刚好凑满;
- dp[0][0] = 1,其余为 Infinity
/**
* @param {number} amount
* @param {number[]} coins
* @return {number}
*/
var change = function (amount, coins) {
const n = coins.length;
const value = coins;
const dp = Array.from(
{
length: n + 1,
},
() => new Array(amount + 1).fill(0)
);
for (let i = 0; i <= n; i++) {
dp[i][0] = 1;
}
for (let i = 1; i <= n; i++) {
for (let j = 0; j <= amount; j++) {
if (value[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j] + dp[i][j - value[i - 1]];
}
}
}
return dp[n][amount];
};
复杂度
- 时间:amount * size;
- 空间:amount * size;
区间 dp
区间动态规划
- 通过区间长度划分状态,使用区间左右索引作为状态维度;
- 一个状态由一个区间长度更小的状态转移;
- 通过小区间的最优解,进行合并,得到大区间的最优解;
常见分类
- 单个区间从中间向两侧转移:
[i+1,j-1]
===>[i,j]
; - 多个区间合并,转移至大区间;
[i,k] + [k,j]
===>[i,j]
;
基本思路
从中间向两侧
- 状态转移方程:
dp[i][j]=max(dp[i+1][j-1], dp[i+1][j], dp[i][j-1]) + cost[i][j]
;- 由于使用下方,左方和左下方状态;
- i 倒序遍历,j 正序遍历;
- 根据具体题目处理
j>=i 或 j>i
;- 默认区
i>=j
,再判断 i===j 的特殊情况;
- 默认区
- 解题思路;
- 枚举区间起点;
- 枚举区间终点;
- 定义状态转移方程;
for i in range(size - 1, -1, -1):
for j in range(i+1, size):
dp[i][j] = max(dp[i + 1][j - 1], dp[i + 1][j], dp[i][j - 1]) + cost[i][j]
多个区间合并
- 状态转移方程:
dp[i][j]=max(dp[i][j], dp[i][k],dp[k][j] + cost[i][j]) (i<=k<=j)
; - 根据具体题目,考虑 k 的开闭区间;
- 解题思路;
- 枚举区间长度;
- 枚举区间起点,根据区间长度得到区间终点;
- 枚举区间分割点;
- 定义状态转移方程;
for l in range(1, n+1):
for i in range(n):
j = i + l - 1
if j >= n:
break
dp[i][j] = float('-inf')
for k in range(i+1, j):
dp[i][j] = max(dp[i][j], dp[i][k] + dp[k][j] + cost[i][j])
区间 dp 题目
最长回文子序列
题目
- 516;
思路
- 划分阶段:根据区间两端索引划分;
- 定义状态:dp[i][j] 表示 [i,j] 区间内的最长回文子序列长度;
- 状态转移方程;
- 如果
s[i] === s[j]
;- 如果 i===j,
dp[i][j]=1
- 反之
dp[i][j] = dp[i+1][j-1]+2
- 如果 i===j,
- 反之
dp[i][j]=max(dp[i][j-1], dp[i+1][j])
;
- 如果
- 初始条件:全部为 0;
- 终止条件:返回 dp[0][size-1];
/**
* @param {string} s
* @return {number}
*/
var longestPalindromeSubseq = function (s) {
const size = s.length;
const dp = Array.from(
{
length: size,
},
() => new Array(size).fill(0)
);
for (let i = size - 1; i >= 0; i--) {
for (let j = i; j < size; j++) {
if (s[i] === s[j] || i === j) {
if (i === j) dp[i][j] = 1;
else dp[i][j] = dp[i + 1][j - 1] + 2;
} else dp[i][j] = Math.max(dp[i][j], dp[i + 1][j], dp[i][j - 1]);
}
}
return dp[0][size - 1];
};
复杂度
- 时间:n^2;
- 空间:n^2;
最长回文子串
题目
- 5;
思路
- 划分阶段:根据区间两端索引划分;
- 定义状态:
dp[i][j]
表示s[i,j]
区间是否为回文子串; - 状态转移方程;
- 维护最长子串长度和起始位置 len 和 start;
- 如果
s[i] === s[j]
;- 如果 j===i,
dp[i][j] = true
; - 如果 j===i+1,
dp[i][j] = true
; - 反之
dp[i][j] = dp[i+1][j-1]
;
- 如果 j===i,
- 反之
dp[i][j]=false
;
- 初始条件:
dp[i][i]
= true,其余为 false; - 终止条件:返回 s.slice(start,start+len);
/**
* @param {string} s
* @return {string}
*/
var longestPalindrome = function (s) {
const n = s.length;
const dp = Array.from({ length: n }, () => new Array(n).fill(false));
let start = 0;
let len = -1;
for (let i = n - 1; i >= 0; i--) {
for (let j = i; j < n; j++) {
if (s[i] !== s[j]) continue;
if (i === j || i + 1 === j) dp[i][j] = true;
else dp[i][j] = dp[i + 1][j - 1];
if (dp[i][j] && j - i + 1 > len) {
start = i;
len = j - i + 1;
}
}
}
return len === -1 ? s : s.slice(start, start + len);
};
复杂度
- 时间:n^2;
- 空间:n^2;
回文子串
题目
- 647;
思路
- 同最长回文子串;
- 初始值设置为字符串长度,
- 最大长度和起始位置替换为次数即可;
/**
* @param {string} s
* @return {number}
*/
var countSubstrings = function (s) {
const size = s.length;
const dp = Array.from(
{
length: size,
},
() => new Array(size).fill(false)
);
for (let i = 0; i < size; i++) {
dp[i][i] = true;
}
let res = 0;
for (let i = size - 1; i >= 0; i--) {
for (let j = i; j < size; j++) {
if (s[i] === s[j]) {
if (j - i <= 1) dp[i][j] = true;
else dp[i][j] = dp[i + 1][j - 1];
if (dp[i][j] === true) res++;
} else {
dp[i][j] = false;
}
}
}
return res;
};
复杂度
- 时间:n^2;
- 空间:n^2;
戳气球
题目
- 312;
思路
- 超出边界的气球为 1,因此数组两端添加两个数字为 1 的虚拟气球;
- 划分阶段:根据区间两端索引划分;
- 定义状态:
dp[i][j]
表示戳破(i,j)
区间所有气球获得的硬币数量; - 状态转移方程;
- 假设
(i,j)
最后一个被戳破的气球索引为 k; dp[i][j]
分割为dp[i][k] 和 dp[k][j]
;dp[i][j] = dp[i][k] + dp[k][j] + arr[i]*arr[j]*arr[k]
- 假设
- 初始条件:
dp[i][j] = 0
; - 终止条件:
dp[0][size-1]
;
/**
* @param {number[]} nums
* @return {number}
*/
var maxCoins = function (nums) {
nums.push(1);
nums.unshift(1);
const size = nums.length;
const dp = Array.from(
{
length: size,
},
() => new Array(size).fill(0)
);
for (let l = 3; l <= size; l++) {
for (let i = 0; i < size; i++) {
j = i + l - 1;
if (j >= size) continue;
for (let k = i + 1; k < j; k++) {
dp[i][j] = Math.max(
dp[i][j],
dp[i][k] + dp[k][j] + nums[i] * nums[j] * nums[k]
);
}
}
}
return dp[0][size - 1];
};
复杂度
- 时间:n^3;
- 空间:n^2;
有效的括号字符串
题目
- 678;
思路
- 划分阶段:子串索引位置划分;
- 定义状态:dp[i][j] 为 s[i,j] 是否为有效字符串;
- 状态转移方程;
- 长度为 1:若 s[i] 为 *,可看做空字符串,dp[i][j] = true;
- 长度为 2:若两个字符串可分别看作
()
,dp[i][j] = true; - 其余情况;
- 一个有效字符串:若两个字符串可分别看作
()
,dp[i][j] = dp[i+1][j-1]; - 多个有效字符串:dp[i][j]=dp[i][k]&&dp[k+1][j];
- 一个有效字符串:若两个字符串可分别看作
- 起始条件:dp[i][j] = false;
- 终止条件:返回 dp[0][size-1];
/**
* @param {string} s
* @return {boolean}
*/
var checkValidString = function (s) {
const n = s.length;
const dp = Array.from(
{
length: n,
},
() => new Array(n).fill(false)
);
for (let i = n - 1; i >= 0; i--) {
for (let j = i; j < n; j++) {
if (i === j && s[i] === "*") dp[i][j] = true;
else if (
i + 1 === j &&
(s[i] === "(" || s[i] === "*") &&
(s[j] === ")" || s[j] === "*")
)
dp[i][j] = true;
else if (
(s[i] === "(" || s[i] === "*") &&
(s[j] === ")" || s[j] === "*")
) {
dp[i][j] = dp[i + 1][j - 1];
for (let k = i; k < j; k++) {
if (dp[i][j]) break;
dp[i][j] = dp[i][k] && dp[k + 1][j];
}
}
}
}
return dp[0][n - 1];
};
复杂度
- 时间:n^3;
- 空间:n^2;