第一次接触动态规划时是在英雄哥的视频里碰到的,当时跟着英雄哥刷一些简单的力扣题,于是对DP有了一点点模糊的概念。后来上课学了一阵,看了点教材但也只是随便翻看的程度,所以以下有些地方可能是错误的;另外虽然这篇博客是笔记,但我个人废话比较多,还请大家海涵
目录
首先从爬楼梯开始,这是DP梦开始的地方
一、引例:爬楼梯问题(可跳过 因为废话有点多)
问题:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?(1<=n<=45)
以n=3为例,我们做一个简单的分析:
为了爬到第3阶,我得先在第2阶或者在第1阶(前者的情况下我可以爬1个台阶到达,后者的情况下我可以一口气爬2个台阶到达);爬到第1阶的方法数是显然只有1种的;为了爬到第2阶,我得先在第1阶或者第0阶(也就是起点),这种情况有2种方法:1+1,或者,一口气爬2阶。
也许你会说,我用枚举得到n=3的爬楼梯方案无非就是:
1阶+2阶
1阶+1阶+1阶
2阶+1阶
但你一定发现了吧,上面枚举出来的方案也可以用下面这种方式理解:
1阶+2阶
//为了爬到第3阶,我先在第1阶;为了爬到第1阶,我在起点爬1阶
1阶+1阶+1阶
//为了爬到第3阶,我先在第2阶;为了爬到第2阶,我先爬到第1阶;为了爬到第1阶,我在起点爬1阶
2阶+1阶
//为了爬到第3阶,我先在第2阶;为了爬到第2阶,我再起点一口气爬2阶
这种理解方式内含着一句话:“为了爬到第n阶,我需要考虑爬到第n-1阶和爬到第n-2阶的情况”。“爬到第n-1阶”和“爬到第n-2阶”代表了两种情况,把这两种情况下的方法数加起来,总和就是爬到第n阶方案数的答案!也就是说,我们得到了一个状态转移方程:
设f(x)表示爬到第n阶的方法数,则有f(x) = f(x-1) + f(x-2)
在刚才的分析里,边界条件是:
f(1) = 1; f(2) = 2
也有人会设f(0),f(1)是边界条件,我觉得这是无伤大雅的,只要f(0)设置对了就行了。
如果设f(0),f(1)是边界条件,那么代码有:
/*****************递推*****************/
class Solution {
public:
int climbStairs(int n) {
int f[46];
f[0]=1;
f[1]=1;
for(int i=2; i<46; i ++){
f[i] = f[i-1]+f[i-2];
}
return f[n];
}
};
/*************无备忘的递归*************/
class Solution {
public:
int climbStairs(int n) {
if(n==0){
return 1;
}
if(n==1){
return 1;
}
int ways;
ways = climbStairs(n-1)+climbStairs(n-2);
return ways;
}
};
毫无疑问,递归的代码实现起来慢了很多,尽管它输出的结果是正确的。
二、基本思想
DP通过组合子问题的解来求解原问题,通常用来求解最优化问题。
这里有两个概念需要考察:子问题和最优化问题。
子问题
不同的子问题具有公共的子子问题(重叠子问题性质),这意味着将递归求解子问题(或者说,子子问题)。
子问题的概念并不是DP独有的,分治策略也提到了子问题,子问题如果互不相交,那么分治策略解决这个问题是很合适的。
之前在嘎嘎写ppt的时候查了一下资料,发现其也有人会说动态规划的子问题是独立的,给我整困惑了:独立难道不是分治的“特权”吗?
algorithm - 应用动态规划技术的子问题的独立性 - IT工具网 (coder.work)
这篇文章里提到了最短简单路径问题体现的“独立”问题:
它们独立的具体含义是,即使两个问题重叠,它们也是“独立的”,因为它们不相互作用——一个的解决方案并不真正依赖于另一个的解决方案。他们实际上使用了与我所做的相同的示例,最短路径。最短路径问题的子问题是较小的独立的最短路径问题:如果从 A 到 B 的最短路径经过 C,那么从 A 到 C 的最短路径不使用从 C 到 C 的最短路径中的任何边B. 相比之下,最长路径问题不具有子问题的独立性。
懂了,但好像没懂,以后应该也不会再去纠结这个问题,反正DP要求子问题要重叠(我这是何等不负责的发言)
动态规划对每个子问题只求解一次,将解保存在表格中。这也是为什么动态规划是dynamic programming,其中programming指的是表格法,强调了表格的重要性。
最优化问题
问题存在多个可行解,动态规划可求得问题的一个最优解。
例如在01背包问题里,DP可以给出一个最优的解决方案;那如果实际的最优解决方案不止1个呢?DP还是只能给出一个最优的方案。但是回溯法就可以找出多个最优的解决方案。
三、带备忘的递归
我们知道,如果只是普通的递归,运行速度会非常慢,不仅因为递归可能进行的入栈出栈操作,还因为普通的递归会对于重复的问题进行反复的计算。于是我们对递归引入了备忘技术:通过表来记录子问题的解,但仍保持递归的自顶向下。
带备忘的递归只求解绝对必要的子问题,递推则会把所有子问题都不重复地计算出来。
以01背包问题为例:
N表示物品种类数,c表示背包容量,w[i]表示物品i的重量,v[i]表示物品i的价值,f[i][j]表示对于前i种物品、背包容量为j时,可装入的最大价值
设N=5,c=10,w={2,2,6,5,4}, v={6,3,5,4,6}
初始化f[n][m] = {-1}
先把除了DP以外的地方写好:
#include <iostream>
#include <iomanip>
using namespace std;
const int N = 1010;
int w[N], v[N];
int f[N][N];
void init(int n, int m) {
for (int i = 1; i <= n; i ++) {
for (int j = 0; j <= m; j ++) {
f[i][j] = -1;
}
}
for (int j = 0; j <= m; j ++) {
if (j < w[1]) {
f[1][j] = 0;
} else {
f[1][j] = v[1];
}
}
}
int KnapSack(int i, int j) {
//TODO:可由递推或带备忘的递归实现
}
int main() {
int w0[n] = {2, 2, 6, 5, 4};
int v0[n] = {6, 3, 5, 4, 6};
for ( int i = 1; i <= n; i ++ ) {
w[i] = w0[i - 1];
v[i] = v0[i - 1];
}
init(n, m);
KnapSack(n, m);
cout << f[n][m] << endl;
for (int i = 1; i <= n; i ++) {
for (int j = 0; j <= m; j ++) {
cout << setw(3) << f[i][j];
}
cout << endl;
}
return 0;
}
然后写一下KnapSack的带备忘递归和递推的两种方式:
/*************有备忘的递归*************/
int KnapSack(int i, int j) {
if (f[i][j] != -1) {
return f[i][j];
} else {
if (j < w[i])
f[i][j] = KnapSack(i - 1, j);
else
f[i][j] = max(KnapSack(i - 1, j), KnapSack(i - 1, j - w[i]) + v[i]);
return f[i][j];
}
}
/*****************递推*****************/
int KnapSack(int n, int m){
for ( int i = 1; i <= n; i ++ )
for ( int j = 0; j <= m; j ++ )
{
f[i][j] = f[i - 1][j];
if ( j >= w[i] ) f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + v[i]);
}
return f[n][m];
}
这是有备忘的递归的运行结果:
可以很明显地看出来,带备忘的递归确确实实只求解了那些绝对必要的子问题。不过这也导致了保存解信息的表仍有大量内容是未知的,仅凭表里目前的信息,当n扩规模时,是无法继续往下推的;而递推则不同,它的保存解信息的表是全部已知了,n扩规模后,还可以根据目前的这张表往下推理(假如这张表其实有6行,只是现在由于某种原因第6行被盖住了; 当你只看上面5行的解信息,仍然可以根据这5行的信息结合第6种物品的重量和价值,推导出第6行的情况;而递归的那张表则不行,因为n=6时需要的一些解信息在n=5的表里是未知的、没有被求解的)。
设第6件物品的重量为1,价值为20;下面比较一下n=5和n=6时,带备忘递归的运行结果:
如果想进一步了解带备忘的递归是怎么知道哪些子问题是绝对必要求解的,可以打印一下相关信息:
int KnapSack(int i, int j) {
if (f[i][j] != -1) {
return f[i][j];
} else {
cout<<"为了解决f["<<i<<"]["<<j<<"],";
cout<<"需要解决f["<<(i-1)<<"]["<<j<<"]";
if(j >= w[i]) {
cout<<"和f["<<(i-1)<<"]["<<j-w[i]<<"]";
}
cout<<endl;
if (j < w[i])
f[i][j] = KnapSack(i - 1, j);
else
f[i][j] = max(KnapSack(i - 1, j), KnapSack(i - 1, j - w[i]) + v[i]);
return f[i][j];
}
}
打印结果如下:
之前打的草稿,展示了一下递归的过程:
我不知道带备忘的递归的时间效率是否仍然慢于递推,现在问题规模不大所以看不出来,而且自己也没有去进一步了解(
四、动态规划原理
当问题满足以下要求时,可以用动态规划解决:
-
符合最优化原理(最优子结构性质)
一个问题的最优解包含了子问题的最优解
如果对最优子结构性质仍然感到模糊的话,不妨从换个角度考虑:有哪些问题时不满足最优子结构性质的吗?即,有哪些问题,它的最优解并不包含子问题的最优解?
-
无后效性
下一时刻的状态只与当前状态有关,而和当前状态之前的状态无关
同样,如果仍然对此概念模糊的话就用有后效性和它对一下比较。
有后效性:某个状态之后要做的决策会受之前的状态及决策的影响
比如说,如果我们把爬楼梯问题进一步描述:因为一口气爬2阶后太累,接下来只能爬1阶。此时我们就不能继续使用刚才的状态转移方程了。
又比如方格取数问题:题解 P7074 【方格取数(民间数据)】 - Carrots' World - 洛谷博客 (luogu.com.cn)
-
重叠子问题
一个递归解决方案里包含的子问题虽然很多,但不同的子问题很少。
也有人认为,即使问题没有这条性质也是可以被动态规划解决的。我的理解(不一定对,因为没有实践过)是,动态规划就是递归+去冗余,重叠子问题就是那些冗余,冗余越多,DP去冗余的效果就越明显,优势越大;现在如果问题几乎没有冗余了,那DP也几乎没有去冗余的效果,时间复杂度向着暴力枚举的级别上升,不再具备优势。换而言之,或许没有重叠子问题也能用DP解决,但这时的DP和枚举在时间效率上已经差别不大,不具有DP独特的优势了。