四国军棋引擎开发(3)寻找到军旗的最短路径

1.问题描述

本来打算接下来做α-β剪枝算法,但是我觉得还是要先解决下棋时一个非常蠢的问题,就是军旗都在旁边了却不懂得去挖。这里要解决的算法就是寻找本方棋子到对方军旗的通路,还要在所有通路中寻找最少步数的路径。这是一个非常有意思的算法场景,所以特地为这个问题来写一篇文章。
如下图所示,此时到军旗的路径有绿色和黄色2条,首先要把这2条路径找出来,然后由于绿色路径的长度是4,而黄色路径的长度是3,所以我们选择黄色这条路径。
这里写图片描述

2.算法实现

一开始仍然是遍历本方的30个棋子,对于每个棋子寻找到军旗的通路,如果找到了并且比之前的路径要短则替换原来的路径。

    ClearPathCnt(pJunqi);
    for(i=0; i<30; i++)
    {
        pLineup = &pJunqi->Lineup[pJunqi->eTurn][i];
        if( pLineup->bDead || pLineup->type==NONE ||
            pLineup->type==DILEI || pLineup->type==JUNQI )
        {
            continue;
        }
        isMove = CanMovetoJunqi(pEngine, pLineup->pChess);
    }

CanMovetoJunqi()这个函数是用来寻找军旗的路径的,在搜索过程中为每个棋子都做一个标记,记录路径的深度,非工兵棋子在直线铁路上的路径深度是相同的,只有转弯后才增加路径的深度,当然工兵在铁路上没有障碍物时可以自由移动,所以在铁路上并不会增加路径的深度。

在搜索时采用的图的深度优先的遍历方法,至于为什么不用广度优先的搜索方法呢,因为我们找寻的是最短路径,不是纯粹的遍历每个棋子的位置。

在每进入到一个结点时,遍历周围所有可能的结点,总共有16个可能相邻的位置,由于棋盘是用一个17*17的数组表示,所以注意不要超出棋盘的范围,另外有些位置虽然在棋盘内,但不是有效的行棋区域,还有些位置虽然处于结点的周围但是不能一次移动,碰到这些都要跳过再遍历下一个结点。

如下图所示,当遍历到某个结点时,再以该点作为起始的中心点,搜索与该点一步相邻的结点进行递归,红色是遍历的所有可能是相邻结点区域,外面的这一层只可能在九宫格的位置是相邻的,因此如果不是九宫格就无需再继续搜索。绿色超出了棋盘的范围,而黄色是非法行棋区域,而白色区域并不与中心点一步相邻。
这里写图片描述
相关代码如下

    for(i=0; i<18; i++)
    {
        //遍历到自身
        if( i==4 ) continue;
        //里面一层
        if( i<9 )
        {
            x = pSrc->point.x+1-i%3;
            y = pSrc->point.y+i/3-1;
        }
        //如果是九宫格还需要遍历外面一层
        else if( pSrc->isNineGrid )
        {
            if( i==13 ) continue;
            x = pSrc->point.x+(1-(i-9)%3)*2;
            y = pSrc->point.y+((i-9)/3-1)*2;
        }
        //不是九宫格
        else
        {
            break;
        }
        //遍历到棋盘外区域
        if( x<0||x>16||y<0||y>16 ) continue;
        //该位置是有效的行棋区域
        if( pJunqi->aBoard[x][y].pAdjList )
        {
            //获取该棋子
            pNbr = pJunqi->aBoard[x][y].pAdjList->pChess;
            //如果不能移动说明不相邻
            //当然工兵可以说和任何铁路上的点都相邻
            if( !IsEnableMove(pJunqi, pSrc, pNbr) )
            {
                continue;
            }
            ... ...
        }
    }

为了避免搜索的太深入,限制路径的深度为4,为了防止返回到原来的结点,递归调用时在进入到CanMovetoJunqi()函数就将pSrc->pathFlag置1,调用返回时将pSrc->pathFlag清0,如果在遍历相邻结点时发现该标志位被置1,说明已经回到了路径之前的某一点,为了避免出现循环,不要再继续搜索,另外由于搜索的是与军旗连通的路径,碰到障碍物也不用继续搜索了。

