leetcode常规算法题复盘(第十一期)——解数独
题目原文
37. 解数独
编写一个程序,通过填充空格来解决数独问题。
一个数独的解法需遵循如下规则:
- 数字
1-9
在每一行只能出现一次。 - 数字
1-9
在每一列只能出现一次。 - 数字
1-9
在每一个以粗实线分隔的3x3
宫内只能出现一次。
空白格用 '.'
表示。
一个数独。
答案被标成红色。
提示:
- 给定的数独序列只包含数字
1-9
和字符'.'
。 - 你可以假设给定的数独只有唯一解。
- 给定数独永远是
9x9
形式的。
尝试解答
传统回溯以点到为止,不要搞窝里斗,我一看不报错了,啪的一下就提交了!很快啊!然后就AC了,全AC了啊~(啪!少废话!)/捂脸
1 class Solution: 2 def solveSudoku(self, board): 3 """ 4 Do not return anything, modify board in-place instead. 5 """ 6 _nums = 0 7 res = [] 8 for i in range(9): 9 for j in range(9): 10 if board[i][j]==".": 11 _nums += 1 12 res.append([i,j]) 13 14 def return_ord(n): 15 #n不能超过_nums 16 #输入值为从左上开始的空格 17 #返回的是所在的board的第n个空格的坐标 18 return res[n-1] 19 def check(row,col): 20 #返回在此位置可以填的数 21 #没有就返回空列表 22 #先找row 23 cur = ["1","2","3","4","5","6","7","8","9"] 24 for i in board[row]: 25 if i!=".": 26 cur.remove(i) 27 #再找col 28 for i in range(9): 29 if board[i][col]!="." and (board[i][col] in cur): 30 cur.remove(board[i][col]) 31 #最后找九宫格 32 if row%3==0: 33 q = [row,row+1,row+2] 34 if row%3==1: 35 q = [row-1,row,row+1] 36 if row%3==2: 37 q = [row-2,row-1,row] 38 if col%3==0: 39 r = [col,col+1,col+2] 40 if col%3==1: 41 r = [col-1,col,col+1] 42 if col%3==2: 43 r = [col-2,col-1,col] 44 for i in q: 45 for j in r: 46 if board[i][j]!="." and (board[i][j] in cur): 47 cur.remove(board[i][j]) 48 return cur 49 def backTrack(n): 50 if n==_nums: 51 if check(return_ord(n)[0],return_ord(n)[1]): 52 board[return_ord(n)[0]][return_ord(n)[1]]=check(return_ord(n)[0],return_ord(n)[1])[0] 53 return True 54 else: 55 return False 56 ans = check(return_ord(n)[0],return_ord(n)[1]) 57 if len(ans)==0: 58 return False 59 else: 60 while len(ans)>0: 61 board[return_ord(n)[0]][return_ord(n)[1]] = ans[0] #改改改 62 ans.pop(0) 63 if backTrack(n+1): 64 return True 65 board[return_ord(n)[0]][return_ord(n)[1]] = "." 66 return False 67 backTrack(1) 68 print(board) 69
这段时间复杂度应该比较难优化了,在剪枝方面有两点可以优化,一是可以记录下每一次试数的结果,降低运算成本(我的方法没有记录前后试验的board);二是先试验最容易确定的空位(我是直接一行一行往下试的,老菜鸡了)
标准题解
前言
我们可以考虑按照「行优先」的顺序依次枚举每一个空白格中填的数字,通过递归 + 回溯的方法枚举所有可能的填法。当递归到最后一个空白格后,如果仍然没有冲突,说明我们找到了答案;在递归的过程中,如果当前的空白格不能填下任何一个数字,那么就进行回溯。
由于每个数字在同一行、同一列、同一个九宫格中只会出现一次,因此我们可以使用 line[i]\textit{line}[i]line[i],column[j]\textit{column}[j]column[j],block[x][y]\textit{block}[x][y]block[x][y] 分别表示第 iii 行,第 jjj 列,第 (x,y)(x, y)(x,y) 个九宫格中填写数字的情况。在下面给出的三种方法中,我们将会介绍两种不同的表示填写数字情况的方法。
九宫格的范围为 0≤x≤20 \leq x \leq 20≤x≤2 以及 0≤y≤20 \leq y \leq 20≤y≤2。
具体地,第 iii 行第 jjj 列的格子位于第 (⌊i/3⌋,⌊j/3⌋)(\lfloor i/3 \rfloor, \lfloor j/3 \rfloor)(⌊i/3⌋,⌊j/3⌋) 个九宫格中,其中 ⌊u⌋\lfloor u \rfloor⌊u⌋ 表示对 uuu 向下取整。
由于这些方法均以递归 + 回溯为基础,算法运行的时间(以及时间复杂度)很大程度取决于给定的输入数据,而我们很难找到一个非常精确的渐进紧界。因此这里只给出一个较为宽松的渐进复杂度上界 O(99×9)O(9^{9 \times 9})O(99×9),即最多有 9×99 \times 99×9 个空白格,每个格子可以填 [1,9][1, 9][1,9] 中的任意整数。
方法一:递归
思路
最容易想到的方法是用一个数组记录每个数字是否出现。由于我们可以填写的数字范围为 [1,9][1, 9][1,9],而数组的下标从 000 开始,因此在存储时,我们使用一个长度为 999 的布尔类型的数组,其中 iii 个元素的值为 True\text{True}True,当且仅当数字 i+1i+1i+1 出现过。例如我们用 line[2][3]=True\textit{line}[2][3] = \text{True}line[2][3]=True 表示数字 444 在第 222 行已经出现过,那么当我们在遍历到第 222 行的空白格时,就不能填入数字 444。
算法
我们首先对整个数独数组进行遍历,当我们遍历到第 iii 行第 jjj 列的位置:
如果该位置是一个空白格,那么我们将其加入一个用来存储空白格位置的列表中,方便后续的递归操作;
如果该位置是一个数字 xxx,那么我们需要将 line[i][x−1]\textit{line}[i][x-1]line[i][x−1],column[j][x−1]\textit{column}[j][x-1]column[j][x−1] 以及 block[⌊i/3⌋][⌊j/3⌋][x−1]\textit{block}[\lfloor i/3 \rfloor][\lfloor j/3 \rfloor][x-1]block[⌊i/3⌋][⌊j/3⌋][x−1] 均置为 True\text{True}True。
当我们结束了遍历过程之后,就可以开始递归枚举。当递归到第 iii 行第 jjj 列的位置时,我们枚举填入的数字 xxx。根据题目的要求,数字 xxx 不能和当前行、列、九宫格中已经填入的数字相同,因此 line[i][x−1]\textit{line}[i][x-1]line[i][x−1],column[j][x−1]\textit{column}[j][x-1]column[j][x−1] 以及 block[⌊i/3⌋][⌊j/3⌋][x−1]\textit{block}[\lfloor i/3 \rfloor][\lfloor j/3 \rfloor][x-1]block[⌊i/3⌋][⌊j/3⌋][x−1] 必须均为 False\text{False}False。
当我们填入了数字 xxx 之后,我们要将上述的三个值都置为 True\text{True}True,并且继续对下一个空白格位置进行递归。在回溯到当前递归层时,我们还要将上述的三个值重新置为 False\text{False}False。
代码
1 class Solution: 2 def solveSudoku(self, board: List[List[str]]) -> None: 3 def dfs(pos: int): 4 nonlocal valid 5 if pos == len(spaces): 6 valid = True 7 return 8 9 i, j = spaces[pos] 10 for digit in range(9): 11 if line[i][digit] == column[j][digit] == block[i // 3][j // 3][digit] == False: 12 line[i][digit] = column[j][digit] = block[i // 3][j // 3][digit] = True 13 board[i][j] = str(digit + 1) 14 dfs(pos + 1) 15 line[i][digit] = column[j][digit] = block[i // 3][j // 3][digit] = False 16 if valid: 17 return 18 19 line = [[False] * 9 for _ in range(9)] 20 column = [[False] * 9 for _ in range(9)] 21 block = [[[False] * 9 for _a in range(3)] for _b in range(3)] 22 valid = False 23 spaces = list() 24 25 for i in range(9): 26 for j in range(9): 27 if board[i][j] == ".": 28 spaces.append((i, j)) 29 else: 30 digit = int(board[i][j]) - 1 31 line[i][digit] = column[j][digit] = block[i // 3][j // 3][digit] = True 32 33 dfs(0) 34 35 36 #作者:LeetCode-Solution 37 #链接:https://leetcode-cn.com/problems/sudoku-solver/solution/jie-shu-du-by-leetcode-solution/ 38 #来源:力扣(LeetCode) 39 #著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
方法二:位运算优化
思路与算法
在方法一中,我们使用了长度为 999 的数组表示每个数字是否出现过。我们同样也可以借助位运算,仅使用一个整数表示每个数字是否出现过。
具体地,数 bbb 的二进制表示的第 iii 位(从低到高,最低位为第 000 位)为 111,当且仅当数字 i+1i+1i+1 已经出现过。例如当 bbb 的二进制表示为 (011000100)2(011000100)_2(011000100)2 时,就表示数字 333,777,888 已经出现过。
位运算有一些基础的使用技巧。下面列举了所有在代码中使用到的技巧:
对于第 iii 行第 jjj 列的位置,line[i] ∣ column[j] ∣ block[⌊i/3⌋][⌊j/3⌋]\textit{line}[i] ~|~ \textit{column}[j] ~|~ \textit{block}[\lfloor i/3 \rfloor][\lfloor j/3 \rfloor]line[i] ∣ column[j] ∣ block[⌊i/3⌋][⌊j/3⌋] 中第 kkk 位为 111,表示该位置不能填入数字 k+1k+1k+1(因为已经出现过),其中 ∣|∣ 表示按位或运算。如果我们对这个值进行 ∼\sim∼ 按位取反运算,那么第 kkk 位为 111 就表示该位置可以填入数字 k+1k+1k+1,我们就可以通过寻找 111 来进行枚举。由于在进行按位取反运算后,这个数的高位也全部变成了 111,而这是我们不应当枚举到的,因此我们需要将这个数和 (111111111)2=(1FF)16(111111111)_2 = (\text{1FF})_{16}(111111111)2=(1FF)16 进行按位与运算 &\&&,将所有无关的位置为 000;
我们可以使用按位异或运算 ∧\wedge∧,将第 iii 位从 000 变为 111,或从 111 变为 000。具体地,与数 1<<i1 << i1<<i 进行按位异或运算即可,其中 <<<<<< 表示左移运算;
我们可以用 b & (−b)b ~\&~ (-b)b & (−b) 得到 bbb 二进制表示中最低位的 111,这是因为 (−b)(-b)(−b) 在计算机中以补码的形式存储,它等于 ∼b+1\sim b + 1∼b+1。bbb 如果和 ∼b\sim b∼b 进行按位与运算,那么会得到 000,但是当 ∼b\sim b∼b 增加 111 之后,最低位的连续的 111 都变为 000,而最低位的 000 变为 111,对应到 bbb 中即为最低位的 111,因此当 bbb 和 ∼b+1\sim b + 1∼b+1 进行按位与运算时,只有最低位的 111 会被保留;
当我们得到这个最低位的 111 时,我们可以通过一些语言自带的函数得到这个最低位的 111 究竟是第几位(即 iii 值),具体可以参考下面的代码;
我们可以用 bbb 和最低位的 111 进行按位异或运算,就可以将其从 bbb 中去除,这样就可以枚举下一个 111。同样地,我们也可以用 bbb 和 b−1b-1b−1 进行按位与运算达到相同的效果,读者可以自行尝试推导。
实际上,方法二中整体的递归 + 回溯的框架与方法一是一致的。不同的仅仅是我们将一个数组「压缩」成了一个数而已。
代码
1 class Solution: 2 def solveSudoku(self, board: List[List[str]]) -> None: 3 def flip(i: int, j: int, digit: int): 4 line[i] ^= (1 << digit) 5 column[j] ^= (1 << digit) 6 block[i // 3][j // 3] ^= (1 << digit) 7 8 def dfs(pos: int): 9 nonlocal valid 10 if pos == len(spaces): 11 valid = True 12 return 13 14 i, j = spaces[pos] 15 mask = ~(line[i] | column[j] | block[i // 3][j // 3]) & 0x1ff 16 while mask: 17 digitMask = mask & (-mask) 18 digit = bin(digitMask).count("0") - 1 19 flip(i, j, digit) 20 board[i][j] = str(digit + 1) 21 dfs(pos + 1) 22 flip(i, j, digit) 23 mask &= (mask - 1) 24 if valid: 25 return 26 27 line = [0] * 9 28 column = [0] * 9 29 block = [[0] * 3 for _ in range(3)] 30 valid = False 31 spaces = list() 32 33 for i in range(9): 34 for j in range(9): 35 if board[i][j] == ".": 36 spaces.append((i, j)) 37 else: 38 digit = int(board[i][j]) - 1 39 flip(i, j, digit) 40 41 dfs(0) 42 43 44 #作者:LeetCode-Solution 45 #链接:https://leetcode-cn.com/problems/sudoku-solver/solution/jie-shu-du-by-leetcode-solution/ 46 #来源:力扣(LeetCode) 47 #著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
方法三:枚举优化
思路与算法
我们可以顺着方法二的思路继续优化下去:
如果一个空白格只有唯一的数可以填入,也就是其对应的 bbb 值和 b−1b-1b−1 进行按位与运算后得到 000(即 bbb 中只有一个二进制位为 111)。此时,我们就可以确定这个空白格填入的数,而不用等到递归时再去处理它。
这样一来,我们可以不断地对整个数独进行遍历,将可以唯一确定的空白格全部填入对应的数。随后我们再使用与方法二相同的方法对剩余无法唯一确定的空白格进行递归 + 回溯。
代码
1 class Solution: 2 def solveSudoku(self, board: List[List[str]]) -> None: 3 def flip(i: int, j: int, digit: int): 4 line[i] ^= (1 << digit) 5 column[j] ^= (1 << digit) 6 block[i // 3][j // 3] ^= (1 << digit) 7 8 def dfs(pos: int): 9 nonlocal valid 10 if pos == len(spaces): 11 valid = True 12 return 13 14 i, j = spaces[pos] 15 mask = ~(line[i] | column[j] | block[i // 3][j // 3]) & 0x1ff 16 while mask: 17 digitMask = mask & (-mask) 18 digit = bin(digitMask).count("0") - 1 19 flip(i, j, digit) 20 board[i][j] = str(digit + 1) 21 dfs(pos + 1) 22 flip(i, j, digit) 23 mask &= (mask - 1) 24 if valid: 25 return 26 27 line = [0] * 9 28 column = [0] * 9 29 block = [[0] * 3 for _ in range(3)] 30 valid = False 31 spaces = list() 32 33 for i in range(9): 34 for j in range(9): 35 if board[i][j] != ".": 36 digit = int(board[i][j]) - 1 37 flip(i, j, digit) 38 39 while True: 40 modified = False 41 for i in range(9): 42 for j in range(9): 43 if board[i][j] == ".": 44 mask = ~(line[i] | column[j] | block[i // 3][j // 3]) & 0x1ff 45 if not (mask & (mask - 1)): 46 digit = bin(mask).count("0") - 1 47 flip(i, j, digit) 48 board[i][j] = str(digit + 1) 49 modified = True 50 if not modified: 51 break 52 53 for i in range(9): 54 for j in range(9): 55 if board[i][j] == ".": 56 spaces.append((i, j)) 57 58 dfs(0) 59 60 61 #作者:LeetCode-Solution 62 #链接:https://leetcode-cn.com/problems/sudoku-solver/solution/jie-shu-du-by-leetcode-solution/ 63 #来源:力扣(LeetCode) 64 #著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/sudoku-solver/solution/jie-shu-du-by-leetcode-solution/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
思路差距
逐渐膨胀,认为思路上没有差距[x]
技术差距
1、 递归、回溯运用的还是不熟,不到70行代码写了将近两个小时,需要多加练习
2、判定在九宫格中的位置的时候写法有些笨拙,需要参考大佬的写法。