算法分析与设计:0-1背包问题(回溯法)【本文算法的时空复杂度都未达到最优,核心目的在于展现并理解回溯法的算法过程】

===== 本文算法的时空复杂度都未达到最优,核心目的在于展现并理解回溯法的算法过程。=====


0-1背包问题

给定 n n n 种物品和一个背包。物品 i i i 的重量为 w i w_i wi,其价值为 p i p_i pi,背包的容积为 c c c。问如何选择装入背包中的物品,使得装入背包中物品的总价值最大?


回溯法

回溯法是最常用的解题方法,有“通用的解题法”之称。当要解决的问题有若干可行解时,则可以在包含问题所有解的空间树中,按深度优先的策略,从根节点出发搜索解空间树。算法搜索至解空间树的任一结点时,总是先判断该结点是否肯定不包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的搜索,继续查找该结点的兄弟结点,若它的兄弟结点都不包含问题的解,则返回其父结点——这个步骤称为回溯。否则进入一个可能包含解的子树,继续按深度优先的策略进行搜索。这种以深度优先的方式搜索问题的解的算法称为回溯法。它本质上是一种穷举法,但由于在搜索过程中不断略过某些显然不合适的子树,所以搜索的空间大大少于一般的穷举,故它适用于解一些组合数较大的问题。


问题分析

回溯法是通过深度优先搜索进行构建解空间树的算法。在搜索解空间树时,只要其左儿子节点是一个可行节点,搜索就进入其左子树。当右子树中有可能包含最优解时才进入右子树搜索,否则将右子树剪去。

r r r 是当前剩余物品的价值总和, c p cp cp 是当前背包中已装物品的价值, b e s t p bestp bestp 是当前最优价值。当 c p + r ⩽ b e s t p cp+r \leqslant bestp cp+rbestp 时,可剪去右子树。

计算右子树中解的上界的更好方法是,将剩余物品依照其单位重量价值排序,然后依次装入物品,直至装不下时,再装入该物品的一部分而装满背包,由此得到的价值是右子树中解的上界。


解空间树

测试用例:物品个数 n = 5 n=5 n=5,背包容积 c = 50 c=50 c=50,物品的体积数组 w = [ 5 , 15 , 25 , 27 , 30 ] w=[5, 15, 25, 27, 30] w=[5,15,25,27,30], 物品的价值数组 p = [ 12 , 30 , 44 , 46 , 50 ] p=[12, 30, 44, 46, 50] p=[12,30,44,46,50]

如图所示为算法的解空间树,红线标注的是最优解。节点的命名(A~Q)是按照生成的先后顺序命名的。

解空间树


C++源代码

#include <iostream>
#include <algorithm>
using namespace std;

// 回溯节点类
class Knap
{
public:
    double Bound(int i);
    void Backtrack(int i);
    int c;     // 背包容量
    int n;     // 物品数量
    int *w;    // 物品重量数组
    int *p;    // 物品价值数组
    int cw;    // 当前重量
    int cp;    // 当前价值
    int bestp; // 当前最优价值
};

// 物品类
class Object
{
public:
    int ID;
    double d; // 物品的单位价值
};

// 冒泡排序
Object *Sort(Object *array, int len)
{
    Object temp;
    for (int i = 0; i < len; i++)
    {
        for (int j = 0; j < len - 1 - i; j++)
        {
            if (array[j + 1].d > array[j].d)
            {
                temp = array[j + 1];
                array[j + 1] = array[j];
                array[j] = temp;
            }
        }
    }
    return array;
}

// 计算当前上界
double Knap::Bound(int i)
{
    int tp_i = i;        // 临时储存i的值
    int c_left = c - cw; // 剩余容量
    double b = cp;
    // 剩余以物品单位重量价值递减序装入物品
    while (i <= n && w[i] <= c_left)
    {
        c_left -= w[i];
        b += p[i];
        i++;
    }
    // 装满背包
    if (i <= n)
    {
        b += (double)p[i] * c_left / w[i];
    }
    cout << "--- 计算上界: " << tp_i << "\t上界为: " << b << endl;
    return b;
}

// 回溯核心函数
void Knap::Backtrack(int i)
{
    cout << "第" << i << "层: "
         << "当前价值(cp): " << cp << ",\t已使用的背包容量(cw): " << cw << endl;
    if (i > n) // 到达叶节点
    {
        bestp = cp;
        return;
    }
    if (cw + w[i] <= c) // 进入左子树
    {
        cw += w[i];
        cp += p[i];
        Backtrack(i + 1);
        cw -= w[i];
        cp -= p[i];
    }
    // 选择进入右子树,如果当前商品不装,计算上界(按照当前的方案最多能装的价值),
    // 若大于则进入右子树
    // 反之则进行剪枝
    if (Bound(i + 1) > bestp)
    {
        cout << "(进入右子树)" << endl;
        Backtrack(i + 1);
    }
}

