问题原型
给定一系列城市和每对城市之间的距离,求推销员从某个城市出发后经过所有城市,然后回到出发城市的最短路径。
为了方便讲解,我们以2019年字节跳动校招笔试题“毕业旅行问题为例”。牛客原链接:https://www.nowcoder.com/profile/4097742/codeBookDetail?submissionId=58509076
题目描述
小明目前在做一份毕业旅行的规划。打算从北京出发,分别去若干个城市,然后再回到北京,每个城市之间均乘坐高铁,且每个城市只去一次。由于经费有限,希望能够通过合理的路线安排尽可能的省一些路上的花销。给定一组城市和每对城市之间的火车票的价钱,找到每个城市只访问一次并返回起点的最小车费花销。
输入描述
城市个数n(1<n≤20,包括北京)
城市间的车票价钱 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元以内,无需考虑极端情况。
扫描二维码关注公众号,回复: 10354012 查看本文章
这个问题首先需要构造一个图,图的一个对应一座城市,边的权值对应城市到城市之间火车票价格,根据题目描述,这是一个完全图(各个顶点都有一条边两两互相连接),并且各个边没有方向。
这道题一般有两种解法:
- 回溯法
- 动态规划
回溯法
把所有的解通过一棵树表达出来,然后通过深度优先遍历,找到一个解的时候就将其记录下来,最后输出最小的解即可。
如图所示,从根节点到叶子节点的所经过的节点就是其路径,每个边的长度之和就是总车票价格。
当然还有一种优化的方法,就是在遍历过程中,如果发现此时路径长度已经超出了之前找到的最小路径长度,就可以进行剪支操作,即不再遍历。
这种方法的时间复杂度为 ,当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的最短路径
这三个小问题对应的最小值就是问题的最优解。
知道怎么划分子问题后,接下来就是找状态转移方程。
我们定义 为从城市 出发,途径城市 (不保证访问城市的顺序)然后回到城市0的最短路径。按照上述划分问题的方法,我们可以推导出:
其中, 代表从城市 到城市 的距离。
显然对于上述例子而言,其最终的解为
的值,为了求出该解,我们只需要求出
,
,
的最小值即可。
同样,求出
只需要求出
即可,而
,
代表从城市3回到起点的距离,也就是
。
那么如何建立一个数组来表达上述状态转移方程呢?
我们可以使用状态压缩的方法,用一个int
数字的每一位来表达
中的
,即
是否存在等价于该int
数字的第m
位是否为1,所以一个int
数字可以表达
,即32个城市。在刚才的字节跳动笔试题中,题目已经给出
,所以使用一个int
数字已经足够了。所以最后,dp
数组的宽度为城市的数量
,长度为
。
所以该算法的时间复杂度为 ,空间复杂度为 ,虽然看上去时间复杂度还是很大,但好在基数 并不大,所以一般在 几秒钟就能解决问题。要知道在 的时候,和回溯法相比理论效率提高了50亿倍,时间开销小了很多。
解决数组问题后,接下来就是建立初始状态。
刚才已经说过
,所以我们可以把各个城市n
到起点的距离
赋值给
:
int[][] dp = new int[n][1 << (n - 1)];
for(int i = 0; i < n; i++) {
dp[i][0] = map[i][0];
}
接下来就是考虑如何填充dp
表。
首先,我们现在已知
的值
例如选定
,我们可以直接推导出
(因为
)
同理选定
,我们可以直接推导出
(因为
)
所以第一个循环为遍历城市集合,即依次遍历
,因为只有根据小的集合才能推导出大的集合,将这个集合
对应的int
数赋值为
(下列代码中为p
):
for (int p = 1; p < 1 << (n - 1); p++) {
//...
}
第二个循环选择起点城市(当然起点城市不能包含在 中 ),并将起始城市赋值给变量 ,此时城市集合和起点城市就选定好了,也就是我们要计算的 :
for (int p = 1; p < 1 << (n - 1); p++) { //遍历所有集合
for (int i = 0; i < n; i++) { //选定一个起点城市
if(self(i, p)) { //起点城市不能包含在P中
continue;
}
//...
}
}
如何计算
呢,我们之前之前提过:
所以第三个循环就是从集合 中选取每个城市作为子问题的起点 ,也就是需要计算 (集合 为不包含 的集合 )
所以我们遍历的时候只需要将这个子问题中城市的起点 所对应的位标为0就可以了,在标为0后,其集合 对应的数值必然小于 ,肯定是之前循环已经计算好的结果,然后我们再将其加上 就可以作为子问题的解了,计算完所有子问题后,我们将这些子问题的最小值作为 的值,也就是最优解。
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))));
}
}