华容道与数据结构

一、 序言

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

其实编写"华容道"的想法早在上大学时就有了,那时候我在《科学》杂志上读到胡华旦的一篇文章《"华容道"难题的计算机解》后心潮澎湃,非得用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树的重构过程就是"旋转",有两种类型的旋转,左旋与右旋。常见的旋转方法如下:

 

 

举个例子:

 

八、    程序解题过程


在这部分内容中,我们通过一个简化的实际例子来看看在华容道求解过程中,循环链表、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操作就可以得到正确的解题步骤了。

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

九、 代码设计

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

我们首先从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 ++ )
  
{
    
if(this.chessmen[i] == this.sortedChessmen[_current])
    
{
      l.chessmen[i].position 
= newPosition;
      l.blankPosition 
= newBlankPosition;
    }

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

  }

剩下的工作就是对布局初始化,生成移动"步法"并判断是否曹操移动到了目的位置,然后将"步法"交给中介者进一步处理。

Chessman的代码就介绍到这里,下部分内容将介绍Layout。

十一、 Layout的设计

Layout在我的程序中充当"布局",也就是说记录当前棋盘的状态。棋盘状态其实无外乎就是10个棋子的位置以及2个空格的位置而已。所以在Layout类中包含了两个成员:

public   class  Layout
{
  
private Chessman[] _chessmen = new Chessman[10];
  
private BlankPosition _blankPosition;
  
  …………
}

同时,Layout应当具有将自己转换为4字节整数的功能。所以提供了ToInt函数。关于转换的方式,可以参考《华容道与数据结构 (1)》中的内容。我这里需要说的是如何将Layout转换为整数。

由于Layout中的每个棋子都有其坐标值,例如如下布局:

 

为了正确对棋子编号,我们需要一个排序操作,而这个排序的依据就是先按纵坐标排,纵坐标相同的按横坐标排。排序后就可以根据每个棋子的类型生成对应的整数。就上面这个例子来说,我们可以得到如下表:

棋子
类型
Y
X
Y×10+X
顺序号
赵云VChessman0001
曹操General011A
张飞VChessman0332
马超VChessman20203
黄忠VChessman23236
关羽HChessman414110
卒1Solider21214
卒2Solider22225
卒3Solider31317
卒4Solider32328
空格1Blank40409
空格2Blank434311

按照Y×10+X公式排序后得到的顺序号正好是我们棋子的编号。剩下的事情就是将这些棋子串起来,形成一个整数值。在这里我们使用左移运算符。前面 的设计中用二进制的00表示空格,01表示卒,10表示竖放,11表示横放,这与ChessmanTypeEnum中各棋子的整数值正好相对应。所以我们 可以如此完成串接过程(仅仅以8位二进制位来演示):

int  result  =   0 ;        // 二进制的00000000
=   2 ;                 // 二进制的10,竖放棋子
=   3 ;                 // 二进制的11,横放棋子
=   1 ;                 // 二进制的01,卒
result  =  a;            // 二进制的00000010
result  =  result  <<   2 // 左移两位00001000
result  +=  b;           // 得到结果00001011
result  =  result  <<   2 // 左移两位00101100
result  +=  c;           // 得到结果00101101

其中左移运算给新棋子的加入腾出位置。具体Layout中的实现可以参考代码中的ToInt方法,这里就不再多说。

Layout中另外一处需要注意的问题就是在类的定义中有这样的两个数组:

private  Chessman[] _chessmen  =   new  Chessman[ 10 ];
private  Chessman[] sortedChessmen   =   new  Chessman[ 12 ];

其中的sortedChessmen起到什么作用?我们知道数组是引用型的变量,为了兼顾排序求Int以及Layout的复制操作,我们需要两个数 组,一个是不排序的,而另外一个是排序的,但它们都指向同一内存中的对象。这样在求整数以及追踪第几个棋子被移动的过程中,我们使用 sortedChessmen,在复制Layout时我们使用_chessmen,但最终引用的对象确是同一个对象。如下图所示:

 

sortedChessmen在Layout的InitLayoutMap方法中被初始化,使用快速排序法进行排序操作(有关快速排序法将在下部分内容中加以介绍)。

Layout中还有一个只读属性IsFinished属性,用来判断当前棋局是否可以结束,其实就是判断曹操是否移动到了指定位置而已。该属性在ToInt方法中被初始化。

十二、 快速排序法

其实"快速排序法"不应当出现在华容道系列中,毕竟它是纯数据结构的内容。在这里我只简要介绍一下。以下内容来自D.S.Malik与P.S.Nair著的《Data Structures Using Java》一书。

关于快速排序法有以下要点:

1、 基于递归调用;
2、 使用分而治之的方法对列表排序;
3、 将要排序的列表分成两个子列表,然后被分别排序,并依次递归。最后合并成完成的排序列表。

如图:

 

首先找到一个"中心",在这个例子中是50,与第一位交换,然后将后面的数值归为两类,比50小的(用黄颜色标识)与比50大的(用青颜色标识), 通过smallindex指针、index指针以及交换操作可以很容易达到这个目的。经过交换后我们在将中心元素50与smallindex所指的元素进 行置换,于是50前面的元素一定比50小,而50后面的元素一定比50大。第一轮置换就到此为止。整个数组被分成了两个部分(黄颜色部分与青颜色部分)。

接下来的工作便是递归了。我们再分别对黄颜色与青颜色数列进行第二轮置换操作,道理完全同上。这样最终数组将被完全排序。这便是快速排序算法。具体的代码实现这里就不说的,感兴趣可以找相关资料或查看华容道中QuickSortArray类中的代码。

(下部分内容我们将讨论TreeLinkedList。我们将链表与树整合起来用来追踪华容道的"步法"。)

十三、 TreeLinkedList的设计

在《华容道与数据结构 (2) 》中,设计了用于广度优先的树型链表。如下图:

 

链表是通过图中蓝颜色箭头链接起来的,而黑颜色箭头构成了树型结构。整个结构用来存储搜索结果,每一搜索步骤是一个层级,逐层深入直到找到结果。从图中可以看出,每个节点有两个指针,分别指向下一个节点与父节点。因此,在TreeLinkedList中定义节点类如下:

protected   class  TreeNode
{
  
public ChessStep chessStep;
  
public TreeNode parentNode;
  
public TreeNode link;
}

chessStep是该节点的"负载",parentNode指向父节点,而link指向下一节点。在TreeLinkedList中有三个指针:

protected  TreeNode root  =   null ;
protected  TreeNode current  =   null ;
protected  TreeNode end  =   null ;

分别指向根节点,"当前"节点(所谓当前节点是指:所有新加入的节点的父节点都被设置为current指针指向的节点),链表尾节点。TreeLinkedList被初始化时,各指针均被清空,并将计数器清零。

public   void  ClearAll()
{
  root 
= null;
  current 
= null;
  end 
= null;
  count 
= 0;
}

向树中插入节点的代码如下:

public   void  insertNode(ChessStep newitem)
{
  TreeNode newNode;
    
  newNode 
= new TreeNode();
  newNode.chessStep 
= newitem;
  newNode.link 
= null;
  newNode.parentNode 
= null;

  
if(root == null)
  
{   
    root 
= newNode;
    current 
= newNode;
    end 
= current;
  }

  
else
  
{
    newNode.parentNode 
= current;
    end.link 
= newNode;
    end 
= newNode;
    end.link 
= newNode;
    end 
= newNode;
  }

  count
++;
}

可以看出,代码中新节点为newNode,如果根节点为空,则仅仅将root、current、end指针指向新节点。如果根节点不为空,那么将 newNode节点的父节点指向current指针所指的节点,将end节点的下一个节点设置为newNode,然后将end指针指向newNode。于 是新节点便被添加进来。除此之外,我们还需要一个方法,在合适的时候移动current指针:

public   void  MoveCurrentToNext()
{
  
if(current != null && current.link != null)
    current 
= current.link;
}

现在,有了这些方法我们就可以构件TreeLinkedList结构了。如图:

 

要想构建上图所示树型结构,我们可以通过以下命令来实现:

ChessStep s1, s2, s3, s4, s5, s6, s7, s8;

TreeLinkedList t 
=   new  TreeLinkedList();
t.insertNode(s1);
t.insertNode(s2);
t.insertNode(s3);
t.MoveCurrentToNext();
t.insertNode(s4);
t.insertNode(s5);
t.MoveCurrentToNext();
t.insertNode(s6);
t.insertNode(s7);
t.insertNode(s8);

