Loading

基础算法 —— 5. 回溯法

方法论

回溯法和分支限界法都是搜索解空间的方法。两者都可以用剪枝函数优化加快搜索。

  • 回溯法采取深度优先搜索,通常用于搜索所有可行解的情况。
  • 分支限界法采取广度优先搜索,根据策略不同有先进先出分支限界和优先级分支限界,通常用于搜索最优解。
解空间

两种方法适用于解空间是两类树,子集树和排列树。

  • 子集树:顾名思义其问题类似于子集问题,元素只有两种状态对应0 -1,叶节点个数为 \(2^n\) 。更一般的讲,叶节点个数为 \(m^n\) m 代表状态个数,n 代表元素个数。

  • 排列树:顾名思义其问题类似排列问题,所以其叶子节点一般是 \(n!\)

  • 根据上面两种组合更改的树。

回溯法的实现

一般来讲,用递归实现的回溯算法采取深度优先搜索。但也可以使用广度优先搜索算法。

void BackTrack_r(int level){		//子集树的递归实现
	if(level>元素个数){
		说明到达叶节点,得到一个可行解
    }
    else{ 
    	for(对当前每个备选方案){
    		if(满足约束&&满足限界){
    			进入下一层搜索
    		}
    	}
    }
}
void BackTrack_r(int level){		//排列树的递归实现
	if(level>元素个数){
		说明到达叶节点,得到一个可行解
    }
    else{ 
    	for(对当前每个备选方案){
    		将当前的方案与第一个方案进行交换
    		if(满足约束&&满足限界){
    			进入下一层搜索
    		}
    		再交换一次恢复原状
    	}
    }
}

void BackTrack(){//非递归实现
	int level = 0;
	while(level>0){
		if(当前有备选方案){
			for(对每个备选方案){
				if(满足约束&&满足限界){
					if(level>元素个数){
						说明到达叶节点,得到一个可行解
					}
					else{
						++level;
					}
				}
			}
        }
        else{
        	--level;
        }
	}
}
分支限界法的实现

队列式分支限界法与解空间的广度搜索很相似。

优化

回溯法实际上算是另一种穷举法,所以效率一般不高,但我们可以用剪枝函数剪掉树中不可能的分支,从而减少搜索量,提高效率。

用剪枝函数,剪枝函数分为两类:1. 用约束函数剪去不满足约束的子树。2. 用限界函数剪去不满足最优解的子树。

其它

回溯法和动态规划有时很像。

我们应该了解到,对于某些子集问题,其几乎需要遍历所有解空间,那么我们可以用一个n位的二进制序列来模拟,从而进一步省下了递归所需的栈空间,又比非递归版简洁易懂。

问题集

最优装载问题变种

分析问题,显然选择子集树来表示解空间是合适的,两种状态对应于一个货物装的船号。不过我们先采取书上的方法,用两种状态表示货物是否装到1号船,后面我们会探究这两种不同的表示方式有何区别。

由于题目条件,货物总重量 <= 两艘船载重量之和,所以这个问题可以转化将第一艘船尽可能装满的问题。令\(w_i\) 表示第i个货物的重量和,\(x_i\) 取值 0-1 表示是否选择第i个货物到第一艘船,\(c_1\) 表示第一艘船的最大载重量。那么我们要求在满足 ${\sum \limits_{i=1}^{n} w_i*x_i}<= c_1 $ 条件下的 \(\max {\sum \limits_{i=1}^{n} w_i*x_i}\)\(x\) 的集合。

接下来分析约束函数和限界函数。

  • 我们的约束条件就是条件 \({\sum \limits_{i=1}^{n} w_i*x_i}<= c_1\),不满足这个条件的分支都应该被剪去。
  • 限界函数分析的是否能得到最优解的情况。这块我们引入限制下界函数的限界函数,假设已经搜索到一个可行解,令maxw = 这个可行解,如果当前的载重量cw + 剩下所有货物的重量和 <= maxw 说明就算将剩下所有的货物都装到第一艘船,也没有前面的装法装的多所以剪去该分支,相当于限制了下界。