u8 CanMovetoJunqi(Engine *pEngine, BoardChess *pSrc)
{
     ... ...
    if( pSrc->pathCnt>3 )
    {
        return rc;
    }
    //该点正在当前遍历的路径中
    //该标志位必须在本函数内清0
    //如果后续碰到该点不要遍历
    pSrc->pathFlag = 1;
    ... ...
    pSrc->pathFlag = 0;
}
            if( 找到敌方大本营 )
            {
                ... ...
            }
            //碰到当前路径中的结点
            else if( pNbr->pathFlag )
            {
                continue;
            }
            //碰到自家或阵亡敌方的大本营
            //碰到障碍物
            else if( pNbr->isStronghold || pNbr->type!=NONE )
            {
                continue;
            }
            //继续深入遍历
            else
            {
                ...
            }

为了寻找最短的路径,和避免重新搜索之前已经搜索过的路径,我们用pSrc->pathCnt来代表路径的长度。从搜索第一个棋子的路径前,需要先调用ClearPathCnt(pJunqi)把路径长度清0,见开始那一段代码。但是之前的棋子搜索过的路径信息仍然保留着供后续棋子参考。

所有新的路径的转折点(与上一个转折点不能一步移动)都保存在一个双向循环链表里,双向循环链表就不多说了,和之前寻找工兵路径的算法里是类似的。

    //起始点
    if( pEngine->pPath[1]==NULL )
    {
        //标记起始点,在退出时将最后一个结点从链表移除
        flag = 1;
        pSrc->pathCnt++;
        AddToPath(pEngine, pSrc, 1);
    }
    pHead = pEngine->pPath[1];

以下通过递归调用搜索合法的路径,这里分为2种情况,如果上一个转折点不能与新结点一步移动则增加路径深度,并把该结点添加到路径链表里,否则不用添加新的路径结点,只需继续遍历即可
这里写图片描述
关于pSrc->sameFlag的代码在后面会说明

                if( !IsEnableMove(pJunqi, pHead->pPrev->pChess, pNbr) )
                {
                    if( pSrc->pathCnt+1>pNbr->pathCnt && pNbr->pathCnt!=0 )
                    {
                        continue;
                    }

                    if( pSrc->sameFlag )
                    {
                        continue;
                    }
                    if( pSrc->pathCnt+1==pNbr->pathCnt )
                    {
                        pNbr->sameFlag = 1;
                    }

                    pNbr->pathCnt = pSrc->pathCnt+1;
                    //AddToPath与RemovePathTail必须成对出现
                    AddToPath(pEngine, pSrc, 1);
                    rc |= CanMovetoJunqi(pEngine, pNbr);
                    RemovePathTail(pEngine, 1);
                }
                else
                {
                    if( pSrc->pathCnt>pNbr->pathCnt && pNbr->pathCnt!=0 )
                    {
                        continue;
                    }

                    //只有发现更短路径pSrc->pathCnt<pNbr->pathCnt才继续搜索
                    //遍历到的点路径长度与之前相同,可能存在新路径继续走直线
                    //而原来的路径出现拐弯,从而使pSrc->pathCnt<pNbr->pathCnt
                    //否则就不用继续遍历了
                    if( pSrc->sameFlag && pSrc->pathCnt==pNbr->pathCnt )
                    {
                        continue;
                    }
                    if( pSrc->pathCnt==pNbr->pathCnt )
                    {
                        pNbr->sameFlag = 1;
                    }

                    pNbr->pathCnt = pSrc->pathCnt;
                    rc |= CanMovetoJunqi(pEngine, pNbr);
                }
                //在相同路径长度的点遍历完毕后清除标志位
                pNbr->sameFlag = 0;
            }

