动态规划:0-1背包问题与其空间优化方法

问题描述:有N件物品和一个总容量为C的背包,第i件物品的重量是Wi,得到的价值是Vi,求解将哪些物品放入背包中可使价值总和最大

分析:0-1背包问题是最基础的背包问题,同样属于子集和问题,用二维数组F[i][w]来代表前i件物品恰好放入一个容量为w的背包时的最大价值总和。与K-Sum问题类似,对于第i件物品,我们有放入包内和不放入包内两种策略(W表示当前包内剩余容量):

1、如果选择放入,则问题转化为是否能在前i-1件物品中挑选重量不超过W-Wi的物品组合
2、如果不放入第i件物品,则问题可以转化为是否能在前i-1件物品中挑选重量不超过W的物品组合

而问题所求为总价值最大的物品组合,因此我们需要比较这两种方案中哪一个的价值更大,这样就得到一个状态转移方程:F[i][W]=max{F[i-1][W],F[i-1][W-Wi]+Vi}
所以问题的关键,是要利用状态转移方程,将F[i][W]的值全部填充完毕,也就是一个填表的过程

物品清单:
物品清单.png

最大价值表:
最大价值表.png

这个表格可由状态方程解出,但更重要的是理解它的现实意义:F[i][w]是指背包容量在w的情况下,备选物品为F[i]情况下,物品组合最大的价值。以F[4][5]=24为例,其意义为,在当前背包容量为5,备选物品为F[4]={1,2,3,4}的情况下,最佳方案(选择物品1和物品3)可获得的价值为24,是所有方案中所能获得的最大价值。只要理解这一现实意义,就可以很容易看出题目所求的最大价值即为表格的最后一项F[5][12]
另外,二维表中第一列意义是,对于一个容量为0的背包,不论选择怎样的备选组合,其价值都必定为0;第一行的意义是,对于一个任意容量的背包,其备选组合为空集的情况下,背包内部的价值必定为0

根据状态转移方程,我们可以通过最终的最优解,利用回溯的方式找到对应的解空间,有以下的两种寻解方式:

1、若第i件物品未被选择,则有F[i][W]=F[i-1][W]
2、若第i件物品被选择,说明该物品是最优解的一部分,则有F[i][W]=F[i-1][W-Wi]

因此,从二维表的右下角往回看,如果垂直下降,也就是发生了F[i][W]=F[i-1][W],说明第i件物品未被选择,如果是倾斜下降,则说明第i件物品被选中。如下图:


回溯寻解.png

代码实现:

#include <iostream>
using namespace std;
typedef struct goods {
    int weight;
    int value;
}goods;
const int Capacity = 11;
const int N = 5;      //物品件数
goods comodity[N + 1] = { {0,0},{1,1},{2,6 }, {5, 18}, {6, 22}, {7, 28} };
int selected[N + 1][Capacity + 1];

int Max(int a,int b) {
    return a > b ? a : b;
}

int GetMaxValue() {
    for (int w = 0; w <= Capacity; w++)
        selected[0][w] = 0;   //表示容量为w的背包为空时,其价值为0
    for (int i = 1; i <= N; i++)
        selected[i][0] = 0;   //表示容量为0的背包,其价值为0
    for(int i=1;i<=N;i++)
        for (int w = 1; w <= Capacity; w++) {
            if (comodity[i].weight > w)
                selected[i][w] = selected[i - 1][w];
            else
                selected[i][w] = Max(selected[i - 1][w], comodity[i].value + selected[i - 1][w - comodity[i].weight]);
        }
    return selected[N][Capacity];
}
int main()
{
    cout << "Max Value is " << GetMaxValue() << endl;
    int remainspace = Capacity;
    for (int i = N; i > 0; i--) {
        //从最优解出发,将所有满足选中状态的物品编号输出即可得到最优解的组成
        if (selected[i][remainspace] == comodity[i].value + selected[i - 1][remainspace - comodity[i].weight]) {
            cout << "item " << i << " is selected" << endl;
            remainspace -= comodity[i].weight;
        }
    }
    return 0;
}

