Python——数独求解器(规则应用+回溯搜索)

该文章已生成可运行项目,

前言

解决一个数独很有意思,解决一个数独求解器更有意思

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

设计思路

在正经设计求解器之前,我其实还没有正经学习过数独技巧,纯靠一些朴素的观察和推理去解决(后来才知道这些推理都是有名字的技巧)。设计之初我就考虑使用回溯搜索的暴力方式来解决,即找一个空格,把所有候选数都列为分支,探索一条分支,找下个空格继续,直到完成,或陷入死局(有空格没有可用候选数了)然后回溯探索下一条分支。不过仔细一想就知道,这个方案有问题,因为搜索深度和分支都很多,会指数爆炸,需要剪枝。剪枝当然就是利用数独技巧了,在列分支之前就通过数独技巧排除掉大量的候选数,可以显著剪枝,对于简单的数独,甚至可以完全避免回溯,只用技巧剪出唯一的路径。我从这里学了一些数独技巧https://sudoku.com/zh/shu-du-gui-ze/,至于怎么用代码实现它们,我借鉴了Impala(老本行了属于是)的SQL表达式改写功能的思想,把技巧包装为改写规则(有改写条件和改写结果),从规则的简单到复杂,逐个判断是否可以应用(是否能剔除至少一个格子的一个候选数),成功应用之后局面会发生变化,需要重新检查。伪代码大概是就是:

while Trueif 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()
本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值