分支界限(B&B Branch And Bound)也是一种编程范式,在离散组合性质问题上的最优解问题通常可以用分支界限方法进行求解,与回溯有相同之处,它也是枚举所有备选项,但是回溯是一种遍历所有可能解的结构,这种枚举通常是暴力型的,是不太聪明的枚举;分支界限是一种涡轮增压性的枚举(turbo-charged enumeration),是一种聪明型的枚举,它也是列举并勾选所有可行结果,但是它采用“提前量”手段让自己的下一步选择看起来更为明智。有一些概念需要理解一下:
1、界限(Bound): 就是条件限制,也就是边界值
例如一个数值集合中,如上图,这个集合一定会有一个上限和一个下限:
上限(upper bound):大于等于集合中最大值的数。
下限(lower bound):小于等于集合中最小值的数。
上限和下限是根据目标函数和条件函数共同确定的,计算按照系数比例值对未知量进行排序重组,然后进行相关计算,例如:
目标函数:8x1 + 11x2 + 6x3 + 4x4
条件函数:5x1 + 7x2 + 4x3 + 3x4 ≤ 14
其中x1,x2,x3,x4变量的系数比例值是:1.6,1.571,1.5,1.3,已经按照从大到小的顺序排好了,按照条件函数,可以得知:
(x1,x2,x3,x4)=(1,1,0.5,0),maxVal=22
(x1,x2,x3,x4)=(0,1,1,1),minVal=21
=>最优解的上线是22,下限是21 !x只能取整数
界限(bound)的确定对问题的求解有很大的影响,界限值可以在计算的过程中逐步计算出来。
2、分支(Branch): 构建一个解的空间树,但是构造过程是有条件的,一个节点能否还能构造子树,需要看该节点是否有产生更优解的“潜质”,如果这个节点不满足界限条件或者不能产生最优解,则将该节点直接抛弃,也就相当于切除该分支,不再进行遍历。
分支界限在求解过程中有一些虚拟概念产生,搬取教科书的内容解释如下:
1、激活:搜索到某个结点的时候,该结点就被激活,是一个动作。
2、死结点:所有子结点已经全部被搜索完或者自身条件不满足界限条件的结点。
3、活结点:已被激活或者还未被激活的结点。
4、E-结点:扩展结点,当前正在搜索其孩子的结点。
分支界限的基本思想描述如下:
1、创建一个活结点表B,可用某个list数据结构进行表述
2、将状态空间树的根结点放到活结点表B中
3、初始化当前最优解bsv
while isNotEmpty(B):
//从B表中获取某个结点,并将其从B表中删除
E-node=B.remove(0);//B表可以是排好序的,可以按照最小消费/最大比值等进行排序
if E-node 具有当前最优解
bsv=E-node.value;//更新最优解
for each son of E-node:
if 当前孩子结点满足约束条件并且目标函数的当前界限:
激活当前孩子结点,并将其放到B表单中。
else:
将当前结点剪枝,也就是不将当前结点放到B表单中。
以上算法描述中有一个关键点,就是目标函数的当前界限,这里用0-1背包进行描述,以下是一个简单的图示:
上图中,标红的结点可以被剪枝,不再对其进行扩展,为什么呢?红结点的当前值是0,可选值只有一个12,因为存在一个结点结点的值是16,它
比12要大,所以从红结点扩展出的值肯定不是最优解,所以也就没必要再进行扩展了。这就是剪枝,此时目标函数的当前界限是16,如果一个结点的当前值
和它可选的值的小于这个值的话,那么那个结点就没必要再进行扩展了。
以上空间树的构造同样是一个抽象的概念,在计算的过程中不需要创建真实的树结构,但是我们对其的描述需要用树进行描述。其中结点值有很多不同的状态,比如:当前重量、当前值、当前解,这里可用一个结点进行描述,这里给出一个0-1背包的解法:
def Node:
int[] result;//结果
int weight;//当前重量
int value;//当前值
int possibility;//还可以取的可能值
int depth;//树的深度,用于判断是否到叶结点了
def knapspack:
capacity=5
value=[6,10,12]
weight=[1,2,3]
maxValue=-1;//记录最大值
result=[];
inittialNode=new Node();
initialNode.possibility=sum(value);//初始化可能值
list<Node> temp=new ArrayList<>()
temp.add(initialNode);//初始化活结点
while isNotEmpty(temp):
node=temp.remove(0);
if maxValue<node.value://更新最优解
maxValue=node.value;
result=node.result;
if temp.size()>0:
maxPossibility=node.value+possibility;//最大的可能值
// 判断界限值,如果满足条件,则进行剪枝
if maxPossibility < temp集合中任意一个结点的value值:
node=null;
continue;//剪枝
depth=node.depth+1;//进行下一层的遍历
//判断是否已经到达叶子结点:
if depth < value.length:
//更新左孩子值
node.possibility=node.possibility-value[depth];
node.depth=depth;
temp.add(node);
//判断右孩子的值是否满足约束条件,如果不满足条件,则进行剪枝
if node.weight+weight[depth]<capacity:
Node rightNode=new Node();
rightNode.possibility=node.possibility-value[depth];
rightNode.weight=node.weight+weight[depth];
rightNode.depth=depth;
rightNode.result=node.result;
//将当前值放到结果值中,以便于记录结果值
rightNode.result[depth]=value[depth];
//记录当前的结果值的和
rightNode.value=sum(rightNode.result);
temp.add(rightNode);
//这里可以对temp进行排序,可以用possibility进行排序,哪个结点更有可能产生最优解,哪个结点放到前面
sortByPossibility(temp);
return maxValue and result;
上述伪代码中标红的地方是计算过程中动态界限值产生的地方,而且上述代码中只会返回一个最优解。
分支界限算是回溯方法的一个增强版,但是其难点在于界限值的计算,它是根据当前已有的值和后续可能取值的最大和与当前其他节点的值进行计算,如果某个节点的可计算的可行解比当前其他节点的值要小,那就没有扩展的必要了,需要进行剪枝了;还有,如果当前节点不满足约束条件,也需要直接剪枝。分支界限的经典之处就是预判某个节点是否具有展开的价值,如果没有,则直接排除掉这个节点。所以它的遍历要比回溯法少,如果约束条件和界限值收敛的好的话,则其效果更好。