《hello-algo》回溯 —— 小记随笔
回溯算法
「回溯算法 backtracking algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。
回溯算法通常采用“深度优先搜索”来遍历解空间。在“二叉树”章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
给定一棵二叉树,搜索并记录所有值为 (7) 的节点,请返回节点列表。
对于此题,我们前序遍历这棵树,并判断当前节点的值是否为 (7) ,若是,则将该节点的值加入结果列表 res 之中。相关过程实现如图 13-1 和以下代码所示:
/* 前序遍历:例题一 */ func preOrderI(root *TreeNode, res *[]*TreeNode) { if root == nil { return } if (root.Val).(int) == 7 { // 记录解 *res = append(*res, root) } preOrderI(root.Left, res) preOrderI(root.Right, res) }
尝试与回退
之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。
在二叉树中搜索所有值为 (7) 的节点,请返回根节点到这些节点的路径。
在例题一代码的基础上,我们需要借助一个列表 path 记录访问过的节点路径。当访问到值为 (7) 的节点时,则复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解。代码如下所示:
/* 前序遍历:例题二 */ func preOrderII(root *TreeNode, res *[][]*TreeNode, path *[]*TreeNode) { if root == nil { return } // 尝试 *path = append(*path, root) if root.Val.(int) == 7 { // 记录解 *res = append(*res, append([]*TreeNode{}, *path...)) } preOrderII(root.Left, res, path) preOrderII(root.Right, res, path) // 回退 *path = (*path)[:len(*path)-1] }
在每次“尝试”中,我们通过将当前节点添加进 path 来记录路径;而在“回退”前,我们需要将该节点从 path 中弹出,以恢复本次尝试之前的状态。
剪枝
复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于“剪枝”。
在二叉树中搜索所有值为 (7) 的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为 (3) 的节点。
为了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为 (3) 的节点,则提前返回,不再继续搜索。代码如下所示:
/* 前序遍历:例题三 */ func preOrderIII(root *TreeNode, res *[][]*TreeNode, path *[]*TreeNode) { // 剪枝 if root == nil || root.Val == 3 { return } // 尝试 *path = append(*path, root) if root.Val.(int) == 7 { // 记录解 *res = append(*res, append([]*TreeNode{}, *path...)) } preOrderIII(root.Left, res, path) preOrderIII(root.Right, res, path) // 回退 *path = (*path)[:len(*path)-1] }
“剪枝”是一个非常形象的名词。如图 13-3 所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,避免许多无意义的尝试,从而提高了搜索效率。
框架代码
我们尝试将回溯的“尝试、回退、剪枝”的主体框架提炼出来,提升代码的通用性。
/* 回溯算法框架 */ func backtrack(state *State, choices []Choice, res *[]State) { // 判断是否为解 if isSolution(state) { // 记录解 recordSolution(state, res) // 不再继续搜索 return } // 遍历所有选择 for _, choice := range choices { // 剪枝:判断选择是否合法 if isValid(state, choice) { // 尝试:做出选择,更新状态 makeChoice(state, choice) backtrack(state, choices, res) // 回退:撤销选择,恢复到之前的状态 undoChoice(state, choice) } } }
接下来,我们基于框架代码来解决例题三。状态 state 为节点遍历路径,选择 choices 为当前节点的左子节点和右子节点,结果 res 是路径列表:
/* 判断当前状态是否为解 */ func isSolution(state *[]*TreeNode) bool { return len(*state) != 0 && (*state)[len(*state)-1].Val == 7 } /* 记录解 */ func recordSolution(state *[]*TreeNode, res *[][]*TreeNode) { *res = append(*res, append([]*TreeNode{}, *state...)) } /* 判断在当前状态下,该选择是否合法 */ func isValid(state *[]*TreeNode, choice *TreeNode) bool { return choice != nil && choice.Val != 3 } /* 更新状态 */ func makeChoice(state *[]*TreeNode, choice *TreeNode) { *state = append(*state, choice) } /* 恢复状态 */ func undoChoice(state *[]*TreeNode, choice *TreeNode) { *state = (*state)[:len(*state)-1] } /* 回溯算法:例题三 */ func backtrackIII(state *[]*TreeNode, choices *[]*TreeNode, res *[][]*TreeNode) { // 检查是否为解 if isSolution(state) { // 记录解 recordSolution(state, res) } // 遍历所有选择 for _, choice := range *choices { // 剪枝:检查选择是否合法 if isValid(state, choice) { // 尝试:做出选择,更新状态 makeChoice(state, choice) // 进行下一轮选择 temp := make([]*TreeNode, 0) temp = append(temp, choice.Left, choice.Right) backtrackIII(state, &temp, res) // 回退:撤销选择,恢复到之前的状态 undoChoice(state, choice) } } }
根据题意,我们在找到值为 (7) 的节点后应该继续搜索,因此需要将记录解之后的 return 语句删除。图 13-4 对比了保留或删除 return 语句的搜索过程。
常用术语
优点与局限性
回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优点在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受。
- 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
- 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。
即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何优化效率,常见的效率优化方法有两种。
- 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。
- 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。
经典例题
回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。
-
搜索问题:这类问题的目标是找到满足特定条件的解决方案。
** 全排列问题:给定一个集合,求出其所有可能的排列组合。
** 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
** 汉诺塔问题:给定三根柱子和一系列大小不同的圆盘,要求将所有圆盘从一根柱子移动到另一根柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。 -
约束满足问题:这类问题的目标是找到满足所有约束条件的解。
** (n) 皇后:在 (n \times n) 的棋盘上放置 (n) 个皇后,使得它们互不攻击。
** 数独:在 (9 \times 9) 的网格中填入数字 (1) ~ (9) ,使得每行、每列和每个 (3 \times 3) 子网格中的数字不重复。
** 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。 -
组合优化问题:这类问题的目标是在一个组合空间中找到满足某些条件的最优解。
** 0-1 背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选择物品使得总价值最大。
** 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
** 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。
请注意,对于许多组合优化问题,回溯不是最优解决方案。
- 0-1 背包问题通常使用动态规划解决,以达到更高的时间效率。
- 旅行商是一个著名的 NP-Hard 问题,常用解法有遗传算法和蚁群算法等。
- 最大团问题是图论中的一个经典问题,可用贪心算法等启发式算法来解决。
全排列问题
全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出其中元素的所有可能的排列。
无相等元素的情况
输入一个整数数组,其中不包含重复元素,返回所有可能的排列。
从回溯算法的角度看,我们可以把生成排列的过程想象成一系列选择的结果。假设输入数组为 ([1, 2, 3]) ,如果我们先选择 (1) ,再选择 (3) ,最后选择 (2) ,则获得排列 ([1, 3, 2]) 。回退表示撤销一个选择,之后继续尝试其他选择。
从回溯代码的角度看,候选集合 choices 是输入数组中的所有元素,状态 state 是直至目前已被选择的元素。请注意,每个元素只允许被选择一次,因此 state 中的所有元素都应该是唯一的。
重复选择剪枝
为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 selected ,其中 selected[i] 表示 choices[i] 是否已被选择,并基于它实现以下剪枝操作。
- 在做出选择 choice[i] 后,我们就将 selected[i] 赋值为 (\text{True}) ,代表它已被选择。
- 遍历选择列表 choices 时,跳过所有已被选择的节点,即剪枝。
观察图 13-6 发现,该剪枝操作将搜索空间大小从 (O(n^n)) 减小至 (O(n!)) 。
代码实现
/* 回溯算法:全排列 I */ func backtrackI(state *[]int, choices *[]int, selected *[]bool, res *[][]int) { // 当状态长度等于元素数量时,记录解 if len(*state) == len(*choices) { newState := append([]int{}, *state...) *res = append(*res, newState) } // 遍历所有选择 for i := 0; i < len(*choices); i++ { choice := (*choices)[i] // 剪枝:不允许重复选择元素 if !(*selected)[i] { // 尝试:做出选择,更新状态 (*selected)[i] = true *state = append(*state, choice) // 进行下一轮选择 backtrackI(state, choices, selected, res) // 回退:撤销选择,恢复到之前的状态 (*selected)[i] = false *state = (*state)[:len(*state)-1] } } } /* 全排列 I */ func permutationsI(nums []int) [][]int { res := make([][]int, 0) state := make([]int, 0) selected := make([]bool, len(nums)) backtrackI(&state, &nums, &selected, &res) return res }
考虑相等元素的情况
输入一个整数数组,数组中可能包含重复元素,返回所有不重复的排列。
那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优雅,因为生成重复排列的搜索分支没有必要,应当提前识别并剪枝,这样可以进一步提升算法效率。
相等元素剪枝
我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次。
代码实现
/* 回溯算法:全排列 II */ func backtrackII(state *[]int, choices *[]int, selected *[]bool, res *[][]int) { // 当状态长度等于元素数量时,记录解 if len(*state) == len(*choices) { newState := append([]int{}, *state...) *res = append(*res, newState) } // 遍历所有选择 duplicated := make(map[int]struct{}, 0) for i := 0; i < len(*choices); i++ { choice := (*choices)[i] // 剪枝:不允许重复选择元素 且 不允许重复选择相等元素 if _, ok := duplicated[choice]; !ok && !(*selected)[i] { // 尝试:做出选择,更新状态 // 记录选择过的元素值 duplicated[choice] = struct{}{} (*selected)[i] = true *state = append(*state, choice) // 进行下一轮选择 backtrackI(state, choices, selected, res) // 回退:撤销选择,恢复到之前的状态 (*selected)[i] = false *state = (*state)[:len(*state)-1] } } } /* 全排列 II */ func permutationsII(nums []int) [][]int { res := make([][]int, 0) state := make([]int, 0) selected := make([]bool, len(nums)) backtrackII(&state, &nums, &selected, &res) return res }
假设元素两两之间互不相同,则 (n) 个元素共有 (n!) 种排列(阶乘);在记录结果时,需要复制长度为 (n) 的列表,使用 (O(n)) 时间。因此时间复杂度为 (O(n!n)) 。
最大递归深度为 (n) ,使用 (O(n)) 栈帧空间。selected 使用 (O(n)) 空间。同一时刻最多共有 (n) 个 duplicated ,使用 (O(n^2)) 空间。因此空间复杂度为 (O(n^2)) 。
两种剪枝对比
- 重复选择剪枝:整个搜索过程中只有一个 selected 。它记录的是当前状态中包含哪些元素,其作用是避免某个元素在 state 中重复出现。
- 相等元素剪枝:每轮选择(每个调用的 backtrack 函数)都包含一个 duplicated 。它记录的是在本轮遍历(for 循环)中哪些元素已被选择过,其作用是保证相等元素只被选择一次。
子集和问题
无重复元素的情况
给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和等于 target 。给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这些组合,列表中不应包含重复组合。
例如,输入集合 ({3, 4, 5}) 和目标整数 (9) ,解为 ({3, 3, 3}, {4, 5}) 。需要注意以下两点。
- 输入集合中的元素可以被无限次重复选取。
- 子集不区分元素顺序,比如 ({4, 5}) 和 ({5, 4}) 是同一个子集。
参考全排列解法
类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素和”,当元素和等于 target 时,就将子集记录至结果列表。
而与全排列问题不同的是,本题集合中的元素可以被无限次选取,因此无须借助 selected 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码:
/* 回溯算法:子集和 I */ func backtrackSubsetSumINaive(total, target int, state, choices *[]int, res *[][]int) { // 子集和等于 target 时,记录解 if target == total { newState := append([]int{}, *state...) *res = append(*res, newState) return } // 遍历所有选择 for i := 0; i < len(*choices); i++ { // 剪枝:若子集和超过 target ,则跳过该选择 if total+(*choices)[i] > target { continue } // 尝试:做出选择,更新元素和 total *state = append(*state, (*choices)[i]) // 进行下一轮选择 backtrackSubsetSumINaive(total+(*choices)[i], target, state, choices, res) // 回退:撤销选择,恢复到之前的状态 *state = (*state)[:len(*state)-1] } } /* 求解子集和 I(包含重复子集) */ func subsetSumINaive(nums []int, target int) [][]int { state := make([]int, 0) // 状态(子集) total := 0 // 子集和 res := make([][]int, 0) // 结果列表(子集列表) backtrackSubsetSumINaive(total, target, &state, &nums, &res) return res }
向以上代码输入数组 ([3, 4, 5]) 和目标元素 (9) ,输出结果为 ([3, 3, 3], [4, 5], [5, 4]) 。虽然成功找出了所有和为 (9) 的子集,但其中存在重复的子集 ([4, 5]) 和 ([5, 4]) 。
为了去除重复子集,一种直接的思路是对结果列表进行去重。但这个方法效率很低,有两方面原因。
- 当数组元素较多,尤其是当 target 较大时,搜索过程会产生大量的重复子集。
- 比较子集(数组)的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。
重复子集剪枝
我们考虑在搜索过程中通过剪枝进行去重。
在搜索过程中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。
- 前两轮选择 (3) 和 (5) ,生成子集 ([3, 5, \dots]) 。
- 前两轮选择 (4) 和 (5) ,生成子集 ([4, 5, \dots]) 。
- 若第一轮选择 (5) ,则第二轮应该跳过 (3) 和 (4) ,因为子集 ([5, 3, \dots]) 和 ([5, 4, \dots]) 与第 1. 步和第 2. 步中描述的子集完全重复。
总结来看,给定输入数组 ([x_1, x_2, \dots, x_n]) ,设搜索过程中的选择序列为 ([x_{i_1}, x_{i_2}, \dots, x_{i_m}]) ,则该选择序列需要满足 (i_1 \leq i_2 \leq \dots \leq i_m) ,不满足该条件的选择序列都会造成重复,应当剪枝。
代码实现
为实现该剪枝,我们初始化变量 start ,用于指示遍历起始点。当做出选择 (x_{i}) 后,设定下一轮从索引 (i) 开始遍历。这样做就可以让选择序列满足 (i_1 \leq i_2 \leq \dots \leq i_m) ,从而保证子集唯一。
除此之外,我们还对代码进行了以下两项优化。
- 在开启搜索前,先将数组 nums 排序。在遍历所有选择时,当子集和超过 target 时直接结束循环,因为后边的元素更大,其子集和一定超过 target 。
- 省去元素和变量 total ,通过在 target 上执行减法来统计元素和,当 target 等于 (0) 时记录解。
/* 回溯算法:子集和 I */ func backtrackSubsetSumI(start, target int, state, choices *[]int, res *[][]int) { // 子集和等于 target 时,记录解 if target == 0 { newState := append([]int{}, *state...) *res = append(*res, newState) return } // 遍历所有选择 // 剪枝二:从 start 开始遍历,避免生成重复子集 for i := start; i < len(*choices); i++ { // 剪枝一:若子集和超过 target ,则直接结束循环 // 这是因为数组已排序,后边元素更大,子集和一定超过 target if target-(*choices)[i] < 0 { break } // 尝试:做出选择,更新 target, start *state = append(*state, (*choices)[i]) // 进行下一轮选择 backtrackSubsetSumI(i, target-(*choices)[i], state, choices, res) // 回退:撤销选择,恢复到之前的状态 *state = (*state)[:len(*state)-1] } } /* 求解子集和 I */ func subsetSumI(nums []int, target int) [][]int { state := make([]int, 0) // 状态(子集) sort.Ints(nums) // 对 nums 进行排序 start := 0 // 遍历起始点 res := make([][]int, 0) // 结果列表(子集列表) backtrackSubsetSumI(start, target, &state, &nums, &res) return res }
考虑重复元素的情况
给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和等于 target 。给定数组可能包含重复元素,每个元素只可被选择一次。请以列表形式返回这些组合,列表中不应包含重复组合。
相比于上题,本题的输入数组可能包含重复元素,这引入了新的问题。例如,给定数组 ([4, \hat{4}, 5]) 和目标元素 (9) ,则现有代码的输出结果为 ([4, 5], [\hat{4}, 5]) ,出现了重复子集。
造成这种重复的原因是相等元素在某轮中被多次选择。在图 13-13 中,第一轮共有三个选择,其中两个都为 (4) ,会产生两个重复的搜索分支,从而输出重复子集;同理,第二轮的两个 (4) 也会产生重复子集。
相等元素剪枝
为解决此问题,我们需要限制相等元素在每一轮中只能被选择一次。实现方式比较巧妙:由于数组是已排序的,因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素。
与此同时,本题规定每个数组元素只能被选择一次。幸运的是,我们也可以利用变量 start 来满足该约束:当做出选择 (x_{i}) 后,设定下一轮从索引 (i + 1) 开始向后遍历。这样既能去除重复子集,也能避免重复选择元素。
算法实现
/* 回溯算法:子集和 II */ func backtrackSubsetSumII(start, target int, state, choices *[]int, res *[][]int) { // 子集和等于 target 时,记录解 if target == 0 { newState := append([]int{}, *state...) *res = append(*res, newState) return } // 遍历所有选择 // 剪枝二:从 start 开始遍历,避免生成重复子集 // 剪枝三:从 start 开始遍历,避免重复选择同一元素 for i := start; i < len(*choices); i++ { // 剪枝一:若子集和超过 target ,则直接结束循环 // 这是因为数组已排序,后边元素更大,子集和一定超过 target if target-(*choices)[i] < 0 { break } // 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过 if i > start && (*choices)[i] == (*choices)[i-1] { continue } // 尝试:做出选择,更新 target, start *state = append(*state, (*choices)[i]) // 进行下一轮选择 backtrackSubsetSumII(i+1, target-(*choices)[i], state, choices, res) // 回退:撤销选择,恢复到之前的状态 *state = (*state)[:len(*state)-1] } } /* 求解子集和 II */ func subsetSumII(nums []int, target int) [][]int { state := make([]int, 0) // 状态(子集) sort.Ints(nums) // 对 nums 进行排序 start := 0 // 遍历起始点 res := make([][]int, 0) // 结果列表(子集列表) backtrackSubsetSumII(start, target, &state, &nums, &res) return res }
n 皇后问题
根据国际象棋的规则,皇后可以攻击与同处一行、一列或一条斜线上的棋子。给定 (n) 个皇后和一个 (n \times n) 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
如图 13-15 所示,当 (n = 4) 时,共可以找到两个解。从回溯算法的角度看,(n \times n) 大小的棋盘共有 (n^2) 个格子,给出了所有的选择 choices 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 state 。
图 13-16 展示了本题的三个约束条件:多个皇后不能在同一行、同一列、同一条对角线上。值得注意的是,对角线分为主对角线 \ 和次对角线 / 两种。
逐行放置策略
皇后的数量和棋盘的行数都为 (n) ,因此我们容易得到一个推论:棋盘每行都允许且只允许放置一个皇后。
也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。
图 13-17 所示为 (4) 皇后问题的逐行放置过程。受画幅限制,图 13-17 仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。
从本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。
列与对角线剪枝
为了满足列约束,我们可以利用一个长度为 (n) 的布尔型数组 cols 记录每一列是否有皇后。在每次决定放置前,我们通过 cols 将已有皇后的列进行剪枝,并在回溯中动态更新 cols 的状态。
那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 ((row, col)) ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,即对角线上所有格子的 (row - col) 为恒定值。
也就是说,如果两个格子满足 (row_1 - col_1 = row_2 - col_2) ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助图 13-18 所示的数组 diags1 记录每条主对角线上是否有皇后。
同理,次对角线上的所有格子的 (row + col) 是恒定值。我们同样也可以借助数组 diags2 来处理次对角线约束。
代码实现
请注意,(n) 维方阵中 (row - col) 的范围是 ([-n + 1, n - 1]) ,(row + col) 的范围是 ([0, 2n - 2]) ,所以主对角线和次对角线的数量都为 (2n - 1) ,即数组 diags1 和 diags2 的长度都为 (2n - 1) 。
/* 回溯算法:n 皇后 */ func backtrack(row, n int, state *[][]string, res *[][][]string, cols, diags1, diags2 *[]bool) { // 当放置完所有行时,记录解 if row == n { newState := make([][]string, len(*state)) for i, _ := range newState { newState[i] = make([]string, len((*state)[0])) copy(newState[i], (*state)[i]) } *res = append(*res, newState) } // 遍历所有列 for col := 0; col < n; col++ { // 计算该格子对应的主对角线和次对角线 diag1 := row - col + n - 1 diag2 := row + col // 剪枝:不允许该格子所在列、主对角线、次对角线上存在皇后 if !(*cols)[col] && !(*diags1)[diag1] && !(*diags2)[diag2] { // 尝试:将皇后放置在该格子 (*state)[row][col] = "Q" (*cols)[col], (*diags1)[diag1], (*diags2)[diag2] = true, true, true // 放置下一行 backtrack(row+1, n, state, res, cols, diags1, diags2) // 回退:将该格子恢复为空位 (*state)[row][col] = "#" (*cols)[col], (*diags1)[diag1], (*diags2)[diag2] = false, false, false } } } /* 求解 n 皇后 */ func nQueens(n int) [][][]string { // 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位 state := make([][]string, n) for i := 0; i < n; i++ { row := make([]string, n) for i := 0; i < n; i++ { row[i] = "#" } state[i] = row } // 记录列是否有皇后 cols := make([]bool, n) diags1 := make([]bool, 2*n-1) diags2 := make([]bool, 2*n-1) res := make([][][]string, 0) backtrack(0, n, &state, &res, &cols, &diags1, &diags2) return res }
逐行放置 (n) 次,考虑列约束,则从第一行到最后一行分别有 (n)、(n-1)、(\dots)、(2)、(1) 个选择,使用 (O(n!)) 时间。当记录解时,需要复制矩阵 state 并添加进 res ,复制操作使用 (O(n^2)) 时间。因此,总体时间复杂度为 (O(n! \cdot n^2)) 。实际上,根据对角线约束的剪枝也能够大幅缩小搜索空间,因而搜索效率往往优于以上时间复杂度。
数组 state 使用 (O(n^2)) 空间,数组 cols、diags1 和 diags2 皆使用 (O(n)) 空间。最大递归深度为 (n) ,使用 (O(n)) 栈帧空间。因此,空间复杂度为 (O(n^2)) 。
本文作者:Blue Mountain
本文链接:https://www.cnblogs.com/BlueMountain-HaggenDazs/p/18040687
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具