禁忌搜索算法是解TSP问题的常见方法,本人根据自己的经验总结了几点小贴士。
tip1、解的形式必须进行清洗
由于同一条路径(同一个解)因出发城市和行进方向不同,可以有2N种表示形式。为此强制对解进行清洗,一般只有一种表达形式,消除重复,防止禁忌表被重复数据占用。
比如:强制规定某城市为出发地,同时另一城市只允许在前半程,如果不满足,就通过清洗函数改变形式。
tip2、搜索初值或者邻域内的搜索顺序至少一项应当具有随机性
由于邻域本身是确定的,如果采用固定搜索顺序,可能形成每次运行都是同一顺序,则搜索无随机性。
对此有两种应对方式:1、搜索顺序在每次运行时随机打乱,2、搜索初值随机设定。优先推荐随机打乱搜索顺序。
tip3、邻域内的搜索覆盖率要适当
不同搜索邻域设置方式对搜索运算次数影响很大,当城市数量变大时,搜索邻域本身可能会急剧增大,极大浪费时间。
为此建议如果城市数量过多,搜索邻域内可以不必全覆盖搜索,可随机选择邻域内的少部分解进行搜索。但是搜索的覆盖率不能过低,本人经验是不低于10%
tip4、禁忌表变量要避免用numpy数组格式
由于python广播机制,两个numpy数组的比较,是按元素比较的。因此要判断解是否在禁忌表内,就会出错,虽然可以通过表达式实现正确的逻辑判断,但是运行效率极低。推荐采用list格式。
程序代码如下:
import numpy as np
distance_matrix=np.load(r'distance_matrix.npy')#读取城市矩阵,预先以np.array格式保存
#准备工作
#置初值
global city_num,tabu_list,tabu_length,best_route,best,best_route_this,best_this
city_num=len(distance_matrix)#城市数
max_time=((city_num-1)*(city_num-2))//2#理想状态下,连续搜索次数(终止条件)应当大于邻域空间,实际可以缩短。
tabu_length=int(max_time*.8)#禁忌表长度必须小于搜索次数(终止条件)。
tabu_list=list()
#解的清洗,消除重复,避免同解不同型式
def wash_route(route):
one_loc=np.argwhere(route==1)[0,0]
zero_loc=np.argwhere(route==0)[0,0]
if (one_loc-zero_loc)%city_num<city_num/2:
return np.append(route[zero_loc:],route[:zero_loc])
else:
return np.append(route[zero_loc::-1],route[:zero_loc:-1])
#计算一个解的总路程函数
def distance_total_func(route0):
return distance_matrix[route0[np.arange(city_num)-1],route0].sum()
#初始(最佳)路径及距离
best_route=wash_route(np.random.permutation(city_num))#初始(最佳)路径
#best_route=np.arange(city_num)
best=distance_total_func(best_route)#最短距离
route_core=best_route.copy()#以初始解为首次搜索的邻域中心
LOC=np.array([(loc1,loc2) for loc1 in range(1,city_num-1) for loc2 in range(loc1+1,city_num)])
i=0
while i<=max_time:
#历遍邻域,隐含:当邻域内不存在比邻域中心更优的解时结束搜索
best_this,best_route_this=999999,np.array([])
np.random.shuffle(LOC)#打乱邻域搜索顺序
step=10
for loc in LOC[::step]:#利用步长作为候选集占邻域的比例
route=route_core.copy()
route[loc[0]:loc[1]+1]=route_core[loc[1]:loc[0]-1:-1]#邻域:以中心路径上所有两点之间整段逆行形成的新路径为邻域
route=wash_route(route)#解必须进行清洗
if route.tolist() in tabu_list:#解在禁忌表内
pass
else:#解不在禁忌表内
distance_total=distance_total_func(route)#不在禁忌表中才计算路径,避免资源浪费
if distance_total<best_this:#优于本邻域内已知最佳结果
best_route_this,best_this=route.copy(),distance_total.copy()#取代本邻域最佳结果
if distance_total<best:#优于已知全局最佳结果
best_route,best=route.copy(),distance_total.copy()#取代历史最佳结果
print(r"%.2f"%best)
i=0#重新开始
break#每当发现新的全局更优解,立即移动,开始新邻域搜索
#更新禁忌表
if not(best_route_this.tolist() in tabu_list) and best!=best_this:
tabu_list.append(best_route_this.tolist())#写入禁忌表最末
if len(tabu_list)>=tabu_length:
tabu_list.pop(0)#超出禁忌表长度,删去首个解,先进先出原则。
route_core=best_route_this.copy()#更新搜索邻域中心
i+=1
print(r"%.2f"%best,(best_route).tolist())#输出最优解,及最短距离
print(best)
--------------2023.12.15补充-----------------
- 理想状况下,移动邻域的大小由城市数量和移动方式决定。如果是通过路径上两个城的变动,则邻域的空间大小为C(n-1,2)=(n-1)(n-2)/2
- 禁忌表的作用主要是为了避免陷入循环搜索,理想状态下其长度应当大于邻域大小。同时对全局已知最优解,直接不加禁忌,这样不用再设置特赦规则,即可保证禁忌表内所有的解最终能被“刑满”释放。相应的代价是搜索时间会较长。实际并无必要,只要不太短(暂无量化数据)即可。
- 以连续未发现更优解的迭代代数作为终止条件,连续未发现更优解代数必须大于禁忌表长度,推荐再增加余量。
- 理想情况下邻域内的每次搜索应当覆盖整个邻域,且每次的搜索顺序应当随机。如问题规模大,计算太大,则可以对邻域按比例抽样搜索,即:候选集。可以通过循环语句的步长来控制。
- 每当发现历史最优解就立即移动并重新搜索,还是等到候选集全部搜索完后再移动,各有优劣,前者更易跳出马鞍点,但后者搜索更深入。在前期直接移动利于搜索速度更快发现更好解,在后期后者能对邻域进行更彻底搜索,因为此时发现更好解的概率变小。
- 每当发现新的全局更好的解,虽然本身被禁忌,但是其邻域内更好的解容易优先被发现。
- 解决百余城规模的问题此方法尚能胜任,但是规模再大,耗时将大幅上升,想找到最优解很困难。如果只是要一个满意解,还是有可能的。
- 动态调整搜索参数,因为随着搜索的进行,发现更好解的概率也在逐步减少。因此想要获得更优的解,必然要增大搜索范围,建议采用加密邻域内搜索密度的方式来实现。
- 是否要人为给出一个较好解,取决于解决问题的目标,是追求迫近最优解,还是只要求一个很好的满意解。如果只是单纯地为了减少前期迭代进程,意义不大,因为前期发现新的更好解的概率大,所以搜索效率很高。但是如果是为了要提高搜索精度,找到更迫近最优的解,那么可以尝试。建议通过多次迭代,将之前的最优解,作为重新运行的初始解(与增加迭代代数的区别在于禁忌表重置了)。