农夫过桩渡河(动态广搜)

      第一次写博客,还有点兴奋。好久以前就想写博客,迟迟没动笔,今天算是一个开始。

      这几天研究了一下广度搜索算法,有一点心得。以前老是深度搜索,用递归实现,从来没有用过广搜,直到前几天参加ITAT复赛时,看了下数据结构上迷宫的广搜算法,大概知道了广搜是如何实现的了。于是就动手用广搜解决一个Sramoc问题,没想到一下就解决了,只不过遇到了int溢出问题,所以在CSDN提问了,迟迟没人回答。接下来,就动手陆续解决了”六数码“,”奇怪的电梯“,还有”农夫过桩渡河“等问题。其实,个人感觉广搜比深思更容易实现一下,因为广搜不需要回溯,只要遇到满足条件的就进队,减少了回溯的实现过程。而且实现步骤也很简单,大致分为这几步:(1)初始状态入队;(2)队头节点出队,以此节点扩展,如果满足条件,则后续节点入队;(3)如果到达目标状态,则输出,可以退出了,否则转(2);(4)如果队列为空,则无解。这样有一个明显的特征就是扩展是按层进行的,其实本质就是树里面的层次序遍历过程,层数就是当前状态下已经走过的步数(或次数等等),那么最短路径(最少次数,最短时间等)也就是求这个层数,不过一般不会特别在意这个层数,也不会刻意去存储它。程序中需要重点处理的是节点状态的表示,避免节点的重复以及限制条件的表示。

      节点如何表示?一般都是采用结构体,把重要的信息保存起来,以用来扩展,这个结构体一般包括当前所处位置(即访问数组的下标)和数据等,如果需要打印路径则需要保存前驱节点(即从哪个节点扩展来的,就是我们所说的父节点),迷宫就是一个典型的例子,还有特殊情况就需要保存当前所处层数,本题就是如此。这里需要说明一下,一般最好不要用C++类库里面提供的queue用法,主要有两个问题:(1)不能打印路径,因为队头节点都已经弹出了嘛,当然也不能求最少次数了;(2)这里的queue是一个环形队列,可能在队列很长时会覆盖某些已扩展节点(跳到队头),以节损空间,这是我们班高手告诉我的。总之,用队列主要解决的是相对不是很复杂的,而且不需要打印路径或求最少次数而只需要判断能否到达目标状态(即是否有解)的题目,这样用队列处理起来比较方便,程序也比较简洁。

      如何判断节点是否重复?一般我们会用一个很大的标记数组来表示所有可能的状态,注意:这个数组一定要能够表示所有状态,否则会无解或不能够求得最优解。处理方法就是:如果当前节点已经入队,则标记为1(初始均为0),在下次出队遇到时便跳过去,不重复扩展已扩展节点,这样会大大减少冗余的扩展次数,提高效率。其实,我们可以这样想,存在重复的节点则说明绕圈形成环了,也就是说由树变为图了,这样不停地绕来绕去,什么时候是个头啊?而且在极少数情况下会形成死循环无能求出解,或者至少也会大大延长求解时间,因为队列多了很多冗余的值,需要不停地出队才能找到那个解。

      还有一个就是限制条件的表示,只有满足条件的节点才能入队,这样做的优点类似于节点的判重,而且判重本身也是限制条件。由于各种题目不一样,限制条件也不同,所以需要具体问题具体分析了。

      说了这么多,我们可以看出深搜与广搜的一些不同。(1)首先从题目入手,带”最“字求最优解的一般都是用广搜,而求所有解的一般都是用深搜,还有就是有明显边界条件的用深搜,反之用广搜。(2)广搜用队列实现,可以说是空间换时间,很耗内存,存储了那么多节点而且还要用标记数组判重,但是效率很高,不停地往前走,找到的解就是最优解;而深搜是用栈或递归(本质还是用栈)实现,用时间换空间(深度很大时也会很耗内存),因为每次只需要存储当前路径的状态信息,但是会有回溯过程,不停地向前或向后跑,浪费了很多时间,而且找到的解还不能确定是最优解,还有存起来最后进行判断和选择。不过为了效率,两者都可以剪枝,进行启发式搜索(3)从实现上来说,个人认为广搜更容易编程实现,因为不需要回溯,只要遇到满足条件的就入队,最后的最优解一定是在这个队列里的,套用一句经典的话就是”不管黑猫白猫,抓到老鼠就是好猫“。这两种搜索方法各有优劣,用哪种要看个人习惯,也要因题而异。典型的八皇后,马的巡游等问题用深搜,而八数码,农夫过河(与此题不同)等问题则用广搜。

      好,说到正题了。乍看题目,觉得”动态广搜“这个词语有点意思,有人会不会觉得是动态规划+广度搜索?其实不然,这里所说的动态,是相对于一般的广搜问题的静态而言的。为什么说一般的题目都是静态的?因为题目中有固定不变的扩展条件,就像六数码问题中只有固定的α,β变幻方式,不会随着其他的因素而产生变化。而本题的最大特点在于每种扩展方式在不同的时刻会发生变化,不是固定不变的。于是也就产生了不同的处理方法。从程序中也可以看出来,一般广搜程序的模式就是最外层while(队列非空).....而此题的while语句外面还有一层循环,就是时间自增的循环。那么求解方法会产生什么不一样的呢?一般的广搜不需要管上层是否扩展完,就可以实现下层的扩展,而此题不行,因为外面还有时间的自增。可能存在上层(t-1时刻)的节点还没有被扩展完,便进入了下层(t时刻)扩展,这样本来在t-1时刻扩展完毕的情况下能找到最优解的,但是现在不能了,因为随着时间的增加,节点记录的时间会不断增大,当然最后求得的时间不是最短时间了。不信你可以试一下,去掉这个while循环,你会看到”最短时间“会变大,也就不是最短时间了。我没加while循环时求得的时间是17,而正确答案是4。仔细体会这里面的区别,相信你会理解的。还有程序中两个为什么的注释也需要仔细揣摩(当然这是个人编程风格不同),说一下不这么处理的情况。(1)如果在while循环里面自增了,那么也就意味着出队了,那么在下降状态足够长的情况下,农夫可能只能站在一个桩上了,现在再一出队队列就为空了,而这样不就无解了嘛,但是事实是只要时间足够长就一定会到达对岸的(粗略证明一下:如果当前时刻农夫只能站在原桩上,那么以后必然存在一个时刻使农夫能够往前走,那么只要这样不停地走下去,就一定会到达对岸),不信你试一下把(2,1)后面的(1,1)改成(1,100)试试,你会发现程序中途停止了运行,原因就是队列为空了。(2)另外就是为什么Push(cur,t)的位置不能放后面,有人会说先入后入(相对于cur+1->min(cur+5,len))不是一样的嘛,因为由于front在while里面没有自增也就是没有出队,那么可能本次扩展会延续到下一时刻(t+1时刻),但是记录的还是本时刻,因此”最短时间“会缩短,不信试一下,把这个语句放到后面,你求得的最短时间会是3。这两种处理方法会产生连锁反应,联合在一起才会达到想要的效果。

     好了,下面是题目、代码和详细注释,欢迎各位提出更好的处理方法,进一步降低处理难度并提高程序的执行效率。