使用这种方式,最终的时间复杂度为O(CN),其空间复杂度为O(CN)

但实际上,对于状态转移方程,考虑到每个F[i][W]都仅和F[i-1][W]或F[i-1][W-Wi]有关,那么可以通过利用滚动数组的方式,将F[N][C]所需要空间压缩至F[2][C],从而将空间复杂度降为O(C)。举个例子,我们将F[0][Wi]状态存在二维表的第0行,接着推出F[1][Wi]对应的值,并将F[1][Wi]的状态存放于二维表的第1行。再接着推出F[2][Wi]的状态时,F[0][Wi]的状态已经没有保存意义了,因此直接将F[2][Wi]的状态保存到二维表的第0行,并以此类推,使数组空间得到循环利用。
代码实现:

//实现上仅仅只是对selected数组的行标进行模2处理,并最终返回selected[1][Capacity]即可
int GetMaxValue() {
    for (int w = 0; w <= Capacity; w++)
        selected[0][w] = 0;   //表示容量为w的背包为空时,其价值为0
    for (int i = 1; i <= N; i++)
        selected[i % 2][0] = 0;      //表示容量为0的背包,其价值为0
    for(int i=1;i<=N;i++)
        for (int w = 1; w <= Capacity; w++) {
            if (comodity[i].weight > w)
                selected[i % 2][w] = selected[(i - 1) % 2][w];
            else
                selected[i % 2][w] = Max(selected[(i - 1) % 2][w], \
                    comodity[i].value + selected[(i - 1)%2][w - comodity[i].weight]);
        }
    return selected[1][Capacity];
}

在理解了滚动数组优化空间的基础之上,还可以进一步将长度2*C的二维表进一步压缩成一个一维数组,其状态转移方程为B[W]=max{B[W],B[W-Wi]+Vi}。
代码实现:

int selected[Capacity];
int GetMaxValue() {
    for (int i = 1; i <= N; i++)
        for (int w = Capacity; w >= 0; --w) {
            if (comodity[i].weight <= w)
                selected[w] = Max(selected[w], comodity[i].value + selected[w - comodity[i].weight]);    
                //从二维表变化可以看出,第i件物品不选的情况下,select[i]的值是不发生变化的(即垂直下降)
        }
    return selected[Capacity];
}

这种优化的原理也是非常明显的,先计算F[i-1][W]并存入数组B当中,然后再计算F[i][W]并覆盖数组B中的内容,依次类推直至所有状态求出。由于B[W]可由B[i-1][W-Wi]推出,因此在覆盖数组时必须采用倒序存储。举个例子,当你计算出B[0]的状态并覆盖掉原来B[0]中的值时,再要求B[1]时,由于B[0]的值已经发生了改变,所以你无法得到正确的B[1]。

然而,不管是滚动数组还是一维数组,在压缩空间的同时,其带来的缺点也很明显,那就是无法通过回溯的方式将所有的最优解求出。若要通过回溯方式求出最优解,那必须对所有的历史状态都进行保留。而压缩存储空间的同时也压缩了问题的解空间,滚动数组的解空间最多仅能容纳2个物品的解,而一维数组的解空间最多只能容纳1个

总结:

1、01背包问题属于NP问题之一,每个物品有选和不选两种策略,若采用暴力搜索算法,其时间复杂度为O(2n),而采用动态规划的方式,则可以将时间复杂度从O(2n )降到O(n2),通过自底向上逐层递推可以求得最优解
2、在动态规划的过程中,由于其无后效性,往往能够通过对状态转移方程的分析,使用滚动数组的方式压缩存储空间,当当前状态仅仅与前一状态相关时,甚至可以采用一维数组进行进一步压缩,此时需要特别注意数组下标的遍历次序。

参考资料:
1、《编程之法——面试和算法心得》
2、《0-1背包问题的动态规划算法》:https://zhuanlan.zhihu.com/p/30959069
3、《背包问题九讲》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342