背包问题-面试中的动态规划

背包问题-面试中的动态规划

序言

背包问题是动态规划(Dynamic Programming)中一类经典的问题,弄懂背包问题对于理解DP有很大的帮助。在程序员的面试当中也会有许多背包问题的缩影。本文收集了一些Online Judge平台中的例子来介绍各类背包问题的情形。面试当中遇到相关的DP问题,可以举一反三。

网上也有很多讲背包问题的文章,之所以写这篇文章是想作一个的总结,
以便自己以后查看。也希望能够给读者带来一些帮助。
如果想要系统了解背包问题的理论知识,
《背包问题九讲》是很好的学习资料,但是其中给出的伪代码到可运行的源代码差距还比较大,本文给出了所有的案例的源代码实现。

01背包

从最简单的01背包开始讲起。
先看一个题目吧

Q1:
在N个物品中挑选若干物品装入背包,最多能装多满?
假设背包的大小为V,第i个物品的大小为C[i]

注意事项:
你不可以将物品进行切割。
样例:

  • 如果有4个物品[2, 3, 5, 7]
  • 如果背包的大小为11,可以选择[2, 3, 5]装入背包,最多可以装满10的空间。
  • 如果背包的大小为12,可以选择[2, 3, 7]装入背包,最多可以装满12的空间。
  • 函数需要返回最多能装满的空间大小。

这道题目来自于lintcode-backpack

//AC code
class Solution {
private:
    int V;

public:
    Solution():V(0) {}
    void ZeroOnePack(int F[], int C, int W) {
        for (int v = V; v >= C; v--) {
            F[v] = max(F[v], F[v-C] + W);
        }
    }
    /**
     * @param V: An integer V denotes the size of a backpack
     * @param C: Given n items with size C[i]
     * @return: The maximum size
     */
    int backPack(int V, vector<int> C) {
        int N = C.size();
        this->V = V;
        int f[V+1];
        // 初始化
        for (int i = 0; i <= V; i++) {
            f[i] = 0;
        }
        for (int i = 1; i <= N; i++) {
            ZeroOnePack(f, C[i-1], C[i-1]);
        }
        return f[V];
    }
};

另外一个类似的题目:

Q2:
有N件物品和一个容量为V的背包。放入第i件物品耗费的费用是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大?

这道题来自于lintcode-backpack-ii
只需要将Q1的AC代码中ZeroOnePack的第三个参数改为W[i-1],即可得到Q2的AC代码。

Q1是Q2的一种特殊形式,即物品的价值就是物品本身。因为Q1的问题是如何才能使得背包装的最满,意思就是说物品的体积就是物品的价值。Q2只不过是将价值换成了一种其他的说法。
Q1,Q2是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放
用子问题定义状态:即F[i,v]表示前i件物品恰放入一个容量为v的背包可以获得的最大价值。
其状态转移方程便是:F[i,v] = max{F[i−1, v], F[i−1, v−Ci] + Wi}
这个方程是什么意思呢?用通俗易懂的语言来描述:就是假设我们已经知道了有i件物品放入v背包中的方案,其获得的最大的价值用F[i,v]来表示。那么在这个方案中,我们来研究一下第i件物品的策略(放或者不放)。如果不放第i件物品,那么问题就转化为“前i−1件物品放入容量为v的背包中”,价值为F[i−1, v];如果放第i件物品,那么问题就转化为“前i−1件物品放入剩下的容量为v−Ci的背包中”,此时能获得的最大价值就是F[i−1, v−Ci]再加上通过放入第i件物品获得的价值Wi。

//伪代码
def ZeroOnePack(F, C, W)
  for(int v = V; v >= C; v--)
    F[v] = max(F[v], F[v-C]+W)

def main
  int F[V+1]
  //初始化
  for(int i = 0; i <= V; i++)
    F[0] = 0;
  for(int i = 1; i <= N; i++)
      ZeroOnePack(F, C[i], W[i]))

这儿使用了一个优化空间的手段,可以看到我们的状态转移方程中使用的是一个二维变量F[i,v],而在伪代码中这个二维变量变成了F[V]。因为我们最后要求的结果是N个物品放入到容量为V的包中所能获得的最大价值,所以我们只关心最后的F[N,V]这个值是多少?而F[N,V]依赖于F[N-1,V]和F[N-1,V-C[N]]。
可以看到在main函数中i是从1一直自增到N的,因此我们用F[V]来表示每次循环后的结果,即F[i,V]。那么当i变为i+1的时候,计算F[i+1,V]时用到的F[i,V-C[i+1]],即F[V-C[i+1]]已经被覆盖(V-C[i+1] < V)。这儿只要从v=V自减便能解决这个问题。

还有一个关于初始化的问题:关于背包问题,通常有两种不太相同的问法。有的题目要求“恰装满背包”时的最优解,有的题目并没有要求必须把背包装满。针对于这两种不同的问法,实现时表现在于初始化的时候有所不同。如果是第一种问法,要求恰好装满背包,那么在初始化时除了F[0]为0,其它F[1..V]均设为−∞,这样就可以保证最终得到的F[V]是一种恰好装满背包的最优解。如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将F[0..V]全部设为0。

再看一个要求正好装满的题目吧

