从递归开始,彻底搞懂动态规划
前言
以前ACM时期,一开始学习动态规划时,并不懂这个东西到底是个什么概念。搜索题解,大部分往往也是甩个题目,然后直接列出状态转移方程,紧接着直接来个AC代码,让人云里雾里。尽管做过一些题目,但是往往遇到新的题目便抓瞎了。只会一些做过的题,例如导弹拦截,各种背包。今天借助算法课的作业,仔细的将动态规划剖析一遍。一步一步说明,如何将普通的指数级复杂度的递归算法优化为多项式级复杂度的动态规划算法。
题目背景:投资问题
$m$ 元钱,投资 $n$ 个项目,效益函数 $f_i(x) $,表示 $i$ 个项目投资 $x$ 元的收益,求如何分配每个项目的钱数使得总效益最大?
递归算法
递归式推导
假设投资 $ i $ 个项目,共投资 $ x $ 元的收益情况的所有可能性为 $g_i(x)$ 。显然可得:
$$ \begin{cases} g_1(x)=f_1(x_1),\ \ x_1 \leq x .\\ g_2(x)=f_1(x_1)+f_2(x_2),\ \ x_1+x_2 \leq x\\ g_3(x)=f_1(x_1)+f_2(x_2)+f_3(x_3),\ \ x_1+x_2+x_3 \leq x\\ \ \ ...\\ \ \ ...\\ \ \ ...\\ g_i(x)=\sum_{k=1}^if_k(x_k),\ \ \sum_{k=1}^ix_k \leq x\\ \end{cases} $$
将 $g_{i-1}(x-x_i)$ 代入 $g_i(x)$ 可得:
$$ \begin{cases} g_1(x)=f_1(x_1),\\ g_2(x)=g_1(x-x_2)+f_2(x_2),\\ g_3(x)=g_2(x-x_3)+f_3(x_3),\\ \ \ ...\\ \ \ ...\\ \ \ ...\\ g_i(x)=g_{i-1}(x-x_i)+f_i(x_i)\\ \end{cases} $$
整理可得递归式:
$$ g_i(x)= \begin{cases} f_1(x),\ \ i=1 .\\ g_{i-1}(x-x_i)+f_i(x_i),\ \ i>1, \sum_{k=1}^ix_k \leq x\\ \end{cases} $$
我们希望总收益最大,即求
$$w_i(x)=max\{g_i(x)\}$$
此即递归定义的目标函数 ,注意 $g_i(x)$ 是当前项目投资收益与前面所有投资项目的收益的排列组合。
递归实现
Java代码
/**
* @Author xwh
* @Date 2020/4/13 13:05:53
**/
public class Investment {
/* 投资收益函数 */
public static int f[][] = new int[5][10];
/**
* 投资i个工厂, 共x元的最大收益
*/
public static int g(int i, int x) {
// 输出一下当前的计算层级, 方便下个步骤分析复杂度
System.out.println("g " + i + " " + x);
int max = 0;
if (i == 1) {
// 投资第一个工厂的最大收益就是对应函数值
return f[i][x];
} else {
// DFS, 根据公式穷举所有收益情况, 并求其中最大值返回
// 当前收益 = 第i个工厂投资j元收益 + 前i-1个工厂投资x-j元的最大收益
for (int j = 0; j <= x; j++) {
int temp = f[i][j] + g(i - 1, x - j);
if (temp > max) {
max = temp;
}
}
}
return max;
}
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int n = 4, m = 6;
// 投资函数初始化, f[i][j]表示第i个工厂投资j元的收益
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= m; j++) {
f[i][j] = scanner.nextInt();
}
}
System.out.println("搜索树的DFS序列:");
int w = g(4, 6);
System.out.printf("向%d个工厂投资%d元的最大收益为:%d元", n, m, w);
}
}
测试数据
0 20 50 65 80 85 85
0 20 40 50 55 60 65
0 25 60 85 100 110 115
0 25 40 50 60 65 70
其中第 $i$ 行第 $j$ 列的数字表示第 $i$ 个工厂投资 $j$ 元的收益。
复杂度分析
对于每一个 $g_i(x)$ ,递归求解时下一级都为一个组合数。所以复杂度应为:
$$C_{m+n-1}^m= \frac {(m+n-1)!} {(n-1)!m!} = Ω((1+\epsilon)^{m+n-1})$$
结果是指数级的。我们知道指数就意味着爆炸。显然这样复杂度的算法在解决实际问题上并没有太大的意义。所以我们必须通过一定的方法将这个复杂度降阶。这种方法就是动态规划。
递归算法的问题分析
要知道如何进行优化,我们必须知道问题出在哪里。下面来分析一下递归的性能就行损耗在了哪里。传入测试数据,执行上一节的代码,得到如下结果:
可以看到,计算4个工厂投资6元的收益,共穷举了120种情况。继续分析:
我们发现 $g_2(j)$ 这种情况被计算了28次。实际上这种情况应该只有 $ g_2(0), g_2(1) \cdots g_2(6)$ 共6种才对。
为什么会出现这种情况呢?
对于每一次计算,递归程序都会去尝试一遍更深一层递归的所有排列组合,导致性能非常低。例如,在计算 $g_4(6)$ 时,程序会去计算 $g_3(0) \cdots g_3(6) $, 而对于其中的每一项,都会去计算 $g_2(0) \cdots g_2(6)$,在这些排列组合中找到当前的最优解。
可见,当前递归算法的问题在于:1. 相同的子问题重复计算。 2. 已经得出最优解的问题重复地去穷举次优解。
优化:动态规划
能用动态规划进行优化的问题,必须满足如下三个原则:
- 重叠子问题
- 最优子结构
- 无后效性
看上去非常抽象,实际上我们已经证明了当前问题满足这三个性质,下面开始说明。
重叠子问题
重叠子问题是一个递归解决方案里包含的子问题虽然很多,但不同子问题很少。少量的子问题被重复解决很多次。
这是重叠子问题的定义说明,看上去是不是很熟悉。就在上一节,我们已经发现的递归算法的问题1便是这个意思。我们重复计算了很多一样的问题,而这种计算实际上是可以通过记忆化搜索,通过时间换空间避免的。
最优子结构
一个最优化策略的子策略总是最优的。换句话说,一个最优策略,必然包含子问题的最优策略。
同样,我们在上一节发现,我们计算当前最大收益 $g_i(x)$ 时,$ g_{i-1}(j) $ 必然是全部被计算过的,递归发现 $g_{i-2}(k)$ 同样如此。
无后效性
所谓无后效性原则,指的是这样一种性质:某阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响。
意思就是,对于每一个 $g_i(x)$,仅仅与 $g_{i-1}(j)$ 有关,而 $g_{i+1}(k)$ 也仅仅和 $g_i(x)$ 有关。同样的,我们在计算中,每次的排列组合仅仅与前 $i$ 个工厂的总收益有关。
优化方案
现在我们已经证明,当前的投资问题,满足使用动态规划三个原则。那么我们可以通过动态规划来进行优化。那么说了这么久究竟什么是动态规划呢?
我们回到之前提到过的两个问题,即:1. 相同的子问题重复计算。 2. 已经得出最优解的问题重复地去穷举次优解。
问题1对应着重叠子问题,问题2对应着最优子结构。那么我们是不是可以通过一定的方法,避免程序重复地去穷举次优解,以及重复地去计算最优解呢?
既然我们已经得出,当前最优解只与前一个最优解有关,那我们只要每次保存下计算的最优解,每次用到直接调用拿到,不需要再深入递归去计算不就消去以上两个问题了吗?
换句话说,我们只要找到一个公式,根据公式,利用当前状态的最优解集合不断地穷举下一个状态的最优解,不是可以直接消去递归了吗?
状态转移方程
利用最优子结构及重叠子问题性质,改写之前的递归公式,得:
$$ w_i(x)= \begin{cases} f_1(x),\ \ i=1 .\\ max\{g_i(x)\}=f_i(k)+w_{i-1}(x-k),\ \ i>1 \\ \end{cases} $$
我们成功地将当前最优解与之前计算过的最优解联系了起来,避免了每次去穷举次优解和计算过的最优解。
此即状态转移方程。
Java代码
public static void dp(int n, int m) {
int i, j, k, temp;
int w[][] = new int[n + 1][m + 1];
// 计算投资第一个项目的最大收益
for (i = 0; i <= m; i++) {
w[1][i] = f[1][i];
}
// 投资前i个项目
for (i = 2; i <= n; i++) {
// 计算每一个g[i][x], 0<=x<=m
for (j = 0; j <= m; j++) {
// 状态转移, 利用w[i-1][]计算w[i]
// g[i][x] == temp == f[i][k] + w[i-1][j-k]
// k投资当前项目的钱数, 0<=k<=j
for (k = 0; k <= j; k++) {
temp = f[i][k] + w[i - 1][j - k];
if (temp > w[i][j]) {
// 更新当前的最优解, 给下一个最优解调用
w[i][j] = temp;
}
}
}
}
System.out.printf("向%d个工厂投资%d元的最大收益为:%d元\n", n, m, w[n][m]);
}
复杂度分析
很容易可以看出,此动态规划算法的复杂度就是三层循环嵌套。复杂度为:$$ O(nm^2) $$ 性能比递归版本可谓是优化了非常多。
总结
我把动态规划理解为进行了次优解剪枝与重复子问题记忆化的穷举算法,通过最优解来穷举最优解。也不知道当初的学者为什么会把 $ Dynamic \ Programming $ 翻译成动态规划,太字面了真的不好理解。