目录
1. 概述
1.1 回溯思想
回溯算法(Backtrack)是一种试错思想,本质上是深度优先搜索。即:从问题的某一种状态出发,依次尝试现有状态可以做出的选择从而进入下一个状态。递归这个过程,如果在某个状态无法做更多选择,或者已经找到目标答案时,则回退一步甚至多步重新尝试,直到最终所有选择都尝试过。
整个过程就像走迷宫一样,当我们遇到一个分叉口时,可以选择从一个方向进去尝试。如果走到死胡同则返回上一个分叉口,选择另外一条方向继续尝试,直到发现没有出口,或者找到出口。
1.2 回溯的三要素
理解了回溯算法的思想,下面我们来分析回溯的关键点。在回溯算法中,需要考虑三个要素:路径、选择列表、结束条件,以走迷宫为例:
- 1. 路径:已经做出的选择
- 2. 选择列表:当前状态可以做出的选择
- 3. 结束条件:选择列表为空,或者找到目标
要走出这个迷宫,我们需要重复做一件事:选择从一个方向进去尝试。如果走到死胡同则返回上一个分叉口,选择另外一条方向继续尝试。用程序实现出来,这个重复做的事就是递归函数,实际中我们可以遵循一个解题模板 & 思路:
fun backtrack(){
1\. 判断当前状态是否满足终止条件
if(终止条件){
return solution
}
2\. 否则遍历选择列表
for(选择 in 选择列表){
3\. 做出选择
solution.push(选择)
4\. 递归
backtrack()
5\. 回溯(撤销选择)
stack.pop(选择)
}
}
需要注意的是,解题框架 & 思路不是死板的,应根据具体问题具体分析。例如:回溯(撤销选择)并不是必须的,第 3.2 节第 k 个排序、第 5 节岛屿数量问题中,它们在深层函数返回后没有必要回溯。
1.3 回溯剪枝
由于回溯算法的时间复杂度非常高,当我们遇到一个分支时,如果“有先见之明”,能够知道某个选择最终一定无法找到答案,那么就不应该去尝试走这条路径,这个步骤叫作剪枝。
那么,怎么找到这个“先见之明”呢?经过我的总结,大概有以下几种情况:
- 重复状态
例如:47. Permutations II 全排列 II(用重复数字) 这道题给定一个可包含重复数字的序列,要求返回所有不重复的全排列,例如输入[1,1,2]
预期的输出为[1,1,2]、[1,2,1]、[2,1,1]
。用我们前面介绍的解题模板,这道题并不难:
class Solution {
fun permute(nums: IntArray): List<List<Int>> {
val result = ArrayList<List<Int>>()
// 选择列表
val useds = BooleanArray(nums.size) { false }
// 路径
val track = L