枚举也称暴力搜索,指的是穷举所有可能情况,从而达到求解的目的。枚举法非常通用,缺点是算法的冗余比较多。
我们这里引入两个概念:数据空间和状态空间。数据空间是欧氏空间还是非欧空间取决于数据的结构,状态空间是欧氏空间还是非欧空间取决于算法的架构。数据空间是否是欧氏空间与状态空间是否是欧氏空间没有必然联系,即使数据空间是同种类型,如果采用不同的算法,状态空间也有可能是不同类型的。
对数据空间的遍历可以在数据结构章节找到,这里主要介绍的是对状态空间的枚举。
根据所求解的内容,枚举题又可以分为两种:枚举结点和枚举路径。枚举结点/路径是指:求解有多少种可能的结点/路径(可行性)或者是求解最优的结点值/路径值是什么(最优性)。
\[\text{枚举状态空间} \left\{ \begin{aligned} &\text{欧氏状态空间(线性空间)} \Rightarrow \text{循环} \left\{ \begin{aligned} &\text{结点}\\ &\text{路径} \end{aligned} \right. \\ &\text{非欧状态空间(树或森林)} \left\{ \begin{aligned} &\text{递归} \left\{ \begin{aligned} &\text{结点}\Rightarrow\text{DFS}\\ &\text{路径}\Rightarrow\text{回溯剪枝} \end{aligned} \right. \\ &\text{循环} \left\{ \begin{aligned} &\text{结点}\Rightarrow\text{BFS}\\ &\text{路径}\Rightarrow\text{分支限界} \end{aligned} \right. \end{aligned} \right. \end{aligned} \right.\]数据空间由题目给定,而状态空间由算法生成,所以设计枚举算法时最重要的是要考虑,状态的定义是什么,状态之间如何转移。 状态的定义与转移没有固定的范式,主要依靠经验来确定(所以还是要多刷题啊)。
在数据结构章节对数据空间的遍历,我们还强调到底是自顶向下还是自底向上。但是对状态空间的枚举,我们一般都是自顶向下就可以解决问题,不再区分这两个概念了。
如果状态空间是欧氏空间(线性空间),直接通过循环就可以实现。算法框架就是多重循环,对于不再必要的枚举可以采用break/continue机制跳过。
如果状态空间是非欧空间(树或森林),可以通过递归和循环实现。
非欧状态空间的DFS和BFS详见深度优先搜索和宽度优先搜索,不再赘述。这里主要介绍回溯剪枝和分支限界。
回溯剪枝法根据尝试访问的位置的不同,可以分为两种。
1、尝试访问自身:
void search(int x) {
if (x到达递归边界) {
// 得到一条到边界的路径(意味着得到一个解),进行处理
return;
}
if (x满足剪枝条件) return;
// 尝试x,更新状态
for (遍历x的所有可能后继y) {
if (y到达递归边界) {
// 得到一条到边界的路径(意味着得到一个解),进行处理
continue;
}
if (y满足剪枝条件) continue;
search(y);
}
// 还原现场
}
search(s); // 调用search,s为状态空间根结点
2、尝试访问后继:
void search(int x) {
if (x到达递归边界) {
// 得到一条到边界的路径(意味着得到一个解),进行处理
return;
}
if (x满足剪枝条件) return;
for (遍历x的所有可能后继y) {
if (y到达递归边界) {
// 得到一条到边界的路径(意味着得到一个解),进行处理
continue;
}
if (y满足剪枝条件) continue;
// 尝试y,更新状态
search(y);
// 还原现场
}
}
// 在此处尝试s,更新状态
search(s); // 调用search,s为状态空间根结点
// 还原现场
注解:回溯剪枝算法框架中,判断剪枝条件、判断递归边界、进行访问操作都有两种位置,总结起来区别就是,在扩展后继之前的剪枝条件/递归边界/访问操作是针对自身的,在扩展后继之后的剪枝条件/递归边界/访问操作是针对后继的:
(1)对于访问操作的两种位置,如果采用针对后继的访问操作,在整个递归过程中是访问不到根结点的,如果需要访问根结点只能在递归外部进行,而采用针对自身的访问操作,在递归过程中能够访问到根结点。不过如果状态空间是森林型结构,构造一个无意义结点作为几个树型结构的父结点,那么整个状态空间就变成了一个树型结构,并且不需要访问根结点,则采用针对后继的访问操作更为方便。
(2)剪枝条件有两种位置,剪枝后继比剪枝自身的优点是,剪枝后继时还可以看到前驱结点,如果要利用前驱结点的信息进行剪枝,就必须使用剪枝后继。
(3)递归边界有两种位置,如果是在扩展后继之前,递归会进入空指针(即x已经在边界之外),如果是在扩展后继之后,递归不会进入空指针(y不会到边界之外)。
分支限界法根据尝试访问的位置的不同,可以分为两种。
1、尝试访问自身:
queue<int> q;
// 在此处尝试s,更新状态
q.push(s); // 从s结点开始搜索
while (!q.empty()) {
auto t = q.front();
q.pop();
if (t到达循环边界) {
// 得到一条到边界的路径(意味着得到一个解),进行处理
continue;
}
if (满足限界条件) continue;
for (遍历t的所有可能后继y) {
if (t到达循环边界) {
// 得到一条到边界的路径(意味着得到一个解),进行处理
continue;
}
if (满足限界条件) continue;
// 尝试y,更新状态
q.push(y);
}
}
2、尝试访问后继:
queue<int> q;
q.push(s); // 从s结点开始搜索
while (!q.empty()) {
auto t = q.front();
q.pop();
if (t到达循环边界) {
// 得到一条到边界的路径(意味着得到一个解),进行处理
continue;
}
if (满足限界条件) continue;
// 尝试t,更新状态
for (遍历t的所有可能后继y) {
if (t到达循环边界) {
// 得到一条到边界的路径(意味着得到一个解),进行处理
continue;
}
if (满足限界条件) continue;
q.push(y);
}
}
注解:分支限界算法框架中,判断限界条件、判断循环边界、进行访问操作都有两种位置,总结起来区别就是,在扩展后继之前的限界条件/循环边界/访问操作是针对自身的,在扩展后继之后的限界条件/循环边界/访问操作是针对后继的:
(1)对于访问操作的两种位置,如果采用针对后继的访问操作,在整个循环过程中是访问不到根结点的,如果需要访问根结点只能在循环外部进行,而采用针对自身的访问操作,在循环过程中能够访问到根结点。不过如果状态空间是森林型结构,构造一个无意义结点作为几个树型结构的父结点,那么整个状态空间就变成了一个树型结构,并且不需要访问根结点,则采用针对后继的访问操作更为方便。
(2)限界条件有两种位置,限界后继比限界自身的优点是,限界后继时还可以看到前驱结点,如果要利用前驱结点的信息进行剪枝,就必须使用限界后继。
(3)循环边界有两种位置,如果是在扩展后继之前,循环会进入空指针(即x已经在边界之外),如果是在扩展后继之后,循环不会进入空指针(y不会到边界之外)。