首页 > Python算法 > 回溯算法 阅读数:38

数独问题—回溯算法经典例题

之前我们已经简单地说明了怎样用回溯算法解决数独的问题,思路如下:
  1. 从第一个空格开始。依次尝试 1 到 9 的数字,如果数字与盘面冲突就换成下一个数字,如果不冲突就去往第二个空格;
  2. 在第二个空格,同样依次尝试 1 到 9 的数字,如果与盘面冲突就换成下一个数字,如果不冲突就去往第三个空格,以此类推;
  3. 如果当前空格 1 到 9 的数字都填不了,就返回到上一个空格,再依次尝试没有试过的数字,如果与盘面冲突就换成下一个数字,如果不冲突就去往下一个空格;
  4. 当最后一个空格被成功填上数字时,我们将答案加入答案列表。

如果第一个空格填 1 到 9 都不成立,那么问题肯定没有解,在这种情况下返回的结果列表为空。

数独和 N 皇后十分相似,也是一个“前进—后退—前进—后退”的过程,只不过 N 皇后是一行一行地尝试,而数独是一个空格一个空格地尝试。因此我们同样利用 helper() 方法帮助我们实现递归的代码结构。

我们定义 helper(x) 方法去负责填满第 x 格到最后一格的所有空格,因此它在填完当前格后会调用自己,让 helper(x+1) 方法继续填满下一个到最后一个空格。

在解题过程中,我们直接用传入的盘面作为我们动态的题解。盘面是一个 9×9 的二维数组,已知的数字被标记出来,空格用 0 表示。在改变盘面的时候,我们需要注意不能将已知数字与后期填的数字弄混。

我们还需要记录当前空格的坐标,用于改变盘面中的数字。我们有两个选择。
  1. 用长度为 2 的 [i,j] 数组表示,如 [1,2] 代表第二行的第三个空格。
  2. 用一个数字变量表示。如图 1 所示,第 1 格的 index 是 0,第 2 格的 index 是 1,第 80 格的 index 是 79,第 81 格的 index 是 80。如果给定一个坐标,比如 55,通过除以 9 并取余数,我们就能得知 55 代表的是第 7 行的第 2 个数。
盘面中每一个空格的坐标
图 1:盘面中每一个空格的坐标

用数字变量记录空格坐标更方便,所以我们选择这种方式。

我们定义 sudoku() 方法,输入一个 2 维数组表示盘面,sudoku() 方法负责输出所有可能的答案。

输入盘面:
[[5 , 3 , 0 , 0 , 7 , 0 , 0 , 0 , 0 ],
[ 6 , 0 , 0 , 1 , 9 , 5 , 0 , 0 , 0 ],
[ 0 , 9 , 8 , 0 , 0 , 0 , 0 , 6 , 0 ],
[ 8 , 0 , 0 , 0 , 6 , 0 , 0 , 0 , 3 ],
[ 4 , 0 , 0 , 8 , 0 , 3 , 0 , 0 , 1 ],
[ 7 , 0 , 0 , 0 , 2 , 0 , 0 , 0 , 6 ],
[ 0 , 6 , 0 , 0 , 0 , 0 , 2 , 8 , 0 ],
[ 0 , 0 , 0 , 4 , 1 , 9 , 0 , 0 , 5 ],
[ 0 , 0 , 0 , 0 , 8 , 0 , 0 , 7 , 9 ]]

sudoku() 方法输出:
[
[[5, 3, 4, 6, 7, 8, 9, 1, 2],
[6, 7, 2, 1, 9, 5, 3, 4, 8],
[1, 9, 8, 3, 4, 2, 5, 6, 7],
[8, 5, 9, 7, 6, 1, 4, 2, 3],
[4, 2, 6, 8, 5, 3, 7, 9, 1],
[7, 1, 3, 9, 2, 4, 8, 5, 6],
[9, 6, 1, 5, 3, 7, 2, 8, 4],
[2, 8, 7, 4, 1, 9, 6, 3, 5],
[3, 4, 5, 2, 8, 6, 1, 7, 9]]
]

以下是完整的代码,其中,index 代表当前格的坐标。copy.deepcopy() 是深度复制盘面的方法。
import copy
#输出board所有的答案
def sudoku(board):
    #检查当前数字在盘面中是否成立
    #i是当前行,j是当前列,num是当前数字
    def checkBoard(i,j,num):
        for t in range(9):            #检查行
            if t!=j and board[i][t] == num:
                return False
            if t!= i and board[t][j] == num:   #检查列
                return False
        for t in range(i-i%3, 3+i-i%3):     #检查3×3区域
            for s in range(j-j%3, 3+j-j%3):
                if t!=i and s!=j and board[t][s] == num:
                    return False
        return True
