文章目录
- 一、前言
- 二、优点
- 三、引申
- 1. [coin-change](https://leetcode.com/problems/coin-change/solution/) 换零钱问题
- 2. [Minimum Path Sum](https://leetcode.com/problems/minimum-path-sum/) 最小路径问题
- 3. [Partition Equal Subset Sum](https://leetcode.com/problems/partition-equal-subset-sum/) 背包问题
- 3. [Longest Palindromic Substring](https://leetcode.com/problems/longest-palindromic-substring/) 最长回文子串
一、前言
动态规划(英語: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];
}
3. Longest Palindromic Substring 最长回文子串
给定一个字符串s,返回 最长的回文子在s。
示例:
Input: s = “babad” Output: “bab” Note: “aba” is also a valid answer.
Input: s = “cbbd” Output: “bb”
Input: s = “a” Output: “a”
问题分解:
设状态变量state(s, e)表示 str[s, e] 是否是回文
for s = e, "a" 是回文
for s + 1 = e, "aa" 是回文 (如果 str[s] = str[e])
for s + 2 = e, "aba" 是回文 (如果 "b" 是回文 且 str[s] = str[e] )
for s + 3 = e, "abba"是回文 (如果 "bb"是回文 且 str[s] = str[e] )
for s + 4 = e, "abcba"是回文 (如果 "c"是回文 且 “bcb” 是回文..... 且 str[s + 1, e - 1] 是回文 且 str[s] = str[e] )
··· 递归 ···
我们意识到:
for s + dist = e, str[s, e] will be palindromic (if str[s] == str[e] and str[s + 1, e - 1] is palindromic)
S为回文起点,dist为回文长度,e为回文终点
列出状态转换方程:
state(s, e) is true:
for s = e,
for s + 1 = e, if str[s] == str[e]
for s + 2 <= e, if str[s] == str[e] && state(s + 1, e - 1) is true
注意:state(s + 1, e - 1)之前应该计算state(s, e)。也就是说,在 bottop-up dp 实现期间 s 正在减少,而dists 和 e 之间正在增加,这就是为什么
for (int s = len - 1; s >= 0; s--) {
for (int dist = 1; dist < len - i; dist++) {
实现:
class Solution {
public:
string longestPalindrome(string s) {
if (s.length() <= 1) {
return s;
}
int len = s.length();
// state[i][j] true if s[i, j] is palindrome.
vector<vector<bool>> state(len, vector<bool>(len));
// Base cases.
for (int i = 0; i < len; i++) {
state[i][i] = true; // dist = 0.
}
// to compute state[i][j] we need state[i + 1][j - 1]
// so the i - outer loop needs to go from higher to lower
// and the j - inner loop needs to go from lower to higher
int maxLen = 1, maxSta = 0;
for (int i = len; i >= 0; i--) {
// dist of 0 - already covered by initialization
for (int dist = 1; dist < len - i; dist++) {
int j = i + dist;
// we are ready to compute dist [i] [j]
if (dist == 1){
state[i][j] = (s.at(i) == s.at(j));
} else {
state[i][j] = (s.at(i) == s.at(j)) && state[i + 1][j - 1];
}
// state[i][j] = (dist == 1) ? s.at(i) == s.at(j) : (s.at(i) == s.at(j)) && state[i + 1][j - 1];
// if true
if (state[i][j] && (j - i + 1) > maxLen) {
maxLen = j - i + 1;
maxSta = i;
}
}
}
return s.substr(maxSta, maxLen);
}
};
优化流程:
class Solution{
public:
string longestPalindrome(string s) {
int slength = s.length();
if (slength <= 1)
return s;
vector<vector<bool>> dp(slength, vector<bool>(slength));
// bool (*dp)[slength] = (bool(*)[slength])malloc(sizeof(bool) * slength * slength);
for (int i = 0; i < slength; i++)
dp[i][i] = true;
int longestPalindromeStart = 0, longestPalindromeLength = 1;
for (int start = slength - 1; start >= 0; start--) {
for (int end = start + 1; end < slength; end++) {
if (s.at(start) == s.at(end)) {
if (end - start == 1 || dp[start + 1][end - 1]) {
dp[start][end] = true;
if (longestPalindromeLength < end - start + 1) {
longestPalindromeStart = start;
longestPalindromeLength = end - start + 1;
}
}
}
}
}
// free(dp);
return s.substr(longestPalindromeStart, longestPalindromeLength);
}
};
int main()
{
Solution sol;
string str;
std::cin >> str;
std::cout << sol.longestPalindrome(str) << std::endl;
return 0;
}
可进行空间优化:
class Solution{
public:
string longestPalindrome(string s) {
if(s.empty() || s.size() == 1) return s;
int len = s.size();
bool dp[len];
int left, length;
left = 0;
length = 1;
for(int j = 1; j < len; j++) {
for(int i = 0; i < j; i++) {
if(j - i <= 2) {
dp[i] = s[i] == s[j];
} else {
dp[i] = (s[i] == s[j]) && dp[i + 1];
}
if(dp[i] && (j - i + 1) > length) {
length = j - i + 1;
left = i;
}
}
}
return s.substr(left, length);
}
};
int main()
{
Solution sol;
string str;
std::cin >> str;
std::cout << sol.longestPalindrome(str) << std::endl;
return 0;
}
参考文章: