解决魔方问题
本章只是就解魔方的编程方法作扼要介绍,具体的程序将在下一章进行分析。
简介
本文将介绍如何使用PROLOG语言解决魔方问题。在本程序中使用到的一些方法,将有助于解决建造专家系统时所遇到的一些问题。速度和效率是我们的所要考虑的重点。
这个程序是一个经典的专家系统,他能够比人更快的解决魔方问题。然而它并不具有找到解决魔方的方法的能力。它只是简单的把人们解决魔方问题的经验汇集起来。所以编程者必须知道如何人工解决魔方问题。
因为在魔方问题中状态图是相当大的,所以盲目搜索将不起作用。这时必须使用优化算法指导搜索。使用不同的优化方法,搜索空间将极大的减少,从而达到在有限时间内能够找到结果的程度。 这个问题和其它的人工智能问题一样,分为三个设计阶段。首先我们必须决定如何表示魔方的当前状态。然后还必须表示对魔方的操作。最后我们必须解决如何把这些操作运用到魔方之上。这三个方面在程序设计中都非常重要的。
由于PROLOG语言的强大功能,在此我们选用它作为解决魔方问题的工具。在本文中将简要介绍编程的过程,PROLOG语言和其它语言一样也有优点和缺点,在这个例子中我们都将看到。
魔方简介
魔方是一个看似简单的玩具,它的每个面有九个格。我们的目标是使每个面的格子的颜色都相同。每个格子实际上是小方块的一部分。小方块的每个面都可以旋转,角方块有三个面,边方块有两个面。
目标是把一个每面都是随机颜色的魔方还原成每面颜色都相同的魔方。问题是,有无数的方法操作魔方,然而只有极少的方法能够达到目的。使用盲目的搜索算法是不行的,即使在最大型的计算机是也要用上几年的时间。然而会玩的人在几分钟之内就可以搞定,很显然这不是使用的盲目搜索算法。解决魔方问题困难之一就是当你想移动一个方块时,不得不移动其它七个方块(中心的方块是不动的)。在求解的早期这不是大问题,但是当大部分的方块都到达正确位置时,新的旋转将会破坏已完成的部分。一些玩魔方的高手都知道许多复杂的旋转方法,这些方法能够只改变少数方块的位置而不影响其它的方块。例如本程序中一个十四步的方法能够只旋转两个角方块的方向。
魔方的数据结构
任何人工智能程序的核心都是如何表达知识。在这个程序中,核心就是如何表达魔方以及对它的操作。魔方本身的特点决定了有两种表达方式。我们既可以把魔方看作是54的分离的小面,也可以是26个小方块。其中6个方块只有一个面(即中间的方块),12个方块有两个面(即边方块),8个方块有3个面(即角方块)。由于本程序中的大多数智能都是基于小方块的位置和方向,所以使用小方块来表示魔方是有必要的。然而,我们的程序中使用了广度搜索,使用54个分离的小面的表示方法将能够提高搜索的速度,这是因为这种表达方法没有嵌套结构。
下面我们要决定是使用Prolog的复合结构表达魔方,还是使用列表来表达。如果使用列表表达,则有助于在魔方中搜寻某一个特定的小方块,但是当整体考虑时,它又会影响速度。使用复合结构虽然不容易搜寻魔方的某个部分,但是却能够有效地对魔方的整体进行操作。考虑到程序的复杂性以及效率,我们决定同时使用这两种数据结构。我们使用Prolog的复合结构来表达54个分离的小面,而用列表来表达26个方块。
下面是使用复合结构表达:(每个X变量表示一个小面)
cube(X1, X2, X3, X4, ........, X53, X54)
下面是使用列表表达:(其中的X变量与上面的小面对应)
[p(X1),p(X2),...p(X7,X8,X9),... p(X31,X32),p(X33,X34),...]
其中的p(...)表示一个方块。它们的参数数目有1、2、和3不等,这就是每个方块的可见面。只有一个参数的表示中心方块,2个参数的表示边方块,3个参数的则表示角方块。每一个小面都是使用的大写字母的变量表示的,这表示每个小面的数据(颜色)是不确定的。我们给每个面一个常数来定义最终的状态,在本程序中使用ghoul谓词来储存最终的魔方状态。
ghoul( cube('F', 'R', 'U', 'B', ............))
注意使用引号引起来的都是常数,F表示front,B表示back,如此类推。此处我们没有使用颜色,不过这并不影响解题。
对魔方的操作
当决定了使用这两种方式表达魔方后,我们必须编写这两种数据结构的转换程序。Prolog强大的联合功能可以很好的完成这个任务。下面的谓词pieces可以进行这种转换,它的编写方法很简单:
pieces( cube(X1, X2, .......X54), [p(X1), ...... p(X7, X8, X9),.....]).
如果变量Z保存的是魔方的各小面的数据,下面的询问则可以轻松地转换为各小方块的数据:
?- pieces(Z,Y).
变量Y将绑定为各小方块的数据,反过来也是一样。下面的询问可以把最终目标的方块数据表示求出来。(我们只定义了最终状态的小面数据表示)
?- ghoul(FlatState), pieces(FlatState, PieceState).
FlatState = cube('F', 'R', 'V', 'B', .....).
PieceState = [p('F'), p('R'), .... p('R', 'U'),
......p('B', 'R', 'F'), ......].
第一个目标使变量FlatState绑定为我们定义的最终状态的小面数据表示。再调用pieces把小面数据转换为小方块数据。我们同样可以使用联合来完成对魔方的基本旋转。第一个参数为旋转的名称,第二个和第三个参数则表示了某个面顺时针旋转前后的两个状态。例如:上部分的旋转可以如下表示:
mov(u, cube(X1, ...X6, X7, X8, X9, ...),
cube(X1, ...X6, X20, X19, X21, ...))
我们可以使用mov谓词来对最终的魔方进行一次旋转:
?- ghoul(State), mov(u, State, NewState).
变量NewState将绑定为魔方最终状态的上部顺时针旋转后的状态。当然逆时针旋转的功能也很容易实现,只要把NewState作为输入,则State将绑定为NewState逆时针旋转后的状态。下面的move谓词,通过调用谓词mov完成魔方的正反方向的旋转:
move(+M, OldState, NewState):-
mov(M, OldState, NewState).
move(-M, OldState, NewState):-
mov(M, NewState, OldState).
到此为止我们已经编写出了魔方的基本旋转,下面来表达一些复杂的旋转序列。一般玩魔方的高手都记得许多固定的玩法,使用这些方法可以快速地解魔方。例如,有一种旋转序列可以只交换三个角方块,而其他的方块都不动。这种旋转序列使用列表是很容易表达的,下面就是只交换三个角方块的旋转序列:
seq(tc3, [+r, -u, -l, +u, -r, -u, +l, +u]).
我们使用尾递归谓词move_list来完成旋转序列的操作:
move_list( [ ], X, X).
move_list( [Move|T], X, Z):-
move(Move, X, Y),
move_list(T, Y, Z).
到此为止我们已经使用非常有效的方法表达了魔方以及对它的操作。下面我们需要加入指导搜索的启发式方法。
高级规则
最明显的解魔方的方法就是一步一步使各个小块到达它应该在的位置。我们在此把解魔方分为6个步骤,这6个步骤顺序从左到右来解魔方。例如其中的两个步骤是:“使左边的边方块到位”和“使右边的角方块到位”。我们把每一步需要完成的目标使用stage谓词储存起来。每个步骤都有特定的几个方块需要归位。这些信息则使用谓词pln贮存。它有两个参数,第一个是步骤的序列数,第二个是要归位的方块列表。例如步骤5是把又变得4个边方块归位,所以如下表达:
pln(5, [p('R', 'U'), p('F', 'R'),
p('R', 'D'), p('B', 'R')]).
而每一个步骤所使用的旋转序列是不同的。为了加快状态图的搜索,我们为每一个步骤都定义它自己所需要使用的旋转序列,在此我们使用谓词cnd来储存这些信息。
例如,第一阶段(左边的4个边方块)可以只使用右、上和前侧的基本旋转就可以完成。而最后的阶段(右边的4个角方块)就必须使用只交换角方块或者旋转角方块的复杂的旋转序列开能够完成。我们已经使用seq谓词为每种旋转序列起了名字。下面是这两个阶段使用的cnd子句:
cnd(l, [r, u, f]).
cnd(6, [u, tc1, tc3, ct1, ct3]).
stage谓词则是进行每一阶段搜索的核心。它首先初始化某个阶段,包括这个阶段的目标和所使用的旋转序列。然后调用一系列的程序改进魔方的状态,直到达到目标。
优化状态
stage谓词调用搜索程序来达到目标,然后更新所有需要更新的数据。这一部分程序的核心是如何表达一个部分完成的魔方。如何让搜索程序知道某个小方块已经到达了它应该在的位置呢?本程序中,就有几个谓词专门完成在魔方的列表数据结构中搜索小方块的任务。它们能够确定某个方块的位置,或者某个位置上的方块。这些谓词在程序中的许多地方都很有用处,不过如果用它们来判断是否达到了某个阶段的目标,则会极大地影响速度。这很容易理解:因为每次都需要判断新的方块是否到位,并且已经到位的方块是否没有被移动。
为了解决这个问题,我们再次使用联合。迄今为止,我们的程序中已经有两个cube子句了。一个表示最终的魔方状态,一个表示当前的魔方状态。现在我们引入第三个cube子句,它是一种标准,用来确定哪些方块已经到位。
一开始这个标准cube的所有参数都是变量。所以标准cube可以与任何cube匹配。当小方块开始就位后,某些变量就与已经就位的方块绑定。在这种情况下,这个标准cube只能与那些有相同的小方块就位的cube匹配。例如,当程序试图归为第6个方块时,improve谓词首先把标准cube的第6个方块绑定为已归位的状态。这个时候前六个小方块应该都已经绑定为归位的状态了。而剩下的方块就使用未绑定的变量来表示。因此这个标准cube就可以与任何一个前六个方块已归位的状态匹配了。
搜索
万事俱备,只差搜索了。本程序所使用的是广度搜索。搜索谓词为rotate:
rotate([], State, State).
rotate(Moves, State, Crit):-
rotate(PriorMoves, State, NextState),
get_move(ThisMove, NextState, Crit),
append(PriorMoves, [ThisMove], Moves).
变量Moves在调用时没有绑定,最后返回从State状态到Crit状态的旋转列表。State是当前的魔方状态,而Crit是某个阶段的目标状态。就是上一节中所介绍的标准Cube。因此rotate可以找到一系列的旋转方法使得在不破坏已归位的小方块的同时而把新的小方块归位。
这个广度搜索我们前面已经介绍过了,此处就不再多说。这里我们没有储存搜索的中间状态,也就是说会产生重复搜索的情况。不过由于搜索的深度并不深,所以这样做并不会影响速度。
每个阶段所可能使用的旋转方法储存在谓词cand中(真正的程序比这里的稍微复杂一下),例如阶段1的cand如下:
cand(r).
cand(u).
cand(f).
get_move谓词的功能是找到从State到Crit的旋转方法(它只寻找一步)。所以调用时Move变量是没有被绑定的。它的程序如下:
get_move(+Move, State, Crit):-
cand(Move),
mov(Move, State, Crit).
get_move(-Move, State, Crit):-
cand(Move),
mov(Move, Crit, State).
这实际上是一种产生并校验的方法。使用cand找到可能的旋转方法,然后使用mov来判断它是否是所需要的旋转方法。由于Prolog内建的回溯功能,我们不必要关心具体的循环判断。它由两个子句构成,是因为魔方有正反两种操作方法。
由于广度搜索比较难于理解,所以你最好回顾一下前面介绍广度搜索的章节。
更多的优化
到此为止,我们的程序已经可以运行了。不过有几种情况会使得搜索的路径过长。这些情况破坏了程序的完美。
所以有必要加入更多的智能成分好让程序能够识别这些不易解的情况,并且在调用rotate之前就修正它们。在放置左边的方块时会经常遇到一个问题:如果要放置的方块在右边就很好办,可以通过几步就搞定,但是如果这些方块已经在左边,而位置不对, 就比较麻烦了,我们必须先把它们移到右边,然后再移到左边。这样搜索的路径就太长了,因此必须加入更多的优化。
优化程序首先分析方块,如果发现这个情况,就使用盲目的移动把方块移到右边。而后续的搜索又会把它还原到左边,并且位置也正确了。
为了减少搜索的工作量,我们可以引入许多优化方案。但是我们现在的程序已经能够很好的解决魔方问题了。只有当出现了搜索不出来的情况时,我们才有必要加入更多的优化,让计算机工作总比让人工作划的来吧!:-)
这一章只是简要地介绍了解魔方的方法,所提供的程序也是不完整的。下一章我们将来分析完整的程序。完整的程序很长,所以也只能分析主要的部分。(如果下一章不是介绍解魔方的程序,那就表示我还没有写完,请耐心等候吧) |