这里有一个问题,遍历的结点已经在其他路径中遇到过,这个结点在当前路径中的深度与之前的路径相同,要不要遍历下去呢?如果不继续遍历,可能会出现路径丢失,如下图所示
先搜索黄色路径,由于超过最大的路径深度,无法找到军旗,而红色路径可以找到军旗,但是在绿色圈出来的结点内,由于之前黄色路径已经遍历过了,所以碰到该结点返回,并不会深入下去,这时候就出现路径丢失
这里写图片描述
下图也是路径丢失,相比之下工兵要比排长的路径更短,但是通向军旗的路径全部被路径长度为1的结点挡住了,造成路径丢失。
这里写图片描述
如果在路径相同的结点继续搜索下去,那么将会出现大量的重复搜索,绝大部分情况找不到更短的路径,而且搜索的结点会爆炸性增长,尤其是工兵,在后期有某一家阵亡时,会有大量的结点可以遍历,这些节点排列组合的路径数量让搜索基本上相当于死循环,即使把工兵过滤掉,在后期搜索的结点数量也是成千倍的增长,从开始到全部搜索完毕能感到明显的停顿感。

所以为了效率的考虑,遇到深度相同的路径并不继续无脑搜索,而是先对这一个结点做标记一个sameFlag,在递归到这个结点时无法找到更短的路径则不继续深入搜索,这样就砍断了大量重复的搜索,而且也不会丢失路径,实现见上面代码关于sameFlag的部分

如果遇到大本营,那么就找到了路径。这里分2种情况,一种是军旗明了,一种是军旗还未明,这里用2种不同的事件处理,军旗明了,就不要挖假旗了,而且挖假旗要用比较小的子来挖。如果找到更短的路径,那么把军旗结点(pNbr)和之前的结点(pSrc)添加到路径,更新路径长度,要注意如果pSrc是原始结点,和其他情况稍有不同。

    //这里用局部变量rc来记录找到的是明军旗还是暗军旗
    //这是错的,在文章的最后会说明
    u8 rc = 0;
    ... ...
            if( pNbr->isStronghold && pNbr->iDir%2!=ENGINE_DIR%2 &&
                !pJunqi->aInfo[pNbr->iDir].bDead  )
            {
                if( pSrc!=pHead->pChess )
                {
                    if(  pSrc->pathCnt+1>=pNbr->pathCnt && pNbr->pathCnt!=0 )
                    {
                        break;
                    }
                    pNbr->pathCnt = pSrc->pathCnt+1;
                }
                else
                {
                    pNbr->pathCnt = 1;
                }

                if( pNbr->type==JUNQI )
                {
                    rc |= 1;
                    SETBIT(aEventBit, JUNQI_EVENT);
                }
                else
                {
                    //找到军旗后也不要挖假旗了
                    //亮旗后就不要挖假旗了
                    //必须要没挖过,必须要比旅长小
                    if( ((rc&1)==0) && !pJunqi->aInfo[pNbr->iDir].bShowFlag &&
                            pNbr->type==DARK && pHead->pChess->type>LVZH )
                    {
                        rc |= 2;
                        SETBIT(aEventBit, MOVE_EVENT);
                    }
                    else
                    {
                        break;
                    }
                }

                if( pHead->pChess!=pSrc )
                {
                    AddToPath(pEngine, pSrc, 1);
                }
                AddToPath(pEngine, pNbr, 1);

                pEngine->pMove[0] = pHead->pChess;
                pEngine->pMove[1] = pHead->pNext->pChess;


                RemovePathTail(pEngine, 1);
                RemovePathTail(pEngine, 1);

                break;
            }

3.bug记录

1.在IsEnableMove()函数里去除了pSrc->type!=NOME的判断,在搜索路径的过程中中间结点是空的,如果有这个条件就无法使用IsEnableMove()函数,其他地方使用时需要自行保证必须存在棋子。

2.递归调用时直接在CanMovetoJunqi()函数里用静态变量记录路径深度,这是错的,这个记录的是路径中有多少个棋子位置,并不是路径的长度(即移动的步数)

3.在遍历到新结点时把RemovePathTail写到后面,因为在路径长度相同时并没有把结点添加到链表,这样会导致AddToPath和RemovePathTail没有成对出现

