Leetcode(51)——N 皇后
题目
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
nnn 皇后问题 研究的是如何将 nnn 个皇后放置在 n×nn \times nn×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 nnn ,返回所有不同的 nnn 皇后问题 的解决方案。
每一种解法包含一个不同的 nnn 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
示例 1:
输入:n = 4
输出:[[“.Q…”,“…Q”,“Q…”,“…Q.”],[“…Q.”,“Q…”,“…Q”,“.Q…”]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
示例 2:
输入:n = 1
输出:[[“Q”]]
提示:
- 111 <= n <= 999
题解
关键:通过回溯和剪枝减少时间复杂度
直观的做法是暴力枚举将 NNN 个皇后放置在 N×NN \times NN×N 的棋盘上的所有可能的情况,并对每一种情况判断是否满足皇后彼此之间不相互攻击。暴力枚举的时间复杂度是非常高的,因此必须利用限制条件加以优化。
显然,每个皇后必须位于不同行和不同列,因此将 NNN 个皇后放置在 N×NN \times NN×N 的棋盘上,一定是每一行有且仅有一个皇后,每一列有且仅有一个皇后,且任何两个皇后都不能在同一条斜线上。基于上述发现,可以通过回溯的方式寻找可能的解。
回溯的具体做法是:使用一个数组记录每行放置的皇后的列下标,依次在每一行放置一个皇后。每次新放置的皇后都不能和已经放置的皇后之间有攻击:即新放置的皇后不能和任何一个已经放置的皇后在同一列以及同一条斜线上,并更新数组中的当前行的皇后列下标。当 NNN 个皇后都放置完毕,则找到一个可能的解。当找到一个可能的解之后,将数组转换成表示棋盘状态的列表,并将该棋盘状态的列表加入返回列表。
由于每个皇后必须位于不同列,因此已经放置的皇后所在的列不能放置别的皇后。第一个皇后有 NNN 列可以选择,第二个皇后最多有 N−1N-1N−1 列可以选择,第三个皇后最多有 N−2N-2N−2 列可以选择(如果考虑到不能在同一条斜线上,可能的选择数量更少),因此所有可能的情况不会超过 N!N!N! 种,遍历这些情况的时间复杂度是 O(N!)O(N!)O(N!)。
为了降低总时间复杂度,每次放置皇后时需要快速判断每个位置是否可以放置皇后,显然,最理想的情况是在 O(1)O(1)O(1) 的时间内判断该位置所在的列和两条斜线上是否已经有皇后——这就需要一些变量或数据结构来辅助实现。
以下两种方法分别使用集合和位运算对皇后的放置位置进行判断,都可以在 O(1)O(1)O(1) 的时间内判断一个位置是否可以放置皇后,算法的总时间复杂度都是 O(N!)O(N!)O(N!)。
小结
回顾这道题,拿到这道题的时候,其实我们很容易看出需要使用枚举的方法来求解这个问题,当我们不知道用什么办法来枚举是最优的时候,可以从下面三个方向考虑:
- 子集枚举:可以把问题转化成「从 n2n^2n2 个格子中选一个子集,使得子集中恰好有 nnn 个格子,且任意选出两个都不在同行、同列或者同对角线」,这里枚举的规模是 2n22^{n^2}2n2;
- 组合枚举:可以把问题转化成「从 n2n^2n2 个格子中选择 nnn 个,且任意选出两个都不在同行、同列或者同对角线」,这里的枚举规模是 (n2n){n^2} \choose {n}(nn2);
- 排列枚举:因为这里每行只能放置一个皇后,而所有行中皇后的列号正好构成一个 111 到 nnn 的排列,所以我们可以把问题转化为一个排列枚举,规模是 n!n!n!。
带入一些 nnn 进这三种方法验证,就可以知道哪种方法的枚举规模是最小的,这里我们发现第三种方法的枚举规模最小。这道题给出的两个方法其实和排列枚举的本质是类似的。
方法一:基于集合的回溯
思路
为了判断一个位置所在的列和两条斜线上是否已经有皇后,使用三个集合 columns\textit{columns}columns、diagonals1\textit{diagonals}_1diagonals1 和 diagonals2\textit{diagonals}_2diagonals2 分别记录每一列以及两个方向的每条斜线上是否有皇后。
列的表示法很直观,一共有 NNN 列,每一列的下标范围从 000 到 N−1N−1N−1,使用列的下标即可明确表示每一列。
如何表示两个方向的斜线呢?对于每个方向的斜线,需要找到斜线上的每个位置的行下标与列下标之间的关系。
方向一的斜线为从左上到右下方向, 同一条斜线上的每个位置满足行下标与列下标之差相等 ,例如 (0,0)(0,0)(0,0) 和 (3,3)(3,3)(3,3) 在同一条方向一的斜线上。因此使用行下标与列下标之差即可明确表示每一条方向一的斜线。

方向二的斜线为从右上到左下方向,同一条斜线上的每个位置满足行下标与列下标之和相等,例如 (3,0)(3,0)(3,0) 和 (1,2)(1,2)(1,2) 在同一条方向二的斜线上。因此使用行下标与列下标之和即可明确表示每一条方向二的斜线。

每次放置皇后时,对于每个位置判断其是否在三个集合中,如果三个集合都不包含当前位置,则当前位置是可以放置皇后的位置。
代码实现
Leetcode 官方题解:
class Solution {
public:
vector<vector<string>> solveNQueens(int n) {
auto solutions = vector<vector<string>>();
auto queens = vector<int>(n, -1);
auto columns = unordered_set<int>();
auto diagonals1 = unordered_set<int>();
auto diagonals2 = unordered_set<int>();
backtrack(solutions, queens, n, 0, columns, diagonals1, diagonals2);
return solutions;
}
void backtrack(vector<vector<string>> &solutions, vector<int> &queens, int n, int row, unordered_set<int> &columns, unordered_set<int> &diagonals1, unordered_set<int> &diagonals2) {
if (row == n) {
vector<string> board = generateBoard(queens, n);
solutions.push_back(board);
} else {
for (int i = 0; i < n; i++) {
if (columns.find(i) != columns.end()) {
continue;
}
int diagonal1 = row - i;
if (diagonals1.find(diagonal1) != diagonals1.end()) {
continue;
}
int diagonal2 = row + i;
if (diagonals2.find(diagonal2) != diagonals2.end()) {
continue;
}
queens[row] = i;
columns.insert(i);
diagonals1.insert(diagonal1);
diagonals2.insert(diagonal2);
backtrack(solutions, queens, n, row + 1, columns, diagonals1, diagonals2);
queens[row] = -1;
columns.erase(i);
diagonals1.erase(diagonal1);
diagonals2.erase(diagonal2);
}
}
}
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;
}
};
我自己的:
复杂度分析
- 时间复杂度:O(N!)O(N!)O(N!),其中 NNN 是皇后数量。
- 空间复杂度:O(N)O(N)O(N),其中 NNN 是皇后数量。空间复杂度主要取决于递归调用层数、记录每行放置的皇后的列下标的数组以及三个集合,递归调用层数不会超过 NNN,数组的长度为 NNN,每个集合的元素个数都不会超过 NNN。
方法二:基于位运算的回溯
思路
方法一使用三个集合记录分别记录每一列以及两个方向的每条斜线上是否有皇后,每个集合最多包含 NNN 个元素,因此集合的空间复杂度是 O(N)O(N)O(N)。如果利用位运算记录皇后的信息,就可以将记录皇后信息的空间复杂度从 O(N)O(N)O(N) 降到 O(1)O(1)O(1)。
具体做法是,使用三个整数 columns\textit{columns}columns、diagonals1\textit{diagonals}_1diagonals1 和 diagonals2\textit{diagonals}_2diagonals2 分别记录每一列以及两个方向的每条斜线上是否有皇后,每个整数有 NNN 个二进制位。棋盘的每一列对应每个整数的二进制表示中的一个数位,其中棋盘的最左列对应每个整数的最低二进制位,最右列对应每个整数的最高二进制位。
那么如何根据每次放置的皇后更新三个整数的值呢?在说具体的计算方法之前,首先说一个例子。
棋盘的边长和皇后的数量 N=8N=8N=8。如果棋盘的前两行分别在第 222 列和第 444 列放置了皇后(下标从 000 开始),则棋盘的前两行如下图所示。

