旅行商问题解法(2019年字节跳动校招笔试算法题“毕业旅行问题”)

问题原型

给定一系列城市和每对城市之间的距离,求推销员从某个城市出发后经过所有城市,然后回到出发城市的最短路径。

为了方便讲解,我们以2019年字节跳动校招笔试题“毕业旅行问题为例”。牛客原链接:https://www.nowcoder.com/profile/4097742/codeBookDetail?submissionId=58509076

题目描述

小明目前在做一份毕业旅行的规划。打算从北京出发,分别去若干个城市,然后再回到北京,每个城市之间均乘坐高铁,且每个城市只去一次。由于经费有限,希望能够通过合理的路线安排尽可能的省一些路上的花销。给定一组城市和每对城市之间的火车票的价钱,找到每个城市只访问一次并返回起点的最小车费花销。

输入描述

城市个数n(1 城市间的车票价钱 n行n列的矩阵 m[n][n]

输出描述

最小车费花销 s

示例1

输入

4
0 2 6 5
2 0 4 4
6 4 0 2
5 4 2 0

输出

13

说明

共 4 个城市,城市 1 和城市 1 的车费为0,城市 1 和城市 2 之间的车费为 2,城市 1 和城市 3 之间的车费为 6,城市 1 和城市 4 之间的车费为 5,依次类推。假设任意两个城市之间均有单程票可购买,且票价在1000元以内,无需考虑极端情况。

这个问题首先需要构造一个图,图的一个对应一座城市,边的权值对应城市到城市之间火车票价格,根据题目描述,这是一个完全图(各个顶点都有一条边两两互相连接),并且各个边没有方向。

旅行商问题解法(2019年字节跳动校招笔试算法题“毕业旅行问题”)_第1张图片

这道题一般有两种解法:

  • 回溯法
  • 动态规划

回溯法

把所有的解通过一棵树表达出来,然后通过深度优先遍历,找到一个解的时候就将其记录下来,最后输出最小的解即可。

旅行商问题解法(2019年字节跳动校招笔试算法题“毕业旅行问题”)_第2张图片

如图所示,从根节点到叶子节点的所经过的节点就是其路径,每个边的长度之和就是总车票价格。
当然还有一种优化的方法,就是在遍历过程中,如果发现此时路径长度已经超出了之前找到的最小路径长度,就可以进行剪支操作,即不再遍历。

这种方法的时间复杂度为 O ( n ! ) O(n!) O(n!),当n大于12之后,其计算时间就已经非常大了。这种方法会因为超时无法通过所有的测试数据。

下面贴出代码:

import java.util.Arrays;
import java.util.Scanner;
import java.util.concurrent.atomic.AtomicInteger;

public class Main {
 
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        int[][] arr = new int[n][n];
        for(int i = 0; i < arr.length; i++) {
            for(int j = 0; j < arr.length; j++) {
                arr[i][j] = sc.nextInt();
            }
        }
 
        boolean[] vis = new boolean[n];  //记录各个城市访问情况
        vis[0] = true;
        AtomicInteger ans = new AtomicInteger(Integer.MAX_VALUE);
        dfs(arr, vis, n, 0, 1, 0, ans);
        System.out.println(ans.get());
    }
 
 	//vn为已经访问的城市数量,local为当前城市编号,price为当前累计票价
    private static void dfs(int[][] arr, boolean[] vis, final int n, int local, 
    		int vn, int price, AtomicInteger ans) {
        if(price > ans.get()) {  //如果此时价格已经超出了之前找到的最小价格,那么进行剪支操作
            return;
        }
 
        if(vn == n) {  //如果已经遍历完成
            int val = price + arr[local][0];  //因为走完所有城市后还要回到起点所以加上arr[local][0]
            if(val < ans.get()) {
                ans.set(val);
            }
            return;
        }
 
        for(int i = 1; i < n; i++) {  //因为起点为0所以无需考虑起点
            if(vis[i])
                continue;
            vis[i] = true;
            dfs(arr, vis, n, i, vn + 1, price + arr[local][i], ans);  //继续遍历
            vis[i] = false;
        }
    }
 
}

