回溯算法 vs 深度优先遍历
维基百科中「回溯算法」和「深度优先遍历」的定义:
回溯法 采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:
- 找到一个可能存在的正确的答案;
- 在尝试了所有可能的分步方法后宣告该问题没有答案。
深度优先搜索 算法(英语:Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。这个算法会 尽可能深 的搜索树的分支。当结点 v 的所在边都己被探寻过,搜索将 回溯 到发现结点 v 的那条边的起始结点。这一过程一直进行到已发现从源结点可达的所有结点为止。如果还存在未被发现的结点,则选择其中一个作为源结点并重复以上过程,整个进程反复进行直到所有结点都被访问为止
「回溯算法」强调了「深度优先遍历」思想的用途,用一个 不断变化 的变量,在尝试各种可能的过程中,搜索需要的结果。强调了 回退 操作对于搜索的合理性。而「深度优先遍历」强调一种遍历的思想,与之对应的遍历思想是「广度优先遍历」。
回溯算法 vs 动态规划
共同点
用于求解多阶段决策问题。多阶段决策问题即:
- 求解一个问题分为很多步骤(阶段);
- 每一个步骤(阶段)可以有多种选择。
不同点
- 动态规划只需要求评估最优解是多少,最优解对应的具体解是什么并不要求。因此很适合应用于评估一个方案的效果;
- 回溯算法可以搜索得到所有的方案(当然包括最优解),但是本质上它是一种遍历算法,时间复杂度很高。
LeetCode 22. 括号生成
题目
回溯
该类问题是在一棵隐式的树上求解,可以用深度优先遍历,也可以用广度优先遍历。用深度优先遍历,代码好写,使用递归的方法,直接借助系统栈完成状态的转移。广度优先遍历需自己编写节点类和借助队列。
以n=2 为例,画出的树形结构图如下:
public class Solution {
public List<String> generateParenthesis(int n) {
List<String> res = new ArrayList<>();
if (n == 0) {
return res;
}
dfs("", n, n, res);
return res;
}
private void dfs(String curStr, int left, int right, List<String> res) {
if (left == 0 && right == 0) {
res.add(curStr);
return;
}
if (left > right) {
return;
}
if (left > 0) {
dfs(curStr + "(", left - 1, right, res);
}
if (right > 0) {
dfs(curStr + ")", left, right - 1, res);
}
}
}
「回溯算法」强调了在状态空间特别大的时候,只用一份状态变量去搜索所有可能的状态,在搜索到符合条件的解的时候,通常会做一个拷贝,这就是为什么经常在递归终止条件的时候,有 res.add(new ArrayList<>(path)); 这样的代码。正是因为全程使用一份状态变量,因此它就有「恢复现场」和「撤销选择」的需要
严格按照「回溯法」的定义的代码如下:
public class Solution {
public List<String> generateParenthesis(int n) {
List<String> res = new ArrayList<>();
if (n == 0) {
return res;
}
StringBuilder path = new StringBuilder();
dfs(path, n, n, res);
return res;
}
private void dfs(StringBuilder path, int left, int right, List<String> res) {
if (left == 0 && right == 0) {
res.add(path.toString());
return;
}
if (left > right) {
return;
}
if (left > 0) {
path.append("(");
dfs(path, left - 1, right, res);
path.deleteCharAt(path.length() - 1);
}
if (right > 0) {
path.append(")");
dfs(path, left, right - 1, res);
path.deleteCharAt(path.length() - 1);
}
}
}
LeetCode 39. 组合总和
题目
回溯
这一类问题都需要先画出树形图,然后编码实现。编码通过 深度优先遍历 实现,使用一个列表,在 深度优先遍历 变化的过程中,遍历所有可能的列表并判断当前列表是否符合题目的要求。
以输入:candidates=[2,3,6,7],target=7 为例:
这棵树有 4 个叶子结点的值 0,对应的路径列表是 [[2, 2, 3], [2, 3, 2], [3, 2, 2], [7]] ,而示例中给出的输出只有 [[7], [2, 2, 3]] 。即:题目中要求每一个符合要求的解是 不计算顺序 的。
产生重复的原因是:在每一个结点,做减法,展开分支的时候,由于题目中说 每一个元素可以重复使用,我们考虑了 所有的 候选数,因此出现了重复的列表。
通常会想到借助哈希表(HashSet )天然的去重功能,但实际操作并没有那么容易实现。
可以通过设置 **下一轮搜索的起点begin **实现在搜索的过程中去重:对于这一类相同元素不计算顺序的问题,在搜索的时候就需要 按某种顺序搜索,见下图:
class Solution {
List<List<Integer>> res = new ArrayList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
dfs(candidates, target, 0, new ArrayList<>());
return res;
}
private void dfs(int[] nums, int target, int begin, List<Integer> path) {
if (0 == target) {
res.add(new ArrayList<>(path));
return ;
}
if (0 > target) {
return ;
}
for (int i = begin; i < nums.length; i++) {
path.add(nums[i]);
dfs(nums, target - nums[i], i, path);
path.remove(path.size() - 1);
}
}
}
什么时候使用 used 数组,什么时候使用 begin 变量
- 排列问题,讲究顺序(即
[2, 2, 3] 与 [2, 3, 2] 视为不同列表时),需要记录哪些数字已经使用过,此时用 used 数组; - 组合问题,不讲究顺序(即
[2, 2, 3] 与 [2, 3, 2] 视为相同列表时),需要按照某种顺序搜索,此时使用 begin 变量。
LeetCode 46. 全排列
题目
回溯
本题的树形结构如图:
class Solution {
List<List<Integer>> res = new ArrayList<>();
public List<List<Integer>> permute(int[] nums) {
boolean[] used = new boolean[nums.length];
dfs(nums, 0, new ArrayList<>(), used);
return res;
}
private void dfs(int[] nums, int n, List<Integer> path, boolean[] used) {
if (n == nums.length) {
res.add(new ArrayList<>(path));
return ;
}
for (int i = 0; i < nums.length; i++) {
if (!used[i]) {
path.add(nums[i]);
used[i] = true;
dfs(nums, n + 1, path, used);
used[i] = false;
path.remove(path.size() - 1);
}
}
}
}
Reference
|