LeetCode 中一系列问题使用回溯方法解决。本篇介绍一个通用的回溯方法框架。
回溯算法
概念
回溯算法系统地遍历搜索空间的所有可能。
以下以一种通用的方法说明。用向量 $a = (a_1, a_2, \ldots, a_n)$ 来表示一个 solution,其中的每个元素 $a_i$ 都来自一个有限有序集合 $S$。例如,该向量可以用来表示一个子集 $S$,如果 $S$ 中包括全集的第 $i$ 个元素那么 $a_i$ 为 true
。该向量也可表示一组排列中的某一种安排。
在回溯算法的每一个迭代步骤,我们从一个「部分解决的」solution 开始(用 $a = (a_{1}, a_{2},…, a_{k})$ 表示)。然后,通过在末尾添加一个元素来 扩展 这个solution。扩展后,还需检查是否已经得到了一个完整 solution —— 如果已经得到,可以执行打印、计数等操作;否则,检查该扩展后的 部分解决的 solution 仍满足可扩展性到完整 solution 的约束。如果满足那么执行递归操作并继续迭代;否则,需要删除刚添加的元素并尝试其他可能直到穷尽。
基本框架
上述对于算法的描述可以用代码表示如下。其中 finished
用来标志「提前结束」的情况。
1 | bool finished = FALSE; /* found all solutions yet? */ |
is_a_solution(a, k, input)
判断当前的部分解向量a[1..k]
是否是一个符合条件的解。construct_candidates(a, k, input, c, ncandidates)
根据目前状态,构造这一步可能的选择,存入c[]
数组,其长度存入ncandidates
。process_solution(a, k, input)
对于符合条件的解进行处理,通常是输出、计数等。make_move(a, k, input)
andunmake_move(a, k, input)
前者将所采用的选择更新到原数据结构中,后者把这一选择从原数据结构中删除。
结合实际优化框架
上述框架对于理解回溯算法有很好的帮助。但在实际应用时,通常有很多优化控件。
举例来说,在八皇后问题中,构造 candidates 时,根据 input 构造 candidates 时需要根据已有的皇后位置来确定下一行可能摆放的位置。这就需要传递皇后位置等参数。而实际上,这一步可以简化成:在循环进行时再考虑当前行的每一列是否可以放置皇后。这就避免了传参带来的低效。
下面 Combination Sum 一节也说明了另一个类似情况。
应用回溯算法解题
37 Sudoku Solver
按照以上基本框架,替换相应的内容,下面给出的代码还有部分优化操作。在最初尝试解答此题的时候出现了错误,原因是没有在回溯方法中(调用方法本身一步后)清理使用空间。
比照我原先效率低下的代码,这份简洁代码中:
- 没有使用
visited[][]
来标记已访问。因为在数独数组中,只要元素是.
就是未访问过,而且要注意访问完发现不满足时要及时 撤销 操作。visited[][]
记号源自回溯解决迷宫问题。在迷宫问题中,因为迷宫数组不可改变,需要另外设置标记符号。 - 直接在
solve()
中遍历board[][]
,以替代计算下一个candidate的操作。 - 用于检查数组合法性的
isValid()
很高效。我最初是采用标志位逐行逐列依次比较的方法(36题思路),而此题中只需一次循环比较特定位置是否满足就可以了。
1 | public class Solution { |
39 Combination Sum
题目描述
Given a set of candidate numbers (
candidates
) (without duplicates) and a target number (target
), find all unique combinations incandidates
where the candidate numbers sums totarget
.
The same repeated number may be chosen fromcandidates
unlimited number of times.
Note:
All numbers (includingtarget
) will be positive integers.
The solution set must not contain duplicate combinations.
Example 1:
1
2
3
4
5
6 Input: candidates = [2,3,6,7], target = 7,
A solution set is:
[
[7],
[2,2,3]
]
Example 2:
1
2
3
4
5
6
7 Input: candidates = [2,3,5], target = 8,
A solution set is:
[
[2,2,2,2],
[2,3,3],
[3,5]
]
使用框架时进行优化
如果不加修改地使用原来的框架,也能够解决问题,但效率太低。存在这样几个问题:
- 如果不传入多余的参数,需要每次反复进行数组求和计算。巧妙的办法是:每次递归调用时,用
target - candidates[i]
作为参数,而不是不加处理地传入target
,可以省去很多计算操作。 - 如果每次选取(本轮计算的)
candidates
时都默认从所有原始的candidates
中选取,则会出现[2, 2, 3]
和[2, 3, 2]
等其他同一组数的排列结果都会加入结果集合中。一个提升效率的方法是,比candidate[i]
小的数字不再作为新一轮递归调用的candidates
。注意,这不会导致漏选。 - 原先使用 HashSet 来消除 Solution List 中重复的数组,如果按上述第二条实现则无需考虑这一情况。
以下的实现来自 LeetCode 的讨论版块。
1 | public List<List<Integer>> combinationSum(int[] nums, int target) { |
40 Combination Sum II
题目描述
Given a collection of candidate numbers (
candidates
) and a target number (target
), find all unique combinations incandidates
where the candidate numbers sums totarget
.
Each number incandidates
may only be used once in the combination.
Note:
All numbers (includingtarget
) will be positive integers.
The solution set must not contain duplicate combinations.
Example 1:
1
2
3
4
5
6
7
8 Input: candidates = [10,1,2,7,6,1,5], target = 8,
A solution set is:
[
[1, 7],
[1, 2, 5],
[2, 6],
[1, 1, 6]
]
Example 2:
1
2
3
4
5
6 Input: candidates = [2,5,2,1,2], target = 5,
A solution set is:
[
[1,2,2],
[5]
]
代码实现
与 39 Combination Sum 类似,不再具体分析。不过有两个值得一提的注意点:
- 为了避免由于
candidates
中某一个数重复从而重复输出同一结果,在某些情况下不执行回溯操作。注意,这不会导致[1, 1, 6]
的漏解。 - 注意
backtrack()
调用时参数的设置:startIndex = i + 1
。
1 | public List<List<Integer>> combinationSum2(int[] nums, int target) { |
46 Permutations
1 | public List<List<Integer>> permute(int[] nums) { |
46 Permutations with duplicates
输出含有重复元素的全排列,不加处理地套用上一题的代码会得到:
1 | [1, 1, 2] |
需要对生成全排列的树形结构进行剪枝。具体来说,该树结构第一层节点是 1 / 1 / 2
,需要剪去中间的一个 1
;此外还需要剪去第一层节点 2
的孩子节点中的第二个节点。可以发现需要剪枝的节点是:位于 nums
数组中非首次出现的节点,还需注意这些节点需要满足:与它们相同的前序节点还未排列。这就可以通过修改上述代码来完成:
1 | public List<List<Integer>> permuteUnique(int[] nums) { |
附:回溯的其他应用
除了下面列出的几个经典应用,这个博客还列举了几个其他应用:输出不重复数字的全排列、求解数独(剪枝的示范)、给定字符串生成其字母的全排列、求一个n元集合的k元子集、电话号码生成字符串、一摞烙饼的排序等。
列出所有子集
通过迭代地计算出 $2^n$ 个长度为 $n$ 的集合,构造出 $n$ 个元素的 $2^n$ 个子集:其中每一个元素 $a_i \in {true, false}$ 表示该元素是否出现在集合中。那么,$S_k = (true, false),当 $k == n$ 时得到一个完整 solution。
1 | is_a_solution(int a[], int k, int n){ |
列出所有排列
当计算第 $i$ 个元素的可能情况时,必须考虑该元素不能与前 $i - 1$ 个元素重复,因此在 construct_candidates
时需要排除这些情况。该案例中,$S_k = {1, \ldots, n} - a$,当 $k == n$ 时得到一个完整 solution。
1 | construct_candidates(int a[], int k, int n, int c[], int *ncandidates){ |
其他函数的构造与上一案例类似。
「八皇后」问题
1 | construct_candidates(int a[], int k, int n, int c[], int *ncandidates){ |