介绍
回溯算法是一种暴力搜索算法。回溯算法采用试错的思想,算法中往往将问题分成多步,每一步都有多种选择,发现某一步走错了,则原路返回选择另外一个选项,直到走出正确的解决步骤。可以看出,回溯算法本质是对决策树的一次深度优先遍历,也可以说是前序遍历。在发现走错路、走到尽头时,则原路返回一步,走其他分支;在发现走对路时,则继续走下去;发现走对路(即做过的决策符合题目要求),则将路径(即已做决策)加入结果集。
回溯算法往往采用递归来实现,而且因为是一种暴力算法,时间复杂度往往是指数级别的。所以这一类题目往往数据量不会很大,否则容易超时和造成栈溢出。
模板
我们说过,回溯算法可以看作对决策树的一次深度优先遍历,这里为了更容易理解一点,我们将用一道全排列的题目来做例子。
在这图中,每个方框内部是决策列表,而线条上的是路径(即已经做了的决策)。遍历的过程是,每次做完决策,则将决策加入路径,并进行下一轮决策(即进入下一层递归)。下一层递归的决策列表,在这个题目中,会收到上一层递归的影响。当路径符合某个条件时,比如在这个题目中,是路径长度等于原始输出长度,则将结果加入到结果集中,然后进行回溯,返回到上一层递归,取消之前已经做了的决策,并选择下一个决策。直到遍历完成整棵决策树。
在了解决策列表、已走路径、取消决策的概念后,我们可以轻易的写出这个回溯算法模板。
结果集=[]
def 回溯(决策列表,已走路径):
if 已走路径 满足 条件:
结果集.append(路径)
for 决策 in 决策列表:
路径.append(决策) #做决策
回溯(决策列表,已走路径)
路径.pop() #取消决策
回溯算法中,我们需要关注路径什么时候满足决策、什么时候需要回溯、每一轮决策的决策列表是是怎么样子的。这里说一下什么时候需要回溯,回溯地方其实就是递归的出口。这个针对每个题目都有不同。比如在全排列中,如果每一轮的决策列表数量都是上一轮决策列表数量的-1,那到最后决策为0时,递归函数就会自动返回。但是在其他一些题目里,可能需要自己去设计回溯的条件,防止无限递归下去。
细节处理
由于很多回溯题目,都要求返回符合题目的结果集,这可能会涉及将一个列表放入到另一个列表的列表。这里说的列表,指代编程语言中可以动态增长收缩的线性表,比如Python的列表(List),Go的切片(Slice)。不管是什么名字,这种数据结构往往有个特点,就是语义上是引用类型。引用类型大部分情况下,如果不指明深复制,将会导致浅复制。浅复制会导致一个问题,我们将用列表储存的路径作为结果加入了结果集,在回溯时,路径会变化,而结果集里的结果也会跟着变化。这显然不是我们想要的,因为放入结果集的结果就应该定下来,不再变化。
对此,我们需要在结果加入结果集时指定深复制。
假设path
为路径(即要记录的结果),result
为结果集。对于Go,可以这样。
//path []int
//result [][]int
result = append(result, append([]int(nil), path...))
由于结果集可能作为指针传入到递归函数中,所以可能还需要解引用,即
*result = append(*result, append([]int(nil), path...))
对于Python,也有个技巧,不需要使用copy
包。只使用切片语法[:]
,生成一个等长等内容的新切片
# path []
# result [][]
result.append(path[:])
常见题目
这里我列举我做过的题目,来讲一下我面对这些题目的思路。包括全排列、全排列2、子集、子集2、组合、组合总和、组合总和2、组合总和3、复原IP地址、N皇后、解数独。
全排列
Leetcode 46
如上面所说,我们可以设计递归函数,每一轮的决策数量都是上一轮数量的-1。递归的出口是路径长度为原始数组的长度。因为这个函数被设计成每一轮的决策数量-1,最后一轮递归已经没有决策了,函数会自动返回。其实可以不用写return
。
func permute(nums []int) [][]int {
result := make([][]int, 0)
bt([]int{}, nums, len(nums), &result)
return result
}
func bt(path, nums []int, length int, result *[][]int) {
if len(path) >= length {
*result = append(*result, append([]int(nil), path...))
// 这里不需要return,因为最后一轮已经没有决策了
}
for i := 0; i < len(nums); i++ {
path = append(path, nums[i])
newNums := append([]int(nil), nums[:i]...)
newNums = append(newNums, nums[i+1:]...)
bt(path, newNums, length, result)
path = path[:len(path)-1]
}
}
当然,也可也换一种方式实现回溯函数。使用一个visited
数组标记元素是否被加入到路径中,这要求原始数组不能有重复元素。
func permute(nums []int) [][]int {
result := make([][]int, 0)
visited:=make([]bool,len(nums))
bt(nums,[]int{},visited,&result)
return result
}
func bt(nums,path []int,visited []bool,result *[][]int){
if len(path)==len(nums){
*result = append(*result, append([]int(nil), path...))
}
for i:=0;i<len(nums);i++{
if visited[i]{
continue
}
visited[i]=true
path=append(path,nums[i])
bt(nums,path,visited,result)
path=path[:len(path)-1]
visited[i]=false
}
}
全排列 II
Leetcode 47
这一次输入的原始数组包含重复元素,要求输出的排列中,不能有重复元素。我们可以看一下决策树应该如何画。
假设我们输入[1,2,2]
,继续使用上一题的递归函数,将会多出3个重复结果。因此需要对决策树进行剪枝。图中打叉的部分就是被减去的树枝。那么该如何设计剪枝算法呢?
当然,你可以在每次递归的出口处检测当前路径是否已经存在于结果集,但那样时间复杂度太高了。可以用更加简便的方式计算。
我们可以留意到,在我们画的决策树中,被剪枝的树节点,包含着重复元素。每组重复元素下面,都有多棵子树,我们只遍历其中靠左的子树。比如图中左边部分,在遍历到[2,2]
节点时,我们只选择左边的2
,剪去右边的2
。当我们遍历最上方的[1,2,2]
节点时,我们只遍历左边的2
,剪掉右边的2
。这是因为要去重,所以遇到重复元素,我们只遍历一次,这里我们只选择一组重复元素中,最靠左边的一个。
因此,我们设计的剪枝思路可以设计为,每轮决策列表中,遇到一组重复元素,只选择遍历第一个。当然,为了实现这样的算法,我们必须对数组进行排序,以便我们可以选择一组重复元素中的第一个。
算法中,对于索引大于0,且与前一元素重复的元素,都会被略过。
import "sort"
func permuteUnique(nums []int) [][]int {
sort.Ints(nums)
length := len(nums)
result := make([][]int, 0)
bt([]int{}, nums, length, &result)
return result
}
func bt(path, nums []int, length int, result *[][]int) {
if len(path) >= length {
*result = append(*result, append([]int(nil), path...))
return
}
for i := 0; i < len(nums); i++ {
if i > 0 && nums[i] == nums[i-1] {
continue
}
path = append(path, nums[i])
newNums := append([]int(nil), nums[:i]...)
newNums = append(newNums, nums[i+1:]...)
bt(path, newNums, length, result)
path = path[:len(path)-1]
}
}
子集
Leetcode 78
题目要求输入一个不重复的数组,返回其幂集。
子集和是全排列不同的是,集合是无序的,因此[1,2,3]
和[2,1,3]
是等价的。
简化后是这样的。
我们继续采用上面的决策树,图中包括最上面的空集,每一条直线都是一个子集,都要被加入到结果集中。继续采用上面的决策树,我们可以发现,需要剪去的枝非常多。比如在第二层,我们将[2,1]
剪掉了,因为集合中[2,1]
和[1,2]
是相同的。这些被减去枝都有个特点,就是包含比当前路径下标小的元素。所以我们发现了剪枝的规律,不要选择比当前路径最大下标小的元素,因为他们之前已经被加入结果集中了。换句话说,让路径的下标保持升序。
我们可以这样设计回溯算法,每轮传入一个pos
(位置,position)变量表示当前路径的最大下标,下一轮决策,从pos+1
下标处开始选择。也有一些博主,会把这个变量命名为begin
、start
。
func subsets(nums []int) [][]int {
result := make([][]int, 0)
bt(nums, 0, []int{}, &result)
return result
}
func bt(nums []int, pos int, path []int, result *[][]int) {
*result = append(*result, append([]int(nil), path...))
for i := pos; i < len(nums); i++ {
path = append(path, nums[i])
bt(nums, i+1, path, result)
path = path[:len(path)-1]
}
}
实际上子集问题,除了用回溯,还有其他方式实现。但在这篇文章里,我们暂时只讨论回溯算法。
子集 II
Leetcode 90
题目输入数组包含重复元素,要求输出的幂集不包含重复子集。
继续使用上边的决策树,我们发现,需要剪枝。剪枝的判读,和全排列II差不多,即遇到一组重复元组,只选择最靠左边的遍历。所以我们可以模范全排列2的方式,先对数组进行排序,每轮决策中,遇到一组重复的元素,只遍历第一个,其余的跳过。
import "sort"
func subsetsWithDup(nums []int) [][]int {
result := make([][]int, 0)
sort.Ints(nums)
bt(nums, []int{}, 0, &result)
return result
}
func bt(nums, path []int, pos int, result *[][]int) {
*result = append(*result, append([]int(nil), path...))
for i := pos; i < len(nums); i++ {
if i > pos && nums[i] == nums[i-1] {
continue
}
path = append(path, nums[i])
bt(nums, path, i+1, result)
path = path[:len(path)-1]
}
}
递增子序列
Leetcode 491
这道题其他和子集2比较像,但是加了两个限定条件,每个结果的长度必须大于等于2且必须递增(可含有重复元素),而且因为需要求递增,所以不能对原数组去重。
我们可以画出这题的决策树
可以看出,需要剪枝(去重)的地方,往往是该节点已经有一条树枝选择了相同的数字,即一轮决策里,一个数字只能被选择一次。所以我们可以创建一个哈希表,储存当前决策已经选择过的数字,如果已经被选择过,则跳过。注意,我们只需要关注避免选择当前决策选择过的数字,所以该哈希表只作用于当前函数,不需要传参。同时,因为选择过的数字不能再被选择,所以在路径回退时,哈希表不需要删除被选择的数字。
而关于递增的部分,我们只需要保证当前考虑选择的数字不小于路径最后的一个数字就行。
func bt(nums, path []int, pos int, result *[][]int) {
if len(path) > 1 {
*result = append(*result, append([]int(nil), path...))
}
used := make(map[int]bool)
for i := pos; i < len(nums); i++ {
if used[nums[i]] || (len(path) > 0 && path[len(path)-1] > nums[i]) {
continue
}
path = append(path, nums[i])
used[nums[i]] = true
bt(nums, path, i+1, result)
path = path[:len(path)-1]
}
}
func FindSubsequences(nums []int) [][]int {
result := make([][]int, 0)
bt(nums, []int{}, 0, &result)
return result
}
组合
Leetcode 77
题目输入n
和k
,要求返回范围 [1, n]
中所有可能的k
个数的组合。其实本质就是,输入数组[1,..,n]
,输出长度为k
的子集。
因此我们可以轻易地在子集的基础上,写出代码。注意,不要忘记return
。
func bt(nums, path []int, pos, length int, result *[][]int) {
if len(path) >= length {
*result = append(*result, append([]int(nil), path...))
return
}
for i := pos; i < len(nums); i++ {
path = append(path, nums[i])
bt(nums, path, i+1, length, result)
path = path[:len(path)-1]
}
}
func combine(n int, k int) [][]int {
result := make([][]int, 0)
nums := make([]int, n)
for i := 0; i < n; i++ {
nums[i] = i + 1
}
bt(nums, []int{}, 0, k, &result)
return result
}
组合总和
给定一个无重复元素的正整数数组 candidates 和一个正整数 target ,找出 candidates 中所有可以使数字和为目标数 target 的唯一组合。
candidates 中的数字可以无限制重复被选取。如果至少一个所选数字数量不同,则两种组合是唯一的。
对于给定的输入,保证和为 target 的唯一组合数少于 150 个。
以输入candidate=[2,3,5],target=8
为例,画一下决策树,为了方便,这里仅仅画出符合要求的路径。
与上面其他问题不同,这题因为数字可重复选取,所以每轮决策列表可以相同,同时题目要求不能出现重复的组合。也就是不能同时出现,[2,3,5]
和[5,2,3]
,需要去重。
其实这个也与子集有很多相似之处,只不过递归的出口,变成了路径总和为target
。数字可以重复选择,但结果集中不能有重复组合。
因此我们参照子集,写出以下代码
func sum(path []int)int{
sum:=0
for _,v := range path{
sum+=v
}
return sum
}
func bt(nums,path []int,pos,target int, result *[][]int){
if sum(path)>target {
return
} else if sum(path)==target {
*result = append(*result, append([]int(nil), path...))
return
}
for i := pos; i < len(nums); i++ {
path = append(path, nums[i])
bt(nums, path,i ,target, result) //下一轮仍然从i开始,而非i+1
path = path[:len(path)-1]
}
}
func combinationSum(candidates []int, target int) [][]int {
result := make([][]int, 0)
bt(candidates, []int{}, 0,target, &result)
return result
}
注意,代码中我们仍然使用了pos
变量,但是因为数字可以重复选择,下一轮的决策仍然从i
开始,而非i+1
。递归的出口,除了考虑路径总和为target,还要考虑路径总和大于target,这种情况下,继续递归下去路径总和会越来越大,越来越远离target,所以需要及时返回。
对于上面的代码,我们可以稍微做一点优化,每一轮递归,将当前target
减去当前选择数字,传递到下一递归中。这样就不需要每轮递归都遍历路径求和了。我在Leetcode上测试时,不管有没有优化,时间和空间都是一样的。不过实际用 go benchmark 测试,较大数据下优化后能快1倍。
func bt(nums,path []int,pos,target int, result *[][]int){
if target < 0 {
return
} else if target == 0 {
*result = append(*result, append([]int(nil), path...))
return
}
for i := pos; i < len(nums); i++ {
path = append(path, nums[i])
bt(nums, path,i ,target-nums[i], result)
path = path[:len(path)-1]
}
}
func combinationSum(candidates []int, target int) [][]int {
result := make([][]int, 0)
bt(candidates, []int{}, 0,target, &result)
return result
}
组合总和 II
Leetcode 40
这次是每个次数只允许用一次,同时输入数组也可能存在重复数据,所以其实我们可以故技重施,参考子集2,将数组排序,遇到一组重复的元素,只遍历第一个,其余的跳过。
编写代码如下
import "sort"
func bt(nums, path []int, pos, target int, result *[][]int) {
if target < 0 {
return
} else if target == 0 {
*result = append(*result, append([]int(nil), path...))
return
}
for i := pos; i < len(nums); i++ {
if i > pos && nums[i] == nums[i-1] {
continue
}
path = append(path, nums[i])
bt(nums, path, i+1, target-nums[i], result)
path = path[:len(path)-1]
}
}
func combinationSum2(candidates []int, target int) [][]int {
result := make([][]int, 0)
sort.Ints(candidates)
bt(candidates, []int{}, 0, target, &result)
return result
}
组合总和 III
找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
说明:所有数字都是正整数。解集不能包含重复的组合。
虽然这个是组合总和的最后一题,但是总体还是比较普通。
我们可以手动构建1-9的数组,然后在递归出口再加上路径长为k,路径长大于k的情况。
func bt(nums, path []int, pos, length, target int, result *[][]int) {
if target < 0 {
return
} else if len(path) > length {
return
} else if target == 0 && len(path) == length {
*result = append(*result, append([]int(nil), path...))
return
}
for i := pos; i < len(nums); i++ {
path = append(path, nums[i])
bt(nums, path, i+1, length, target-nums[i], result)
path = path[:len(path)-1]
}
}
func combinationSum3(k, n int) [][]int {
result := make([][]int, 0)
candidates := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
bt(candidates, []int{}, 0, k, n, &result)
return result
}
复原IP地址
给定一个只包含数字的字符串,用以表示一个 IP 地址,返回所有可能从 s 获得的 有效 IP 地址 。你可以按任何顺序返回答案。
有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。
例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "192.168@1.1" 是 无效 IP 地址。
复原 IP 我感觉还是比较有意思的题目,需要考虑更多的情况。比如每段IP分隔,不许允有前导0,如果有,那只能这一段分隔为0。还有如何设计路径的存储,也需要去思考。
我们可以设计这样的思路,一个指针i
从左向右遍历IP字符串,若下标0
到i
的这一段符合IP的分隔格式(比如为0
或小于等于255
的数字),则打下一个分割符号.
进入下一轮递归。下一轮递归从上一轮的分隔符后一个字符开始遍历,若上一个分隔符和下标i
这一段也符合IP分隔的格式,则再进入下轮递归。当已经打下3
个分隔符时,可以检测当前IP地址是否符合格式。若是符合,则加入结果集。
这个是决策树,因为这道题的决策树分支很多,于是很多地方用省略号代替,符合要求的路径,用了红线标注。
那么该如何完成“打分隔符”,“检测当前IP是否符合格式”呢?我们可以稍微变换一下思路,当要打分隔符时,将0
或上一个分隔符
到当前指针i
这一段,加入到一个结构为字符串数组的路径中,若路径中存在4段字符串。检测当前是否已经遍历完字符串,若已经遍历完,则可加入到结果集中。
func bt(ipStr string, path []string, pos int, result *[]string) {
if len(path) == 4 && pos == len(ipStr) {
ipAddr := ""
for i, v := range path {
ipAddr += v
if i < len(path)-1 {
ipAddr += "."
}
}
*result = append(*result, ipAddr)
// 这里不需要return
} else if len(path) == 4 && pos < len(ipStr) {
return
}
for i := pos; i < len(ipStr); i++ {
if i != pos && ipStr[pos] == '0' {
return
}
if num, _ := strconv.Atoi(string(ipStr[pos : i+1])); num > 255 {
return
}
path = append(path, ipStr[pos:i+1])
bt(ipStr, path, i+1, result)
path = path[:len(path)-1]
}
}
func restoreIpAddresses(s string) []string {
result := make([]string, 0)
bt(s, []string{}, 0, &result)
return result
}
编写代码。指针i
会检测上一个分隔符(上一轮i
停留的位置+1
)或下标0
到当前这一段是否符合IP分隔的格式,若为 0 和小于等于255,则进入下一轮。这里我们设计成,若当前分隔有前导0或大于255,则直接返回,剩下的会进入下一轮递归。当路径中存储了4段分隔后,检测是否已经遍历完成,并将结果加入结果集中,否则,则返回。因为递归到最后字符串已经遍历完成,所以加入结果集的操作不需要return
。
N皇后
Leetcode 51
N皇后老实说,我觉得并不能算难题,但是代码量确实很多。
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
具体的思想其实很简单,首先在棋盘第一行第一列放下一个皇后,然后从第二行开始,从左到右逐个检测是否能放下,以此类推。当某一行所有格子都不能放下皇后,则回溯到上一行,将此行皇后向右移动到下一个可以放置的位置,若无法放置,则再回溯。当棋盘最后一行放下了棋子,就记录下当前状态。
这道题,个人觉得主要核心在于找到每行能放置皇后的位置。先放初版代码。
// 检测 row 行,col 列 是否能放下皇后
func isValid(board [][]byte, row, col int) bool {
// 纵向
for row := row; row >= 0; row-- {
if board[row][col] == 'Q' {
return false
}
}
// 左上方斜向
for row, col := row-1, col-1; row >= 0 && col >= 0; row, col = row-1, col-1 {
if board[row][col] == 'Q' {
return false
}
}
// 右上角斜向
for row, col := row-1, col+1; row >= 0 && col < len(board[row]); row, col = row-1, col+1 {
if board[row][col] == 'Q' {
return false
}
}
return true
}
func bt(board [][]byte, row int, result *[][]string) {
if row >= len(board) {
snapshot := make([]string, 0)
for _, row := range board {
snapshot = append(snapshot, string(row))
}
*result = append(*result, snapshot)
return
}
for col := 0; col < len(board); col++ {
if isValid(board, row, col) {
board[row][col] = 'Q'
bt(board, row+1, result)
board[row][col] = '.'
}
}
}
func solveNQueens(n int) [][]string {
board := make([][]byte, n)
for i := range board {
board[i] = make([]byte, n)
}
result := make([][]string, 0)
for i := range board {
for j := range board[i] {
board[i][j] = '.'
}
}
bt(board, 0, &result)
return result
}
首先我们根据题目构造出一个二维数组棋盘board
,当在第row
行、col
列放置皇后,则borad[row][col]='Q'
。isValid()
函数接收棋盘和行列坐标,返回该坐标是否能放置皇后。因为放置皇后是从上到下的放置的,isValid()
会检查该坐标的左上方、正上方、右上方是否有其他皇后,如果没有,则该坐标可放置皇后,否则不可放置。bt()
中的循环会在该行逐列检测能否放下,若有位置,则递归进入下一行。若没有,则回溯。
在上面的代码中,isValid()
函数每次都要遍历左上、正上、右上的棋格。我们可以用哈希表对其优化。借用Leetcode 题解的图,我们可以看出规律,从左上到右下方向的同一斜线上的格子,其行号-列号
的值往往相等,从右上到左下的同一斜线上的格子,其行号+列号
往往相等。我们可以设计出diagonal
和diagona2
两个哈希表分别表示这两个方向上的斜线上有无皇后。再加上一个columns
哈希表,表示同一列上是否有皇后。当在row
行、col
列放置皇后,diagonal1[row-col]
、diagonal2[row+col]
、columns[col]
都将置为true
,取消选择时,则都置为flase
。
代码如下
func bt(board [][]byte, row int, columns, diagonal1, diagonal2 map[int]bool, result *[][]string) {
if row >= len(board) {
snapshot := make([]string, 0)
for _, row := range board {
snapshot = append(snapshot, string(row))
}
*result = append(*result, snapshot)
return
}
for col := 0; col < len(board); col++ {
if columns[col] || diagonal1[row-col] || diagonal2[row+col] {
continue
} else {
columns[col] = true
diagonal1[row-col] = true
diagonal2[row+col] = true
board[row][col] = 'Q'
bt(board, row+1, columns, diagonal1, diagonal2, result)
board[row][col] = '.'
diagonal2[row+col] = false
diagonal1[row-col] = false
columns[col] = false
}
}
}
func solveNQueens(n int) [][]string {
board := make([][]byte, n)
for i := range board {
board[i] = make([]byte, n)
}
result := make([][]string, 0)
for i := range board {
for j := range board[i] {
board[i][j] = '.'
}
}
columns := make(map[int]bool)
diagonal1 := make(map[int]bool)
diagonal2 := make(map[int]bool)
bt(board, 0, columns, diagonal1, diagonal2, &result)
return result
}
解数独
编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
数字 1-9 在每一行只能出现一次。
数字 1-9 在每一列只能出现一次。
数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)数独部分空格内已填入了数字,空白格用 '.' 表示。
数独个人认为很像N皇后问题。无非就是逐行扫描,检测某个空白位是否可以写下数字,如果可以,则写下,查看下一个空白位置。若是写不下任何一个数字,则回溯。
我们参照N皇后,写出初版代码。
func IsVaild(i, j int, num byte, board [][]byte) bool {
// 检测同行
for col := 0; col < len(board[i]); col++ {
if board[i][col] == num {
return false
}
}
// 检测同列
for row := 0; row < len(board); row++ {
if board[row][j] == num {
return false
}
}
// 检测同宫格
palaceI := i / 3
palaceJ := j / 3
for row := palaceI * 3; row < palaceI*3+3; row++ {
for col := palaceJ * 3; col < palaceJ*3+3; col++ {
if board[row][col] == num {
return false
}
}
}
return true
}
func nextPosition(i, j int, board [][]byte) (int, int) {
for row := i; row < len(board); row++ {
for col := j; col < len(board[row]); col++ {
if board[row][col] == '.' {
return row, col
}
}
j = 0
}
return -1, -1
}
func bt(i, j int, board [][]byte) {
i, j = nextPosition(i, j, board)
if i == -1 && j == -1 {
panic("遍历完成")
//引发异常直接退出到最外层函数,防止回溯
}
for num := 1; num < 10; num++ {
if IsVaild(i, j, byte(num+'0'), board) {
board[i][j] = byte(num + '0')
bt(i, j, board)//你也可以传入i,j+1,但不能传入i+1,j+1
board[i][j] = '.'
}
}
}
func solveSudoku(board [][]byte) {
defer func() { recover() }()
bt(0, 0, board)
}
数独和N皇后都有一个棋盘,不过区别在于,N皇后要求斜线上不能有其他皇后,数独则要求同宫格内不能有相同数字。将棋盘分为9个宫格,每行每列都有3个,将其视为一个3x3的棋盘,每个宫可用行号和列号表示,范围都为[0-2]
。以i,j
为坐标,坐标为[i][j]
的数字坐落于[i//3][j//3]
(//
为整除,即向下取整)宫格上。比如坐标为[1][1]
的数字,就在[0][0]
宫格。知道这样,就能很容易写出isVaild()
函数。
此外,数独棋盘中会预先置入一些数字,我们不能改变这些已经置入的数字,只能在空白位填数字。所以我在上面的代码中设计了nextPosition()
函数,它会返回包括坐标[i][j]
在内的下一个空白位坐标。每一轮选择的坐标都由nextPosition()
决定。注意,在向下一轮选择传递坐标时,其实可以传送i,j+1
,但是不能传入i+1,j+1
,因为算法是逐行扫描的,i+1
意味着直接跨行了。和N皇后又有不同的地方是,N皇后每轮的决策列表是棋盘格,而数独的决策列表则是1-9
九个数字。若是棋盘走到最后,nextPosition()
则会返回-1,-1
。因为这题并不需要将路径写入结果集,我在这里直接抛出一个异常,防止回溯,并在最外层函数捕捉。达到直接跳出多重递归的效果。
上述代码,是最符合直觉的最直观的代码,但并不是最优的。我们可以使用数组对其优化。
func solveSudoku(board [][]byte) {
defer func() { recover() }()
row := [9][9]bool{}
columns := [9][9]bool{}
block := [3][3][9]bool{}
space := make([][]int, 0)
for i := range board {
for j := range board[i] {
if board[i][j] == '.' {
space = append(space, []int{i, j})
} else {
num := board[i][j] - '1'
row[i][num] = true
columns[j][num] = true
block[i/3][j/3][num] = true
}
}
}
bt(0,board,space,row,columns,block)
}
func bt(pos int, board [][]byte, space [][]int, row, columns [9][9]bool, block [3][3][9]bool) {
if pos == len(space) {
panic("完成了")
}
i, j := space[pos][0], space[pos][1]
for num := 0; num < 9; num++ {
if row[i][num] || columns[j][num] || block[i/3][j/3][num] {
continue
} else {
row[i][num] = true
columns[j][num] = true
block[i/3][j/3][num] = true
board[i][j] = byte(num + '1')
bt(pos+1, board, space, row, columns, block)
board[i][j] = '.'
block[i/3][j/3][num] = false
columns[j][num] = false
row[i][num] = false
}
}
}
其实这份代码和Leetcode官方题解很像,只不过没有使用闭包的写法,看上去可能更加清晰,但是传参比较多。我们设计row[9][9]bool
,columns[9][9]bool
,block[3][3][9]bool
来代表同行,同列,同宫格内是否存在某个数字。注意因为我们现在使用了数组下标存储某个数字的出现情况,因此循环中要从0
开始到8
结束。row[i][num]==true
表示第i
行已存在数字num+1
,columns[j][num]==true
表示第j
列存在数字num+1
,block[i/3][j/3][num]==ture
表示i
行j
列所在宫格存在数字num+1
。
最后为了方便,我直接抛出异常跳出多重循环。当然你也可以像Leetcode官方题解那样在回溯函数中加上返回值,判断跳出递归。
func bt(pos int, board [][]byte, space [][]int, row, columns [9][9]bool, block [3][3][9]bool) bool{
if pos == len(space) {
return true
}
i, j := space[pos][0], space[pos][1]
for num := 0; num < 9; num++ {
if row[i][num] || columns[j][num] || block[i/3][j/3][num] {
continue
} else {
row[i][num] = true
columns[j][num] = true
block[i/3][j/3][num] = true
board[i][j] = byte(num + '1')
if bt(pos+1, board, space, row, columns, block){
return true
}
board[i][j] = '.'
block[i/3][j/3][num] = false
columns[j][num] = false
row[i][num] = false
}
}
return false
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。