class Loading{
private:
    static constexpr int N = 10;
    int weight[N];
    int c1,c2;
    int maxw,r,cw;
    int cw1,cw2;
    bitset<N> bits;

public:
    Loading(){
        for_each(weight,weight+N,[](int &i){cout<<(i = rand()%9+1)<<' ';});
        cout<<endl;
        
        r = accumulate(weight,weight+N,0);
        c2 = 26;
        c1 = r-c2+1;
        cw1=cw2=0;
        maxw = cw = 0;
    }
    void maxLoading(int level = 1){
        if(level > N){
            maxw = std::max(maxw,cw);
            cout<<cw<<'\t'<<bits.to_string()<<endl;
        }
        else{
            int ccw = weight[level-1]; // currentCargoWeight

            r -= ccw;
            {
                if(cw+ccw<=c1){
                    cw += ccw;
                    bits.set(level-1,true);
                    maxLoading(level+1);
                    bits.set(level-1,false);
                    cw -= ccw;
                }

                if(cw + r >= maxw){
                    maxLoading(level+1);
                }
            }
            r += ccw;
        }
    }
    void maxLoading2(int level = 1){
        if(level > N){
            maxw = max(maxw,cw1);
            cout<<cw1<<'\t'<<bits.to_string()<<endl;
        }
        else{
            int ccw = weight[level-1];
            if(cw1+ccw<=c1){
                bits.set(level-1,true);
                cw1 += ccw;
                maxLoading2(level+1);
                cw1 -= ccw;
                bits.set(level-1,false);
            }
            if (cw2+ccw<=c2){
                cw2 += ccw;
                maxLoading2(level+1);
                cw2 -= ccw;
            }
        }
    }
    ~Loading(){
        cout<<maxw;
    }
};

注意到,如果我们把左右两种状态对应于两种船号。这样做就不再是子集问题,也就是说最多只能使用约束函数,无法使用剪枝函数。其效率较低。

应该意识到,如果要求解最优解,那么使用限界函数就可以减少很多不必要的搜索。有了它效率能够提升一大截。

批作业调度

这个问题要的解是作业的序列,所以其解空间是一个排列树。因为题目没有其它约束条件,所以就没有约束函数和限界函数。

不过需要注意的是,完成时间的计算方法。

class FlowShop{
private:
    int (*work)[2];
    static constexpr int N = 3;
    int mint,ct1,ct2;

public:
    FlowShop(){
        work = new int [N][2];
        for_each(work,work+N,[](auto &i){i[0] = rand()%9+1;i[1] = rand()%9+1;});
        mint = ct1 = ct2 = 0;
        mint = INT_MAX;
    }
    void minPermutation(int level = 1){
        if(level > N){
            mint = min(mint,ct2);
            
            cout<<ct2<<'\t';
            for_each(work,work+N,[](auto&i){cout<<'('<<i[0]<<','<<i[1]<<')'<<' ';});
            cout<<endl;
        }
        else{
            for(int i=level-1;i<N;++i){
                swap(work[level-1],work[i]);
                int temp = max(
                    ct1 + work[level-1][0]+work[level-1][1],
                    ct2+work[level-1][1]
                );
                ct2 += temp;
                ct1 += work[level-1][0];
            
                minPermutation(level+1);
                ct2 -= temp;
                ct1 -= work[level-1][0];
                swap(work[level-1],work[i]);
            }
        }
    }
    ~FlowShop(){
        cout<<mint;
        delete[]work;
    }
};
符号三角形

只要确定第一行就确定整个三角形,其解空间是子集树。

因为整个三角形符号总数为 \(\frac{n^2+n}{2}\) 所以约束函数是当前 ‘-’ ‘+’ 总数<= \(\frac{n^2+n}{2}\) 由于问题是统计所有的可行解,没有所谓的最优解,所以没有限界函数。

n皇后

分析问题,其解空间可以说是子集树,不过每个皇后有n个状态;也可以说是排列树,因为很明显皇后们不能在同一行或列。

约束函数就是皇后们不能在同一列或行或斜线。因为没有最优解,所以无限界函数。

0-1背包

虽然在之前我们用了动态规划的算法,这块我们考虑考虑回溯的解法。

其解空间是一颗二叉子集树。和上面的最优装载问题很相似,约束函数就是当前所装物品要小于背包容量。限界函数跟也跟其一样,当前的价值+剩下所有货物的价值 < 得到的最大价值,则说明不可能得到最优解。

posted @ 2020-07-10 11:08  沉云  阅读(260)  评论(0)    收藏  举报