TreeLinkedList类中还有一个TraceResult,是用来通过堆栈结构追踪棋步的。比较简单,在这里就不再介绍了。

(下部分内容将介绍CircularLinkedList类。它是整个程序的核心。)

十四、 CircularLinkedList的设计

CircularLinkedList类是整个程序的核心,所有搜索操作都由CircularLinkedList来触发。 CircularLinkedList是一个环形链表,每个链表中的元素都含有下一元素的一个指针,元素与元素链接成封闭的环形。下面是该环型链表的节点 定义:

protected   class  LinkedListNode
{
  
public Layout info;
  
public LinkedListNode link;
}

该节点包含一个Layout类型的info,还有一指向下一节点的指针link。CircularLinkedList自身在构造时传入一个 ILayoutFactory类型的工厂,用于加工生成Layout(确保环形链表一旦溢出,能够生成新节点元素)。下面是 CircularLinkedList的构造函数:

public  CircularLinkedList(ILayoutFactory layoutFactory)
{
  current 
= null;
  last 
= null;
  allocate 
= null;
  end 
= null;
  count 
= 0;
  stepCounter 
= 1;
  
this.layoutFactory = layoutFactory;
}

在传入工厂对象的同时初始化四个指针:current、last、allocate与end,除此之外还初始化两个计数器count(用于计算环型 链表总共包含多少个节点)与stepCounter(用于计算当前搜索层级,搜索到了第几步)。current、last、allocate三个指针的作 用在前面的内容中已经说的很清楚了。这里的end指针是用于防止溢出,记录溢出位置的指针。

分配可用Layout的函数以及确认分配的函数定义如下:

// ======================================================
//  获取下一个可用空节点
// ======================================================
public  Layout AllocateLayout()
{
  
if(allocate.link == current)
  
{
    end 
= allocate;
    insertNode(layoutFactory.Create());
  }

    
  
return allocate.link.info;
}


// ======================================================
//  确认分配的空间
// ======================================================
public   void  ConfirmAllocation()
{
  allocate 
= allocate.link;
}

在这里需要注意的是当分配可用Layout时,注意防止溢出发生。溢出判断的方式是:allocate.link == current,如果相同表明将会发生溢出,这时候需要先插入新节点,然后再分配可用布局。确认分配的动作很简单,就是将allocate指针指向下一节 点而已。

插入节点时,首先判断当前环形链表是否为空,如果为空,新节点的下一个节点就是自己本身,单一节点构成一封闭的环。如果环形链表不为空,则在end指针处插入新节点。具体插入操作可以参考下面的代码。

private   void  insertNode(Layout newitem)
{
  LinkedListNode trail;
  LinkedListNode newNode;
    
  newNode 
= new LinkedListNode();
  newNode.info 
= newitem;
  newNode.link 
= null;
   
  
if(current == null)
  
{   
    current 
= newNode;
    current.link 
= newNode;
    end 
= current;
  }

  
else
  
{
    trail 
= end.link;
    end.link 
= newNode;
    end 
= newNode;
    newNode.link 
= trail;
  }

  count
++;
}

当某一步搜索完成后,就会进入下一轮搜索,系统重置各指针位置。这是由NextStep方法来实现的:

private   bool  NextStep()
{
  stepCounter
++;
  _mediator.HandleInfo(stepCounter);

  
if(allocate == last)
    
return false//棋局无解

  current 
= last.link;
  last 
= allocate;
  _mediator.MoveCurrentToNext();
  
return true;
}

注意这里调用了中介者的HandleInfo方法,允许外部程序得到当前系统运行状态的一个通知。

 _mediator.HandleInfo(stepCounter);

另外,如果allocate指针未能分配出任何布局,说明程序运行到此处已经没有任何可行的解了,程序就此终止。否则重置各指针位置并通过中介者通知TreeLinkedList移动current指针。

CircularLinkedList中的核心运算方法就是BeginProcess方法。该方法通过递归调用完成搜索操作。首先对当前层级的每个 节点运算可行的下步走法,然后调用NextStep方法继续搜索下一步直至搜索完成或系统判断处棋局无解。期间通过stop属性判断是否终止搜索。我们可 以通过CircularLinkedList中的Stop方法随时终止搜索进程。在WinHRD的例子中,通过将搜索放到单独的线程中运行确保不影响主程 序,同时也可以让用户随时通过Stop方法终止程序运行。BeginProcess方法与Stop方法的定义如下:

