// 标题是糊弄人的

1. 问题引入

给出一张图,求其最短哈密尔顿回路,也就是 “旅行商问题”(Traveling Saleman Problem,TSP)

假设有一个旅行商人要拜访 $n$个城市,他必须选择所要走的路径,路径的限制是每个城市只能拜访一次,而且最后要回到原来出发的城市。

路径的选择目标是要求得的路径路程为所有路径之中的最小值。

这是一个著名的 NP-C(NP 完全)问题,也就是说——爆搜不可避

爆搜很简单,枚举一个排列表示第 $1, 2, 3, …, n$个经过的点,再判断距离就行了,取最小值

但是这样复杂度是 $O(n!)$级别的,即使 $n \leq 20$都是不可接受的

对于 $n \leq 20$左右的数据,可以用状态压缩动态规划求解,复杂度 $n ^ 2 2 ^ n$,可能需要常数上的优化

2. 模拟退火

模拟退火将热力学的理论应用到统计学中,一开始温度高,分子能量大,移动范围广,可以防止停留在局部最优解

到后面一步步降温,移动范围逐渐收敛,最终获取到的解一般较优

模拟退火解决 TSP 问题大概分为如下几部:

  1. 根据当前路径,随机生成一个新路径
  2. 比较当前路径与新路径,若新路径较优,则接受新路径,把当前路径替换成新路径
  3. 否则以 $e ^ {\Delta / T}$的概率接受新路径,其中 $\Delta$表示新路径长度与当前路径长度的差(为负数),$T$表示当前温度
  4. 若温度低于设定的最低温度,则退出算法,否则降低温度,并回到第一步继续迭代

生成新解一般就是随机一对 $a, b$,然后交换当前路径的第 $a$个和第 $b$个点作为新路径

初始温度 $T _ 0$需要足够大,比如最大边权的 $3$倍

每次降温就是 $T’ = T \times k$,$k$是个常数,一般取什么 $0.99$之类的,看情况而定

经过实验,直接迭代收敛太快了,效果不是很好,可以对于每个温度进行多次迭代

这些参数取什么可以用暴力对拍看正确率找

例题:Hie with the Pie POJ – 3311

题意大概就是给 $n + 1$个点($n \leq 10$),求每个点至少经过一次的最短回路长度(每个点可以经过多次)

(边权我试了一下,是 $\leq 1000$的)

可以经过多次并没有什么不同,用 floyd 求出两点之间最短距离以后就没有必要经过某个点两次了,于是上模拟退火

调参其实挺难搞的,因为有多组数据,所以正确率要求很高

代码中添加了优化:若发现迭代多次一直都没取到更优值,则跳出迭代直接降温

代码:

#include <cstdio>
#include <cstdlib>
#include <cctype>
#include <cstring>
#include <cmath>
#include <algorithm>

#define NS (12)

using namespace std;

template<typename _Tp> inline void IN(_Tp& dig)
{
    char c; bool flag = 0; dig = 0;
    while (c = getchar(), !isdigit(c)) if (c == '-') flag = 1;
    while (isdigit(c)) dig = dig * 10 + c - '0', c = getchar();
    if (flag) dig = -dig;
}

int n, f[NS][NS], x[NS];

bool book[NS];

int main(int argc, char const* argv[])
{
    srand(19260817);
    while (IN(n), n)
    {
        n++, memset(book, 0, sizeof(book));
        for (int i = 1; i <= n; i += 1)
            for (int j = 1; j <= n; j += 1)
                IN(f[i][j]);
        for (int k = 1; k <= n; k += 1)
            for (int i = 1; i <= n; i += 1)
                for (int j = 1; j <= n; j += 1)
                    f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
        for (int i = 1; i <= n; i += 1)
        {
            while (x[i] = rand() % n + 1, book[x[i]]);
            book[x[i]] = 1;
        }
        int lst = f[x[n]][x[1]];
        for (int i = 2; i <= n; i += 1) lst += f[x[i - 1]][x[i]];
        int best = lst, cnt = 0;
        double T = 5000;
        while (T >= 1e-6)
        {
            for (int C = 1; C <= 3000; C += 1)
            {
                int cur = lst, a = rand() % (n - 1) + 2, b;
                while (b = rand() % (n - 1) + 2, a == b);
                swap(x[a], x[b]), cur -= f[x[n]][x[1]];
                for (int i = 2; i <= n; i += 1) cur -= f[x[i - 1]][x[i]];
                if (cur > 0) lst -= cur, cnt = 0, best = min(best, lst);
                else
                {
                    if (exp((double)cur / T) * RAND_MAX > rand()) lst -= cur;
                    else swap(x[a], x[b]);
                    cnt++;
                }
                if (cnt > 500) break;
            }
            T *= 0.98;
        }
        printf("%d\n", best);
    }
    return 0;
}

