动态规划与 0-1 背包

动态规划思想常常用来解决斐波那契数列相关的题型,比如 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);
    }
}

这种递归方法的代码虽然简单,但是其时间复杂度可就有点高了,我们稍加分析一下就知道这个算法的时间复杂度为 O(2^{n})。我们要想得到 F(n) 的值,就得得到 F(n - 1) 和 F(n- 2 ) 的值,F(n - 1) 的值就需要利用 F(n - 2) 和 F(n - 3) 计算,依次类推,那么此递归算法所走过的路径为:

这个二叉树的节点个数就是该递归算法所需要计算的次数,该二叉树的高度是 n - 1,节点个数为 2^{n-1},所以时间复杂度取 O(2^{n})。此方法也有待改进,因为我们看到,有很多重复计算的操作,途中使用相同颜色标记的就是需要重复计算的 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 中不存在,就计算出结果,存入备忘录中。这种算法的时间复杂度相对于刚才的那种就优化了不少,可以看出时间复杂度为 O(n),由于借用了中间变量 Map 来存储中间值,且总共存储中间值 n - 2 个,所以空间复杂度是 O(n)。至此,我们解决此问题的程序性能已经得到了很大的优化,但其实还不是真正的动态规划实现。继续优化此代码的话,需要考虑减小空间复杂度,时间复杂度已经不能在优化了。我们通过之前观察递归方法的时间复杂度可知,F(n) 只与 F(n - 1) 与 F(n - 2) 有关,即如果我们需要计算 F(10),只需要从临时变量中拿到 F(8) 和 F(9) 即可,至于计算 F(8) 和 F(9) 所需要的 F(6) 与 F(7),我们则不需要存储。也就是说,不必像备忘录算法那样,存储每一个子状态的值,只需在每一次迭代中保持前两个子状态的值即可,这样的话,存储中间值所需要的空间复杂度即可缩小为常量级的 O(1)

那么真正的动态规划代码实现为:

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 座金矿,由于不考虑需要挖金矿的个数,那么排列组合起来就有 2^{n} 种选择,然后对所有的可能性做遍历,排除那些使用工人数超过 10 的组合,在剩下的选择里找出获得金币数最多的选择。这样的话时间复杂度就是 O(2^{n})。我们之前说过,动态规划有三个核心元素,最优子结构、边界和状态转移方程。对于此题来说,乍一看其最优子结构是 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 的二叉树。方法的时间复杂度是 O(2^{n})

方法三:备忘录算法。在简单递归的基础上增加一个 HashMap 结构的备忘录,用来存储中间结果。HashMap 的 key 是一个包含金矿数 n 和工人数 w 的对象,value 是最优选择获得的黄金数。方法的时间复杂度和空间复杂度相同,为 O(n),都等同于备忘录中不同 key 的数量。

方法四:动态规划法。我们使用一个横坐标为工人数,纵坐标为金矿的图表来模拟挖金的场景。

对于金矿1,根据上面展示的状态转移公式可得挖金量与工人间的关系为:

工人1工人2工人3工人4工人5工人6工人7工人8工人9工人10
金矿1
400金/5人
0000400400400400400400
金矿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人
0000400400400400400400
金矿2
500金/5人
0000500500500500500900
金矿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人
0000400400400400400400
金矿2
500金/5人
0000500500500500500900
金矿3
200金/3人
00200200500500500700700900
金矿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人
0000400400400400400400
金矿2
500金/5人
0000500500500500500900
金矿3
200金/3人
00200200500500500700700900
金矿4
300金/4人
00200300500500500700800900
金矿5
350金/3人
00350350500550650850850900

其中的规律就是除了第一行以外,其他的单元格的值都是通过其上一行的一个或两个单元格计算而来。我们在写代码的时候也正是用的这种思路,利用前一行的值推导出当前行的值。代码如下:

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 不太使用数组,所以在此就不展示了~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值