动态规划

我们可以使用动态规划,把一个大问题划分为多个小问题来求解。

例如大问题是从顶点0开始,经过顶点1,2,3然后回到顶点1的最短路程,那么我们可以分割为三个小问题找最优解:

  • 从顶点0出发到顶点1,再从顶点1出发,途径2,3城市(不保证访问顺序),然后回到0的最短路径
  • 从顶点0出发到顶点2,再从顶点2出发,途径1,3城市,然后回到0的最短路径
  • 从顶点0出发到顶点3,再从顶点3出发,途径1,2城市,然后回到0的最短路径

这三个小问题对应的最小值就是问题的最优解。

知道怎么划分子问题后,接下来就是找状态转移方程。

我们定义 d p [ n ] { p 1 , p 2 , . . . , p m } dp[n]\{p_1,p_2,...,p_m\} dp[n]{p1,p2,...,pm} 为从城市 n n n 出发,途径城市 p 1 , p 2 , . . . , p m p_1,p_2,...,p_m p1,p2,...,pm(不保证访问城市的顺序)然后回到城市0的最短路径。按照上述划分问题的方法,我们可以推导出:

d p [ n ] { p 1 , p 2 , . . . , p m } = m i n ( d p [ p 1 ] { p 2 , . . . , p m } + D p 1 n , d p [ p 2 ] { p 1 , p 3 , . . . , p m } + D p 2 n , . . . . . . , d p [ p m ] { p 1 , p 2 , . . . p m − 1 } + D p m n ) dp[n]\{p_1,p_2,...,p_m\}= min(dp[p_1]\{p_2,...,p_m\}+D_{p_1}^n, dp[p_2]\{p_1,p_3,...,p_m\}+D_{p_2}^n,......,dp[p_m]\{p_1,p_2,...p_{m-1}\}+D_{p_m}^n) dp[n]{p1,p2,...,pm}=min(dp[p1]{p2,...,pm}+Dp1n,dp[p2]{p1,p3,...,pm}+Dp2n,......,dp[pm]{p1,p2,...pm1}+Dpmn)

其中, D y x D_y^x Dyx 代表从城市 x x x 到城市 y y y 的距离。

显然对于上述例子而言,其最终的解为 d p [ 0 ] { 1 , 2 , 3 } dp[0]\{1,2,3\} dp[0]{1,2,3} 的值,为了求出该解,我们只需要求出 d p [ 1 ] { 2 , 3 } + D 1 0 dp[1]\{2,3\} + D_1^0 dp[1]{2,3}+D10 d p [ 2 ] { 1 , 3 } + D 2 0 dp[2]\{1,3\} + D_2^0 dp[2]{1,3}+D20 d p [ 3 ] { 1 , 2 } + D 3 0 dp[3]\{1,2\}+D_3^0 dp[3]{1,2}+D30 的最小值即可。
同样,求出 d p [ 1 ] { 2 , 3 } dp[1]\{2,3\} dp[1]{2,3} 只需要求出 d p [ 2 ] { 3 } dp[2]\{3\} dp[2]{3} 即可,而 d p [ 2 ] { 3 } = d p [ 3 ] { } + D 3 2 dp[2]\{3\}=dp[3]\{\}+D_3^2 dp[2]{3}=dp[3]{}+D32 d p [ 3 ] { } dp[3]\{\} dp[3]{} 代表从城市3回到起点的距离,也就是 d p [ 3 ] { } = D 0 3 dp[3]\{\}=D_0^3 dp[3]{}=D03

