前言
解决一个数独很有意思,解决一个数独求解器更有意思
在MobaXterm自带的一堆终端小游戏里,最有意思的当属数独了。它除了支持标准的9x9数独外还可以自定义数宫大小、对称性和难度,还支持一些特殊规则(X、Jigsaw、Killer),可玩性还是不错的。不过比起亲自去解决一个数独,我还是觉得设计开发一个数独求解器更有意思,正所谓解决一个问题个例不如解决一个能解决所有同类问题的问题。顺带一提,它还能够导入导出题目,方便输入给求解器或者反过来复盘了,给求解器省了图像识别的功夫。

设计思路
在正经设计求解器之前,我其实还没有正经学习过数独技巧,纯靠一些朴素的观察和推理去解决(后来才知道这些推理都是有名字的技巧)。设计之初我就考虑使用回溯搜索的暴力方式来解决,即找一个空格,把所有候选数都列为分支,探索一条分支,找下个空格继续,直到完成,或陷入死局(有空格没有可用候选数了)然后回溯探索下一条分支。不过仔细一想就知道,这个方案有问题,因为搜索深度和分支都很多,会指数爆炸,需要剪枝。剪枝当然就是利用数独技巧了,在列分支之前就通过数独技巧排除掉大量的候选数,可以显著剪枝,对于简单的数独,甚至可以完全避免回溯,只用技巧剪出唯一的路径。我从这里学了一些数独技巧https://sudoku.com/zh/shu-du-gui-ze/,至于怎么用代码实现它们,我借鉴了Impala(老本行了属于是)的SQL表达式改写功能的思想,把技巧包装为改写规则(有改写条件和改写结果),从规则的简单到复杂,逐个判断是否可以应用(是否能剔除至少一个格子的一个候选数),成功应用之后局面会发生变化,需要重新检查。伪代码大概是就是:
while True:
if solved: break
if fill_blank(): break
if Rule1(): continue
if Rule2(): continue
...
if Rulen(): continue
# 没招了,开始回溯搜索
start_backtracking()
除了解决数独本身之外,求解器还需要和游戏界面的交互能力,也就是提我们把答案甚至思考过程填入游戏界面,这部分问了AI,可以借助pynput库的能力直接操作鼠标键盘。
用法和效果
把下面的代码放在一个py文件里,然后游戏界面开一局(可以任选大小和难度,但是还不支持特殊规则),点击Game -> Copy复制得到对应的局面初始情况,贴入main函数里parse_sudoku_string的参数中,运行。
把鼠标光标放在游戏界面左上角第一个格子中心,按下F1键记录起点位置,这是可以发现鼠标自动移到了右下角的格子上,也可以按F3来让鼠标遍历一遍空格检查,如果最后鼠标不在右下角格子上说明界面大小不匹配,需要使用F4来自定义起点和步长(先在左上角第一个格子按一次,然后在右侧的格子再按一次)。
然后按下F2,开始求解数独吧:

代码
代码大体可以分为三个部分,首先要记录单元格情况(填了什么数字或者还有哪些候选数)和游戏界面的对应格子交互(点击、填数、填候选数);然后是求解器本身,包括各个规则和回溯算法;最后是外围代码,包括数独字符串的解析和反序列化(方便从游戏界面直接拷贝和粘贴回去)。
单元格类的代码如下:
from time import sleep, perf_counter
from itertools import combinations
from pynput import keyboard, mouse
from pynput.keyboard import Key, Listener
from pynput.mouse import Button
class Cell:
DISPLAY = True # 求解过程中是否在界面上同步进行解题操作,启用之后解题会比较慢,但是可以看见中间步骤
MOVE_DELAY = 0.1 # 解题操作步骤之间的延迟,单位秒,太短了就看不清了
def __init__(self, i, j, value, sudoku):
self.i = i
self.j = j
self.not_fixed = value is None
self.value = value
self.candidates = None
self.sudoku = sudoku
def __repr__(self):
return f"C({self.i}, {self.j})={self.value if self.value else self.candidates}"
def empty(self):
return self.value is None
def no_candidates(self):
return len(self.candidates) == 0
def move(self):
new_pos = (self.sudoku.origin_pos[0] + self.j * self.sudoku.move_step, self.sudoku.origin_pos[1] + self.i * self.sudoku.move_step)
self.sudoku.mouse_controller.position = new_pos
if Cell.MOVE_DELAY > 0:
sleep(Cell.MOVE_DELAY)
def auto_output(self):
if self.not_fixed:
self.move()
if self.value is not None:
self.sudoku.mouse_controller.click(Button.left, 1)
self.sudoku.keyboard_controller.type(self.value)
elif self.candidates is not None:
for candidate in self.candidates:
self.sudoku.mouse_controller.click(Button.right, 1)
self.sudoku.keyboard_controller.type(candidate)
def output_value(self, value):
if not Cell.DISPLAY:
return
self.move()
self.sudoku.mouse_controller.click(Button.left, 1)
self.sudoku.keyboard_controller.type(value)
def output_candidate(self, candidate):
if not Cell.DISPLAY:
return
self.move()
self.sudoku.mouse_controller.click(Button.right, 1)
self.sudoku.keyboard_controller.type(candidate)
def reset(self):
self.value = None
self.candidates = None
self.output_value(' ')
def apply_unique_candidate(self):
assert len(self.candidates) == 1
self.value = self.candidates.pop()
self.candidates = None
self.output_value(self.value)
self.sudoku.num_empty_cells -= 1
def reset_candidates(self, candidates):
self.reset()
self.candidates = candidates
if len(self.candidates) == 1:
self.sudoku.one_candidate_cells.add(self)
for candidate in candidates:
self.output_candidate(candidate)
def remove_candidate(self, value):
if self.candidates is None or value not in self.candidates:
return False
self.candidates.remove(value)
if len(self.candidates) == 1:
self.sudoku.one_candidate_cells.add(self)
self.output_candidate(value)
return True
求解器本体代码:
class SudokuSolver:
def __init__(self, sudoku_string, char_map=None, box_width=None, box_height=None):
self.rank = len(sudoku_string.strip().split('\n'))
self.char_map = {c for c in '123456789abcdefghijklmnopqrstuvwxyz'[:self.rank]} if char_map is None else char_map
sudoku = [[c if c in self.char_map else None for c in line.strip()] for line in sudoku_string.strip().split('\n')]
assert all(len(row) == self.rank for row in sudoku)
self.box_width = int(self.rank ** 0.5) if box_width is None else box_width
self.box_height = self.box_width if box_height is None else box_height
assert self.rank % self.box_height == 0 and self.rank % self.box_width == 0
assert self.box_width * self.box_height == self.rank
self.cells = [[Cell(i, j, sudoku[i][j], self) for j in range(self.rank)] for i in range(self.rank)]
# for operate
self.mouse_controller = mouse.Controller()
self.keyboard_controller = keyboard.Controller()
self.origin_pos = None
self.move_step = None # default move step size
# for solving
self.get_row_idx = lambda cell: cell.i
self.get_col_idx = lambda cell: cell.j
self.get_box_idx = lambda cell: (cell.i // self.box_height, cell.j // self.box_width)
self.one_candidate_cells = set()
self.num_empty_cells = sum(cell.empty() for row in self.cells for cell in row)
self.backtracking_stack = []
def run(self):
with Listener(on_press=self.key_handler) as listener:
print("Listening for key events...")
listener.join()
def key_handler(self, key):
if key == Key.f1:
self.origin_pos = self.mouse_controller.position
self.move_step = 48
self.cells[self.rank - 1][self.rank - 1].move()
elif key == Key.f2:
start = perf_counter()
self.solve()
end = perf_counter()
print(f"Sudoku solved, remaining empty cells: {self.num_empty_cells}, cost: {end - start: .3f}s")
elif key == Key.f3:
if self.origin_pos is None:
print("Set origin position first.")
return
self.for_all_cells(lambda cell: cell.auto_output())
elif key == Key.f4:
if self.origin_pos is not None and self.move_step is None:
self.move_step = self.mouse_controller.position[0] - self.origin_pos[0]
print(f"Second time f4 pressed, move step set to: {self.move_step}, starting move testing.")
self.cells[self.rank - 1][self.rank - 1].move()
print("Move testing complete, press f4 again if you want to reset the origin position or move step.")
else:
self.origin_pos = self.mouse_controller.position
self.move_step = None
print(f"First time f4 pressed, origin position set to: {self.origin_pos}, "
f"please move the mouse to the next cell and press tab again to set the step size.")
def solve(self):
self.for_all_empty_cells(self.compute_candidates)
while self.num_empty_cells > 0:
# Rule 1: Fill cells with only one candidate
if self.handle_one_candidate():
# Found error, try backtracking
if self.explore_next_branch():
continue
print("No more branches to explore, backtracking failed.")
break
if self.num_empty_cells == 0:
break
# Rule 2: For all row/column/box, if a candidate appears only once, fill it
if self.for_empty_cells_by_dimension(self.handle_single_appearance):
continue
# Rule 3: For all row/column/box, handle all interlock n-tuples for n <= sqrt(rank)
if self.handle_all_interlock_tuples():
continue
# Rule 4: For all row/column/box, if any candidate only appears in cross location, remove it from other location
if self.handle_all_cross_constraints():
continue
# Rule 5: Y-Wing
if self.handle_all_y_wing():
continue
# Rule 9: X-Wing & Swordfish
if self.handle_all_x_wing_and_swordfish():
continue
# Finally, if no rules can be applied, we need to backtrack
self.start_new_backtracking()
def get_row(self, i, is_empty=True):
return {self.cells[i][j] for j in range(self.rank) if is_empty == self.cells[i][j].empty()}
def get_col(self, j, is_empty=True):
return {self.cells[i][j] for i in range(self.rank) if is_empty == self.cells[i][j].empty()}
def get_box(self, box_idx, is_empty=True):
# x and y is box index, not cell index
x, y = box_idx
box_row_start = x * self.box_height
box_row_end = box_row_start + self.box_height
box_col_start = y * self.box_width
box_col_end = box_col_start + self.box_width
return {self.cells[i][j] for i in range(box_row_start, box_row_end)
for j in range(box_col_start, box_col_end)
if is_empty == self.cells[i][j].empty()}
def get_affected(self, cell, is_empty):
box_idx = cell.i // self.box_height, cell.j // self.box_width
return self.get_row(cell.i, is_empty) | self.get_col(cell.j, is_empty) | self.get_box(box_idx, is_empty)
@staticmethod
def get_candidates_location(cells):
candidates_location = {}
for cell in cells:
for candidate in cell.candidates:
candidates_location.setdefault(candidate, set()).add(cell)
return candidates_location
def for_all_cells(self, func):
for i in range(self.rank):
for j in range(self.rank):
func(self.cells[i][j])
def for_all_empty_cells(self, func):
for i in range(self.rank):
for j in range(self.rank):
if self.cells[i][j].empty():
func(self.cells[i][j])
def for_empty_cells_by_line(self, func):
found = False
for i in range(self.rank):
found |= func(self.get_row(i))
for j in range(self.rank):
found |= func(self.get_col(j))
return found
def for_empty_cells_by_box(self, func):
found = False
for x in range(self.rank // self.box_height):
for y in range(self.rank // self.box_width):
found |= func(self.get_box((x, y)))
return found
def for_empty_cells_by_dimension(self, func):
found = False
found |= self.for_empty_cells_by_line(func)
found |= self.for_empty_cells_by_box(func)
return found
def compute_candidates(self, cell):
# cell should be empty
candidates = self.char_map.copy()
for affected_cell in self.get_affected(cell, is_empty=False):
candidates.discard(affected_cell.value)
cell.reset_candidates(candidates)
def handle_one_candidate(self):
while len(self.one_candidate_cells) > 0:
cell = self.one_candidate_cells.pop()
if cell.no_candidates():
# return True means error found, no candidates left
# may need backtracking, or it's an unsolvable sudoku
return True
cell.apply_unique_candidate()
for effected_cell in self.get_affected(cell, is_empty=True):
effected_cell.remove_candidate(cell.value)
return False
def handle_single_appearance(self, cells):
# all cell in cells should be empty
found = False
for candidate, locations in self.get_candidates_location(cells).items():
if len(locations) == 1:
cell = locations.pop()
cell.reset_candidates({candidate})
found = True
return found
def handle_interlock_n_tuples(self, cells, n):
cells = list(cells)
found = False
for cell_indices in combinations(range(len(cells)), n):
selected_cells = [cells[i] for i in cell_indices]
union = set().union(*(cell.candidates for cell in selected_cells))
intersection = set(self.char_map).intersection(*(cell.candidates for cell in selected_cells))
if len(union) == n:
# Visible n-tuple, remove union candidates from other cells
for i in range(len(cells)):
if i not in cell_indices and cells[i].candidates & union:
new_candidates = cells[i].candidates - union
cells[i].reset_candidates(new_candidates)
found = True
continue
if len(intersection) == n:
for i in range(len(cells)):
if i not in cell_indices and intersection & cells[i].candidates:
break
else:
# Invisible n-tuple, reset candidates as intersection for these cells
for cell in selected_cells:
cell.reset_candidates(intersection.copy())
found = True
return found
def handle_all_interlock_tuples(self):
max_tuple_size = int(self.rank ** 0.5)
for n in range(2, max_tuple_size + 1):
if self.for_empty_cells_by_dimension(lambda cells: self.handle_interlock_n_tuples(cells, n)):
return True
return False
def handle_cross_constraint(self, cells, get_idx, get_constraint_cells):
found = False
for candidate, locations in self.get_candidates_location(cells).items():
constraint_ids = {get_idx(cell) for cell in locations}
if len(constraint_ids) == 1:
# candidate's all cells in same dimension
for cell in get_constraint_cells(constraint_ids.pop()):
if cell not in locations:
found |= cell.remove_candidate(candidate)
return found
def handle_all_cross_constraints(self):
found = False
found |= self.for_empty_cells_by_box(lambda cells: self.handle_cross_constraint(cells, self.get_row_idx, self.get_row))
found |= self.for_empty_cells_by_box(lambda cells: self.handle_cross_constraint(cells, self.get_col_idx, self.get_col))
found |= self.for_empty_cells_by_line(lambda cells: self.handle_cross_constraint(cells, self.get_box_idx, self.get_box))
return found
def handle_all_y_wing(self):
two_candidates_cells = []
self.for_all_empty_cells(lambda cell: two_candidates_cells.append(cell) if len(cell.candidates) == 2 else None)
for i in range(len(two_candidates_cells)):
middle = two_candidates_cells[i]
affected_cells = self.get_affected(middle, True)
for j in range(i + 1, len(two_candidates_cells)):
left = two_candidates_cells[j]
if left not in affected_cells or len(middle.candidates & left.candidates) != 1:
continue
for k in range(j + 1, len(two_candidates_cells)):
right = two_candidates_cells[k]
if left.i == right.i or left.j == right.j:
continue
if right not in affected_cells or len(middle.candidates & right.candidates) != 1:
continue
if left.candidates != right.candidates and len(middle.candidates | left.candidates | right.candidates) == 3:
intersection = left.candidates & right.candidates
assert len(intersection) == 1
candidate = intersection.pop()
found = False
found |= self.cells[left.i][right.j].remove_candidate(candidate)
found |= self.cells[right.i][left.j].remove_candidate(candidate)
if found:
return True
return False
def get_appear_twice_lines(self, get_idx):
cells_by_lines_by_candidate = {}
# {'1':{i1:[c1, c2], i2:[c3], i3:[c4, c5, c6], i4:[c7, c8]}, '2':{...}, ...}
self.for_all_empty_cells(lambda cell: [
cells_by_lines_by_candidate.setdefault(candidate, {}).setdefault(get_idx(cell), []).append(cell)
for candidate in cell.candidates])
filter_appear_twice = lambda lines_by_candidate: {
candidate: two_cell_lines
for candidate, lines in lines_by_candidate.items()
if len(two_cell_lines := [cells for cells in lines.values() if len(cells) == 2]) >= 2
}
# {'1':[[c1, c2], [c7, c8]], '2':[...], ...}
return filter_appear_twice(cells_by_lines_by_candidate)
@staticmethod
def handle_single_x_wing_or_swordfish(candidate, lines, get_idx, get_another_idx, get_another_line):
found = False
# lines: [[c1, c2], [c7, c8], ...]
# lines_idxes: [(i1, {j1, j2}), (i2, {j7, j8}), ...]
lines_idxes = [(get_idx(line[0]), {get_another_idx(line[0]), get_another_idx(line[1])}) for line in lines]
for i in range(len(lines_idxes)):
for j in range(i + 1, len(lines_idxes)):
# Check X-wing
union = lines_idxes[i][1] | lines_idxes[j][1]
if len(union) == 2:
i1, i2 = lines_idxes[i][0], lines_idxes[j][0]
for cell in get_another_line(union.pop()) | get_another_line(union.pop()):
if get_idx(cell) != i1 and get_idx(cell) != i2:
found |= cell.remove_candidate(candidate)
if found:
return found
continue
# Check Swordfish
for k in range(j + 1, len(lines_idxes)):
union = lines_idxes[i][1] | lines_idxes[j][1] | lines_idxes[k][1]
if len(union) == 3:
i1, i2, i3 = lines_idxes[i][0], lines_idxes[j][0], lines_idxes[k][0]
for cell in get_another_line(union.pop()) | get_another_line(union.pop()) | get_another_line(union.pop()):
if get_idx(cell) != i1 and get_idx(cell) != i2 and get_idx(cell) != i3:
found |= cell.remove_candidate(candidate)
if found:
return found
return False
def handle_all_x_wing_and_swordfish(self):
found = False
handle_lines = lambda get_idx, get_another_idx, get_another_line: any(
self.handle_single_x_wing_or_swordfish(candidate, rows, get_idx, get_another_idx, get_another_line)
for candidate, rows in self.get_appear_twice_lines(get_idx).items()
)
found |= handle_lines(self.get_row_idx, self.get_col_idx, self.get_col)
found |= handle_lines(self.get_col_idx, self.get_row_idx, self.get_row)
return found
def start_new_backtracking(self):
# create snapshot
assert len(self.one_candidate_cells) == 0
snapshot = ([], self.num_empty_cells)
for i in range(self.rank):
for j in range(self.rank):
cell = self.cells[i][j]
# Only empty cells need to be recorded
if cell.empty():
snapshot[0].append((i, j, cell.candidates.copy()))
# find the cells with the fewest candidates and have candidate which affect most cells
min_candidates = self.rank + 1
min_cells = []
for i in range(self.rank):
for j in range(self.rank):
cell = self.cells[i][j]
if cell.empty():
if len(cell.candidates) < min_candidates:
min_candidates = len(cell.candidates)
min_cells = [cell]
elif len(cell.candidates) == min_candidates:
min_cells.append(cell)
max_affected_nums = ()
max_affected_cell= None
sorted_candidates_of_cell = None
for cell in min_cells:
affected_cells = self.get_affected(cell, True)
candidates_location = self.get_candidates_location(affected_cells)
num_candidates_affected = [(candidate, len(candidates_location[candidate])) for candidate in cell.candidates]
num_candidates_affected.sort(key=lambda x: x[1], reverse=True)
sorted_candidates, affected_nums = zip(*num_candidates_affected)
if affected_nums > max_affected_nums:
max_affected_nums = affected_nums
max_affected_cell = cell
sorted_candidates_of_cell = sorted_candidates
sorted_candidates_of_cell = list(sorted_candidates_of_cell)
sorted_candidates_of_cell.reverse()
self.backtracking_stack.extend(
(max_affected_cell.i, max_affected_cell.j, candidate, snapshot) for candidate in sorted_candidates_of_cell)
print(f"New branch: {[f'C({i}, {j}) = {c}' for i, j, c, s in self.backtracking_stack]}")
# explore first branch, no need to apply snapshot
i, j, candidate, snapshot = self.backtracking_stack.pop()
cell = self.cells[i][j]
cell.reset_candidates({candidate})
print(f"Apply {f'C({i}, {j}) = {candidate}'}")
def explore_next_branch(self):
if len(self.backtracking_stack) == 0:
return False
cell_i, cell_j, candidate, snapshot = self.backtracking_stack.pop()
# apply snapshot
self.one_candidate_cells.clear()
self.num_empty_cells = snapshot[1]
for i, j, candidates in snapshot[0]:
cell = self.cells[i][j]
if cell.candidates != candidates:
cell.reset_candidates(candidates.copy())
# apply cell candidate
cell = self.cells[cell_i][cell_j]
cell.reset_candidates({candidate})
print(f"Apply {f'C({cell_i}, {cell_j}) = {candidate}'}")
return True
外围代码:
def parse_sudoku_string(ss):
sudoku = []
box_height, box_width = -1, -1
for i, line in enumerate(ss.strip().split('\n')):
line = line.strip()
if line.startswith('-'):
if box_height == -1:
box_height = i
continue
if box_width == -1:
box_width = line.replace(' ', '').find('|')
sudoku.append(line.replace(' ', '').replace('|', ''))
if box_height == -1:
box_height = len(sudoku)
if box_width == -1:
box_width = len(sudoku[0])
fingerprint = ''
num_dot = 0
symbols = '_abcdefghijklmnopqrstuvwxyz'
for c in ''.join(sudoku):
if c == '.':
num_dot += 1
else:
if num_dot != 0 or len(fingerprint) != 0:
fingerprint += symbols[num_dot]
fingerprint += str(int(c, 36))
num_dot = 0
if num_dot != 0:
fingerprint += symbols[num_dot]
print(f'{box_height}x{box_width}:{fingerprint}')
return '\n'.join(sudoku), box_height, box_width
def main():
Cell.DISPLAY = True
Cell.MOVE_DELAY = 0.001
sudoku, box_height, box_width = parse_sudoku_string("""
. 3 . | . . . | . . .
7 . . | . . 6 | . 9 .
6 9 5 | . 7 . | . . 8
------+-------+------
2 . . | . . 4 | . . .
. 7 . | 1 . 8 | . 2 .
. . . | 3 . . | . . 6
------+-------+------
1 . . | . 9 . | 8 7 4
. 8 . | 2 . . | . . 9
. . . | . . . | . 6 .
""")
ss = SudokuSolver(sudoku_string=sudoku, box_height=box_height, box_width=box_width)
ss.run()
if __name__ == "__main__":
main()

&spm=1001.2101.3001.5002&articleId=152125314&d=1&t=3&u=7465dcbcd8ae4094a7eed0d2ff66d562)
1858

被折叠的 条评论
为什么被折叠?



