旅行商问题就是找到经过所有站点的最短闭合路径,如下图为在美国地图框架内产生的200个旅行站点,而旅行商要找到一条最短路径将200个站点都旅行到。这也可以借助二元整数规划求解。
首先,例子的计算规模很大,200个站点,相应就有19900个二元变量,对如此多的变量同时进行优化,计算量可想而知,所以求解中使用了稀疏矩阵表达约束,也就是说Matlab优化函数是
接受稀疏矩阵形式的约束条件的;
%% 旅行商问题,Travelling Salesman Problem
% 这个例子展示如何使用二元整数规划求解经典的旅行商问题。这个问题就是找到经过所有
% 站点的最短闭合路径。这个例子使用了200个站点,但也可以通过改变nStops来得到不同
% 运算量的求解过程。
% 对于初始情况,求解结果中存在子旅途,即结果没有给出一条经过所有站点的连续路线,
% 而是有一些独立的环存在。对此,可以通过迭代加约束、再优化的方法实现子旅途的去除。
clc
clear
close all
%% 绘制地图和站点
nStops = 200; % 站点个数,可以自己调整,不过求解复杂度与其平方成正比
stopsLon = zeros(nStops,1); % 分配站点的x坐标
stopsLat = stopsLon; % 分配y坐标
% 在代表美国的粗糙多边形内产生随机站点
load('usborder.mat','x','y','xx','yy'); % 加载数据,其中(x,y)为美国的精细轮廓,(xx,yy)为粗糙轮廓
rng(3,'twister') % 初始化随机序列,保证每次运行的重复性
n = 1;
while (n <= nStops)
xp = rand*1.5;
yp = rand;
% 是否在(xx,yy)的粗糙多边形内
if inpolygon(xp,yp,xx,yy)
stopsLon(n) = xp;
stopsLat(n) = yp;
n = n+1;
end
end
figure
plot(x,y,'Color','red'); % 多边形边界
hold on
plot(stopsLon,stopsLat,'*b') % 站点
hold off
pause(1)
%% 问题建模
% 将旅行商问题建模为整数线性规划问题:
% 1. 得到所有可能的旅行,即所有站点对;
% 2. 计算各旅行的距离;
% 3. 代价函数就是最小化旅途距离;
% 4. 决定变量是二元的,即如果旅行出现在旅途中,则为1,否则为0;
% 5. 为了保证旅途包含所有站点,可以设定如下线性约束:每个站点出现在两个旅行中,即达到和离开。
%% 计算点之间的距离
% 如果有200个站点,就有19900个旅行,即19900个二元变量。
idxs = nchoosek(1:nStops,2); % 得到所有组合
%% 计算旅行的距离
% 假设地球是平的,这样可以快速计算
dist = hypot(stopsLat(idxs(:,1)) - stopsLat(idxs(:,2)), ...
stopsLon(idxs(:,1)) - stopsLon(idxs(:,2)));
lendist = length(dist);
%% 小结1
% 基于上面定义的dist向量,旅途长度为dist'*x,其中x为二元解向量。这正是我们要最小化的代价函数。
%% 等式约束
% 问题有两类等式约束,第一个保证共有200个旅行,第二个保证每个站点必须有2个旅行与其相关。
% 第一个可以用Aeq*x = beq的形式指定。
Aeq = spones(1:length(idxs)); % 全1向量,即将所有x相加
beq = nStops;
% 第二个通过扩展上面的Aeq来完成
Aeq = [Aeq;spalloc(nStops,length(idxs),nStops*(nStops-1))]; % 分配一个稀疏矩阵
for ii = 1:nStops
whichIdxs = (idxs == ii); % 找到包含站点ii的所有旅行
whichIdxs = sparse(sum(whichIdxs,2)); % 只要有任何一端为ii,即包括此旅行
Aeq(ii+1,:) = whichIdxs'; % 加入Aeq矩阵
end
beq = [beq; 2*ones(nStops,1)];
%% 二元边界
% 所有变量都是二元的,所以设置intcon保证所有变量为整数,同时设置下边界为0,上边界为1
intcon = 1:lendist;
lb = zeros(lendist,1);
ub = ones(lendist,1);
%% 使用intlinprog完成最优化
opts = optimoptions('intlinprog','Display','off');
tic
[xopt,costopt,exitflag,output] = intlinprog(dist,intcon,[],[],Aeq,beq,lb,ub,opts);
toc
%% 绘制结果
hold on
lh = zeros(nStops,1); % 用来保存线的句柄
lh = updateSalesmanPlot(lh,xopt,idxs,stopsLon,stopsLat);
title('带有子旅途的结果');
%% 小结2
% 从绘制结果可以看到,图中存在许多子旅途。到目前还没有设置约束阻止这些子旅途的出现。
% 而为了阻止它们的出现,需要设置难以想象的大量不等式约束。
%% 子旅途约束
% 由于不可能一下添加所有子旅途约束,所以可以通过迭代的方式添加约束。
% 即在当前结果中发现子旅途时,则添加相应的不等式约束来阻止这些特定子旅途的出现。
% 通过这种方式,通过简单的几次迭代就可找到合适的旅途。
% 利用不等式约束消除子旅途,比如出现一个5点的子旅途,则有5条线将这些点连起来,
% 所以可以要求这五个点之间的线不多于4条。如果找到这五个点之间的所有线,然后限定解中最多出现4条这种线。
% 这样限定是合理的,因为如果有5个或更多线出现在结果中,则将必然出现子旅途(具有n个节点和边的图必然存在圈)。
% 函数detectSubtours通过分析求解结果返回向量元胞数组,每个向量包含了组成子旅途的站点。
tours = detectSubtours(xopt,idxs);
numtours = length(tours); % 子旅途的个数
fprintf('子旅途的数目为: %d\n', numtours);
%% 加入线性不等式约束消除子旅途,然后重复优化过程,直到只得到一个子旅途为止
A = spalloc(0,lendist,0); % 分配稀疏线性不等式约束矩阵
b = [];
rowIdx = 0;
while numtours > 1 % 循环直到只有一个子旅途
% 添加子旅途约束
b = [b;zeros(numtours,1)];
A = [A;spalloc(numtours,lendist,nStops)]; % 初始分配的稀疏空间不那么重要,这里设为nStops,少了会自动分配空间
for ii = 1:numtours % 添加numtours个不等式约束
rowIdx = rowIdx+1; % 指向约束的指针
subTourIdx = tours{ii}; % 提取当前子旅途
% 找到与当前子旅途关联的所有变量,然后添加不等式约束来阻止所有途径这些站点的子旅途
variations = nchoosek(1:length(subTourIdx),2);
for jj = 1:length(variations)
whichVar = (sum(idxs==subTourIdx(variations(jj,1)),2)) & ...
(sum(idxs==subTourIdx(variations(jj,2)),2));
A(rowIdx,whichVar) = 1;
end
b(rowIdx) = length(subTourIdx)-1; % 子旅途站点减1为b的值
end
% 再次尝试优化
tic
[xopt,costopt,exitflag,output] = intlinprog(dist,intcon,A,b,Aeq,beq,lb,ub,opts);
toc
% 显示结果
lh = updateSalesmanPlot(lh,xopt,idxs,stopsLon,stopsLat);
% 检测这次的子旅途数目?
tours = detectSubtours(xopt,idxs);
numtours = length(tours);
fprintf('子旅途的数目: %d\n',numtours);
end
title('除去子旅途的结果');
hold off
%% 求解效果
% 结果给出了一个可行旅途,但它是否是最小代价的旅途呢?一种检验方式就是检查输出变量output
output
% output中显示内部计算的上下限间隔为0,所以结果为最优解。
function lh = updateSalesmanPlot(lh,xopt,idxs,stopsLat,stopsLon)
% tsp_intlinprog例子的绘图函数
if ( lh ~= zeros(size(lh)) ) % 通过lh是否全零来判断是否第一次进入
set(lh,'Visible','off'); % 移除之前的线
end
% 求解结果中的旅行
segments = find(xopt);
% 形成绘制数据
Lat = zeros(3*length(segments),1);
Lon = zeros(3*length(segments),1);
for ii = 1:length(segments)
start = idxs(segments(ii),1);
stop = idxs(segments(ii),2);
% 数据之间用NaN分割,这样可以绘制分离的线段,不然需要一个一个绘制线段,产生很多句柄
Lat(3*ii-2:3*ii) = [stopsLat(start); stopsLat(stop); NaN];
Lon(3*ii-2:3*ii) = [stopsLon(start); stopsLon(stop); NaN];
end
lh = plot(Lat,Lon,'k:','LineWidth',2);
set(lh,'Visible','on');
drawnow;
function subTours = detectSubtours(x,idxs)
% 返回子旅途元胞数组,即图中的子圈
x = round(x); % 纠正不确切的整数
r = find(x);
substuff = idxs(r,:); % 旅行线的节点对
unvisited = ones(length(r),1); % 跟踪未访问的旅行
curr = 1; % 正在评价的子旅途
startour = find(unvisited,1); % 第一个未访问的旅行
while ~isempty(startour)
home = substuff(startour,1);
nextpt = substuff(startour,2);
visited = nextpt;
unvisited(startour) = 0;
while nextpt ~= home
% 找以nextpt为起点的旅行
[srow,scol] = find(substuff == nextpt);
% 确定相应旅行的节点
trow = srow(srow ~= startour);
scol = 3-scol(trow == srow); % 1变2,2变1
startour = trow;
nextpt = substuff(startour,scol); % 子旅途的下一节点位置
visited = [visited,nextpt]; % 将节点加入子旅途
unvisited(startour) = 0; % 更新访问过的位置
end
subTours{curr} = visited; % 保存找到的子旅途
curr = curr + 1;
startour = find(unvisited,1);
end
end