那么如何建立一个数组来表达上述状态转移方程呢?
我们可以使用状态压缩的方法,用一个int数字的每一位来表达 d p [ n ] { p 1 , p 2 , . . . , p m } dp[n]\{p_1,p_2,...,p_m\} dp[n]{p1,p2,...,pm} 中的 { p 1 , p 2 , . . . , p m } \{p_1,p_2,...,p_m\} {p1,p2,...,pm},即 p m p_m pm是否存在等价于该int数字的第m位是否为1,所以一个int数字可以表达 { p 1 , p 2 , . . . , p 32 } \{p_1,p_2,...,p_{32}\} {p1,p2,...,p32},即32个城市。在刚才的字节跳动笔试题中,题目已经给出 n ≤ 20 n\leq20 n20,所以使用一个int数字已经足够了。所以最后,dp数组的宽度为城市的数量 x x x,长度为 2 x − 1 2^{x-1} 2x1

所以该算法的时间复杂度为 O ( 2 n n 2 ) O(2^nn^2) O(2nn2),空间复杂度为 O ( 2 n ) O(2^n) O(2n),虽然看上去时间复杂度还是很大,但好在基数 n n n 并不大,所以一般在 n < 20 n<20 n<20几秒钟就能解决问题。要知道在 n = 20 n=20 n=20的时候,和回溯法相比理论效率提高了50亿倍,时间开销小了很多。

解决数组问题后,接下来就是建立初始状态。
刚才已经说过 d p [ n ] { } = D 0 n dp[n]\{\}=D_0^n dp[n]{}=D0n,所以我们可以把各个城市n到起点的距离 D 0 n D_0^n D0n赋值给 d p [ n ] [ 0 ] dp[n][0] dp[n][0]

int[][] dp = new int[n][1 << (n - 1)];
for(int i = 0; i < n; i++) {
	dp[i][0] = map[i][0];
}

