DP算法简介
DP算法即动态规划 (Dynamic Programming) 算法,是一种解决复杂问题的有效方法,它将问题分解为相互重叠的子问题,并通过存储子问题的解来避免重复计算,从而提高效率。
核心思想
动态规划基于两个关键思想:
- 最优子结构:问题的最优解包含其子问题的最优解
- 重叠子问题:在递归求解过程中,相同的子问题会被多次计算
基本步骤
- 定义状态:找出问题的状态表示方式
- 建立状态转移方程:确定状态之间的关系
- 确定初始条件和边界条件
- 计算顺序:通常采用自底向上或记忆化搜索的方式
- 构造最终解
例题
背包问题
有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。
第 i 件物品的体积是 v[i],价值是 w[i]。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。
输入格式
第一行两个整数,N,V,用空格隔开,分别表示物品数量和背包容积。
接下来有 N 行,每行两个整数 v[i],w[i],用空格隔开,分别表示第 i 件物品的体积和价值。
输出格式
输出一个整数,表示最大价值。
数据范围
0 < N, V ≤ 1000
0 < v[i], w[i] ≤ 1000
输入样例
输出样例
代码
第一步:用 dfs 暴力枚举
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| #include <iostream> using namespace std;
const int N = 1010; int n, m; int v[N], w[N];
int dfs(int x, int spV) { if (x > n) { return 0; } if (v[x] > spV) { return dfs(x + 1, spV); } else { return max(dfs(x + 1, spV), dfs(x + 1, spV - v[x]) + w[x]); } }
int main() { scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { scanf("%d%d", &v[i], &w[i]); } int res = dfs(1, m); printf("%d\n", res); return 0; }
|
- 很显然,这个算法会超时,因为重复计算了很多节点。
- 很自然的想到,如果把已经算过的节点存下来,需要的时候直接返回其值,就可以大幅节省时间。
第二步:记忆化搜索
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| #include <iostream> using namespace std;
const int N = 1010; int n, m; int v[N], w[N];
int mem[N][N];
int dfs(int x, int spV) { if (mem[x][spV]) return mem[x][spV];
int sum;
if (x > n) { return 0; } if (v[x] > spV) { sum = dfs(x + 1, spV); } else { sum = max(dfs(x + 1, spV), dfs(x + 1, spV - v[x]) + w[x]); }
mem[x][spV] = sum; return sum; }
int main() { scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { scanf("%d%d", &v[i], &w[i]); } int res = dfs(1, m); printf("%d\n", res); return 0; }
|
- 时间复杂度成功从 O(2^n) 降到了 O(nm),但是空间复杂度从 O(n) 提高到了 O(nm) 典型的以空间换时间算法。
- 这样的写法已经做到了优化(其实一般情况下到这一步就可以了),但是这样的写法不够优雅,而且打字也多(如果你就喜欢多敲代码当我没说)。
- 下一个方法是通过递推写出 状态转移方程,你会发现这和 dfs 的每一步是对应的。
第三步:递推实现
为了体现一一对应,我就不把 dfs 函数删掉了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| #include <iostream> using namespace std;
const int N = 1010; int n, m; int v[N], w[N]; int mem[N][N];
int f[N][N];
int dfs(int x, int spV) { if (mem[x][spV]) return mem[x][spV]; int sum;
if (x > n) { return 0; } if (v[x] > spV) { sum = dfs(x + 1, spV); } else { sum = max(dfs(x + 1, spV), dfs(x + 1, spV - v[x]) + w[x]); } mem[x][spV]= sum; return sum; }
int main() { scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { scanf("%d%d", &v[i], &w[i]); } for (int i = n; i >= 1; i--) { for (int j = 0; j <= m; j++) { if (j < v[i]) { f[i][j] = f[i + 1][j]; } else { f[i][j] = max(f[i + 1][j], f[i + 1][j - v[i]] + w[i]); } } } printf("%d\n", f[1][m]); return 0; }
|
- 这里可以更加直观的看出时空复杂度,很显然和 dfs + 记忆化搜索 是一样的。
- 还有没有更优解呢?
第四步:进一步优化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| #include <iostream> using namespace std;
const int N = 1010; int n, m; int v[N], w[N]; int f[N];
int main() { scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { scanf("%d%d", &v[i], &w[i]); } for (int i = n; i >= 1; i--) { for (int j = m; j >= v[i]; j--) { f[j] = max(f[j], f[j - v[i]] + w[i]); } } printf("%d\n", f[m]); return 0; }
|
关键点说明:
- 我们只需要一个一维数组
f[N],而不是二维数组 f[N][N]。
- 内层循环需要从大到小遍历(从
m 到 v[i]),这样可以确保在计算 f[j] 时,f[j - v[i]] 还是上一轮 i+1 的值。
- 当
j < v[i] 时,f[j] 保持不变,所以不需要处理。
经过这一次优化,空间复杂度就变为了 O(n),但是一般情况下这一步优化都是不必要的。
总结
DP算法依赖你对 dfs 和状态转移方程的理解,现在你发现他们是一回事了。
有的人可能会有疑问,为什么不用贪心?
因为贪心是寻找局部最优解,而 DP是寻找全局最优解,如果每次能取物品的一部分而没有要求一定要取走整个物品,用的就是贪心了。