牵着老婆满街逛

每做一件事情之前都要好好想清楚,斟酌斟酌再斟酌!
GMail/GTalk: yanglinbo#google.com;
MSN/Email: tx7do#yahoo.com.cn;
QQ: 3 0 3 3 9 6 9 2 0 .

C++博客 首页 新随笔 联系 聚合 管理
  801 Posts :: 46 Stories :: 434 Comments :: 0 Trackbacks

作者:吕震宇

来源:http://www.cnblogs.com/zhenyulu

华容道系列-开篇

一、 序言

这个学期给学生上《设计模式》的课程,有些学生提出找些题目练练手,增强一些实战经验,我决定让他们编写"华容道"游戏。说实在的,当时并没有深思熟虑。后来自己仔细想想,发现这里面东西还真不少,甚至包括下学期我才给他们开设的课程《数据结构》中的大量内容。所以我决定自己先来尝试一下。

其实编写"华容道"的想法早在上大学时就有了,那时候我在《科学》杂志上读到胡华旦的一篇文章《"华容道"难题的计算机解》后心潮澎湃,非得用C语言写一个,可最终因为种种原因没有做成,现在也是圆我的一个梦想吧。

目前网上能够搜索到很多"华容道"计算机解的源代码,但用面向对象语言站在数据结构以及设计模式角度编写的几乎没有。我想尝试成为第一个吃螃蟹的人。在正式开始写这个系列之前,我曾经考虑过很久将代码定位在一个什么层面上,是教学还是应用。其实数据结构与设计模式的应用是一个很自然的事情,不应该为了模式而模式,但出于教学目的又必须明确用了哪些模式,这又违背了模式的思想,我会尽量找到一个合适的平衡点。另外,为了兼顾内存使用和运算效率以及通用性的要求,最好能够应用泛型编程,但目前绝大多数人恐怕还在使用.net 2003,所以我最终选择了放弃泛型技术。

最后,由于本人水平有限,而且是边做边写,难免会有些疏漏,希望大家能够谅解。


二、 对棋盘布局的说明

我们在程序中研究的棋盘布局的限制条件是:最大子只允许有一个;最小子允许有4或8个(棋子总数相应为10至12个),其余棋子任选;棋盘上必须和只允许有两个空格。

另外需要明确的就是棋盘上任意两个相同形状、相同摆放的棋子是"同质"的,在计算机看来没有任何区别。如图:

 

计算机将认为以上两个棋局是完全相同的,也就是说计算机不会记录"人物"信息,只存储"布局"信息。这样做的目的一方面可以减少内存空间占用,另外一方面可以将UI与解题过程剥离开,降低耦合,允许各自变化。


三、 棋盘布局的表示方法

下面的布局是网上一个公开源代码的VB6.0程序,作者刘峻松,Email地址(liujunsong@yahoo.com.cn)。在他的程序中是这样描述一个棋盘布局的:

棋盘状态说明:用12个整数来表示棋盘的开始状态,每个整数用两位表示,目前的算法只支持5员大将,大将可以横放,也可以竖放。棋盘的大小为高度5,宽度4,从上到下,从左到右为棋子位置来排号,其中第一位是曹操的位置,2-6代表5员大将,7-10代表4个小卒,11-12代表两个空格的位置。整个棋盘的取值范围是1-20,当曹操走动第14个位置时行走成功。初始化的状态"021801040912101114151720"代表"横刀立马"布局。

 

在这个表述中,12个整数将占用12×4=48个字节。即便用字符串来描述的化,每个布局还是要占用24个字节的长度。在进行一个复杂棋局的运算中,这样的布局要生成几万个,会占用1M左右的空间。算上其它内容,内存占用还会更大。

如果抛弃"人物"信息,内存占用将大大降低。经过优化后,可以使用4个字节来描述一个布局(10子或12子布局均可)。这样,我们只需使用一个Int32便可。具体设计方案以及基本算法描述我会在下一篇文章中公布。


