7.5 路径寻找问题
在第6章中曾经介绍过图的遍历。很多问题都可以归结为图的遍历,但这些问题中的图却不是事先给定、从程序读入的,而是由程序动态生成的,称为隐式图。本节和前面介绍的回溯法不同:回溯法一般是要找到一个(或者所有)满足约束的解(或者某种意义下的最优解),而状态空间搜索一般是要找到一个从初始状态到终止状态的路径。
提示7-15:路径寻找问题可以归结为隐式图的遍历,它的任务是找到一条从初始状态到终止状态的最优路径,而不是像回溯法那样找到一个符合某些要求的解。
八数码问题。编号为1~8的8个正方形滑块被摆成3行3列(有一个格子留空),如图7-14所示。每次可以把与空格相邻的滑块(有公共边才算相邻)移到空格中,而它原来的位置就成为了新的空格。给定初始局面和目标局面(用0表示空格),你的任务是计算出最少的移动步数。如果无法到达目标局面,则输出-1。
| ![]() |
图7-14 八数码问题举例
样例输入:
2 6 4 1 3 7 0 5 8
8 1 5 7 3 6 4 0 2
样例输出:
31
【分析】
不难把八数码问题归结为图上的最短路问题,图的“结点”就是9个格子中的滑块编号(从上到下、从左到右把它们放到一个包含9个元素的数组中)。根据第6章的讲解,无权图上的最短路问题可以用BFS求解,代码如下:
typedef int State[9]; //定义"状态"类型
const int maxstate = 1000000;
State st[maxstate], goal; //状态数组。所有状态都保存在这里
int dist[maxstate]; //距离数组
//如果需要打印方案,可以在这里加一个"父亲编号"数组 int fa[maxstate]
const int dx[ ] = {-1, 1, 0, 0};
const int dy[ ] = {0, 0, -1, 1};
//BFS,返回目标状态在st数组下标
int bfs( ) {
init_lookup_table( ); //初始化查找表
int front = 1, rear = 2; //不使用下标0,因为0被看作"不存在"
while(front < rear) {
State& s = st[front]; //用"引用"简化代码
if(memcmp(goal, s, sizeof(s)) == 0) return front;//找到目标状态,成功返回
int z;
for(z = 0; z < 9; z++) if(!s[z]) break; //找"0"的位置
int x = z/3, y = z%3; //获取行列编号(0~2)
for(int d = 0; d < 4; d++) {
int newx = x + dx[d];
int newy = y + dy[d];
int newz = newx * 3 + newy;
if(newx >= 0 && newx < 3 && newy >= 0 && newy < 3){ //如果移动合法
State& t = st[rear];
memcpy(&t, &s, sizeof(s)); //扩展新结点
t[newz] = s[z];
t[z] = s[newz];
dist[rear] = dist[front] + 1; //更新新结点的距离值
if(try_to_insert(rear)) rear++; //如果成功插入查找表,修改队尾指针
}
}
front++; //扩展完毕后再修改队首指针
}
return 0; //失败
}
注意,此处用到了cstring中的memcmp和memcpy完成整块内存的比较和复制,比用循环比较和循环赋值要快。主程序很容易实现:
int main( ){
for(int i = 0; i < 9; i++) scanf("%d", &st[1][i]); //起始状态
for(int i = 0; i < 9; i++) scanf("%d", &goal[i]); //目标状态
int ans = bfs( ); //返回目标状态的下标
if(ans > 0) printf("%d\n", dist[ans]);
else printf("-1\n");
return 0;
}
注意,应在调用bfs函数之前设置好st[1]和goal。上面的代码几乎是完整的,唯一没有涉及的是init_lookup_table( )和try_to_insert(rear)的实现。为什么会有这两项呢?还记得BFS中的“判重”操作吗?在DFS中可以检查idx来判断结点是否已经访问过;在求最短路的BFS中用d值是否为-1来判断结点是否访问过,不管用哪种方法,作用是相同的:避免同一个结点访问多次。树的BFS不需要判重,因为根本不会重复;但对于图来说,如果不判重,时间和空间都将产生极大的浪费。
如何判重呢?难道要声明一个9维数组vis,然后执行if(vis[s[0]][s[1]][s[2]]…s[8]))?无论程序好不好看,9维数组的每维都要包含9个元素,一共有99=387420489项,太多了,数组开不下。实际的结点数并没有这么多(0~8的排列总共只有9!=362880个),为什么9维数组开不下呢?原因在于,这样的用法存在大量的浪费——数组中有很多项都没有被用到,但却占据了空间。
下面通过讨论3种常见的方法来解决这个问题,同时将它们用到八数码问题中。
第1种方法是:把排列“变成”整数,然后只开一个一维数组。也就是说,设计一套排列的编码(encoding)和解码(decoding)函数,把0~8的全排列和0~362879的整数一一对应起来。第10章中将详细讨论编码和解码问题,这里先给出代码以便读者形成一个感性认识:
int vis[362880], fact[9];
void init_lookup_table( ){
fact[0] = 1;
for(int i = 1; i < 9; i++) fact[i] = fact[i-1] * i;
}
int try_to_insert(int s){
int code = 0; //把st[s]映射到整数code
for(int i = 0; i < 9; i++){
int cnt = 0;
for(int j = i+1; j < 9; j++) if(st[s][j] < st[s][i]) cnt++;
code += fact[8-i] * cnt;
}
if(vis[code]) return 0;
return vis[code] = 1;
}
尽管原理巧妙,时间效率也非常高,但编码解码法的适用范围并不大:如果隐式图的总结点数非常大,编码也将会很大,数组还是开不下。
第2种方法是使用哈希(hash)技术。简单地说,就是要把结点“变成”整数,但不必是一一对应。换句话说,只需要设计一个所谓的哈希函数h(x),然后将任意结点x映射到某个给定范围[0, M-1]的整数即可,其中M是程序员根据可用内存大小自选的。在理想情况下,只需开一个大小为M的数组就能完成判重,但此时往往会有不同结点的哈希值相同,因此需要把哈希值相同的状态组织成链表,细节参见下面的代码:
const int hashsize = 1000003;
int head[hashsize], next[maxstate];
void init_lookup_table( ) { memset(head, 0, sizeof(head)); }
int hash(State& s){
int v = 0;
for(int i = 0; i < 9; i++) v = v * 10 + s[i];//把9个数字组合成9位数
return v % hashsize; //确保hash函数值是不超过hash表的大小的非负整数
}
int try_to_insert(int s){
int h = hash(st[s]);
int u = head[h]; //从表头开始查找链表
while(u){
if(memcmp(st[u],st[s], sizeof(st[s]))==0)return 0; //找到了,插入失败
u = next[u]; //顺着链表继续找
}
next[s] = head[h]; //插入到链表中
head[h] = s;
return 1;
}
哈希表的执行效率高,适用范围也很广。除了BFS中的结点判重外,还可以用到其他需要快速查找的地方。不过需要注意的是:在哈希表中,对效率起到关键作用的是哈希函数。如果哈希函数选取得当,几乎不会有结点的哈希值相同,且此时链表查找的速度也较快;但如果冲突严重,整个哈希表会退化成少数几条长长的链表,查找速度将非常缓慢。有趣的是,前面的编码函数可以看作是一个完美的哈希函数,不需要解决冲突。不过,如果事先并不知道它是完美的,也就不敢像前面一样只开一个vis数组。哈希技术还有很多值得探讨的地方,建议读者在网上查找相关资料。
第3种方法是用STL集合t。把状态转化成9位十进制整数,就可以用set<int>判重了:
set<int> vis;
void init_lookup_table( ) { vis.clear( ); }
int try_to_insert(int s){
int v = 0;
for(int i = 0; i < 9; i++) v = v * 10 + st[s][i];
if(vis.count(v)) return 0;
vis.insert(v);
return 1;
}
在刚才的3种实现中,使用STL集合的代码最简单,但时间效率也最低(若此时不用-O2优化则速度劣势更加明显)。建议读者在时间紧迫或对效率要求不太高的情况下使用,或者仅把它作为“跳板”——先写一个STL版的程序,确保主算法正确,然后把set替换成自己写的哈希表。
提示7-16:隐式图遍历需要用一个结点查找表来判重。一般来说,使用STL集合实现的代码最简单,但效率也较低。如果题目对时间要求很高,可以先把STL集合版的程序调试通过,然后转化为哈希表甚至完美哈希表。
某些特定的STL实现中还有hash_set,它正是基于前面的哈希表,但它并不是标准C++的一部分,因此不是所有情况下都可用。
例题7-8 倒水问题(Fill, UVa 10603)
有装满水的6升的杯子、空的3升杯子和1升杯子,3个杯子中都没有刻度。在不使用其他道具的情况下,是否可以量出4升的水呢?
方法如图7-15所示。
图7-15 倒水问题:一种方法是(6,0,0)→(3,3,0)→(3,2,1)→(4,2,0)
注意:由于没有刻度,用杯子x给杯子y倒水时必须一直持续到把杯子y倒满或者把杯子x倒空,而不能中途停止。
你的任务是解决一般性的问题:设3个杯子的容量分别为a, b, c,最初只有第3个杯子装满了c升水,其他两个杯子为空。最少需要倒多少升水才能让某一个杯子中的水有d升呢?如果无法做到恰好d升,就让某一个杯子里的水是d'升,其中d'<d并且尽量接近d。(1≤a,b,c,d≤200)。要求输出最少的倒水量和目标水量(d或者d')。
【分析】
假设在某一时刻,第1个杯子中有v0升水,第2个杯子中有v1升水,第3个杯子中有v2升水,称当时的系统状态为(v0,v1,v2)。这里再次提到了“状态”这个词,它是理解很多概念和算法的关键。简单地说,它就是“对系统当前状况的描述”。例如,在国际象棋中,当前游戏者和棋盘上的局面就是刻画游戏进程的状态。
图7-16 倒水问题的状态图
把“状态”想象成图中的结点,可以得到如图7-16所示的状态图(state graph)。
由于无论如何倒,杯子中的水量都是整数(按照倒水次数归纳即可),因此第3个杯子的水量最多只有0, 1, 2,…, c共c+1种可能;同理,第2个杯子的水量一共只有b+1种可能,第1个杯子一共只有a+1种可能,因此理论上状态最多有(a+1)(b+1)(c+1)=8120601种可能性,有点大。幸运的是,上面的估计是不精确的。由于水的总量x永远不变,如果有两个状态的前两个杯子的水量都相同,则第3个杯子的水量也相同。换句话说,最多可能的状态数不会超过2012=40401。
注意:本题的目标是倒的水量最少,而不是步数最少。实际上,水量最少时步数不一定最少,例如a=1, b=12, c=15, d=7,倒水量最少的方案是C->A, A->B重复7次,最后C里有7升水。一共14步,总水量也是14。还有一种方法是C->B,然后B->A, A->C重复4次,最后C里有7升水。一共只有10步,但总水量多达20。
因此,需要改一下算法:不是每次取出步数最少的结点进行扩展,而是取出水量最少的结点进行扩展。这样的程序只需要把队列queue换成优先队列priority_queue,其他部分的代码不变。下面的代码把状态(三元组)和dist合起来定义为了一个Node类型,是一种常见的写法。如果要打印路径,需要把访问过的所有结点放在一个nodes数组中,然后在Node中加一个变量fa,表示父结点在nodes数组中的下标,而在队列中只存结点在nodes数组中的下标而非结点本身。如果内存充足,也可以直接在Node中用一个vector保存路径,省去顺着fa往回找的麻烦。
#include<cstdio>
#include<cstring>
#include<queue>
using namespace std;
struct Node {
int v[3], dist;
bool operator < (const Node& rhs) const {
return dist > rhs.dist;
}
};
const int maxn = 200 + 5;
int vis[maxn][maxn], cap[3], ans[maxn];
void update_ans(const Node& u) {
for(int i = 0; i < 3; i++) {
int d = u.v[i];
if(ans[d] < 0 || u.dist < ans[d]) ans[d] = u.dist;
}
}
void solve(int a, int b, int c, int d) {
cap[0] = a; cap[1] = b; cap[2] = c;
memset(vis, 0, sizeof(vis));
memset(ans, -1, sizeof(ans));
priority_queue<Node> q;
Node start;
start.dist = 0;
start.v[0] = 0; start.v[1] = 0; start.v[2] = c;
q.push(start);
vis[0][0] = 1;
while(!q.empty( )) {
Node u = q.top( ); q.pop( );
update_ans(u);
if(ans[d] >= 0) break;
for(int i = 0; i < 3; i++)
for(int j = 0; j < 3; j++) if(i != j) {
if(u.v[i] == 0 || u.v[j] == cap[j]) continue;
int amount = min(cap[j], u.v[i] + u.v[j]) - u.v[j];
Node u2;
memcpy(&u2, &u, sizeof(u));
u2.dist = u.dist + amount;
u2.v[i] -= amount;
u2.v[j] += amount;
if(!vis[u2.v[0]][u2.v[1]]) {
vis[u2.v[0]][u2.v[1]] = 1;
q.push(u2);
}
}
}
while(d >= 0) {
if(ans[d] >= 0) {
printf("%d %d\n", ans[d], d);
return;
}
d--;
}
}
int main( ) {
int T, a, b, c, d;
scanf("%d", &T);
while(T——) {
scanf("%d%d%d%d", &a, &b, &c, &d);
solve(a, b, c, d);
}
return 0;
}
需要注意的是:上述算法非常直观,正确性却不是显然的。事实上,笔者目前没有找到反例,但也无法严格证明它是正确的(1)。幸运的是,上述算法稍加修改,就可以得到第11章中要介绍的Dijkstra算法,从而保证算法的正确性。等学完Dijkstra算法之后,读者不妨回来再看看这道题目,相信会有新的体会。希望读者能够通过这个例题看到搜索和图论这两个看似无关的主题之间的联系。
例题7-9 万圣节后的早晨(The Morning after Halloween, Japan 2007, UVa1601)
w*h(w,h≤16)网格上有n(n≤3)个小写字母(代表鬼)。要求把它们分别移动到对应的大写字母里。每步可以有多个鬼同时移动(均为往上下左右4个方向之一移动),但每步结束之后任何两个鬼不能占用同一个位置,也不能在一步之内交换位置。例如如图7-17所示的局面:一共有4种移动方式,如图7-18所示。
| ![]() |
| 图7-17 题设局面 | 图7-18 4种移动方式 |
输入保证所有空格连通,所有障碍格也连通,且任何一个2*2子网格中至少有一个障碍格。输出最少的步数。输入保证有解。
【分析】
以当前3个小写字母的位置为状态,则问题转化为图上的最短路问题。状态总数为2563,每次转移时需要53枚举每一个小写字母下一步的走法(上下左右加上“不动”)。可惜状态数已经很大了,转移代价又比较高,很容易超时,需要优化。
首先是优化转移代价。条件“任何一个2*2子网格中至少有一个障碍格”暗示着很多格子都是障碍,并且大部分空地都和障碍相邻,因此不是所有4个方向都能移动,因此可以把所有空格提出来建立一张图,而不是每次临时判断5种方案是否合法。加入这个优化以后BFS就可以通过本题的数据了,但还有改进的空间。
其次是换一个算法,例如双向广度优先搜索(2)。这种算法在前面并没有介绍,但是对于“暴力搜索”这样的非常规算法来说,并不一定要严格遵守所谓的“标准方法”。例如,提到“双向广度优先算法”,可以“想当然”地设计出这样的算法:正着搜索一层,反着搜索一层,然后继续这样交替下去,直到两层中出现相同的状态,读者不妨一试。
本题非常经典,强烈推荐读者编写程序。