public   bool  BeginProcess()
{
  
while(current != last.link)
  
{
    current.info.CheckAvailableSteps();

    
if(stop)
      
return true;

    current 
= current.link;
    
if(current != last.link)
      _mediator.MoveCurrentToNext();
  }

  
if(this.NextStep())
  
{
    
return BeginProcess();
  }

  
else
    
return false;
}


public   void  Stop()
{
  
this.stop = true;
}
CircularLinkedList是程序求解的关键部件,我们从上面的递归调用中可以看出求解的思路。在下部分内容中,将介绍中介者Mediator对象。

十五、 Mediator的设计

华容道自动求解程序的解题过程可以说是Layout、CircularLinkedList、AVLTree和TreeLinkedList相互协 作的过程。为了降低对象间的耦合度,引入了一个中介者Mediator,负责协调它们之间的调用。我们可以在CircularLinkedList对象以 及Layout对象中看到各有一个Mediator对象的引用。同时也可以看到Mediator对象持有CircularLinkedList、 AVLTree和TreeLinkedList的引用。

Mediator对象首先初始化各个对象,为CircularLinkedList对象创建若干个节点以备使用,同时为TreeLinkedList与AVLTree添加根节点。代码如下:

public   void  Init( int  n)
{
  
for(int i = 0; i<n; i++)
    _circleList.insertNode();

  ChessStep c 
= _circleList.Initialize();
  _treeList.ClearAll();
  _avlTree.DestroyTree();

  _treeList.insertNode(c);
  _avlTree.Insert(c.layout);
  _circleList.mediator 
= this;
}

Mediator初始化完成后,我们就可以通过调用Mediator的BeginProcess方法启动搜索过程,Stop方法停止搜索。另外通过Release方法释放所有占用的内存资源。

public   void  Release()
{
  _circleList.ClearAll();
  _treeList.ClearAll();
  _avlTree.DestroyTree();
}


public   bool  BeginProcess()
{
  
return _circleList.BeginProcess();
}


public   void  Stop()
{
  _circleList.Stop();
}

Mediator中的其它方法是为协调个运算对象设计的。我们可以从以下几个方面来看看Mediator是如何协调对象间的工作的:

首先,当某个Layout的CheckAvailableSteps方法被调用,并找到一个可行的布局后,Layout首先检查这个布局是否已经达 到解题目的,如果没有,则调用Mediator的CheckStep方法,检查这个布局和以往布局是否有重复,如果没有重复,则将此布局以及走法添加到 TreeLinkedList与AVLTree中,并通知CircularLinkedList确认分配的空间。如果布局就是最终解,则调用Layout 的Finished方法,追溯棋局走法并输出结果。Layout中的代码如下:

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

else
 _mediator.CheckStep(cs);

这个过程可以用以下两个序列图来描述:

 

 

代码实现如下:

public   void  CheckStep(ChessStep cStep)
{
  
try
  
{
    _avlTree.Insert(cStep.layout);
    _circleList.ConfirmAllocation();
    _treeList.insertNode(cStep);
  }

  
catch
  
{
  }

}


public   void  Finished(ChessStep cStep)
{
  _circleList.Stop();
  ChessStep[] s 
= _treeList.TraceResult(cStep);

  _resultHandler.HandleResult(s);
  
return;
}

Mediator除了实现上述功能外,它还作为CircularLinkedList与TreeLinkedList通讯的一个中间渠道。在前面的 文章中曾经提到过,要确保TreeLinkedList中的current指针与CircularLinkedList中的current指针同步移动; 同时,Layout在求解的过程中也需要从CircularLinkedList中分配一些可用的Layout来使用。所有这些都是通过Mediator 来协调的。

public  Layout AllocateLayout()
{
  
return _circleList.AllocateLayout();
}


public   void  MoveCurrentToNext()
{
  _treeList.MoveCurrentToNext();
}

Mediator的引入降低了各对象间的耦合度,增强了系统的灵活性。

在后面的内容中,我将介绍Sample中的代码实现以及性能方面的测试和讨论。

十六、 WinHRD的设计

基本组件的编写工作完成后,我们设计一个程序测试一下。在提供的源代码中提供了两个例子。一个是ConsoleHRD(DOS环境下的求解程序),比较简单。另外一个是Windows界面的WinHRD。

 