续第一部分《华容道系列-开篇

 

四、 棋盘布局存储方案

华容道棋盘布局经过优化后可以存储在4个字节中,确切的说是3字节零两个二进制位(10子)布局。经过简单调整后,也可以将12子布局存储在4个字节当中。不过12子布局的走法过于简单,所以在今后的文章中,布局全部指10子布局,软件只针对10子布局开发。设计方案如下:

 

上图演示了两种布局的存储结构。棋子总共分成5类,分别是最大子、横放、竖放、卒以及空格(在这里将空格认为是一个棋子)。将最大子记做A,单独存储。其它4种棋子可以使用两个二进制位来描述,分别是:

00-空格, 01-卒, 10-竖放, 11-横放

一个布局共有此类子(含空格)11个,共占用22个二进制位。A子用4个二进制位来记录(可表示16种变化),其取值可以是十进制1~12中的一个,表示A子在所有棋子中的位置。因此,一盘布局仅需26个二进制位,其余6个二进制位作为保留位。

在棋子排放上,从棋盘左上角开始,每个棋子的摆放位置都选取最小可用的行、列坐标位置作为摆放位置(如图),并在合适的时候插入A子(根据A的顺序号)。

这样,一个布局仅需一个Int32就可以记录下来。在后面的内容中,不管是建立AVL平衡树还是广度优先的搜索算法都是针对Int32进行的。一个布局可以和一个整数相互转换(在后续内容中将给出代码实现方案)。


五、 基本算法描述

华容道求解过程不外乎罗列所有可能的走法,采用广度优先的树形结构进行计算机搜索,当搜索到符合解的布局时便终止搜索。并从树型结构中追溯出解题的全过程。因此需要每个布局保留一个指向上步布局的引用。为了保证合理的内存使用,每次都要检索当前布局是否在前面已经出现过,如果有重复布局,则自动剔除。用图形的方式描述出来便是:

在后续的章节中,我们将应用数据结构的知识来构建华容道求解过程中所需要的数据结构,内容将涉及链表、树、AVL平衡树、堆栈等结构,尽量在确保合理内存占用的情况下简化计算机求解过程。

六、 数据结构设计

针对上面说到的解题方法,设计如下的数据结构:

1、广度优先的树型结构

由于整个棋局的可行解可以描述成一个树型结构,并且为了得到最少移动步数需要采用广度优先的搜索算法,因此考虑将链表与树型结构整合起来,便于进行广度搜索。如图,当我们试图搜索第三步可行解时,只需要顺着第二步的链表依次搜索便可以实现了。

 


2、堆栈结构输出最少步数

由于在树型结构设计上,每个子节点都保留了一个对父节点的引用。所以一旦找到最优解,我们就需要从最底层向上追溯所有移动步骤(如下图)。但这个顺序与走棋的顺序正好相反。借助一个堆栈结构实现后进先出便把这个次序逆转过来了。

 


3、引用环形链表解决求解下一步走法的问题

在分析1中,我将链表和树整合在了一起,究其原因就是为了便于广度搜索。实际上我们还面临着以下几个问题:

第一,4个字节的棋局表示虽然减小了存储空间,但不利于求解。为了实现步法移动,我们至少要分清棋子的上下左右,4字节的表示方式很难达到这一点。我们需要另外一种带方位的棋局表示方式以便进行求解,而这种表示方式的棋局数量还不能太多,在使用完后要及时释放,否则就会占用大量内存。

第二,为了降低系统模块间的耦合度,尽量让带方位的棋局表示与4字节表示剥离开,两者能够独立变化。