4.用pFlag[4]表示地方的四个军旗位,然后对每个位置搜索一次,这里重复了,因为每次搜索就已经遍历了所有路径,只需要看最后的结果是否搜索出到军旗的路径。

5.在找到军旗结点后,只把军旗结点添加到路径,并没有把军旗结点的上一个结点即pSrc添加到路径中,事实上由代码逻辑可知pSrc是在即将递归调用新结点时添加到路径中的,在找到军旗的时候并没有添加到路径中。

6.在上一个问题中,添加的pSrc不能是原始结点,原始结点已经在第一次进入CanMovetoJunqi()函数时就添加到了头结点,如果继续添加则造成路径中的结点重复。

7.路径丢失问题,见前面的分析

8.找到军旗时没有设置更新pSrc->pathCnt,这样就无法找到最短路径,找到的只是最后一次搜索到的路径。

9.在军旗未明时,有时候已经走到了军旗前面却突然不挖了,这是因为此时出现了进营事件,这一事件比挖假旗的优先级高,而这时候处理进营事件后又发现没有棋子可以占营,此时会随机走一步,所以这个bug当时出现的时候会感到莫名奇妙,解决方案是把进营事件的优先级设为最低即可。

10.明明假旗被挖过了,在出现到军旗的通路时,还是当作假旗事件处理。这是因为挖过假旗后,只设置了棋子的type为军旗,而没有设置棋子上的位置的type为军旗,而在寻找路径时是以位置上的type作为判断军旗的标准。

    if( pDst->isStronghold && ((pResult->extra_info&1)==0) )
    {
        //假旗被挖后,另外一个大本营只能是军旗
        if( pDst->index==26 )
        {
            pJunqi->Lineup[pDst->iDir][28].type = JUNQI;
            pJunqi->ChessPos[pDst->iDir][28].type = JUNQI;//这里少了
        }
        else
        {
            pJunqi->Lineup[pDst->iDir][26].type = JUNQI;
            pJunqi->ChessPos[pDst->iDir][26].type = JUNQI;//这里少了
        }
        pJunqi->aInfo[pDst->iDir].bShowFlag = 1;//这里也少了
    }

11.工兵碰到假旗撞死后,还有棋子去挖假旗。这是因为亮军旗标志位bShowFlag忘置1了,见上面的代码,所以没有判断出这是假旗。为什么pNbr->type==DARK 没有阻挡住条件的进入呢,因为在PlayResult()函数中,KILLED类型只更新棋子的类型,并没有更新棋子位置的类型,此时假旗位置上的类型还是DARK,所以这个条件还是会进去的。

                    //找到军旗后也不要挖假旗了
                    //亮旗后就不要挖假旗了
                    //必须要没挖过,必须要比旅长小
                    if( ((rc&1)==0) && !pJunqi->aInfo[pNbr->iDir].bShowFlag &&
                            pNbr->type==DARK && pHead->pChess->type>LVZH )
                    {
                        rc |= 2;
                        SETBIT(aEventBit, MOVE_EVENT);
                    }

12.记录挖的是明军旗还是未明的军旗时的变量rc用的是局部变量,递归调用时局部变量会被清0,因此无法记录之前找到军旗的信息,所以如果是先找到明军旗后找到未明的军旗,结果使用的路径是未明军旗的路径,rc也不能使用静态局部变量,这要考虑什么时候清0的问题,因为搜索的是30个棋子,应该是在搜索第一个棋子的路径之前清0。所以应该用一个全局变量。这里可以直接用事件标志位就可以了。上面的代码改为

                    if( (!TESTBIT(aEventBit, JUNQI_EVENT)) && !pJunqi->aInfo[pNbr->iDir].bShowFlag &&
                            pNbr->type==DARK && pHead->pChess->type>LVZH )
                    {
                        SETBIT(aEventBit, MOVE_EVENT);
                    }
                    else
                    {
                        break;
                    }

4.测试

第一个随机下棋的版本为1.0,上一个版本未1.1,这个版本为1.2,测试情况如下

引擎A引擎BA VS B 战绩
1.11.08:2
1.21.18:2
1.21.010:0

5.源代码

https://github.com/pfysw

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值