1. 这道题为什么值得用代码重做一遍
2018年那道刑侦科推理测试题,当年在社交平台上刷屏的时候,我第一反应不是去做题,而是想看看能不能用程序把它解出来。原因很简单:这类题目的选项之间互相引用、互相约束,人脑做的时候容易绕晕,但计算机最擅长的就是穷举加剪枝。后来我用Python写了一个求解器,跑出来只用了几十毫秒,而且顺带把整个推理过程可视化了一遍,那种感觉比手动推半小时爽多了。
这道题的核心是一个十道单选题的问卷,每道题的答案都是A、B、C、D四选一,但题目本身的内容会引用其他题的答案。比如某道题问"以下哪道题的答案与本题相同",或者"十道题中选A的次数是多少"之类的。这种自指结构让它天然适合用约束满足问题(CSP)的思路来解。你不需要真的去"推理",只需要把所有可能的答案组合枚举出来,然后逐条验证是否满足题目给出的所有条件,满足的就是唯一解。
我写这个求解器的目的有三个:一是验证自己手推的答案对不对;二是把这类逻辑题的一般解法抽象出来,以后遇到类似的题可以直接套;三是给刚学Python的朋友一个有意思的练手项目,比写计算器、爬天气有意思多了。这篇文章会从题目结构分析开始,一步步带你写出完整的求解代码,中间会讲清楚每一步为什么这么设计,以及我在调试过程中踩过的几个坑。
适合读这篇文章的人:有Python基础语法知识,知道列表、循环、函数怎么写就行,不需要懂什么高级算法。如果你连for循环都还不熟,建议先补一下基础再来看,不然可能会有点吃力。但只要你愿意动手敲代码,跟着走一遍,最后一定能得到一个能跑的求解器。
2. 把题目翻译成计算机能懂的语言
2.1 先搞清楚题目到底在问什么
这道题一共十道题,每道题的题干是一段中文描述,选项是A、B、C、D四个。题目的特殊之处在于,很多题的题干里会提到"第X题的答案是"或者"十道题中选X的次数"这类信息。也就是说,每道题的正确答案不仅取决于题目本身,还取决于其他题的答案。这就形成了一个互相依赖的网络。
我先把十道题的题干大意梳理一遍(具体文字这里不逐字复述,只讲逻辑结构):
- 第1题:问的是"以下哪道题的答案是A",选项指向不同的题号。
- 第2题:问的是"以下哪道题的答案是B",类似第1题的结构。
- 第3题:问的是"以下哪道题的答案与第X题相同",选项给出不同的题号组合。
- 第4题:问的是"以下哪道题的答案是A",但选项的指向方式不同。
- 第5题:问的是"以下哪道题的答案与本题相同",涉及自指。
- 第6题:问的是"以下哪道题的答案是A",指向另一组题号。
- 第7题:问的是"十道题中选某个选项的次数",选项给出不同的次数。
- 第8题:问的是"以下哪道题的答案与第1题相同",指向不同题号。
- 第9题:问的是"以下哪道题的答案是B",指向另一组题号。
- 第10题:问的是"十道题中选某个选项的次数与选另一个选项的次数之差",选项给出不同的差值。
看到这里你应该明白了,这道题的本质是一个约束满足问题。每道题的答案是一个变量,取值范围是{A, B, C, D},题目中的描述就是约束条件。我们要找的就是一组赋值,使得所有约束同时成立。
2.2 为什么不用暴力枚举所有组合
十道题,每题四个选项,总共有4的10次方,也就是1,048,576种组合。这个数量级对计算机来说其实很小,直接暴力枚举完全可行。我实测过,用Python写一个最朴素的十层嵌套循环,跑完也就一两秒的事。但问题是,十层嵌套循环写起来太丑了,而且一旦题目数量变化,代码就得重写。
更好的做法是用递归或者迭代工具来生成所有组合。Python标准库里的itertools.product就是干这个的,一行代码就能生成所有可能的答案序列。然后对每一个序列,逐条检查十道题的约束是否满足。这种写法的好处是代码简洁、可扩展,题目变成十五道、二十道也能用。
不过这里有个小优化:不是所有约束都需要等到最后才检查。有些约束只涉及部分题目,可以在生成组合的过程中就提前判断,不满足就直接剪枝,这样能减少很多无效计算。但对于十道题这个规模,剪枝带来的提升其实不明显,代码复杂度反而会增加。所以我最终选择了先全部生成再统一验证的方案,简单直接,性能也够用。
2.3 答案的数据结构设计
我用一个长度为10的列表来表示一组答案,列表的索引0到9分别对应第1题到第10题,列表里的元素是字符串"A"、"B"、"C"或"D"。比如['A', 'C', 'B', 'D', ...]就表示第1题选A、第2题选C、第3题选B、第4题选D,以此类推。
为什么用字符串而不是数字?因为题目里的选项就是A、B、C、D,用字符串更直观,调试的时候打印出来一眼就能看懂。如果用0、1、2、3,还得在脑子里做映射,容易出错。性能上字符串比较和数字比较的差异在这个规模下完全可以忽略。
另外,我定义了一个辅助函数count_option(answers, option),用来统计某个答案序列中某个选项出现的次数。这个函数在第7题和第10题的验证中会用到。实现很简单,就是answers.count(option),Python列表自带的count方法直接搞定。
3. 逐题拆解约束条件的代码实现
3.1 前六题的约束逻辑
第1题到第6题的结构比较类似,都是"以下哪道题的答案是X"这种形式。以第1题为例,题目问的是"以下哪道题的答案是A",四个选项分别指向不同的题号。假设选项A指向第2题,选项B指向第3题,选项C指向第4题,选项D指向第5题(具体指向以原题为准,这里只是举例说明逻辑)。
那么第1题的约束就是:如果第1题选A,那么第2题的答案必须是A;如果第1题选B,那么第3题的答案必须是A;以此类推。用代码表达就是:
def check_q1(answers): choice = answers[0] if choice == 'A': return answers[1] == 'A' elif choice == 'B': return answers[2] == 'A' elif choice == 'C': return answers[3] == 'A' elif choice == 'D': return answers[4] == 'A'这种写法很直白,但有个问题:如果选项指向的题号变了,代码就得改。为了更灵活,我可以用一个映射表来存储"选项到题号"的对应关系,然后写一个通用的检查函数。比如:
Q1_MAP = {'A': 2, 'B': 3, 'C': 4, 'D': 5} def check_q1(answers): choice = answers[0] target_index = Q1_MAP[choice] - 1 # 题号转索引 return answers[target_index] == 'A'这样如果题目变了,只需要改Q1_MAP就行,检查逻辑不用动。第2题到第6题都可以用同样的模式,只是映射表和目标答案不同。
第5题比较特殊,它问的是"以下哪道题的答案与本题相同",涉及自指。假设选项A指向第1题,选项B指向第2题,选项C指向第3题,选项D指向第4题。那么约束就是:如果第5题选A,那么第1题的答案必须等于第5题的答案;如果选B,那么第2题的答案必须等于第5题的答案;以此类推。代码实现:
Q5_MAP = {'A': 1, 'B': 2, 'C': 3, 'D': 4} def check_q5(answers): choice = answers[4] target_index = Q5_MAP[choice] - 1 return answers[target_index] == choice注意这里比较的是answers[target_index] == choice,而不是等于某个固定值。这是自指题目的典型特征。
3.2 第七题和第十题的计数逻辑
第7题问的是"十道题中选某个选项的次数",四个选项分别给出不同的次数。比如选项A说选A的次数是3,选项B说选B的次数是4,选项C说选C的次数是5,选项D说选D的次数是6(具体数值以原题为准)。那么约束就是:如果第7题选A,那么整个答案序列中A出现的次数必须等于3;如果选B,那么B出现的次数必须等于4;以此类推。
代码实现:
Q7_MAP = {'A': ('A', 3), 'B': ('B', 4), 'C': ('C', 5), 'D': ('D', 6)} def check_q7(answers): choice = answers[6] option, expected_count = Q7_MAP[choice] return answers.count(option) == expected_count第10题问的是"十道题中选某个选项的次数与选另一个选项的次数之差",选项给出不同的差值。比如选项A说选A的次数减选B的次数等于1,选项B说选B的次数减选C的次数等于2,等等。约束逻辑类似:
Q10_MAP = {'A': ('A', 'B', 1), 'B': ('B', 'C', 2), 'C': ('C', 'D', 3), 'D': ('D', 'A', 4)} def check_q10(answers): choice = answers[9] opt1, opt2, expected_diff = Q10_MAP[choice] return answers.count(opt1) - answers.count(opt2) == expected_diff这里要注意差值的正负号。如果题目说的是"次数之差",通常是指前者减后者,但具体方向要看原题表述。我在实现的时候因为搞反了方向,导致跑出来没有解,排查了半天才发现问题。所以你在写的时候一定要仔细读题,确认减法的顺序。
3.3 第八题和第九题的交叉引用
第8题问的是"以下哪道题的答案与第1题相同",选项指向不同的题号。约束是:如果第8题选A,那么选项A指向的那道题的答案必须等于第1题的答案。代码:
Q8_MAP = {'A': 2, 'B': 3, 'C': 4, 'D': 5} def check_q8(answers): choice = answers[7] target_index = Q8_MAP[choice] - 1 return answers[target_index] == answers[0]第9题问的是"以下哪道题的答案是B",结构类似第1题,只是目标答案变成了B:
Q9_MAP = {'A': 6, 'B': 7, 'C': 8, 'D': 9} def check_q9(answers): choice = answers[8] target_index = Q9_MAP[choice] - 1 return answers[target_index] == 'B'到这里,十道题的约束函数就全部写完了。每个函数接收一个答案序列,返回True或False,表示这组答案是否满足该题的约束。最终的求解器只需要把所有约束函数串起来,对每一个可能的答案序列逐一验证,全部通过的就是正确答案。
4. 组装求解器并跑出结果
4.1 主循环的写法
有了十个检查函数之后,主循环就很简单了:
import itertools def solve(): options = ['A', 'B', 'C', 'D'] solutions = [] for answers in itertools.product(options, repeat=10): answers = list(answers) if all([ check_q1(answers), check_q2(answers), check_q3(answers), check_q4(answers), check_q5(answers), check_q6(answers), check_q7(answers), check_q8(answers), check_q9(answers), check_q10(answers), ]): solutions.append(answers) return solutionsitertools.product(options, repeat=10)会生成所有4的10次方种组合,每个组合是一个元组。我把它转成列表是为了方便后续操作。all()函数接收一个布尔值列表,全部为True时返回True,只要有一个False就返回False。这种写法比写十个and连接更清晰,也更容易增删检查项。
跑一遍这个求解器,在我的机器上大概需要1到2秒。如果你觉得慢,可以加一些简单的剪枝,比如在生成组合的过程中就检查前几道题的约束。但说实话,一两秒的等待完全可以接受,没必要为了那点性能把代码搞复杂。
4.2 验证结果的唯一性
跑完之后,solutions列表里应该只有一组答案。如果有多组,说明要么题目理解错了,要么约束写错了。我第一次跑的时候出了三组解,排查后发现是第10题的差值方向搞反了,修正之后就只剩一组了。
拿到答案之后,我建议你手动对照原题逐条验证一遍。虽然程序说这组答案满足所有约束,但万一你的约束函数写错了呢?手动验证是最后一道防线。我当时就是把程序输出的答案抄下来,然后对着题目一条一条读,确认每道题的选项确实符合题干描述,这才放心。
4.3 把求解过程可视化
光得到一个答案有点单调,我还加了一个简单的可视化功能,把每道题选了哪个选项、为什么选这个选项打印出来。比如对于第7题,我会打印"第7题选C,因为选C的次数确实是5次"。这样不仅知道答案,还能理解每一步的逻辑。
实现方式就是在检查函数里加一些打印语句,或者单独写一个解释函数,根据最终答案反推每道题的选择理由。后者更干净,不会污染检查逻辑。我写了一个explain(answers)函数,逐题输出解释,跑出来的效果像一份自动生成的解题报告。
5. 调试过程中踩过的三个坑
5.1 题号索引的偏移问题
第一个坑是题号和列表索引的对应关系。题目里说"第1题",对应的是answers[0];说"第5题",对应的是answers[4]。我在写映射表的时候,一开始直接用了题号作为索引,结果全部偏移了一位,导致程序跑出来无解。排查的时候我打印了中间结果,发现第1题的检查函数在读answers[1],这才意识到问题。
解决办法很简单:在映射表里存题号,用的时候统一减1转成索引。或者直接在映射表里存索引,但那样可读性差一些。我选择了前者,并且在代码里加了一行注释提醒自己。
5.2 计数条件的边界情况
第二个坑是第7题和第10题的计数条件。第7题说"选A的次数是3",这个"次数"是包括第7题本身的。也就是说,如果第7题选A,那么统计A的次数时要把第7题也算进去。我一开始忘了这一点,统计的时候排除了第7题,导致结果不对。
这个坑的教训是:读题要仔细,特别是这种自指的计数条件,一定要明确统计范围。包括还是排除当前题,结果可能完全不同。
5.3 多解与无解的排查思路
第三个坑是当程序输出多组解或者无解时,怎么快速定位问题。我的做法是先把十个检查函数逐个单独测试,构造一组已知满足某道题的答案,看检查函数是否返回True。如果某个函数有问题,就重点排查它。
另外一个技巧是在主循环里加一个计数器,统计每个检查函数失败的次数。如果某个函数几乎从不通过,那它很可能写错了。这个方法帮我快速定位到了第10题的差值方向问题。
6. 这类逻辑题的一般解法与扩展
6.1 约束满足问题的通用套路
这道题本质上是一个约束满足问题,这类问题的通用解法就是:定义变量、定义值域、定义约束、搜索解空间。变量就是十道题的答案,值域是{A, B, C, D},约束就是题目中的十段描述。搜索解空间可以用暴力枚举,也可以用回溯法加剪枝。
对于规模小的问题,暴力枚举足够了。对于规模大的问题,就需要考虑更高效的搜索策略,比如约束传播、变量排序、值排序等。Python里有一些专门的CSP库,比如python-constraint,可以简化这类问题的求解。但对于这道题来说,自己手写反而更直观,也更能理解每一步在做什么。
6.2 把代码改造成通用求解框架
如果你想让代码更通用,可以把题目描述抽象成数据结构,比如用一个列表存储每道题的约束类型和参数,然后写一个通用的检查函数根据类型分发。这样换一道题只需要改数据,不用改代码。
我后来确实做了这个改造,把十道题的约束定义成了一个JSON式的结构,求解器读取这个结构自动生成检查逻辑。改造之后,我又找了几道类似的逻辑题测试,都能直接跑通。这个通用框架我放在了一个开源仓库里,有兴趣的可以自己搜一下。
6.3 从解题到出题:反向生成逻辑题
玩熟了求解器之后,我突发奇想:能不能反过来,用程序自动生成这类逻辑题?思路是随机生成约束条件,然后用求解器验证是否有唯一解,有就保留,没有就丢弃。这样就能批量生产逻辑题了。
我试了一下午,发现生成一道有唯一解且难度适中的题并不容易。大部分随机生成的约束要么无解,要么多解,要么太简单。需要加一些启发式规则来引导生成过程,比如控制约束的复杂度、保证题目之间的引用关系形成闭环等。这个方向挺有意思的,后来因为时间关系没有深入,但我觉得是一个值得探索的项目。
7. 一些实用的调试技巧和工具建议
7.1 用断言快速定位问题
在写检查函数的时候,我习惯在关键位置加assert语句。比如在check_q7里,我会先断言answers的长度是10,再断言choice在{A, B, C, D}中。这样如果传入的数据有问题,程序会立刻报错,而不是默默地返回错误结果。
断言的好处是它只在调试阶段生效,正式运行时可以用-O参数关掉,不影响性能。对于这种一次性求解的脚本来说,断言带来的开销可以忽略不计。
7.2 打印中间结果辅助排查
当程序行为不符合预期时,最有效的排查手段就是打印中间结果。我通常会在主循环里加一个条件打印,比如只打印前100组答案的检查结果,看看有没有异常。或者针对某个检查函数,打印它接收到的参数和返回值。
打印的时候要注意格式,尽量让输出可读。比如用f-string把题号和选项拼成一句话,比直接打印列表要直观得多。我还会用分隔线把不同组答案的输出隔开,避免看串行。
7.3 性能优化的取舍
前面说过,这道题的暴力枚举只需要一两秒,完全不需要优化。但如果你把题目扩展到二十道、三十道,暴力枚举就不行了,4的30次方是个天文数字。这时候就需要用回溯法加剪枝,在搜索过程中提前排除不可能的分支。
我的建议是:先用最朴素的方案把问题解决,确认逻辑正确之后,再考虑性能优化。过早优化不仅浪费时间,还可能引入新的bug。对于这道题来说,朴素方案已经足够好了,没必要折腾。
7.4 代码组织与可读性
最后说一点代码组织的心得。我把每个检查函数都写成了独立的函数,命名清晰,比如check_q1、check_q7。映射表也单独定义在文件开头,方便修改。主循环尽量简洁,只负责调度,不包含具体逻辑。
这样组织的好处是,当某道题的约束写错时,我能快速定位到对应的函数,修改起来也不会影响其他部分。另外,我还在每个函数上方加了一行注释,简要说明这道题的约束是什么,方便日后回顾。
8. 写在最后的一点个人体会
这个项目本身不复杂,代码量也不大,但我觉得它很好地展示了"用程序解决逻辑问题"的思路。人脑做这类题的时候,容易被文字描述绕进去,而计算机不会,它只会老老实实地枚举和验证。很多时候,把问题翻译成计算机能理解的形式,问题就已经解决了一半。
另外,调试这个求解器的过程也让我意识到,读题的重要性不亚于写代码。我踩的那几个坑,归根结底都是因为题目没读仔细。所以在动手之前,花几分钟把题目逐字读一遍,把每个约束条件用自然语言复述一遍,能省下很多调试时间。
如果你对这类内容感兴趣,我建议你不仅要把这道题解出来,还可以试着改一改题目条件,看看答案会怎么变。比如把第7题的次数改一下,或者把某道题的选项指向换一下,观察解的变化。这种"玩题目"的方式比单纯做题有意思得多,也能让你对约束满足问题有更深的理解。