/****************************************************************
Description
       农夫每天去种地都要经过一条河,这条河很宽,过河要走上面的木桩。木桩有n支,排成一排,从左岸延伸到右岸,编号为1到n.左岸在1号桩的左边,右岸在n号桩的右边。但 这些木桩会定时升降,因此每天他都花不少时间在过河上。所以他想找一种最快过河的方法。 在时刻0,农夫在左岸,他要在最短时间内到达对岸。在任何时刻,每一支桩都只能处在升或降的其中一种状态。  升起的桩才可以站上去,农夫只能站在升起的桩上或岸上。 每一支桩在时刻0都是降的状态,接着升起A分钟,降下B分钟,再升起A分钟,降下B分钟,这样一直交替下去。例如A=2,B=3的桩,在时刻1 2升,在时刻3 4 5降。A和B是时间常数,对每个桩可能不一样。  设在时刻t农夫站在p桩,那么在时刻t+1,农夫能走到p桩的左右5个桩上或岸上,也可以原地不动,当然桩是可站立的。例如,在5号桩,他能走到1,2,3,4,5,6,7,8,9,10或到左岸。 请帮农夫找一种能最快到达右岸的方法。
  
Input
             
第一行是桩的数目n( 5                 
Output
                           
最早到右岸的时刻。当不可以到达时输出NO
                              
              Sample  Input                   
                                            
              10
              11
              11
              11
              11
              21
              11
              11
              11
              11
              11
              Sample  Output

              4
*****************************************************************/

