问题描述
某售货员要到若干城市去推销商品,已知各城市之间的路线(或旅费)。要选定一条从驻地出发,经过每个城市一遍,最后回到驻地的路线,使总的路程(或总旅费)最小。本文只考虑4个城市的情况,下面这个带权图即为问题的转化。
由于只有4个城市,如果规定售货员总是从城市1出发,那么依据排列组合可以得到6种不同的旅行方案,比如12341、13241等等。在这些排列组合基础上可以很容易绘制出一棵排列树,也是该问题的解空间树,排列树如下:
根据解空间树可以得到一些有用的信息:
- 该树的深度为5
- 两个节点之间路径上的标识数组代表所走城市
- 树上没有体现出回到城市1的路径,但实际上计算要考虑这段路程
下面利用回溯法和分支限界法分别求解该问题,对于回溯法会给出相应的Python实现代码,对于分支限界法会采用优先队列式介绍求该问题最优解的步骤。
回溯法
回溯法有点类似于暴力枚举的搜索过程,回溯法的基本思想是按照深度优先搜索的策略,从根节点出发深度搜索解空间树,当搜索到某一节点时,如果该节点可能包含问题的解,则继续向下搜索;反之回溯到其祖先节点,尝试其他路径搜索。
如果问题只要求求得一个可行解,那么搜索到问题的一个解即可结束;如果问题所求是最优解,那么需要搜索整个解空间树,得到所有解之后择最优作为问题的解,或者在搜索到叶子节点之前已经能确定该路径不为最优解时就可以进行剪枝,节省搜索时间,那么本文的旅行售货员问题属于后者。
解空间树除了叶子节点之外的每一个节点都可能拥有多个子节点,而回溯法每次搜索只能搜索一条路径,这也是与分支限界法的区别,并且这能更好的帮助理解下面图片的讲解。
下图中的F和L与原解空间树有些偏差,但对步骤和结果没有影响
首先已知售货员于城市1起步,第一步可以画下任意一条可行解,不受任何约束,上图的路径为12341,算上F->A路程总和为59,这也是当前的最优解。由于L只有F一个孩子节点,那么只能回溯至C节点,因为C还有另一条路径可供搜索。
可以看到此时搜索的路径为12431,算上M->A路程总和为66,此时路程总和是大于最优解的,所以最优解仍为59,而节点C所有孩子节点搜索结束,那么需要向上回溯到C的祖先节点B。
此时的路径为13241,并且路程总和为25,当前解是远远小于之前所得最优解,所以需要更新最优解为25,然后回溯到节点D,原理同上。
可以看到这张图中的路径比之前几张图多了一个X(叉),也是这一步实现了对数的剪枝,依据呢?因为城市1到城市3再到城市4的路程总和为26,无论怎么走也不会优于当前的最优解,所以Duck不必继续向下搜索,直接回溯即可。
解空间树剩下两条路径可以按照上述方法继续搜索,当遍历所有可行路径之后,最后得到的最优解best_length就为该问题的全局最优解,对应的路径即为全局最优路径。
下面介绍如何用代码实现回溯法搜索问题最优解,这里会省略一些定义变量之类的简单代码,只介绍关键部分,完整代码在文末会给出获取方式。
首先需要做的一定是对地图转化,对于这个图我们可以得到其对应的邻接矩阵如下:
$$ \begin{pmatrix} -1 & 30 & 6 & 4 \\ 30 & -1 &5 & 10 \\ 6 & 5 & -1 & 20 \\ 4 & 10 & 20 & -1 \\ \end{pmatrix} $$
City_Graph = [[0, 0, 0, 0, 0],
[0,-1, 30, 6, 4],
[0,30, -1, 5, 10],
[0,6, 5, -1, 20],
[0,4, 10, 20, -1]]
path_num = len(City_Graph)
isin = [0]*(path_num) #用来检测该节点是否已经添加到路径中
path = [0]*(path_num) #用于储存路径
best_path = [0]*(path_num) #用于储存最优路径
best_length = 100000 #初始化最优路径的路程总和
那么可以一个二维数组存储该地图,可以看到这与图所对应的邻接矩阵有些不同,数组的第一行与第一列都为0,为什么这么做呢?举个例子,例如城市1到城市2的路程为30,在邻接矩阵中对应(1,2),此时在二维数组对应的索引也为(1,2),填0就是为了统一索引,并且这里城市本身位置填入-1,表示不存在路径。
整个程序可由一个条件分割成两个部分,向下搜索还是向上回溯,如果先不计剪枝操作,那么搜索到解空间树的叶子节点就是向上回溯的标志,中间节点一般则是做向下搜索操作。
而现在的问题就是这个条件是什么,对于一个由1、2、3...n构成的解空间树,它是由[1:n]所有排列构成,如果我们暂定搜索树的深度为t的话,会有下面两种情况。
- 当t<=n时,即当前扩展节点位于第t-1层:如果在t-1层的扩展节点与在t层的扩展节点之间有路径存在,并且[1:t]对应路径的路程总和小于最优解,则向下继续搜索,否则进行剪枝。
- 当t>n时,即当前节点为叶子节点:整个路径只差回到城市1,这时需要判断是否存在这个回路,如果存在且得到的路程总和优于当前最优解,那么需要更新当前最优解。
这两种情况只是这么说可能会有一些抽象,可以结合上面的解空间树加以理解。可以看到t>n就是判断是否需要回溯的条件,也就是本文条件下的t>path_num-1。
if t > path_num-1: # 搜索至叶子节点
for i in range(1, path_num): # 输出当前路径
ThePath += str(path[i])
ThePath += '->'
temp = int(ThePath.split('->')[3])
if City_Graph[temp][1] > 0: # 判断是否存在回路
ThePath += '1' # 路径加上回路回到城市1
print("当前路径:%s" % ThePath)
back_length = now_length+City_Graph[temp][1] # 回路路程也需要相加
print("当前路径总和:%d" % back_length)
if back_length < best_length: #更新最优解
for i in range(1, path_num):
best_path[i] = path[i]
best_length = back_length
BestPath = ThePath # 更新最优路径
return #返回
如果t>path_num-1条件成立,就说明解空间树其中一条路径搜索完成,所以需要输出路径和对应的路程总和。但在输出之前,需要判断在地图中这条路径是否存在回到城市1的路径,若存在回路,就输出当前搜索路径及其对应的路程总和。如果当前路程总和优于当前最优解,则需要做更新最优解和最优路径的操作。
else:
for j in range(1, path_num):
if City_Graph[path[t - 1]][j] != -1 and (not isin[j]): #两城市间存在路径并且还未走过
isin[j] = 1 #表示该城市已经来过
path[t] = j #将该城市存至path中
now_length = now_length + City_Graph[path[t - 1]][j] #加路径对应路程和
TSP(t+1)
isin[j] = 0
path[t] = 0
now_length = now_length - City_Graph[path[t - 1]][j]
如果条件不成立,则说明正在搜索树的中间节点。对于每一条正在搜索的路径,如果两城市之间存在路径,并且还未走过该路径,则需要将其填入路径path中,并将这个路径对应权值需要加至当前路程总和now_length中,然后继续向下搜索。
这部分代码最后三行的作用是在进行回溯操作时,需要将相应节点的数据还原。比如路径12341,回溯搜索对应12431,在回溯之前需要将3和4对应数据还原才方便加入4和3对应数据。
可以看到上面代码中的循环都是从1开始,这也是为了与上文二维数组中的地图索引相对应。最后运行程序可以得到每条可行路径及其对应路程总和。
在程序中有设置最优解及最优路径参数,所以可以直接调用并输出:
分支限界法
分支限界法是利用广度优先搜索的策略或者以最小耗费(最大效益)优先的方式搜索问题的解空间树,对于解空间树中的活节点只有一次机会成为拓展节点,活节点一旦成为扩展节点,那么将一次性产生其所有儿子节点。
对于优先队列式的分支限界法,这些儿子节点中,不可行解或者一定不能成为最优解的儿子节点会被舍弃,其余儿子节点将会按照优先级依次存入一个活节点表(队列),此后会挑出活节点表优先级最高的节点作为下次扩展节点,重复此过程,直至找到问题的最优解。
下面讲解图片中会有两个新的简写变量,首先声明一下对应含义:
- nl:now_length,当前所走路程长度。
- Lb:lower bound,所有可行解的下界,即每一个节点出边之和。
左上角为图的邻接矩阵,右上角为活节点表,当扩展节点为B时,他需要一次性产生自己的所有子节点。可以看到B出的Lb为18,这个18怎么得到的呢?就是每一行或者每一列除-1之外最小权值相加,即4+5+5+4=18。下界通常不是一个可行解,但是它为可行解提供了一个界定,方便找出最小消耗的那条路径(最优解)。
对于B的子节点,共有3条路径,可以看到C、D、E对应的Lb都为14,这是因为从城市1到另外3个城市的路径已经确定,所以下界也应该减去4。这就好比最初的下界是我们猜测的4个最短路径相加,但是现在第一个路径的实际值已经确定了,那么下界就应该减去第一个路径的猜测值,此时的下界就是后三个路径猜测值的总和。
此时应该将C、D、E三个节点按照优先级存入活节点表,路程总和越小则优先级越高,当前的路程总和=nl+Lb。根据计算节点E的优先级最高,所以E也是下次的扩展节点。
对于扩展节点E,路径1和4的值已经确定了,所以子节点的Lb=18-4-4=10,计算两个子节点当前路程总和,并按照优先级插入活节点表,D成为下次的扩展节点。
对于扩展节点D,路径1和3的值已经确定了,所以子节点的Lb=18-4-5=9,按照上述方法更新活节点表,H成为下次的扩展节点。
因为H是叶子节点的父节点,所以需要判断地图中是否有从4回到1的回路,如果存在回路,那么整个路径组合就是该问题的一个可行解,并且它也是当前的最优解,但并不一定是全局的最优解。
因为此时K节点对应的路程总和为24,是要优于当前最优解25的,所以还需将节点N插入至活节点表中,按照上述方法,让K成为扩展节点继续向下搜索,最后得到的路程总和也为25,并不优于当前最优解。那么按照活节点表的顺序,节点N再次成为扩展节点,如下图:
自此活节点表中没有节点优先级高于N,所以节点N对应的路径13241成为最优路径,对应的路程总和25成为问题的最优解。
总结
回溯法和分支限界法都是在问题的解空间树上搜索问题解的算法。回溯法主要利用深度优先搜索策略,通常目标是找到问题的所有可行解;分支限界法主要利用广度优先搜索策略,通常目标是尽快找到一个满足问题约束条件的解,所以对于TSP这类求最优解的问题,分支限界法比回溯法更加适合。
公众号【奶糖猫】后台回复“TSP”可获取文中提及的源码供参考