// 回溯法的初始化
int Knapsack(int p[], int w[], int c, int n)
{
    int W = 0, P = 0;          // 总重量和总价值
    Object *Q = new Object[n]; // 物品单位价值数组
    // 初始化物品队列
    for (int i = 1; i <= n; ++i)
    {
        Q[i - 1].ID = i;
        Q[i - 1].d = (double)p[i] / w[i];
        P += p[i];
        W += w[i];
    }
    Sort(Q, n);
    // 如果总重量都小于背包容量,则装所以物品
    if (W <= c)
        return P;
    // 依照物品的单位重量进行排序

    Knap K;
    K.p = new int[n + 1];
    K.w = new int[n + 1];
    for (int i = 1; i <= n; ++i)
    {
        K.p[i] = p[Q[i - 1].ID];
        K.w[i] = w[Q[i - 1].ID];
    }
    K.cp = 0;
    K.cw = 0;
    K.c = c;
    K.n = n;
    K.bestp = 0;
    K.Backtrack(1);
    delete[] Q;
    delete[] K.w;
    delete[] K.p;
    return K.bestp;
}

int main()
{
    int n, c;
    cin >> n >> c;
    int *p = new int[n + 1];
    int *w = new int[n + 1];
    for (int i = 1; i <= n; ++i)
    {
        cin >> w[i] >> p[i];
    }
    int res = Knapsack(p, w, c, n);
    cout << "\n======= 最大背包价值:" << res << " =======" << endl;
    return 0;
}
/*
5 50
5 12
15 30
25 44
27 46
30 50
*/

程序输出

在程序输出中,展示了回溯算法进入左右子树的过程,在进入右子树之前会有一个上界判断的过程(--- 计算上界: 5 上界为: 94.3333),进入左子树则不需要。

结合程序输出和上图中的解空间树,可以更好的理解回溯法构造子树的过程。

1: 当前价值(cp): 0, 	   已使用的背包容量(cw): 02: 当前价值(cp): 12,     已使用的背包容量(cw): 53: 当前价值(cp): 42,     已使用的背包容量(cw): 204: 当前价值(cp): 86,     已使用的背包容量(cw): 45
--- 计算上界: 5 上界为: 94.3333
(进入右子树)5: 当前价值(cp): 86,        已使用的背包容量(cw): 45
--- 计算上界: 6 上界为: 86
(进入右子树)6: 当前价值(cp): 86,        已使用的背包容量(cw): 45
--- 计算上界: 4 上界为: 93
(进入右子树)4: 当前价值(cp): 42,        已使用的背包容量(cw): 205: 当前价值(cp): 88,        已使用的背包容量(cw): 47
--- 计算上界: 6 上界为: 88
(进入右子树)6: 当前价值(cp): 88,        已使用的背包容量(cw): 47
--- 计算上界: 5 上界为: 92
(进入右子树)5: 当前价值(cp): 42,        已使用的背包容量(cw): 206: 当前价值(cp): 92,        已使用的背包容量(cw): 50
--- 计算上界: 6 上界为: 42
--- 计算上界: 3 上界为: 90.0741
--- 计算上界: 2 上界为: 91.037

======= 最大背包价值:92 =======
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
回溯法解0_1背包问题时,会用到状态空间树。在搜索状态空间树时,只要其左儿子结点是一个可行结点,搜索就进入其左子树。当右子树有可能包含最优解时才进入右子树搜索,否则将右子树剪去。设r是当前剩余物品价值总和;cp是当前价值;bestp是当前最优价值。当cp+r≤bestp时,可剪去右子树。计算右子树中解的上界可以用的方法是将剩余物品依其单位重量价值排序,然后依次装入物品,直至装不下时,再装入该物品的一部分而装满背包。由此得到的价值是右子树中解的上界,用此值来剪枝。 为了便于计算上界,可先将物品依其单位重量价值从大到小排序,此后只要顺序考察各物品即可。在实现时,由MaxBoundary函数计算当前结点处的上界。它是类Knap的私有成员。Knap的其他成员记录了解空间树种的节点信息,以减少函数参数的传递以及递归调用时所需要的栈空间。在解空间树的当前扩展结点处,仅当要进入右子树时才计算上界函数MaxBoundary,以判断是否可以将右子树减去。进入左子树时不需要计算上界,因为其上界与父结点的上界相同。 在调用函数Knapsack之前,需要先将各物品依其单位重量价值从达到小排序。为此目的,我们定义了类Objiect。其中,运算符与通常的定义相反,其目的是为了方便调用已有的排序算法。在通常情况下,排序算法将待排序元素从小到大排序。 在搜索状态空间树时,由函数Backtrack控制。在函数中是利用递归调用的方法实现了空间树的搜索
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值