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,这些决策背后的理由才是以后能复用的经验。