动态规划
一、什么是动态规划
1.动态规划简介
动态规划
Dynamic Programming
,是一种将原问题分解为若干个子问题,通过解决这些子问题最终得到原问题答案的算法。
2.动态规划的概念
-
优化问题
- 动态规划通常用于解决优化问题,这类问题需要求解最优解或最小化某种代价。
-
最优子结构
- 最优子结构是指原问题的最优解可以通过子问题的最优解来构造。
- 通过利用子问题的最优解,可以逐步构建出原问题的最优解
-
重叠子问题
- 在动态规划中,子问题通常会被重复求解多次。
- 通过
记忆化搜索
或者自底向上
的迭代方式,可以避免重复计算子问题,提高效率
-
状态转移方程
- 状态转移方程描述了状态之间的转移关系,即如何从一个状态转移到另一个状态。
- 通过状态转移方程,可以将原问题分解为一系列子问题,并通过求解子问题来求解原问题。
3.动态规划的核心思想
动态规划最核心的思想,就在于拆分子问题,推导公式,记住过往,减少重复计算。
举一个例子:1 + 1 + 1 = 3
当左边的公式再+1
的时候可以很快得出答案是4
。我们不需要重复计算四个1
相加,只需要通过前面的答案再加上1
就可以得到答案。这就是动态规划当中的 记住求过的解来节省时间
4.动态规划中的术语
- 状态
state
- 在动态规划中,状态表示问题的局部解,通常用一个或多个变量来表示。
- 状态是问题求解过程中的关键信息,动态规划的核心就是定义好状态。
- 无后效性
- 无后效性则是定义好的状态不受之前各个阶段状态的影响,这意味着,在解决一个问题时,可以通过求解子问题的最优解来构建全局最优解,而不需要考虑子问题的解与其它阶段的状态之间的关系。
- 记忆化搜索
Memoiztion
- 记忆化搜索是一种优化动态规划算法的方法,通过保存子问题的解,避免重复计算子问题。
- 通常使用数组、哈希表等数据结构来保存已经计算过的子问题的解。
- 自底向上的迭代
Bottom-Up Iteration
- 自底向上的迭代是另一种优化动态规划算法的方法,通过迭代计算子问题的解,从而求解原问题。
- 与记忆化搜索不同,自底向上的迭代不需要递归调用,通常更高效。
dp/db
- 在动态规划中,通常会使用一个二维数组(或者更高维的数组)来存储问题的中间结果或者状态转移方程的值。这个数组一般被简称为 DP 表或者 DP 数组。
- 在个别代码里通常会用bd来表示该数组
二、什么样的题型可以使用动态规划
-
动态规划通常用于解决优化问题,特别适用于具有以下特征的问题:
- 重叠子问题:问题的解可以被分解为若干个子问题,并且这些子问题在求解过程中会被重复计算。
- 最优子结构:问题的最优解可以通过子问题的最优解来构造,即问题具有最优子结构性质。
- 状态转移:问题可以通过状态转移方程来描述,即问题的当前状态与之前状态之间存在某种转移关系。
-
基于以上特征,以下是一些常见类型的算法问题可以使用动态规划求解:
- 最长公共子序列(Longest Common Subsequence):寻找两个序列中的最长公共子序列的长度。
- 背包问题(Knapsack Problem):在给定的一组物品中选择一些物品装入背包,使得装入的物品价值最大,但是不能超过背包的容量。
- 最短路径问题(Shortest Path Problem):寻找两个节点之间的最短路径,如 Dijkstra 算法、Floyd-Warshall 算法等。
- 最长递增子序列(Longest Increasing Subsequence):在给定序列中找到一个最长的子序列,使得子序列中的元素按照顺序递增。
- 字符串编辑距离(Edit Distance):计算两个字符串之间的最小编辑操作次数,如插入、删除、替换等操作,使得两个字符串相等。
- 矩阵链乘法(Matrix Chain Multiplication):给定一系列矩阵,寻找一种矩阵乘法的顺序,使得总的乘法次数最小。
- 子集和问题(Subset Sum Problem):给定一个集合和一个目标值,判断集合中是否存在一个子集的和等于目标值。
三、动态规划怎么用
当发现题型的解法可以用动态规划时,可以考虑按照如下四步按顺序解题
- 创建
dp
理解其含义,定义好dp
状态,初始化dp
数组 - 递推公式-确定状态转移方程
- 遍历顺序,计算结果
下面将根据以上三步,对几个经典题型做推导解析
文档内所有代码均可在编辑器中运行使用
1. [爬楼梯-力扣70](70. 爬楼梯 - 力扣(LeetCode))
假设你正在爬楼梯。需要
n
阶你才能到达楼顶。每次你可以爬
1
或2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
解题思路:
假设爬到n
阶段需要f(n)
种方法。当我们处在最后一步的时候就只有两种情况,两层或者爬一层,那么可以得知x = f(n - 1) + f(n - 2)
。也就是说f(n)
只与之前的f(n - 1)
和f(n - 2)
有关。符合动态规划概念中提到的无后效性。
下面逐步推导,已知一次只能爬1
或 2
个台阶,,也就是说最小的两个值分别是1
和2
。
得到最小的值后 就可以从第三个值开始算起
根据推导就可以着手开始完成算法题了。
const climbStairs = (n) => {
//创建一个数组用来存储中间状态的答案,也就是dp数组。该数组的长度取决于台阶n的数量
const db = new Array(n + 1).fill(0);
db[1] = 1;
db[2] = 2;
// 顺序遍历 开始位置为已知值的后一个
for (let i = 3; i <= n; i++) {
// 确认状态转移方程
db[i] = db[i - 1] + db[i - 2];
}
// 返回dp 数组中的第 n 个 也就是爬到n个台阶有多少种组合
return db[n];
};
最终执行结果
54ms
49.00MB
2.买卖股票的最佳时机-力扣121
给定一个数组
prices
,它的第i
个元素prices[i]
表示一支给定股票第i
天的价格。你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回
0
。
解题思路:
求这笔交易可以得到的最大利润,也就是求第n
天可以得到得最大利润。其中n
为 prices
的长度
每天交易结束会存在两种情况,也就是手上还有股票和手上没有股票分别会有多少利润。所以可设db[i] = ['持有股票的利润','不持有股票的利润']
第一天的状态则是 db[0] = [-prices[0],0]
创建好db
和定义好状态后推导状态转移公式:
- 当天如果选择持有股票的话,利润最大值应该取自下面两种其中之一
- 前一天持有股票
db[i - 1][0]
- 今天买入股票
-prices[i]
- 前一天持有股票
- 如果当天选择不持有股票,利润最大值应该取自下面两种其中之一
- 前一天不持有股票时的利润
db[i - 1][1]
- 前一天的股票今天卖出
db[i - 1][0] + prices[i]
- 前一天不持有股票时的利润
根据推导就可以着手开始完成算法题了。
var maxProfit = function (prices) {
// 创建一个dp数组 保存每天的状态
const db = new Array(prices.length).fill([0, 0]);
// 初始化第一天的状态
db[0] = [-prices[0], 0];
// 循环遍历
for (let i = 1; i < prices.length; i++) {
// 状态转移方程 推导最后一天的状态
db[i] = [
Math.max(db[i - 1][0], -prices[i]),
Math.max(db[i - 1][1], db[i - 1][0] + prices[i]),
];
}
// 返回最后一天持有股票的最大利润和不持有股票的最大利润中的最大值
return Math.max(...db[prices.length - 1]);
};
最终执行结果
190ms
82.26MB
变种 不创建dp
数组 节省空间和时间
var maxProfit = (prices) => {
let hold = -prices[0];
let empty = 0;
for (let i = 0; i < prices.length; i++) {
hold = Math.max(hold, -prices[i]);
empty = Math.max(empty, hold + prices[i]);
}
return Math.max(hold, empty);
};
最终执行结果
82ms
58.93MB
3.买卖股票的最佳时机2-力扣122
给你一个整数数组
prices
,其中prices[i]
表示某支股票第i
天的价格。在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
解题思路:
求能获得的最大利润,可以购买多次,且可以当天买卖,但是同一时间只能持有一只股票。也就是求出n
天的最大利润 n
为 prices
的长度
因为同一时间只能有一只股票,所以每天交易的状态可能会有两种,手里有股票和手里没股票 也就可以设置状态为 db[i] = [没有股票的最大收益,有股票的最大收益]
第一天的状态则是 db[0] = [0,-prices[0]]
创建好db
和定义好状态后推导状态转移公式:
- 如果当天选择不持有股票,利润最大值应该取自下面两种其中之一
- 前一天没有股票的情况下,今天也选择不持有股票,那么取值就应该是前一天没有股票的最大收益
db[i - 1][0]
- 前一天有股票的情况下,需要今天卖出,取值应该前一天的持有加上今天卖出的收益
db[i - 1][1] + prices[i]
- 前一天没有股票的情况下,今天也选择不持有股票,那么取值就应该是前一天没有股票的最大收益
- 当天如果选择持有股票的话,利润最大值应该取自下面两种其中之一
- 前一天如果持有股票的话,那取值就是前一天持有股票的最大收益
db[i - 1][1]
- 前一天如果没有股票的话,今天需要买入股票,那么取值应该是前一天没有股票的收益减去今天要买股票的钱
db[i - 1][0] - prices[i]
- 前一天如果持有股票的话,那取值就是前一天持有股票的最大收益
根据推导就可以着手开始完成算法题了。
var maxProfit2 = function (prices) {
// 创建一个dp数组 保存每天的状态
const db = new Array(prices.length).fill([0, 0]);
// 初始化第一天的状态
db[0] = [0, -prices[0]];
// 循环遍历
for (let i = 1; i < prices.length; i++) {
// 状态转移方程 推导最后一天的状态
db[i] = [
Math.max(db[i - 1][0], db[i - 1][1] + prices[i]),
Math.max(db[i - 1][0] - prices[i], db[i - 1][1]),
];
}
// 返回最后一天持有股票的最大利润和不持有股票的最大利润中的最大值
return Math.max(...db[prices.length - 1]);
};
最终执行结果
72ms
52.3MB
4. [打家劫舍-力扣198](198. 打家劫舍 - 力扣(LeetCode))
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
解题思路:
求一夜之间能偷窃的最高金额,也就是求出n
个屋子都过一遍能偷到的最大金额,其中n
应该等于屋子的数量 nums.length
用一个数组来存放每一个走过的屋子可以得到最大利润 db[i]
,因为偷取相邻的房屋会触发警报,那么走过当前房屋能获得的最大金额取决于两个方面
- 前一个房能得到的最大金额
- 前两个房能得到的最大金额加上本次能得到的最大金额
两者取最大就是走过当前房屋能获得的最大金额,可以推导出状态转移公式为
db[i] = Math.max(db[i - 1], db[i - 2] + nums[i])
已知走过第一个房屋的最大收益为偷取该房屋的收益也就是 nums[0]
那么走过第二个房屋的收益就应该取自走过上一个房屋的收益和偷取本次收益的最大值 Math.max(nums[1], nums[0])
根据推导可以得出
var rob = function (nums) {
const db = new Array(nums.length).fill(Infinity)
db[0] = nums[0]
db[1] = Math.max(nums[1], nums[0])
for (let i = 2; i < nums.length; i++) {
db[i] = Math.max(db[i - 1], db[i - 2] + nums[i])
}
return db[nums.length - 1]
};
最终执行结果
47ms
49.2MB
5. [打家劫舍2-力扣213](213. 打家劫舍 II - 力扣(LeetCode))
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
解题思路:
与[打家劫舍](198. 打家劫舍 - 力扣(LeetCode))一样,求走一圈能得到的最大利润,区别是这一次最后一家和第一家首尾相连
如果偷了第一个房屋,那么最后一个房屋就不能在考虑的范围内,所以取值要在 [0 ~ nums.length - 2]
这个区间
如果没有偷第一个房屋,那么可以放心的走到最后一个房屋,所以取值范围是 [1 ~ nums.length - 1]
因为每一个房屋的状态要保留两个值,所以可以设 db[i] = [不偷取第一个,偷取第一个]
有了区间就可以按照[打家劫舍](198. 打家劫舍 - 力扣(LeetCode))的转移公式去更新状态了
可以得出
var rob = function (nums) {
const db = new Array(nums.length + 1).fill([0, 0]);
db[1] = [0, nums[0]];
for (let i = 2; i <= nums.length; i++) {
db[i] = [
Math.max(db[i - 1][0], db[i - 2][0] + nums[i - 1]),
i - 1 != nums.length - 1
? Math.max(db[i - 1][1], db[i - 2][1] + nums[i - 1])
: db[i - 1][1],
];
}
return Math.max(...db[nums.length]);
};
最终执行结果
62ms
49.2MB
6. [最长递增子序列-力扣300](300. 最长递增子序列 - 力扣(LeetCode))
给你一个整数数组
nums
,找到其中最长严格递增子序列的长度。子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,
[3,6,2,7]
是数组[0,3,1,6,2,2,7]
的子序列。
解题思路
找到最长的严格递增子序列长度,也就是找到n
个数字所在位置最长递增子序列的数 db[i]
最后求出db
中的最大值即可。其中 n
应该等于数组的长度 nums.length
首先从小到大计算 db
数组的值,已知位于bd[0]
的数是1
,也就是db[0]
的递增子序列为[nums[0]]
,所以长度为1
当计算到第二个数值的时候,需要再从前往后遍历nums
,直到下标j
等于当前的数,遍历的时候会有两种情况
- 下标
j
的数大于当前数nums[i]
,这时候说明nums[j]
不属于递增子序列的范畴 所以不做处理 - 下标
j
的数小于当前数nums[i]
,这时候说明nums[j]
属于递增子序列的范畴,需要更新db[i]
的值为db[j] + 1
,考虑到nums[j]
后面的数值中可能会出现更长的递增子序列,所以更新db[i]
的时候要和db[j] + 1
比较,取最大值。可得出状态转移方程
db[i] = nums[i] > nums[j] ? Math.max(db[i], db[j] + 1) : db[i]
根据推导可得
var lengthOfLIS = function (nums) {
const db = new Array(nums.length).fill(1);
for (let i = 0; i < nums.length; i++) {
for (let j = 0; j < i; j++) {
db[i] = nums[i] > nums[j] ? Math.max(db[i], db[j] + 1) : db[i];
}
}
return Math.max(...db);
};
最终执行结果
173ms
50.5MB
7.三角形最小路径和-力扣120
给定一个三角形
triangle
,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标
i
,那么下一步可以移动到下一行的下标i
或i + 1
。示例 1:
输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]] 输出:11 解释:如下面简图所示: 2 3 4 6 5 7 4 1 8 3 自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
解题思路
找出最小路径和,且每次移动只能为i
或 i + 1
。第一次做的时候寻思,每次取最小不就完了,还是想简单了,很快被打脸
如果从顶往下找,会发现:
- 第一个来到的值为
triangle[0][0]
,可以很快速的确定为当前最小路径和为triangle[0][0]
因为这一层只有这一个数字。 - 接着往下来到第二层,如果光判断这一层最小的值,也可以很容易的找到
triangle[1][0]
和triangle[0][1]
的最小值是谁。如果只有两层的话这样是没有问题的,但是题目中三角形是一个多层结构,换句话说,三角形的层数是未知的,因为不知道下一层会有什么所以无法明确这一层应该选谁
经过这个推导发现,如果从底至顶的找,比较明确,因为不管 triangle[i][j]
的值是多少 我们都可以知道 triangle[i + 1][j]
的值和triangle[i + 1][j + 1]
的值
可以设置 db
用来存储三角形的状态,db[i] = 这一层所包含的数的最小路径值
,也就是说db
的结构应该也是和三角形相同的二维数组,其内部数组中的最小路径值得个数应该等于这一层三角形得值的个数
接下来就可以遍历三角形的每一层找到对应的 triangle[i]
再通过循环这一层的所有值拿到tariangle[i][j]
的值
最后一层的最小路径值应该和tariangle
最后一层的值相同
通过推导可以推出状态转移方程为 db[i][j] = Math.min(db[i + 1][j], db[i + 1][j + 1]) + triangle[i][j]
根据推导可得
var minimumTotal = function (triangle) {
const h = triangle.length;
const db = new Array(h);
for (let i = h - 1; i >= 0; i--) {
db[i] = new Array(triangle[i].length);
for (let j = 0; j < triangle[i].length; j++) {
if (i == h - 1) {
db[i][j] = triangle[i][j];
} else {
db[i][j] = Math.min(db[i + 1][j], db[i + 1][j + 1]) + triangle[i][j];
}
}
}
return db[0][0];
};
最终执行结果
63ms
50.8MB
四、小结与拓展
在拿到一道题的时候如何考虑该题适不适用动态规划算法
- 题目是否要求找到,一个状态的最大 最多 多少种
- 题目的问题是否可以被分解为多个相同的子问题,且随意一个子问题的解法都与最终的解法相同
- 解题时能否定义清楚问题的状态以及状态之间的转移关系
- 题目能否通过递推或者迭代的方式求解
- 题目是否能明确状态的初始值
如果一道算法题满足以上条件,那么可以考虑使用动态规划来解题。当然有的问题可能存在更简单、更高效的解法,需要综合考虑问题的特点和算法的适用性,所以在做题时还是要多想下是否存在更优解法。
动态规划也包含了暴力搜索和记忆化搜索,前者通过枚举所有可能的解来解决问题,然后逐一检查每个解是否符合要求。后者则是通过递归存储已经计算过的结果来避免重复计算。需要注意的是记忆化搜索是一种 从顶到底的计算,而动态规划是一种从底至顶的计算思想
除了动态规划外,还有一些与之类似的算法,它们在解决问题时具有一定的相似性或者共同的特点。以下是一些常见的与动态规划类似的算法:
- 贪心算法 (Greedy Algorithm):
- 特点:每一步都选择当前状态下的最优解,没有回溯,不考虑将来的情况。贪心算法通常适用于满足贪心选择性质的问题,可以快速找到局部最优解,但不一定能找到全局最优解。
- 分治法 (Divide and Conquer):
- 特点:将问题划分为若干个规模较小的子问题,分别求解这些子问题,然后合并子问题的解得到原问题的解。与动态规划不同的是,分治法通常不会存在重叠子问题,因此适用于那些子问题相互独立的情况。
- 回溯算法 (Backtracking):
- 特点:通过尝试所有可能的候选解,并在搜索过程中不断剪枝,以达到快速找到问题解的目的。回溯算法通常用于求解组合优化问题或者排列组合问题。
- 线性规划 (Linear Programming):
- 特点:用于求解线性约束条件下的最优化问题。与动态规划类似的地方在于都是在一组约束条件下寻找最优解,但线性规划通常适用于连续的优化问题,而动态规划更多用于离散的优化问题。
- 近似算法 (Approximation Algorithm):
- 特点:在多项式时间内找到一个接近最优解的解法。通常用于求解 NP 难题或者无法在多项式时间内找到精确解的问题。
这些算法在不同的场景下有着各自的优势和适用范围,选择合适的算法取决于问题的特性以及对解决方案的要求。