关于python解数独的效率探究
该文章为我上一篇文章的思路及源码改进,增加了代码可读性,改进了部分效率,测试了np数组的影响
我的上一个python解数独思路及源码
基础数独的规则及解决思路
基础9x9数独的规则:
1.每行9个数字中必须出现1~9九个数字
2.每列9个数字中必须出现1~9九个数字
3.每个九宫格中必须出现1~9九个数字
共27个条件。
第一步:填充
我们先观察以下数独,可以发现
+-----+-----+-----+
| 3 6| | 7|
|9<α>8| | 6 |
| 7 | 5| 9|
+-----+-----+-----+
| 7| 8 4| 3|
| | 1|4<β> |
|3 |7 |2 1 |
+-----+-----+-----+
| |5 |6 |
|6 3| 9 |7 |
|4 1 | | |
+-----+-----+-----+
遵循27条件,则其中左上九宫格中<α>
处的可能性有:[2, 4, 5]
。
如果存在一个格子,其只有一种可能性,我们可以立刻填入这种可能性。对该数独来说,这种填写方案是百分百正确的。但现实是,这种格子存在极少,虽然易于判断,但每次只依靠这种方法前期几乎填不了多少格子。本文称为排除推测值。该方法可以完全确定可能性为1种的格子。
考虑<β>处格子,如果只看27条件,我们知道其可能性为[5, 7]
。
然而,我们知道,一个九宫格中至少存在一个7
,而观察<β>
所在的九宫格的其他格子,受限于其他九宫格中的7
,没有任何一个其他格子拥有可能性7
。因此,我们可以判断<β>
处也百分之百为7
。本文称为唯一推测值。该方法可以确定部分可能性大于1种的格子。
这两种推测方法均可以推测出原数独中部分没有多解的格子上百分百正确的值,同理,如果按照该方法推导发现自相矛盾,则该数独原题一定无解。
第二步:猜想
绝大多数较难的数独都无法直接通过填充解决,这时,我们可以选择创建一个当前数独的副本,然后选择一个可能性数量为2种的格子,随便猜一个,这时数独状态更新,我们可以继续进行填充,之后进行矛盾检查,如果发现了矛盾则返回上一级,选择第二种可能性进行填充。如果没有可能性数量为2的,放宽这个条件,直到找到一个最小的。
一个猜想分支的判断策略
数独解决整体的判断策略
将唯一推测值方法从九宫格扩展到行列中并辅以排除推测值方法即可大幅提高数独整体完整度,减少每次猜测的成本,结合猜测树,即可在几次猜想内完成数独。
算法实现
import time
from copy import deepcopy
UNSOLVED = 1
SOLVED = 0
ANTINOMY = -1
FULL_ANTINOMY = -2
数独类
class Sudoku:
def __init__(self, sudoku_str=None, super_sudoku=None):
if sudoku_str: # 新建数独用
sudoku_str = sudoku_str.replace('.', '0').strip().replace('\n', ',')
self.board = [[int(n) for n in ln] for ln in sudoku_str.split(',')]
assert len(self.board) == 9, "传入数独行数有误"
elif super_sudoku: # 复制数独用
self.board = super_sudoku
else: # 白板数独
self.board = [[0] * 9 for _ in range(9)]
def __call__(self, row, col, value=None):
'''
传入row和col,然后返回数独上对应位置的值。如果第三个参数存在则对数独进行修改后返回修改后的值。
'''
if value:
self.board[row][col] = value
return self.board[row][col]
def __str__(self):
split_line = '+-----+-----+-----+\n'
ret_str = ''
for row in range(9):
if row % 3 == 0:
ret_str += split_line
ln = list('|' + ' '.join([' 123456789'[self(row, col)] for col in range(9)]) + '|\n')
ln[6] = '|'
ln[12] = '|'
ret_str += ''.join(ln)
ret_str += split_line[:-1]
return ret_str
def copy(self):
"""
获取数独实例拷贝
返回一个自身数独的深拷贝并转为数独实例
:return: Sudoku, 自身数独的拷贝的实例
"""
return Sudoku(super_sudoku=deepcopy(self.board))
def o_value(self, coords):
"""
唯一值方法
* 唯一值(only-one value, o_value):
当一个九宫格中的数字可能性如下时:
[1,2,3] [2,3] [2,3]
4 5 6
7 8 9
该情况下可以确定左上角格子只可能为1,因为九宫格中必然存在一个1
扩展到行和列,即可获得本方法 (o_value方法)
该方法用于确定可能值数量大于1但实际可能性只有1的部分推理简单的空格
:param coords: Tuple[Tuple[int, int]] 需要推理的坐标
:return: {Tuple[int, int]: int, ...} 坐标与对应唯一值的字典
"""
probs = {}
result = {}
counts = [[] for _ in range(10)]
for coord in coords:
if self(*coord) == 0:
probs[coord] = self.get_prob(*coord)
for coords, nums in probs.items():
for num in nums:
counts[num].append(coords)
for n in range(1, 10):
if len(counts[n]) == 1:
result[counts[n][0]] = n
return result
def deduce(self):
"""
使用两种百分之百正确的方法推断数独中可以确定的格子。
- 唯一值(only-one value, o_value): 见方法 self.o_value(coords)
当有解时,使用唯一值方法填充格子。
- 排除值(possibility-one value, p_value):
当一个格子中的数字有且仅有一种可能性时,立即填写这种可能性。
该方法用于直接填写可能值数量为1的空格。
当数独有解时,该方法会先使用唯一值方法,然后再使用排除值方法填充格子。
当数独无解时,该方法会留下至少一个存在矛盾的空格(没有可能的数字)。
- 此时该格子会被 self.is_safe() 方法检测返回 false 。
唯一值方法在排除值方法之前执行时,效率稳定提升8%~10%。
原因是唯一值方法考虑了可能性为1种的格子而排除值方法不考虑,此时唯一值方法可以为排除值方法“铺路”。
虽然可以通过记录两种方法是否对数独进行过更改,并据此判断是否需要再次执行上述两种填充方式,
但之后的连锁分析大多只能破解出一个格子,
而每次分析都需要遍历整个数独。况且最终一定有一次分析结果数量为0的情况用于离开循环。
调试发现循环时连锁分析只进行了2~4次。
一种可行的方式是在调用处进行两次或三次调用,
从而减少猜测树(深拷贝)的制造,进而减少内存使用。
但实测中发现,一次的效率是最高的。原因是第二次开始破解的格子数量变得极少导致的。
"""
# 唯一值方法
for x in range(9):
# 对行x填写唯一值
[self(*coord, value) for coord, value in self.o_value([(x, col) for col in range(9)]).items()]
# 对列x填写唯一值
[self(*coord, value) for coord, value in self.o_value([(row, x) for row in range(9)]).items()]
# 对九宫格x填写唯一值
zod_col, zod_row = divmod(x, 3)
zod_col *= 3
zod_row *= 3
coords = [_ for __ in
[[(row, col) for col in range(zod_col, zod_col + 3)] for row in range(zod_row, zod_row + 3)]
for _ in __]
for coord, value in self.o_value(coords).items():
self(*coord, value)
# 排除值方法
for row in range(9):
for col in range(9):
if self(row, col) == 0:
probs = self.get_prob(row, col)
if len(probs) == 1:
self(row, col, probs[0])
for row in range(9):
for col in range(9):
if self(row, col) == 0:
probs = self.get_prob(row, col)
if len(probs) == 1:
self(row, col, probs[0])
def is_safe(self):
"""
检测当前数独是否产生了矛盾。
"""
for row in range(9):
for col in range(9):
if len(self.get_prob(row, col)) == 0:
return False
return True
def is_complete(self):
"""
检测数独是否完整且遵循27个条件。
"""
if not all([all(col) for col in self.board]):
return False # 检测是否完整无空
for x in range(9):
counts = [0] * 9
for col in range(9):
counts[self(x, col) - 1] += 1
if not all(counts):
return False # 每行完备
counts = [0] * 9
for row in range(9):
counts[self(row, x) - 1] += 1
if not all(counts):
return False # 每列完备
col, row = divmod(x, 3)
counts = [0] * 9
for r in range(row * 3, row * 3 + 3):
for c in range(col * 3, col * 3 + 3):
counts[self(r, c) - 1] += 1
if not all(counts):
return False # 每九宫格完备
return True
def get_prob(self, row, col):
"""
获取指定格子的可能性列表。
"""
if self(row, col): # 如果本格已经写入数字,返回含该数字本身的1长度列表
return [self(row, col)] # 该返回值用于对应o_vakue
probs = [True] * 10
for c in self.board[row]:
probs[c - 1] = False # 排除每行
for c in [row[col] for row in self.board]:
probs[c - 1] = False # 排除每列
for cx in range(col // 3 * 3, col // 3 * 3 + 3):
for rx in range(row // 3 * 3, row // 3 * 3 + 3):
probs[self(rx, cx) - 1] = False # 排除每九宫格
prob = [x + 1 for x in range(9) if probs[x]]
return prob
def get_empty_cell(self):
"""
获取一个空的格子位置。
遍历数独棋盘,寻找一个可能性最小且大于1的空格子。
"""
for max_prob in range(2, 10):
for row in range(9):
for col in range(9):
prob_nums = len(self.get_prob(row, col))
if prob_nums == 0:
return None
if 1 < prob_nums <= max_prob:
return row, col
return None
解数独类
class SudokuSolver:
def __init__(self, sudoku):
self.sudoku = sudoku
self.status = UNSOLVED # guess方法未进行/未完成的情况
def solve(self):
self.status = self.guess()
return self.status
def guess(self):
self.sudoku.deduce()
self.cell = self.sudoku.get_empty_cell()
if self.cell is None:
if self.sudoku.is_safe():
return SOLVED
else:
return ANTINOMY
probs = self.sudoku.get_prob(*self.cell)
for prob in probs: # 收集每个可能性的数独的解,如果解决则本身也返回
# 构造子数独
next_sudoku = self.sudoku.copy()
next_sudoku(*self.cell, prob)
next_sudoku_solver = SudokuSolver(next_sudoku)
# 解决子数独
next_status = next_sudoku_solver.solve()
if next_status == SOLVED:
self.status = SOLVED
self.sudoku = next_sudoku_solver.sudoku # 将已解决的子数独接收到自己的属性上
self.sudoku.deduce() # 避免单可能性空格子问题
return SOLVED
return FULL_ANTINOMY # 枝穷
def get_status(self):
ret = ['SOLVED', 'UNSOLVED', 'FULL_ANTINOMY', 'ANTINOMY'][self.status]
ret += '(Valid)' if self.sudoku.is_complete() else '(Invalid)'
# 完全通过且合法的节点应该是 "SOLVED(Valid)"
return ret
读取数独和解数独计时等
def read_sudoku_file(file_path, SudokuType):
with open(file_path, 'r') as file:
lines = file.readlines()
sudoku_lns = []
sudokus = []
for Ln, line in enumerate(lines):
line = line.strip()
if line == "---":
assert len(sudoku_lns) == 9, f'高度不为9:在Ln{Ln}'
sudokus.append(SudokuType(sudoku_str=','.join(sudoku_lns)))
sudoku_lns = []
continue
sudoku_lns.append(line)
assert len(line) == 9, f'宽度不为9:在Ln{Ln}'
return sudokus
def solve_sudoku_timing(sudoku=None, sudoku_str=None, SudokuType=None, name="Sudoku"):
if sudoku_str:
sudoku = SudokuType(sudoku_str=sudoku_str)
node = SudokuSolver(sudoku)
st = time.time()
node.solve()
et = time.time()
if node.get_status() != 'SOLVED(Valid)':
print(f'-->{name}:{node.get_status()}')
return float("inf")
print(f'{name}: time cost: {et - st:.6}s')
return et - st
使用例
TSudoku = Sudoku
file_path = 'sudoku.txt'
print(f'读取到{len(sudokus := read_sudoku_file(file_path, TSudoku))}个数独')
all_time_cost = sum(solve_sudoku_timing(sudoku=sudoku, name=str(i + 1)) for i, sudoku in enumerate(sudokus))
print()
print(f'总用时: {all_time_cost:.6f}s')
print(f'{len(sudokus)}个数独的平均用时: {all_time_cost / len(sudokus):.6f}s')
用于读取的sudoku.txt可以于本文顶部获取
作者操作环境下每个数独平均在0.04s左右(虽然在朋友的硬件设备帮助下突破了0.01s但并没有垂直可比性),读者可以使用自己的设备进行尝试
本算法使用NumPy数组优化的方案及其结果
我们知道NumPy对数组的优化很好,实际上,数独中需要向量化操作的地方并不多,数独本身数据集也不大,但本着实践精神我们还是尝试一下
因为我们将对数独的读写操作均集中到了__call__
方法上,所以对原有的数独类的修改可以很方便地完成,甚至可以直接通过继承完成。
数独类
import numpy as np
class SudokuNP(Sudoku):
def __init__(self, sudoku_str=None, super_sudoku=None):
super().__init__()
if sudoku_str:
sudoku_str = sudoku_str.replace('.', '0').strip().replace('\n', ',')
self.board = np.array([[int(n) for n in ln] for ln in sudoku_str.split(',')])
assert self.board.shape == (9, 9), "传入数独行列数有误"
elif super_sudoku is not None:
self.board = np.array(super_sudoku)
else:
self.board = np.zeros((9, 9), dtype=int)
def __call__(self, row, col, value=None):
if value is not None:
self.board[row, col] = value
return
return self.board[row, col]
def copy(self):
"""
获取数独实例的深拷贝。
返回一个自身数独的深拷贝并转为数独实例。
:return: Sudoku, 自身数独的拷贝的实例
"""
return SudokuNP(super_sudoku=self.board.copy())
def get_prob(self, row, col):
"""
获取指定格子的可能性列表。
"""
if self(row, col):
return [self(row, col)]
counts = np.ones(10, dtype=bool)
counts[self.board[row, :]] = False
counts[self.board[:, col]] = False
start_row, start_col = (row // 3) * 3, (col // 3) * 3
counts[self.board[start_row:start_row + 3, start_col:start_col + 3]] = False
probs = [x for x in range(1, 10) if counts[x]]
return probs
使用例
TSudoku = SudokuNP
# 之后同上
作者操作环境下平均0.08s左右(对照:原生数组约为0.04s)。实际上,基于本思路想完全利用好numpy提供的向量化操作并不容易,或者说针对numpy可以给出另一种算法供其优化。本文算法下使用python原生数组效率更高。
执行推测的方案及效率
在Sudoku.deduce()
方法中,我们同时使用了唯一推测值和排除推测值方法,这是因为排除推测值方法只关注本格剩余的方格,这将由27条件范围所规定对于单格的其他20个已有数字的格子决定,而唯一推测值方法将通过其他格子的剩余可能值决定。无论某个格子当前的状态为“仅剩一种可能性”还是“已经被填写”,Sudoku.get_prob
方法始终返回单元素列表以供唯一推测值方法进行推断。因此,纵使推测后数独和推测前数独最终将属于同一数独,但唯一值方法不受排除值方法进行的影响,且排除值方法会受到唯一值方法填充后数独改变的影响。排除值方法的结果也会受到本身进行填充后对数独状态的更新的影响。
那么应该如何权衡deduce方法中两个方法的执行方案呢,这取决于
以下的实验或许能说明一些问题:
对照组:
14个数独的平均用时: 0.038782s
在SudokuSolver.guess中两次执行deduce方法(等同于两次唯一值方法,两次排除值方法)
14个数独的平均用时: 0.043277s
在deduce中两次执行排除值方法:
14个数独的平均用时: 0.037038s
在deduce中三次执行排除值方法:
14个数独的平均用时: 0.037215s
在deduce中四次执行排除值方法:
14个数独的平均用时: 0.041331s
在测试过程中,我发现排除值方法在Sudoku.deduce()
方法里多次执行后,效果不明显地提升了一点,但直接在SudokuSolver.guess()
里暴力多次执行Sudoku.deduce()
的结果却明显地不尽人意。原因应该是排除值方法的运行速度远高于唯一值方法,但却没有唯一值方法所推断的数量多。基于这一点,我们可以插入一个简单的循环,仅对排除值方法进行循环执行直到没有新的可能性出现。
class Sudoku:
def deduce(self):
# 唯一值方法 略
# 排除值方法
is_continue = True
while is_continue:
is_continue = False
for row in range(9):
for col in range(9):
if self(row, col) == 0:
probs = self.get_prob(row, col)
if len(probs) == 1:
self(row, col, probs[0])
is_continue = True
最后终于得到了飞跃性(约18%)的优化:
读取到14个数独
1: time cost: 0.0327134s
2: time cost: 0.0160565s
3: time cost: 0.0394614s
4: time cost: 0.0176356s
5: time cost: 0.0269258s
6: time cost: 0.107358s
7: time cost: 0.0107322s
8: time cost: 0.0176454s
9: time cost: 0.0161128s
10: time cost: 0.0165918s
11: time cost: 0.0244157s
12: time cost: 0.0654345s
13: time cost: 0.0327852s
14: time cost: 0.0219097s
总用时: 0.445778s
14个数独的平均用时: 0.031841s
结语
上一版文章中代码跑空数独约0.529s,14个例题约0.211s
今天优化的代码跑空数独约0.257s,14个例题约0.0318s
十分感慨,几天时间居然能把这个小题目优化将近一个数量级
另外我发现下面这篇文章的思路和我的想法很像,命名也比我清晰的多,代码方面如果使用c语言或许还能优化一个数量级……敬请期待下一章?
大佬的文章: 数独-比回溯法更优的人类思维逻辑的数独解法
这期间学了使用函数装饰器检测各个函数的访问频次,还浅学了numpy的向量化操作(当然最大的收获还是发现其实numpy并非总是会优化数组操作)
本版中还未优雅地实现数独多解,在SudokuSolver.guess()
将要返回SOLVE
时截停并输出,然后以矛盾形式继续检测其他节点或许就能实现,基于这一点似乎还能制作一个数独难度分类器……
最后不正经地浅改了一个可以给出多解的SudokuSolver
替换类SudokuFullSolver
,只取消了SOLVED
条件返回,所以如果用它代替SudokuSolver
会导致总是会返回FULL_ANTINOMY
,不过无所谓,遍历所得到的解都打印出来了,看就完了~
class SudokuFullSolver(SudokuSolver):
def guess(self):
self.sudoku.deduce()
self.cell = self.sudoku.get_empty_cell()
if self.cell is None:
if self.sudoku.is_safe():
self.sudoku.deduce()
print(self.sudoku)
return ANTINOMY
for p in self.sudoku.get_prob(*self.cell):
next_sudoku = self.sudoku.copy()
next_sudoku(*self.cell, p)
SudokuFullSolver(next_sudoku).solve()
return FULL_ANTINOMY