深度优先搜索—N 皇后(leetcode 51)

探讨N皇后问题的解决方法,包括回溯算法的两种实现方式:基于集合和基于位运算的回溯,通过这两种方法找到所有不冲突的解法。

题目描述

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

示例 1:

输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:

输入:n = 1
输出:[["Q"]]

提示:

    1 <= n <= 9
    皇后彼此不能相互攻击,也就是说:任何两个皇后都不能处于同一条横行、纵行或斜线上。

算法分析

「N皇后问题」研究的是如何将 N个皇后放置在 N×N 的棋盘上,并且使皇后彼此之间不能相互攻击。

皇后的走法是:可以横直斜走,格数不限。因此要求皇后彼此之间不能相互攻击,等价于要求任何两个皇后都不能在同一行、同一列以及同一条斜线上。

直观的做法是暴力枚举将 N个皇后放置在 N×N的棋盘上的所有可能的情况,并对每一种情况判断是否满足皇后彼此之间不相互攻击。暴力枚举的时间复杂度是非常高的,因此必须利用限制条件加以优化。

显然,每个皇后必须位于不同行和不同列,因此将 N个皇后放置在 N×N 的棋盘上,一定是每一行有且仅有一个皇后,每一列有且仅有一个皇后,且任何两个皇后都不能在同一条斜线上。基于上述发现,可以通过回溯的方式寻找可能的解。

回溯的具体做法是:使用一个数组记录每行放置的皇后的列下标,依次在每一行放置一个皇后。每次新放置的皇后都不能和已经放置的皇后之间有攻击:即新放置的皇后不能和任何一个已经放置的皇后在同一列以及同一条斜线上,并更新数组中的当前行的皇后列下标。当 N个皇后都放置完毕,则找到一个可能的解。当找到一个可能的解之后,将数组转换成表示棋盘状态的列表,并将该棋盘状态的列表加入返回列表。

由于每个皇后必须位于不同列,因此已经放置的皇后所在的列不能放置别的皇后。第一个皇后有 N列可以选择,第二个皇后最多有 N−1列可以选择,第三个皇后最多有 N−2列可以选择(如果考虑到不能在同一条斜线上,可能的选择数量更少),因此所有可能的情况不会超过 N!种,遍历这些情况的时间复杂度是 O(N!)。

为了降低总时间复杂度,每次放置皇后时需要快速判断每个位置是否可以放置皇后,显然,最理想的情况是在 O(1)的时间内判断该位置所在的列和两条斜线上是否已经有皇后。

以下两种方法分别使用集合和位运算对皇后的放置位置进行判断,都可以在 O(1)的时间内判断一个位置是否可以放置皇后,算法的总时间复杂度都是 O(N!)。
方法一:基于集合的回溯

为了判断一个位置所在的列和两条斜线上是否已经有皇后,使用三个集合 columns 和 diagonals2分别记录每一列以及两个方向的每条斜线上是否有皇后。

列的表示法很直观,一共有 N列,每一列的下标范围从 0到 N−1,使用列的下标即可明确表示每一列。

如何表示两个方向的斜线呢?对于每个方向的斜线,需要找到斜线上的每个位置的行下标与列下标之间的关系。

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

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

每次放置皇后时,对于每个位置判断其是否在三个集合中,如果三个集合都不包含当前位置,则当前位置是可以放置皇后的位置。

代码

class Solution {
public:
    vector<vector<string>> ans;
    vector<int> path;
    unordered_set<int> cols;
    unordered_set<int> mains;
    unordered_set<int> sub;
    void dfs(int row, int n) {
        if(row == n) {
            vector<string> board = getBoard(path, n);
            ans.emplace_back(board);
            return;
        }
        for(int i = 0; i < n; ++i) {
            if(cols.find(i) != cols.end() || mains.find(row-i) != mains.end() || sub.find(row+i)!= sub.end()) {
                continue;
            }
            path[row] = i;
            cols.insert(i);
            mains.insert(row-i);
            sub.insert(row+i);
            dfs(row+1, n);
            path[row] = -1;
            cols.erase(i);
            mains.erase(row-i);
            sub.erase(row+i);
        }
    }
    vector<string> getBoard(vector<int>& path, int n) {
        vector<string> board;
        for(int i = 0; i < n; ++i) {
            string row = string(n, '.');
            row[path[i]] = 'Q';
            board.emplace_back(row);
        }
        return board;
    }
    vector<vector<string>> solveNQueens(int n) {
        path.resize(n, -1);
        dfs(0, n);
        return ans;
    }
};

算法复杂度分析

时间复杂度:O(N!),其中 N 是皇后数量。

空间复杂度:O(N),其中 N是皇后数量。空间复杂度主要取决于递归调用层数、记录每行放置的皇后的列下标的数组以及三个集合,递归调用层数不会超过 N,数组的长度为 N,每个集合的元素个数都不会超过 N。

 方法二:基于位运算的回溯

