[译]解决所有的数独难题(下)

Peter Norving写的关于Python的文章,原文地址在这里(http://norvig.com/sudoku.html)

------------------------翻译的分割线-------------------------

搜索

另一个解决办法是通过搜索找到解决方案:有步骤地尝试所有可能性,直到我们找到一个可行的。完成这个的代码只有不到10行,但是我们又会遇到另外一种风险:有可能会一直运行下去。注意上面的grid2A24种可能(1679)A35中可能(12679);加在一起有20,如果一直乘下去,对于整个谜题我们将得4.62838344192 × 1038 种可能性。我们该如何应付这个呢?有两种选择。

首先,我们可以尝试暴力的方法。假设我们有个非常高效的程序能够只花一条指令就评估一个位置,并且假设我们已经拥有下一代计算机技术,那是说1024核的10GHZ处理器,而且假设我们可以买得起100万台这样的机器,还假设我们拥有一台时光机,回到130亿年前,在宇宙起始的时候,开始运行我们的程序。到现在我们也许只完成了1%左右的工作量

第二种方式是想办法使得每条指令处理远大于1条。这看起来不太可能,但是幸运的是这就是约束传播(constraint propagation)所做的。我们不用尝试所有的4 × 1038可能性,因为当我们尝试一种情况的时候马上就可以消除很多其他的可能性。举例来说,上个谜题的格子(square)H7有两种可能性,69。我们可以尝试9然后马上就会发现冲突。这意味着我们消除的不仅仅是一种可能性,而是4 × 1038中整整一半的可能性。

实际上,解决这个特定的谜题我们只需要查找25种可能性,只需要在61个未填充的格子(square)中搜索9个就可以了;约束传播(constraint propagation)会完成剩下的。在95难题 中,每个难题我们平均只要考虑64种可能性,没有个我们需要搜索超过16个格子的。

那这个搜索算法是怎么实现的呢?简单:首先,确保我们还尚未找到一个解决方案或冲突,如果没有,选择一个未填充的格子然后尝试它所有可能的值。一次一个,尝试给这个格子赋上每个值,然后从这个结果开始搜索。换句话说,我们搜索值d就是将值d赋给格子s后,我们能成功的找到解决方案。如果搜索导致某个位置失败,返回然后尝试除d外的其他值。这就是递归搜索,我们称它为深度优先   搜索,因为在尝试s为其他值之前,我们会(递归)尝试values[s]=d下所有的可能性。

为了避免bookkeeping complications,我们在每次递归调用search时都创建一个values的拷贝。这样搜索树的每个分支都是独立的,不会影响到其他分支。(这就是我为什么选择将一个格子的可能的值集合用字符串实现:我可以通过values.copy()来拷贝values,简单而又高效。如果我将值集合(possibility)实现为Python中的setlist,我需要使用低效的copy.deepcopy(values)来实现。)另一种(The alternative)是我们需要跟踪values的每一步变化,然后当到了死胡同(dead end)时撤销该变换,这就是所谓的回溯搜索(backtracking search )。搜索的每一步都是这个巨大数据结构的单一变化是有道理的,但是复杂的是由于约束传播每次赋值都会导致许多其他的变化。

在搜索中我们需要做出两个选择:变量序(variable ordering)(我们应该从哪个格子开始?)和值序(value ordering)(对于这个格子我们应该先尝试哪个数字?)。对于变量序(variable ordering),我们将采用常用的称之为最小剩余值(minimum remaining values)的启发式方式,意思就是我们选择可能值最少的格子(或者其中之一)。为什么?考虑上面的grid2。假设我们首先选择B3。它有7种可能性(1256789),所以我们猜错的可能性就是6/7。如果选择G2,它只有2种可能性(89),我们猜错的可能性只有(1/2)。因此我们选择最少可能性和最可能猜对的格子。对于值序(value ordering)我们不做特殊处理;我们将采用数字顺序。

现在我们将根据search函数定义solve函数:

 

def solve(grid): return search(parse_grid(grid))

def search(values):
    "Using depth-first search and propagation, try all possible values."
    if values is False:
        return False
    if all(len(values[s] == 1 for s in squares)):
        return values ## Solved!
    ## Choose the unfilled square s with the fewest possibilities
    n,s = min((len(values[s]), s) for s in squares if len(values[s]) > 1)
    return some(search(assign(values.copy(), s, d))
                for d in values[s])

def some(seq):
    "Return some element of seq that is true."
    for e in seq:
        if e: return e
    return False

这就是全部!我们做到了,它只有一页代码,然而我们现在能解决所有的数独难题。

结论

你可以浏览完整的程序,以下是在命令行运行程序的输出;它解决了50easy95 hard puzzles(查看95解决结果),以及我在[hardest sudoku]  找到的11个谜题,和一组随机选择的难题:

bTaAzxL+6mNnRkHkwDgcuQ5AACAtZHnAAAA1kaeAwAAWJuf5wAAALCQc54DAADAov4LoHK1qiadt7MAAAAASUVORK5CYII=

分析

以上每个谜题都在小于15分之一秒被解决掉了。那真正难的谜题又会怎么样呢?完成数学家Arto Inkala描述的他的2006 puzzle“现今已知最困难的数独谜题”和他的2010 puzzle“我所创造的最难谜题。”我的程序完成每个都在0.01秒之内(solve_all定义如下)

639d3f7j5NflIAACuw0UlHAkAmJaGi0o4EgAwLQ0XlXAkAGBaSRsOAIACDQcAMB4NBwAwHg0HADAeDQcAMJ6kDZfwQ6AJRwIApvVsuP8HE1BsZIcvsKwAAAAASUVORK5CYII=wC7o35RsG2zMwAAAABJRU5ErkJggg==5B9qK80KGCwAAAAASUVORK5CYII=

我想如果我需要一个真正困难的谜题,我得自己构造他们。我不知道如何去构造困难的谜题,所以我构造了100万个随机谜题。我构造随机谜题的算法很简单:首先,随机打乱格子的顺序。一个个的给每个格子填上一个随机数,作为可能的数字。如果产生冲突,重新开始。如果在至少17个格子中填上了至少8个不同的数字就可以了。(注意:少于17个格子或少于8个不同的数字都将造成重复的结果。感谢 Olivier Grégoire关于8个不同数字的建议.)就算有这些检查,我的随机谜题也不能保证一个唯一的结果。许多有多个结果,有些(大概0.2%)没有结果。在书本和杂志上出现的谜题总是有一个唯一解的。

解决一个谜题的平均时间是0.01秒,有超过99.5%的少于0.1秒,但是一些花费的要更长些:

  0.032% (1 in 3000) 花费超过 0.1

  0.014% (1 in 7000) 花费超过 1

  0.003% (1 in 30,000) 花费超过 10

  0.0001% (1 in 1,000,000) 花费超过 100

100万个中有139个时间超过1秒,下面就是以秒为时间单位的排序好的线性和对数图:

 cMAAHZa4RGWuZxZe4NWAIC3YEsRBgDAvP8H1P67Fv2sWWkAAAAASUVORK5CYII=

ReC1Eu69YtYAAAAASUVORK5CYII=

很难从这里得到结论。最后这些值的上扬显著吗?如果我构造1000万个谜题,是否会有一个超过1000秒?以下是我的100万个随机谜题中最难的一个:

plQHABzA3hJj+bgAgGPYBxYAYAZSHQDADKQ6AIAZSHUAADMopToAAC5kO9UBAHBdUh0AwAykOgCAGfwf42W5g6K0ErIAAAAASUVORK5CYII=

不幸的是,这不是一个真正的数独谜题,因为它有多个解。(这是我采纳 Olivier Grégoire8个数字限制建议之前构造的,所以注意这个谜题的任意解都可以通过互换第1和第7而变为其他的解。)

但这是严格意义上的困难谜题吗?或者这个困难只是由于我的搜索策略采用了特定的variable- value-ordering造成的吗?为了测试,我随机打乱我的value ordering(我将search中最后一行的for d in values[s]改为for d in shuffled(values[s]),通过random.shuffle实现shuffled)。结果是完全地两极分化:30次实验中的27次,花费的时间小于0.02秒,而其他3次实验每次都花了大约190(大概10,000)这个谜题有多个解,这个随机的search找到了13个不同的解。我的猜测是在搜索的早期某处有一组格子(可能是2)我们可能选择了错误的组合去填充这些格子,所以花了大概190秒才发现有冲突。但是如果我选择了另外一个,我们要么能很快找到解,要么很快发现冲突然后转到另外一个选择上。所以这个算法的速度取决于是否能避免选择不可能的值。

随机在大多数情况下能够工作(2730),但是我们或许可以通过一个更好的value ordering(一个流行的启发式方法是least-constraing value,优先选择在peers中有所占值最少的)或尝试一个更聪明的variable ordering来做的更好些。

在对困难谜题做出一个好的总结之前我还需要更多的经验。我决定实验另外100万个随机谜题,这次统计运行时间的中位数,50%(中间)90%99%,最大和标准偏差。结论是相同的,只是这次有两个谜题话费时间超过100秒,并且其中一个相当长:1439秒。

这个谜题是属于无解的0.2%之中的,所以应该不计算在内。主要的信息是中位数和中间值在样本越多的时候也基本保持一致,但是最大值却一致增长---急剧的。标准差也edges up,但这大多是因为时间极长,数量极少的一部分超过了99%的范围。这是重尾分布(heavy-tailed),不是正常的。

为了对比,下面的两个表格左边给出了难题解决方案的运行时间分析,右边给出了常见(高斯)分布,该分布中值为0.014,标准差是1.4794.注意在100万个样例的情况下,高斯分布最大值是中值的5倍标注差(这大致也是你希望从高斯获得的结果),而谜题最大的运行时间是超过了中值的1000标准差。

7HHQAAAAAAwAcRDQAAAAAgHIhoAAAAAADhQEQDAAAAAAgHIhoAAAAAQDj+BzBV+RYifuTcAAAAAElFTkSuQmCC

以下就是那花费了1439秒的不可能完成的谜题:

APt2uwqg0vtIAAAAAElFTkSuQmCC

以下就是solve_all代码,通过它从文件和随机谜题中验证谜题。

import time, random

def solve_all(grids, name='', showif=0.0):
    """Attempt to solve a sequence of grids. Report results.
    When showif is a number of seconds, display puzzles that take longer.
    When showif is None, don't display any puzzles."""
    def time_solve(grid):
        start = time.clock()
        values = solve(grid)
        t = time.clock()-start
        ## Display puzzles that take long enough
        if showif is not None and t > showif:
            display(grid_values(grid))
            if values: display(values)
            print '(%.2f seconds)\n' % t
        return (t, solved(values))
    times, results = zip(*[time_solve(grid) for grid in grids])
    N = len(grids)
    if N > 1:
        print "Solved %d of %d %s puzzles (avg %.2f secs (%d Hz), max %.2f secs)." % (
            sum(results), N, name, sum(times)/N, N/sum(times), max(times))

def solved(values):
    "A puzzle is solved if each unit is a permutation of the digits 1 to 9."
    def unitsolved(unit): return set(values[s] for s in unit) == set(digits)
    return values is not False and all(unitsolved(unit) for unit in unitlist)

def from_file(filename, sep='\n'):
    "Parse a file into a list of strings, separated by sep."
    return file(filename).read().strip().split(sep)

def random_puzzle(N=17):
    """Make a random puzzle with N or more assignments. Restart on contradictions.
    Note the resulting puzzle is not guaranteed to be solvable, but empirically
    about 99.8% of them are solvable. Some have multiple solutions."""
    values = dict((s, digits) for s in squares)
    for s in shuffled(squares):
        if not assign(values, s, random.choice(values[s])):
            break
        ds = [values[s] for s in squares if len(values[s]) == 1]
        if len(ds) >= N and len(set(ds)) >= 8:
            return ''.join(values[s] if len(values[s])==1 else '.' for s in squares)
    return random_puzzle(N) ## Give up and make a new puzzle

def shuffled(seq):
    "Return a randomly shuffled copy of the input sequence."
    seq = list(seq)
    random.shuffle(seq)
    return seq

grid1  = '003020600900305001001806400008102900700000008006708200002609500800203009005010300'
grid2  = '4.....8.5.3..........7......2.....6.....8.4......1.......6.3.7.5..2.....1.4......'
hard1  = '.....6....59.....82....8....45........3........6..3.54...325..6..................'
    
if __name__ == '__main__':
    test()
    solve_all(from_file("easy50.txt", '========'), "easy", None)
    solve_all(from_file("top95.txt"), "hard", None)
    solve_all(from_file("hardest.txt"), "hardest", None)
    solve_all([random_puzzle() for _ in range(99)], "random", 100.0)

为什么?

我为什么会做这个?作为计算机安全专家 Ben Laurie  已经指出,数独是“对人类智慧的拒绝服务攻击(a denial of service attack)”。我所知的许多人(包括我妻子)都被这种思想所感染,然而我想应该让他们明白他们再也不需要在数独上花费任何时间了。这对我的朋友并不好使(我的妻子已经自己改掉了这个习惯,我没有起到任何帮助作用),但是至少有一位陌生人写到说这个文章对他有用,所以我认为我使得这个世界更有生产力了。或许是通过谈论一些关于Python,约束传播,以及搜索相关的东西。

翻译

这段代码已经被许多人通过许多种语言实现了:

转载于:https://www.cnblogs.com/emanonwzy/archive/2011/05/12/2044988.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值