1 回溯算法
例题一给定一个二叉树,搜索并记录所有值为 7 的节点,请返回节点列表。
/* 前序遍历:例题一 */
void preOrder(TreeNode root) {
if (root == null) {
return;
}
if (root.val == 7) {
// 记录解
res.add(root);
}
preOrder(root.left);
preOrder(root.right);
}
1.1 尝试与回退
例题二在二叉树中搜索所有值为 7 的节点, 请返回根节点到这些节点的路径 。
/* 前序遍历:例题二 */
void preOrder(TreeNode root) {
if (root == null) {
return;
}
// 尝试
path.add(root);
if (root.val == 7) {
// 记录解
res.add(new ArrayList<>(path));
}
preOrder(root.left);
preOrder(root.right);
// 回退
path.remove(path.size() - 1);
}
在每次“尝试”中,我们通过将当前节点添加进 path 来记录路径;而在“回退”前,我们需要将该节点从path 中弹出,以恢复本次尝试之前的状态。 观察下图所示的过程,我们可以将尝试和回退理解为“前进”与“撤销”,两个操作是互为逆向的。
1.2 剪枝
例题三在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径, 并要求路径中不包含值为 3 的节点 。
/* 前序遍历:例题三 */
void preOrder(TreeNode root) {
// 剪枝
if (root == null || root.val == 3) {
return;
}
// 尝试
path.add(root);
if (root.val == 7) {
// 记录解
res.add(new ArrayList<>(path));
}
preOrder(root.left);
preOrder(root.right);
// 回退
path.remove(path.size() - 1);
}
![](https://i-blog.csdnimg.cn/blog_migrate/4e9f3998bc9b1fa07d52e33d4ad18112.png)
1.3 框架代码
/* 回溯算法框架 */
void backtrack(State state, List<Choice> choices, List<State> res) {
// 判断是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 停止继续搜索
return;
}
// 遍历所有选择
for (Choice choice : choices) {
// 剪枝:判断选择是否合法
if (isValid(state, choice)) {
// 尝试:做出选择,更新状态
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, choice);
}
}
}
/* 回溯算法:例题三 */
//state 表示当前的选择路径,choices 表示当前可选择的节点,res 表示存储解的列表。
void backtrack(List<TreeNode> state, List<TreeNode> choices, List<List<TreeNode>> res) {
// 检查是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
}
// 遍历所有选择
for (TreeNode choice : choices) {
// 剪枝:检查选择是否合法
if (isValid(state, choice)) {
// 尝试:做出选择,更新状态
makeChoice(state, choice);
// 进行下一轮选择
backtrack(state, Arrays.asList(choice.left, choice.right), res);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, choice);
}
}
}
/* 判断当前状态是否为解 */
boolean isSolution(List<TreeNode> state) {
//检查选择路径 state 是否为空,以及路径的最后一个节点的值是否等于 7
return !state.isEmpty() && state.get(state.size() - 1).val == 7;
}
/* 记录解 */
void recordSolution(List<TreeNode> state, List<List<TreeNode>> res) {
//将当前选择路径 state 添加到解的列表 res 中。
res.add(new ArrayList<>(state));
}
/* 判断在当前状态下,该选择是否合法 */
boolean isValid(List<TreeNode> state, TreeNode choice) {
// 检查选择 choice 是否为非空,且节点值不等于 3。
return choice != null && choice.val != 3;
}
/* 更新状态 */
void makeChoice(List<TreeNode> state, TreeNode choice) {
//将选择 choice 添加到当前选择路径 state 中。
state.add(choice);
}
/* 恢复状态 */
void undoChoice(List<TreeNode> state, TreeNode choice) {
//移除当前选择路径 state 的最后一个节点,以便回退到之前的状态。
state.remove(state.size() - 1);
}
![](https://i-blog.csdnimg.cn/blog_migrate/b50c6b0061ec599093774d236f304d28.png)
1.4 常用术语
![](https://i-blog.csdnimg.cn/blog_migrate/c9614b06eaf2aeaba11cfbc4093bc3a8.png)
问题、解、状态等概念是通用的,在分治、回溯、动态规划、贪心等算法中都有涉及。
1.5 优势与局限性
启发式搜索(Heuristic Search)是一种在搜索问题中引入启发信息(heuristic)来指导搜索方向的搜索算法。启发式信息是一种对问题领域的额外知识,通常是一些估计值或规则,用于估计每个状态的“好坏”程度。这种估计有助于算法更聪明地选择下一步搜索的方向,从而更有效地达到目标。
以下是启发式搜索的一般步骤和一些常见的启发式搜索算法:
启发式搜索的一般步骤:
- 初始化: 将初始状态加入搜索队列。
- 循环直到找到解或搜索完整个状态空间:
- 从队列中取出当前状态。
- 根据启发式函数评估每个可能的下一步状态,将它们加入队列。
- 根据启发式函数的估计值排序队列,以优先考虑最有希望的状态。
- 检查当前状态是否为目标状态,如果是,则搜索结束。
- 返回解或标记问题无解。
常见的启发式搜索算法:
A*算法:
- 使用启发式函数估计从起点到当前状态的代价和从当前状态到目标的代价。
- 计算当前状态的总代价(估计值 + 实际代价),并根据总代价进行排序。
- A*算法通过估计函数实现了一种较为精确的搜索,可以找到最优解。
IDA算法(Iterative Deepening A):
- 类似于A*算法,但使用深度优先搜索策略,并通过逐渐增加深度限制来提高搜索效率。
- 通过迭代深化的方式,IDA*在每一轮迭代中限制总代价,并逐渐扩大这个限制。
贪心搜索:
- 仅根据启发式函数的估计值选择下一步状态,不考虑过去的代价。
- 贪心搜索通常效率较高,但可能无法找到最优解,因为它可能会陷入局部最优解。
IDA算法(Iterative Deepening A):
- 类似于A*算法,但使用深度优先搜索策略,并通过逐渐增加深度限制来提高搜索效率。
- 通过迭代深化的方式,IDA*在每一轮迭代中限制总代价,并逐渐扩大这个限制。
局部搜索算法(如爬山算法、模拟退火):
- 通过在当前解附近进行局部调整,尝试找到更好的解。
- 这些算法不一定保证找到全局最优解,但在某些问题上表现很好。
启发式搜索的性能取决于启发式函数的质量,好的启发式函数应该提供对当前状态的良好估计,以引导搜索朝着更有希望的方向前进。
1.6 回溯典型例题
2 全排列问题
如数组:[1,2,3]
他的全部排列有:[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]
2.1 无相等元素的情况
Q:输入一个整数数组,数组中不包含重复元素,返回所有可能的排列。
1. 重复选择剪枝
2 代码实现
想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短代码行数,我们不单独实现框 架代码中的各个函数,而是将他们展开在 backtrack() 函数中。
void backtrack(List<Integer> state, int[] choices, boolean[] selected, List<List<Integer>> res) {
// 当状态长度等于元素数量时,记录解
if (state.size() == choices.length) {
res.add(new ArrayList<Integer>(state));
return;
}
// 遍历所有选择
for (int i = 0; i < choices.length; i++) {
int choice = choices[i];
// 剪枝:不允许重复选择元素
if (!selected[i]) {
// 尝试:做出选择,更新状态
selected[i] = true;
state.add(choice);
// 进行下一轮选择
backtrack(state, choices, selected, res);
// 回退:撤销选择,恢复到之前的状态
selected[i] = false;
state.remove(state.size() - 1);
}
}
}
/* 全排列 I */
List<List<Integer>> permutationsI(int[] nums) {
List<List<Integer>> res = new ArrayList<List<Integer>>();
backtrack(new ArrayList<Integer>(), nums, new boolean[nums.length], res);
return res;
}
2.2 考虑相等元素的情况
Q: 输入一个整数数组, 数组中可能包含重复元素 ,返回所有不重复的排列。
1. 相等元素剪枝
2. 代码实现
/* 回溯算法:全排列 II */
void backtrack(List<Integer> state, int[] choices, boolean[] selected, List<List<Integer>> res) {
// 当状态长度等于元素数量时,记录解
if (state.size() == choices.length) {
res.add(new ArrayList<Integer>(state));
return;
}
// 遍历所有选择
Set<Integer> duplicated = new HashSet<Integer>();
for (int i = 0; i < choices.length; i++) {
int choice = choices[i];
// 剪枝:不允许重复选择元素 且 不允许重复选择相等元素
if (!selected[i] && !duplicated.contains(choice)) {
// 尝试:做出选择,更新状态
duplicated.add(choice); // 记录选择过的元素值
selected[i] = true;
state.add(choice);
// 进行下一轮选择
backtrack(state, choices, selected, res);
// 回退:撤销选择,恢复到之前的状态
selected[i] = false;
state.remove(state.size() - 1);
}
}
}
/* 全排列 II */
List<List<Integer>> permutationsII(int[] nums) {
List<List<Integer>> res = new ArrayList<List<Integer>>();
backtrack(new ArrayList<Integer>(), nums, new boolean[nums.length], res);
return res;
}
3.两种剪枝对比
3 子集和问题
3.1 无重复元素的情况
Q:给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和等于 target 。给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这些组合,列表中不应包含重复组合。
1. 参考全排列解法
void backtrack(List<Integer> state, int target, int total, int[] choices, List<List<Integer>> res) {
// 子集和等于 target 时,记录解
if (total == target) {
res.add(new ArrayList<>(state));
return;
}
// 遍历所有选择
for (int i = 0; i < choices.length; i++) {
// 剪枝:若子集和超过 target ,则跳过该选择
if (total + choices[i] > target) {
continue;
}
// 尝试:做出选择,更新元素和 total
state.add(choices[i]);
// 进行下一轮选择
backtrack(state, target, total + choices[i], choices, res);
// 回退:撤销选择,恢复到之前的状态
state.remove(state.size() - 1);
}
}
/* 求解子集和 I(包含重复子集) */
List<List<Integer>> subsetSumINaive(int[] nums, int target) {
List<Integer> state = new ArrayList<>(); // 状态(子集)
int total = 0; // 子集和
List<List<Integer>> res = new ArrayList<>(); // 结果列表(子集列表)
backtrack(state, target, total, nums, res);
return res;
}
![](https://i-blog.csdnimg.cn/blog_migrate/8d7dc34bbc4ce32b16fd14657d998f8b.png)
2. 重复子集剪枝
3. 代码实现
/* 回溯算法:子集和 I */
void backtrack(List<Integer> state, int target, int[] choices, int start, List<List<Integer>> res) {
// 子集和等于 target 时,记录解
if (target == 0) {
res.add(new ArrayList<>(state));
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
for (int i = start; i < choices.length; i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (choices[i]>target) {
break;
}
// 尝试:做出选择,更新 target, start
state.add(choices[i]);
// 进行下一轮选择
backtrack(state, target - choices[i], choices, i, res);
// 回退:撤销选择,恢复到之前的状态
state.remove(state.size() - 1);
}
}
/* 求解子集和 I */
List<List<Integer>> subsetSumI(int[] nums, int target) {
Arrays.sort(nums); // 对 nums 进行排序
List<Integer> state = new ArrayList<>(); // 状态(子集)
int start = 0; // 遍历起始点
List<List<Integer>> res = new ArrayList<>(); // 结果列表(子集列表)
backtrack(state, target, nums, start, res);
return res;
}
![](https://i-blog.csdnimg.cn/blog_migrate/791bc86635d7520ea9ebabc074b431ac.png)
3.2 考虑重复元素的情况
1.相等元素剪枝
void backtrack(List<Integer> state, int target, int[] choices, int start, List<List<Integer>> res) {
// 子集和等于 target 时,记录解
if (target == 0) {
res.add(new ArrayList<>(state));
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
for (int i = start; i < choices.length; i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (choices[i]>target ) {
break;
}
// 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过
if (i ==start+1 && choices[i] == choices[i - 1]) {
continue;
}
// 尝试:做出选择,更新 target, start
state.add(choices[i]);
// 进行下一轮选择
backtrack(state, target - choices[i], choices, i, res);
// 回退:撤销选择,恢复到之前的状态
state.remove(state.size() - 1);
}
}
/* 求解子集和 I */
List<List<Integer>> subsetSumI(int[] nums, int target) {
Arrays.sort(nums); // 对 nums 进行排序
List<Integer> state = new ArrayList<>(); // 状态(子集)
int start = 0; // 遍历起始点
List<List<Integer>> res = new ArrayList<>(); // 结果列表(子集列表)
backtrack(state, target, nums, start, res);
return res;
}
4 N 皇后问题
Q:
根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定 𝑛个皇后和一个 𝑛 × 𝑛 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
1. 逐行放置策略
本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。
2. 列与对角线剪枝
3. 代码实现
/* 回溯算法:N 皇后 */
void backtrack(int row, int n, List<List<String>> state, List<List<List<String>>> res,
boolean[] cols, boolean[] diags1, boolean[] diags2) {
// 当放置完所有行时,记录解
if (row == n) {
List<List<String>> copyState = new ArrayList<>();
for (List<String> sRow : state) {
copyState.add(new ArrayList<>(sRow));
}
res.add(copyState);
return;
}
// 遍历所有列
for (int col = 0; col < n; col++) {
// 计算该格子对应的主对角线和副对角线
int diag1 = row - col + n - 1;
int diag2 = row + col;
// 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
// 尝试:将皇后放置在该格子
state.get(row).set(col, "Q");
cols[col] = diags1[diag1] = diags2[diag2] = true;
// 放置下一行
backtrack(row + 1, n, state, res, cols, diags1, diags2);
// 回退:将该格子恢复为空位
state.get(row).set(col, "#");
cols[col] = diags1[diag1] = diags2[diag2] = false;
}
}
}
/* 求解 N 皇后 */
List<List<List<String>>> nQueens(int n) {
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
List<List<String>> state = new ArrayList<>();
for (int i = 0; i < n; i++) {
List<String> row = new ArrayList<>();
for (int j = 0; j < n; j++) {
row.add("#");
}
state.add(row);
}
boolean[] cols = new boolean[n]; // 记录列是否有皇后
boolean[] diags1 = new boolean[2 * n - 1]; // 记录主对角线是否有皇后
boolean[] diags2 = new boolean[2 * n - 1]; // 记录副对角线是否有皇后
List<List<List<String>>> res = new ArrayList<>();
backtrack(0, n, state, res, cols, diags1, diags2);
return res;
}
5 小结
1. 重点回顾
- 回溯算法本质是一种穷举法,通过深度优先遍历解空间来寻找满足条件的解。在搜索中,尝试与回退是两个关键操作。
- 剪枝是提高回溯算法效率的重要手段,能够提前结束不必要的搜索分支。
- 回溯算法主要用于搜索问题和约束满足问题,而组合优化问题可能有更高效的解法。
2. 全排列问题
- 在全排列问题中,通过数组记录元素选择情况,避免重复选择相同元素。
- 针对集合中存在重复元素的情况,使用哈希表确保相等元素在每轮中只能被选择一次。
3. 子集和问题
- 子集和问题旨在找到和为目标值的所有子集。通过排序数组和设置遍历起点,剪枝掉重复搜索分支。
- 数组中相等元素可能产生重复集合,通过判断相邻元素是否相等实现剪枝。
4. 𝑛 皇后问题
- 𝑛皇后问题要求在 𝑛 × 𝑛 尺寸棋盘上放置皇后,保证它们两两之间无法攻击。列约束、主对角线和副对角线约束的处理方式。
- 数组用于记录列约束和对角线约束信息,确保每个格子的选择是合法的。
怎么理解回溯和递归的关系?总的来看,回溯是一种“算法策略”,而递归更像是一个“工具”。‧ 回溯算法通常基于递归实现。然而,回溯是递归的应用场景之一,是递归在搜索问题中的应用。‧ 递归的结构体现了“子问题分解”的解题范式,常用于解决分治、回溯、动态规划(记忆化递归)等问题。