目录
海贼王之伟大航路
时间与内存要求:
总时间限制: 1000ms
内存限制: 65536kB
问题描述:
“我是要成为海贼王的男人!”,路飞一边喊着这样的口号,一边和他的伙伴们一起踏上了伟大航路的艰险历程。
路飞他们伟大航路行程的起点是罗格镇,终点是拉夫德鲁(那里藏匿着“唯一的大秘宝”——ONE PIECE)。而航程中间,则是各式各样的岛屿。
因为伟大航路上的气候十分异常,所以来往任意两个岛屿之间的时间差别很大,从A岛到B岛可能需要1天,而从B岛到A岛则可能需要1年。当然,任意两个岛之间的航行时间虽然差别很大,但都是已知的。
现在假设路飞一行从罗格镇(起点)出发,遍历伟大航路中间所有的岛屿(但是已经经过的岛屿不能再次经过),最后到达拉夫德鲁(终点)。假设他们在岛上不作任何的停留,请问,他们最少需要花费多少时间才能到达终点?
输入 :
输入数据包含多行。
第一行包含一个整数N(2 < N ≤ 16),代表伟大航路上一共有N个岛屿(包含起点的罗格镇和终点的拉夫德鲁)。其中,起点的编号为1,终点的编号为N。
之后的N行每一行包含N个整数,其中,第i(1 ≤ i ≤ N)行的第j(1 ≤ j ≤ N)个整数代表从第i个岛屿出发到第j个岛屿需要的时间t(0 < t < 10000)。第i行第i个整数为0。
输出:
输出为一个整数,代表路飞一行从起点遍历所有中间岛屿(不重复)之后到达终点所需要的最少的时间。
样例输入:
样例输入1:
4 0 10 20 999 5 0 90 30 99 50 0 10 999 1 2 0
样例输入2:
5 0 18 13 98 8 89 0 45 78 43 22 38 0 96 12 68 19 29 0 52 95 83 21 24 0
样例输出:
样例输出1:
100
样例输出2:
137
提示:
对于样例输入1:路飞选择从起点岛屿1出发,依次经过岛屿3,岛屿2,最后到达终点岛屿4。花费时间为20+50+30=100。
对于样例输入2:可能的路径及总时间为:
1,2,3,4,5: 18+45+96+52=211
1,2,4,3,5: 18+78+29+12=137
1,3,2,4,5: 13+38+78+52=181
1,3,4,2,5: 13+96+19+43=171
1,4,2,3,5: 98+19+45+12=174
1,4,3,2,5: 98+29+38+43=208
所以最短的时间花费为137
单纯的枚举在N=16时需要14!次运算,一定会超时。
问题分析:
虽然题目看起来很花里胡哨,但如果知道的人一看就会发现这不就是一个套着“One Piece”外衣的旅行商问题吗?当然我们现在假设我们并不知道所谓的旅行商问题和NP-hard。我们来直接就题论题分析一下。
我们从起点(罗格镇)出发,最终要到达终点(拉夫德鲁),那么对于我们最终的路径来说,我们已经确定了:第一位是1,最后一位是N(需要输入的变量)。这个题目中还有一个限制:中途的每个岛都要到达一次,也就是说,我最终的路径一定是一个N个元素的数组,且这N个元素互不相同。
接下来看题目的输入输出,第一行输入了N的大小,题干中告诉我们N最大是16,目前看来还不大。接下来是一个邻接矩阵(我命名为TravelTime),其( i , j )位表示从 i 岛到 j 岛所需要的时间,并且TravelTime[i][j]!=TravelTime[j][i],这代表不能通过对称矩阵的方式进行简化。我们需要输出的是最短时间MinTime。
通过观察这个提示,我们可以发现一个特点:对于样例2而言,其路径中的改变量只有中间3个岛的先后顺序,枚举数量就是(2,3,4)的全排列数量。虽然提示明确说了枚举会超时,但我们还是要试试。(完全不是因为一开始没有思路)
在枚举时我们会遇到一些问题,在我们使用递归函数时,我们如何表示我们走过了哪些岛呢?如果把我们走过的岛存成一个数组,那么在之后进行操作(检查某个岛是否走过,走过某个岛后如何标记这个岛走过)的时候太不方便。这个时候我们用二进制数来进行状态压缩就显得十分必要。比如:01101代表我走过了1,3,4号岛,如果我此时又走过了2号岛,那么这个路径就变为01111。状态压缩可以极大的减小空间复杂度,又因为中间的运算大多采取位运算,时间复杂度也较低。
当然,我们的路径可以不考虑首和尾,只用考虑中间的N-2项即可,所以我们不难得到下面的这个最简单的枚举程序:
#include<iostream>
using namespace std;
int MinTime=999999;
int TravelTime[17][17];//存放邻接矩阵,也就是时间表
int Final;//代表最终的岛,也就是题目中的N
//Travelled就是上面提到的状态压缩的路径表示,ReachedIsland表示已经走过了几座岛,curr表示现在所在的岛屿
void GreatVoyage(int ReachedIsland,int curr,int CostTime,int Travelled){
if(ReachedIsland==Final-1){//因为首尾是固定的(1和Final),所以只用枚举到Final-1
MinTime=min(CostTime+TravelTime[curr][Final],MinTime);
return;
}
for(int i=2;i<=Final-1;i++){
if((Travelled&(1<<(i-2)))==0) {//位与运算,检查i-2位是否已经是1,如果结果是0,代表该位不是1,即没有走过
CostTime += TravelTime[curr][i];
GreatVoyage(ReachedIsland + 1, i, CostTime,Travelled+(1<<(i-2)));
CostTime -=TravelTime[curr][I];//回溯
}
}
}
int main(){
scanf("%d",&Final);
for(int i=1;i<=Final;i++){
for(int j=1;j<=Final;j++){
scanf("%d",&TravelTime[i][j]);
}
}
GreatVoyage(1,1,0,0);//最初走了1号岛,现在在1号岛,花费时间为0,路径是0
printf("%d\n",MinTime);
}
(如果不明白位与运算的话,我在这里简单解释一下,11011&00010=00010,11011&00100=00000,对二进制的每一位进行与的操作,只有两数这一位都为1时,结果的对应位才是1 ,我们可以借此检查某一位是否是1)
对于这样的一个枚举程序,我们喜闻乐见(苦大仇深)地看到了Time Limit Exceeded这样标红的三个单词。那么接下来有两个思路来进行改进。
1)进行搜索剪枝(比如当前CostTime已经大于MinTime,直接return,但一个大概不够);
2)动态规划(用空间换时间);
由于笔者水平有限,没有将两者结合起来,而前者没有想到什么特别强的剪枝方法,所以我们只能考虑后者。
优化:
千万不要被动态规划这个名词给吓住!其实可以当作我们现在在寻找另一种与直接枚举不同的解决问题的算法。
提示:样例2
1,2,3,4,5: 18+45+96+52=211
1,2,4,3,5: 18+78+29+12=137
1,3,2,4,5: 13+38+78+52=181
1,3,4,2,5: 13+96+19+43=171
1,4,2,3,5: 98+19+45+12=174
1,4,3,2,5: 98+29+38+43=208
继续观察提示中的样例2,我们要想获得结尾是5,路径为11111的最短路径,就要找到路径为01111的,结尾分别为2,3,4的最短路径(之后再从该位置走到5即可),在这三者中取最小值;接下来我们要获得结尾是2的路径为01111的最短路径,就要找到路径为01101的结尾是3,4的最短路径(之后再从该位置走到2即可),在这两者之间取较小值……这样我们就会把一个大问题化为许多规模较小的子问题。而我们知道的是,路径为00011,结尾为2的时间为TravelTime[1][2];路径为00101,结尾为3的时间为TravelTime[1][3]……那么这个目标问题不断拆分成子问题最终一定能求出解来。
为了方便我们计算,我们需要一个二维数组来存储特定状态下的花费时间,所以我建立了一个名叫Voyage的二维数组,第一个坐标代表当前路径状态,第二个坐标代表当前所在岛(也就是结尾所在岛)。同样地,我们只需要中间N-2个岛路径即可。
注意:1)代码中还用到了两个位运算的技巧
第一个:如何判断一个数j是不是2的n次方? 考虑j&(j-1),如果结果是0,那么就是2的n次方。e.g. 8=001000,7=000111,8&7=000000;
第二个:如何表示把j中二进制第i位的1变成0? 考虑位异或运算符,j^(1<<i),异或代表若这两位相同,结果对应位为0;若这两位不同,结果对应位为1。j的第i位是1,1<<i的第i位也是1(<<是位左移,顾名思义),异或后对应位结果为0,意义就是假设这座岛没有被走过。
2)由于在代码中为了减小空间复杂度,我们只考虑了中间N-2座岛的顺序,所以Voyage第一个坐标的范围是0~1<<14(2的14次方),那么在表示路径中的第i位,实际上相当于第i+2座岛。(i从0开始)
最终代码:
内存:1040kB 时间:12ms
#include<iostream>
using namespace std;
int MinTime=999999;
int TravelTime[17][17];//存放邻接矩阵,也就是时间表
int Final;
int Voyage[1<<14][14];//前者是路径,后者是路径结尾所在岛屿-2
int main(){
scanf("%d",&Final);
for(int i=1;i<=Final;i++){
for(int j=1;j<=Final;j++){
scanf("%d",&TravelTime[i][j]);
}
}
if(Final==2){//如果只有两座岛,毋庸置疑
printf("%d\n",TravelTime[1][2]);
return 0;
}
for(int i=0;i<=Final-3;i++) {
Voyage[1<<i][i] = TravelTime[1][i+2];//+2的原因在注意的第2条已经写了
}
int x=0;
for(int j=1;j<1<<(Final-2);j++){
if((j&(j-1))==0){//如果是2的次方,那么在上面的循环中已经算过了
x++;//x代表当前的j的二进制中的1最大是多少位,那么之后的循环就可以不用循环完,到x 就可以了
}
else {
for (int i = 0; i <= x ; i++) {
if ((j & (1 << i)) != 0) {//j路径中有i
int tmp = 999999;
for (int k = 0; k <= x ; k++) {
if (((j ^ (1 << i)) & (1 << k )) != 0) {//j路径中删去i后是否还有k,如果有k就试一下结尾所在岛是k的情况
tmp = tmp < Voyage[j ^ (1 << i)][k] + TravelTime[k+2][i+2] ? tmp : Voyage[j ^ (1 << i)][k] + TravelTime[k+2][i+2];//取较小值,也可以用min函数,但需要添加<algorithm>头文件
}
}
Voyage[j][i] = tmp;//Voyage[j][i]路径最短,就是假设没有走i的情况下,看上一个路径状态中所有结尾所在岛情况的最小值
}
}
}
}
for(int i=0;i<=Final-3;i++){
MinTime=min(MinTime,Voyage[(1<<(Final-2))-1][i]+TravelTime[i+2][Final]);
}
printf("%d\n",MinTime);
}
总结:
位运算与状态压缩是在处理这种复杂问题中十分有效的方法。
其实这个程序虽然有时可以到11ms,但是我见到还有比这个更快的,在个位数ms的,只是我暂时没有更好的思路。
动态规划把这个问题的复杂度压缩到O(n^2*2^n),据说是最低的复杂度,所以动态规划和拆分子问题的思想也十分重要。
希望这篇文章可以为您带来帮助!