DP动态规划--背包DP

356 阅读7分钟

这是我参与更文挑战的第 13 天,活动详情查看: 更文挑战

背包 DP

背包 DP,这个称呼实际上是来自一个比较经典的 DP 问题:“背包问题”,在面试中比较出名的是“背包九讲”。但“背包九讲”对于很多只需要参加面试的同学来说,内容有些偏难,并且大部分面试只会涉及 01 背包和完全背包。因此,接下来我会带你分析面试中常常出现的高频背包问题。

如果你没有学习过背包问题,甚至从来没有听说过,也不影响你接下来的学习。

例子:分割等和子集

题目

一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

注意: 1)每个数组中的元素不会超过 100;2)数组的大小不会超过 200

输入:A = [2, 2]

输出:true

解释:可以将这个数组分成 [2], [2] 这两个子集和是相等的。

分析

由于分出来的两个子集和要求是相等的,如果整个数组和为奇数,那么没有讨论的必要。这里我们只讨论 sum(A) = 2 x V 的情况。也就是分出来的两个子集,其和分别为 V。

这个问题,也可以想象成:

有一个容量为 V 的背包,给你一些石头,石头的大小放在数组 A[] 中,现在需要捡一些石头,刚好装满容量为 V 的背包。(你可以不去考虑填充的时候的缝隙)

那么,在这个场景下,每个石头就只有选和不选两种情况。下面我们具体看一下如何利用6 步分析法处理这个问题。

1. 最后一步

这个问题里面,比较难以想到的是最后一步,我们先把最后一步的来龙去脉讲清楚。

首先,假设给定的数组 A[] = {1, 2, 3},然后看一下利用这个数组可以组合出哪些数。在一开始,如果我们什么元素都不取,肯定可以走到的点为 0。因此,可以将 0 设置为起点。

至此,你应该已经分析出最后一步应该如何操作。它依赖两点:

  • 已经可以访问到的点集 X(后面我们把可以访问到的数均称为点集);
  • A[n-1] 元素。

最后一步操作可以用伪代码表示如下(解析在注释里):

Y = {....}; // 旧有的点集的状态
Z = new HashSet(Y); // 新的可以访问的点集
// 生成新的可以访问的点
for node in Y:
  Z.insert(node + A[n-1])
// 查看V是否在N点集中
Z.contains(V) -> true / false
两个点集之间的关系可以简略表示如下:

2. 子问题

通过观察最后一步,可以发现它就是在可访问点集 Y 的基础上,通过加入边A[n-1] ,然后生成点集 Z。如果引入更早一点的可访问点集 X,

它的子问题就是:

在一个可访问点集 X 里面,通过加入A[i] 元素之后,
是否可以访问 y1?
是否可以访问 y2?
……
是否可以访问 ym?

3. 递推关系

如果我们用 f()函数表示这一层关系,可以表示为:

  • f(X, A[n-2]) => Y
  • f(Y, A[n-1]) => Z

需要注意的是, f() 函数并不表示一个数是否可以生成,其输出表示的是一个点集。因此,这个例子告诉我们:有时候,f(x) 的输出与我们想要的结果并不直接相关。

比如,在这道题中,我们最终想要的答案是:

值 V 是否出现在了点集 Z 中?

但是,f() 函数并没有直接回答这个问题,而是通过以下方式来回答最终问题:

f(Y, A[n-1]) => Z
return Z.contains(V)

4. f(x) 的表示

在这道题目里面,f() 函数更一般的写法为:f(S, A[i])。其中 S 是已知的点集,而这个函数的输出得到的是一个新的点集。

那么,我们在写程序的时候,应该用什么去表达 f() 函数呢?在之前的代码里面,我们要么用数组,要么用哈希函数。但是在这里,S 表示的是可以访问的点集。像这样,如何进行哈希呢?

优化 1

不过,如果我们根据前面数组 A[] = {1, 2, 3} 给出的示例,可以用 f() 函数表示如下:

S0 = {0}
S1 = f(S0, A[0])
S2 = f(S1, A[1])
S3 = f(S2, A[2])
return S3.contains(V)

我们发现,这个步骤可以很轻松地写成两个点集迭代的形式:

old = {0}
for x in A:
    new = f(old, x)
    old = new
return old.contains(V)

优化 2

尽管只用两个点集迭代就可以完成计算过程。但是,我们还有一个条件没用上,就是给定的数组里面的元素都是正整数。

这就意味着, f(S, A[i]) 在进行迭代的时候,新生成的数,必然会更大。这对于我们的迭代有什么帮助呢?这种递增方向是否可以使我们只使用一个集合就完成工作呢?

假设 S = {0, 5},A[i] = 2 现在要原地完成一个集合的迭代,我们从小到大开始迭代,

但是,如果按此操作,就会出错。因为 A[i] = 2 被使用了两次,而题目要求只能使用一次。

出现这个问题的原因是我们无法区分旧有的数,新加入的数。使用另外一个数组标记旧有的数,新生成的数本质上就与两个集合完成迭代没有区别了。那么有什么办法可以帮助我们区分旧有的数和新生成的数呢?

如果我们试试从大往小更新呢?从大往小更新主要是基于这样一个条件:

新生成的数总是要更大一些的。如果我们先让大的数加上了 A[i],这些更大的数会放在后面,再次遍历,我们总是不会遇到这些新生成的数。

我们发现,如果采用从大往小的方向遍历,就可以利用一个点集完成迭代。

5. 初始条件与边界

首先,当我们什么都不选的时候,肯定可以得到 0,所以一开始的点集就是 {0}。

其次,我们要得到的数是 V。如果旧有的点集中,已经有数比 V 大了,比如 R,那么可以直接把 R 扔掉。因为在后面的迭代过程中,A[i] 都是正数,迭代之后,只会让 R 越来越大,所以保留 R 没有意义。

因此动态规划的边界就是 [0, V]。

6. 计算顺序

有两个计算顺序需要注意:

迭代的时候,需要用 A[0], A[1],…, A[n-1] 依次去更新点集;

当我们更新点集的时候,需要按从大到小的顺序更新。

当 f() 函数在整个迭代过程中只需要一个点集,并且这个点集的范围已经是固定 [0, V] 的时候,就可以用 boolean 数组来表示这个点集。

完整代码

经过前面的分析,我们已经可以写出如下代码了(解析在注释里):

class Solution {
    public boolean canPartition(int[] A) {
        final int N = A == null ? 0 : A.length;
        if (N <= 0) {
            return true;
        }
        // 数组求和
        int s = 0;
        for (int x : A) {
            s += x;
        }
        // 如果为奇数, 肯定是没有办法切分
        if ((s & 0x01) == 1) {
            return false;
        }
        // 分割为等和,那么相当于要取同值的一半
        final int V = s >> 1;
        // 这个dp表示着一开始可以访问的点集
        // 我们用true表示这个点存在于点集中
        // false表示这个点不存在点集中
        boolean[] dp = new boolean[V + 1];
        // 首先初始集合肯定是s0={0}
        dp[0] = true;
        // 开始利用a[i]来进行推导
        for (int x : A) {
            // 注意这里更新的方向
            for (int node = V; node - x >= 0; node--) {
                final int newNode = node;
                final int oldExistsNode = node - x;
                if (dp[oldExistsNode]) {
                    dp[newNode] = true;
                }
            }
        }
        return dp[V];
    }
}

复杂度分析:时间复杂度 O(NV),空间复杂度 O(V)。

小结

在这道题目里面,我们再次利用 DP 的 6 步分析法求解问题。在求解的过程中,可以发现有两个有意思的地方:

  • 利用 A[i] 逐步进行迭代;
  • dp[] 数组的更新方向,需要从大到小更新的根本原因是数组里面面的数都是正整数。