枚举

枚举也称暴力搜索,指的是穷举所有可能情况,从而达到求解的目的。枚举法非常通用,缺点是算法的冗余比较多。

我们这里引入两个概念:数据空间和状态空间。数据空间是欧氏空间还是非欧空间取决于数据的结构,状态空间是欧氏空间还是非欧空间取决于算法的架构。数据空间是否是欧氏空间与状态空间是否是欧氏空间没有必然联系,即使数据空间是同种类型,如果采用不同的算法,状态空间也有可能是不同类型的。

对数据空间的遍历可以在数据结构章节找到,这里主要介绍的是对状态空间的枚举。

根据所求解的内容,枚举题又可以分为两种:枚举结点和枚举路径。枚举结点/路径是指:求解有多少种可能的结点/路径(可行性)或者是求解最优的结点值/路径值是什么(最优性)。

\[\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不会到边界之外)。