3. 蚁群算法

蚁群算法(AntColonyOptimization,ACO),是一种用来在图中寻找优化路径的机率型算法。

这里有一个蚁群算法的演示:Github 仓库

它的灵感来源于蚁群寻找食物的过程,因为往往一只蚂蚁并没有太多 “智能” 的表现,而蚁群往往有 “智能” 的动作,比如大部分都趋向于食物

这是因为它们在移动的路径上会留下 “信息素”,它们会更大概率沿着信息素更浓的路径行走

而路径越短,信息素就会越浓

这很符合 TSP 问题的要求

蚁群算法流程大概如下:

  1. 初始化算法
  2. 派出一只蚂蚁,随机一个点作为蚂蚁的起点
  3. 对于蚂蚁当前所在的点,根据所有后继边的信息素浓度以及长度根据公式计算出访问每个后继节点的概率
  4. 用轮盘法随机出下一步走到哪个节点
  5. 重复 3,4 步,直到所有节点都访问了一次
  6. 计算回路长度,计算经过的边的信息素增加量
  7. 所有边上的信息素都会蒸发一定比例使算法收敛,增加经过的边的信息素
  8. 回到第 2 步,进行下一轮迭代

当然第二步只派出一只蚂蚁正确率是很低的,一般要派出节点数数倍的蚂蚁,精度要求越高就要派出更多蚂蚁

首先讲一下怎么计算后继节点访问概率

有这么几个基本的算法参数:

  • $\alpha$,表示信息素重要程度
  • $\beta$,表示能见度重要程度,能见度指的是边长的倒数
  • $\rho$,蒸发率

若当前第 $k$个蚂蚁在点 $x$,$x, y$之间的信息素浓度为 $\tau _ {xy}$,$x, y$之间的能见度为 $\eta _ {xy}$,与 $x$相连且未访问的集合为 $\mathrm {allowed} _ x$,那么访问接下来 $y$的概率为:

$$p _ {xy} ^ {k} = {\frac {(\tau _ {xy}^{\alpha })(\eta _ {xy}^{\beta })}{\sum _ {z\in \mathrm {allowed} _ {x}}(\tau _ {xz}^{\alpha })(\eta _ {xz}^{\beta })}}$$

公式中分子是信息素浓度的 $\alpha$次方乘以能见度的 $\beta$次方

分母就是所有分子之和

然后再 rand 一个 $[0, 1]$之间的随机数,就能选出后继节点了

(如果所有边的浓度都为 0,就随便选一个了)

可以发现 $\alpha$的取值越大,信息素占比越大;$\beta$的取值越大,能见度占比越大

这个值一般就取 $\alpha = \beta = 1$或者 $\alpha = 1, \beta = 2$,需要玄学调参

关于信息素的更新,首先是初始化算法的时候需要设定一个初始信息素浓度,这个浓度太大会导致新增的信息素没什么用,太小会导致过快结束算法,陷入局部最优解。

这个值可以先跑一遍贪心算出一个答案,然后参照这个答案设定

怎么设定呢?根据路径越长信息素浓度越低的规则,某只蚂蚁走过的回路长度为 $length$的话,那么它对走过的路径的信息素浓度的贡献为 $\frac 1 {length}$

因此初始浓度可以设定为 $\frac M {length}$,其中 $M$表示每批蚂蚁个数

然后根据这个规则,每批蚂蚁走过以后每条边的信息素增加量也是能算出来的

为了精度可以使每次增加的信息浓度乘上一个常数

注意是:

  • $M$只蚂蚁都跑一遍,并记录每条边的信息素增量,但并不加上去
  • 所有蚂蚁都跑完后,每条边都蒸发一定比例
  • 蒸发完后再增加

这个蒸发率 $\rho$一般是取 $50\%$到 $80\%$,蒸发比例越大收敛越快

每次把每条边的信息素浓度都乘以 $1 – \rho$,就能逐渐让访问频率高的边的浓度与其他边的浓度差更大,最后其他边的浓度一般会降为 0

一般迭代次数就是 $500$到 $1000$次

这样正确率还是比较高的,答案也还比较优秀

例题:售货员的难题 LUOGU – 1171

这个就是 TSP 裸题了

$n \leq 20$,$1$秒钟,状态压缩动态规划是很吃力的

