本文通过几个简单的例子,介绍约束逻辑程序设计的基本知识,以及如何用Mozart-Oz来实现。
(本文中将把Constraint Programming翻译为约束程序设计,译者本人并不知道许多专有名词的译法,因此可能有谬误,请见谅)
介绍
1. 什么是约束程序设计?
我们还是从一个例子着手吧。
例1.
有如下的数学算式:SEND+MORE=MONEY,每个字母代表一位从0到9的数字,首位不能为0,每个字母所代表的数字不同,请问各个字母所代表的数字是多少?
这个问题有唯一解答:9567+1085=10652
这就是一个条件约束问题。解决此类问题最简单的方法可以描述为"产生并测试"。我们很容易看出这个问题
有8个字母,而每个字母又可以是从0到9,即有10种可选方案。所以总共的可选答案是8^10种。产生程序将产生这8^10种可能解答, 然后使用测试程序来测试所产生的解答的正确性。下面列出这个问题的约束条件:
1. 8个字母所代表的数字两两不同。
2. S M不能为零
3. S*1000+E*100+N*10+D + M*1000+O*100+R*10+E = M*10000 + O*1000+N*100+E*10+Y
只要产生的某种组合符合上述三个条件就可以说是找到答案了。
但是显然这种方法在多数情况下是行不通的。这是由于组合问题中通常都存在组合爆炸,例如上题中如果不是8个字母, 而是9个的话,那么可能的组合数目就是9^10,字母仅仅只多了一个,而要产生的排列却增加了3倍多。
本文将详细介绍如何编制解决此类问题的程序,不过我们不使用传统的程序设计语言,而是使用一种已经包含了完整解决方案的语言:OZ。
2.基本原理
首先来看看需要使用什么方法才能够减少测试的次数吧。我们从另外一个简单例子入手。典型的条件约束问题具有如下的形式:
它包含n个变量:V1,......,Vn, 每个变量的取值范围分别是:D1,......Dn,其中D1,......Dn都是包含有限元素的集合,通常集合中的元素都可以使用整数来表示。目标是找到一组满足约束条件C(V1,......,Vn)的解。下面是一个简单的例子:
例2.有15个变量V1,......,V15,他们的取值范围都是{1,......,15},约束条件为:V1V14很明显这个问题只有一个答案就是Vk=k。我们来看如何让电脑来解决这个问题吧。
首先使用通常的产生并测试的方法:
我们可以通过下面的方法来产生一系列的可能组合:
选择一个尚未赋值的变量,从该变量的值域中选择一个值赋给此变量,重复此步骤直到所有的变量都赋上了值。这个操作将生成一个树状结构:每个非叶节点都代表一个尚未完全赋值的状态,叶节点则代表了一种可能的组合。
满足约束条件的叶节点就是问题的解,不满足约束条件的叶节点就是失败点。在本文中我们将经常使用图形来表达这种树状结构:
其中蓝色圆形代表选择点,即非叶节点。红色方形代表失败点,绿色菱形代表解节点。为了方便起见,一棵所有叶节点都是失败点的子树使用红色三角形代表,而包括了解节点的子树则使用绿色三角形代表。
现在来看看前面的问题:一共有15个变量,每个变量的可能取值有15个,那么总共的可能组合就是15^15=437,893,890,380,859,275种, 就目前的计算机来说这是个天文数字了。
3. 交错产生和测试
前面所介绍的"产生并测试"的方法,是在完全产生了一种组合之后,再来测试这个组合。我们可以把产生和测试交错进行,这样可以极大地提高效率。在每一次选择之后都使用约束条件来判断这步选择是否合理,而不是完全产生了某种组合之后再来判断。例如在上面的例子中,当给V1和V2赋值之后,就可以立即使用V1传播和分配(Propagate and Distribute)
到目前为止,我想你应该已经认识到使用简单的"产生并测试"的方法是行不通的,而"交错产生和测试"虽然能够提高效率,但是也有它的局限性:若使用这种方法来解例2,那么搜索树将包含917477个节点。如果变量数目增加到100又会如何呢?:)
那么使用什么方法呢?我们不希望测试过程只是被动的检测某种组合是否满足条件,而希望测试过程是积极的。这就是说通过测试,我们希望缩小变量的取值范围。在前面的例子中,V1和V2的取值范围都是1到15,但是由于V1然而在通常情况下搜索是不可避免的,我们的目的是在每次搜索之前先通过约束条件尽量缩小变量的取值范围。这种方法在约束程序设计中被叫做"传播和分配(propagate and distribute)"。
传播通过简单的确定性的推导过程尽量减少可能的解的个数。当传播过程无法继续的时候,就需要进行分配。所谓分配就是进行非确定性的选择(下面将详细介绍,如果有何不明白请继续阅读),这样搜索树的尺寸能够尽量缩小,我们可以把传播的过程看作对搜索树的裁剪。
Mozart OZ
OZ是一门程序设计语言,而Mozart则是它的实现系统之一。OZ是一门新兴的程序设计语言,因此它也包含了许多特别的技术。自然,约束程序设计是它的主要功能之一,除此之外它有像Prolog一样的逻辑程序设计模式,有Lisp, SML式的函数式程序设计模式,另外还有并行计算,多线程,面向对象等模式。本文主要介绍它在约束程序设计方面的运用,至于其它方面将在必要的时候进行讲解。
1. 安装
Mozart支持许多操作系统,你可以到www.mozart-oz.org下载相应的版本。除此之外你的电脑上必须安装Emacs(Xemacs),Mozart将使用这个编辑器作为开发界面。安装中如果出现什么问题,请参照mozart的帮助文档。
下面是运行界面,最上面的窗口就是我们写程序的地方。中间一部分(标有Oz compiler)用来显示编译信息,最下面一行是Emacs的提示栏,用来显示和输入一些编辑信息。
我们先来看如何做HelloWorld程序,在最上面的窗口输入下面文字之后,按Ctrl+.然后按Ctrl+b就会把当前窗口中的程序送交编译器编译运行,当然也可以选择OZ菜单中的feed buffer选项,另外还有Feed Region,Feed Line,Feed Paragraph等选项,Region表示当前的本选择的文字,Line表示光标所在的行,Paragraph表示一段程序(没有空行分隔)。我们看到编译信息窗口出现了编译信息,accepted表示编译通过,可是运行的结果在哪里呢?运行的结果在一个尚未打开的buffer中,选择OZ->Show/Hide->Emulator就可以看到结果了。
把上面的Show改为Browse,即{Browse 'Hello World!'}并feed buffer之后,将看到
窗口出现。此后我们都将使用OZ browser来显示结果。
2. 传播
OZ包含了整个约束程序设计所需要的功能,我们想来看看传播(propagate),即OZ是如何最大限度的缩小变量的取值范围的。
先输入如下程序(把上面的Oz Browser,先关闭,把程序buffer中的文字也请清空)
declare X Y Z
X::1#10
Y::1#10
Z::1#10
{Browse [X Y Z]}
然后用Feed Paragraph把上面程序段送交编译器。
declare关键字用来声明变量,这里声明了三个变量标识符:X Y Z,OZ中所有的变量标识符都必须大写。X::1#10意思是说X的取值范围从1到10,包括1和10。::传播器是用来定义约束变量的取值范围的,而1#10则是某个可能的取值空间。最后我们用Browse把三个变量的值显示出来,[X Y Z]把这三个变量做成一个列表,关于列表,如果你熟悉Prolog,Lisp的话应该不会陌生。出现的结果如下:
我们看到变量标识符后面就是此变量的取值范围。
此后我们每输入一句语句,就用Feed Line把它送交编译器,这样可以动态地看到程序运行的结果,Browse中的显示内容不再用图表示,我在前面加上>>前缀表示是Browser中的内容。
X<:Y
>> [X{1#9} Y{2#10} Z{1#10}]
输入X<:Y之后我们惊奇的发现,Browser中的内容变化了,这也是Brwoser的强大功能之一,它并不只是简单的把结果显示出来,当变量发生变化的时候,这些变化也会立刻在Browser中体现出来。X<:Y是一个一个传播器,当我们把这个约束条件送交编译器后,传播起作用了,同时缩小了X Y的取值范围。
Y<:Z
>> [X{1#8} Y{2#9} Z{3#10}]
输入Y<:Z后我们发现X的取值范围也发生了变化,这也就是说X<:Y这个传播器也起了作用。
X+Y+Z=:6
>>[1 2 3]
我们发现这个约束条件加上之后,X Y Z的值就都确定下来了。
我们看到了传播器可以自己进行推导,尽量的减小变量的取值范围。不过这种推导也是非常复杂的,因此Mozart在这方面做了某些限制。例如Y=:2*X并不能够推导出Y必须是偶数,
除了上面的这些简单的传播器之外,Mozart还提供了许多很高级的约束传播器,等我们需要的时候再进行讲解。
3. 分配
当传播器无法在缩小变量的取值范围的时候,就需要进行分配了。所谓分配就是把变量组中的某个变量的取值范围分成两个部分,然后分别再对这两个部分进行传播和分配。
下面我们先来看一个完整的传播分配的例子:
我们要解决的问题如下:
X Y Z属于{0,1,...,7}
X+Y=3*Z
X-Y=Z
首先我们可以根据上面的约束条件写出如下的传播器:
X::1#7
Y::1#7
Z::1#7
上面三个传播器可以缩写为: [X Y Z] ::: 1#7,我们用列表把三个变量扩起来,然后用:::定义这个列表的取值范围,即列表中的每个元素的取值范围。剩下的两个等式的传播器如下:
X + Y =: 3*Z
X - Y =: Z
如果你把上述的传播器传送给编译器的话,
declare X Y Z
[X Y Z]:::1#7
X+Y=:3*Z
X-Y=:Z
{Browse [X Y Z]}
将得到如下结果:
>> [X{2#7} Y{1#6} Z{1#4}]
很显然,并没有找到答案。
因此我们要使用分配器,把上述变量中的某个变量的取值范围分成两个部分,然后再分别对这两个部分进行传播。分配的方式有很多, 最简单的方式就是选择变量组中的第一个变量,假设这个变量的取值范围是a#b,那么分开的两组范围就是a和(a+1)#b。这种分配方式叫做naïve。分配语句如下:
{FD.distribute naive [X Y Z]}
这句话表示我们要对变量组X Y Z的值域进行分配,分配方式是naïve。
我们先来看看实现程序:
declare
proc {Equations Sol}
X Y Z
in
Sol = solution(x:X y:Y z:Z)
% Propagate
[X Y Z] ::: 1#7
X + Y =: 3*Z
X - Y =: Z
% Distribute
{FD.distribute naive [X Y Z]}
end
{Explorer.all Equations}
这里我们声明了一个过程,过程的声明方式如下:
proc {Equations Sol}
end
Equations为过程名,Sol为过程的参数,这里这个参数是作为返回值用的。
X Y Z是这个过程的内部变量标识符,我们用一个结solution(x:X y:Y z:Z)把这三个变量包含起来,并赋予Sol,这样过程的返回值就是一组解了。
下面的分别定义传播器和分配器,%表示注释:
% Propagate
[X Y Z] ::: 1#7
X + Y =: 3*Z
X - Y =: Z
% Distribute
{FD.distribute naive [X Y Z]}
定义了上述函数之后,我们还必须把这个函数传递给专门的搜索引擎:
{Explorer.all Equations}
{Explorer.one Equations}
Explorer.all找Equations的所有解,而Explorer.one只搜索一个解。把上面的程序传送给编译器后,我们看到如下的结果:
我们看到Explorer显示的是一棵搜索树,按照上面树的节点的顺序一起双击,则节点的值在下面的Inspector
中显示出来。图中每个蓝色的圆形节点都表示一次分配的过程。我们看到节点1的值就是前面只进行传播之后的结果。由于得到这个结果之后无法再进行传播,因此分配器对变量组[X Y Z]中的第一个变量X进行分配,得到如下两组取值范围:
[X{2} Y{1#6} Z{1#4}]
[X{3#7} Y{1#6} Z{1#4}]
对[X{2} Y{1#6} Z{1#4}]进行传播最终得到一组解:2#solution(x:2 y:1 z:1),即图中的第二个节点,解都是由绿色的菱形表示。而节点3就是上面的第二组取值范围[X{3#7} Y{1#6} Z{1#4}]进行传播之后的结果[X{3#7} Y{1#5} Z{2#4}],我们看到Y和Z的值域发生了变化。因为传播器无法对[X{3#7} Y{1#5} Z{2#4}]这个取值范围进行传播以获得更小的取值范围,因此分配器再次进行分配:
[X{3} Y{1#5} Z{2#4}]
[X{4#7} Y{1#5} Z{2#4}]
传播器对第一组结果进行传播发现自相矛盾,因此我们得到一个红色的正方形节点,表示失败。对[X{4#7} Y{1#5} Z{2#4}]进行传播的结果为节点4。
如此下去当整个搜索树完成的时候,我们得到三个绿色的菱形节点,也就是三组解。
现在我们知道了传播分配的基本原理,在OZ中提供各种各样的分配器。例如有些分配器是找到变量组中可能取值个数最少变量进行分配,这样有助于尽早确定这个变量的值,而有的分配器是把变量的取值范围等分为两部分,这样做有助于传播器最大限度的对搜索树进行裁剪。不同的问题采用不同的分配器,得到结果最终是一样的,不过效率就有很大的区别了。