动态规划 Dynamic programming
一、前言
动态规划(英語:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划常常适用于有重叠子问题和最优子结构性质的问题,相对与递归解法的自顶向下,动态规划的自底而上由循环迭代完成计算,其所耗时间往往远少于传统递归。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。
所以,动态规划问题的一般形式就是求最值。既然是要求最值,核心问题是什么呢?求解动态规划的核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值呗。
其主要可以分为这么几大类:
- 树形DP:01背包问题
- 线性DP:最长公共子串,最长公共子序列
- 区间DP:矩阵最大值
- 数位DP:数字游戏
- 状态压缩DP:旅行商
二、优点
我们在做Fibonacci Number计算时,常根据公式进行暴力算法:
int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
我们也知道这样写代码虽然简洁易懂,但是十分低效,低效在哪里?假设 n = 20,请画出递归树(图片来源:labuladong):
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效 (复杂度达到了
O
(
2
n
)
O(2^n)
O(2n)) 。
这就是动态规划问题的第一个性质:重叠子问题。我们前面说过,动态规划就是空间换时间,暴力穷举,但如果仅仅暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。
使用经典的空间换时间的动态规划,先穷举,然后查询:
int fib(int N) {
if(N < 2)
return N;
int memo[N+1];
memo[0] = 0;
memo[1] = 1;
for(int i=2; i<=N; i++)
memo[i] = memo[i-1] + memo[i-2];
return memo[N];
}
- Time Complexity - O(N)
- Space Complexity - O(N)
当然,我们在这里观察也得到,并不是每一次的结果都需要存储起来,如果我们只需要最后的结果,那么我们只需要存储其上两次的值,这能为我们节约许多空间。
int fib(int N) {
if(N < 2)
return N;
int a = 0, b = 1, c = 0;
for(int i = 1; i < N; i++)
{
c = a + b;
a = b;
b = c;
}
return c;
}
- Time Complexity - O(N)
- Space Complexity - O(1)
这个技巧就是所谓的「状态压缩」,如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试用状态压缩来缩小 DP table 的大小,只记录必要的数据,上述例子就相当于把DP table 的大小从 n 缩小到 2。一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)。
有人会问,动态规划的另一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在说明重叠子问题的消除方法,演示得到最优解法逐步求精的过程。
三、引申
动态规划的诀窍就在与找到状态转移方程进行枚举,当然Fibonacci Number状态转移方程就是其公式,其他的就需要我们思考,过程就是观察其是否可以拆成子问题,找到父子问题之间的关联。
让我们多看一些DP的例子加深印象。
1. coin-change 换零钱问题
给你 k 种面值的硬币,面值分别为 c 1 , c 2 . . . c k c_1, c_2 ... c_k c1,c2...ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。
比如说 k = 3,面值分别为 1,2,5,总金额 amount = 11。那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。
递归版本:
迭代版本:
代码:
int coinChange(vector<int>& coins, int amount) {
// 数组大小为 amount + 1,初始值也为 amount + 1
vector<int> dp(amount + 1, amount + 1);
// base case
dp[0] = 0;
// 外层 for 循环在遍历所有状态的所有取值
for (int i = 0; i < dp.size(); i++) {
// 内层 for 循环在求所有选择的最小值
for (int coin : coins) {
// 子问题无解,跳过
if (i - coin < 0) continue;
dp[i] = min(dp[i], 1 + dp[i - coin]);
}
}
return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
PS:为啥 dp 数组初始化为 amount + 1 呢,因为凑成 amount 金额的硬币数最多只可能等于 amount(全用 1 元面值的硬币),所以初始化为 amount + 1 就相当于初始化为正无穷,便于后续取最小值。
2. Minimum Path Sum 最小路径问题
给定一个m × n的网格,其中填充了非负数,请找到一条从左上到右下的路径,该路径将沿其路径的所有数字的总和最小化。
注意:您只能在任何时间点向下或向右移动。
例如输入:
[
[1,3,1],
[1,5,1],
[4,2,1]
]
输出: 7
说明:因为路径1→3→1→1→1使总和最小。
这是一个典型的DP问题。假设到达点的最小路径总和(i, j)为S[i][j],则状态方程为
- S[i][j] = min(S[i - 1][j], S[i][j - 1]) + grid[i][j]。
好吧,需要处理一些边界条件。边界条件发生在最上面的行(S[i - 1][j]不存在)和最左边的列(S[i][j - 1]不存在)上。假设grid就像[1, 1, 1, 1],那么到达每个点的最小和只是前一个点的累加,结果是[1, 2, 3, 4]。
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
vector<vector<int> > sum(m, vector<int>(n, grid[0][0]));
for (int i = 1; i < m; i++)
sum[i][0] = sum[i - 1][0] + grid[i][0];
for (int j = 1; j < n; j++)
sum[0][j] = sum[0][j - 1] + grid[0][j];
for (int i = 1; i < m; i++)
for (int j = 1; j < n; j++)
sum[i][j] = min(sum[i - 1][j], sum[i][j - 1]) + grid[i][j];
return sum[m - 1][n - 1];
}
};
可以看出,每次更新时sum[i][j],我们只需要sum[i - 1][j](在当前列)和sum[i][j - 1](在左列)。因此,我们不需要维护完整的m*n矩阵。维护两列就足够了。
3. Partition Equal Subset Sum 背包问题
给定一个仅包含正整数的非空数组,请确定该数组是否可以划分为两个子集,以使两个子集中的元素之和相等。
范例:
输入:[1、5、11、5]
输出:true
说明:数组可以划分为[1、5、5] 和 [11]。
按照背包问题的套路,可以给出如下定义:
dp[i][j] = x
表示,对于前i
个物品,当前背包的容量为j
( j=sum/2
) 时,若x
为true,则说明可以恰好将背包装满,若x为false,则说明不能恰好将背包装满。
比如说,如果dp[4][9] = true
,其含义为:对于容量为 9 的背包,若只是用前 4 个物品,可以有一种方法把背包恰好装满。或者说对于本题,含义是对于给定的集合中,若只对前 4 个数字进行选择,存在一个子集的和可以恰好凑出 9。
根据这个定义,我们想求的最终答案就是dp[N][sum/2]
,base case 就是dp[..][0] = true 和 dp[0][..] = false
,因为背包没有空间的时候,就相当于装满了,而当没有物品可选择的时候,肯定没办法装满背包。
回想刚才的dp数组含义,可以根据「选择」对 dp[i][j]
得到以下状态转移:
- 背包已经装满:如果不把
nums[i]
算入子集,或者说你不把这第i
个物品装入背包,那么是否能够恰好装满背包,取决于上一个状态dp[i-1][j]
,继承之前的结果。 - 背包还未装满:如果把
nums[i]
算入子集,或者说你把这第i个物品装入了背包,那么是否能够恰好装满背包,取决于状态dp[i - 1][j-nums[i-1]]
。
首先,由于i
是从 1 开始的,而数组索引是从 0 开始的,所以第i个物品的重量应该是nums[i-1]
,这一点不要搞混。
dp[i - 1][j-nums[i-1]]
也很好理解:你如果装了第i
个物品,就要看背包的剩余重量j - nums[i-1]
限制下是否能够被恰好装满。
换句话说,如果j - nums[i-1]
的重量可以被恰好装满,那么只要把第i
个物品装进去,也可恰好装满j
的重量;否则的话,重量j
肯定是装不满的。
coding:
bool canPartition(vector<int>& nums) {
int sum = 0;
for (int num : nums) sum += num;
// 和为奇数时,不可能划分成两个和相等的集合
if (sum % 2 != 0) return false;
int n = nums.size();
sum = sum / 2;
vector<vector<bool>> dp(n + 1, vector<bool>(sum + 1, false));
// 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 <= sum; j++) {
if (j - nums[i - 1] < 0) {
// 背包容量不足,不能装入第 i 个物品
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][sum];
}
参考文章:
本文地址:https://blog.csdn.net/weixin_40539125/article/details/108558643
下一篇: 一些常用Java序列化框架的比较