旅行是许多人的热爱,但是在规划一个完美的假期时,找到最经济的路线常常是一个挑战。这里就需要引入一个著名的优化问题——旅行商问题。本文将介绍TSP的基础知识,并使用MTZ消除子环方法优化一个简单的TSP问题的示例。
旅行商问题简介
TSP,全称为Traveling Salesman Problem,即旅行商问题。它是一个经典的组合优化问题,其目标是找到一条路径,使得旅行商能够访问一组城市,并且总路程最短。
在TSP中,假设有一个旅行商需要访问N个城市,每个城市之间的距离已知。问题的目标是找到一条路径,使得旅行商能够从一个城市出发,最多只能经过所有城市一次(出发城市除外),最终回到出发城市,并且使得总路程最短。
TSP是一个非常重要且具有挑战性的问题,它在实际应用中有广泛的应用,如物流、电路板布线、旅游规划等。然而,由于TSP是一个NP-hard问题,对于大规模的问题,求解最优解是非常困难的,需要使用启发式算法、确切算法等来求解。
解决方案
下面是使用整数线性规划方法的简单描述:
- 定义问题:将每个城市视为TSP中的节点,城市之间的距离视为边的权重。
- 建立数学模型:使用二进制变量定义节点之间的连接关系,例如x[i,j]表示是否从节点i到节点j的路径。使用p[i,j]表示节点i到节点j的距离。
- 构建约束条件:
- 每个节点只能进入一次:对于每个节点i,约束条件为 ∑ x[i,j] = 1。
- 每个节点只能离开一次:对于每个节点i,约束条件为 ∑ x[j,i] = 1。
- 避免子环路:对于每个节点子集S,确保其节点之间的连接关系不构成环路。这可以通过添加额外的约束条件来实现,例如割平面约束或子环路消除约束(本文使用的方法)。
- 定义目标函数:目标是最小化总路程。目标函数可以定义为 Minimize ∑(i,j) p[i,j] * x[i,j]。
- 使用优化求解器:使用适合您编程语言的优化库或工具(本文使用的是MindOpt 优化求解器和MindOpt APl 建模语言),将模型加载到求解器中,并运行求解器来找到最优解。
一个问题示例
问题描述和数据搜集
小明目前在做一份毕业旅行的规划。打算从城市1出发,分别去如下几个城市:2,3,4,5,6,7,8,9,10,每个城市去一次,然后再回到城市1。
由于经费有限,小明在制定节省大旅游计划,比如住宿便宜青旅、蹭同学寝室、旅游景点门票绕开高峰期,计划里可调整的主要是路费和耗时。
为了简化问题,小明以高铁费用为参考,来计算花费最少的旅行路径。
在12306软件上查询了各个城市之间通行的路费如下:(并登记在一个表格文件 railway_cost.csv
里。票价12306会有折扣,因此会出现不一致情况,按感觉的中位数随机选)
上海 |
杭州 |
黄山 |
苏州 |
南京 |
千岛湖 |
景德镇 |
金华 |
宁波 |
舟山 |
|
上海 |
0 |
73 |
190 |
38 |
135 |
143 |
217 |
147 |
116 |
无 |
杭州 |
- |
0 |
110 |
111 |
112 |
62 |
181 |
74 |
71 |
无 |
黄山 |
- |
- |
0 |
230 |
240 |
50 |
71 |
176.5 |
无 |
无 |
苏州 |
- |
- |
- |
0 |
94 |
182 |
无 |
190 |
182 |
无 |
南京 |
- |
- |
- |
- |
0 |
190 |
325 |
180 |
201 |
无 |
千岛湖 |
- |
- |
- |
- |
- |
0 |
119 |
128 |
无 |
无 |
景德镇 |
- |
- |
- |
- |
- |
- |
0 |
146 |
无 |
无 |
金华 |
- |
- |
- |
- |
- |
- |
- |
0 |
130 |
无 |
宁波 |
- |
- |
- |
- |
- |
- |
- |
- |
0 |
20 |
舟山 |
- |
- |
- |
- |
- |
- |
- |
- |
- |
0 |
查询数据发现,舟山市一个特殊的很多海岛的城市,与外界高铁不方便,最合理的是宁波去再回宁波。
- 这样会产生一个矛盾:每个城市去一次,对于宁波因为路过会去两次,与去一次冲突。
- 对于这个问题,解决策略是:舟山必然和宁波相连地去游玩,去掉这个局部小环路。
- 因此,在建立数学模型的时候,可以将舟山先省略,先排其他的路程。
数学规划建模
下面我们采用数学规划的方法来建模这个旅行商问题。
首先:定义集合,描述数据,方便后面描述时候索引用。
- 城市集合
- 得到两两城市之间有方向的边(线段向量)的集合 ,用 来表示, 不等于
高铁票价作为参数:
- 两地点之间车票价格
然后:我们定义变量来解决这个问题。
设置变量是数学建模的关键一步,要思考我们可以操控改变的决策,能怎么用数学量化符号来表示。
比如这里我们想要找路径,那么直接可以设置0-1变量记录两两城市之间的路径是否有生效。即
- 0-1的二进制决策变量 。如果经过这个路线,则 ;如果没有经过这个点,则 。
然后:根据设置的变量,我们来描述结果评估好坏,比如本TSP问题我们用花费来评价,
- 则我们设置目标函数:最小化总旅行成本:
然后:开始设置约束,来限定变量的取值逻辑,使得符合要求又能找到可行解。
这里比较容易想到的约束是:
- 保证每个地点有两条路线相连接,其中一条为进入路线,一条为出去路线:
- 对于任意城市 , ,且
子环问题
这里我们思考这个问题,或者编程尝试运行后会发现:仅有上面这条约束,会导致多个子圈(子回路)的情况,即算出来多个环,如下图示意。我们需要加限制是的只有一个圈环。这个过程称为消除子回路(subtour elimination)。(用户可自行尝试修改后文代码仅有这两条约束时运行结果,了解子环。)
消除子环的约束形式有多种方式,比如MTZ模型(Miller-Tucker-Zemlin subtour elimination constraints)和DFJ模型(Dantzig-Fulkerson-Johnson subtour elimination constraints)。
本文主要介绍MTZ的方法,它的设计很巧妙。
MTZ模型:
首先,我们引入自由变量 来代表i是路径中的第几个点。
然后引入一个 Big-M,大M法思路,引入一个足够大的数,联合 0-1变量 来构造一个不等式,如下:
- ,其中i
- 当 时,表示i->j是路径中的一个有向线段,此时 与 是路径上相继的两个点,因此 ,上述约束成立;
- 当 ,右边的值 足够大,上述公式也成立。
为了更好地估计,这里我们的Big-M取值最好接近理论上界:
- 由于u_i和u_j的差异最大是城市数量 减去1,即 ,
- 即不等式左边是 , 可取Big-M为
。 - 再将变量都挪到等式左边,整理得到
此外,需要注意的是,此
记录的是 个城市的顺序。去环后是个单向顺序。而实际的我们本例中的TSP问题,是从起点城市 出发后,最后需要回到起点 。
- 因此,除了城市集合C,我们还另外拓了个城市,序号 ,此序号就是起点城市,其路费的费用与起点城市相同。
- 与之对应的,前面的进、出城市只有一条的约束,也会因为这个增加的节点,有变化:
- 城市间线段的结合E增加了N个城市到终点城市的向量
- 1-N 的城市都只有一条出去的线路
- 2-N+1的城市都只有一条回来的线路
整理后得到数学公式如下:
选择工具编程和清理数据
这里我们采用达摩院自研的代数建模语言 MindOpt APL(MAPL)进行编程。方便码代码和更换求解器测试。
为了编程的时候方便,也为了数据能统一表达,
- 我们将上述的城市进行编号1-9。整理在
data/city_list.csv
。 - 对于无高铁的,临时用大数值(10000)来代替其价格防止被选,并且将之前的半三角的价格,转置粘贴再相加后,得到价格如文件
data/price.csv
。
- MAPL目前对于文件形式的二维表格的读入理解支持不好,比较适合复制进代码直接改,或者拉成k-v对的长表更利于读取(文件
data/price_longlist.csv
)。下文代码给了两种示例。
最后撰写MAPL的代码如下:
clear model; option modelname TSP_01; #定义存储文件名 # ----------建模--------Start---- # TSP_01.mapl print "导入数据和参数-------"; ## 导入数据------------ # 城市集合--- param fileDir = "data/"; set City = {read fileDir+"city_list.csv" as "<1n>" skip 1}; # 读取城市序号 param CityName[City] = read fileDir+"city_list.csv" as "<1n> 2s" skip 1; #读取城市名称 param cityNum = card(City); # 路费参数--- # 输入方式1 param price[City*City] = read fileDir+"price_longlist.csv" as "<1n,2n> 5n" skip 1; #读取高铁费用 #print price; #输入方式2 #param price[City*City] = # |1, 2, 3, 4, 5, 6, 7, 8, 9| #|1| 0, 73, 190, 38, 135, 143, 217, 147, 116| #|2| 73, 0, 110, 111, 112, 62, 181, 74, 71| #|3|190, 110, 0, 230, 240, 50, 71, 176.5, 10000| #|4| 38, 111, 230, 0, 94, 182, 10000, 190, 182| #|5|135, 112, 240, 94, 0, 190, 325, 180, 201| #|6|143, 62, 50, 182, 190, 0, 119, 128, 10000| #|7|217, 181, 71, 10000, 325, 119, 0, 146, 10000| #|8|147, 74, 176.5, 190, 180, 128, 146, 0, 130| #|9|116, 71, 10000, 182, 201, 10000, 10000, 130, 0|; #print price; # 设定起始城市---- param startCity = 1; #选择对应数字序号的城市为起始城市(startCity),此处可根据需要修改成自己的出发城市 # 其他的城市 set City_internal = City - {startCity}; # 引入虚拟城市,实际就是起始城市,对虚拟城市endCity给予ID赋值, # 比如路径 1->3->4->2->1,最后一个1是虚拟引入的 # 注意:引入虚拟城市,是为了消除TSP中所有的环,否则引入MTZ约束后,问题会不可行。(因为MTZ约束实际上不允许环存在,这也包括了TSP问题的可行解) param endCity = cityNum + 1; #生成边的集合---- # 城市之间的两两互通(有方向) + 除了起始城市外的所有城市去结束城市 set Edge = {<i,j> in City*City with i!=j:<i,j>} + {k in City_internal:<k, endCity>}; #print Edge; print "开始建模-------"; ## 定义变量----------- var u[City] >= 0 ; # MTZ模型需要,代表各个城市是路径中的第几个点 var x[Edge] binary; #这个边是否有 ## 目标函数------------ minimize totalCost : sum{<i,j> in Edge with j != endCity} price[i,j] * x[i,j] + sum{k in City_internal} price[k,startCity]*x[k,endCity]; # 注:结束节点endCity的路费,用startCity的price来替 ## 约束函数------------ # 每个城市离开1次,除结束城市外 subto leaveCityEquals1_: forall {c in City} sum{ <c,j> in Edge} x[c,j] == 1; # 每个城市进入1次,除起始城市外 subto enterCityEquals1_: forall {c in (City_internal + {endCity})} sum{ <i,c> in Edge} x[i,c] == 1; # 消除子环 # MTZ模型: # 引入自由变量u_i>= 0来代表i是路径中的第几个点。 # 然后引入一个Big-M,构造 (u_i - u_j) +1 <= M (1- x_ij): # - 当x_ij=1时,表示i->j是路径中的一个有向线段,此时u_i与u_j是路径上相继的两个点,因此 u_i - u_j = -1,上述约束成立; # - 当x_ij =0,右边的值足够大,上述公式也成立。 # 为了更好地估计,这里我们的Big-M取值最好接近理论上界。 由于u_i和u_j的差异最大是cityNum-1,可取Big-M为cityNum。 # 整理得到 (u_i - u_j) + cityNum * x_ij <= cityNum -1 subto MTZ_: forall {<i,j> in Edge with j!=endCity } u[i] - u[j] + cityNum * x[i,j] <= cityNum-1; print "开始求解-------"; ## 求解----- option solver mindopt; # (可选)指定求解用的求解器,默认是MindOpt option mindopt_options 'print=0'; #设置求解器输出级别,减少过程打印 #option mindopt_options 'iisfind=1'; # 设置IIS solve;# 求解 ## 打印约束--------在调试时候求解不出时候打印 #print "IIS info:"; # 如果想避免人工列举所有约束,也可以使用MAPL的内置关键字_con与_ncons来一次性 遍历全部约束,代码如下: #for i in 1.._ncons with _con[i].iis > 0: print "{}{}: iisType={}" % _con[i].name, _con[i].index, _con[i].iis; print "打印结果-------"; #display; #结果太多,省略,需要打印的时候打印 forall {<i,j> in Edge with x[i,j]>=0.5 and j != endCity} print "{}号城市{} --> {}号城市{}:{},价格{}"%i,CityName[i],j,CityName[j],x[i,j],price[i,j]; #用起始城市替换结束城市名称 forall {<i,endCity> in Edge with x[i,endCity]>=0.5 } print "{}号城市{} --> {}号城市{}:{},价格{}" % i, CityName[i], startCity, CityName[startCity], x[i,endCity], price[i,startCity]; print "整理后访问的路径是:---------"; forall { y in 1..9 } forall {<i> in City with u[i] == y-1 } print "第{}个城市是{}号{}" % y, i, if CityName[i] == "宁波" then CityName[i] + ", 之后往返舟山" else CityName[i] end; print "最后回到起始城市是{}号{}"%endCity,CityName[startCity]; forall {<i,j> in Edge with x[i,j]>=0.5 and j != endCity} print "{}->{}"% (if CityName[i] == "宁波" then CityName[i] + "->舟山 舟山->宁波 宁波" else CityName[i] end), CityName[j]; forall {<i,endCity> in Edge with x[i,endCity]>=0.5 } print "{}->{}"% (if CityName[i] == "宁波" then CityName[i] + "->舟山 舟山->宁波 宁波" else CityName[i] end), CityName[startCity]; print "总花费是 = {}"% (sum{<i,j> in Edge with j != endCity} price[i,j] * x[i,j] + sum{k in City_internal} price[k,startCity]*x[k,endCity]) + 2 * 20; # 验证MTZ约束 #forall {<i,j> in City * City with i < j and j >= 2} # print "u[{}]_{} - u[{}]_{} + (cityNum -1) * x[i,j]_{} = {}" % i,u[i],j,u[j],x[i,j],(u[i] - u[j] + (cityNum -1) * x[i,j]);
运行上述代码结果如下:
导入数据和参数------- 开始建模------- 开始求解------- Running mindoptampl wantsol=1 print=0 MindOpt Version 1.0.1 (Build date: 20231114) Copyright (c) 2020-2023 Alibaba Cloud. Start license validation (current time : 04-FEB-2024 15:05:26). License validation terminated. Time : 0.005s OPTIMAL; objective 819.00 Completed. 打印结果------- 1号城市上海 --> 4号城市苏州:1,价格38 2号城市杭州 --> 6号城市千岛湖:1,价格62 3号城市黄山 --> 7号城市景德镇:1,价格71 4号城市苏州 --> 5号城市南京:1,价格94 5号城市南京 --> 2号城市杭州:1,价格112 6号城市千岛湖 --> 3号城市黄山:1,价格50 7号城市景德镇 --> 8号城市金华:1,价格146 8号城市金华 --> 9号城市宁波:1,价格130 9号城市宁波 --> 1号城市上海:1,价格116 整理后访问的路径是:--------- 第1个城市是1号上海 第2个城市是4号苏州 第3个城市是5号南京 第4个城市是2号杭州 第5个城市是6号千岛湖 第6个城市是3号黄山 第7个城市是7号景德镇 第8个城市是8号金华 第9个城市是9号宁波, 之后往返舟山 最后回到起始城市是10号上海 上海->苏州 杭州->千岛湖 黄山->景德镇 苏州->南京 南京->杭州 千岛湖->黄山 景德镇->金华 金华->宁波 宁波->舟山 舟山->宁波 宁波->上海 总花费是 = 859
最后如果大家想基于这个问题进行修改,做更多的尝试,可以扫描下方的二维码或者点击这个链接,进入MindOpt Studio 云上建模求解平台中获取(无需下载软件)。