方法一使用三个集合记录分别记录每一列以及两个方向的每条斜线上是否有皇后,每个集合最多包含 N个元素,因此集合的空间复杂度是 O(N)。如果利用位运算记录皇后的信息,就可以将记录皇后信息的空间复杂度从 O(N) 降到 O(1)。

具体做法是,使用三个整数 columns、diagonals1和 diagonals2分别记录每一列以及两个方向的每条斜线上是否有皇后,每个整数有 N个二进制位。棋盘的每一列对应每个整数的二进制表示中的一个数位,其中棋盘的最左列对应每个整数的最低二进制位,最右列对应每个整数的最高二进制位。

那么如何根据每次放置的皇后更新三个整数的值呢?在说具体的计算方法之前,首先说一个例子。

棋盘的边长和皇后的数量 N=8。如果棋盘的前两行分别在第 2列和第 4列放置了皇后(下标从 0开始),则棋盘的前两行如下图所示。

如果要在下一行放置皇后,哪些位置不能放置呢?我们用 0代表可以放置皇后的位置,1代表不能放置皇后的位置。

新放置的皇后不能和任何一个已经放置的皇后在同一列,因此不能放置在第 2列和第 4 列,对应 columns=00010100(2)。

新放置的皇后不能和任何一个已经放置的皇后在同一条方向一(从左上到右下方向)的斜线上,因此不能放置在第 4列和第 5 列,对应 diagonals1=00110000(2)​。其中,第 4列为其前两行的第 2列的皇后往右下移动两步的位置,第 5列为其前一行的第 4列的皇后往右下移动一步的位置。

新放置的皇后不能和任何一个已经放置的皇后在同一条方向二(从右上到左下方向)的斜线上,因此不能放置在第 0列和第 3列,对应 diagonals2=00001001(2)​。其中,第 0列为其前两行的第 2列的皇后往左下移动两步的位置,第 3列为其前一行的第 4列的皇后往左下移动一步的位置。

由此可以得到三个整数的计算方法:

    初始时,三个整数的值都等于 0,表示没有放置任何皇后;

    在当前行放置皇后,如果皇后放置在第 i列,则将三个整数的第 i个二进制位(指从低到高的第 i个二进制位)的值设为 1;

    进入下一行时,columns的值保持不变,diagonals1左移一位,diagonals2右移一位,由于棋盘的最左列对应每个整数的最低二进制位,即每个整数的最右二进制位,因此对整数的移位操作方向和对棋盘的移位操作方向相反(对棋盘的移位操作方向是 diagonals1右移一位,diagonals2左移一位)。

每次放置皇后时,三个整数的按位或运算的结果即为不能放置皇后的位置,其余位置即为可以放置皇后的位置。可以通过 (2^n−1) & (∼(columns∣diagonals1∣diagonals2))得到可以放置皇后的位置(该结果的值为 1的位置表示可以放置皇后的位置),然后遍历这些位置,尝试放置皇后并得到可能的解。

遍历可以放置皇后的位置时,可以利用以下两个按位与运算的性质:

    x & (−x)可以获得 x的二进制表示中的最低位的 1的位置;

    x & (x−1) 可以将 x的二进制表示中的最低位的 1置成 0。

具体做法是,每次获得可以放置皇后的位置中的最低位,并将该位的值置成 0,尝试在该位置放置皇后。这样即可遍历每个可以放置皇后的位置。

class Solution {
public:
    vector<vector<string>> ans;
    vector<int> path;
    void dfs(int row, int n, int col, int main, int sub) {
        if(row == n) {
            vector<string> board = generateBoard(path, n);
            ans.emplace_back(board);
            return;
        }
        int availablePosition = (((1<<n)-1)&(~(col | main|sub)));
        while(availablePosition != 0) {

            // 2 = [0010]  -2=[1110]   2&(-2) = 0010

            int position = availablePosition & (-availablePosition);

            availablePosition &= (availablePosition-1);
            int columns = getIndex(position);
            path[row] = columns;
            dfs(row+1, n, col|position, (main|position)>>1,((sub|position)<<1));
            path[row] = -1;
        }
    }
    int getIndex(int x) {
        int count = 0;
        while(x) {
            if(x &1 == 1) {
                break;
            }
            x>>=1;
            ++count;
        }
        return count;
    }
    vector<string> generateBoard(vector<int>& path, int n) {
        vector<string> board;
        for(int i=0; i < n; ++i) {
            string row = string(n, '.');
            row[path[i]] = 'Q';
            board.emplace_back(row);
        }
        return board;
    }
    vector<vector<string>> solveNQueens(int n) {
        path.resize(n, -1);
        dfs(0,n,0,0,0);
        return ans;
    }
};

时间复杂度:O(N!),其中 N是皇后数量。

空间复杂度:O(N),其中 N是皇后数量。由于使用位运算表示,因此存储皇后信息的空间复杂度是 O(1),空间复杂度主要取决于递归调用层数和记录每行放置的皇后的列下标的数组,递归调用层数不会超过 N,数组的长度为 N。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值