出于上面两点考虑,我决定引入"池"的概念。胡华旦的《"华容道"难题的计算机解》一文中记录了某一步走法的最大可能布局数(树型结构某层级的最多布局数)在200到800之间(10棋子),或1400左右(11棋子),或1800左右(12棋子)。由于我们只研究10棋子布局,所以这个池的大小设置在800比较合适(实际检验的结果是池的大小在1100左右,因为上一步走法与下一步走法存在相关性,并且10子布局中某一步最大可能布局数经检验会大于1000)。通过一个环形链表(加入必要的溢出检验)可以很容易的达到目的。设计如下:

 

环形链表中的每一个节点都是一个带方位关系的布局表述,有了这个环形链表,就可以将在分析1中的链表结构从树型结构中剥离开了。


4、AVL树(平衡树)

每当我们求解得到下一步的一个可行走法后,都要检查该走法完成后的棋盘布局是否在以前搜索过的布局中出现过,如果出现过的化则直接剔除,不再添加到树型结构和环形链表中。这就需要一个检索的过程。

我们知道,在一个未排序的队列中进行检索是一个很耗时的工作,需要遍历每个结点。如果经过排序则可以使用二分法迅速定位。我们可以把所有出现过的布局组织到一个结构中,这个结构应当满足以下两点:一是能够快速的进行查找,二是不允许该结构中出现重复值。在这里,AVL树是再合适不过了。它基于二叉树,并且不允许树中两个节点具有相同取值,同时还可以保证最高的检索效率。在下一篇文章中我将重点介绍AVL树,并说明它在华容道求解过程中的应用。


(待续)

七、 AVL(平衡树)

平衡树其实就是一特殊结构的二叉树。由于二叉树的搜索算法的性能取决于二叉树的结构,如果二叉搜索树构造出来是线性的,搜索算法的效率不高。如果结构合理,则查找速度较快。实际上,树的高度越小,查找速度越快。大家可以比较一下下面两个二叉树在检索时,哪个效率更高一些:

 

AVL树(也称作平衡树),在这种树型结构中,二叉树结构近似于平衡。AVL树具有如下特征:

1. 根的左子树和右子树的高度差的最大值为1。
2. 根的左子树和右子树都是AVL树。

补充:AVL树中不允许出现重复值,这正好与我们的目的相同。

在对AVL 树执行插入操作时,我们通过"旋转"达到确保高度差的目的。如图:

 

AVL树的重构过程就是"旋转",有两种类型的旋转,左旋与右旋。常见的旋转方法如下:

 

 

举个例子:

注:以上所有图片均来自 D.S.Malik与 P.S.Nair著的《Data Structures Using Java》一书。

八、    程序解题过程


在这部分内容中,我们通过一个简化的实际例子来看看在华容道求解过程中,循环链表、AVL树以及树之间是如何相互协作的。首先我们假设所有的棋子只能向下移动(这样可以大大减少树中的节点数量),我们来看系统如何搜索所有可行步骤:

首先,系统初始化各个部件。

 

环形链表中维护了三个指针:current指明当前运算到了哪个布局;last指针指向当前搜索层级的最后一个布局;allocate指针指向下一个可分配的布局,所有这些布局都是可以循环反复使用的。

TreeLinkedList初始化根节点以及Current指针。这个指针用来表示树型结构中子节点的父节点是谁。例如当前初始布局有两个可行的"下一步",那么这两个布局的父节点就是Current指针指向的节点。

AVLTree用初始布局的整数值初始化根节点。系统为华容道求解做好了准备。

第一步:当初始化完成后,系统开始进行搜索运算。首先计算当前步中所有布局(现在只有一个初始布局)的可行走法,并将可行走法追加到allocate分配的空间中。

 

每次移动CircularLinkedList中的current指针时,同步移动TreeLinkedList中的Current指针,确保父节点的正确性。另外,每计算得到一个可行的"下一步"布局,都先将其转换为整数表示,并在AVL树中判断有无重复值。如果没有重复值,便确认 CircularLinkedList中分配的空间,同时向TreeLinkedList中添加节点。