接下来就是考虑如何填充dp表。
首先,我们现在已知 d p [ 1 ] { } , d p [ 2 ] { } , . . . , d p [ n ] { } dp[1]\{\},dp[2]\{\},...,dp[n]\{\} dp[1]{},dp[2]{},...,dp[n]{}的值
例如选定 d p [ 1 ] { } dp[1]\{\} dp[1]{},我们可以直接推导出 d p [ 0 ] { 1 } , d p [ 2 ] { 1 } , . . . , d p [ m ] { 1 } dp[0]\{1\},dp[2]\{1\},...,dp[m]\{1\} dp[0]{1},dp[2]{1},...,dp[m]{1}(因为 d p [ n ] { 1 } = d p [ 1 ] { } + D 1 m dp[n]\{1\}=dp[1]\{\}+D_1^m dp[n]{1}=dp[1]{}+D1m
同理选定 d p [ n ] { } dp[n]\{\} dp[n]{},我们可以直接推导出 d p [ 0 ] { n } , d p [ 2 ] { n } , . . . , d p [ m ] { n } dp[0]\{n\},dp[2]\{n\},...,dp[m]\{n\} dp[0]{n},dp[2]{n},...,dp[m]{n}(因为 d p [ m ] { n } = d p [ m ] { } + D n m dp[m]\{n\}=dp[m]\{\}+D_n^m dp[m]{n}=dp[m]{}+Dnm

所以第一个循环为遍历城市集合,即依次遍历 { 1 } , { 2 } , { 1 , 2 } , { 3 } , { 1 , 3 } , { 2 , 3 } , { 1 , 2 , 3 } , . . . , { 1 , . . . , n } \{1\},\{2\},\{1,2\},\{3\},\{1,3\},\{2,3\},\{1,2,3\},...,\{1,...,n\} {1},{2},{1,2},{3},{1,3},{2,3},{1,2,3},...,{1,...,n},因为只有根据小的集合才能推导出大的集合,将这个集合 P P P 对应的int数赋值为 X X X(下列代码中为p):

for (int p = 1; p < 1 << (n - 1); p++) {
	//...
}

第二个循环选择起点城市(当然起点城市不能包含在 P P P 中 ),并将起始城市赋值给变量 i i i,此时城市集合和起点城市就选定好了,也就是我们要计算的 d p [ i ] [ p ] dp[i][p] dp[i][p]

for (int p = 1; p < 1 << (n - 1); p++) {  //遍历所有集合
	for (int i = 0; i < n; i++) {  //选定一个起点城市
		if(self(i, p)) {  //起点城市不能包含在P中 
        	continue;
        }
        //...
    }
}

如何计算 d p [ i ] [ p ] dp[i][p] dp[i][p]呢,我们之前之前提过:
d p [ n ] { p 1 , p 2 , . . . , p m } = m i n ( d p [ p 1 ] { p 2 , . . . , p m } + D p 1 n , d p [ p 2 ] { p 1 , p 3 , . . . , p m } + D p 2 n , . . . . . . , d p [ p m ] { p 1 , p 2 , . . . p m − 1 } + D p m n ) dp[n]\{p_1,p_2,...,p_m\}= min(dp[p_1]\{p_2,...,p_m\}+D_{p_1}^n, dp[p_2]\{p_1,p_3,...,p_m\}+D_{p_2}^n,......,dp[p_m]\{p_1,p_2,...p_{m-1}\}+D_{p_m}^n) dp[n]{p1,p2,...,pm}=min(dp[p1]{p2,...,pm}+Dp1n,dp[p2]{p1,p3,...,pm}+Dp2n,......,dp[pm]{p1,p2,...pm1}+Dpmn)

所以第三个循环就是从集合 P P P 中选取每个城市作为子问题的起点 p x p_x px,也就是需要计算 d p [ p x ] { U } dp[p_x]\{U\} dp[px]{U}(集合 U U U 为不包含 p x p_x px的集合 P P P

所以我们遍历的时候只需要将这个子问题中城市的起点 p x p_x px所对应的位标为0就可以了,在标为0后,其集合 U U U对应的数值必然小于 X X X,肯定是之前循环已经计算好的结果,然后我们再将其加上 D p x i D_{p_x}^i Dpxi就可以作为子问题的解了,计算完所有子问题后,我们将这些子问题的最小值作为 d p [ i ] [ p ] dp[i][p] dp[i][p]的值,也就是最优解。

for (int p = 1; p < 1 << (n - 1); p++) {  //遍历所有集合
	for (int i = 0; i < n; i++) {  //选定一个起点城市
		dp[i][p] = Integer.MAX_VALUE >> 1;
		if(self(i, p)) {  //当然起点城市不能包含在P中 
        	continue;
        }
        for (int k = 1; k < n; k++) {  //依次枚举子问题,选取城市k为子问题的起点
            if(visit(k, p)) {  //判断城市k是否在集合p中
            	int op = unmark(p, k);  //将起点k对应的位标为0
                dp[i][p] = Math.min(dp[i][p], dp[k][op] + map[i][k]);
        	}
        }
    }
}

最终代码如下:

public class Main {

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        int[][] map = new int[n][n];
        for(int i = 0; i < map.length; i++) {
            for(int j = 0; j < map.length; j++) {
                map[i][j] = sc.nextInt();
            }
        }

        int[][] dp = new int[n][1 << (n - 1)];
        for (int i = 0; i < n; i++) {
            dp[i][0] = map[i][0];
        }

        for (int p = 1; p < 1 << (n - 1); p++) {  //遍历所有集合
            for (int i = 0; i < n; i++) {  //选定一个起点城市
                dp[i][p] = Integer.MAX_VALUE >> 1;  //除以2防止计算时越界
                if(self(i, p)) {  //城市i不能出现在集合p中,因为i是起点
                    continue;
                }
                for (int k = 1; k < n; k++) {  //依次枚举子问题
                    if(visit(k, p)) {  //判断城市k是否在集合p中
                        int op = unmark(p, k);
                        dp[i][p] = Math.min(dp[i][p], dp[k][op] + map[i][k]);
                    }
                }
            }
        }

        System.out.println(dp[0][(1 << (n - 1)) - 1]);
    }


    private static boolean self(int city, int p) {  //对城市0统一返回false
        return (p & (1 << (city - 1))) != 0;
    }

    private static boolean visit(int city, int p) {
        return self(city, p);
    }

    private static int unmark(int p, int city) {
        return (p & (~(1 << (city - 1))));
    }
}

你可能感兴趣的:(算法)