Peter Norvig:用 Python 解决数独问题

在这篇文章中,我要处理求解任意数独这个问题。用了两个想法(idea):约束传播搜索后,它其实是很简单的(主旨大约只有一页代码,加上润色后也不过两页)。

数独记号和初步概念

首先我们要在记号上达成一致。一个数独谜题是由 81 个方块组成的网格。大部分爱好者把列标为 1-9,把行标为 A-I,把 9 个方块的一组(列,行,或者方框)称为一个单元,把处于同一单元的方块称为对等方块。谜题中有些方块是空白的,其他的填入了数字。谜题的主旨是这样的:

当每个单元的方块填入了 1 到 9 的一个排列时,谜题就解决了。

也就是说,在一个单元中,任何数字都不能出现两次,而且每个数字都要出现一次。这意味着每个方块的值和它的对等方块的值是不同的。下面是方块的名字、一个典型的谜题及其解答:

每个方块都属于 3 个单元,有 20 个对等方块。例如,如下是方块 C2 的单元和对等方块:

我们可以用 Python 按如下方式来实现单元、对等方块、方块的概念:

如果你对 Python 的一些特性不熟悉,请注意‘字典’是哈希表在Python中的叫法,它将每个键映射到一个值。dict((s, […]) for s in squares) 创建了一个字典,把每个方块 s 映射到是一个列表的值。表达式 [u for u in unitlist if s in u] 的意思是:这个值是由那些包含s的单元u构成的列表。所以这个赋值表达式要这么读:units是一个字典,其中每个方块映射到包含它的单元组成的列表。类似地,下一个赋值语句要这么读:peers 是个字典,其中每个方块s映射到s所属单元的方块的并集,但不包括 s 自身。

做一些测试是没有坏处的(它们都通过了):

既然我们有了方块、单元和对等方块,下一步是定义数独网格。事实上我们需要两个表示(representations):首先要有一个用来指定谜题初始状态的文本格式(译注:指字符串),我们会为之保留 grid 这个名字;然后还需要谜题的任何状态(部分解决或完成)的内部表示,我们称之为 values 集合,因为对于每个方块,它将给出剩余可能的值。对于文本格式(网格),我们允许字符串中用 1-9 表示数字,0 或句点表示空方块。所有其他的字符都会被忽略(包括空格,换行,破折号)。因此以下的三个网格字符串代表了同样的谜题:

现在来说 values。有人可能认为 9×9 的数组是很显然的数据结构。但是方块的名字是 ‘A1’ 这种,而不是(0,0)。因此 values 将会是字典,以方块为键。每个键的值是那个方块的可能的数字:如果数字是作为谜题的一部分给定的,或者我们已经确定它必定的值,那么就是一个数字;如果我们还不确定,那就是一堆数字。这堆数字可以用Python集合或列表来表示,但我选择用数字字符串来表示(稍后会看到为什么)。因此,A1 为 7,C7 为空的网格 可以表示为 {‘A1’: ‘7’, ‘C7’: ‘123456789’, …}。

这是将网格解析成 values 字典的代码:

约束传播

parse_grid 函数调用 assign(values, s, d)。我们可以把这实现为 values[s] = d,但我们可以做的更多。有解决数独谜题经验的人知道,在朝着填满所有方块前进时,有两个重要的策略:

(1) 如果一个方块只有一个可能值,把这个值从方块的对等方块(的可能值)中排除。
(2) 如果一个单元只有一个可能位置来放某个值,就把值放那。

作为策略(1)的例子,如果我们把 7 填入 A1,也就是 {‘A1’: ‘7’, ‘A2′:’123456789’, …},我们看到 A1 只有一个值,因此7可以从A1的对等方块A2(也包括其他所有对等方块)中移除,得到{‘A1’: ‘7’, ‘A2’: ‘12345689’, …}。作为策略(2)的例子,如果A3到A9都不能以3作为可能值,那么3必定属于A2,我们可以更新为{‘A1’: ‘7’, ‘A2′:’3’, …}。对A2的更新,反过来可能导致对它的对等方块以及对等方块的对等方块的更新,等等。这个过程称为约束传播。

函数assign(values, s, d)会返回更新后的values(包括来自约束传播的更新),但是如果产生了矛盾(即赋值不能保证一致),assign会返回False。例如,如果网格是以数字’77…’开头的,当我们试图把7赋给A2时,assign会注意到A2不可能是7,因为A2为7的可能性已经被它的对等方块A1排除了。

对于一个方块来说,基本操作不是赋值,而是排除一个可能的值,我们用eliminate(values, s, d)来实现它。一旦我们有了eliminate,assign(values, s, d)就可以定义为“从s中排除d以外的所有值”。

在能走得更远之前,我们需要能显示一个谜题:

