旅行商问题(Travelling Salesman Problem,即 TSP 问题)是一个经典的算法优化问题,它的描述是:一位旅行商人需要辗转若干个城市卖东西,每个城市只去一次,最终需要回到出发的城市,问如何规划路线,使得总旅程最短?
科幻小说《三体》的第二部里,三体人的水滴在太空中攻击地球舰队那一段,就有类似问题的描写:
太空中的无情杀戮在继续,随着舰群间距的拉大,水滴迅速加速,很快把自己的速度增加了一倍,达到60公里/秒。在不间断的攻击中,水滴显示了它冷酷而精确的智慧。在一定的区域内,它完美地解决了邮差问题,攻击路线几乎不重复。在目标位置不断移动的情况下做到这一点,需要全方位的精确测量和复杂的计算,而这些,水滴都在高速运动中不动声色地完成了。但有时,它也会从一个区域专心致志的屠杀中突然离开,奔向舰群的边缘,迅速消灭已经脱离总舰群的一些战舰,在这样做的同时,会把舰群向这个方向逃离的趋势遏止住。……
大刘在故事中写的是邮差问题,与旅行商问题略有差异,前者有若干固定的路线(某些点之间可能没有路线连通),后者则所有点之间都可直接连接,因此这儿说水滴在一定区域内要求解的是旅行商问题其实更合适。
旅行商问题是一个 NP 问题,目前还没有完美的解法,不过已经有很多高效的处理方法,比如动态规划、遗传算法等。本文介绍的是使用遗传算法来求解,并给出一个 JavaScript 版本的示例。(注:也可参见笔者之前写的 Python 版本。)
遗传算法
遗传算法是一种通过模拟生物进化来解决特定问题的方法,它的核心思想是将问题编码为“基因”,然后生成若干个基因不同的个体,让这些个体相互竞争,并使用一种评估机制让它们“优胜劣汰”,最终“进化”出足够优秀的解。
具体而言,它有两个核心要点:第一是编码,即需要找到一种合适的方法,将待解决的问题映射为“基因”编码,生成多个个体;第二是评估,即需要一种定量的方法给每个个体打分,评估哪个个体所对应的方案更接近最佳答案,从而可以根据这个分数优胜劣汰。
遗传算法只是一种思想,并没有很具体的方法,比如编码和评估方法,都需要具体问题具体分析。它的大致流程如下图所示:
编码
为了方便分析,我们可以将所有城市编号。旅行商从一个城市出发,每个城市只走一次,且没有城市被遗漏,那么旅行商的轨迹实际上就可以简化为城市编号的一个排列,这个排列可以用数列(数组)来表示。
举例来说,如果一共有 50 个城市,我们给城市从 1 开始编号,可以得到这样的数列:
1, 2, 3, 4, 5, 6, …, 47, 48, 49, 50
这个数列顺序可以随意打乱,每一种排列都对应一种走法。比如:
12, 50, 30, 26, 13, 45, …, 39, 18, 41, 17
这个序列表示旅行商从 12 号城市出发,先到 50 号城市,再到 30 号城市,再到 26 号城市,再到 13 号城市,再到 45 号城市,……,再到 39 号城市,再到 18 号城市,再到 41 号城市,最后到 17 号城市,当然,最后的最后还要回到起点城市 12 号城市。
由于是随机生成的,这个路线在图上看起来可能一团糟:
这样的序列便是一种编码方法,每一种序列我们称之为一个“基因”。一个基因对应的数组类似下图这样:
计算开始时,我们可以随机生成 N 个序列,然后进行下一步评估。本例中,城市数量为 50 个,对应的基因个体数为 100 个。
评估
旅行商问题的评估目标非常明确:总路程最短。
为了处理方便,我们通常希望每个个体的分数是越大越好,既然总路程是越小越好,那么评估函数的实现便有一个很简单直接的方案,——取总路程的倒数。在我们这个例子中,这个简单的方案是有效的,不过有些复杂场景下,可能会需要更复杂的评估函数。
rate (gene) {
return 1 / this.getDistance(gene)
}
有了评估函数,我们便能给所有“基因”打分,并基于这个打分产生下一代。
下一代的数量通常与前代相同。生成规则一般是:
- 前代最佳的基因直接进入下一代;
- 从前代选中两个基因,选中概率正比于得分,将两个基因随机交叉、变异,生成下一代;
- 重复第 2 步,直到下一代的个体数达到要求。
交叉
遗传算法模仿的是生物的进化,每次产生下一代时,总是两个前代交换基因(交叉),生成一个后代。交叉的过程如下图所示:
1、输入两个父代
2、随机选择父代的基因片断
3、交换基因片断
这样便能得到两个新的子代,我们只需随机返回一个子代即可。
需要注意的是,在旅行商问题中,城市不能重复也不能遗漏,上面的方法产生的子代显然不能满足这个要求。解决方法也很简单,我们直接将另一个父代的基因连接在子代后面,再对数组去重即可:
xFunc (lf1, lf2) {
let p1 = Math.floor(Math.random() * (this.n - 2)) + 1
let p2 = Math.floor(Math.random() * (this.n - p1)) + p1
let piece = lf2.gene.slice(p1, p2)
let new_gene = lf1.gene.slice(0, p1)
piece.concat(lf2.gene).map(i => {
if (!new_gene.includes(i)) {
new_gene.push(i)
}
})
return new_gene
}
变异
遗传算法本质上是一种搜索算法,为了避免陷入局部最优解,我们要以一定概率让基因发生变异。如同生物界,基因突变大多数情况下都是不好的,但如果没有基因突变,仅靠现有个体交换基因,会很难产生新物种,整个种群有可能一直在低水平的局部最优解徘徊。
由于我们的基因是一个数组,常见的基本变异可以有交换、移动、倒序等几种。
交换
交换指的是在一段基因中,随机选取两个片断,然后交换它们的位置。如下图所示:
倒序
在基因中随机选择一个片断,将这个片断的顺序颠倒。如下图所示:
移动
在基因中随机选择一个片断,将它移到另一个位置。如下图所示:
这三种变异对应的代码形如:
let funcs = [
(g, p1, p2) => {
// 交换
let t = g[p1]
g[p1] = g[p2]
g[p2] = t
}, (g, p1, p2) => {
// 倒序
let t = g.slice(p1, p2).reverse()
g.splice(p1, p2 - p1, ...t)
}, (g, p1, p2) => {
// 移动
let t = g.splice(p1, p2 - p1)
g.splice(Math.floor(Math.random() * g.length), 0, ...t)
}
]
实践
有了上面的基础,就可以进行编码实践了。本文所有代码在 GitHub 上开源。
首先随机生成 N
个城市,这儿我们取 N = 50
,每个城市包含 x
、y
两个坐标。将它们用红圈在画布上画出来,再随机生成一条路线,图片看起来类似下图:
然后开始迭代,并将每一代得分最高的路线绘制到图上。下面是第 710 代的样子:
可以看到,已经比初代好多了,但仍然有几个地方明显有优化空间。继续进化迭代,大约到第 3000 代时,得到下面的图:
再往后又继续迭代了几千次,但路线没有再变化。看起来,这便是当前能找到的最优解了。
下面是另一次试验的动图:
你可以访问以下链接在线体验:
小结
本文介绍了遗传算法的主要思想以及一个求解旅行商问题的实例。
遗传算法的应用非常广,很多常规方法无从下手的工程优化问题,用遗传算法可以优雅地求解。不过遗传算法也不是万能药,需要注意的是,遗传算法并不能保证找到全局最优解,如果编码设计得不好,或参数不合适,它很有可能陷入某个局部最优解。
除此之外,遗传算法的应用主要有两个限制:
- 问题必须可编码为“基因”;
- 有合适的评估函数。
有很多问题看似简单,但想将解法转为基因编码却非常困难,只有先将解法编码为基因,才可进行下一步的交叉、变异等操作。
对大部分问题来说,编码方法都是存在的,只是有一些不那么容易想到,但是否有合适的评估函数就是一个实在的难题了,因为这个函数要满足稳定(相同的基因得到相同的分数)且高效,以便能进行自动化迭代。
几年前,我们曾探索过使用遗传算法来生成漂亮的广告图片的方案,图片中所包含的各种元素很多,编码挺复杂,但本质上并不难,最后难倒我们的是找不到合适的评估函数,——什么样的结果是“漂亮”的?无法定义评估函数,整个迭代便无法自动化。我们也试过半自动化,即基因的生成、交叉、变异等过程自动化,然后人工给每一代的结果打分。但后来发现这个做法也行不通,因为首先,通常需要上千次迭代才能得到可用的结果,对人来说,这个时间成本太高了,第二也是更重要的是,人其实并不能很好并且稳定地识别什么是“漂亮”的,——有时觉得这样组合很漂亮,但过一会儿再看到可能又觉得一般了。打分的不稳定,导致进化过程的不稳定,很难收敛到最优解。如果你需要处理类似这样的问题,目前更好的方案大概是深度学习。
最后,再贴一下在线演示链接:oldj.net/static/tsp/…。