天天看点

回溯法——N皇后问题与TSP问题回溯法——N皇后问题与TSP问题

回溯法——N皇后问题与TSP问题

对于一般的问题而言,很多时候无法找到其最优子结构性质或者其不是一个最优化问题,这些问题都是NP的。我们无法采用像动态规划一样的技巧简化求出某个具体解的过程,唯一能做的就是蛮力遍历所有可能的解。在蛮力法的基础上,假如把求每个解的过程组织成一个树状结构,从根节点到叶节点的每次遍历就是一个可能解,那么就可以采用深搜+剪枝的方式来简化计算,在计算之前就知道某些解是不可行的,因此可以说回溯法是特殊情况下对蛮力法的优化。

问题:

N后问题:在N×N的棋盘中放置N个皇后,使得任何两个皇后之间不能相互攻击(不在同一行,同一列,同一对角线),试给出所有的放置方法。

旅行售货员(货郎担TSP)问题:某售货员要到若干城市去推销商品,已知各城市间的路程耗费(代价),如何选定一条从驻地出发,经过每个城市一遍,最后回到驻地的路线,使得总路程耗费最小。

分析:

N皇后问题,不是最优化问题,仅仅是可行解问题,可行解也不唯一。即使将可行解视为最优解,子问题求得了一个可行解,该可行解也不一定构成父问题的可行解。

TSP问题,没有最优子结构,即使求得了一个最优路径,其子问题也不一定是最优的,因为有经过所有节点的要求。

二者都是蛮力求解。

尝试建立树状解空间,用回溯法求解。解空间的表示分为四个步骤:

  1. 问题解的表示:将问题的解表示成一个n元式 (x1, x2, …, xn) 的形式。
  2. 显示约束:对分量xi的取值限定。
  3. 隐示约束:为满足问题的解而对不同分量之间施加的约束。
  4. 解空间:解向量满足约束条件的所有多元组,构成了问题的一个解空间

因此N皇后问题:

  1. 问题解表示为(x1, x2, …, xn)
  2. 显示约束:xi in {1, 2, 3, … ,n}
  3. 隐示约束:xi和前面的所有x不同行不同列不同斜线,即xi != xj, |i - j| != |xi - xj|
  4. 解空间为满足显示约束和隐示约束的所有(x1, x2, …, xn)

TSP问题:

  1. 问题解表示为(x1, x2, …, xn)
  2. 显示约束:xi in {1, 2, 3, … ,n}
  3. 隐示约束:(xi, xi+1 ) in E和任意i, k in{1,2, 3, … ,n}, xi != xk, (xn, x1) in E
  4. 解空间为满足显示约束和隐示约束的所有(x1, x2, …, xn)

解空间的组织形式一般可分为两类,子集树和排列树。当所给的问题是从n个元素,每个元素有m种不同选择的集合S中找出满足某种性质的子集时,相应的解空间树称为子集树,子集树通常有mn个叶结点。当所给的问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树,排列树通常有n!个叶结点。

其中,已经被完全访问过的子树的根节点称为黑节点,未被完全访问过的子树的根节点称为灰节点,完全尚未被访问的子树的根节点称为白节点。其中白节点和灰节点统称为活结点,黑节点为死节点。

对于N皇后问题而言,目的是求可行解,而按可行解构造树比较难以实现,因此实际上常常会放宽搜索的范围。易于实现的解空间树就是子集树和排列树,N皇后问题两种树均可使用,其实就是判不判列的问题。

对于TSP问题而言,其可行解比较容易构造,就是排列树,其目的是求可行解中的一个最优解,因此可以用排列树求解。

由此刚好引出了两种剪枝方式。对于N皇后问题而言,假如当前灰节点已经不符合约束了,那么以其为根节点的所有子树都可以舍弃,这是约束函数剪枝;对于TSP问题而言,假如当前灰节点求得的值已经使得后面无论取什么值,得到的可行解都比当前记录的最优解小或者大了,那么以其为根节点的所有子树都可以舍弃,这是限界函数剪枝。

针对限界剪枝,回溯法必须满足多米诺性质,就是限界剪枝一定是正确的:

设P(x1, x2, …, xi)是关于向量<x1, x2, …, xi>的某个性质,那么P(x1, x2, …, xi+1)真蕴含P(x1, x2, …, xi)为真,即

P(x1, x2, …, xi+1) → P(x1, x2, …, xi) (0< i < n) (n为向量维数)

翻译过来就是子问题不对了,父问题也不能对(逆否命题)。

代码:

代码有挺多细节的,还是背一下模板比较好

#include<bits/stdc++.h>

using namespace std;

// 判断pos层选择的位置是否和前面已选的冲突
bool check(int res[],int pos){
    for(int i = 1;i < pos;i++){
        if((abs(i - pos) == abs(res[i] - res[pos])) || (res[pos] == res[i])){
            return false;
        }
    }
    return true;
}