这里需要我们注意的是,TreeLinkedList中添加的节点还包括一个"走法"信息。如Move 6 Down和Move 7 Down。这两个走法分别被附加到了两个青颜色的节点上。但是,这里的走法其实是黄颜色节点的"走法"。黄颜色节点通过该"走法"得到子节点的"布局"。 所以在最终通过堆栈获取所有步骤时,我们要将走法"上移"到父节点,以标识父节点如何走得到子节点。(此处可以参考本文最后关于Stack部分的内容)

第二步:当前步求解完成后,便进入下一步求解(在CircularLinkedList中调用NextStep方法),重新设置好current、last、allocate指针。

 

从上图中我们可以看出,出现了重复布局,该重复布局被AVLTree检测出来,于是allocate分配的空间没能被正确ConfirmAllocation,因此下次需要分配一个布局空间时,仍然会将该布局分配出去。

第三步:该步操作与上一步基本相同,需要注意的是AVLTree在插入布局整数11144021时发生了"旋转"操作,因此确保树拥有最小高度,和最好的检索效率。

 

除此之外,我们还应注意"无解"判断。当某一搜索深度的可行解为0时,我们便说该棋局"无解"。如下图所示:

 

该棋局便是一个"无解"棋局。

最后,假设在我们移动完棋子A后,我们得到了最优解,我们看一看如何通过堆栈结构将解题步骤完整的罗列出来。

 

在对堆栈执行Push时,需要注意的是将移动方法"上移",这样通过Pop操作就可以得到正确的解题步骤了。

到此为止,我们便完成了一个完整的解题循环。


完整的程序代码可以从http://www2.cnblogs.com/Files/zhenyulu/HRD.rar下载

九、 代码设计

在看完了解题过程后,下面来看一看具体的代码设计方案:

我们首先从Core开始,在Core.dll里面定义了系统所需的最基本的数据类型以及相关的接口。其中枚举ChessmanType与MoveMethod分别定义了棋子的类型以及棋子移动的方法。

public enum ChessmanType
{
  Blank 
= 0,
  Solider 
= 1,
  VChessman 
= 2,
  HChessman 
= 3,
  General 
= 99
}


public enum MoveMethod
{
  Up, 
  Down, 
  Left, 
  Right, 
  Up2, 
  Down2, 
  Left2, 
  Right2, 
  Turning,
  Nothingness    
//没有任何移动
}

ChessStep定义了棋局中的"一步"棋。包括当前棋盘布局的整数表示、移动了哪一个棋子以及如何移动。当华容道自动解题程序完成后,将返回一个ChessStep[]数组,记录每一步的走法。我们可以通过实现IResultHandler接口的"HandleResult (ChessStep[] steps)"方法来达到这一目的。IResultHandler接口将在后面加以介绍。

public struct ChessStep
{
  
public short chessmanNum;
  
public MoveMethod moveMethod;
  
public int layout;
}

Position与BlankPosition是两个结构"structure",用来记录棋子的位置以及某一棋盘上空格的位置。BlankPosition中有一IsBlank(int x, int y)方法,用来判断坐标为(x,y)的点是否是空格。

public struct Position
{
  
public int x;
  
public int y;

  
public Position(int x, int y)
  
{
    
this.x = x;
    
this.y = y;
  }

}


public struct BlankPosition
{
  
public Position Pos1;
  
public Position Pos2;
    
  
public BlankPosition(Position pos1, Position pos2)
  
{
    
this.Pos1 = pos1;
    
this.Pos2 = pos2;
  }


  
public bool IsBlank(int x, int y)
  
{
    
if( x == Pos1.x && y == Pos1.y)
      
return true;
    
if( x == Pos2.x && y == Pos2.y)
      
return true;
      
return false;
  }

}

之所以选择使用结构而不是类是因为struct是值类型的,而class是引用类型。在本程序中,struct要比class更有效率。关于struct与class的区别在这里就不再详细讨论了。我们看下面一段程序:

Position P1 = new Position(0,0);
Position P2;
P2 
= P1;

