从迷宫问题、连连看、红与黑说回溯算法遍历解空间
今天上午完成了“迷宫”问题,也思考了“2.5基本算法之搜索”的另外几个问题:小游戏(就一连连看),马走日,红与黑等。我所关注的这几个问题都可以用回溯算法来进行解决。回溯算法简单说就是当运行到叶子节点证明不是解时回到上一层节点继续遍历,如此循环直到找到一个解;如果需要全部解,可以继续遍历,如果不需要可以直接退出。很明显,回溯算法是一种深度优先的搜索算法,非常适合在解空间中找到一个解的问题。
一、迷宫问题:
1792:迷宫 总时间限制: 3000ms 内存限制: 65536kB 描述 一天Extense在森林里探险的时候不小心走入了一个迷宫,迷宫可以看成是由n * n的格点组成,每个格点只有2种状态,.和#,前者表示可以通行后者表示不能通行。同时当Extense处在某个格点时,他只能移动到东南西北(或者说上下左右)四个方向之一的相邻格点上,Extense想要从点A走到点B,问在不走出迷宫的情况下能不能办到。如果起点或者终点有一个不能通行(为#),则看成无法办到。 输入 第1行是测试数据的组数k,后面跟着k组输入。每组测试数据的第1行是一个正整数n (1 <= n <= 100),表示迷宫的规模是n * n的。接下来是一个n * n的矩阵,矩阵中的元素为.或者#。再接下来一行是4个整数ha, la, hb, lb,描述A处在第ha行, 第la列,B处在第hb行, 第lb列。注意到ha, la, hb, lb全部是从0开始计数的。 输出 k行,每行输出对应一个输入。能办到则输出“YES”,否则输出“NO”。 样例输入 2 3 .## ..# #.. 0 0 2 2 5 ..... ###.# ..#.. ###.. ...#. 0 0 4 0 样例输出 YES NO
对于迷宫问题,如果要求一个解,那么回溯法无疑是非常快的,它往往只需要遍历迷宫中很少的部分——具体遍历多少要看我们的排序方法和迷宫的情况是否相符。
通常,实现回溯的方法是使用栈来存储节点,这样后存储的节点将被先搜索,如果一个叶子节点失败了,也无需特别的操作,只要继续出栈就是在回溯。栈无疑是一种非常适合回溯的数据结构。使用栈来实现回溯时,我们何以把思维重点放在如何处理搜索“成功”与“失败”的处理上,而不必关心具体的回溯过程。下面的函数很好的诠释了这一点:
bool msearch(point curpoint,bool map[],int maplen,bool path[]){ stack<point> stackstep; stackstep.push(curpoint); point testpoint; while(!stackstep.empty()){ testpoint=stackstep.top(); stackstep.pop(); if(testpoint.x==outpoint.x && testpoint.y==outpoint.y){ return true; }else{ path[testpoint.x+testpoint.y*maplen]=true; //记录走过的路径 point steps[maplen*maplen]; int i,nextcnt =nextstep(testpoint,map,maplen,steps,path); if(nextcnt==0){ //path[testpoint.x+testpoint.y*maplen]=false; //恢复走过的记录 }else{ for(i=0;i<nextcnt;i++){ stackstep.push(steps[i]); } } } } return false; }
在while循环前,首先将第一个数据压栈,然后就可以进入循环;在循环过程中首先获取当前元素,对当前元素进行处理(此处是判断是否达到终点)的同时继续压栈相关元素(这里是下一步的可能位置)。这样直到栈为空时全部元素都被处理一遍,即全部节点都被处理完成。
上面的函数中,使用了一个与map大小相同的path数组,它的功能是将当前路径经过的点记录下来以防止程序陷入死循环和加速,可以看到在处理当前点时,把当前点记录下来,绿的被我注释掉的行就是回溯过程把这个点记录恢复为没有走过。这里要思考一下,是否需要恢复走过的路径上的点:
1、若不恢复,会不会对父节点的兄弟节点或父节点的父节点搜索产生影响?
2、若恢复,会不会导致搜索效率降低?
其实这两个问题很好解答,是否注释掉要看我们需要多少解。如果只需要一个解,那么:
1、若路径上有解,没有必要恢复。
2、路径上没有解,也没有必要恢复。
所以,在当前只需要找到一条路径的这个迷宫问题中,注释掉可以提高效率。但是,如果需要多个解:
1、若路径上有解,必须恢复,以备其他路径再次找到这一段路径上的某个部分。
2、若路径上没有解,那么没有必要恢复。
结论是当需要多个解且路径上有解时,需要恢复路径,否则不用恢复。
另外,为了提高搜索效率,我们可以用一些“贪心”的思想:对下一步可走的路径点进行排序——按距离终点从远到近的顺序(还记得后入先出吧),这样距离终点较近的子树将被优先搜索:
int nextstep(point curpoint,bool map[],int maplen,point steps[],bool path[]){ int i,tmpidx,stepcnt=0; point testpoint; //搜索除路径上的点以外的可行点:在搜索前进行剪枝,防止倒退进入循环。 for(i=0;i<4;i++){ testpoint.x=curpoint.x+direction[i].x; testpoint.y=curpoint.y+direction[i].y; tmpidx=testpoint.x+testpoint.y*maplen; if(0<=testpoint.x && testpoint.x<maplen && 0<=testpoint.y && testpoint.y<maplen && map[tmpidx] && !path[tmpidx]){ //在地图上、地图上标记为可走、没有走过 steps[stepcnt++]=testpoint; } } //对结果进行排序:距离目标点近的排在后面,是一种贪心思想。 sort(steps,steps+stepcnt,compare); return stepcnt; }
当然,这样做有一定的风险,因为我们无法证明贪心是有效的,但至少比每次都先像南(下)搜索靠谱一点——期待迷宫不是被针对设计的吧。就本题来说,测试数据有可能数据不像常人所理解的出入口就在迷宫里面或者入口和出口是分开的,这是一个处于空间曲率飘忽不定的空间内的迷宫,一切皆有可能,所以搜索之前还需要一些界定。把完整代码贴在这里:
#include<iostream> #include<cstring> #include<stack> #include<algorithm> //回溯法 using namespace std; struct point{ int x; int y; }; point outpoint; point direction[4]={{0,-1},{0,1},{-1,0},{1,0}};//u,d,l,r bool compare(const point a,const point b){ int lax=a.x-outpoint.x; int lay=a.y-outpoint.y; int lbx=b.x-outpoint.x; int lby=b.y-outpoint.y; return lbx*lbx+lby*lby-lax*lax-lay*lay; } int nextstep(point curpoint,bool map[],int maplen,point steps[],bool path[]){ int i,tmpidx,stepcnt=0; point testpoint; //搜索除路径上的点以外的可行点 for(i=0;i<4;i++){ testpoint.x=curpoint.x+direction[i].x; testpoint.y=curpoint.y+direction[i].y; tmpidx=testpoint.x+testpoint.y*maplen; if(0<=testpoint.x && testpoint.x<maplen && 0<=testpoint.y && testpoint.y<maplen && map[tmpidx] && !path[tmpidx]){ //在地图上、地图上标记为可走、没有走过 steps[stepcnt++]=testpoint; } } //对结果进行排序:距离目标点近的排在后面,是一种贪心思想。 sort(steps,steps+stepcnt,compare); return stepcnt; } bool msearch(point curpoint,bool map[],int maplen,bool path[]){ stack<point> stackstep; stackstep.push(curpoint); point testpoint; while(!stackstep.empty()){ testpoint=stackstep.top(); stackstep.pop(); if(testpoint.x==outpoint.x && testpoint.y==outpoint.y){ return true; }else{ path[testpoint.x+testpoint.y*maplen]=true; //记录走过的路径 point steps[maplen*maplen]; int i,nextcnt =nextstep(testpoint,map,maplen,steps,path); if(nextcnt==0){ //path[testpoint.x+testpoint.y*maplen]=false; //仅当需要多个解且当前路径上有解时需要恢复路径记录。 }else{ for(i=0;i<nextcnt;i++){ stackstep.push(steps[i]); } } } } return false; } int main(){ int i,y,x,mapscnt,maplen; point curpoint; string mapline; bool *map,*path; cin>>mapscnt; for(i=0;i<mapscnt;i++){ cin>>maplen; map=new bool[maplen*maplen]; path=new bool[maplen*maplen]; memset(map,0,sizeof(true)*maplen*maplen); memset(path,0,sizeof(true)*maplen*maplen); for(y=0;y<maplen;y++){ cin>>mapline; for(x=0;x<maplen;x++){ if(mapline[x]=='.'){ map[x+y*maplen]=true; } } } cin>>curpoint.y>>curpoint.x>>outpoint.y>>outpoint.x; if(0<=curpoint.x && curpoint.x<maplen && 0<=curpoint.y && curpoint.y<maplen && 0<=outpoint.x && outpoint.x<maplen && 0<=outpoint.y && outpoint.y<maplen && map[curpoint.x+curpoint.y*maplen] && map[outpoint.x+outpoint.y*maplen]){ path[curpoint.x+curpoint.y*maplen]=true; if(msearch(curpoint,map,maplen,path)){ printf("%s\n","YES"); }else{ printf("%s\n","NO"); } }else{ printf("%s\n","NO"); } delete []map; delete []path; } }
二、连连看
这个问题是这样的:http://noi.openjudge.cn/ch0205/1804/
怎么看都是一个连连看游戏。这个问题实际上是这样的:
在一个宽度高度分别为w,h的面板中,从(1,1)到(w-1,h-1)范围内放置卡片。即外围又一圈通路。这样这个问题就变成迷宫问题,如果只要求一个路径,那么解法相同。但它需要搜索全部解,并进行比较,找出拐点最少的一个解。所以,如果限定时间比较短的话,最好还是用BFS。后面会另外写一篇这个问题的解法。
三、马走日
这个问题与二相同,题目都要求遍历全部解。所以,它们都可以用另一种方式来进行搜索:
void msearch(point curpoint,bool map[],int maplen,int depth,bool path[]){ if(path[outpoint.x+outpoint.y*maplen]){ //达到终点 flag=true; } if(depth==0 && flag){ //达到最深可能深度或达到终点时结束搜索 return; }else{ point *steps=new point[maplen*maplen]; int i,nextcnt =nextstep(curpoint,map,maplen,steps,path); for(i=0;i<nextcnt;i++){ //遍历每一种可能走法 path[steps[i].x+steps[i].y*maplen]=true; //执行一步 msearch(steps[i],map,maplen,depth-1,path); path[steps[i].x+steps[i].y*maplen]=false; //撤销一步 } delete []steps; } }
这种递归的方式也存在回溯过程,但想用它来找到一个解将比较耗时。
四、红与黑
这是这样一个问题:
总时间限制: 1000ms 内存限制: 65536kB 描述 有一间长方形的房子,地上铺了红色、黑色两种颜色的正方形瓷砖。你站在其中一块黑色的瓷砖上,只能向相邻的黑色瓷砖移动。请写一个程序,计算你总共能够到达多少块黑色的瓷砖。 输入 包括多个数据集合。每个数据集合的第一行是两个整数W和H,分别表示x方向和y方向瓷砖的数量。W和H都不超过20。在接下来的H行中,每行包括W个字符。每个字符表示一块瓷砖的颜色,规则如下 1)‘.’:黑色的瓷砖; 2)‘#’:白色的瓷砖; 3)‘@’:黑色的瓷砖,并且你站在这块瓷砖上。该字符在每个数据集合中唯一出现一次。 当在一行中读入的是两个零时,表示输入结束。 输出 对每个数据集合,分别输出一行,显示你从初始位置出发能到达的瓷砖数(记数时包括初始位置的瓷砖)。 样例输入 6 9 ....#. .....# ...... ...... ...... ...... ...... #@...# .#..#. 0 0 样例输出 45
这个问题实际上就是一个4向的区域填充问题。解决思路很简单:
1 将种子坐标入栈(@的那一点) 2 循环(结束条件——堆栈是为空) 3 弹出一个点,若该点为黑色:记录数+1,设置该点为红色。将上下左右四向中位于图像内的的点入栈
这就是一个图像处理的基本代码——“快速种子填充”,在我的另一个博客上有详细的VB.NET代码和优化过程:
http://softos.org/?p=380