所谓N皇后问题,是一个经典的关于回溯法的问题。
问题描述:在n*n的棋盘上放置彼此不受攻击的n个皇后。按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
分析:对于每一个放置点而言,需要考虑四个方向上是否已经存在皇后。分别是行,列,四十五度斜线和一百三十五度斜线。
其中,对于行:每一行只能放一个皇后,直到我们把最后一个皇后放到最后一行的合适位置。对于列:列相同的约束条件,只需判断该放置点与已放置好的皇后的j是否相等即可。
对于四十五度斜线和一百三十五度斜线:当前棋子和已放置好的棋子不能存在行数差的绝对值等于列数差的绝对值的情况,若存在则说明两个棋子在同一条斜线上。
首先由比较简单的四皇后开始分析。
对于解空间比较小的情况,最简单的当然就是使用暴力搜索法。
BruteForceSearchclass nQueen { public: void update(std::vector<std::vector<int>> &is_occupied, int row, int col) { int n = is_occupied.size(); for (int r = row+1; r < n; ++r) { if (0 == is_occupied[r][col]) is_occupied[r][col] = 1; if (col+r-row < n && 0 == is_occupied[r][col+r-row]) is_occupied[r][col+r-row] = 1; if (col+row >= r && 0 == is_occupied[r][col+row-r]) is_occupied[r][col+row-r] = 1; } } void visualize(std::vector<std::vector<int>> &solutions) { int n = solutions.size(); for (int r = 0; r < n; ++r) { int l = solutions[r].size(); for (int c = 0; c < l; ++c) std::cout << solutions[r][c] << " "; std::cout<< std::endl; for (int c = 0; c < l; ++c) { int pos = solutions[r][c]; for (int i = 0; i < l; ++i) { if (i == pos) std::cout << "Q "; else std::cout << "* "; } std::cout << std::endl; } std::cout << std::endl; } std::cout << "Total solutions: " << solutions.size() << std::endl; } // 暴力穷举式搜索法 void fourQueen() { int n = 4; for (int i = 0; i < n; ++i) { std::vector<int> ret; ret.push_back(i); std::vector<std::vector<int>> is_occupied(n, std::vector<int>(n, 0)); for (int c = 0; c < n; ++c) if (c != i) is_occupied[0][c] = 1; update(is_occupied, 0, i); for (int j = 0; j < n; ++j) { if (0 == is_occupied[1][j]) { ret.push_back(j); update(is_occupied, 1, j); for (int h = 0; h < n; ++h) { if (0 == is_occupied[2][h]) { ret.push_back(h); update(is_occupied, 2, h); for (int k = 0; k < n; ++k) { if (0 == is_occupied[3][k]) { ret.push_back(k); solutions.push_back(ret); break; } } } } } } } visualize(solutions); } public: std::vector<std::vector<int>> is_occupied; std::vector<std::vector<int>> solutions; };对上面的方法稍加观察即可发现当我们在一步一步进行 for 循环的时候,其实循环结构非常相似,而且每一层循环都要在尚未结束的时候,向下一层继续搜索,这样就可以考虑采用递归的方式了。
乍一看 while 循环好像也不错,但其实是行不通的,因为在循环里面我们只能将当前行的可行位置搜索完后才能搜索下一行,而这不是我们想要的方式,也搜索不到相应的解。
DFS+backtrack For fourQueenclass nQueen { public: void update(std::vector<std::vector<int>> &is_occupied, int row, int col) { int n = is_occupied.size(); for (int r = row+1; r < n; ++r) { if (0 == is_occupied[r][col]) is_occupied[r][col] = 1; if (col+r-row < n && 0 == is_occupied[r][col+r-row]) is_occupied[r][col+r-row] = 1; if (col+row >= r && 0 == is_occupied[r][col+row-r]) is_occupied[r][col+row-r] = 1; } } // 需要新加恢复占位标志的函数 void update_reset(std::vector<std::vector<int>> &is_occupied, int row, int col) { int n = is_occupied.size(); for (int r = row+1; r < n; ++r) { if (1 == is_occupied[r][col]) is_occupied[r][col] = 0; if (col+r-row < n && 1 == is_occupied[r][col+r-row]) is_occupied[r][col+r-row] = 0; if (col+row >= r && 1 == is_occupied[r][col+row-r]) is_occupied[r][col+row-r] = 0; } } void visualize(std::vector<std::vector<int>> &solutions) { int n = solutions.size(); for (int r = 0; r < n; ++r) { int l = solutions[r].size(); for (int c = 0; c < l; ++c) std::cout << solutions[r][c] << " "; std::cout<< std::endl; for (int c = 0; c < l; ++c) { int pos = solutions[r][c]; for (int i = 0; i < l; ++i) { if (i == pos) std::cout << "Q "; else std::cout << "* "; } std::cout << std::endl; } std::cout << std::endl; } std::cout << "Total solutions: " << solutions.size() << std::endl; } void solution(int row, std::vector<int> &ret, int n) { if (row >= n) { solutions.push_back(ret); } else { for (int c = 0; c < n; ++c) { if (0 == row) { for (int j = 0; j < n; ++j) { is_occupied[0][j] = (j != c ? 1 : 0); } } if (0 == is_occupied[row][c]) { ret.push_back(c); update(is_occupied, row, c); solution(row+1, ret, n); // 因为要回溯,刚刚向下搜索时改变的标志位都要恢复成搜索之前的状态,才能保证按行向右依次进行验证 // 同时作为该行结果的ret的最后一位也要弹出,为下一个解做准备 ret.pop_back(); update_reset(is_occupied, row, c); } } } } void fourQueen() { int n = 4; is_occupied.resize(n); for (int i = 0; i < n; ++i) is_occupied[i].resize(n, 0); std::vector<int> ret; solution(0, ret, n); visualize(solutions); } public: std::vector<std::vector<int>> is_occupied; std::vector<std::vector<int>> solutions; };上面的 is_occupied 数组是对每一个格点的状态都进行了记录,为了减少操作量,我们可以只记录放置皇后的地方,然后根据之前的皇后的位置去判断与当前格点是否会产生冲突。
现在其实已经可以升级到N皇后了,下面看一下递归加回溯解法一
Recursive+Backtrack for NQueenclass nQueen { public: void visualize(std::vector<std::vector<int>> &solutions) { int n = solutions.size(); for (int r = 0; r < n; ++r) { int l = solutions[r].size(); for (int c = 0; c < l; ++c) std::cout << solutions[r][c] << " "; std::cout<< std::endl; for (int c = 0; c < l; ++c) { int pos = solutions[r][c]; for (int i = 0; i < l; ++i) { if (i == pos) std::cout << "Q "; else std::cout << "* "; } std::cout << std::endl; } std::cout << std::endl; } std::cout << "Total solutions: " << solutions.size() << std::endl; } // 递归加回溯的解法一 bool check(int row, int col, int n) { for (int i = 0; i < row; ++i) { if (1 == is_occupied[i][col]) { return false; } for (int j = 0; j < n; ++j) { if (std::abs(i-row) == std::abs(j-col) && 1 == is_occupied[i][j]) return false; } } return true; } void nQueneRecursively(int row, int n, std::vector<int> &result) { if (row >= n) { solutions.push_back(result); } for (int c = 0; c < n; ++c) { if (check(row, c, n)) { is_occupied[row][c] = 1; result.push_back(c); nQueneRecursively(row+1, n, result); result.pop_back(); is_occupied[row][c] = 0; } } } void nQuene() { int n = 6; is_occupied.resize(n); for (size_t r = 0; r < n; ++r) { is_occupied[r].resize(n, 0); } std::vector<int> result; nQueneRecursively(0, n, result); visualize(solutions); } public: std::vector<std::vector<int>> is_occupied; std::vector<std::vector<int>> solutions; };既然我们对格点状态标志处理之后还要恢复其原来的标志,以便继续搜索可能的解,同时此种约束完全可以根据记录的前几个皇后的位置计算而得到,于是就有了更简单的做法
Recursive+Backtrack v2 for NQueenclass nQueen { public: void visualize(std::vector<std::vector<int>> &solutions) { int n = solutions.size(); for (int r = 0; r < n; ++r) { int l = solutions[r].size(); for (int c = 0; c < l; ++c) std::cout << solutions[r][c] << " "; std::cout<< std::endl; for (int c = 0; c < l; ++c) { int pos = solutions[r][c]; for (int i = 0; i < l; ++i) { if (i == pos) std::cout << "Q "; else std::cout << "* "; } std::cout << std::endl; } std::cout << std::endl; } std::cout << "Total solutions: " << solutions.size() << std::endl; } // 递归加回溯解法二 // 说明:check2 先在当前行某个格子放置好皇后,再检验此时是否无冲突 // nQueenRecursively2 递归求解各种摆法,s表示其中一种解法,n是一个固定值, // 题目的要求皇后数 bool check2(std::vector<int> &s, int row) { for (int i = 0; i < row; ++i) { if (std::abs(s[i]-s[row]) == row-i || s[i] == s[row]) return false; } return true; } void nQueenRecursively2(int row, std::vector<int> &s, int n) { if (row >= n) { solutions.push_back(s); } else { for (int i = 0; i < n; ++i) { s[row] = i; if (1 == check2(s, row)) { nQueenRecursively2(row+1, s, n); } } } } void nQuene2() { int n = 6; std::vector<int> s(n); nQueenRecursively2(0, s, n); visualize(solutions); } public: std::vector<std::vector<int>> solutions; };对于此类问题,好像利用广度优先搜索也可以完成,下面是其中的一种解法,
分支限界法class Solution { public: struct Node { int level; std::vector<int> path; Node(int n): level(n) {} }; void visualize(std::vector<std::vector<int>> &solutions) { int n = solutions.size(); for (int r = 0; r < n; ++r) { int l = solutions[r].size(); for (int c = 0; c < l; ++c) std::cout << solutions[r][c] << " "; std::cout<< std::endl; for (int c = 0; c < l; ++c) { int pos = solutions[r][c]; for (int i = 0; i < l; ++i) { if (i == pos) std::cout << "Q "; else std::cout << "* "; } std::cout << std::endl; } std::cout << std::endl; } std::cout << "Total solutions: " << solutions.size() << std::endl; } // 分支限界法,其实就是BFS的方法 bool check3(Node q, int row) { for (int j = 0; j < row; ++j) { if (std::abs(row-j)==std::abs(q.path[j]-q.path[row]) || q.path[j]==q.path[row]) return false; } return true; } void nQueen(int n) { Node flag(-1); std::queue<Node> q; q.push(flag); std::vector<int> solve(n, 0); int row = 0; Node currentNode(0); while (!q.empty()) { if (row < n) { for (int k = 0; k < n; ++k) { Node nodetmp(row); for (int i = 0; i < row; ++i) nodetmp.path.push_back(currentNode.path[i]); nodetmp.path.push_back(k); if (check3(nodetmp, row)) q.push(nodetmp); } } currentNode = q.front(); q.pop(); if (-1 == currentNode.level) { ++row; q.push(flag); currentNode = q.front(); q.pop(); } if (n-1 == currentNode.level) { for (int i = 0; i < n; ++i) { solve[i] = currentNode.path[i]; } solutions.push_back(solve); if (row == n-1) ++row; } } visualize(solutions); } public: std::vector<std::vector<int>> solutions; };除此之外,还有一种利用位运算求解的算法
Bit Operation for nQueen Problemclass Solution { public: void nQueen(int k, int ld, int rd) { if (k == max) { ++count; return; } int pos = max & ~(k | ld | rd); while (pos) { int p = pos & (~pos+1); pos -= p; nQueen(k | p, (ld | p) << 1, (rd | p) >> 1); } } public: int count = 0; int max = 1; }; int main (int argc, char *argv[]) { int n = 8; // n is the number of queen Solution solver; solver.max = (solver.max << n) -1; solver.nQueen(0, 0, 0); std::cout << "total solutions: " << solver.cout << std::endl; return 0; }分析该算法时都是基于其二进制形式,其中,
k 记录当前已经放有皇后的列, 1 表示该列已经放有皇后了, 0 表示尚未放有皇后。
ld 记录斜率为 -1 的方向上是否有皇后,1 表示有,0 表示没有。
rd 记录斜率为 1 的方向上是否有皇后,1 表示有,0 表示没有。
pos 记录当前可以放置皇后的列,1 表示可以放置,0 表示不能放置。
根据位运算推导, ~pos+1 = -pos ,然后 pos & (-pos) 的意思是取 pos 中二进制形式中最后一位 1,在这里的意思就是要在当前行的该列放置一个皇后。
下一步 实际上就是 pos = pos - (pos & (-pos)) 将 pos 二进制形式中最后一位 1 置位 0,在这里的意思是更新当前可以放置皇后的列,因为刚刚我们放置了一个皇后。
递归查找下一个皇后放置的位置。
带结果可视化的版本如下
class Solution { public: void visualize(std::vector<std::vector<int>> &solutions) { int n = solutions.size(); for (int r = 0; r < n; ++r) { int l = solutions[r].size(); for (int c = 0; c < l; ++c) std::cout << solutions[r][c] << " "; std::cout<< std::endl; for (int c = 0; c < l; ++c) { int pos = solutions[r][c]; for (int i = 0; i < l; ++i) { if (i == pos) std::cout << "Q "; else std::cout << "* "; } std::cout << std::endl; } std::cout << std::endl; } std::cout << "Total solutions: " << solutions.size() << std::endl; } // 计算一个整数的二进制形式中有多少个比特位为1 int count1Bit(int n) { int countOneBit = 0; while (n) { ++countOneBit; n = n & (n-1); } return countOneBit; } void nQueenRecursively(int k, int ld, int rd) { if (k == max) { ++count; solutions.push_back(s); return; } int pos = max & ~(k | ld | rd); int index = count1Bit(k); while (pos) { int p = pos & (~pos+1); pos -= p; // 根据p是2的多少次幂判断当前放置的位置 s[index] = (p==1 ? 0 : 1+(int)log2(p>>1)); nQueenRecursively(k | p, (ld | p) << 1, (rd | p) >> 1); } } void nQueen(int n) { max = (max << n) - 1; s.resize(n, -1); nQueenRecursively(0, 0, 0); visualize(solutions); } public: int count = 0; int max = 1; std::vector<int> s; std::vector<std::vector<int>> solutions; }; int main (int argc, char *argv[]) { int n = 8; // n is the number of queen Solution solver; solver.nQueen(n); return 0; }下面以 n=4 时的两种情况加以说明
第一种情况
Step 1 main 函数中调用时 k = 0, ld = 0, rd = 0
Step 2 进入函数体首先 pos = 1111 & ~(0000 | 0000 | 0000) = 1111 ,然后开始 while 循环
Step 3 首先 p = pos & (~pos+1) = 0001 ,表示要将第一个皇后棋子放在第一行第一列的位置, 0001 可以理解为从右到左依次为 0,0,0,1,(由于N皇后问题左右是对称的,理解成从左到右和从右到左都可以,只是我习惯从左向右遍历)
Step 4 更新 pos = pos - p = 1110 ,表示最左边一列已经放置了皇后,其他皇后再放这一列会受到攻击,如下图A中第一幅图第一行所示,o 表示放置皇后,x 表示被攻击的位置
Step 5 接下来进入递归, k = 0001, ld = 0010, rd = 0000 ,此时表示在第二行寻找可以放置皇后的位置,pos = 1111 & ~(0001 | 0010 | 0000) = 1100,k=0001 表示最左边一列不能放,ld=0010 表示从左边起第二列在斜率为 -1 的方向上有皇后,
也就是我们刚才在第一行第一列放置的皇后棋子,此时的 pos 表示从左边起第三列和第四列可以放置皇后, p = pos & (~pos+1) = 0100 取最后一位 1 ,表示将棋子放在第三列的位置,如下图A第一幅图第二行所示
Step 6 更新 pos = pos - p = 1000 ,表示在当前行第四列还可以放置皇后
Step 7 进入下一轮递归, k = 0101, ld = 1100, rd = 0010 ,在第三行寻找不冲突的位置,pos = 1111 & ~(0101 | 1100 | 0010) = 0000 表示该行已经没有可以放皇后的位置了, k=0101 表示第一列和第三列都有棋子攻击,
ld=1100 表示第三列和第四列在斜率为 -1 的方向上会受到攻击,由下图A第二幅图第三行所示,第三列和第四列刚好是前两个皇后的斜线攻击位置,
rd=0010 表示第二列在斜率为 1 的方向上会受到攻击,由图可知其在第二个皇后的斜线攻击位置,至此此种放法不可行
Step 8 返回到调用处即进行第二列第四行的可行性检验……
第二种情况
Step 1 当第一行第一列检查过之后, pos = 1110 ,此时取最后一位 1 , p = pos & (~pos+1) = 0010 ,表示放在第一行第二列的位置,如下图B第一幅图第一行所示
Step 2 更新 pos = pos - p = 1100 ,接下来进入递归
k = 0000 | 0010 = 0010 ld = (0000 | 0010) << 1 = 0100 rd = (0000 | 0010) >> 1 = 0001Step 3 开始第二行的遍历, pos = 1111 & ~(0010 | 0100 | 0001) = 1000 ,此时第二列会受到纵向攻击,第三列会受到一百三十五度方向的斜线攻击,第一列会收到四十五度方向的斜线攻击
Step 4 进入 while 循环, p = 1000,pos = 0000 ,表示将棋子放在第四列的位置,当前行已没有其他可以放置棋子的位置,如下图B第二幅图第二列所示,下一轮递归,
k = 0010 | 1000 = 1010 ld = (0100 | 1000) << 1 = 11000 rd = (0001 | 1000) >> 1 = 0100Step 5 开始第三行的遍历, pos = 1111 & ~(1010 | 11000 | 0100) = 0001 ,此时该行第二列和第四列都会受到其它已放置的皇后的纵向攻击,第四列还会受到一百三十五度方向的斜线攻击,第三列会受到四十五度方向的斜线攻击
Step 6 可放置皇后的位置只剩第一列,p = 0001 表示放置在第一列,更新 pos = 0000 ,表示该行也已经没有其他可以放置棋子的位置,如下图B第三幅图第三行所示,然后进入再一轮递归
k = 1010 | 0001 = 1011 ld = (11000 | 0001) << 1 = 110010 rd = (0100 | 0001) >> 1 = 0010Step 7 开始第四行的遍历, pos = 1111 & ~(1011 | 0010 | 0010) = 0100 ,此时第一列第二列第四列会受到其它已放置的皇后的纵向攻击,第二列会受到一百三十五度方向斜线攻击,第二列会受到四十五度方向斜线攻击
Step 8 可以放置皇后的位置只剩第三列, p = 0100,pos = 0000 ,再进入递归时 k = 1011 | 0100 = 1111 = max ,完成了一次完整的搜索,表示此时找到了一种解法,然后再一次进行后面的搜索。
参考资料
[2] n皇后问题-回溯法求解
[3] 利用搜索树来解决N皇后问题
[4] n皇后问题(分支限界法)
[6] 目前最快的N皇后问题算法!!!