文章目录
写在前面
本专栏专注于分析与讲解【面试经典150】算法,两到三天更新一篇文章,欢迎催更……
专栏内容以分析题目为主,并附带一些对于本题涉及到的数据结构等内容进行回顾与总结,文章结构大致如下,部分内容会有增删:
- Tag:介绍本题牵涉到的知识点、数据结构;
- 题目来源:贴上题目的链接,方便大家查找题目并完成练习;
- 题目解读:复述题目(确保自己真的理解题目意思),并强调一些题目重点信息;
- 解题思路:介绍一些解题思路,每种解题思路包括思路讲解、实现代码以及复杂度分析;
- 知识回忆:针对今天介绍的题目中的重点内容、数据结构进行回顾总结。
Tag
【N皇后】【回溯】【位运算】
题目来源
题目解读
N 皇后问题研究的是将 N 个皇后放置在 nxn
的棋盘上,并且使皇后彼此之间不能互相攻击。因为皇后可横直斜走,且格式不限。所以 N 皇后问题本质上需要保证 nxn
的棋盘上每一行、每一列、每一条与棋盘主副对角线平行的斜线上仅有一个皇后。
N 皇后问题就像是数独问题一样,只能枚举出所有的可能的,接着判断每一种可能会不会造成冲突,如果冲突了就回溯测试另一种可能。例如现在在安排第 3
行的皇后,依次放置在第 3
的每个位置上判断,比如说现在放在了第 4
列,需要进行如下判断:
- 判断这一列是否会有冲突;
- 与
3
行4
列位于同一条斜线上是否会有冲突(包括与主、副对角线平行的)
如果以上都没有冲突,则递归枚举下一行的皇后;如果有冲突,则向后退一步选择下一列(第 5
列)进行冲突判断。
当有行都更新完之后,将结果加入到答案数组中,这也是递归函数的出口。
关于如何判断列、斜线是否有冲突,通常有两种方式:
- 集合判断
- 位运算判断
方法一:基于集合的回溯
思路
为了判断每一个位置所在的列和两条斜线上是否已经有皇后,使用三个集合 columns
、diagonals1
和 diagnoals2
分别记录每一列以及两个方向的每条斜线上是否有皇后。
表示列比较直观,棋盘格一共有 n
列,每一列的范围在 0~N-1
内,使用列的下标即可明确表示每一列。
平行于主对角线的斜线的表示(包括自己)
见 下图,通过简单计算我们可以发现,同一条斜线上的每个位置满足 行下标于列下表之差相等,也就是说 diagnoals1
集合中插入的是行下标与列下标之差,在判断当前行列所在的与主对角线平行的斜线上是否有冲突时,直接在集合中查找行下标与列下标之差即可,如果存在则冲突,否则不冲突。
平行于副对角线的斜线的表示(包括自己)
见 下图。通过简单计算我们可以发现,同一条斜线上的每个位置满足 行下标于列下表之和相等,也就是说 diagnoals2
集合中插入的是行下标与列下标之和。判断冲突的方法与上述方法相似。
代码
class Solution {
private:
// 生成表示 N 皇后结果的棋盘
vector<string> generateBoard(vector<int>& queens, int n) {
vector<string> board;
for (int i = 0; i < n; ++i) {
string row = string(n, '.');
row[queens[i]] = 'Q';
board.push_back(row);
}
return board;
}
// 从第 row 行开始更新
void backtrack(vector<vector<string>>& res, vector<int>& queens, int n, int row,
unordered_set<int>& columns, unordered_set<int>& diagnoals1, unordered_set<int>& diagnoals2) {
if (row == n) { // 皇后已经放在完 N 行了
vector<string> board = generateBoard(queens, n);
res.push_back(board);
}
for (int i = 0; i < n; ++i) {
if (columns.find(i) != columns.end()) { // 第 i 列已经放置过皇后了
continue;
}
int diagnoal1 = row - i;
if (diagnoals1.find(diagnoal1) != diagnoals1.end()) { // 与主对角线平行的(包括自己)斜线上有皇后了
continue;
}
int diagnoal2 = row + i;
if (diagnoals2.find(diagnoal2) != diagnoals2.end()) { // 与副对角线平行的(包括自己)斜线上有皇后了
continue;
}
queens[row] = i;
columns.insert(i);
diagnoals1.insert(diagnoal1);
diagnoals2.insert(diagnoal2);
backtrack(res, queens, n, row + 1, columns, diagnoals1, diagnoals2);
// 恢复现场
queens[row] = -1;
columns.erase(i);
diagnoals1.erase(diagnoal1);
diagnoals2.erase(diagnoal2);
}
}
public:
vector<vector<string>> solveNQueens(int n) {
vector<vector<string>> res;
vector<int> queens(n, -1); // queens[i] = a 表示 i 行皇后的位置为 a
unordered_set<int> columns;
unordered_set<int> diagnoals1, diagnoals2;
backtrack(res, queens, n, 0, columns, diagnoals1, diagnoals2);
return res;
}
};
复杂度分析
时间复杂度: O ( n ! ) O(n!) O(n!)。
空间复杂度: O ( n ) O(n) O(n),其中 n n n 是皇后数量。空间复杂度主要取决于递归调用层数、记录每行放置的皇后的列下标的数组以及三个集合,递归调用层数不会超过 n n n,数组的长度为 n n n,每个集合的元素个数都不会超过 n n n。
方法二:基于位运算的回溯
思路
在方法一中使用集合记录棋盘格的列和两个斜线上是否有皇后,空间复杂度为 O ( n ) O(n) O(n)。如果利用位运算记录皇后的信息,就可以将记录皇后信息的复杂度降到 O ( 1 ) O(1) O(1)。
约束的二进制数表示
具体地,使用三个整数 colums
、diagnoals1
和 diagnoals2
分别记录每一列以及两个斜线上是否有皇后。每个整数有 n
个二进制位。棋盘上的每一列对应二进制数中的一个数位,其中棋盘的最左列对应每个整数的最低二进制位,最右列对应每个整数的最高二进制位。
棋盘的边长和皇后的数量 n=8
。如果棋盘的前两行分别在第 2 列和第 4 列放置了皇后(下标从 0 开始),那么在下一行(第三行)放皇后就不能放在第 2 列和第 4 列(对应图中绿色标记),对应的 columns = 00010100
。我们用 0 代表可以放置皇后的位置,1 代表不能放置皇后的位置。
接着看两个斜线对第三行放置皇后的影响。
新放置的皇后不能和任何一个已经放置的皇后在与主对角线平行包括自己(从左上到右下方向)的斜线上,因此不能放置在第 4 列和第 5 列(对应图中红色标记),对应 diagonals1=00110000
。
新放置的皇后不能和任何一个已经放置的皇后在与副对角线平行包括自己(从右上到左下方向)的斜线上,因此不能放置在第 0 列和第 3 列(对应图中蓝色标记),对应 diagonals2=00001001
。
综上,第三行可以放置皇后的位置对应的二进制数为 availablePositions = ((1 << n) - 1) & (~(columns | diagonals1 | diagonals2))
。按照上述例子有 availablePositions = 11000010
。
注意:此时结果中的 1 表示可以放置皇后的位置
枚举可能的列
接下来遍历可以放置皇后的位置,可以利用以下两个按位与运算的性质:
x & (-x)
可以获得x
的二进制表示中的最低位 1 的位置;x & (x - 1)
可以将x
的二进制表示中的最低位 1 置为 0。
availablePositions
中的 1 都是可以放置皇后的位置,我们从哪里开始放置呢?可以从最后一个 1 开始放置,我们利用 availablePositions & (-availablePositions)
获得最后一个 1 的位置 position
。将皇后放置在该位置,接着需要更新 availablePositions
最后一个 1 为 0,表示该位置不能再被放置皇后。相应的在递归计算下一行皇后位置时,要更新下一行的 colums = columns | position
,diagonals1 = (diagonals1 | position) << 1
,diagonals2 = (diagonals2 | position) >> 1
。
最后利用 __builtin_ctz
统计 position
从最低位到最高位有多少个连续的 0,用该值来更新 queen[row]
表示选择的是对应的列放置。
总结
- 位运算方法相比集合方法将时间复杂度降低到了 O ( 1 ) O(1) O(1),但是思考量较大。
- 需要注意,位运算一开始使用 0 表示可以放置皇后,后面变成了使用 1 表示可以放置皇后。
- 需要关注如何根据本行的
colums
、diagnoals1
和diagnoals2
来更新下一行的这三个参数。 - 还有就是对
x & (-x)
、x & (x - 1)
和__builtin_ctz
的理解与使用。
代码
class Solution {
public:
vector<vector<string>> solveNQueens(int n) {
auto solutions = vector<vector<string>>();
auto queens = vector<int>(n, -1);
solve(solutions, queens, n, 0, 0, 0, 0);
return solutions;
}
void solve(vector<vector<string>> &solutions, vector<int> &queens, int n, int row, int columns, int diagonals1, int diagonals2) {
if (row == n) {
auto board = generateBoard(queens, n);
solutions.push_back(board);
} else {
int availablePositions = ((1 << n) - 1) & (~(columns | diagonals1 | diagonals2));
while (availablePositions != 0) {
int position = availablePositions & (-availablePositions);
availablePositions = availablePositions & (availablePositions - 1);
int column = __builtin_ctz(position);
queens[row] = column;
solve(solutions, queens, n, row + 1, columns | position, (diagonals1 | position) << 1, (diagonals2 | position) >> 1);
queens[row] = -1;
}
}
}
vector<string> generateBoard(vector<int> &queens, int n) {
auto board = vector<string>();
for (int i = 0; i < n; i++) {
string row = string(n, '.');
row[queens[i]] = 'Q';
board.push_back(row);
}
return board;
}
};
写在最后
如果您发现文章有任何错误或者对文章有任何疑问,欢迎私信博主或者在评论区指出 💬💬💬。
如果大家有更优的时间、空间复杂度的方法,欢迎评论区交流。
最后,感谢您的阅读,如果有所收获的话可以给我点一个 👍 哦。