[问题]给定n种物品和1个背包,背包允许的最大重量为Capacity。物品i的重量为weight[i],价值为value[i]。问应当选择哪些物品装入背包,使背包中的物品的总价值最大?
[解析]因为每种物品只有装入背包或不装入背包两种选择,所以该问题称为0-1背包问题。
方法1.动态规划法
按照从1到n的顺序来依次决定每种物品是否装入背包中。设轮到决定物品i时背包的剩余容量为C,此时利用从i开始的所有剩余物品和背包的剩余容量可以得到的最大价值为f(i,C),则
(1)若选择将物品i装入背包,则所得的最大价值为f(i+1,C-weight[i])+value[i]
(2)若选择将物品i不装入背包,则所得的最大价值为f(i+1,C)
所以,f(i,C) = Max{f(i+1,C-weight[i])+value[i],f(i+1,C)}
由此可知,该问题具有最优子结构性质。
方法2.回溯法
先定义问题的解空间,该问题的解空间由长度为n的0-1向量组成。该解空间包含对变量的所有0-1赋值。当n=3时,其解空间为:
{(0,0,0),(0,0,1),(0,1,0),(0,1,1),(1,0,0),(1,0,1),(1,1,0),(1,1,1)}
然后组织解空间,这里可采用完全二叉树来表示其解空间,如下图所示。
解空间树的第i层到第i+1层的边上的标号给出了0-1变量的值,从树根到叶子的任一路径表示解空间中的一个元素。例如,从根节点A到叶节点H的路径相应于解空间中的元素(1,1,1)
最后从根节点A开始,对解空间树进行DFS。所选用的剪枝函数为判断当前背包的剩余容量是否不小于0,属于约束函数。搜索过程中每到达一个叶节点,则记录当前背包的总价值,并与已找到的最大值进行比较,根据比较结果来更新最大值。
代码如下:
// 标识编号为i的物品(编号从0开始)是否装入背包,即当前路径在解空间树中层次为i的节点处选择的是其左链接(true)还是右链接(false),其中根节点的层次为0,数组初始化为全false static bool selected[n]; // 存储当前已找到的最优装包方案,初始化为全false static bool optimal[n]; // 当前已找到的最大价值,初始化为0 static int maxTotalValue = 0; // 当前背包的价值,初始化为0 static int valueOfPackage = 0; // 当前背包的剩余容量,初始化为背包的容量 static int residualCapacity = Capacity; // 对解空间树中层次为i的当前节点进行DFS void BackTrack(int i) { // 检查是否已到达叶节点 if(i == n) { // 如果已到达叶节点,则对当前路径所表示的装包方案进行处理 if(valueOfPackage > maxTotalValue) { // 存储当前已找到的最优装包方案 Copy(selected,optimal,n); // 更新最大价值 maxTotalValue = valueOfPackage; } } else // 如果尚未到达叶节点,则沿着当前节点的左右两条子链接分别进行DFS { #pragma region 沿着当前节点的左子链接进行DFS residualCapacity -= weight[i]; // 检查当前节点的左子节点是否满足约束条件,即当前背包的剩余容量是否不小于0 if(residualCapacity >= 0) { // 如果满足,则推进到当前节点的左子结点 selected[i] = true; valueOfPackage += value[i]; // 对新的当前节点进行DFS BackTrack(i + 1); // 对新的当前节点进行DFS完毕后,回溯到其父节点,即原来的当前节点 selected[i] = false; valueOfPackage -= value[i]; residualCapacity += weight[i]; } else { // 如果不满足,则直接回溯到当前节点 residualCapacity += weight[i]; } #pragma endregion // 沿着当前节点的右子链接进行DFS BackTrack(i + 1); } }
也可添加一个限界函数进行剪枝,即计算以当前节点为根的子树中的所有解的上界(粗略计算为到达当前节点时背包已有的价值加上所有未装入背包的物品的总价值,更准确的计算方法见下一段),如果所得结果不大于当前已找到的最大价值,则不必再对当前节点进行DFS,而应直接回溯。
上面所述的计算以当前节点为根的子树中的所有解的上界的方法是粗略的,结果常常会大于准确值。我们可以有更准确的方法。初始时就将所有物品按照单位重量的价值递减的顺序排列。对于当前背包的剩余容量,我们假设按照物品的顺序依次装入背包,当物品i装入后,再装物品i+1时,发现背包的剩余容量不足,此时背包的剩余容量为residualCapacity,则以当前节点为根的子树中的所有解的上界为背包中物品的总价值再加上residualCapacity * (value[i+1] / weight[i+1])
方法3.分支限界法
解空间与回溯法相同,所采用的限界函数为在上一段中所计算的以当前节点为根的子树中的所有解的上界。如果当前节点的限界值不大于已找到的最大价值,则说明以当前节点为根的子树不可能包含比当前已找到的最优解更优的解,因此可以剪去。如果到达一个叶节点,则说明其它活节点的限界值均不大于该叶节点所对应的解,即以其它活节点为根的子树不可能包含比该叶节点所对应的解更优的解,所以该叶节点所对应的解就是一个最优解。
如果需要得出最优解的构造,则应在搜索过程中保存当前已构造出的部分解空间树。这样当搜索到达叶节点时,可以在解空间树中从该叶节点开始向根节点回溯,从而得出相应的最优解的构造。
代码如下:
enum Child { Left, Right }; // 定义解空间树中的节点 struct TreeNode { Link parent; // 指向父节点的指针 enum Child child; // 标识是父节点的左孩子还是右孩子,即当前路径在解空间树中层次为i的节点处选择的是其左链接还是右链接,即编号为i的物品(编号从0开始)是否装入背包 }; // 定义活结点优先队列的大顶堆实现中的节点 struct HeapNode { Link treeNode; // 指向活节点的指针 int UpBound; // 用限界函数对该活结点进行计算所得的上界,即以该节点为根的子树中的所有解的上界。用作优先队列的优先级 int level; // 活结点在解空间树中所处的层序号,其中根节点的层次为0 int valueOfPackage; // 活结点处背包的价值 int residualCapacity; // 活结点处背包的剩余容量 }; // 生成新的活结点,并添加到解空间树和活结点优先队列中 void AddLiveNode(Link parent,enum Child child,int upBound,int level,int valueOfPackage,int residualCapacity) { Link link = CreateTreeNode(parent,isLeft); HeapNode heapNode = CreateHeapNode(link,upBound,level,valueOfPackage,residualCapacity); PriorityQueueInsert(heapNode); } void Package() { // 存储找到的最优装包方案,optimal[i]表示编号为i的物品(编号从0开始)是否装入背包 bool optimal[n]; // 当前已找到的最大价值,初始化为0 int maxTotalValue = 0; // 解空间树中的当前节点的限界值,初始化为根节点的限界值 int upBound = GetUpBound(0,0); // 生成解空间树的根节点 Link link = AddLiveNode(NULL,Right,upBound,0,0,Capacity); // 只要尚未搜索到叶节点 while(link->level < n) { link = PriorityQueueDelelteMax() #pragma region 沿着当前节点的左右两条子链接分别进行分支 #pragma region 沿着当前节点的左子链接进行分支 // 如果当前节点的左子节点满足约束条件,即当前背包的剩余容量不小于0,则对它进行分支 if(link->residualCapacity - weight[i] >= 0) { // 当前节点的左子节点的限界值与当前节点的限界值相等,而当前节点的限界值是所有活结点中最大的,所以其左子节点的限界值必不小于已找到的最大价值,所以应将其作为活结点生成,并添加到解空间树和活结点优先队列中 AddLiveNode(link,Left,upBound.level + 1,link->valueOfPackage + value[i],link->residualCapacity - weight[i]); // 如果在新的活结点处背包的价值大于当前已找到的最大价值 if(link->valueOfPackage + value[i] > maxTotalValue) { // 则更新当前已找到的最大价值 maxTotalValue = link->valueOfPackage + value[i]; } } #pragma endregion #pragma region 沿着当前节点的右子链接进行分支 // 计算当前节点的右子结点的限界值 upBound = GetUpBound(link->valueOfPackage,link->level + 1); // 如果当前节点的右子节点的限界值大于已找到的最大价值 if(upBound > maxTotalValue) { // 则将其作为活结点生成,并添加到解空间树和活结点优先队列中 AddLiveNode(link,Right,upBound.level + 1,link->valueOfPackage,link->residualCapacity); } #pragma endregion } // 如果已到达叶节点,则输出当前路径所表示的最优解 for(int level = n - 1;level >= 0;level--) { if(link->child == Left) { optimal[level] = true; } else { optimal[level] = false; } link = link->parent; } }