1 前言
一直以来 Yogurt 都有点喜欢数独,但又不是那种没它不行,天天都做题那种,而是很多个 3 分钟热度那种。然后最近突然间有点想玩一下数独,果不其然的没玩几题就开始想偷懒了,何不用程序自己来数独呢?
其实有这个想法也不是一天两天了,在 Yogurt 写完这个代码之后,一翻以前的博客,居然在去年的 4 月份就研究过这玩意儿,当时试算了号称世界上最难的数独,最快可以在 1.2 秒算出来,平均在 2-3 秒之间,而在用网上搜出来的在线解题器最快的也需要 10 多秒,还有些文章说用 C/C++ 写出来的可以零点几秒就可以算出来。第一次研究这个,能做出这样的成绩自然是非常开心的,所以那时就写了篇博文:
由于这次写之前忘记了之前写的(毕竟一年了🤣),想着要不重新写一个吧,三个晚上前后写了两个版本,第一个版本跑这个最难数组跑了 30 秒。好家伙,不说超越从前的自己,最起码也得齐平吧,这干出了 30 多秒可还行?然后开始 "痛定思痛,认真反思",重写了逻辑,最终写出了现在这个版本,最快能在 0.07 秒解出这个世界最难数独😎。
国际惯例先上图:
还挺有纪念意义的,特此记录一下。
诺,下图就是这个号称世界最难数独的真身了👇👇👇
2 需求分析
需求其实就是数独的游戏规则:填入的数字需要满足行、列、宫三个区域不出现重复,且满足相关区域从 1-9 全部填写完毕。范例如下:
3 技术选型
本次使用的 Python 作为解题的编程语言,一方面是我会一点。。。二是感觉用 numpy 来做要方便一点,去年的版本也用了这个库,但感觉没用好,这次个人感觉用的还不错。Python 写起来也比较轻松,总的来说就是省事儿。。。😂
4 开发环境
| 信息 | 说明 |
|---|---|
| 操作系统 | Microsoft Windows 10 专业工作站版 10.0.19044 64 位 |
| 开发工具 | Microsoft Visual Studio Code |
| Python 版本 | 3.8.3 |
| CPU | i5-10210U |
| 内存 | 16GB |
5 解题思路
在网上看了很多案例和代码,大多都是基于数独的基本规则来实现的,有些甚至直接穷举,我都惊呆了,太暴力了。基于个人玩数独的小心得,基于基本规则为这个代码确定了两个主要解题方法 —— 候选词法、试填法。其中,试填法也是基于候选词法来处理的,其根本算法就是 回溯算法 (惭愧,Yogurt 是写完代码之后,才偶然间知道这个法子是有学名的,吃了没文化的亏😅)。
5.1 候选词法
一般情况下,人工解题的时候,往往可以通过各种数字的条件限制,很直观的找到填入的唯一结果。例如:
上图红色标注的 5 就是根据相关的限制条件推算出来的。如果直接使用这种 人眼模式 来推算的话,以 Yogurt 现阶段的能力还不足以做到。"当天神跟你关上一扇门,一定会给你留一扇窗的"。要实现 人眼模式 所实现的效果,构建候选词 就是一个好办法。如下图:
我们可以看到,图中标记的红色候选词 5,虽然它这个格子里的候选数非常多,但是不论行、列还是宫的候选词里,都有且仅有它这个格子的候选中有 5,意味着这个位置有且仅有一个唯一值 —— 5,那么这个位置填入的就是 5。不论是行、列还是宫,只要一个区域里出现了有且仅有的一个结果时,这个位置最终都只能填入这个结果了,并不需要同时满足三个区域的条件。
这是最基础的一个填数法了。当整个九宫格的格子填数越完整,那么该剩余格子内的候选数也将会越来越少,越来越精确,但并不意味着一定正确。
5.2 试填法(回溯算法)
候选词法虽好,但终究会有穷尽的时候,不管是人工处理也好,还是计算机处理也好,最终都是要做试填。当所有的格子的候选词均已用尽时,站在分岔路口,就需要我们去做选择了 —— 选择一个数,继续往下做,运气好的,选中了一个数就摧枯拉朽地完成了接下来的所有工作。运气不好的,可能会有很多分岔路口等着你做选择,怎么办呢?只能走到了死胡同里就回到上一次做选择的地方,选择另外一条路了。以此类推,直至推算出最终的结果。如下图:
前面 5.1 中提到,当九宫格内填数越多,剩余格子内的候选数则会越少,也就是随着试填的深度越深,可供候选的数就越少。基于这一点,Yogurt 尽可能的将较多的候选数先行试填,往下填将会很快到达死胡同,之后再回到上一个分岔路口选择下一个候选词,以此类推,直至推算完成。
6 代码实现
基于上述 5 的解题思路,先实现两个比较核心的函数,下方仅介绍实现逻辑,具体代码可通过上方传送门到 Gitee 中查看雅正。
6.1 关键代码
6.1.1 WriteOnlyBackNum:找出所有候选,并预先填入所有唯一候选结果
实现逻辑:
Step 1 计算候选数
- 从上到下逐行整理出已填写数的集合,然后在从左到右逐格的候选数中排除已填写的数
- 同理👆,从左到右逐列整理出已填写数的集合
- 同理👆,从上到下、从左至右逐宫整理出已填写数的集合
Step 2 计算唯一候选
- 从上到下逐行整理出当前行所有候选数的出现频率,当存在候选数的出现频率为
1时,意味着该候选数为唯一候选,替换存在该候选数的格子内候选数为该候选数 - 同理👆,从左到右逐列整理出当前列所有候选数的出现频率
- 同理👆,从上到下、从左至右逐宫整理出当前宫所有候选数的出现频率
Step 3 向原题中填入唯一候选
当 Step 2 计算出了唯一候选时,则将相应的唯一候选填入其对应的格内
6.1.2 GetTestBackNum:计算试填候选
实现逻辑:
Step 1 计算各区域格内候选数最多的候选数
- 从上到下逐行计算候选数最多的候选数
- 从左到有逐列计算候选数最多的候选数
- 从上到下、从左至右逐宫计算候选数最多的候选数
Step 2 计算出 Step 1 所有候选数最少且出现频率最高的候选数
根据 5.2 试填法 的思路可知,在数独的九宫格内,单行、单列或单宫的最大深度为 9,而且越深候选越少,因此在 Step 1 中首先返回各区域找到候选数最多的数,其目的是将不确定性最大的候选放在最表层,越往下的不确定性就越小,遇到死胡同也就越早,就可以尽早结束循环,达到减少循环的目的。
在 Step 1 中最多可获得 9 * 3 = 27 个候选数,再将其中出现最短且频率最高的候选数挑出来作为试填候选数。在最多 27 个候选数中,出现的频率越高,则说明其在题目的试填影响的范围越广。
在实际测试过程中,如何选择准确的试填候选对整个算法的执行时间有着深远的影响,虽然说以上逻辑是 Yogurt 花了时间研究出来的,但不好说这个计算试填候选的逻辑就一定是最优的。不过,至少现在知道了影响整个程序执行时间的关键点在哪了。后续有机会的话再优化吧。
6.1.3 CheckNum:检查题目正确性
除了上述 6.1.1、6.1.2 两个核心函数外,还有这个函数。这个函数的作用是用来检查题目填入数后是否符合数独规则。检查的规则如下:
- 行、列、宫内不存在空值。即所有格子均已填入值
- 行、列、宫内不存在重复值。即所有区域的格子填写均符合从 1-9 不重复的原则
6.2 算法整合
将所有的关键代码整合起来,就是解数独的程序了。实现逻辑如下:
7 后记
没想到,一年之后居然还让 Yogurt 想起了写个代码来玩数独 😂😂,看来不管到什么时候,通过写代码来偷懒的念头一直没有离开过我的脑子,哈哈哈哈哈。回过头来看,其实去年写的代码执行效率就蛮高的,最难的数组也不过是 2-3 秒就执行完了,其实还蛮快的。只是没想到还能再快一点,这一年看来我的进步也不小 🙈🙈🙈,不错不错,允许自己骄傲一下。
就先这样吧,哪天有时间再整个图片识别,那这样就可以直接截图或者拍照来解数独了。