如果要在下一行放置皇后,哪些位置不能放置呢?我们用 000 代表可以放置皇后的位置,111 代表不能放置皇后的位置。
新放置的皇后不能和任何一个已经放置的皇后在同一列,因此不能放置在第 222 列和第 444 列,对应 columns=00010100(2)\textit{columns}=00010100_{(2)}columns=00010100(2)。
新放置的皇后不能和任何一个已经放置的皇后在同一条方向一(从左上到右下方向)的斜线上,因此不能放置在第 444 列和第 555 列,对应 diagonals1=00110000(2)\textit{diagonals}_1=00110000_{(2)}diagonals1=00110000(2)。其中,第 444 列为其前两行的第 22 列的皇后往右下移动两步的位置,第 555 列为其前一行的第 444 列的皇后往右下移动一步的位置。
新放置的皇后不能和任何一个已经放置的皇后在同一条方向二(从右上到左下方向)的斜线上,因此不能放置在第 000 列和第 333 列,对应 diagonals2=00001001(2)\textit{diagonals}_2=00001001_{(2)}diagonals2=00001001(2)。其中,第 000 列为其前两行的第 222 列的皇后往左下移动两步的位置,第 333 列为其前一行的第 444 列的皇后往左下移动一步的位置。

由此可以得到三个整数的计算方法:
- 初始时,三个整数的值都等于 000,表示没有放置任何皇后;
- 在当前行放置皇后,如果皇后放置在第 iii 列,则将三个整数的第 iii 个二进制位(指从低到高的第 iii 个二进制位)的值设为 111;
- 进入下一行时,columns\textit{columns}columns 的值保持不变,diagonals1\textit{diagonals}_1diagonals1 左移一位,diagonals2\textit{diagonals}_2diagonals2 右移一位,由于棋盘的最左列对应每个整数的最低二进制位,即每个整数的最右二进制位,因此对整数的移位操作方向和对棋盘的移位操作方向相反(对棋盘的移位操作方向是 diagonals1\textit{diagonals}_1diagonals1 右移一位,diagonals2\textit{diagonals}_2diagonals2 左移一位)。