P2经过赋值后,P2.x与P2.y都与P1相同,并且这种赋值不是"引用"赋值,修改P2中x、y的值并不会影响P1中x、y的值。

HRD.Core命名空间下除了这些基本类型的定义外,还定义了一系列接口,主要是用来达到解耦目的。在上篇文章中,我们看到了TreeLinkedList、CircularLinkedList以及AVLTree之间如何协作工作,但在实际代码设计中,这将带来严重的耦合问题。组件与组件之间联系过于紧密,造成无法有效的剥离。为此,程序在设计时引入了"中介者"模式,设计了一个Mediator,所有组件仅与Mediator打交道,这样耦合便被"松动"了。

Mediator的定义如下:

public class Mediator
{
  
private IAVLTree _avlTree;
  
private ICircularList _circleList;
  
private ITreeList _treeList;
  
private IResultHandler _resultHandler;
  
  …………

}

其中IAVLTree、ICircularList、ITreeList便是参与运算的三种数据结构。其具体定义可以参考具体的程序源代码。(可以参考http://www2.cnblogs.com/zhenyulu/archive/2005/02/03/101426.html里面的代码,但不是最终版本,可能会与本文有所出入)。

这里还有一个接口定义,就是IResultHandler接口。刚才已经提到过。我们可以将一个实现了该接口的对象传递给Mediator对象,这样,当系统运算完成后便会调用IResultHandler中的特定方法,将结果回传。

public interface IResultHandler
{
  
void HandleResult(ChessStep[] steps);
  
void HandleInfo(int currentStep);
}

IResultHandler定义了两个方法HandleResult与HandleInfo。HandleResult用来处理程序产生的结果,结果以ChessStep数组的形式传入。HandleInfo可以被用来处理程序运算过程中的一些中间信息。这里我只提供了一个信息,那就是当前搜索层次是什么。它通过currentStep参数传入。如果需要,用户可以重新定义该接口以获取更多的信息(例如搜索了多少个节点等)。IResultHandler接口的具体使用方法可以参考源代码中WinHRD或ConsoleHRD中的实现。

HRD.Core命名空间下还定义了Layout以及Chessman,此外还有一个CallBackDelegate的定义,这些将在后续的文章中再加以介绍。

十、 Chessman的设计

整个程序中一个非常关键的环节就是Chessman(棋子)的设计以及Layout(棋盘布局)的设计。这次先说说Chessman的设计。

由于我的程序只针对10子布局,所以一个Layout应当包括10个棋子,分别归属于General、HChessman、VChessman与Soldier。下面是抽象Chessman类的部分定义:

public abstract class Chessman
{
  
protected Position _position;

  
protected Position _newPosition = new Position(-1,-1);
  
protected BlankPosition _newBlankPosition = new BlankPosition();

  
// 构造函数
  public Chessman(Position position)
  
{
    
this._position = position;
  }


  
// ………… 此处省略一些属性的定义

  
public virtual ChessmanType chessmanType
  
{
    
get 
    
{
      
return ChessmanType.Blank;
    }

  }


  
public abstract void CheckAvailableSteps(BlankPosition _blankPosition, CallBackDelegate _callback);
}

在这个定义里面我们可以看到以下几项:_position,记录了当前棋子在棋盘中的位置。_newPosition、_newBlankPosition,当移动棋子后,棋子的新位置以及空格的新位置。关于这点在后面有进一步的说明。只读chessmanType属性,标识当前棋子类型。

还有一个抽象方法:CheckAvailableSteps是Chessman的核心。在方法中包含了一个CallBackDelegate类型参数,是一个回调函数的委派。程序的运行机制是:CircularLinkedList调用Layout的CheckAvailableSteps方法,Layout再依次调用其每个包含棋子的CheckAvailableSteps方法,并提供一个回调函数(就是Layout的EncapsulateChessStep方法)。一旦棋子有可行的走法,就回调该函数,传入新的棋子位置以及新的空格位置,并以此产生一新Layout。然后的步骤就是将此新Layout纳入CircularLinkedList并检验AVLTree是否有重复值等等,大家可以参考(《华容道与数据结构 (4) 》)中的内容。

这理有一点需要明白的就是棋子的移动问题。其实某个棋子能否移动以及如何移动仅与当前空格位置有关。如图:

 

所以,我们仅需知道空格在什么位置便可以计算出下一步的移动方法。另外,棋子移动后,我们只需要回传该棋子的新位置以及空格的新位置就可以在原有布局的基础上产生出一新的可行布局。当然此布局还要经过AVLTree的检验才可以被加入CircularLinkedList以及TreeLinkedList当中。

举例来说,在HChessman类中,我们会看到若干私有方法如下:

private bool CanMoveUp(BlankPosition _blankPosition)
{
  
if(_blankPosition.IsBlank(_position.x , _position.y - 1&& 
     _blankPosition.IsBlank(_position.x 
+ 1, _position.y - 1))
  
{
    
// 设置棋子新位置
    _newPosition.x = _position.x;
    _newPosition.y 
= _position.y - 1;

    
// 设置新的空白位置
    _newBlankPosition.Pos1 = _position;
    _newBlankPosition.Pos2.x 
= _position.x + 1;
    _newBlankPosition.Pos2.y 
= _position.y;
        
    
return true;
  }

  
return false;
}

该段代码判断当前棋子是否可以向上移动,唯一的判别依据就是空格的位置,也就是传入的_blankPosition参数。如果可以移动的化,则给_newPosition与_newBlankPosition赋值移动后棋子的位置与空格的位置。在HChessman的CheckAvailableSteps方法中,我们可以看到如下代码:

public override void CheckAvailableSteps
(BlankPosition _blankPosition, CallBackDelegate _callback)
{
  
if(CanMoveUp(_blankPosition))
    _callback(_newPosition, _newBlankPosition, MoveMethod.Up);

  …………

}

上面代码的意思是说,如果棋子可以向上移动的化,则回调_callback(也就是Layout的EncapsulateChessStep方法),传入三个参数:棋子新位置、新空格位置以及当前移动方法。在EncapsulateChessStep方法中将根据这些信息生成新的棋盘布局Layout。Layout的EncapsulateChessStep方法定义如下:(经部分简化)

private void EncapsulateChessStep
  (Position newPosition, BlankPosition newBlankPosition, MoveMethod mm)
{
  Layout l 
= _mediator.AllocateLayout();
  
  
// 生成新布局
  for(int i = 0; i<10; i++)
  
{
    
if(this.chessmen[i] == this.sortedChessmen[_current])
    
{
      l.chessmen[i].position 
= newPosition;
      l.blankPosition 
= newBlankPosition;
    }

    
else
    
{
      l.chessmen[i].position 
= this.chessmen[i].position;
    }

  }


  
// 初始化新布局
  l.InitLayoutMap();

  
// 初始化当前移动"步法"
  ChessStep cs = new ChessStep();
  cs.layout 
= l.ToInt();
  cs.moveMethod 
= mm;

  
if(sortedChessmen[_current].chessmanType == ChessmanType.General)
    cs.chessmanNum 
= 99;
  
else
    cs.chessmanNum 
= (short)chessmanNum;

  
// 将封装好的ChessStep发送给mediator作进一步处理
  if(l.IsFinished)
  
{
    _gotTheAnswer 
= true;
    _mediator.Finished(cs);
  }

  
else
    _mediator.CheckStep(cs);
}

在这段程序中,首先通过中介者_mediator从CircularLinkedList当中分配一可用Layout。

  Layout l = _mediator.AllocateLayout();  

然后将当前布局的10个棋子复制过去,在这里,注意被移动的棋子要复制其新位置,并用新的空格位置初始化新布局。

  // 生成新布局
  for(int i = 0; i<10; i++</