[个人项目] 用 Python 写了个解数独的代码,最快 0.07 秒解世界最难数独

1,337 阅读8分钟

1 前言

一直以来 Yogurt 都有点喜欢数独,但又不是那种没它不行,天天都做题那种,而是很多个 3 分钟热度那种。然后最近突然间有点想玩一下数独,果不其然的没玩几题就开始想偷懒了,何不用程序自己来数独呢?

其实有这个想法也不是一天两天了,在 Yogurt 写完这个代码之后,一翻以前的博客,居然在去年的 4 月份就研究过这玩意儿,当时试算了号称世界上最难的数独,最快可以在 1.2 秒算出来,平均在 2-3 秒之间,而在用网上搜出来的在线解题器最快的也需要 10 多秒,还有些文章说用 C/C++ 写出来的可以零点几秒就可以算出来。第一次研究这个,能做出这样的成绩自然是非常开心的,所以那时就写了篇博文:

传送门:CSDN - [20210425]什么?号称世界上最难的数独居然没有坚持到2秒

由于这次写之前忘记了之前写的(毕竟一年了🤣),想着要不重新写一个吧,三个晚上前后写了两个版本,第一个版本跑这个最难数组跑了 30 秒。好家伙,不说超越从前的自己,最起码也得齐平吧,这干出了 30 多秒可还行?然后开始 "痛定思痛,认真反思",重写了逻辑,最终写出了现在这个版本,最快能在 0.07 秒解出这个世界最难数独😎。

国际惯例先上图:

image.png

还挺有纪念意义的,特此记录一下。

诺,下图就是这个号称世界最难数独的真身了👇👇👇

1653475057(1).jpg

2 需求分析

需求其实就是数独的游戏规则:填入的数字需要满足行、列、宫三个区域不出现重复,且满足相关区域从 1-9 全部填写完毕。范例如下:

1653476601(1).jpg

3 技术选型

本次使用的 Python 作为解题的编程语言,一方面是我会一点。。。二是感觉用 numpy 来做要方便一点,去年的版本也用了这个库,但感觉没用好,这次个人感觉用的还不错。Python 写起来也比较轻松,总的来说就是省事儿。。。😂

4 开发环境

信息说明
操作系统Microsoft Windows 10 专业工作站版 10.0.19044 64 位
开发工具Microsoft Visual Studio Code
Python 版本3.8.3
CPUi5-10210U
内存16GB

5 解题思路

在网上看了很多案例和代码,大多都是基于数独的基本规则来实现的,有些甚至直接穷举,我都惊呆了,太暴力了。基于个人玩数独的小心得,基于基本规则为这个代码确定了两个主要解题方法 —— 候选词法试填法。其中,试填法也是基于候选词法来处理的,其根本算法就是 回溯算法 (惭愧,Yogurt 是写完代码之后,才偶然间知道这个法子是有学名的,吃了没文化的亏😅)。

5.1 候选词法

一般情况下,人工解题的时候,往往可以通过各种数字的条件限制,很直观的找到填入的唯一结果。例如:

1653478584(1).jpg

上图红色标注的 5 就是根据相关的限制条件推算出来的。如果直接使用这种 人眼模式 来推算的话,以 Yogurt 现阶段的能力还不足以做到。"当天神跟你关上一扇门,一定会给你留一扇窗的"。要实现 人眼模式 所实现的效果,构建候选词 就是一个好办法。如下图:

image.png

我们可以看到,图中标记的红色候选词 5,虽然它这个格子里的候选数非常多,但是不论行、列还是宫的候选词里,都有且仅有它这个格子的候选中有 5,意味着这个位置有且仅有一个唯一值 —— 5,那么这个位置填入的就是 5。不论是行、列还是宫,只要一个区域里出现了有且仅有的一个结果时,这个位置最终都只能填入这个结果了,并不需要同时满足三个区域的条件。

这是最基础的一个填数法了。当整个九宫格的格子填数越完整,那么该剩余格子内的候选数也将会越来越少,越来越精确,但并不意味着一定正确

5.2 试填法(回溯算法)

候选词法虽好,但终究会有穷尽的时候,不管是人工处理也好,还是计算机处理也好,最终都是要做试填。当所有的格子的候选词均已用尽时,站在分岔路口,就需要我们去做选择了 —— 选择一个数,继续往下做,运气好的,选中了一个数就摧枯拉朽地完成了接下来的所有工作。运气不好的,可能会有很多分岔路口等着你做选择,怎么办呢?只能走到了死胡同里就回到上一次做选择的地方,选择另外一条路了。以此类推,直至推算出最终的结果。如下图:

image.png

前面 5.1 中提到,当九宫格内填数越多,剩余格子内的候选数则会越少,也就是随着试填的深度越深,可供候选的数就越少。基于这一点,Yogurt 尽可能的将较多的候选数先行试填,往下填将会很快到达死胡同,之后再回到上一个分岔路口选择下一个候选词,以此类推,直至推算完成。

6 代码实现

传送门:Gitee - Yogurt_cry / SodokuDecoder

基于上述 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 算法整合

将所有的关键代码整合起来,就是解数独的程序了。实现逻辑如下:

未命名绘图.drawio.png

7 后记

没想到,一年之后居然还让 Yogurt 想起了写个代码来玩数独 😂😂,看来不管到什么时候,通过写代码来偷懒的念头一直没有离开过我的脑子,哈哈哈哈哈。回过头来看,其实去年写的代码执行效率就蛮高的,最难的数组也不过是 2-3 秒就执行完了,其实还蛮快的。只是没想到还能再快一点,这一年看来我的进步也不小 🙈🙈🙈,不错不错,允许自己骄傲一下。

就先这样吧,哪天有时间再整个图片识别,那这样就可以直接截图或者拍照来解数独了。