每次放置皇后时,三个整数的按位或运算的结果即为不能放置皇后的位置,其余位置即为可以放置皇后的位置。可以通过 (2n−1) & (∼(columns∣diagonals1∣diagonals2))(2^n-1)~\&~(\sim(\textit{columns} | \textit{diagonals}_1 | \textit{diagonals}_2))(2n−1) & (∼(columns∣diagonals1∣diagonals2)) 得到可以放置皇后的位置(该结果的值为 111 的位置表示可以放置皇后的位置),然后遍历这些位置,尝试放置皇后并得到可能的解。
遍历可以放置皇后的位置时,可以利用以下两个按位与运算的性质:
- x & (−x)x~\&~(-x)x & (−x) 可以获得 xxx 的二进制表示中的最低位的 111 的位置;
- x & (x−1)x~\&~(x-1)x & (x−1) 可以将 xxx 的二进制表示中的最低位的 111 置成 000。
具体做法是,每次获得可以放置皇后的位置中的最低位,并将该位的值置成 000,尝试在该位置放置皇后。这样即可遍历每个可以放置皇后的位置。
代码实现
Leetcode 官方题解:
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;
}
};
复杂度分析
- 时间复杂度:O(N!)O(N!)O(N!),其中 NNN 是皇后数量。
- 空间复杂度:O(N)O(N)O(N),其中 NNN 是皇后数量。由于使用位运算表示,因此存储皇后信息的空间复杂度是 O(1)O(1)O(1),空间复杂度主要取决于递归调用层数和记录每行放置的皇后的列下标的数组,递归调用层数不会超过 NNN,数组的长度为 NNN。
方法三:我自己的方法
思路
因为有 n 个皇后和 n 行 n 列,所以每一行和每一列有且只有一个皇后,所以只需要以某一行或某一列作为起始即可,我这里从第一行开始,方便后续编写代码。
在第一行中遍历每一列,即选择不同的起始点,并在将每个点存入数组中时要先判断是否合理(我这里是通过遍历之前存放的点来判断是否冲突):即与之前保存的点是否冲突,因为是从第一行递归到最后一行,所以不存在行与行的冲突,只需要注意列和斜线是否冲突即可。这里我选择遍历之前保存的所有点以进行判断。若不合理则剪枝。
注意:在每一次存入点到数组中后,如果要返回则记得回溯——即删除末尾点。

代码实现
我自己的:
class Solution {
vector<vector<string>> ans;
vector<pair<int, int>> position;
void DFS(int row, int col, int n){
if(row < 0 || row >= n || col < 0 || col >= n)
return;
if(!position.empty()){
for(auto it: position){
// if(row == it.first || col == it.second)
if(col == it.second) // 行可以不用判断,因为只会从第一行到最后一行,每行放一个
return;
if(abs(row-it.first) == abs(col-it.second))
return;
}
}
position.emplace_back(row, col);
if(position.size() == n){
// 创建答案到 ans 中
ans.emplace_back(n, string(n, '.'));
for(auto it: position)
ans.back()[it.first][it.second] = 'Q';
position.pop_back(); // 回溯
return;
}
for(int i = 0; i < n; i++)
DFS(row+1, i, n);
position.pop_back(); // 回溯
}
public:
vector<vector<string>> solveNQueens(int n) {
// 因为有 n 个皇后和 n 行 n 列,所以每一行和每一列有且只有一个皇后
// 所以只需要以某一行或某一列作为起始即可,这里从第一行开始
for(int m = 0; m < n; m++)
DFS(0, m, n);cout << endl;
return ans;
}
};
复杂度分析
- 时间复杂度:O(N!)O(N!)O(N!),其中 NNN 是皇后数量。
- 空间复杂度:O(N)O(N)O(N),其中 NNN 是皇后数量。空间复杂度主要取决于递归调用层数、记录放置的皇后坐标的数组。
本文详细介绍了N皇后问题的解决方案,包括回溯法的两种实现:基于集合和位运算。这两种方法的时间复杂度均为O(N!),空间复杂度分别为O(N)和O(1)。通过对棋盘状态的高效表示和剪枝策略,有效地减少了计算量,实现了不同解法的演示。

——N 皇后&spm=1001.2101.3001.5002&articleId=125691115&d=1&t=3&u=f89bf3b7c286476dafc8cb6b9dec5bfb)
606

被折叠的 条评论
为什么被折叠?



