动态规划
动态规划的三要素
重叠子问题、最优子结构、状态转移方程
明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case。
第⼆个凑零钱的问题,展⽰了如何流程化确定「状态转移⽅程」,只要通过
状态转移⽅程写出暴⼒递归解,剩下的也就是优化递归树,消除重叠⼦问题
⽽已。
计算机解决问题其实没有任何奇技淫巧,它唯⼀的解决办法就是穷举,穷举
所有可能性。算法设计⽆⾮就是先思考“如何穷举”,然后再追求“如何聪明
地穷举”。
动态规划的流程
具体来说,动态规划的一般流程就是三步:
暴力的递归解法 -> 带备忘录的递归解法 -> 迭代的动态规划解法。
就思考流程来说,就分为以下三步:
找到状态和选择 -> 明确 dp 数组/函数的定义 -> 寻找状态之间的关系。
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 择优(选择1,选择2...)
常用的状态转移方程推导技巧
- 数学归纳法
经典动态规划:高空扔鸡蛋🥚
我们选择在第 i
层楼扔了鸡蛋之后,可能出现两种情况:鸡蛋碎了,鸡蛋没碎。注意,这时候状态转移就来了:
如果鸡蛋碎了,那么鸡蛋的个数 K 应该减⼀,搜索的楼层区间应该从[1…N] 变为 [1…i-1] 共 i-1 层楼;
如果鸡蛋没碎,那么鸡蛋的个数 K 不变,搜索的楼层区间应该从 [1…N]变为 [i+1…N] 共 N-i 层楼。
经典动态规划:0-1背包问题👜
-
问题描述
给你⼀个可装载重量为
W
的背包和N
个物品,每个物品有重量和价值两个属性。其中第i
个物品的重量为wt[i]
,价值为val[i]
,现在让你⽤这个背包装物品,最多能装的价值是多少? -
明确状态
【背包的容量】、【可选择的物品】
-
明确选择
【装进背包】 or 【不装进背包】
-
明确dp数组的定义
状态两个,因此首选二维数组
👉👉
dp[i][w]
的定义如下:对于前i
个物品,当前背包的容量为w
,这种情况下可以装下的最大价值dp[i][w]
-
确定
base case
根据对
dp
数组的定义,我们最终想求得的答案就是dp[N][W]
。确定base case dp[0][...] = dp[...][0] = 0
因为没有物品时或者背包没有空间时,能装下的最大价值就是0
-
根据【选择】明确逻辑框架
【装进背包】:如果把这第
i
个物品装入背包,那么dp[i][w]
应等于dp[i-1][w-wt[i-1]] + val[i-1]
即第
i-1
个物品留有wt[i-1]
重量空间给第i
个物品,由于从索引1开始,因此第i
对应i-1
【不装进背包】:如果不把这第
i
个物品装进背包,则继承之前的结果,那么最大价值dp[i][w] = dp[i-1][w]
【综合考虑】:
dp[i][w]
等于【装进背包】和【不装进背包】两者带来最大价值的那个选择;
d p [ i ] [ w ] = m a x ( d p [ i − 1 ] [ w ] , d p [ i − 1 ] [ w − w t [ i − 1 ] ] + v a l [ i − 1 ] ) dp[i][w] = max(dp[i-1][w],dp[i-1][w-wt[i-1]]+val[i-1]) dp[i][w]=max(dp[i−1][w],dp[i−1][w−wt[i−1]]+val[i−1]) -
处理索引越界和边界情况
if(w - wt[i-1] < 0) { //这种情况下等同于不装入背包 dp[i][w] = dp[i-1][w]; }
-
迭代形式
int knapsack(int W, int N, vector<int> &weight, vector<int> &value){ vector<vector<int>> dp(N + 1, vector<int>(W + 1)); //initialize base case for (int i = 0; i <= N; i++) dp[i][0] = 0; for (int j = 0; j <= W; j++) dp[0][j] = 0; //calculate dp for (int i = 1; i <= N; i++) for (int j = 1; j <= W; j++) if (j - weight[i - 1] < 0) dp[i][j] = dp[i - 1][j]; else dp[i][j] = max(dp[i - 1][j - weight[i - 1]] + value[i - 1], dp[i - 1][j]); return dp[N][W]; }
经典动态规划:完全背包问题👛
-
问题描述
给定不同面额的硬币和一个总金额,写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。
示例:输入:
amount = 5,coins = [1 , 2 , 5]
输出:
4
**解释:**有四种方式可以凑成总金额数
5
,2+2+1
,2+1+1+1
,1+1+1+1+1
-
问题转换
有⼀个钱包,最⼤金额为
amount
,有⼀系列硬币coins
,每个硬币的金额为coins[i]
,每个硬币的数量⽆限。请问有多少种⽅法,能够把钱包恰好装满?「完全背包问题」即指的是题中的硬币数量无限制。
-
明确状态
【钱包的容量】、【可选择的硬币】
-
明确选择
【装进钱包】 or 【不装进钱包】
-
明确dp数组的定义
状态两个,因此首选二维数组
👉👉
dp[i][j]
的定义如下:只使用前i
个硬币,当前钱包的容纳金额为j
时,有dp[i][j]
种方法能装满钱包!若只使⽤
coins
中的前i
个硬币的⾯值,若想凑出⾦额j
,有dp[i][j]
种凑法。 -
确定
base case
根据对
dp
数组的定义,我们最终想求得的答案就是dp[N][amount]
。其中N
为coins
数组的大小,确定
base case: dp[0][...] = 0
,dp[...][0] = 1
因为没有硬币可以选择时,则无论如何都凑不出指定金额;如果指定金额为
0
,则空集是唯一解集。 -
根据【选择】明确逻辑框架
【装进钱包】:如果把这第
i
个硬币装入钱包,那么dp[i][j]
应等于dp[i-1][j-coins[i-1]]
即第
i-1
个物品留有wt[i-1]
重量空间给第i
个物品,由于从索引1开始,因此第i
对应i-1
【不装进背包】:如果不把这第
i
个硬币装进背包,则继承之前的结果,那么方法数dp[i][w] = dp[i-1][w]
【综合考虑】:
dp[i][w]
等于【装进背包】和【不装进背包】两个选择方法数之和
d p [ i ] [ w ] = d p [ i − 1 ] [ j ] + d p [ i ] [ j − c o i n s [ i − 1 ] ] dp[i][w] = dp[i-1][j]~+~dp[i][j-coins[i-1]] dp[i][w]=dp[i−1][j] + dp[i][j−coins[i−1]] -
处理索引越界和边界情况
if(j - coins[i-1] < 0) { //这种情况下等同于不装入背包 dp[i][j] = dp[i-1][j]; }
-
迭代形式
int change(int amount, vector<int> &coins){ int n = coins.size(); vector<vector<int>> dp(n + 1, vector<int>(amount + 1)); //initialize base case for (int i = 0; i <= n; i++) dp[i][0] = 1; for (int j = 1; j <= amount; j++) dp[0][j] = 0; //dp begin for (int i = 1; i <= n; i++) for (int 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]; }
经典动态规划:子集背包问题💼
-
问题描述
给定一个只包含正整数和非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
-
问题转换
那么对于这个问题,我们可以先对集合求和,得出
sum
,把问题转化为背包问题: 给⼀个可装载重量为
sum / 2
的背包和N
个物品,每个物品的重量为nums[i]
。现在让你装物品,是否存在⼀种装法,能够恰好将背包装满? -
明确状态
【容量为数组元素和/2】、【可选择的数组元素】
-
明确选择
【装进数组】 or 【不装进数组】
-
明确dp数组的定义
状态两个,因此首选二维数组
👉👉
dp[i][w]
的定义如下:对于前i
个数组元素,当前背包的容量为w
,若dp[i][w]
为true
则说明恰好可以将背包装满,若dp[i][w]
为false
,则说明不能恰好把背包装满。 -
确定
base case
根据对
dp
数组的定义,我们最终想求得的答案就是dp[N][W]
。确定base case dp[0][...] = false
,dp[...][0] = true
解释:当没有物品可选择的时候,则装不满背包;当背包容量为
0
的时候,则已经装满了背包。 -
根据【选择】明确逻辑框架
【装进背包】:如果把这第
i
个物品装入背包,那么dp[i][w]
应等于dp[i-1][w-nums[i-1]]
即第
i-1
个物品留有nums[i-1]
重量空间给第i
个物品,由于从索引1开始,因此第i
对应i-1
【不装进背包】:如果不把这第
i
个物品装进背包,则继承之前的结果,那么最大价值dp[i][w] = dp[i-1][w]
【综合考虑】:
dp[i][w]
等于【装进背包】和【不装进背包】两者谁带来最大利益,即取逻辑或的关系;
d p [ i ] [ w ] = d p [ i − 1 ] [ w − n u m s [ i − 1 ] ] ∣ ∣ d p [ i − 1 ] [ w ] dp[i][w]~=~dp[i-1][w-nums[i-1]] ~~||~~ dp[i-1][w] dp[i][w] = dp[i−1][w−nums[i−1]] ∣∣ dp[i−1][w] -
处理索引越界和边界情况
if(w - nums[i-1] < 0) { //这种情况下等同于不装入背包 dp[i][w] = dp[i-1][w]; }
-
迭代形式
bool canPartition(vector<int> &nums) { int sum = 0; for (const auto &i : nums) sum += i; if (sum % 2 == 1) return false; int target = sum / 2; //背包问题中的总重量 int n = nums.size(); vector<vector<bool>> dp(n + 1, vector<bool>(target + 1)); //initialize base case for (int i = 0; i <= n; i++) dp[i][0] = true; for (int i = 1; i <= n; i++) for (int j = 1; j <= target; j++) if (j - nums[i - 1] < 0) dp[i][j] = dp[i - 1][j]; else dp[i][j] = dp[i - 1][j - nums[i - 1]] || dp[i - 1][j]; return dp[n][target]; }
经典动态规划:编辑距离🏳
-
问题描述
给定两个字符串
s1
和s2
,计算出将s1
转换成s2
所使用的最少操作数。你可以对字符串进行如下三种操作:1.插入一个字符
2.删除一个字符
3.替换一个字符
示例:输入:
s1 = "horse,"s2="ros"
输出:
3
-
明确状态
【
s1
子串长】、【s2
子串长】 -
明确选择
【跳过(skip)】 、【删除(delete)】、【插入(insert)】、【替换(replace)】
-
明确dp数组的定义
状态两个,因此首选二维数组。解决两个字符串的动态规划问题,⼀般都是⽤两个指针
i,j
分别指向两个字符串的最后,然后⼀步步往前⾛,缩⼩问题的规模。👉👉
dp[i][j]
的定义如下:对于s1
的前i
个字符与s2
的前i
个字符,其最小操作数为dp[i][j]
。 -
确定
base case
根据对
dp
数组的定义,我们最终想求得的答案就是dp[s1.len][s2.len]
。确定base case dp[0][...] = [...]
,dp[...][0] = [...]
解释:
s1
空与s2
的前n
个做匹配,需进行n
次插入操作;s2
空与s1
的前m
个做匹配,需进行m
次插入操作。 -
根据【选择】明确逻辑框架
如果两个字符相同:
【跳过(skip)】:
dp[i][j] = dp[i-1][j-1]
如果两个字符不同:
【删除(delete)】:
dp[i][j] = dp[i-1][j] + 1
【插入(insert)】:
dp[i][j] = dp[i][j-1] + 1
【替换(replace)】:
dp[i][j] = dp[i-1][j-1] + 1
【综合考虑】:
字符相同的情况:
d p