// 子集树求解N皇后问题,递归写法
// 输入:初始层号pos,皇后数量n,结果记录数组res[]
void queenBacktrace(int pos,int n,int res[]){
    // 假如求得了一个可行解,那就输出这个解
    // res最后的结果不是最后一个可行解,这一点特别注意
    if(pos>n){
        for(int i = 1;i <= n;i++)
            cout<<res[i]<<" ";
        cout<<endl;
    }else{
        // 遍历当前节点的所有扩展节点
        for(int i = 1;i <= n;i++){
            // 假如该扩展节点满足要求
            res[pos] = i;
            if(check(res, pos)){
                res[pos] = i;
                queenBacktrace(pos+1, n, res);
            }
        }
    }
}

// 子集树求解N皇后问题,迭代写法
// 输入:皇后数量n,结果记录数组res[]
void queenIterate(int n, int res[]){
    // 初始从第一行开始
    int pos = 1;
    // 需要手动记录每一层的中断返回位置
    int f[9];
    // memset(f, 1, sizeof(f));
    // memset按照字节赋值,只能赋值0或-1
    for(int i = 0;i <= n;i++)
        f[i] = 1;
    // 难点在于何时退出while循环,观察递归可以发现,递归最后的动作是完成了第一行的所有扩展节点
    // 只有当某个节点的所有扩展节点都遍历完时,才会退回这个节点的上一层
    // 因此最后将会退回到第0层,递归终止
    while(pos>0){
        // 假如还有没遍历的扩展节点
        if(f[pos] <= n){
            // 遍历当前节点的所有未检查的扩展节点
            // 与递归的区别,没有递归自然的入栈出栈和记录断点了
            for(;f[pos] <= n;f[pos]++){
                res[pos] = f[pos];
                // 假如该节点满足要求
                if(check(res, pos)){
                    // 假如求得了一个可行解,那么就输出这个解
                  	// 不能像递归一样pos++然后统一到下一层处理,因为数组下标会越界
                    if(pos==n){
                        for(int i = 1;i <= n;i++)
                            cout<<res[i]<<" ";
                        cout<<endl;
                    }else{
                        pos++;
                        // 易错点,每一层有多个独立的子树,这些子树公用一个f[]
                        // 假如新来到一棵子树,那必须要重置这颗子树的f[]
                        // 否则f[]还是上一棵子树的值
                        f[pos] = 0;// 最坑的地方,这个循环出去的时候还有一次++,因此初始化为0
                    }
                }
            }
        }else{// 也可以在每一个子树遍历完之后清除f[]
            // 所有的扩展节点都遍历完了,那就回退一层
            pos--;
            f[pos]++;//易错点
        }
    }
}

int main(){
    int n = 8;
    int res[9];
    memset(res, 0, sizeof(res));

    queenIterate(n, res);
    cout<<endl;
    queenBacktrace(1, n, res);
}
           
#include<bits/stdc++.h>

using namespace std;

#define MAXN 105
#define INF 0x3f3f3f

// 邻接矩阵存储
int m[MAXN][MAXN];
// 结果记录数组,下标为第几个点,对应的是选的哪个点
int res[MAXN];
// 记录最优解
int bestRes[MAXN];
// 最优解的大小
int minRes = INF;


// 检查当前这一步选出的结果是否满足约束
// 当前共选了pos个点,一共有n个待选点
bool check(int pos,int n){
    // 就一个点,肯定是可行解,一般也不会用到,因为第一次就是从第二个点开始算的
    if(pos < 2) return true;
    // pos加入后,若当前的解还是处于中间过程中,检查新加入的点和上一个加入的点之间有没有连通
    if(pos<n && m[res[pos-1]][res[pos]]!=INF) return true;
    // pos加入后,若当前的解就是最终的解,检查新加入的点和上一个加入的点之间有没有连通,还要检查是否能构成一个闭合的回路
    if(pos==n && m[res[pos-1]][res[pos]]!=INF && m[res[pos]][res[1]]!=INF) return true;
    
    return false;
}

// 计算当前部分解的大小
int sum(int pos,int n){
    int nowRes = 0;
    if(pos < n){
        for(int i = 2;i <= pos;i++){
            nowRes += m[res[i]][res[i-1]];
        }
        return nowRes;
    }else{
        for(int i = 2;i <= n;i++){
            nowRes += m[res[i]][res[i-1]];
        }
        nowRes += m[res[n]][res[1]];
        return nowRes;
    }
}

// 限界函数
bool bound(int pos,int n){
    if(pos < 2) return true;
    if(pos <= n && sum(pos, n) <= minRes) return true;
    return false;
}

