回溯算法+约束问题——从八皇后问题到数独问题(三)

引入:

本节我们将从八皇后问题走向更难的数独问题 进一步领会逻辑编程的概念,深入体会回溯算法。

回顾:

皇后残局问题:在一个 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__":

数独问题:

  1. 在一个 n x n 的棋盘上,每个格子可以填入数字1到n。
  2. 每一行、每一列和每一个小宫格(通常是 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},即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)撤销选择(回溯):每次递归后恢复上一步状态。

我们这三个实验都是围绕这四个步骤进行的!

写在后面:

很开心你能耐着性子读到这里,很荣幸能将我的三脚猫知识分享给大家。

星马也是小白,因此更懂小白的心思,大佬认为一眼明白的代码和思路可能在我们眼中就是鸿沟。这篇文章也还有很多不足之处,或是纰漏,希望你发现了及时在评论区提醒我呀~

(人工智能学院就是每周四五天满课的啦,因此更新基本随缘~)

星马是刚入门的大菜比,有错望指正,有项目可以带带我

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值