Q3:给出一个都是正整数的数组 nums,其中没有重复的数。从中找出所有的和为 target 的组合个数。
一个数可以在组合中出现多次。数的顺序不同则会被认为是不同的组合。
样例:
给出 nums = [1, 2, 4], target = 4
可能的所有组合有:
[1, 1, 1, 1]
[1, 1, 2]
[1, 2, 1]
[2, 1, 1]
[2, 2]
[4]
返回 6

这道题目来自于lintcode-backpack-vi

//AC code
class Solution {
public:
    int backPackVI(vector<int>& nums, int target) {
        // Write your code here
        int dp[target+1];
        for(int i = 0; i < target + 1; i++) {
            dp[i] = 0;
        }
        dp[0] = 1;
        for (int i = 1; i <= target; ++i) {
            for (auto a : nums)
            if (i >= a) {
                dp[i] += dp[i - a];
            }
        }
        return dp[target];
    }
};

完全背包问题

Q3中的场景明显与Q1和Q2不相同,Q1和Q2当中,物品的最大一个特点是:每件物品只能使用一次。
Q3描述的是一个新的问题:完全背包问题。在完全背包问题当中,每件物品的数量是没有限制的。

这儿再次描述一下完全背包问题:有N种物品和一个容量为V的背包,每种物品都有无限件可用。放入第i种物品
的费用是Ci,价值是Wi。求解:将哪些物品装入背包,可使这些物品的耗费的费用总和不超过背包容量,且价值总和最大[1]
注意到完全背包问题是在01背包问题的基础上将每件物品的数量限制去掉了。
那么,很自然地,我们可以将完全背包问题转换成为01背包问题求解。
先给出状态转移方程: F[i,v]=max(F[i−1,v],F[i,v−Ci]+Wi)
这个状态转移方程的意思是:将前i件物品放入容量为v的背包中获取到的最大价值等价于当第i件
物品一件都不放入时,前i-1件物品放入容量为v的背包中获取到的最大价值;或者当放入第i件物品
时,前i件物品放入容量为v-Ci的背包中获取到的最大价值,注意这儿并不是前i-1件而仍然是前i件。

//伪代码
def CompletePack(F, C, W)
   for v <-- C to V
      F[v] = max{F[v], F[v-C]+W}

上述伪代码和01背包伪代码唯一的区别只有v的循环次序不同。

再回过头看一眼Q3:target即为背包容量,nums数组给出了每件物品的费用,物品本身的费用即为其价值。另外一类换零钱的问题也比较类似。
可以将target视为一张整钱,nums里面提供的是零钱的面额,此题即要求出换钱的方法和。
AC code中代码的意思是:dp[i]表示将nums中的物品放入容量为i的背包中的总的方法数目。因为问题是求总的换法数目,所以需要将动态转移方程中的max改为sum。dp[i]+=dp[i-a]的意思是有两种可能,a对应的物品不放入背包或者放入背包。

将Q3稍微改变一下:

Q4: 某个国家发行了3种不同的硬币,面值分别为1元,2元和4元。现在某人有一张面值为4元纸币,他需要将纸币换成硬币,问共有多少种换法?和Q3不一样的地方在于,序列[1,2,1]和[1,1,2],[2,1,1]等属于一种换法。

其实,这也是一道完全背包问题。假设F[i,j]表示只用前i种硬币将j兑换的种数,那么动态转移方程就是:
F[i,j] = F[i - 1, j] + F[i, j - C]
再做一下空间上的优化,代码如下:

class Solution {
public:
    static int V;
    template <class T>
    void printArray(T arr[], int len) {
        for(int i = 0; i < len; i++)
            cout<<arr[i]<<" ";
        cout<<endl;
    }

    void CompletePack(int F[], int C, int W) {
        for(int v = C; v <= V; v++) {
            F[v] = max(F[v], F[v - C] + W);
        }
        //printArray(F, V+1);
    }

    /**
     * @param nums an integer array and all positive numbers, no duplicates
     * @param target an integer
     * @return an integer
     */
    int backPackVI(vector<int>& nums, int target) {
        //this is a Complete pack
        int size = nums.size();
        V  = target;
        int f[V+1];

        for(int v = 0; v <= V; v++) {
            f[v] = 0;
        }
        f[0] = 1;

        for(int i = 1; i <= size; i++) {
            //printf("index %d: call CompletePack -> C[%d] W[%d]\n", i, nums.at(i-1), nums.at(i-1));
            CompletePack(f, nums.at(i-1), nums.at(i-1));
        }
        return f[V];
    }
};

int Solution::V = 0;

updating..
多重背包

//Pseudo Code
def ZeorOnePack(F, C, W)
     for( v = V; v >= C; v--)
         F(v) = max(F(v), F(v-C) + W)

def CompletePack(F, C, W)
     for(v = C; v <= V; v++)
         F(v) = max(F(v), F(v-C) + W)

def MultiplePack(F, C, W, M)
     if(C * M >= V)
         CompletePack(F, C, V)
         return
     k = 1
     while( k < M )
         ZeroOnePack(F, kC, kW)
         M -= k;
         k *= 2;
     ZeroOnePack(F, C * M, W * M)

def Main
     for( v = 0; v < V; v++)
         F(v) = 0
     MultiplePack(F, C, W, M)
本文很多内容来自互联网,如有侵权,请联系作者

[参考文献]

  1. 背包问题九讲-崔添翼
posted @ 2017-08-21 15:55  镜子里的人  阅读(297)  评论(0编辑  收藏  举报