现在我们准备好了。我从来自于欧拉计划数独问题的一系列简单谜题中挑出了第一个例子:

在这个例子中,谜题完全是靠生搬硬套策略(1)和(2)解决的。不幸的是,这并不总能行得通。这是一系列困难谜题中的第一个例子:

在这个例子中,我们离解决谜题还差得远呢!有61个方块还不确定。接下来要怎么做?我们可以编码更复杂的策略。例如,naked twins策略寻找同一单元的两个方块,它们有着相同的两个可能数字。给定{‘A5′: ’26’, ‘A6′:’26’, …},我们可以断定2和6必定在A5和A6中(尽管我们不知道分别在哪个方块),因此对于A行单元的其他方块,我们可以排除2和6。我们在eliminate中加上elif len(values[s]) == 2测试来实现这条策略。

像这样编码策略是一条可能的路线,但会需要几百行代码(有几十条这样的策略),我们也不确定能否解决每个谜题。

搜索

另一条路线是搜索一个解答:系统地尝试所有可能性直到找到一个解。这个方法的代码只有十几行,但是我们冒着另一个风险:程序可能要运行很长时间。考虑上面的grid2,A2有4种可能性(1679)),A3有5种可能性(12679)。总共就是20种可能性,如果我们一直乘下去,对于整个谜题是 4.62838344192 × 1038 种可能性。我们要怎么应对?有两个选择。

首先,我们可以尝试暴力方法。假设我们有一个非常高效的程序,计算一个位置只需一条指令;我们可以利用下一代的计算技术,比方说1024核的10GHz处理器,假设我们可以用上一百万这样的处理器;再假设我们购物时选了一个时光机,可以回退130亿年到宇宙的起点,开始运行我们的程序。我们可以算出至今我们差不多完成了这个谜题的1%。

第二个选择是每条机器指令处理远多于一种可能性。这看起来不可能,但幸运的是这恰好就是约束传播所做的。我们不需要尝试所有4 × 1038种可能性,因为尝试一种后,我们立即就能排除很多其他的可能性。例如,这个谜题的方块H7有两种可能性,6和9。我们可以尝试9,很快就发现有矛盾。这意味着我们排除的不是一种可能性,而是4 × 1038种选择中的一半。

事实上,要解决这个特定的谜题我们只要考虑25种可能性,61个未填充的方块中我们只需显式地搜索9个,约束传播会帮我们搞定剩下的。对于清单中的95个困难谜题,平均起来每个谜题只要考虑64种可能性,在所有谜题中都不需要搜索超过16个方块。

这个搜索算法是什么呢?很简单:首先确保我们还没有发现解或者矛盾,选择一个未填充的方块,考虑它的所有可能值。每次考虑一个值,尝试把它赋给方块,从得到的局面继续搜索。换言之,我们搜索这样的值d:我们可以从将d赋给方块s后的局面成功地搜索到一个解。如果搜索导致失败的局面,返回并考虑d的另一个值。这是一个递归的搜索,我们称之为深度优先搜索,因为在考虑s取另一不同值之前,我们(递归地)考虑values[s] = d条件下的所有可能性。

为了避免记录的复杂,每次递归调用search我们都创建values的新的拷贝。这样,搜索树的每个分支都是独立的,不会与另一个分支混淆。(这就是为什么将方块的可能值集合实现为字符串:我可以用values.copy()来拷贝values,简单又高效。如果我用Python集合或列表来实现可能值,我就要用copy.deepcopy(values),就没那么高效了。)另一种方法是记录每次对values的改动,走进死胡同时撤销改动。这就是所谓的回溯搜索。当搜索中的每一步只对大的数据结构做单个改动时,这是可行的。但当每次赋值会通过约束传播导致很多其他的改动时,这么做很复杂。

当我们实现搜索时要做两个选择:变量顺序(先尝试哪个方块?)和值顺序(对于当前方块先尝试哪个数字?)。对于变量顺序,我们用一种称为最小剩余值的常见启发方法,也就是说选择可能值数目最少的方块(或之一)。为什么这么做?考虑上面的grid2。假设我们先选择B3。它有7种可能值(1256789),因此我们猜错的期望是6/7。如果我们选择G2,它只有2种可能值,我们猜错的期望只有1/2。因此我们选择有最少可能值、猜对概率最高的方块。对于值顺序,我们不会做什么特别的事情,只是按数字大小顺序来考虑。

现在我们已经准备好用search函数来定义solve函数了:

就这样,我们搞定了。只用了一页代码,现在我们可以解决任何数独谜题了。

结果

你可以查看完整程序。下面是在命令行运行程序的输出。它解决了来自文件的50个简单谜题95个困难谜题(请看95个谜题解答),我通过搜索最难的数独找到的11个谜题,以及一批随机谜题:

分析

