文章目录
当我们遇到某个需要解决的新问题时,如果没有明确的思路,有一个比较笨的着手点,就是穷举法,尝试遍历所有可能的解,然后选择我们需要的解。要实现无遗漏的完全穷举,也需要按照一定的规律遍历可能解的集合,我们应遵循怎样的规律来保证无遗漏的完全穷举所有可能的解呢?
一、穷举法与遍历规律
穷举所有可能的解比较抽象,穷举实际上就是遍历,我们考虑遍历一组数据中的所有元素更直观些。我们把一个数据结构中的尾结点或叶子结点看作一个问题的解,把该数据结构中的首结点或根结点看作一个问题的初始条件,把中间结点看作问题求解的中间状态或中间结果。穷举所有可能的解,实际上就是从初始条件也即首结点或根结点出发,遍历所有能达到尾结点或者叶子结点的路径,遍历所有路径自然也遍历了所有元素。
对于线性表结构,不管是数组还是链表,一般都是从头开始顺序遍历所有元素,从首结点到尾结点的路径只有一条,比如求n 的阶乘问题。对于更复杂的树结构,遍历方法就不止一种了。以简单的二叉树为例,前序、中序、后序遍历比较类似,都是先从根结点一直找到左叶子结点,然后回退一步往右找其它的叶子结点;层序遍历跟前者不一样,都是先从上往下从左往右逐层遍历,最后才遍历到叶子结点,两种遍历方法如下图示:
假如把根结点看作初始条件,叶子结点看作该问题的所有可能的解。上图左边的二叉树遍历方式优先从根结点选择一条路径遍历到左叶子结点,也即先找到一个可能的解,然后回退找其它可能的解,直到最后穷举出所有可能的解。上图右边的二叉树遍历方式优先从根结点遍历到所有可能的下一个中间状态,直到最后才遍历到所有可能的解。前者优先增加深度,先找到一个可能的解,因此也叫深度优先搜索DFS(Deep First Search);后者优先扩展广度,不放过任何一种求解路径,最后才推进到可能的解,因此也叫广度优先搜索BFS(Breath First Search)。
DFS与BFS 都可以穷举所有可能的解,但二者的侧重点有何不同呢?
- DFS(Deep First Search):不管刚开始有几种可能的求解路径,先选择一条路径获得一个可能的解,如果只是要找某问题的其中一个可能解,很显然DFS 更高效。
- BFS(Breath First Search):先不着急求解,重点看有几种可能的求解路径,然后分别沿所有可能路径往可能解的方向推进,逐阶段逐过程一步步推进,很显然路径最短或步骤最少的解会最先被找到,如果只是找步骤最少的解,则BFS 更高效。
如果要穷举所有可能的解,DFS 与BFS 都需要遍历所有结点,二者效率有区别吗?DFS 在找到某个解后,在原求解路径上回退然后搜索其它可能的解,回退的中间结果因为解已经求出而不再需要保存了,也即当前只需要保存已经找到的解和当前求解路径上的中间结果。BFS 是最后才找到可能解的,整个遍历过程不回退,在找到可能解之前需要保留所有的中间结果,因此占用的存储资源比DFS 更大。因此,如果要穷举所有可能的解,DFS 需要保存的中间结果更少,更节省存储资源。
本文的关注点是如何高效穷举所有可能的解,所以主要介绍DFS 深度优先遍历方式。我们把根结点看作初始状态,叶子结点看作结束状态,中间结点看作求解的中间状态,从初始状态到可能解的结束状态加上从初始状态递推到结束状态途径的中间状态,共同构成了一个求解路径。每个求解路径都是从初始状态开始,按照求解的递推公式逐层接近可能解的结束状态,因此比较适合使用递归调用来求解(当然也可以使用栈模拟递归,或者将其改为迭代形式,不过递归更直观易读)。比如二叉树和多叉树的遍历函数:
// 二叉树的遍历
void binaryTraverse(pTreeNode T)
{
// 递归遍历的结束条件,若T->LeftChild 为空说明结点 T 向左遍历到达终点,开始往右遍历;若T->RightChild 为空说明结点 T 向右遍历到达终点,返回上一个结点继续往右遍历
if (T == NULL)
return;
// 前序遍历在此处输出当前结点 T
binaryTraverse(T->LeftChild);
// 中序遍历在此处输出当前结点 T
binaryTraverse(T->RightChild);
// 后序遍历在此处输出当前结点 T
}
// 多叉树的遍历
void treeTraverse(pTreeNode T)
{
// 递归遍历的结束条件,若结点T 为空,则其父结点在该方向上到达终点,继续往右遍历或者返回上一个结点后往右遍历
if (T == NULL)
return;
// 每个结点逐个遍历其所有子节点
for(pTreeNode pChild : T->child) {
// 前序遍历在此处输出当前结点 T
treeTraverse(pChild);
// 后序遍历在此处输出当前结点 T
}
}
树结构的遍历是按照特定规律遍历完所有的结点,并非每个叶子结点都是一个可能的解,而是完全遍历后才是唯一解,但我们可以借鉴树结构的深度优先遍历规律来穷举所有可能的解。我们把多叉树的每个结点看作一个状态,每个结点的多个子结点看作一个选择,使用一个参数来保存当前从初始状态到结束状态的可能解路径,到达叶子结点时判断可能解路径是否满足要求,若满足要求就保存到可能解的集合中,若不满足要求则回退到上一个状态(也即该叶子结点的父结点)继续往右遍历其它可能解路径,伪代码如下:
// DFS 遍历所有可能的解
void backTracking(vector<typename>& slects, vector<typename>& path, vector<vector<typename>>& results)
{
// 递归遍历的结束条件,若满足结束条件则将可能的解放入解的集合,返回到上一个状态继续往右遍历
if (isValid(path) == true) {
results.push_back(path);
return;
}
// 每个结点状态逐个遍历可能的选择分支
for(typename option : slects) {
// 跳过一些不符合要求的选择分支,以提高效率(也即回溯剪枝中的剪枝思想)
if(isChoise(option) == false) continue;
// 将当前选择的结点 option 添加到当前求解路径 path 中
path.push_back(option);
// 按照求解的递推公式,沿着求解路径向可能解的状态靠近
backTracking(slects, path, results);
// 将当前选择的结点 option 从当前求解路径 path 中移除,继续尝试新的选择分支
path.pop_back();
}
}
这种从初始条件按照递推公式沿选择路径遍历到可能解的过程跟树结构深度优先遍历过程很类似,都是优先沿着可选分支靠左的路径往下推进到结束状态。由于沿当前求解路径推进到结束状态后,需要回退到上一个选择结点,以便选择其它的分支,同时要将末端的结点从求解路径中移除。整个求解过程都是按照树结构的DFS 遍历路径进行选择、回退和判断,因此这种求解方法也被称为回溯思想,强调了选择递推之后的回退撤销过程。
在选择后续分支路径时,有些分支明显不符合求解要求,可以提前跳过这些选择分支,相当于在树结构的剪去一些枝杈,因此也被称为剪枝过程,剪枝可以提高程序的执行效率,跟前面的回溯思想结合起来,就是回溯剪枝算法。
回溯思想与前面介绍的分治思想在解决问题时的思路有很大区别,二者的不同主要如下:
- 分治思想:在空间上将大规模问题分割为多个相互独立且结构相似的小规模子问题,这类问题规模小到一定程度可以直接求解,再将多个子问题的解合并为原问题的解。由此可见,分治算法很适合用来处理大规模问题,通过缩小规模大幅降低计算复杂度,因为分割出的子问题相互独立,也很适合使用并行计算,比如MapReduce;
- 回溯思想:在时序上将原问题拆解为前后相继的多选择分支多阶段子问题(最后阶段也有多个可行解),从初始状态按照DFS 规律向最后阶段的可行解状态逐阶段求解,到达一个可行解状态再回退到上一阶段继续沿其它的求解分支获得其它可行解,满足约束条件的所有可行解集合构成一个解空间,比如排列组合问题就很适合使用回溯思想解决。
二、排列问题
从 n 个不同的元素中取出 m(1 ≤ m ≤ n)个不同的元素,按照一定的顺序排成一列,这个过程就叫排列(Permutation),当 m=n 时称为全排列(All Permutation)。如果选择出的这 m 个元素可以有重复的,这样的排列是为重复排列(Permutation with Repetition),否则就是不重复排列(Permutation without Repetition)。
2.1 全排列问题
从n 个不同的元素中取出m 个不同的元素构成的不重复排列个数为 A n m = n ! ( n − m ) ! A_n^m = \frac{n!}{(n-m)!} Anm=(n−m)!n!,n 个元素构成的不重复全排列个数为 A n n = n ! A_n^n = n! Ann=n! 个。我们怎么获得这些排列呢?
容易想到的一个思路是,n 个元素设置n 个格子,从第一个格子开始往后逐个放置元素,当n 个格子都放进元素后就构成一个排列。往格子里放元素的过程是分阶段的,每个阶段放哪个元素也是有多个选择的,这个过程比较符合前面介绍的多叉树DFS 遍历,可以使用n 层的多叉树来表示往格子里放元素构成排列的过程。
举个简单的例子,数字序列{1, 2, 3} 的全排列可以使用四层多叉树表示,根结点初始状态格子无元素,第一个格子放哪个元素有三个选择分支,第一阶段就有三种放置一个元素的状态,第二个格子放哪个元素剩下两个选择分支,第二阶段分别有两种放置两个元素的状态,最后一个格子只剩一个选择分支,最后阶段就得到了所有的全排列。这个过程可以表示成下图所示的多叉树(按照多叉树DFS 回溯遍历规律就可以找到所有的排列):
这个多叉树跟决策树比较像,求解排列问题的重点是分支选择(也即做决策),决策树的每一层即为一个阶段,每个阶段选择一个元素,当选择够m 个元素即构成一个排列,回溯到上一层继续选择其它分支,直到DFS 遍历完整个决策树就获得了所有排列构成的解空间。要获得一个排列,我们需要记录当前选择路径上已经选择的元素,往上层回溯时需要删除不在当前选择路径上的元素,还有一个重点是不能选择前面已经选择过的元素,我们可以使用一个变量标记已选择过的元素,后面只能选择未被标记过的元素,当然回溯时需要取消标记。
我们从leetcode 上选择一道题,看看全排列问题如何使用回溯算法解决:
[Leetcode - 46] 全排列:给定一个 没有重复 数字的序列,返回其所有可能的全排列。
按照前面介绍的回溯算法伪代码,实现无重复数字序列全排列的函数代码如下:
#include<vector>
#include<iostream>
using namespace std;
void backTracking(vector<int>& nums, vector<vector<int>>& results)
{
// 使用static 变量只在第一次定义并初始化,后续递归调用不会再重新定义或初始化,用于记录选择路径、标记已选择元素
static vector<int> path;
static vector<bool> visited(nums.size(), false);
// 递归遍历的结束条件,若满足结束条件则将该排列放入解空间并返回
if(path.size() == nums.size()){
results.push_back(path);
return;
}
// 每个结点状态逐个遍历可能的选择分支
for (int i = 0; i < nums.size(); ++i) {
// 跳过前面已选择的分支,也即剪枝,提高效率
if(visited[i] == true)
continue;
// 将分支nums[i] 添加到当前选择路径,并标记该元素已选择
path.push_back(nums[i]);
visited[i] = true;
// 按照DFS 遍历规律,沿当前选择路径继续向下一阶段递归
backTracking(nums, results);
// 回退到上一阶段并将分支nums[i] 从当前选择路径移除,取消该元素的标记
path.pop_back();
visited[i] = false;
}
}
void printResults(vector<vector<int>>& results)
{
// 如果没有满足要求的解,打印提示信息并返回
if(results.empty()){
cout << "There is no correct solution." << endl;
return;
}
// 打印所有满足要求的解,n+1 为第几个解
for(int n = 0; n < results.size(); ++n){
cout << n + 1 << ":\t";
for(const auto elm : results[n])
cout << elm << '\t';
cout << endl;
}
}
int main(void)
{
vector<int> nums = {
1, 2, 3, 4, 5};
vector<vector<int>> results;
backTracking(nums, results);
printResults(results);
return 0;
}
这道题中的数字序列换成字符序列也是一样的解法,只改变元素类型就可以了。另一个限制条件是无重复元素序列的全排列,假如该序列包含重复元素会怎样呢?
[Leetcode - 47] 全排列 II:给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
我们很容易想到假如两个元素重复,这俩元素互换位置后的另一个排列与原排列相同,也即获得的全排列中有重复的排列。知道原因就很容易想到解决方案,一种简单的方案是最后将全排列的解空间进