在使用 Python 递归求解数独问题的时候,碰到了一个隐藏比较深的 bug,特此写下这篇文章记录下来
背景介绍
一般来说,数独问题是指填补一个由 9 个 3 × 3 3\times 3 3×3 的小网格组成的 9 × 9 9\times 9 9×9 大网格,使得
- 大网格的每一列都恰好包含 1~9 这九个数字
- 大网格的每一行都恰好包含 1~9 这九个数字
- 每个 3 × 3 3\times 3 3×3 的小网格都恰好包含 1~9 这九个数字
下图是一个示例
代码要求解的是更加一般的数独问题,这里给出更一般的数独问题的定义:假设小网格大小为 n_rows * n_cols,则大网格的大小为 n * n,其中 n = n_rows ⋅ n_cols n=\text{n\_rows} \cdot \text{n\_cols} n=n_rows⋅n_cols,填补这样一个大网格,使得
- 大网格的每一列都恰好包含 1~n 这 n 个数字
- 大网格的每一行都恰好包含 1~n 这 n 个数字
- 每个 n_rows * n_cols 的小网格都恰好包含 1~n 这 n 个数字
下图是一个小网格大小为 2 × 3 2\times 3 2×3 的示例
假设二维数组 grid 为大网格,后面的代码基于下面的算法:
- 每个没有填好的单元格,使用一个列表来存储该单元格可能存放的值。比如说,如果单元格 grid[i][j] 已经填好了,那么 grid[i][j] 的值就等于填好的数字,否则 grid[i][j] 的值就是一个列表,该列表存储了所有可能放置在该单元格的数字
- 递归求解函数会假设:传递给它的 grid 中,每个没有填好的单元格都使用一个列表来存储该单元格可能存放的值。需要注意的是,最开始要求解的 grid 中,每个没有填好的单元格处存储的值是 0,所以在第一次调用递归求解函数之前,需要将 grid 中等于 0 的单元格转化为可能值的列表。
- 递归求解函数的任务就是:
- 找到一个没有填好的具有最少可能值的单元格,需要注意的是,该单元格的可能值列表的长度至少要为 1 才行
a. 如果找不到这样的单元格,说明要么 grid 已经填好了,要么 grid 无解,无论如何此时递归函数都要返回
b. 如果找到了这样的单元格,就接着执行第 2 步 - 遍历该单元格的可能值列表中的每一个值,将其填入到这个单元格中,递归调用求解函数进行求解,如果有解,则直接返回解,否则回溯
- 如果遍历完可能值列表中的每一个值后,还是不能找到解,说明此时 grid 无解,回溯后再返回
第 2 步中,再将一个值填入到单元格后,此时与该单元格同行、同列以及同小网格的其他未填好单元格的可能值列表可以进一步缩小,可以将该值从这些单元格的可能值列表中剔除,从而减小搜索空间,大幅提升算法速度
- 找到一个没有填好的具有最少可能值的单元格,需要注意的是,该单元格的可能值列表的长度至少要为 1 才行
下面遇到的问题就是在回溯上出现了问题,导致代码不能正常工作,后面再细说
遇到的问题与反思
下面是错误的代码
def find_least_uncertain(grid):
'''
找到一个没有填好的具有最少可能值的单元格,该单元格的可能值列表的长度至少要为 1 才行
'''
min_uncertain = len(grid) + 1
min_uncertain_pos = None
for i in range(len(grid)):
row = grid[i]
for j in range(len(row)):
if isinstance(grid[i][j], list):
cur_uncertain = len(grid[i][j])
if cur_uncertain == 0:
return None
elif cur_uncertain == 1:
return (i, j)
elif cur_uncertain < min_uncertain:
min_uncertain_pos = (i, j)
min_uncertain = cur_uncertain
return min_uncertain_pos
def place_value(grid, n_rows, n_cols, row, col, val):
'''
将 val 填入 grid[row][col] 中,更新与 grid[row][col] 同行、同列、同小网格的其他未填好单元格的可能值列表
为了便于后续回溯,将所有修改过的可能值列表返回
'''
lists = []
grid[row][col] = val
for j in range(len(grid[0])):
if isinstance(grid[row][j], list) and (val in grid[row][j]):
lists.append(grid[row][j])
grid[row][j].remove(val)
for i in range(len(grid)):
if isinstance(grid[i][col], list) and (val in grid[i][col]):
lists.append(grid[i][col])
grid[i][col].remove(val)
start_r = (row // n_rows) * n_rows
start_c = (col // n_cols) * n_cols
for r in range(start_r, start_r + n_rows):
for c in range(start_c, start_c + n_cols):
if isinstance(grid[r][c], list) and (val in grid[r][c]):
lists.append(grid[r][c])
grid[r][c].remove(val)
return lists
def sudoku_recursive_solve(grid, n_rows, n_cols):
'''
求解数独的递归函数
该函数会假设:grid 中每个没有填好的单元格都使用一个列表来存储该单元格可能存放的值
'''
#N is the maximum integer considered in this board
n = n_rows*n_cols
least_uncertain = find_least_uncertain(grid)
if not least_uncertain:
if check_solution(grid, n_rows, n_cols):
return grid
else:
return None
else:
row, col = least_uncertain
candidate = grid[row][col]
for val in candidate:
lists = place_value(grid, n_rows, n_cols, row, col, val)
ans = sudoku_recursive_solve(grid, n_rows, n_cols)
if ans:
return ans
# 没有找到解就回溯
for l in lists:
l.append(val)
return None
def sudokuSolver(grid, n_rows, n_cols):
'''
求解数独的函数
这里的 grid 中,每个没有填好的单元格处存储的值是 0
'''
n = n_rows*n_cols
# 在第一次调用递归求解函数之前,需要将 grid 中等于 0 的单元格转化为可能值的列表
for i in range(len(grid)):
row = grid[i]
for j in range(len(row)):
if grid[i][j] == 0:
grid[i][j] = [i for i in range(1, n + 1)]
for i in range(len(grid)):
row = grid[i]
for j in range(len(row)):
if isinstance(grid[i][j], int):
place_value(grid, n_rows, n_cols, i, j, grid[i][j])
# 调用递归求解函数进行计算
return sudoku_recursive_solve(grid, n_rows, n_cols)
读者在阅读上面的代码时,建议先阅读函数 sudokuSolver,再阅读递归求解函数 sudoku_recursive_solve,最后再阅读 find_least_uncertain 和 place_value。
上面的代码存在几个问题:
- Python 中将列表直接传递本质上还是同一个列表,所有的递归函数也都共享同一个 grid。最开始我就是考虑到了这一点,所以 place_value 中我直接将修改过的可能值列表存储到一个列表中返回,以便于 sudoku_recursive_solve 中递归调用求解后的回溯,但是这样做存在两个问题,举个例子来说明,假设 place_value 从没有填好的 grid[i][j] 的可能值列表 [1, 2] 中删除了 2,接下来递归调用 sudoku_recursive_solve,如果递归调用返回 None,此时 grid[i][j] 的可能值列表有可能已经在递归调用中变成了空列表 [](注意 Python 中将列表直接传递本质上还是同一个列表),所以代码接下来的回溯就会出现问题,grid[i][j] 的可能值列表会变为 [2],但是回溯的 grid[i][j] 的可能值列表本应该为 [1, 2];还有另外一种可能,在递归调用中,grid[i][j] 的值可能会变成一个数字,而不是可能值列表,即使后续回溯的时候成功改回了可能值列表,但是 grid[i][j] 的值却没有变回可能值列表。
- sudoku_recursive_solve 的 for 循环结束后没有回溯,需要将 grid[row][col] 的值改回可能值列表 candidate
上面的两个问题,本质上还是因为递归函数之间共享 grid,从而共享所有的可能值列表,一个最简单的修改方式就是,在递归调用之前,使用 copy.deepcopy() 拷贝 grid,将拷贝后的数组作为参数传递给递归函数,这样递归函数之间就不共享 grid 了。
但是这样效率较低,另外一种实现方式是,place_value 返回的可能值列表都是拷贝的原可能值列表,在回溯的时候,只需要将对应的可能值列表赋值到 grid 中对应位置即可,具体修改代码见后面的完整源码
使用递归在求解空间中搜索答案的时候,如果调用递归求解函数没有找到解,则需要进行回溯,回溯的时候一定要注意的是递归函数之间是否存在共享的变量,如果存在,那么回溯的时候一定要注意将共享的变量恢复到此次递归调用之前的状态,当然如果调用递归求解函数找到一个解了,也就不需要进行回溯了;另外如果将所有情况递归调用后还是没有找到解,在程序返回之前需要进行回溯,需要回溯到调用该函数最开始时的状态
完整源码
最后附上完整的源码
import time
grid1 = [
[1, 0, 4, 2],
[4, 2, 1, 3],
[2, 1, 3, 4],
[3, 4, 2, 1]]
grid2 = [
[1, 0, 4, 2],
[4, 2, 1, 3],
[2, 1, 0, 4],
[3, 4, 2, 1]]
grid3 = [
[1, 0, 4, 2],
[4, 2, 1, 0],
[2, 1, 0, 4],
[0, 4, 2, 1]]
grid4 = [
[1, 0, 4, 2],
[0, 2, 1, 0],
[2, 1, 0, 4],
[0, 4, 2, 1]]
grid5 = [
[1, 0, 0, 2],
[0, 0, 1, 0],
[0, 1, 0, 4],
[0, 0, 0, 1]]
grid6 = [
[0, 0, 6, 0, 0, 3],
[5, 0, 0, 0, 0, 0],
[0, 1, 3, 4, 0, 0],
[0, 0, 0, 0, 0, 6],
[0, 0, 1, 0, 0, 0],
[0, 5, 0, 0, 6, 4]]
grid7 = [
[0, 2, 0, 0, 0, 0, 0, 1, 0],
[0, 0, 6, 0, 4, 0, 0, 0, 0],
[5, 8, 0, 0, 9, 0, 0, 0, 3],
[0, 0, 0, 0, 0, 3, 0, 0, 4],
[4, 1, 0, 0, 8, 0, 6, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 9, 5],
[2, 0, 0, 0, 1, 0, 0, 8, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 3, 1, 0, 0, 8, 0, 5, 7]]
grid8 = [
[0, 0, 0, 6, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 5, 0, 1],
[3, 6, 9, 0, 8, 0, 4, 0, 0],
[0, 0, 0, 0, 0, 6, 8, 0, 0],
[0, 0, 0, 1, 3, 0, 0, 0, 9],
[4, 0, 5, 0, 0, 9, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 3, 0, 0],
[0, 0, 6, 0, 0, 7, 0, 0, 0],
[1, 0, 0, 3, 4, 0, 0, 0, 0]]
grid9 = [
[8, 0, 9, 0, 2, 0, 3, 0, 0],
[0, 3, 7, 0, 6, 0, 5, 0, 0],
[0, 0, 0, 4, 0, 9, 7, 0, 0],
[0, 0, 2, 9, 0, 1, 0, 6, 0],
[1, 0, 0, 3, 0, 6, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, 3],
[7, 0, 0, 0, 0, 0, 0, 0, 8],
[5, 0, 0, 0, 0, 0, 0, 1, 4],
[0, 0, 0, 2, 8, 4, 6, 0, 5]]
grids = [(grid1, 2, 2), (grid2, 2, 2), (grid3, 2, 2), (grid4, 2, 2), (grid5, 2, 2), (grid6, 2, 3), (grid7, 3, 3), (grid8, 3, 3), (grid9, 3, 3)]
def check_section(section, n):
if len(set(section)) == len(section) and sum(section) == sum([i for i in range(n+1)]):
return True
return False
def get_squares(grid, n_rows, n_cols):
squares = []
for i in range(n_cols):
rows = (i*n_rows, (i+1)*n_rows)
for j in range(n_rows):
cols = (j*n_cols, (j+1)*n_cols)
square = []
for k in range(rows[0], rows[1]):
line = grid[k][cols[0]:cols[1]]
square +=line
squares.append(square)
return squares
def check_solution(grid, n_rows, n_cols):
'''
This function is used to check whether a sudoku board has been correctly solved
args: grid - representation of a suduko board as a nested list.
returns: True (correct solution) or False (incorrect solution)
'''
try:
n = n_rows*n_cols
for row in grid:
if check_section(row, n) == False:
return False
for i in range(n_rows**2):
column = []
for row in grid:
column.append(row[i])
if check_section(column, n) == False:
return False
squares = get_squares(grid, n_rows, n_cols)
for square in squares:
if check_section(square, n) == False:
return False
return True
except:
return False
def find_least_uncertain(grid):
min_uncertain = len(grid) + 1
min_uncertain_pos = None
for i in range(len(grid)):
row = grid[i]
for j in range(len(row)):
if isinstance(grid[i][j], list):
cur_uncertain = len(grid[i][j])
if cur_uncertain == 0:
# 需要注意的是,grid[i][j]可能是一个空列表
# 当find_least_uncertain返回None时,sudoku_recursive_solve会
# 调用check_solution,此时由于grid[i][j]是一个空列表,并且grid中可能存在其他列表,
# 调用check_solution会报错,为了避免这个报错,check_solution里面加入了一个try-except语句
return None
elif cur_uncertain == 1:
return (i, j)
elif cur_uncertain < min_uncertain:
min_uncertain_pos = (i, j)
min_uncertain = cur_uncertain
return min_uncertain_pos
def place_value(grid, n_rows, n_cols, row, col, val):
lists = []
grid[row][col] = val
for j in range(len(grid[0])):
if isinstance(grid[row][j], list) and (val in grid[row][j]):
lists.append((row, j, grid[row][j].copy()))
grid[row][j].remove(val)
for i in range(len(grid)):
if isinstance(grid[i][col], list) and (val in grid[i][col]):
lists.append((i, col, grid[i][col].copy()))
grid[i][col].remove(val)
start_r = (row // n_rows) * n_rows
start_c = (col // n_cols) * n_cols
for r in range(start_r, start_r + n_rows):
for c in range(start_c, start_c + n_cols):
if isinstance(grid[r][c], list) and (val in grid[r][c]):
lists.append((r, c, grid[r][c].copy()))
grid[r][c].remove(val)
return lists
def sudoku_recursive_solve(grid, n_rows, n_cols):
#N is the maximum integer considered in this board
n = n_rows*n_cols
least_uncertain = find_least_uncertain(grid)
if not least_uncertain:
if check_solution(grid, n_rows, n_cols):
return grid
else:
return None
else:
row, col = least_uncertain
# 这里 candidate 不需要赋值为 grid[row][col].copy()
# 因为后面的递归调用是不会修改这个可能值列表的
candidate = grid[row][col]
for val in candidate:
lists = place_value(grid, n_rows, n_cols, row, col, val)
ans = sudoku_recursive_solve(grid, n_rows, n_cols)
if ans:
return ans
for r, c, l in lists:
grid[r][c] = l
grid[row][col] = candidate
return None
def sudokuSolver(grid, n_rows, n_cols):
n = n_rows*n_cols
for i in range(len(grid)):
row = grid[i]
for j in range(len(row)):
if grid[i][j] == 0:
grid[i][j] = [i for i in range(1, n + 1)]
for i in range(len(grid)):
row = grid[i]
for j in range(len(row)):
if isinstance(grid[i][j], int):
place_value(grid, n_rows, n_cols, i, j, grid[i][j])
return sudoku_recursive_solve(grid, n_rows, n_cols)
def main():
for (i, (grid, n_rows, n_cols)) in enumerate(grids):
print("Solving grid: %d" % (i+1))
start_time = time.time()
solution = sudokuSolver(grid, n_rows, n_cols)
elapsed_time = time.time() - start_time
print("Solved in: %f seconds" % elapsed_time)
print(solution)
if check_solution(solution, n_rows, n_cols):
print("grid %d correct" % (i+1))
else:
print("grid %d incorrect" % (i+1))
if __name__ == "__main__":
main()