上面的每个谜题都用不到五分之一秒解决。真正困难的谜题会怎么样呢?芬兰数学家Arto Inkala把他的2006 puzzle描述为“目前已知最难的数独”,把2010 puzzle描述为“我创作过的最难数独”。我的程序都在0.01秒内解决了它们(solve_all将会在下面定义):

我猜如果我想要一个真正困难的谜题,我得自己来制作。我不知道怎么创作困难的谜题,因此我生成了一百万个随机谜题。我用来生成随机谜题的算法很简单:首先,随机打乱方块的顺序。考虑到可能的数字选择,挨个在每个方块中填入随机数字。如果出现了矛盾,就重新来过。如果填充了至少17个方块以及8种不同的数字,就结束这个过程。(注意:如果填充少于17个方块或者用了不到8种不同数字,数独会有多个解。感谢Olivier Grégoire关于8种不同数字的很好的建议。)即使有这些检查,我的随机谜题仍不能保证有唯一解。很多有多个解,还有一些(大约0.2%)没有解。出现在书报上的谜题总是有唯一解。

解决一个随机谜题平均用时为0.01秒,超过99.95%用了不到0.1秒,但有一些用时长得多:

这里是100万个谜题中用时超过1秒的139个,排好序了,分别用线性和对数坐标表示:

很难从图中做出结论。最后几个值的上涨是显著的吗?如果我生成一千万个谜题,会不会有一个用时超过1000秒?下面是一百万个随机谜题中最难的(对于我的程序来说):

不幸的是,这不是一个真正的数独谜题,因为它有多个解。(它是在我包含了Olivier Grégoire关于8种不同数字的建议之前生成的,因此对于这个谜题的任何解,交换1和7,都得到另一个解。)但这是一个本质上很难的谜题吗?或者它的困难性是我的search程序所使用的变量顺序和值顺序方案的产物?为了检验,我随机化了值顺序(我把search最后一行中的for d in values[s]改成for d in shuffled(values[s]),我用了random.shuffle来实现shuffled)。结果明显地两边倒:30次尝试中的27次用了不到0.02秒,其他的3次每次用时都超过190秒(大约长了10000倍)。这个谜题有多个解,随机化的search找到13个不同的解。我的猜测是在搜索早期的某处,有一系列的方块(可能2个),如果我们选则了某种错误的值的组合来填充方块,就需要大约190秒来发现矛盾。但如果做了其他选择,我们很快要么发现一个解,要么发现矛盾,从另一个选择前进。因此算法的速度取决于它是否能避免选中致命的值组合。

随机化大部分时候都是有效的(30次中的27次),但通过考虑更好的值顺序我们可能会做的更好(一个流行的启发方式是最少约束值,也就是先选对对等方块约束最少的值),或者尝试更智能的变量顺序。

在我能给出对困难谜题一个好的刻画前,还需要更多的实验。我决定在另一个一百万随机谜题上做实验,这次保留运行时间的平均值,50th(中位数),90和99分位数,最大值和标准差这些统计值。结果是相似的,除了这次有两个谜题用时超过100秒,还有一个长得多:1439秒。事实上这个谜题是那没有解的0.2%中的一个,因此它可能不算。但主要的信息是即使我们抽样更多,平均值和中位数差不多保持不变,但最大值引人注目地保持增长。标准差也有微升,但主要是由于99分位数之外的那些很少的长用时。这是重尾分布,而不是正态分布。

为了比较,下面左边的表给出了解决谜题用时的统计,右边的表给出了从平均值为0.014,标准差为1.4794的正态(高斯)分布抽样的统计。注意对于一百万的抽样,高斯分布的最大值是平均值之上5个标准差(大致就是你对高斯分布所期望的),然而最长谜题运行时间在平均值之上1000个标准差。

这里是用时1439秒的无解谜题:

下面是定义solve_all的以及用它来验证来自文件和随机谜题的代码:

为什么?

我为什么要做这个?如计算机安全专家Ben Laurie说的,数独是“对人的理智的拒绝服务攻击”(译注:指数独容易让人上瘾)。我所知道的几个人(包括我的妻子)都被这个病毒感染了,我想这篇文章可以证明他们不用再在数独上花时间了。这对于我的朋友没起作用(尽管我的妻子自此之后不靠我的帮助独立改掉了这个嗜好),但至少有一位陌生人写到这篇文章对他有用,因此我使得世界更多产了。也许沿途我还教了点关于Python,约束传播和搜索的东西。

翻译

这段代码,已经有其他朋友用多种语言重新实现了:

打赏支持我翻译更多好文章,谢谢!

打赏译者

打赏支持我翻译更多好文章,谢谢!

任选一种支付方式

1 2 收藏 评论

关于作者:demoZ

可能感兴趣的话题



直接登录
跳到底部
返回顶部