☰
用Prolog解爱因斯坦斑马问题:约束建模与逻辑编程实战
2026/10/19 9:17:43 网站建设 项目流程

1. 为什么我要用 Prolog 去啃一道“逻辑谜题”

第一次看到爱因斯坦斑马问题的时候,我的反应和大多数人一样:这不就是一道逻辑推理题吗,拿张草稿纸画个表格,慢慢推也能推出来。确实,网上有大量手推的解法,步骤写得清清楚楚,跟着走一遍也能得到答案。但问题在于,手推的过程极其依赖人的耐心和细心,稍微漏掉一个约束条件,就会在某个岔路口选错分支,最后绕一大圈才发现矛盾。更关键的是,手推的解法没法复用——换一道类似的逻辑谜题,你又得从头再来一遍。

后来我接触到了 Prolog 这门语言,才意识到这类问题简直就是为它量身定做的。Prolog 的核心思想是“声明式编程”:你不需要告诉计算机“怎么算”,只需要把问题的约束条件用事实和规则描述清楚,剩下的交给它的推理引擎去搜索。斑马问题本质上就是一堆约束条件的集合——谁住在什么颜色的房子里、谁养什么宠物、谁喝什么饮料、谁抽什么烟——这些约束用 Prolog 的语言表达出来,几乎就是自然语言的直译。

这篇文章适合两类人看:一类是对逻辑编程好奇、想找一个具体项目入门的开发者;另一类是已经会写 Prolog 但没想过拿它解决实际谜题的人。我会从零开始,把整个建模过程、代码实现、调试技巧和踩过的坑都讲清楚。你不需要有 Prolog 基础,只要会基本的编程概念就能跟上。读完之后,你不仅能得到斑马问题的完整 Prolog 解法,更重要的是能理解“约束建模”这套思维方式,以后遇到类似的排班、配置、调度问题,都能用同样的思路去拆解。

2. 斑马问题的约束条件到底在说什么

2.1 把自然语言翻译成可计算的约束

先把题目本身捋一遍。经典的斑马问题有五个房子排成一排,每个房子住着不同国籍的人,有不同颜色的外墙,喝不同的饮料,抽不同品牌的烟,养不同的宠物。题目给出十五条线索,比如“英国人住在红房子里”“瑞典人养狗”“丹麦人喝茶”等等,最后问:谁养鱼?谁喝水?

很多人第一次看到这些线索会觉得杂乱无章,但如果你把它们分类整理,会发现它们其实只有几种基本类型。第一种是“直接绑定”,比如“英国人住在红房子里”,这表示国籍和房子颜色之间存在一对一的关系。第二种是“位置关系”,比如“绿房子在白房子的左边”,这涉及到两个房子之间的相对位置。第三种是“相邻关系”,比如“抽 Blend 烟的人住在养猫的人旁边”,这表示两个属性在位置上相邻。第四种是“中间位置”,比如“喝牛奶的人住在中间那栋房子里”,这是一个绝对位置约束。

理解这四种约束类型非常关键,因为它们在 Prolog 里的表达方式完全不同。直接绑定用简单的合一(unification)就能搞定,位置关系需要用到列表操作,相邻关系需要检查列表中的相邻元素,中间位置则直接锁定列表的第三个元素。我在第一次写的时候没有做这种分类,结果代码写得又长又乱,后来重新整理了一遍,代码量直接少了三分之一。

2.2 为什么选择列表结构而不是数据库

Prolog 有两种常见的数据组织方式:一种是用事实数据库,把每个属性写成独立的事实;另一种是用列表,把五个房子表示成一个列表,每个元素是一个包含所有属性的复合项。我两种都试过,最后选了列表方案,原因有三个。

第一,位置约束用列表表达非常自然。“绿房子在白房子的左边”翻译成列表语言就是“绿房子的索引小于白房子的索引”,用nth1或者自定义的left_of谓词就能轻松实现。如果用事实数据库,你得写一堆辅助谓词来比较位置,代码会变得很臃肿。

第二,列表方案更容易调试。当你把五个房子表示成一个列表时,可以在任何一步用write把当前列表打印出来,一眼就能看到哪些属性已经绑定、哪些还是自由变量。事实数据库方案下,你得逐个查询每个事实,调试效率低很多。

第三,列表方案更接近问题的本质。斑马问题问的是“五个房子的排列”,列表本身就是排列的天然表示。用列表还有一个额外好处:Prolog 的回溯机制会自动帮你枚举所有可能的排列,你只需要写好约束条件,剩下的搜索工作完全不用操心。

