7.5 路径寻找问题

第6章介绍过图的遍历,很多问题都可以归结为图的遍历,但这些图不是事先给定的,而是由程序动态生成的,称为隐式图
本节和前面介绍的回溯法不同,回溯法一般是要找到一个(或者所有)满足约束的解,(或者某种意义下的最优解),而状态空间搜索一般是要找到一个从初始状态到终止状态的路径
路径寻找问题可以归结为隐式图的遍历,它的任务是找到一条从初始状态到终止状态的最有路径,而不是像回溯法那样找到一个符合某些要求的解
典型的例子就是八数码问题

不难把八数码问题归结为图上的最短路问题(BFS又来了),图的结点就是9个格子中的滑块编号(从上到下,从左到右,放在一个9元组中),无权图上的最短路问题可以使用BFS求解

在理解作者程序之前,笔者先讲解一些typedef的相关知识
很明显int a[10],表示标识符a表示一个int[10]类型的变量存储
但是如果此时在前面加上typedef修饰,即typedef int a[10]
此时表示的含义是a从普通变量变成了一个数据类型int[10]
比如想要定义int b[10],此时也可以使用a b,二者的功能是等价的
总之一旦加上typedef关键字,表明后面的标识符不再是普通变量,而是代表去掉typedef该标识符的数据类型
比如typedef int a[10],去掉typedef为int a[10],标识符a是int[10]类型的,所以此时a代表int[10]这种数据类型

点击查看代码
typedef int State[9]; //定义“状态”类型,这边作者采用的是9元组来存放,注意这边State代表int[9]数据类型 
const int maxstate = 1000000; 
State st[maxstate], goal; //状态数组。所有状态都保存在这里,内存消耗很大,作者自己手写队列,并没有使用STL中的queue 
int dist[maxstate]; //距离数组,与上面的状态相对应,作者是将状态和距离割裂开来,存放在两个数组中 
//如果需要打印方案,可以在这里加一个“父亲编号数组” int fa[maxstate],从终点不断回溯,stack存储即可 

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被看作“不存在”
  //这边主要是人为定义的问题,还需要注意的问题,最后输出的是dist,而不是front,因此我们可以大胆的将0看作是非法状态 
  while(front < rear) { //非常简单的队列模拟(或许可以使用循环队列优化空间?) 
    State& s = st[front];//用“引用”简化代码,不适用*是因为使用*后面的调用不如引用方便 
    if(memcpy(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函数之前应该设置好s[1]和goal。
这边注意如何简便的实现从一种状态向另一种状态的转移,我们需要明白八数码的转换真正实现的其实就是两个数字的swap,因此我们并不需要每次都对原先的数据进行全部的移动,而只要转换特定的两个数据就可以了
上述代码中的init_lookup_table()和try_to_insert(rear)还没有实现
BFS为了提高效率,同时具有局部最优是全局最优的特点,因此BFS中存在判重操作,在DFS中可以检查idx来判断结点是否已经访问过,在求最短路的BFS中用d值是否为-1来判断系欸但是否访问过,他们的核心思想都是一致的,防止一个结点被访问多次。树的BFS不需要判重,因为根本不可能成环,但是如果对于图来说,不判重,时间和空间都会产生大量的浪费
最简单的方法就是开辟一个九维数组来存储所有的可能信息,但是很明显,该数组非常大,而且有很多地方冗余,那么此时很自然的想到离散化的思想,我们可以进行排序编号,简称康托变换

作者在这边介绍了三种解决方法:
第一种方法:把排列变成整数,然后只开一个一维数组,也就是说,设计一套排列的编码(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;
  }//code本身代表的含义是该序列在从小到大的排序中的次序 
  if(vis[code]) return 0;
  return vis[code] = 1;
}

尽管原理巧妙,时间效率也非常高,但编码(encoding)解码(decoding)的使用范围并不大,如果隐式图的结点数非常大,编码也会很大,数组还是开不下
注意该中编码本质上的原理应该是将0-8的全排列排序,然后进行编号,最后进行映射,但是一旦数据量可能性过大的时候,排序编号的方法便不再适用,因为这些编号会产生大量的冗余
我们可以这样理解,原先0-8的全排列,并没有将一个数位全部用上,以十进制为例,9没有用上,因此此时我们可以选择排序后进行便后,使得原先的可能性从松散分布变成集中分布,但是此时并非最优,因为很有可能有很多状态是没有被涉及到的,也就是考虑到有效数据的分布可能仍然是松散的,因此此时只要数据量过大,那么对于内存的浪费是非常大的,因此hash出现了,加上hash冲突处理的内存存储是一个非常强大的存储结构,但是就是如果没有一个好的hash函数,查找特定元素仍然可能从o(1)变成o(n)

第二种方法:使用哈希(hash)技术。简单的说,就是要把结点变成整数,但不必是一一对应。换句话说,只需要设计一个所谓的哈希函数h(x),然后将任意结点x映射到某个给定范围[0,M-1]的整数即可,其中M是程序元根据可用内存大小自选的,在理想情况下,只需开一个大小为M的数组就能完成判重,但此时往往会有不同节点的哈希值相同,因此需要把哈希值相同的状态组织成链表,细节参见下面的代码

点击查看代码
const int hashsize = 1000003;
int head[hashsize], next[hashsize];
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; 
}

int try_to_insert(int s) {
  int h = hash(st[s]);
  int u = head[h];//从表头开始查找链表(这边主要做的事情就是查找里面的链表中是否出现了s这个状态) 
  while(u) {
  	if(memcmp(st[u], st[s], sizeof(st[s])) == 0) return 0; //注意因为作者保证了s状态经过该步骤的唯一性,因此即使发生hash冲突,那么也会顺利加入链表 
    //解决hash冲突一定要原先的数据,否则难以判断 
  	u = next[u];
  }
  next[s] = head[h];//注意是将新的元素插入表头不是表尾 
  head[h] = s;
  return 1;
}

哈希表的执行效率高,适用范围也很广,除了BFS中的结点判重外,还可以用到其他需要快速查找的地方。不过需要注意的是:在哈希表中,对效率起到关键作用的是哈希函数。如果哈希函数选取得当,几乎不会有结点的哈希值相同,且此时链表查找的速度比较快,但是如果冲突严重,整个哈希表会退化成少数几条长长的链表,查找速度非常缓慢,有趣的是,前面的编码函数可以看作是一个完美的哈希函数,不需要解决冲突,不过如果事先不知道它是完美的,也就不敢向前面一样只开一个vis数组,哈希技术还有很多知识,笔者将会在其他篇目中学习以及记录。

第三种方法:使用STL集合t,把状态转化成9位十进制数,就可以用set判重了

点击查看代码
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;
} 

很明显,利用C++中STL下的se的代码最为简单,但时间效率也最低,一般在时间紧迫或对效率要求不高的情况下使用,或者把set当作跳板,先写一个STL板的程序,确保主算法正确,然后把set替换成自己写的hash表

隐式图遍历需要用一个结点查找来判重。一般来说,使用STL集合实现的代码最简单,但是效率也较低。如果题目对时间要求很高,可以先把STL集合版的程序调试通过,然后转化为哈希表甚至完美哈希表

某些特定的STL还有hash_set,他正是基于前面的哈希表,但他并不是标准C++的一部分,因此不是所有情况下都适用

posted @   banyanrong  阅读(57)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示