相信"迷宫"是许多人儿时的回忆,大家小时候一定都玩过迷宫游戏。我们从不用别人教导,都知道走迷宫的策略是:
其实,这就是回溯法——一个基于深度优先搜索和约束函数的问题求解方法。
众所周知,回溯法的时间复杂度主要取决于如下几点:
当问题和算法一旦确定下来,就只剩下第5点的实际产生的节点数目是根据问题的具体实例而动态生成,不可预知的。
对于一个具体的问题实例,很难预测出回溯法的行为(先选哪一条岔路,这条岔路是否能很快得到最优解),这时应该怎么办呢?
此时,可以使用蒙特卡罗方法估计回溯法产生的节点数目。
所谓蒙特卡罗方法,其实就是一种以概率统计理论为指导的,使用随机数(或伪随机数)来解决计算问题的方法,通过对大量随机试验求平均,以求得相近的值。
蒙特卡罗方法的基本思想是:当求解的问题是某种随机事件出现的概率或数学期望时,通过大量“实验”的方法,以频率估计概率或者得到某些数字特征,将其近似看作问题的解。
1777年,法国数学家布丰(Buffon)提出使用投针试验的方法求解圆周率π,这被认为是蒙特卡罗方法的起源(扯远了)。
我们需要估计的是回溯法实际产生的节点数目,以此计算回溯法的时间复杂度。
其主要思想是,在解空间树(状态空间树)上动态、随机的产生一条路径,然后沿此路径来估算解空间树中所有满足约束条件的节点总数(这里计算的是最差时间复杂度,假设要走遍所有满足约束条件的节点)。
多次进行上述实验,对结果求平均值,即可得到回溯法中实际生成的节点数目的估计值。
问题背景:8皇后问题是由国际西洋棋棋手马克斯·贝瑟尔于1848年提出的问题,是回溯算法的典型案例。N皇后问题由此推广而来。
问题描述:在N×N格的国际象棋上摆放N个皇后,使其不能互相攻击,即不能处于同一列或同一行,也不能处在同一斜线上,请问有多少种摆法?
N皇后问题也是回溯算法的典型案例,这里,我们可以使用递归和循环迭代两种不同的回溯方式编写代码:
//
// main.cpp
// BackTrack Solution of N-Queens Problem.
//
// Created by Kang on 2020/7/2 at NJUPT.
// Copyright © 2020 Kang. All rights reserved.
//
#include
#include
#include
using namespace std;
const int maxSize = 10;
int x[maxSize];
/**
Judge if the Queen can be placed at (k, x[k]), if OK, return true.
*/
bool CanPlace(int k){
for(int i = 0; i < k; ++i) {
if(x[i] == x[k] || abs(i-k) == abs(x[i]-x[k]))
return false;
}
return true;
}
void Print(int N){
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
if(x[i] != j) {
printf(" ");
} else {
printf("▇\n");
break;
}
}
}
printf("---------\n");
}
/**
k the current deepth( from 0 to n-1) in the Solution Space Tree
*/
int Recursive_BackTrack(int k, int N){
int solutionCount = 0;
if(k == N){
Print(N);
return 1;
}
for (int i = 0; i < N; ++i) {
x[k] = i;
if(CanPlace(k)){
solutionCount += Recursive_BackTrack(k+1, N);
}
}
return solutionCount;
}
int Iterated_BackTrack(int N){
int solutionCount = 0, k = 0; // 问题解的个数、当前层数
for (int i = 0; i < maxSize; ++i) { // 将所有层中,所选子树(列)的初始值置为-1
x[i] = -1;
}
while (k > -1) { // 如果已经退回第0行前面,则结束遍历
if(k == N){ // 如果已经超过最后一行,则打印路径并返回上一层
Print(N);
++solutionCount;
--k;
continue;
}
if(++x[k] < N){ // 如果还有未访问的子节点,则选择这棵树的下一个子树
if(CanPlace(k)){
++k; // 如果当前位置可以摆放,则k自增,进入下一行的循环。
} else {
// 如果当前位置不能摆放,则k不必动,直接进入下次循环。
}
} else { // 子树已经全部搜索,返回上一层
x[k] = -1; // 注意,返回时要将子树进行复位。
--k;
}
}
return solutionCount;
}
int main(int argc, const char * argv[]) {
int N;
int count;
printf("请输入皇后个数:");
scanf("%d", &N);
// count = Recursive_BackTrack(0, N);
count = Iterated_BackTrack(N);
cout<<"共有"<<count<<"种不同的解法。"<<endl;
return 0;
}
笔者在写博客的过程中发现了可以优化的地方,于是赶快去修改了一下。不知道各位读者有没有发现,各位可以仔细对比以下两份不同的代码,答案将在这份代码后面给出。
//
// optimisedMain.cpp
// Optimised BackTrack Solution of N-Queens Problem.
//
// Created by Kang on 2020/7/2 at NJUPT.
// Copyright © 2020 Kang. All rights reserved.
//
#include
#include
#include
using namespace std;
const int maxSize = 10; // 最大能输入的棋盘规模(皇后数量)
int x[maxSize]; // x[k]表示:第k行选择第x[k]列
int flag[maxSize][maxSize]; // flag[k][i]:第k行的第i列如果可选则为1,不可选则为0
/**
Calculate the kth row's all unalternative column i, and turn flag[i] from 1 to 0.
*/
void calcFlag(int k, int N){
for (int i = 0; i < N; ++i) {
flag[k][i] = 1;
}
for(int i = 0; i < k; ++i) { // from 0th to (k-1)th row
flag[k][x[i]] = 0; // 正对下方的列直接Pass
if(x[i] + abs(i-k) < N) // 检查右下方:如果当前列号x[i] + 行差abs(i-k) < N
flag[k][x[i] + abs(i-k)] = 0;
if (x[i] - abs(i-k) > -1) // 检查左下方:如果当前列号x[i] + 行差abs(i-k) < N
flag[k][x[i] - abs(i-k)] = 0;
}
}
void Print(int N){
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
if(x[i] != j) {
printf(" ");
} else {
printf("▇\n");
break;
}
}
}
printf("---------\n");
}
/**
k the current deepth( from 0 to n-1) in the Solution Space Tree
N the scale of the problem
return number of solution
*/
int Recursive_BackTrack(int k, int N){
int solutionCount = 0;
if(k == N){
Print(N);
return 1;
}
calcFlag(k, N);
for (int i = 0; i < N; ++i) {
if(flag[k][i]) {
x[k] = i;
solutionCount += Recursive_BackTrack(k+1, N);
}
}
return solutionCount;
}
/**
N the scale of the problem
return number of solution
*/
int Iterated_BackTrack(int N){
int solutionCount = 0, k = 0; // 问题解的个数、当前层数
for (int i = 0; i < maxSize; ++i) { // 将所有层中,所选子树(列)的初始值置为-1
x[i] = -1;
}
calcFlag(0, N);
while (k > -1) { // 如果已经退回第0行前面,则结束遍历
if(k == N){ // 如果已经超过最后一行,则打印路径并返回上一层
Print(N);
++solutionCount;
--k;
continue;
}
if(++x[k] < N){ // 如果还有未访问的子节点,则选择这棵树的下一个子树
if (flag[k][x[k]] == 1) {
++k; // 如果当前列x[k]可以摆放,则k自增,进入下一行的循环。
calcFlag(k, N);
} else {
// 如果当前位置不能摆放,则k不必动,直接进入下次循环。
}
} else { // 子树已经全部搜索,返回上一层
x[k--] = -1; // 注意,返回时要将子树进行复位。
}
}
return solutionCount;
}
int main(int argc, const char * argv[]) {
int N;
int count;
printf("请输入皇后个数:");
scanf("%d", &N);
//count = Recursive_BackTrack(0, N);
count = Iterated_BackTrack(N);
cout<<"共有"<<count<<"种不同的解法。"<<endl;
return 0;
}
Answer:这其实是一种添加辅助数组flag,从而牺牲空间复杂度O(n2),减小时间复杂度n倍的策略。这样,每一个位置判断是否可以摆放,只需要O(1)的时间复杂度,而非前者O(n)的时间复杂度(以下计算时间复杂度时,均采用的是后者的求解方式)。
根据前面所讲到的蒙特卡罗方法,此时可以将其用于求解N皇后的时间复杂度。对于n元组长度的问题实例,其状态空间树中的节点数目常见的有n!(排列树)、an(子集树)和nn(可复选的排列树),则回溯算法的最坏时间复杂度分别为:
其中,p(n)表示生成一个节点所需的时间复杂度。
虽然回溯法的最坏时间复杂度非常的大,但是在大多数情况下,通常不需要生成状态空间树中的全部节点,而是通过约束函数和限界函数进行剪枝,从而最终只生成状态空间树中很少量的一部分节点,这里,我们就使用N皇后问题来举例,利用蒙特卡罗方法估算一下算法运行中实际生成的节点占状态空间树中总节点的比例。
在求解时间复杂度之前,先分析一下回溯法的效率。回溯法的运行时间通常取决于状态空间树上实际生成的那部分节点的数目。
在n皇后问题中,状态空间树(排列树)中总的节点个数为(如图为4皇后问题的完整的解空间树,共有65个节点):
而实际生成的节点数的个数怎么计算呢?
不妨对4-皇后问题先演算一下~
计算得到4皇后问题实际生成的节点数为16个,只生成了 (16/65)*100% = 24.6% 的节点。
但实际上,我们在通常的问题中无法预知要从哪一条岔路开始搜索解(这里很简单我们从0位置开始向后),而且有的时候我们要求出所有符合条件的解,这就要求我们将状态空间树中所有符合剪枝函数的点全部计算进来。
如果要计算实际生成的节点数,那么问题来了:
这时,蒙特卡罗方法就派上用场了。既然我们不清楚要生成多少节点,那我们不妨进行若干次的试验,然后求平均:每次随机生成一条路径,根据这条路径的情况来草率的推断所有其他路径和这条路一样,从而草率的估计实际生成的节点数量。
很显然,对于8皇后来说,第一行选第0个位置和选第1个位置,从而造成的第2行所能选择的范围都不一样,所以说是“草率的”估计。
但是这么草率的估计是否严谨呢?单单试验一次,恐怕并不严谨。但是,如果我们进行大量的试验,然后对这一整批实验数据求平均,从概率论上来说,试验次数越多,结果越准确(例如,投硬币次数越多,正面朝上的频率越稳定于实际的概率0.5)。
那我们不妨就用实际的栗子,来对8皇后问题进行一个“草率的”估计。这里简单的用(a)图进行举例:
因此,我们“草率的”估计,此次随机试验,解空间树中实际产生的节点数量有1+8+8*5+8*5*4+8*5*4*3+8*5*4*3*2 = 1649个,占总节点数8!/8!+8!/7!+8!/6!+······+8!/2!+8!/1!+8!/0!=109601的1.55%。
此时,我们是否可以说回溯法一定就实际生成1.55%的节点数呢?答案是否定的。我们需要尽可能的做多次这样的随机试验(例如b、c、d、e等图),然后求出平均值,才是较为准确的实际生成的节点占比。(其他几个图随机的路径也是一样的道理,笔者此处不再赘述。)
此处贴上本人手写的代码一份,可以用于测试实际生成的节点与状态空间树中总结点的占比,有疑问欢迎私信。
//
// rate.cpp
// Efficiency Analysis of BackTrack for N-Queens Problem.
//
// Created by Kang on 2020/7/2 at NJUPT.
// Copyright © 2020 Kang. All rights reserved.
//
#include
#include
#include
using namespace std;
const int testCount = 100; // 测试次数
const int MAXN = 20; // 能测试的皇后最大值
int countPerRow[MAXN]; // 存放每行能选列的个数,便于最终计算结果
int bucket[MAXN]; // 符合约束能选则为1,不符合约束不能选则为0
int x[MAXN]; // 存放生成的随机序列
int N; // 本次是N皇后求解
// 初始化临时标记是否是可选的列
// 每行的判定开始前,都需要调用,假定该行的每一列都不能选
void initBucket(){
for(int i = 0; i < MAXN; ++i) {
bucket[i] = 0;
}
}
// 初始化每行能选的列数,每次试验开始前调用,进行置零
void initCountPerRow(){
for(int i = 0; i < MAXN; ++i) {
countPerRow[i] = 0;
}
}
bool CanPlace(int k) { // 判断第k行第x[k]列能不能放
for (int i = 0; i < k; ++i) {
if (x[i] == x[k] || abs(i - k) == abs(x[i] - x[k])) // 是否下面和斜对角有重合
return false;
}
return true;
}
// 返回能够抵达的行数
int RecurBacktrackM(int t) {
initBucket();
if (t == N) { // 如果已经进入第N+1行,则找到解
return t;
} else {
int colCount = 0; // 能摆放几列
int randInt, r = 0; // 随机第几个能摆放的位置
int index = 0; // 摆放的位置的下标
for (int i = 0; i < N; ++i) { // 挨个试探
x[t] = i;
if (CanPlace(t)) { // 判断是否能放
bucket[i] = 1;
colCount++;
countPerRow[t]++; // 第t行实际产生的可行解数量++
}
}
if(colCount > 0) { // 如果这一行有可行解
randInt = rand()%colCount + 1;
while(r < randInt){
if(bucket[index] == 1){
r++;
}
index++;
}
index--;
x[t] = index; // 得到随机选择的可行解的列号
return RecurBacktrackM(t + 1);// 进入下一层
} else { // 不能放说明是死胡同,直接返回
return t;
}
}
}
int main() {
srand((unsigned)time(NULL));
cout<<"请输入棋盘的大小:";
cin>>N;
double allRate = 0;
for(int number = 0; number < testCount; number++){
int row, allNodeCount = 1;
int geneNodeCount = 1; // 蒙特卡洛估计的,实际产生的节点的数量
initCountPerRow();
row = RecurBacktrackM(0);
cout<<"本次实验的随机序列是:";
for(int i = 0; i < row; i++){
cout<<x[i]<<" ";
}
cout<<endl;
int tmp;
cout<<"本次实验的每行节点数分别是:";
for(int i = 0; i < row; i++){
cout<<countPerRow[i]<<" ";
}
cout<<endl;
for(int i = 0; i < row; i++) {
tmp = 1;
for(int j = 0; j <= i; j++) {
tmp *= countPerRow[j];
}
geneNodeCount += tmp;
}
cout<<"本次估计实际产生的节点数是:"<<geneNodeCount<<endl;
for(int i = 0; i < N; i++) {
tmp = 1;
for(int j = 0; j <= i; j++) {
tmp *= N-j;
}
allNodeCount += tmp;
}
cout<<" 状态空间树中的总节点数是:"<<allNodeCount<<endl;
cout<<" 实际生成节点数目与总节点数比值为:"<<((geneNodeCount * 1.0)/allNodeCount)*100<<"%"<<endl;
allRate += (geneNodeCount * 1.0)/allNodeCount;
cout<<"-----------------------------------------------"<<endl;
}
cout<<"此"<<testCount<<"次实验,平均实际生成节点数目与总节点数比值为:"<<(allRate/testCount)*100<<"%"<<endl;
return 0;
}
在N皇后问题中,因为每个节点(最下一层除外)都要遍历包括当前层在内的上面每一层的选择,才能得到当前可行的全部直接子孙节点,且绝大多数节点都集中在层数靠下的位置,故可以估计出平均每个节点的生成时间复杂度p(n)都是n。
例如上例中,根节点生成时要检验它的8个真子孙节点是否可行;对于第一层的每一个节点,也都需要判断其全部的8个真子孙节点是否可行;第二层、第三层······直到倒数第二层,也都需要判断其全部的8个真子孙节点是否可行;最后一层虽比较特殊,无需判断,但最后一层(已经抵达可行解)的节点在N皇后问题(排列树)中和上一层的节点数相同,故与其他节点一视同仁也不影响时间复杂度的计算。
所以N皇后的时间复杂度为O(n×实际生成的节点数)。
凑巧赶上小破邮的算法课程,于是写下人生中第一篇正式的CSDN博客,竟然啰嗦了1w多字,希望大家多多点赞支持,有疑问的地方欢迎私信小博同学进行交流奥~