(但是好像有不少的搜索过了,还挺快的_(:з」∠)_)

于是就上蚁群咯!比状压还是快多了

调参心累啊.jpg

#include <bits/stdc++.h>

#define NS (25)
#define eps (1e-10)
#define M (80)
#define rho (0.5)
#define alp (1)
#define bet (1)
#define Q (100)
#define Rand() ((double)rand() / RAND_MAX)

using namespace std;

template<typename _Tp> inline void IN(_Tp& dig)
{
    char c; bool flag = 0; dig = 0;
    while (c = getchar(), !isdigit(c)) if (c == '-') flag = 1;
    while (isdigit(c)) dig = dig * 10 + c - '0', c = getchar();
    if (flag) dig = -dig;
}

int n, dis[NS][NS], path[NS], ans;

bool book[NS];

double info[NS][NS], dt[NS][NS], p[NS];

void init()
{
    int a = 1, len = 0;
    book[1] = 1;
    for (int c = 1; c < n; c += 1)
    {
        int mn = INT_MAX, nxt = 0;
        for (int i = 1; i <= n; i += 1)
            if (!book[i] && dis[a][i] < mn)
                mn = dis[a][i], nxt = i;
        len += dis[a][nxt], a = nxt, book[a] = 1;
    }
    len += dis[a][1], ans = len;
    for (int i = 1; i <= n; i += 1)
        for (int j = 1; j <= n; j += 1)
            info[i][j] = (double)Q * M / len;
}

inline double Pow(double a, int b)
{
    double res = 1;
    for (int i = 1; i <= b; i += 1) res *= a;
    return res;
}

void run()
{
    int a = rand() % n + 1, s = a, len = 0;
    memset(book + 1, 0, sizeof(bool) * n), book[a] = 1;
    for (int c = 1; c < n; c += 1)
    {
        double tot = 0;
        for (int i = 1; i <= n; i += 1)
            if (book[i]) p[i] = 0;
            else
            {
                p[i] = Pow(info[a][i], alp) / Pow(dis[a][i], bet);
                tot += p[i];
            }
        if (tot < eps) return;
        for (int i = 1; i <= n; i += 1) p[i] /= tot;
        double r = Rand();
        for (int i = 1; i <= n; r -= p[i], i += 1)
            if (!book[i] && r <= p[i])
            {
                len += dis[a][i], a = i, book[a] = 1, path[c] = i;
                break;
            }
    }
    len += dis[a][s], dt[a][s] += (double)Q / len, ans = min(ans, len);
    for (int i = 1; i < n; i += 1)
        dt[s][path[i]] += (double)Q / len, s = path[i];
}

int main(int argc, char const* argv[])
{
    IN(n), srand(19260817);
    for (int i = 1; i <= n; i += 1)
        for (int j = 1; j <= n; j += 1)
            IN(dis[i][j]);
    init();
    for (int c = 1; c <= 700; c += 1)
    {
        for (int i = 1; i <= n; i += 1)
            for (int j = 1; j <= n; j += 1)
                dt[i][j] = 0;
        for (int j = 1; j <= M; j += 1) run();
        for (int i = 1; i <= n; i += 1)
            for (int j = 1; j <= n; j += 1)
                info[i][j] = info[i][j] * rho + dt[i][j];
    }
    printf("%d\n", ans);
    return 0;
}

4. 总结

除了这两种近似算法,还有很多其他的算法能应用于 TSP,例如遗传算法,就没有去仔细研究了

由于状压和搜索只能处理 $n$很小的情况,因此在实际生活中并没有太大用武之地

而近似算法虽然无法保证得出最优解,但是误差也不会太大,而且可以处理点数很多的情况(维基百科上表示有启发式算法可以算出 $10 ^ 6$级别的近似解了,并且误差是有保证的)

竞赛中随机算法也还是有应用场景的,比如可以先用蚁群算法算出较优解,然后用于搜索的剪枝之类的

值得一提的是,由于 POJ 那题有多组数据,我暂时没能用蚁群算法 AC;而 LUOGU 那题模拟退火只能拿到 80 分左右,因此有时候可以考虑把多种近似算法放到一起,取最优解,应该能进一步提升正确率

分类: 文章

Remmina

No puzzle that couldn't be solved.

1 条评论

Larry76 · 2023年5月15日 9:11 下午

蚁群算法的本质上是不是就是蒙特卡洛方法啊/yiw

发表回复

Avatar placeholder

您的电子邮箱地址不会被公开。 必填项已用 * 标注