回溯法
实际上回溯算法就是一个 N 叉树的前序遍历加上后序遍历而已,而且回溯算法是有模板的,一旦掌握,就能秒杀相关问题。下面,我们来循序渐进地理解。
// 二叉树遍历框架
def traverse(root):
if root is None: return
# 前序遍历代码写在这
traverse(root.left)
# 中序遍历代码写在这
traverse(root.right)
# 后序遍历代码写在这
// N 叉树遍历框架
def traverse(root):
if root is None: return
for child in root.children:
# 前序遍历代码写在这
traverse(child)
# 后序遍历代码写在这
算法的整体框架
回溯算法就是 N 叉树的遍历,这个 N 等于当前可做的选择(choices)的总数,同时,在前序遍历的位置作出当前选择(choose 过程),然后开始递归,最后在后序遍历的位置取消当前选择(unchoose 过程)。回溯算法伪代码模板如下:
"""
choiceList:当前可以进行的选择列表
track:可以理解为决策路径,即已经做出一系列选择
answer:用来储存我们的符合条件决策路径
"""
def backtrack(choiceList, track, answer):
if track is OK:
answer.add(track)
else:
for choice in choiceList:
# choose:选择一个 choice 加入 track
backtrack(choices, track, answer)
# unchoose:从 track 中撤销上面的选择
回溯算法相当于一个决策过程,递归地遍历一棵决策树,穷举所有的决策,同时把符合条件的决策挑出来。
如何让计算机正确地穷举并比较所有决策,就需要回溯算法的设计技巧了,回溯算法的核心就在于如何设计 choose 和 unchoose 部分的逻辑。
下文通过讲解全排列问题(permutation)和 N 皇后问题来详述回溯算法的原理和技巧
全排列问题
给定一个没有重复数字的序列,返回全排列。比如输入[1,2,3]
我们的思路是,先把第一个数固定为 1,然后全排列 2,3;再把第一个数固定为 2,全排列 1,3;再把第一个数固定为 3,全排列 1,2
可以发现每向下走一层就是在「选择列表」中挑一个「选择」加入「决策路径」,然后把这个选择从「选择列表」中删除(以免之后重复选择);当一个决策分支探索完成后,我们就要向上回溯,要把该分支的「选择」从「决策列表」中取出,然后把这个「选择」重新加入「选择列表」(供其他的决策分支使用)。
以上,就是模板中 choose 和 unchoose 的过程,choose 过程是向下探索,进行一选择;unchoose 过程是向上回溯,撤销刚才的选择。
这下应该十分清楚了,现在我们可以针对全排列问题来具体化一下回溯算法模板
然后,根据这个思路写代码,虽然不够漂亮,但是已经符合回溯算法的设计模式并且可以解决问题了:
N皇后问题
这个问题大家应该都有耳闻:国际象棋的皇后可以横着,竖着,斜着进行攻击;给一个 N × N 的棋盘和 N 个皇后,如何放置这些皇后,才能使得任意两个都不能互相攻击到?
直接看代码,你会发现和上面的 Java 代码是完全相同的模板:
复杂度分析
分析一下回溯算法的时间复杂度吧。递归树的复杂度都是这样分析:总时间 = 递归树的节点总数 × 每个递归节点需要的时间。
全排列问题,节点总数等于 n + n*(n-1) + n*(n-2) … * n!,总之不超过 O(n*n!)。
对于 Java 代码的那个解法,处理每个节点需要 O(n) 的时间,因为 track.contains(nums[i]) 这个操作要扫描数组。
所以全排列问题总时间不超过 O(n^2 * n!)。
N 皇后问题,节点总数为 n + n^2 + n^3 + … + n^n,不超过 O(n^(n+1))。
处理每个节点需要向上扫描棋盘以免皇后互相攻击,需要 O(n) 时间。
所以 N 皇后问题总时间不超过 O(n^(n+2))。
可见,回溯算法的复杂度是极其高的,甚至比指数级还高,因为树形结构注定了复杂度爆炸的结局。
你可能会问,之前动态规划一文中讲到的优化方法不是专门优化树形结构的时间复杂度的吗?不是能降维打击吗?
但这里无法做任何优化。再强调一遍,计算机做事的策略就是穷举。动态规划的一大特征:重叠子问题,可以让那类问题“聪明地穷举”。但回顾一下回溯算法的决策树,根本不存在重叠子问题,优化无从谈起。
就好比让你找出数组中最大的元素,你起码得把数组全部遍历一遍吧,还能再优化吗?不可能的。