我们这里来看看WinHRD的设计:首先主窗口要实现IResultHandler接口以处理华容道组件提供的信息。例如当前搜索到了多少层级,以 及求解的结果是什么。用户可以自己单独设计一个实现IResultHandler接口的类来处理这些事情。只要将该类的实例提供给Mediator对象就 行了。

public   class  frmMain : Form, IResultHandler
{
  …………
  
public void HandleResult(ChessStep[] steps)
  
{
    
// 处理搜索结果
    …………
    
return;
  }


  
public void HandleInfo(int currentStep)
  
{
    
// 处理搜索中的信息
    this.lblInfo.Text = "搜索深度: " + currentStep.ToString();
    
this.lblInfo.Refresh();
  }

}

在窗体的初始化代码中初始化各个组件:

public  frmMain()
{
  InitializeComponent();

  
// 初始化自动求解程序
  mediator = new Mediator();
  mediator.avlTree 
= new AVLTree();
  mediator.treeList 
= new TreeLinkedList();
  mediator.resultHandler 
= this;
}

为了确保程序的运行效率,我们在单独的线程中运行主处理程序。因此,我们将主要的处理程序封装到一个Process方法中,并将其运行在单独的线程中,线程的优先性设置为BelowNormal,IsBackground设置为true。代码如下(部分简化):

private   void  btnBegin_Click( object  sender, System.EventArgs e)
{
  layoutFactory.mediator 
= mediator;
  mediator.circleList 
= new CircularLinkedList(layoutFactory);
  mediator.resultHandler 
= this;

  mediator.Init(
1000);

  Thread trd 
= new Thread(new ThreadStart(this.Process));
  trd.Priority 
= ThreadPriority.BelowNormal;
  trd.IsBackground 
= true;
  trd.Start();
}


private   void  Process()
{
  
if(!mediator.BeginProcess())
    
this.txtResult.Text = "此题无解!";

  mediator.Release();  
// 及时释放内存;
}


private   void  btnStop_Click( object  sender, System.EventArgs e)
{
  mediator.Stop();
}

至此,我们就完成了客户端的设计。主运算运行在单独的线程中,并可以随时被终止。不会给用户带来任何停滞的感觉。


十七、 性能大比拼

完成了程序的主体工作后,就需要对效率进行一下评价了。我将自己的程序与河北石家庄李智广的"华容道全能版 V1.1"进行了效率对比。在我的机器上(P4 2.0G、512M DDR400内存)得到如下测试结果:

 

从表中可以看出,在一些比较简单的布局中,"华容道全能版"拥有比较高的运算效率。但当搜索的节点数超过40000时,"华容道全能版"的搜索效率 急剧下降。运算时间均在10秒甚至15秒以上。本程序在运算上效率较高,尤其是在复杂布局的求解上也拥有令人满意的效率。究其原因,本人认为有以下几点:

1、本程序将布局转换为4字节整数消耗了过多的CPU时间(需要排序操作),因此为了节省内存而损失了效率。

2、AVLTree带来了良好搜索性能。即使搜索节点超过40000节点,AVLTree也能确保最多在16次比较后就能判断出是否有重复布局(2^16=65532)。

3、种种迹象表明,"华容道全能版"在判断下一步可行走法时,仅仅判断了四种情况:上、下、左、右。而本程序判断了九种情况,除上下左右外还包括上 移两步、下移两步、左移两步、右移两步以及转弯(专门针对卒)。因此"华容道全能版"在一步的判断上速度要快很多,但增加了搜索深度(从表中的数据也可以 看出来),并且不能正确求得华容道求解的最小步数。

"智能算法爱好者"的结论是:走一格尽管深度增加,但一个递归层的节点宽度比走两格的节点宽度窄了很多,比较的有效节点差不多,但比较的总节点数少 了很多,所以走一格比走两格要少很多时间,大约为走两格的70%(用我的同一程序测试),并且只有走两格才能找到最少步数(最优解)。

如果对内存的使用可以放宽松一些的化,我们可以考虑采用其它的方式存储棋盘布局(不再使用4字节表示),这样虽会使AVLTree的构建过程有所减 慢,但华容道求解速度会有所提升(不再需要执行排序操作了)。总体来说,对布局搜索速度会有提升(这只是我的猜测,未经验证)。

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值