#include
#include

using namespace std;

const int MAX = 1000;
const int TIME = 10000;
const int SIZE = 100000000;
const int DN = 0;
const int UP = 1;

int a[MAX][3];  //每根桩升降的时间间隔
int mark[TIME][MAX];   //标记t时刻某根桩上是否已经站了人,防止重复站人,大大提高效率
int front, rear;

struct Queue
{
       int pos;  //所在位置(即哪个桩上)
       int time; //本次所处时间
}q[SIZE];

int min(int a, int b)
{
       return(a < b ? a : b);
}

void Push(int t, int pos)
{
        if(mark[t][pos])  return;  //若当前时间下该状态已入队,则不再入队,避免重复扩展,大大减少了扩展次数

        q[++rear].pos = pos; 
        q[rear].time = t;

        mark[t][pos] = 1;  //置标记为1,表示已入队
}

void main()
{
       int n;   //实际输入数组长度
       int cur; //当前所处位置
       int len = 1;
       int state; //表示木桩所处状态

       cin >> n;

       while(len <= n)
       {
              cin >> a[len][0] >> a[len][1];

              a[len][2] = a[len][0] + a[len][1];  //计算出每次完整时间周期,减少在队列中的重复运算

               len ++;
        }

        front = rear = -1;  //初始状态入队
        q[++rear].pos = 0;
        q[rear].time = 0;

        for(int t = 1; ; t ++)
        {      //队列按时间t分层,在本层扩展时上层的必须扩展完毕,这样才能保证求到最短时间
                while(front == -1 || q[front].time == t-1)   
              //(1)为什么这里front自增1次会出现问题? 因为这里可能导致队列为空 ,但是队列是不可能也不能为空的,至少农夫可以不动   
                 {                                                          
                         cur = q[++front].pos;  //出队,开始扩展节点

                         mark[t][cur] = 0;  //始终可以站在原桩上,标记为0,可扩展
                         Push(t, cur);

                         for(int i = cur+1, mod = 0; i <= min(cur+5,len); i ++)
                         {
                                if(i == len)  //到达对岸便输出最短时间,并退出(入队时判断而不是出队时判断)
                                {
                                        cout << endl << t << endl;

                                        exit(0);
                                 }

                                mod = t % a[i][2];
                                state = (mod && mod <= a[i][0] ? UP : DN);  
                                //得到后5个桩当前时间的状态,不需要每个都计算

                                 if(state == DN)  break;  //如果将上的桩是下降状态,则停止前进

                                 Push(t, i);
                          } //不必再跑到(当前桩)前5个桩上去了,即使此时没有桩可以上也可以停在原桩上,而不必再后退到前面的桩上;退一步讲,即使前面5个某桩可以跳到原桩面,那原桩必然也可以走到此处,因为这段肯定都是上升状态而且说不定还可以走得更远,所以无论如何都不需要往后退到前5个桩上,题目说能走到前面5个桩上实际是个干扰

                            //Push(t, cur); //(2)为什么这句放这里会出现问题?可能导致求解时间更短(不是正确的解)
               }            //应该是因为在while判断时front没有自增然后继续扩展到下1时刻去了
        }                       
}

你可能感兴趣的:(数据结构,广度搜索)