动态规划思想常常用来解决斐波那契数列相关的题型,比如 LeetCode 和牛客上的“爬楼梯”的题目。有一座高度是 10 级台阶的楼梯,从下往上走,每跨一步只能向上 1 级或者 2 级台阶。要求用程序来求出一共有多少种走法。
分析问题:因为每一步只允许走 1 级台阶或 2 级台阶,所以如果只差最后一步就到第 10 级台阶的话,就会有两种方式,要么是从第 9 级走 1 级到第 10 级,要么是从第 8 级走 2 级都到 10 级。所以我们暂时先不用管从 0 到 8 或者从 0 到 9 的过程,反正要想走到第 10 级,最后一步必然是从第 8 级或者第 9 级开始。假设从 0 到 8 一共有 x 种走法,从 0 到 9 一共有 y 种走法,那么从 0 到 10 一共有多少种走法呢?结论是从 0 到 10 的走法总和 = 从 0 到 8 的走法 + 从 0 到 9 的走法 = x + y。为什么是这样的呢,解释如下:
从 0 到 10 的所有走法可以根据最后一步是走 1 级还是走 2 级台阶来分为两部分。第一部分是最后一步走 2 级,即从 8 到 10,这一部分总共的走法与从 0 到 8 的走法相同,即 x 种;第二部分是最后一步走 1 级,即从 9 到 10,这一部分总共的走法与从 0 到 8 的走法相同,即 y 种。以第一部分为例,为什么说从 8 到 10 的走法与从 0 到 8 的走法数量相同呢?是因为不管你从 0 到 8 怎么走,从 8 到 10 只有一种走法,就是直接走 2 级台阶到达第 10 级。以 5 级台阶为例,从 0 到 5 的走法总和 = 从 0 到 4 的走法 + 从 0 到 3 的走法。因为只有 5 级台阶,我们可以通过枚举来验证我们的想法:
由上图可知,从 4 到 5 的走法与从 0 到 4 的走法数量相同,从 3 到 5 的走法与从 0 到 3 的走法数量相同。同时也知道了从 0 到 5 的走法总和 = 从 0 到 4 的走法 + 从 0 到 3 的走法。其实说白了就是 F(5) = F(4) + F(3),熟悉斐波那契数列的人肯定很熟悉这个公式了。将以上结论用于 10 级台阶之中就是:从 0 到 10 的走法总和 = 从 0 到 8 的走法 + 从 0 到 9 的走法 = x + y。也就是说 F(10) = F(9) + F(8)。那么,F(9) 和 F(8) 又该如何计算呢?根据刚才的方法我们可以知道,F(9) = F(8) + F(7),F(8) = F(7) + F(6)。就像这样,把简单问题分阶段进行简化,简化成简单问题的方法其实就是动态规划的思想。那么,简化成简单问题中的“简单问题”到底该如何定义呢?以这个爬楼梯问题为例,“简单问题”其实就是当楼梯只有 1 级或 2 级时最简单,即 F(1) = 1,F(2) = 2。用 n 来代表楼梯级数的话,我们便可得到如下公式:
- F(1) = 1
- F(2) = 2
- F(n) = F(n - 1) + F(n - 2) n ≥ 3
动态规划中涉及三个重要的概念:
- 最优子结构:对于 F(10) 而言,F(9) 和 F(8) 就是 F(10) 的最优子结构;对于 F(9) 来说,F(8) 和 F(7) 就是 F(9) 的最优子结构
- 边界:当只有 1 级台阶或者 2 级台阶时,我们可以直接得到结果,无需再使用 F(n) 的公式去进行计算,所以我们认为 F(1) 和 F(2) 为问题的边界。如果一个没有边界,那么我们将永远得不到有限的结果
- 状态转移公式:F(n) = F(n - 1) + F(n - 2) 就是状态之间的转移公式,这是动态规划的核心,决定了问题的每一个阶段和下一个阶段之间的关系
现在我们来看看具体的代码处理以及优化过程。首先最简单的方法就是使用递归求解,递归结束的条件是 F(1) 和 F(2),F(n) = F(n - 1) + F(n - 2) 为递归计算公式。代码如下:
public static int climbStairs2(int n) {
if (n == 1 || n == 2) {
return n;
} else {
return climbStairs2(n - 1) + climbStairs2(n - 2);
}
}
这种递归方法的代码虽然简单,但是其时间复杂度可就有点高了,我们稍加分析一下就知道这个算法的时间复杂度为 。我们要想得到 F(n) 的值,就得得到 F(n - 1) 和 F(n- 2 ) 的值,F(n - 1) 的值就需要利用 F(n - 2) 和 F(n - 3) 计算,依次类推,那么此递归算法所走过的路径为:
这个二叉树的节点个数就是该递归算法所需要计算的次数,该二叉树的高度是 n - 1,节点个数为 ,所以时间复杂度取 。此方法也有待改进,因为我们看到,有很多重复计算的操作,途中使用相同颜色标记的就是需要重复计算的 F(n),优化思路可以考虑减少重复计算的次数。则优化代码如下:
public static int climbStairs3(int n, HashMap<Integer, Integer> map) {
if (n <= 2) {
return n;
}
if (map.containsKey(n)) {
return map.get(n);
} else {
Integer value = climbStairs3(n -1, map) + climbStairs3(n -2, map);
map.put(n, value);
return value;
}
}
这种方法称为备忘录算法。在代码中,集合 map 就是一个备忘录,当每次需要计算 F(n) 的时候,会首先从 map 中寻找匹配元素。如果 map 中存在,就直接返回结果,如果 map 中不存在,就计算出结果,存入备忘录中。这种算法的时间复杂度相对于刚才的那种就优化了不少,可以看出时间复杂度为 ,由于借用了中间变量 Map 来存储中间值,且总共存储中间值 n - 2 个,所以空间复杂度是 。至此,我们解决此问题的程序性能已经得到了很大的优化,但其实还不是真正的动态规划实现。继续优化此代码的话,需要考虑减小空间复杂度,时间复杂度已经不能在优化了。我们通过之前观察递归方法的时间复杂度可知,F(n) 只与 F(n - 1) 与 F(n - 2) 有关,即如果我们需要计算 F(10),只需要从临时变量中拿到 F(8) 和 F(9) 即可,至于计算 F(8) 和 F(9) 所需要的 F(6) 与 F(7),我们则不需要存储。也就是说,不必像备忘录算法那样,存储每一个子状态的值,只需在每一次迭代中保持前两个子状态的值即可,这样的话,存储中间值所需要的空间复杂度即可缩小为常量级的 。
那么真正的动态规划代码实现为:
public static int climbStairs(int n) {
if (n <= 2) {
return n;
}
int temp1 = 1;
int temp2 = 2;
for (int i = 3; i <= n; i++) {
int result = temp1 + temp2;
temp1 = temp2;
temp2 = result;
}
return temp2;
}
程序从 i = 3 开始迭代,一直到 i = n 结束。每一次迭代,都会计算出多一级台阶的走法数量。迭代过程中只需保留两个临时变量 temp1 和 temp2,分别代表了上一次和上上次迭代的结果。 关于此问题的完整代码,请参考本人的另外一篇文章:70.爬楼梯_jiaomubai的博客-CSDN博客
还有一个经典的国王与金矿问题也可以使用动态规划来解决:有一个国家发现了 5 座金矿,每座金矿的黄金储量不同,需要参与挖掘的工人数也不同。参与挖矿工人的总数是 10 人。每座金矿要么全挖,要么不挖,不能派出一半人挖取一半金矿。要求用程序求解出,要想得到尽可能多的黄金,应该选择挖取哪几座金矿?第一座金矿黄金储量 400,需要 5 名工人;第二座金矿黄金储量 500,需要 5 名工人;第三座金矿黄金储量 200,需要 3 名工人;第四座金矿黄金储量 300,需要 4 名工人;第五座金矿黄金储量 350,需要 3 名工人。五座金矿各自的黄金储量和需要的工人为:
方法一:排列组合。每一座金矿都有挖与不挖两种选择,如果有 n 座金矿,由于不考虑需要挖金矿的个数,那么排列组合起来就有 种选择,然后对所有的可能性做遍历,排除那些使用工人数超过 10 的组合,在剩下的选择里找出获得金币数最多的选择。这样的话时间复杂度就是 。我们之前说过,动态规划有三个核心元素,最优子结构、边界和状态转移方程。对于此题来说,乍一看其最优子结构是 10 个工人、4 个金矿时挖出最多黄金的选择。但其实仔细一想,并不是这样的,因为第五座金矿存在着挖与不挖两种选择,如果不挖第五座金矿的时候,那么最优子结构就是 10 个工人、4 座金矿时挖出最多黄金的最优选择,如果要挖第五座金矿,那么最优子结构有就是 10 - 第五座金矿所需的工人数、4 座金矿的最优选择。那么两个最优子结构和问题的最终解直接有什么关系呢?因为第五座金矿需要 3 个工人,所以问题的最优解就是 7 工人 4 金矿的挖金数量与 7 工人 4 金矿的挖金数量 + 第五座金矿的黄金数量之间的最大值。为便于描述,我们设金矿数量为 n,工人数为 w,黄金储量数组为 g[],金矿所需要的工人数数组为 p[]。那么 5 座金矿和 4 座金矿的最优选择之间就存在如下关系:F(5, 10) = MAX(F(4, 10), F(4, 10 - p[4]) + g[4])。那么此问题的边界又是什么呢?自然而然边界就是只有一座金矿时,得到的黄金数量就是 g[0],但是如果第一座金矿需要的工人数 w < 10 的话,其实第一座金矿我们是挖不了的,也就是当 w < p[0] 时,得到的黄金数其实是 0。用公式总结来说就是当 n = 1, w >= p[0] 时, F(n, w) = g[0];当 n = 1, w < p[0] 时, F(n, w) = 0。整理之后我们就可得到该问题的状态转移方程:
- F(n, w) = 0 (n <= 1, w < p[0]) 如果金矿数量小于 1,或者金矿数量等于 1 且工人数比小于需要工人数,则挖金数量等于 0
- F(n, w) = g[0] (n == 1, w >= p[0]) 如果金矿数量等于 1,且工人数大于等于所需工人数,则挖金数量等于第一座金矿的黄金储量
- F(n, w) = F(n - 1, w) (n > 1, w < p[n - 1]) 如果金矿数量大于 1,且剩余工人数小于下一座金矿所需要的工人数,则最终的挖金数量等于前 n - 1 个金矿的黄金数量总和
- F(n, w) = MAX(F(n - 1, w), F(n - 1, w - p[n - 1]) + g[n]) (n > 1, w >= p[n - 1]) 如果金矿数量大于 1,且剩余工人数大于等于下一座金矿所需要的工人数,则最终的挖金数量取决于前 n - 1 个金矿的黄金数量总和与前 n - 1 个金矿的黄金数量总和加上第 n 座金矿的黄金储量的最大值
方法二:简单递归。把状态转移方程式翻译成递归程序,递归的结束的条件就是方程式当中的边界。因为每个状态有两个最优子结构,所以递归的执行流程类似于一颗高度为 N 的二叉树。方法的时间复杂度是 。
方法三:备忘录算法。在简单递归的基础上增加一个 HashMap 结构的备忘录,用来存储中间结果。HashMap 的 key 是一个包含金矿数 n 和工人数 w 的对象,value 是最优选择获得的黄金数。方法的时间复杂度和空间复杂度相同,为 ,都等同于备忘录中不同 key 的数量。
方法四:动态规划法。我们使用一个横坐标为工人数,纵坐标为金矿的图表来模拟挖金的场景。
对于金矿1,根据上面展示的状态转移公式可得挖金量与工人间的关系为:
工人1 | 工人2 | 工人3 | 工人4 | 工人5 | 工人6 | 工人7 | 工人8 | 工人9 | 工人10 | |
---|---|---|---|---|---|---|---|---|---|---|
金矿1 400金/5人 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
金矿2 500金/5人 | ||||||||||
金矿3 200金/3人 | ||||||||||
金矿4 300金/4人 | ||||||||||
金矿5 350金/3人 |
当 n = 1, w < 5 时,F(1, w) = 0,因为小于 5 名工人不够挖金矿1,蓝色单元格使用公式一;当 n = 1, w >= 5 时,F(1, w) = g[0] = 400,当工人数大于等于 5 时,选择挖金矿1,得到的黄金储量为 400,绿色单元格使用公式二,g[0] 代表金矿1 的黄金储量,0 为金矿1 的下标。
对于金矿2,根据上面展示的状态转移公式可得挖金量与工人间的关系为:
工人1 | 工人2 | 工人3 | 工人4 | 工人5 | 工人6 | 工人7 | 工人8 | 工人9 | 工人10 | |
---|---|---|---|---|---|---|---|---|---|---|
金矿1 400金/5人 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
金矿2 500金/5人 | 0 | 0 | 0 | 0 | 500 | 500 | 500 | 500 | 500 | 900 |
金矿3 200金/3人 | ||||||||||
金矿4 300金/4人 | ||||||||||
金矿5 350金/3人 |
当 n = 2, w < 5 时,F(2, w) = F(1, w),使用公式三,黄色单元格表示;当 n = 2, w >= 5 时,F(2, w) = MAX(F(n - 1, w), F(n - 1, w - p[n - 1]) + g[n]) = MAX(400, F(1, w - 5) + 500),使用公式四,红色单元格表示,需要注意的是,当 n = 2, w = 10 时,F(2, 10) = MAX(F(1, 10), F(1, 10 - 5) + 500) = MAX(400, 400 + 500) = 900,此时选择金矿1和金矿2都挖。
金矿3 黄金储量 200,需要工人 3 名,那么具体选择如下:
工人1 | 工人2 | 工人3 | 工人4 | 工人5 | 工人6 | 工人7 | 工人8 | 工人9 | 工人10 | |
---|---|---|---|---|---|---|---|---|---|---|
金矿1 400金/5人 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
金矿2 500金/5人 | 0 | 0 | 0 | 0 | 500 | 500 | 500 | 500 | 500 | 900 |
金矿3 200金/3人 | 0 | 0 | 200 | 200 | 500 | 500 | 500 | 700 | 700 | 900 |
金矿4 300金/4人 | ||||||||||
金矿5 350金/3人 |
当 n = 3, w < 3 时,F(3, w) = F(2, w) = 0,使用公式三,黄色单元格表示;当 n = 3, w >= 3 时,F(3, w) = MAX(F(2, w), F(2, w - 3) + 200),使用公式四,红色单元格表示。
按照这种思路,我们继续处理 n = 4 和 n = 5 的情况。
工人1 | 工人2 | 工人3 | 工人4 | 工人5 | 工人6 | 工人7 | 工人8 | 工人9 | 工人10 | |
---|---|---|---|---|---|---|---|---|---|---|
金矿1 400金/5人 | 0 | 0 | 0 | 0 | 400 | 400 | 400 | 400 | 400 | 400 |
金矿2 500金/5人 | 0 | 0 | 0 | 0 | 500 | 500 | 500 | 500 | 500 | 900 |
金矿3 200金/3人 | 0 | 0 | 200 | 200 | 500 | 500 | 500 | 700 | 700 | 900 |
金矿4 300金/4人 | 0 | 0 | 200 | 300 | 500 | 500 | 500 | 700 | 800 | 900 |
金矿5 350金/3人 | 0 | 0 | 350 | 350 | 500 | 550 | 650 | 850 | 850 | 900 |
其中的规律就是除了第一行以外,其他的单元格的值都是通过其上一行的一个或两个单元格计算而来。我们在写代码的时候也正是用的这种思路,利用前一行的值推导出当前行的值。代码如下:
public class KingAndGold {
public static Integer kingAndGold(Integer n, Integer w, Integer[] gold, Integer[] workers) {
//存储上一行结果的数组
Integer[] preResult = new Integer[w + 1];
// 存储当前行结果的数组
Integer[] result = new Integer[w + 1];
result[0] = 0;
// 填充边界单元格的值,即设置金矿1 的各种结果,i 用来循环工人数量,闭区间 [0, w]
for (int i = 0; i <= w; i++) {
if (i >= workers[1]) {
preResult[i] = gold[1];
} else {
preResult[i] = 0;
}
}
System.out.println("第1行的结果为:");
display(preResult);
// 填充其余单元格的值,外层循环金矿数量,内层循环工人数量
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= w; j++) {
if (j < workers[i]) {
result[j] = preResult[j];
} else {
Integer temp = preResult[j - workers[i]];
result[j] = Math.max(preResult[j], temp + gold[i]);
}
}
preResult = Arrays.copyOf(result, result.length);
System.out.println("第" + i + "行的结果为:");
display(preResult);
display(result);
}
return result[w];
}
private static void display(Integer[] array) {
for (int i = 0; i < array.length - 1; i++) {
System.out.print(array[i] + "-->");
}
System.out.println(array[array.length - 1]);
}
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
// 初始化金矿含金量数组
Integer gold[] = {0, 400, 500, 200, 300, 350};
// 初始化各金矿所需的工人数组
Integer workers[] = {0, 5, 5, 3, 4, 3};
// 金矿数量(也可以根据p[]的数量获得)
Integer n = 5;
// 工人数量
Integer w = 10;
Integer result = kingAndGold(n, w, gold, workers);
System.out.println("result = " + result);
long endTime = System.currentTimeMillis();
System.out.println("程序运行时间:" + (endTime - startTime) + "ms");
}
}
代码中我们开辟了一个 w + 1 长度的数组 preResult[] 来记录第一行的结果,为了适应数组下标从 0 开始的特点,我们给工人数组和金矿含金量数组新增一个 array[0] = 0 的值,这样的话下标为 1 就代表第一座金矿,第一个工人,方便我们叙述。同时,为了清楚地看到每次循环处理的结果,我们新增了一个打印函数来输出每次循环的结果。需要注意的是代码中给 preResult[] 赋值 result[] 时并没有使用直接赋值,而是去选择使用 copyOf() 方法来进行数值类型的拷贝,如果直接使用 preResult = result 来进行引用类型的拷贝的话,会导致 preResult[i] 会随着 result[i] 改变而改变,会出现与预期不符的情况。
以上代码是使用两个一维数组完成的,当然也可使用一个 n 行 w 列的二维数组去记录,这样的话使用 F(n, w) 的公式时就会容易很多。 之前在 C++ 学习时是使用的二维数组,由于 Java 不太使用数组,所以在此就不展示了~