#填满盘面中第index格到最后一个格
def helper(index):
    if index==81:              #边界条件
        solution= copy.deepcopy(board)    #深度复制当前盘面,放到res列表里
        res.append(solution)
        return               #返回到上一次被调用的地方(第80个空格)
    i = index//9              #当前行
    j = index%9               #当前列
    if board[i][j]==0:            #如果当前格没有已知数字
        for num in range(1,10):       #依次尝试1~9
            board[i][j] = num
            if checkBoard(i,j,num):
                helper(index+1)       #去往下一个空格
                board[i][j] = 0         #将空格重新变空
            else:                #如果当前格是盘面自带的数字
                helper(index+1)
res = []
helper(0)                  #从第一个空格开始
return res

数独问题和 N 皇后问题的方法结构十分相似,都有 checkBoard() 和 helper() 两个子方法。checkBoard() 的作用是检查当前盘面是否成立。helper() 方法负责填满从当前格到最后一格的所有空格。

checkBoard() 方法的三个参数,分别是当前行、当前列和当前数字。因为我们每一次填一个新空格的时候都会检查盘面,所以在填当前数字之前的盘面毋庸置疑是成立的。因此,我们只需要检查当前数字在当前行和当前列是不是独一无二的,和当前数字在它属于的 3×3 区域是不是独一无二的就可以。

helper() 方法负责填满当前格到最后一格的所有空格。它在填完当前格后会自己调用自己,让 helper(index+1) 方法继续填下一个空格到最后一个的所有空格。在填空格之前,它会首先检查有没有剩余空格可填。如果 index<81,就说明有剩余的空格。如果 index=81,盘面肯定已满,这时我们将当前盘面深度复制,加入到结果列表。
def helper(index):           #helper方法帮我们填满盘面
    if index==81:           #如果盘面已满,把当前盘面深度复制
        solution= copy.deepcopy(board)
        res.append(solution)      #放到结果列表里
        return             #返回到上一次被调用的地方(第80个空格)

solution 是 helper() 方法里面的一个局部变量,它用来复制当前盘面。如果我们不深度复制当前盘面的话,会发现到最后所有的答案都是一样的,并且全部是错误的。这是因为盘面(board)一直在动态地变化,如果我们直接 res.append(board) 的话,被复制的是指向 board 的指针,而不是当前 board 的数值。但是如果我们深度复制的话,solution 就是当前 board 数值的复制,这才是我们想要的。

接下来我们来理解最后一行 return 的作用。return 表示从当前方法跳出来。

当前 index 等于 81,相当于我们在不存在的“第 82 格”里。我们是从第 81 格来的,所以会被返回到第 81 格,那时我们最后一次调用 helper() 方法的地方。

因为第 81 格是最后一个空格,所以它只有一个可选择数字。因此我们会走到 for 循环的尽头,然后被返回到第 80 格。

如图 2 所示,假设第 80 格中的当前数字是 7,这意味着我们还没有尝试过 8 和 9。从第 81 格跳出来后,遵循解法的步骤,我们会继续尝试 8 和 9。但是,这两个数字肯定不满足盘面,因为仔细想想,第 80 格也只能有一个可选择数字。8 和 9 尝试失败后,我们会返回到第 79 格。
helper(80)返回到helper(79)(坐标减1)
图 2:helper(80) 返回到 helper(79)(坐标减1)

如图 3 所示,假设第 79 格中的数字是 4,当我们从第 80 格跳回到第 79 格的时候,我们会从 5 继续尝试,再次去往第 80 格。
helper(79)返回到helper(78)(坐标减1)
图 3:helper(79) 返回到 helper(78)(坐标减1)

return 最终的作用是确保算法尝试盘面所有可能,毕竟有些问题不止有一个解。如果只想得到一个解,那么可以考虑 return 一个布尔值,那样便可从整个方法中跳出来。

下面我们来看 index 不等于 81 时的情况。
for num in range(1,10):      #依次尝试1~9
    board[i][j] = num
    if checkBoard(i,j,num):    #如果数字成立
        helper(index+1)
    board[i][j] = 0        #把当前格还原成空格

如果数字成立,我们就去往下一个空格。如果数字不成立,就换下一个数字。如果1~9的数字都不成立,我们把当前格还原成空格,退回到上一格。

我们先把数字填到盘面里,board[i][j]=num,然后再做检查,如果检查不合格,就把格子清空, board[i][j]=0,继续尝试下一个数字。读者可能会疑惑,为什么要把格子先清空再填数字,直接让新数字覆盖上一个数字不是更方便吗?

答案是,想象 1~9 都不可行的情况,这时当前格里的数字是 9。接着我们会退回到上一格尝试下一个数字。当我们尝试 9 的时候,checkBoard 会返回 False,因为当前格的 9 没有被清除。这并不是我们期望看到的,因此我们需要加上 board[i][j]=0。当然,也可以这样做:
for num in range(1,10):
    board[i][j] = num
    if checkBoard(i,j,num):    #当num等于1~8的时候,自动覆盖
        helper(index+1)
    if num==9:           #当num等于8的时候,清空格子
        board[i][j] = 0