// 排列树求解TSP问题,递归
// 输入:起始城市是第pos个,默认从2开始,一共有n个城市
void TSPbacktrace(int pos,int n){
    // 假如找到了一个可行解
    if(pos>n){
        // 假如这个可行解比当前记录的最小可行解还要小
        if(sum(pos, n) < minRes){
            minRes = sum(pos, n);
            // 记录可行解为当前最优解
            for(int i = 0;i <= n;i++){
                bestRes[i] = res[i];
            }
        }
    }else{
        // 遍历所有的扩展节点
        // 由于是排列树,因此每次要选取没有选过的那个点
        // 为了方便,可以把已经选过的点放在一起,每次从没选过的那个点开始选就可以了
        // 为此需要做两点,第一点,不能单纯的按序号从小到大的顺序来遍历所有点了,因为选过的点可能是乱序的,因此要将res初始化为序号从小到大
        // 第二点,每次选了某个点后,将该点与当前res所对应的那个序号的点交换,这样就保证了选过的点放在一起
        for(int i = pos;i <= n;i++){
            swap(res[pos], res[i]);
            // 假如该点满足条件,约束函数+限界函数
            if(check(pos, n) && bound(pos, n)){
                TSPbacktrace(pos+1, n);
            }
            // 注意恢复现场,已经选过这个点了
            swap(res[pos], res[i]);
        }
    }
}

// 排列树求解TSP问题,迭代
// 输入:起始城市是第pos个,默认从2开始,一共有n个城市
void TSPiterate(int pos,int n){
    // 记录每一层访问到了哪个扩展节点
    int f[n+1];
    // 排列树和子集树的初始化不同
    for(int i = 0;i <= n;i++){
        f[i] = i;
    }
    
    while (pos >= 2) {
        for(;f[pos] <= n;f[pos]++){
            swap(res[pos], res[f[pos]]);
            if(check(pos, n) && bound(pos, n)){
                // 找到了可行解
                if(pos == n){
                    if(sum(pos, n) < minRes){
                        minRes = sum(pos, n);
                        // 记录可行解为当前最优解
                        for(int i = 0;i <= n;i++){
                            bestRes[i] = res[i];
                        }
                    }
                    // 理论分析应该有三种恢复现场的swap
                    // 2. 选了这个扩展节点,这个扩展节点还是叶节点,那么将不会向更深一层进发,会选下一个扩展节点,和第一种没选的情况同理
                    //(这里已经是最后一个位置了实际上没有交换)
                    //swap(res[pos], res[f[pos]]);
                }else{
                    pos++;
                    f[pos] = pos-1;// 排列树和子集树的初始化不同
                }
            }else{
                // 1.没选这个扩展节点,在check完之后就应该换回来
                swap(res[pos], res[f[pos]]);
            }
        }
        // 当前子树被遍历完了,也可以不写在for循环完成的地方而是写成八皇后代码里的那种单独判断
        pos--;
        // 3. 选了这个扩展节点,这个扩展节点是内节点,在其子树访问完成后,需要回复现场,否则肯定出错
        swap(res[pos], res[f[pos]]);
        f[pos]++;
    }
}

int main(){
    int n = 4;
    // 初始化操作
    for(int i = 0;i <= n;i++){
        res[i] = i;
        bestRes[i] = 0;
        for(int j = 0;j <= n;j++){
            m[i][j] = INF;
        }
    }
    
    m[1][2] = 10;
    m[1][3] = 6;
    m[1][4] = 4;
    m[2][3] = 5;
    m[2][4] = 10;
    m[3][4] = 20;
    m[2][1] = 10;
    m[3][1] = 6;
    m[4][1] = 4;
    m[3][2] = 5;
    m[4][2] = 10;
    m[4][3] = 20;
    
    // pos从第二个城市开始,因为第一个城市已经默认选择了第一个编号的城市
    TSPbacktrace(2, n);
    //TSPiterate(2, n);
    
    for(int i = 1;i <= n;i++)
        cout<<bestRes[i]<<" ";
    
    cout<<endl<<minRes;
}
           

复杂度分析:

时间复杂度下界视为解空间树叶节点的个数,因此

N皇后问题:Ω(nn)

TSP问题:Ω((n-1)!) = Ω(n!)

时间复杂度的上界比较难以分析,需要考虑其约束函数和限界函数,在这篇博客和后面的几篇回溯博客中,约束函数和限界函数的写法其实包含了重复的计算,但是分析时间复杂度的时候是按照最简的计算方式来分析的。

N皇后问题最差情况下每个节点要与其他N个节点比较,这是O(n),节点数量级为O(nn)级,因此时间复杂度为

O(nn)

TSP问题约束函数和限制函数都是O(1),因此时间复杂度为

O(n!)

空间复杂度都是O(n2)

P.S.回溯法写起来真的很难想,写非递归的时候全程如下表情:

回溯法——N皇后问题与TSP问题回溯法——N皇后问题与TSP问题

继续阅读