2.3 十五条线索的完整梳理

在动手写代码之前,我把所有线索逐条列出来,并且标注了它们的约束类型。这一步看起来简单,但实际上是整个项目中最重要的一步,因为漏掉任何一条线索都会导致答案不唯一或者直接无解。

编号线索内容约束类型
1英国人住在红房子里直接绑定
2瑞典人养狗直接绑定
3丹麦人喝茶直接绑定
4绿房子在白房子的左边位置关系
5绿房子主人喝咖啡直接绑定
6抽 Pall Mall 的人养鸟直接绑定
7黄房子主人抽 Dunhill直接绑定
8中间房子的人喝牛奶绝对位置
9挪威人住第一间房子绝对位置
10抽 Blend 的人住在养猫的人旁边相邻关系
11养马的人住在抽 Dunhill 的人旁边相邻关系
12抽 Blue Master 的人喝啤酒直接绑定
13德国人抽 Prince直接绑定
14挪威人住在蓝房子旁边相邻关系
15抽 Blend 的人有一个喝水的邻居相邻关系

整理完这张表之后,我发现直接绑定的线索有八条,位置关系一条,绝对位置两条,相邻关系四条。直接绑定的线索最容易处理,相邻关系最麻烦,因为需要同时检查两个属性的位置。这个分类直接决定了后面代码的结构:我先处理所有直接绑定和绝对位置约束,把能确定的属性先固定下来,然后再处理位置和相邻约束,这样可以大幅减少搜索空间。

3. 从零搭建 Prolog 解法的完整过程

3.1 环境准备与基础语法速览

我用的环境是 SWI-Prolog,这是一个开源实现,安装非常简单。在主流操作系统上都可以通过包管理器直接安装,装完之后在终端输入swipl就能进入交互式环境。如果你不想装软件,也可以直接用在线的 Prolog 解释器,功能基本够用。

在开始写代码之前,有必要快速过一遍会用到的 Prolog 语法。Prolog 的基本构建块是“事实”和“规则”。事实就是无条件为真的陈述,比如color(red).表示红色是一种颜色。规则是带条件的陈述,形式是“头部 :- 体部”,读作“头部为真,当体部为真”。比如likes(X, Y) :- cat(Y), person(X).表示如果 Y 是猫且 X 是人,那么 X 喜欢 Y。

Prolog 的变量用大写字母开头,常量用小写字母开头。合一(unification)是 Prolog 最核心的操作,当你写X = red时,Prolog 会把变量 X 绑定到 red。回溯(backtracking)是另一个核心机制,当某个目标失败时,Prolog 会自动回到最近的一个选择点,尝试其他可能性。这两个机制配合起来,就构成了 Prolog 的自动搜索能力。

列表在 Prolog 里用方括号表示,比如[a, b, c]。列表可以拆分成头和尾,[H|T]表示头是 H、尾是 T。nth1(N, List, Elem)用来获取列表中第 N 个元素,member(X, List)用来检查 X 是否在列表中。这些谓词在斑马问题里会频繁用到。

3.2 定义房子的数据结构

我决定用一个复合项来表示每个房子,包含五个属性:国籍、颜色、饮料、烟品牌、宠物。在 Prolog 里可以写成house(Nationality, Color, Drink, Smoke, Pet)。五个房子组成一个列表,整个解就是这样一个列表。

% 房子的结构:house(国籍, 颜色, 饮料, 烟, 宠物) % 整个解是五个房子的列表 solve(Solution) :- Solution = [ house(_, _, _, _, _), house(_, _, _, _, _), house(_, _, _, _, _), house(_, _, _, _, _), house(_, _, _, _, _) ], % 约束条件写在这里 ...

这里有一个细节需要注意:我一开始想用length(Solution, 5)来约束列表长度,但后来发现直接写出五个house结构更直观,而且能在写代码的时候就确保结构正确。另外,每个属性的取值范围也需要明确。国籍有五个值:英国、瑞典、丹麦、挪威、德国。颜色有五个值:红、绿、白、黄、蓝。饮料有五个值:茶、咖啡、牛奶、啤酒、水。烟有五个值:Pall Mall、Dunhill、Blend、Blue Master、Prince。宠物有五个值:狗、鸟、猫、马、鱼。

3.3 用 member 和 nth1 表达位置约束

最基础的约束表达方式是使用member和nth1。比如“英国人住在红房子里”可以写成:

member(house(english, red, _, _, _), Solution)

