本教程详细介绍了如何使用Python构建一个数独求解器。文章首先分析了数独求解中的常见问题,特别是文件操作和回溯逻辑的误区。随后,提供了两种核心解决方案:一种是基于回溯算法的通用数独求解器,能够解决任何有效数独;另一种是迭代式“单解”填充器,适用于仅需填充唯一确定单元格的简单数独。教程涵盖了代码实现、原理分析及关键注意事项,旨在帮助读者深入理解数独求解的算法思想。
1. 数独求解器基础:网格表示与有效性检查
数独求解的核心在于两个方面:一是如何表示数独网格,二是如何判断一个数字在特定位置是否有效。
1.1 网格表示
一个标准的9×9数独可以被表示为一个二维列表(或称列表的列表),其中0表示空单元格。
# 示例数独网格 grid = [ [0, 4, 0, 0, 0, 0, 1, 7, 9], [0, 0, 2, 0, 0, 8, 0, 5, 4], [0, 0, 6, 0, 0, 5, 0, 0, 8], [0, 8, 0, 0, 7, 0, 9, 1, 0], [0, 5, 0, 0, 9, 0, 0, 3, 0], [0, 1, 9, 0, 6, 0, 0, 4, 0], [3, 0, 0, 4, 0, 0, 7, 0, 0], [5, 7, 0, 1, 0, 0, 2, 0, 0], [9, 2, 8, 0, 0, 0, 0, 6, 0] ]
main 函数负责从文件读取输入并将其转换为这种网格格式:
import sys def main(): # 从命令行参数获取输入文件路径 with open(sys.argv[1], 'r') as f: s1 = f.read() s2 = s1.split() # 将字符串转换为整数列表 s_int_list = [int(x) for x in s2] # 将一维列表转换为9x9的二维网格 grid = [s_int_list[i:i+9] for i in range(0, len(s_int_list), 9)] # 接下来调用求解函数 # solve(grid) # 具体调用取决于采用哪种求解策略
1.2 有效性检查 (check 函数)
check 函数是数独求解的基石,它判断在给定行 r、列 c 的位置放置数字 k 是否符合数独规则。规则包括:
- 同行不能有重复数字。
- 同列不能有重复数字。
- 同一3×3宫格内不能有重复数字。
def check(grid, r, c, k): # 检查行 for i in range(9): if grid[r][i] == k: return False # 检查列 for i in range(9): if grid[i][c] == k: return False # 检查3x3宫格 # 计算当前单元格所属3x3宫格的左上角坐标 x_area = (c // 3) * 3 y_area = (r // 3) * 3 for i in range(3): for j in range(3): if grid[y_area + i][x_area + j] == k: return False return True
2. 问题分析:原始代码的挑战与局限
原始代码尝试实现一个递归求解器,但存在几个关键问题,导致它只能输出第一步:
立即学习“Python免费学习笔记(深入)”;
- 文件重复打开与覆盖: 在每次递归调用 solve 函数时,都会执行 f = open(sys.argv[2],’w’)。’w’ 模式会清空文件内容,导致每次写入都覆盖之前的内容,最终文件中只保留最后一次写入的数据。
- 文件未关闭: 文件句柄 f 在函数内部打开,但没有显式关闭。虽然 with open(…) 结构能确保文件关闭,但原始代码的 f = open(…) 没有使用 with 语句,可能导致资源泄露或数据未完全写入。
- poss 列表逻辑误解: 原始代码试图通过 len(poss) == 1 来判断是否只有一个可能值,并立即填充。这并没有真正实现“只填充唯一可能值”的逻辑,因为 poss 列表在循环中首次添加元素后,其长度就变为1。更重要的是,这种策略不足以解决所有数独,因为它没有尝试所有可能性,也没有回溯机制。
- 缺乏回溯机制: 当某个尝试的数字无法导致最终解时,原始代码没有将该单元格重置为0(即回溯),而是直接返回 False,导致无法探索其他可能的路径。对于复杂的数独,必须要有回溯才能找到解。
3. 解决方案一:基于回溯法的通用数独求解器
回溯法是一种穷举搜索算法,适用于解决约束满足问题。其核心思想是:尝试一个可能的值,如果发现这条路径无法通向解,就“回溯”到上一步,撤销之前的尝试,然后尝试另一个可能的值。
3.1 原理阐述
- 选择空单元格: 从网格中找到一个空的单元格(值为0)。
- 尝试数字: 遍历数字1到9,对于每个数字 k,使用 check 函数判断其是否可以在当前空单元格放置。
- 递归填充: 如果 k 有效,则将其放入单元格,并递归调用求解函数来填充下一个空单元格。
- 成功与回溯:
- 如果递归调用返回 True(表示后续单元格都成功填充),则当前路径有效,返回 True。
- 如果递归调用返回 False(表示当前 k 无法导致解),则将当前单元格重置为0(回溯),然后尝试下一个数字 k。
- 无解: 如果所有数字1到9都尝试完毕,仍无法找到有效解,则说明当前路径无解,返回 False。
- 终止条件: 如果所有单元格都被填充(即没有空单元格),则表示数独已解决,返回 True。
3.2 代码实现
为了解决文件重复打开和回溯问题,我们将文件操作和计数器变量提升到外部作用域,并使用一个嵌套函数 recur 来实现递归回溯逻辑。
import sys def main(): with open(sys.argv[1], 'r') as f: s1 = f.read() s2 = s1.split() s_int_list = [int(x) for x in s2] grid = [s_int_list[i:i+9] for i in range(0, len(s_int_list), 9)] solve(grid) # 调用新的 solve 函数 def check(grid, r, c, k): # 检查行 for i in range(9): if grid[r][i] == k: return False # 检查列 for i in range(9): if grid[i][c] == k: return False # 检查3x3宫格 x_area = (c // 3) * 3 y_area = (r // 3) * 3 for i in range(3): for j in range(3): if grid[y_area + i][x_area + j] == k: return False return True def solve(grid): # 文件只打开一次,使用 'w' 模式会清空文件,但因为只打开一次,所以不会重复清空 # 更好的做法是使用 'a' 模式(追加)或者在 solve 外部处理文件 # 这里为了与原始需求保持一致,假设每次运行都生成新的输出文件 with open(sys.argv[2], 'w') as f: # 使用 with 语句确保文件关闭 counter = 0 # 计数器只初始化一次 # 嵌套函数实现递归,可以访问外部 solve 函数的 counter 和 f def recur(r, c): nonlocal counter # 声明 counter 为非局部变量,以便修改外部函数的 counter # 基础情况:如果行索引达到9,表示所有行都已处理完毕,数独已解决 if r == 9: return True # 如果列索引达到9,表示当前行已处理完毕,移动到下一行的开头 elif c == 9: return recur(r + 1, 0) # 如果当前单元格不为0(已被填充),则跳过,处理下一个单元格 elif grid[r][c] != 0: return recur(r, c + 1) else: # 尝试填充1到9的数字 for k in range(1, 10): if check(grid, r, c, k): # 检查当前数字 k 是否有效 grid[r][c] = k # 放置数字 counter += 1 # 打印当前步骤到文件 print("-" * 18, "Step " + str(counter) + " - " + str(k) + " @ " + "R" + str(r + 1) + "C" + str(c + 1), "-" * 18, sep='n', file=f) for x in grid: print(" ".join(map(str, x)), file=f) print("-" * 18, file=f) # 递归调用以填充下一个单元格 if recur(r, c + 1): return True # 如果后续填充成功,则当前路径有效 # 回溯:如果所有数字都尝试过,但没有一个能导致解, # 则将当前单元格重置为0,并返回 False grid[r][c] = 0 return False # 从 (0, 0) 开始递归求解 result = recur(0, 0) return result # 返回最终求解结果(True/False) if __name__ == "__main__": main()
关键改进点:
- 文件句柄管理: f = open(sys.argv[2],’w’) 被移动到 solve 函数的开头,并使用 with 语句,确保文件只打开一次且在函数结束时自动关闭。
- 计数器 counter: counter 也被移到 solve 函数内部,并使用 nonlocal 关键字在嵌套函数 recur 中进行修改,确保其累加正确。
- 回溯逻辑: 在 for 循环结束后,如果没有任何数字能成功填充当前单元格并导致解,grid[r][c] = 0 会将单元格重置,实现回溯。
- poss 列表移除: 回溯法不需要预先计算所有可能值,而是直接尝试1-9的每个数字。
4. 解决方案二:迭代式“单解”数独填充器
原始需求中提到“如果有一个单元格只有一个可能的数字,就填入这个数字并打印表格,然后重复这些步骤”。这是一种简化版的数独填充策略,不涉及回溯,只适用于那些可以通过逻辑推理逐步填充的“简单”数独。如果数独需要猜测并回溯,这种方法将无法解决。
4.1 原理阐述
- 循环迭代: 不断循环,直到无法再填充任何单元格或数独被解决。
- 查找唯一解单元格: 在每次迭代中,遍历所有空单元格。对于每个空单元格,尝试数字1到9,找出所有可能的有效数字。
- 填充唯一解: 如果某个空单元格只有一个可能的有效数字,则填充该数字,并打印当前数独状态。
- 终止条件: 如果一轮遍历下来,没有找到任何具有唯一可能解的单元格,则停止。此时,数独可能已解决,或者是一个“复杂”数独,无法通过这种简单方法解决。
4.2 代码实现
import sys # check 函数与 main 函数保持不变,此处省略重复代码 def solve_simple_sudoku(grid): def count_empty_cells(): count = 0 for r in range(9): for c in range(9): if grid[r][c] == 0: count +=1 return count def find_cell_with_one_solution(): """ 在网格中寻找一个空单元格,该单元格只有一个可能的有效填充数字。 如果找到,返回 (行, 列, 唯一数字);否则返回 None。 """ for r in range(9): for c in range(9): if grid[r][c] == 0: # 找到空单元格 poss = [] # 存储当前单元格的所有可能数字 for k in range(1, 10): if check(grid, r, c, k): poss.append(k) if len(poss) == 1: # 如果只有一个可能数字 return r, c, poss[0] return None # 没有找到具有唯一解的空单元格 with open(sys.argv[2], 'w') as f: # 循环次数最多为所有空单元格的数量,因为每次成功填充一个 # 实际可能更少,因为如果无法找到唯一解,会提前退出 for counter in range(count_empty_cells()): result = find_cell_with_one_solution() if not result: # 如果找不到具有唯一解的空单元格 # 如果所有单元格都已填充,则数独已解决 if count_empty_cells() == 0: print("Sudoku solved successfully!", file=f) else: # 否则,这个数独对这种方法来说太复杂了 print("This is not a simple Sudoku puzzle! Requires backtracking.", file=f) return False # 无法通过此方法解决 r, c, k = result # 获取找到的唯一解单元格信息 grid[r][c] = k # 填充数字 # 打印当前步骤和网格状态 print("-" * 18, "Step " + str(counter+1) + " - " + str(k) + " @ " + "R" + str(r + 1) + "C" + str(c + 1), "-" * 18, sep='n', file=f) for x in grid: print(" ".join(map(str, x)), file=f) print("-" * 18, file=f) # 如果循环结束且所有单元格都已填充 if count_empty_cells() == 0: print("Sudoku solved successfully!", file=f) return True else: print("Finished simple filling, but puzzle not fully solved.", file=f) return False # 在 main 函数中调用此 solve_simple_sudoku # if __name__ == "__main__": # # 假设 main 函数已经处理了文件读取和 grid 初始化 # # 然后可以这样调用: # # main() # # 并在 main 函数内部调用 solve_simple_sudoku(grid)
注意事项:
- 此方法不适用于所有数独。如果一个数独需要通过试错和回溯才能解决,find_cell_with_one_solution 将会返回 None,导致程序提前终止,并指出其“不是一个简单数独”。
- counter 在这里表示成功填充的步数,而不是递归调用的次数。
5. 总结与最佳实践
本教程展示了两种不同策略的数独求解器:
- 基于回溯的通用求解器: 适用于解决任何有效数独难题。它通过递归尝试所有可能性并在遇到死胡同时回溯,是解决这类问题的标准算法。文件操作和计数器变量的正确管理是实现的关键。
- 迭代式“单解”填充器: 满足特定需求,即只填充那些具有唯一确定解的单元格。这种方法更直观,但其局限性在于无法解决需要猜测和回溯的复杂数独。
在实际开发中,应根据数独的复杂度和预期行为选择合适的求解策略。对于通用的数独求解需求,回溯法是首选。同时,良好的文件I/O习惯(如使用 with open 语句)和清晰的变量作用域管理(如 nonlocal 关键字)是编写健壮Python代码的重要实践。
评论(已关闭)
评论已关闭