引入:
本节我们将从八皇后问题走向更难的数独问题 进一步领会逻辑编程的概念,深入体会回溯算法。
回顾:
皇后残局问题:在一个 n x n 的国际象棋棋盘上,已放置若干皇后且互不攻击,需继续放置剩余皇后至 n 个,满足任意两皇后不在同一行、列或对角线上。
我们先解析初始矩阵,并判断初始矩阵是否冲突,接着使用回溯算法逐行尝试放置皇后,通过冲突检测剪枝无效的分支实现。
def solve_n_queens_with_given_board(n, initial_board):
def is_valid(board, row, col):#约束条件判断
def spin(row):#回溯函数
# 初始化
board = [-1] * n # 初始化当前棋盘状态(-1 表示未放置皇后)
result = [] # 存储所有解
sol_num = 0
# 解析初始棋盘
for r in range(n):
# 检查初始棋盘是否有冲突
for r in range(n):
# 开始回溯
spin(0)
# 输出结果
if sol_num == 0:
无解
else:
输出解
# 示例调用
if __name__ == "__main__":
数独问题:
- 在一个 n x n 的棋盘上,每个格子可以填入数字1到n。
- 每一行、每一列和每一个小宫格(通常是 sqrt(n) x sqrt(x) 大小)内的数字不能重复。
输入:
- 输入一个n,代表棋盘的大小。【n为完全平方数】
- 输入一个二维 n x n 的数独棋盘,其中部分格子已经有数字,其余格子为空(用 '0' 表示)。
4
[
[1, 0, 0, 0],
[0, 0, 0, 2],
[0, 3, 0, 0],
[0, 0, 4, 0]
]
输出:
如果有解,输出所有解状态。
如果无解,输出“无解”。
解法1:
1 4 2 3
3 1 2 4
2 3 4 1
4 2 1 3
共1种解
实现思路:
我们在本次实验中,一个重要思想是相似问题找改变,对改变逐个击破,按照元题补充解法。
接下来我们看看皇后残局问题和数独问题有什么异同,如何解决?
问题对比:皇后问题 vs 数独问题
(1) 约束条件
- 在皇后问题中,皇后不能在同一行、同一列或同一对角线上。
- 在数独问题中,数字不能在同一行、同一列或同一个小宫格内重复。
我们只需要改变is_valid()冲突检测函数就够了。
(2) 解空间表示
- 皇后问题通常用一维数组表示
- 数独问题通常用二维数组表示
这个相对复杂一些,数据结构改变了,意味着从最开始的容器初始化到数据处理都要改变,是本节核心。
一、初始化与解析:
Ⅰ 定义容器
从输入推手段:
输入是二维数组,而接收二维数组已经是老生常谈了。
问题在于,当我们解析出某个位置有值应该如何有效保存?用什么数据结构存储?
从需求推手段:
这些数字会在什么时候使用?就像八皇后问题一样!——就是作为约束条件!
判断某个位置新的数字是否违背约束条件。
于是我们考虑:如何描述当前位置的约束条件?
显然要知道这一行,这一列,其所处的小宫格已经填写了那些数字?排除后才能填入。
为了存储这些数据,我们需要对每一行、每一列、每一个子宫格建表,每次放入新数字都要遍历这三个表格。
但如果n一大,建立有顺序的表格再查找显然是个很大的开销,有没有更好的方案?
存储容器——列表[集合set()]
集合! 我们只需要判断这一行的集合有没有这个元素即可,并不需要知道元素具体在哪。
Python内部,集合的底层逻辑实际上是哈希表,他的查找存储速率是极快的,大大减少了开销!
接下来问题回到如何定义行、列、子宫格的集合呢?
显然由于每一行都要定义一个集合,对于行 需要n个set。同时,行与行间是有顺序的,因此必须用列表处理,于是我们通过 for _ in range(n) 定义:
rows = [set() for _ in range(n)]
cols = [set() for _ in range(n)]
这里rows和cols是一个以n个set为元素的列表,当n=4时,内部结构示例:
如此就构建了存储行和列的容器。
那么小宫格如何表示?
小宫格不过是相当于一个不可以有重复元素的区域set,但定位这个小宫格则需要横纵坐标,因此我们考虑用grids定义一个二维数组存储set:
grids = [[set() for _ in range(sqrt_n)] for _ in range(sqrt_n)]
每个小宫格都有n个元素,因此其长和宽都为,即sqrt(n)。
通过两次for _ in range(sqrt(n))构建存储小宫格的容器。n=4时,内部结构如图:
至此,我们已经定义好了容器,接下来就要接收初始二维数组了~
Ⅱ 解析二维数组
和C一样,使用for嵌套依次判断输入数组中当前位置是否有元素。
如果有元素,则将其保存在对应行,对应列,所属小宫格容器中。
def init_board():
"""初始化棋盘状态"""
for r in range(n):
for c in range(n):
if board[r][c] != 0:
num = board[r][c]
rows[r].add(num)
cols[c].add(num)
grids[r // sqrt_n][c // sqrt_n].add(num)
rows[r]就是第r行的集合,可以用X.add向其中添加元素。
至此,初始化与解析输入已经完成了!
二、回溯函数:
回溯函数的第一步就是检查当前是否满足结束条件。
考虑遍历方式,由于 数独问题 是 每个元素都要满足横纵小宫格的条件,因此必须一个一个各自遍历,而皇后问题是每一行有一个元素满足即可,因此是一行一行遍历。
那用什么遍历?
惯性思维会希望我们使用二维坐标表示,但是这样有几个问题:
①回溯函数迭代的时候如何切换到下一行? ②边界判断很麻烦 ③不易拓展到其他规格棋盘
因此我们考虑用一个类似指针的变量pos,表示遍历到第几个元素了。
但在进行解状态记录和小宫格遍历的时候,却还是需要用到二维坐标,怎么办呢?
一维转二维技巧
那么如何通过一个一维指针表达二维坐标——考虑转换公式,
C中我们有互化公式:
x = pos/n; y = pos%n;
pos = n*x + y;
生动形象理解一下:
在Python中我们有更方便的方式——divmod(x,y):
对于给定的两个数 x(被除数)和 y(除数),他会返回一个包含两个元素的元组:
第一个元素是 x//y 整除商,第二个元素是 x%y 余数。
其结果正好对应我们需要的二维坐标!!!
r, c = divmod(pos, n) # 当前格子的位置 (r, c)
接下来就是皇后问题的老思路了
先判断pos是不是最后一个格子:
if pos == n * n: # 如果所有格子都填满
return True
判断当前格子是否为空?如果当前位置已有数字则跳过,直接迭代获取下一个格子
if board[r][c] != 0: # 如果当前位置已经有数字,跳过
return spin(pos + 1)
如果没有则开始从1到n填数字,看看符不符合约束条件:
# 尝试填充数字 1 到 n
for num in range(1, n + 1):
if is_valid(r, c, num): # 剪枝:只有有效数字才继续
如果符合,则更新当前行、列、小宫格的内容
# 尝试填充数字 1 到 n
for num in range(1, n + 1):
if is_valid(r, c, num): # 剪枝:只有有效数字才继续
board[r][c] = num
rows[r].add(num)
cols[c].add(num)
grids[r // sqrt_n][c // sqrt_n].add(num)
接着是对深度优先一个很重要的理解点:
我们要迭代探索下一个格子了。
但为什么最后这一句是判断语句,而不是直接调用spin(pos+1)?
# 尝试填充数字 1 到 n
for num in range(1, n + 1):
if is_valid(r, c, num): # 剪枝:只有有效数字才继续
board[r][c] = num
rows[r].add(num)
cols[c].add(num)
grids[r // sqrt_n][c // sqrt_n].add(num)
if spin(pos + 1): # 递归处理下一个格子
return True
else
# 回溯,撤销选择
board[r][c] = 0
rows[r].remove(num)
cols[c].remove(num)
grids[r // sqrt_n][c // sqrt_n].remove(num)
我们看这张节点图,红色三角是当前节点,有数值可填,于是探索下一个节点(pos+1),也有数值可填。然而探索到(pos+2)赋值A时却发现约束条件无法满足了,于是只能返回Flase,退回到(pos+1)。(pos+1)发现还有另一条路通向(pos+2),然而赋值为B后也发现约束条件不满足,只好退到(pos+1),此时(pos+1)无路可走,只能返回spin(pos+1)=Flase。
pos一瞅,我虽然有值可填,但之后无论填什么都无解,只好返回上一节点尝试其他值了。
字有点多,但这就是回溯算法的核心,请多读结合流程图理解!!!
如果spin(pos+1)=Flase,只好执行后面的回溯操作,清除board这一个格子的状态,并清除行、列、小宫格的记录。
最后是处理这个节点始终无值可填,则返回Flase回溯上一个节点。
for num in range(1, n + 1):
#无值可填
return False # 如果无法找到解,返回 False
完整代码:
def spin(pos):
"""回溯函数"""
r, c = divmod(pos, n) # 当前格子的位置 (r, c)
if pos == n * n: # 如果所有格子都填满
return True
if board[r][c] != 0: # 如果当前位置已经有数字,跳过
return backtrack(pos + 1)
# 尝试填充数字 1 到 n
for num in range(1, n + 1):
if is_valid(r, c, num): # 剪枝:只有有效数字才继续
board[r][c] = num
rows[r].add(num)
cols[c].add(num)
grids[r // sqrt_n][c // sqrt_n].add(num)
if spin(pos + 1): # 递归处理下一个格子
return True
else
# 回溯,撤销选择
board[r][c] = 0
rows[r].remove(num)
cols[c].remove(num)
grids[r // sqrt_n][c // sqrt_n].remove(num)
return False # 如果无法找到解,返回 False
三、约束条件判断
其实约束条件判断的思路在第一个板块就讲过了:
“显然要知道这一行,这一列,其所处的小宫格已经填写了那些数字?排除后才能填入。”
和皇后问题一样,依然要传入当前坐标(r,c)以及要判断的元素num。
在Python中判断某一元素是否在容器中可以用not in关键字:只有当前要填的数字num都不在行、列、小宫格容器中才返回True。
行对应元组是rows[r],列对应元组是cols[c],小宫格对应元组是grids[r // sqrt_n][c // sqrt_n]。
def is_valid(r, c, num):
"""检查数字 num 是否可以放在位置 (r, c)"""
return (
num not in rows[r] and
num not in cols[c] and
num not in grids[r // sqrt_n][c // sqrt_n]
)
四、封装
最后是将以上这些函数以及初始化操作封装在一起:
def solve_sudoku(board,n):
sqrt_n = int(math.sqrt(n))#计算根号n
# 初始化容器
rows = [set() for _ in range(n)]
cols = [set() for _ in range(n)]
grids = [[set() for _ in range(sqrt_n)] for _ in range(sqrt_n)]
def init_board():
"""解析输入:记录每行、每列和每个小宫格中已有的数字"""
def is_valid(r, c, num):
"""检查数字 num 是否可以放在位置 (r, c)"""
def backtrack(pos):
"""回溯函数"""
#####################################
调用后从这里开始执行,以上是函数声明
# 初始化棋盘状态
init_board()
# 开始回溯
if spin(0):
return board
else:
return "无解"
于是,这个封装函数只会返回有解时的解状态board[]或者“无解”。
五、调用与输出
在外部我们对其传入n和初始矩阵,接收其返回值,判断是否有解即可。
solution = solve_sudoku(sudoku_board)
有解则从solution[]记录的解中一行一行输出即可,solution此时就是board[],和初始化矩阵suduku_board结构一样,因此可以一行一行输出。
solution = [
[1, 2, 3, 4],
[3, 4, 1, 2],
[4, 3, 2, 1],
[2, 1, 4, 3]
]
无解则输出“无解”。
if __name__ == "__main__":
# 输入一个 4×4 的数独棋盘
n = 4
sudoku_board = [
[1, 0, 0, 0],
[0, 0, 0, 2],
[0, 3, 0, 0],
[0, 0, 4, 0]
]
print("初始棋盘:")
for row in sudoku_board:
print(row)
solution = solve_sudoku(sudoku_board, n)
if solution == "无解":
print("无解")
else:
print("\n解决方案:")
for row in solution:
print(row)
完整代码:
import math
def solve_sudoku(board, n):
sqrt_n = int(math.sqrt(n))
# 预处理:记录每行、每列和每个小宫格中已有的数字
rows = [set() for _ in range(n)]
cols = [set() for _ in range(n)]
grids = [[set() for _ in range(sqrt_n)] for _ in range(sqrt_n)]
def init_board():
"""初始化棋盘状态"""
for r in range(n):
for c in range(n):
if board[r][c] != 0:
num = board[r][c]
rows[r].add(num)
cols[c].add(num)
grids[r // sqrt_n][c // sqrt_n].add(num)
def is_valid(r, c, num):
"""检查数字 num 是否可以放在位置 (r, c)"""
return (
num not in rows[r] and
num not in cols[c] and
num not in grids[r // sqrt_n][c // sqrt_n]
)
def backtrack(pos):
"""回溯函数"""
if pos == n * n: # 如果所有格子都填满
return True
r, c = divmod(pos, n) # 当前格子的位置 (r, c)
if board[r][c] != 0: # 如果当前位置已经有数字,跳过
return backtrack(pos + 1)
# 尝试填充数字 1 到 n
for num in range(1, n + 1):
if is_valid(r, c, num): # 剪枝:只有有效数字才继续
board[r][c] = num
rows[r].add(num)
cols[c].add(num)
grids[r // sqrt_n][c // sqrt_n].add(num)
if backtrack(pos + 1): # 递归处理下一个格子
return True
# 回溯,撤销选择
board[r][c] = 0
rows[r].remove(num)
cols[c].remove(num)
grids[r // sqrt_n][c // sqrt_n].remove(num)
return False # 如果无法找到解,返回 False
# 初始化棋盘状态
init_board()
# 开始回溯
if backtrack(0):
return board
else:
return "无解"
# 示例调用
if __name__ == "__main__":
# 输入一个 4×4 的数独棋盘
n = 4
sudoku_board = [
[1, 0, 0, 0],
[0, 0, 0, 2],
[0, 3, 0, 0],
[0, 0, 4, 0]
]
print("初始棋盘:")
for row in sudoku_board:
print(row)
solution = solve_sudoku(sudoku_board, n)
if solution == "无解":
print("无解")
else:
print("\n解决方案:")
for row in solution:
print(row)
小结:
本次实验,我们从最基础的皇后问题,研究了如何排除无效解,加快效率的剪枝法,以及其使用场景。进一步我们研究了皇后残局问题,学习了二维数组如何用一维数组接收,迭代器的用法。最后打了大Boss——数独问题,深入了解如何根据需求定义数据类型,如何解析输入。
最重要的是,同时根据逻辑编程的框架构建了三个问题的解题思路,连续用了三次回溯法,并通过两次图解解释了回溯法如何运作的,为什么要回溯?
Ⅰ 组合容器的生成[set() for _ in range(n)]
Ⅱ 一维与二维的转换——divmod函数
Ⅲ 我们最后一起复习一下逻辑编程的范式和回溯法的步骤:
逻辑编程范式
由于python是解释性语言,因此我们采用回溯法自行实现求解器。
回溯法步骤
“ 选择 - 约束 - 回溯 ”
(1)定义解空间(描述解):用什么向量表示问题的解?向量中的每个变量如何取值?
(2)确定解空间的结构(画地图、做选择):子集树?排列树? m叉树?以及每个节点和边的含义。
(3)确定剪枝函数(约束):提前终止不符合条件的分支,以深度优先搜索解空间。
(4)撤销选择(回溯):每次递归后恢复上一步状态。
我们这三个实验都是围绕这四个步骤进行的!
写在后面:
很开心你能耐着性子读到这里,很荣幸能将我的三脚猫知识分享给大家。
星马也是小白,因此更懂小白的心思,大佬认为一眼明白的代码和思路可能在我们眼中就是鸿沟。这篇文章也还有很多不足之处,或是纰漏,希望你发现了及时在评论区提醒我呀~
(人工智能学院就是每周四五天满课的啦,因此更新基本随缘~)
星马是刚入门的大菜比,有错望指正,有项目可以带带我