===== 本文算法的时空复杂度都未达到最优,核心目的在于展现并理解回溯法的算法过程。=====
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+r⩽bestp 时,可剪去右子树。
计算右子树中解的上界的更好方法是,将剩余物品依照其单位重量价值排序,然后依次装入物品,直至装不下时,再装入该物品的一部分而装满背包,由此得到的价值是右子树中解的上界。
解空间树
测试用例:物品个数 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): 0
第2层: 当前价值(cp): 12, 已使用的背包容量(cw): 5
第3层: 当前价值(cp): 42, 已使用的背包容量(cw): 20
第4层: 当前价值(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): 20
第5层: 当前价值(cp): 88, 已使用的背包容量(cw): 47
--- 计算上界: 6 上界为: 88
(进入右子树)
第6层: 当前价值(cp): 88, 已使用的背包容量(cw): 47
--- 计算上界: 5 上界为: 92
(进入右子树)
第5层: 当前价值(cp): 42, 已使用的背包容量(cw): 20
第6层: 当前价值(cp): 92, 已使用的背包容量(cw): 50
--- 计算上界: 6 上界为: 42
--- 计算上界: 3 上界为: 90.0741
--- 计算上界: 2 上界为: 91.037
======= 最大背包价值:92 =======