这行代码的意思是:在 Solution 列表中,存在一个房子,它的国籍是 english,颜色是 red,其他属性任意。Prolog 会自动尝试所有可能的位置,直到找到满足条件的绑定。

“挪威人住第一间房子”用nth1表达:

nth1(1, Solution, house(norwegian, _, _, _, _))

“中间房子的人喝牛奶”:

nth1(3, Solution, house(_, _, milk, _, _))

“绿房子在白房子的左边”需要自定义一个谓词:

left_of(A, B, List) :- nth1(PosA, List, A), nth1(PosB, List, B), PosA < PosB.

然后这样使用:

left_of(house(_, green, _, _, _), house(_, white, _, _, _), Solution)

“相邻关系”也需要自定义谓词:

next_to(A, B, List) :- nth1(PosA, List, A), nth1(PosB, List, B), abs(PosA - PosB) =:= 1.

这里用abs是因为相邻既可能是左边也可能是右边,用绝对值判断距离为 1 最简洁。我一开始写成了PosB =:= PosA + 1,结果只能匹配“A 在 B 左边”的情况,漏掉了“A 在 B 右边”的情况,导致答案出不来。这个坑后面还会详细说。

3.4 完整代码的组装与第一次运行

把所有约束条件组装起来,完整的solve谓词是这样的:

solve(Solution) :- Solution = [ house(_, _, _, _, _), house(_, _, _, _, _), house(_, _, _, _, _), house(_, _, _, _, _), house(_, _, _, _, _) ], % 1. 英国人住红房子 member(house(english, red, _, _, _), Solution), % 2. 瑞典人养狗 member(house(swedish, _, _, _, dog), Solution), % 3. 丹麦人喝茶 member(house(danish, _, tea, _, _), Solution), % 4. 绿房子在白房子左边 left_of(house(_, green, _, _, _), house(_, white, _, _, _), Solution), % 5. 绿房子主人喝咖啡 member(house(_, green, coffee, _, _), Solution), % 6. 抽 Pall Mall 的人养鸟 member(house(_, _, _, pall_mall, bird), Solution), % 7. 黄房子主人抽 Dunhill member(house(_, yellow, _, dunhill, _), Solution), % 8. 中间房子喝牛奶 nth1(3, Solution, house(_, _, milk, _, _)), % 9. 挪威人住第一间 nth1(1, Solution, house(norwegian, _, _, _, _)), % 10. 抽 Blend 的人住在养猫的人旁边 next_to(house(_, _, _, blend, _), house(_, _, _, _, cat), Solution), % 11. 养马的人住在抽 Dunhill 的人旁边 next_to(house(_, _, _, _, horse), house(_, _, _, dunhill, _), Solution), % 12. 抽 Blue Master 的人喝啤酒 member(house(_, _, beer, blue_master, _), Solution), % 13. 德国人抽 Prince member(house(german, _, _, prince, _), Solution), % 14. 挪威人住蓝房子旁边 next_to(house(norwegian, _, _, _, _), house(_, blue, _, _, _), Solution), % 15. 抽 Blend 的人有一个喝水的邻居 next_to(house(_, _, _, blend, _), house(_, _, water, _, _), Solution).

第一次运行的时候,我输入solve(Solution), write(Solution), nl.,结果 Prolog 直接返回了false。这意味着没有任何解满足所有约束。我当时的第一反应是“题目抄错了”,但仔细检查之后发现,问题出在next_to谓词上。我最初写的版本是:

next_to(A, B, List) :- nth1(PosA, List, A), nth1(PosB, List, B), PosB is PosA + 1.

这个版本只能匹配“A 在 B 左边相邻”的情况,但线索 10 说的是“抽 Blend 的人住在养猫的人旁边”,这个“旁边”既可能是左边也可能是右边。改成abs(PosA - PosB) =:= 1之后,问题就解决了。这个坑让我意识到,自然语言里的“旁边”在逻辑上是对称的,但代码里如果不显式处理对称性,就会漏掉一半的情况。

4. 调试过程中踩过的那些坑

4.1 变量命名冲突导致的静默失败

Prolog 的变量是局部的,同一个变量名在不同的规则里互不影响。但在同一个规则内部,如果两个不同的约束用了同一个变量名,就会产生意外的绑定。我在写代码的时候,曾经把两个不同的房子都用H来表示,结果 Prolog 把两个房子合一了,导致约束条件变得过强,解不出来。

比如下面这段有问题的代码:

member(house(english, red, _, _, _), Solution), member(house(swedish, _, _, _, dog), Solution),

如果我把两个_都写成H,变成:

member(house(english, red, H, H, H), Solution), member(house(swedish, H, H, H, dog), Solution),

那么 Prolog 会认为这两个房子的饮料、烟、宠物都是同一个值,这显然不对。更隐蔽的是,这种错误不会报错,只会让搜索空间变得异常,最后返回false。我花了将近半个小时才定位到这个问题,后来养成了一个习惯:凡是匿名变量,一律用_,绝不复用变量名。

4.2 相邻约束的方向性陷阱

前面提到的next_to方向性问题,其实还有更深一层的坑。线索 15 说“抽 Blend 的人有一个喝水的邻居”,这里的“有一个”意味着存在一个邻居喝水,但不要求这个邻居是左边还是右边。用next_to表达是正确的,但如果你写成:

nth1(PosB, Solution, house(_, _, water, _, _)), nth1(PosBlend, Solution, house(_, _, _, blend, _)), PosB =:= PosBlend + 1.

那就只能匹配“喝水的人在 Blend 左边”的情况,漏掉了右边的情况。这个坑和前面那个本质相同,但表现形式不同。我的经验是:凡是自然语言里出现“旁边”“邻居”“相邻”这类词,一律用对称的next_to谓词,不要手动写位置比较。

4.3 搜索顺序对性能的影响

Prolog 的回溯搜索是按照子句的顺序进行的。如果你把约束条件按“先绑定最多变量”的顺序排列,搜索效率会高很多。我一开始把member约束随便排了个顺序,运行时间大约需要几秒钟。后来我把约束条件重新排列,先放那些能确定具体位置的约束(比如“挪威人住第一间”“中间房子喝牛奶”),再放那些只绑定属性的约束,最后放相邻约束。重新排列之后,运行时间降到了毫秒级别。

这个优化的原理很简单:Prolog 在搜索时,如果前面的约束已经排除了大量可能性,后面的约束就只需要在很小的空间里搜索。反之,如果先放宽松的约束,Prolog 会生成大量候选解,然后再用后面的约束去过滤,效率自然低。这其实和数据库查询优化里的“选择操作下推”是同一个道理。

4.4 如何验证解的唯一性

得到第一个解之后,我很好奇:这个解是唯一的吗?Prolog 的回溯机制可以帮你验证这一点。你可以在找到第一个解之后输入分号;,让 Prolog 继续搜索下一个解。如果返回false,说明只有一个解。如果返回了第二个解,说明约束条件不够强,需要补充线索。

我运行的时候,第一个解出来之后按分号,Prolog 返回了false,说明解是唯一的。这个验证步骤很重要,因为如果约束条件漏掉了某一条,可能会得到多个解,而你如果不检查,就会误以为第一个解就是正确答案。实际上,斑马问题的经典版本确实只有一个解,如果你得到了多个解,几乎可以肯定是你漏掉了某条线索。

5. 从斑马问题延伸出去的通用建模思路

5.1 约束满足问题的通用解法框架

斑马问题本质上是一个约束满足问题(Constraint Satisfaction Problem,CSP)。这类问题的通用形式是:有一组变量,每个变量有一个取值域,变量之间存在若干约束,要求找到一组赋值使得所有约束都满足。Prolog 解决这类问题的思路非常直接:用逻辑变量表示未知量,用合一和回溯实现搜索,用规则表达约束。

这个框架可以套用到很多实际问题上。比如排班问题:有若干员工、若干班次,每个员工有技能限制,每个班次有人员需求,要求排出满足所有约束的班表。再比如配置问题:有若干硬件组件、若干接口,每个组件有兼容性要求,要求找出一组兼容的配置。这些问题的结构都和斑马问题类似,只是变量更多、约束更复杂。

我在实际工作中遇到过一个类似的场景:需要给一批任务分配执行顺序,任务之间有依赖关系,还有资源限制。当时我用的就是 Prolog 的建模思路,把每个任务表示成一个复合项,把依赖关系和资源约束写成规则,让 Prolog 自动搜索可行解。虽然最终因为性能原因换成了专门的调度库,但 Prolog 原型帮助我快速验证了约束条件的正确性,这个价值是不可替代的。

5.2 性能优化的几个实用技巧

Prolog 的朴素回溯搜索在变量多、取值域大的时候会非常慢。针对斑马问题这类 CSP,有几个通用的优化技巧。

第一个技巧是“约束传播”。在搜索之前,先根据约束条件尽可能缩小每个变量的取值域。比如“挪威人住第一间”直接确定了第一个房子的国籍,不需要在搜索过程中再尝试其他国籍。在 Prolog 里,这可以通过调整约束顺序来实现,把最严格的约束放在最前面。

第二个技巧是“对称性消除”。如果问题存在对称性,比如两个变量的角色完全对称,可以强制其中一个取特定值来减少搜索空间。斑马问题里没有明显的对称性,但在其他 CSP 里这个技巧很常用。

第三个技巧是使用 Prolog 的labeling谓词(在 CLP(FD) 库中)。虽然斑马问题是纯逻辑问题,不涉及数值计算,但如果你把属性编码成整数,就可以用labeling来做更高效的搜索。不过对于斑马问题这个规模,朴素的member和nth1已经足够了,没必要引入额外的库。

5.3 用 Prolog 解决其他经典逻辑谜题

斑马问题只是 Prolog 的入门练习。类似的经典谜题还有“传教士与野人”“狼羊菜过河”“数独”等等。这些问题的共同特点是:状态空间有限,约束条件明确,适合用声明式的方式描述。

以数独为例,用 Prolog 解决数独的思路和斑马问题几乎一样:把 9x9 的棋盘表示成一个列表,每个格子是一个变量,取值域是 1 到 9,行、列、宫格的约束用all_different表达。写完之后你会发现,整个解法的核心代码不超过二十行,但能解决所有标准数独问题。这就是声明式编程的威力:你描述问题的结构,而不是解决问题的步骤。

我后来用同样的思路解决了一个实际的工作问题:给一个跨平台系统做配置校验。系统的配置项之间有各种依赖和互斥关系,用 Prolog 把每个配置项表示成变量,把依赖和互斥写成规则,就能自动检测出配置是否合法。这个工具帮我们团队省了很多手动检查的时间,而且因为规则是声明式的,新增配置项的时候只需要加一条规则,不需要改搜索逻辑。

6. 一些值得记录的实操心得

6.1 先写测试用例再写约束

这是我踩了坑之后总结出来的经验。在写 Prolog 代码之前,先手动构造一个已知正确的解,然后写一个测试谓词来验证这个解是否满足所有约束。这样当你运行solve返回false的时候,你可以先用测试谓词检查你的约束条件本身有没有写错,而不是盲目地怀疑搜索逻辑。

比如我可以手动构造一个解(假设我知道答案),然后写:

test_solution(Solution) :- % 这里放所有约束条件 ...

如果test_solution对已知正确的解返回false,那说明约束条件写错了。如果返回true,但solve返回false,那说明搜索逻辑有问题。这个分离测试的方法能帮你快速定位问题所在。

6.2 善用 trace 和 spy 调试

SWI-Prolog 提供了trace和spy两个调试工具。trace会打印出每一步的调用和返回,spy可以针对特定谓词设置断点。当你的代码返回false但你不确定哪条约束失败时,用spy监控关键谓词,看看哪个目标失败了,能省很多时间。

我调试next_to的时候,就是用spy(next_to)发现它只匹配了一种方向。如果没有这个工具,我可能还要多花半个小时去逐行检查代码。不过要注意,trace的输出非常冗长,只适合在小规模问题上使用。对于斑马问题这种规模,spy比trace更实用。

6.3 代码可读性比炫技更重要

Prolog 有一些很巧妙的写法,比如用maplist和 lambda 表达式来简化代码。但我在这个项目里刻意避免了这些高级特性,原因很简单:斑马问题的代码是给人看的,不是给机器看的。用最基础的member和nth1,任何人都能看懂每一行在做什么。如果用了一堆高阶谓词,代码是短了,但可读性下降了,以后想改一条约束都得想半天。

我的原则是:能用简单写法就用简单写法,除非性能真的成为瓶颈。斑马问题的搜索空间很小,简单写法完全够用。这个原则在大多数 Prolog 项目里都适用,因为 Prolog 的强项是表达力,不是执行效率。

6.4 把解题过程写成文档

最后一个小建议:把你解题的过程写成文档,包括约束条件的整理、代码的演进、踩过的坑。我在写这篇文章的时候,重新回顾了当时的代码和笔记,发现有些坑如果不记下来,过几个月就忘了。而且写文档的过程本身也是二次思考的过程,很多当时没想清楚的地方,写的时候就想明白了。

这份文档不需要很正式,用 Markdown 记在项目目录里就行。关键是要记录“为什么这样做”而不是“做了什么”,因为“做了什么”代码里已经写了,“为什么”才是真正有价值的信息。比如我记录了为什么选择列表而不是事实数据库,为什么next_to要用abs,这些决策背后的理由才是以后能复用的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询