头图

本文将介绍N皇后问题的五种解法,包括朴素回溯法、对称优化、标记优化、可用优化、位运算优化,对于每种解题思路,提供相应的递归版代码实现,最后将对每种解法进行测试,横向对比每种解法的求解时间。

题目描述

N×N 格的国际象棋上摆放 N 个皇后,使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少种摆法?

回溯法

解题思路

回溯法采用深度有限的搜索策略遍历问题的解空间树,可采用递归方式实现,也可采用非递归方式实现。本文提供的解题思路均采用递归法实现。

代码实现

/**
 * N皇后问题:回溯法(所有下标均从1开始)
 * @param n 皇后的数量
 * @return 摆法的数量
 */
int queen(int n) {

    // 特判
    if (n == 1)return 1;

    // 棋盘  解的数量
    int q[n + 1], res = 0;
    memset(q, 0, sizeof q);

    // 判断第k个皇后放置位置是否合适
    auto check = [&](int k) {
        for (int i = 1; i < k; ++i)
            // 同列、同斜线已存在皇后
            if (q[i] == q[k] || abs(q[i] - q[k]) == abs(i - k))return false;
        return true;
    };

    // 尝试放置第i行
    function<void(int)> dfs = [&](int i) {
        if (i > n && ++res)return;
        q[i] = 0;
        while (++q[i] <= n) {
            if (!check(i))continue;
            dfs(i + 1);
        }
    };

    // 尝试放置第一行
    dfs(1);

    return res;
}

时间复杂度:O(n^{n^n})

空间复杂度:O(n)

对称优化

解题思路

仔细观察N-皇后的解,发现一种方案可以通过“对称”得到另一种方案。以“左右对称”为例,当 N=5,限定第一行皇后在左边一半区域时,方案数为 6,如图 1 所示。

(N皇后的可行解存在七种对称关系,此处仅讨论左右对称。)

图1

通过“左右对称”可以获得另一方案,同时发现,后面有两种方案重复,去除重复方案后,剩下的刚好是 N=5 时的全部方案,如图 2 所示。

图2

N 为偶数时关于中间那条线对称,当 N 为奇数时关于中间那一列对称。利用左右对称可以使得工作量减少一半,为此,在放置皇后时,增加两条限制

  1. 第一行的皇后只放在左边一半区域,也即位置小于等于 (n+1)/2
  2. N 为奇数且第一行皇后刚好放在 (n+1)/2 位置(即中间)时,为避免重复,第二行皇后必须放在左边一半区域。

代码实现

/**
 * N皇后问题:对称优化(所有下标均从1开始)
 * @param n 皇后的数量
 * @return 摆法的数量
 */
int queen(int n) {

    // 特判
    if (n == 1)return 1;

    // 棋盘 中点位置 解的数量
    int q[n + 1], w = (n + 1) >> 1, res = 0;
    memset(q, 0, sizeof q);

    // n是否为奇数
    bool odd = n & 1;

    // 判断第k个皇后放置位置是否合适
    auto check = [&](int k) {
        for (int i = 1; i < k; ++i)
            // 同列、同斜线已存在皇后
            if (q[i] == q[k] || abs(q[i] - q[k]) == abs(i - k))return false;
        return true;
    };


    // 尝试放置第i行,第i行放置的上限为k
    function<void(int, int)> dfs = [&](int i, int k) {
        if (i > n && ++res)return;
        // 尝试放置第i行的每个位置
        q[i] = 0;
        while (++q[i] <= k) {
            if (!check(i))continue;
            // 第1行放中间时,第2行必须放左边
            if (odd && i == 1 && q[1] == w)dfs(2, w - 1);
            else dfs(i + 1, n);
        }
    };

    // 第1行的上限为w
    dfs(1, w);

    return res << 1;
}

时间复杂度:O(n^{n^n})

空间复杂度:O(n)

标记优化

解题思路

对于棋盘单元坐标,有如下规律(图 2 为两个 4×4 的棋盘):

  1. 同一正斜线所占据的单元的横纵坐标之和相等。
  2. 同一反斜线所占据的单元的横纵坐标之差相等。

图3

由此,可以设置数组 LR,表示斜线的占有情况,从而可以做到快速判断某位置是否可以放置皇后。

L[i] 表示和为 i 的正斜线是否被占据,i 的范围为 [2,2N],故 0,1两个位置舍去不用。

R[i] 表示差为 i 的反斜线是否被占据,i 的范围为 [1-N,N-1],为避免负下标,对 i 作加 N 处理。

L[i] 中的 i 舍去 0,1 两个位置,R[i] 中的 iN 而不是加 N-1,都是为了减少计算量。

同时,再设置数组 YY[i] 表示第 i 列是否被占据,1≤i≤N。改用根据数组 L,R,Y 来判断某位置是否可以放置皇后,可减少大量判断。( Y[i] 中的 i 不从 0 开始是为了便于处理)。

此处统一约定,对于标志数组 L,R,Y,值为 1 表示占用,值为 0 表示未占用。以 L 为例,L[i]=1 表示正斜线 i 被占用。

代码实现

/**
 * N皇后问题:标记优化(所有下标均从1开始)
 * @param n 皇后的数量
 * @return 摆法的数量
 */
int queen(int n) {

    // 特判
    if (n == 1)return 1;

    // 棋盘 列 左斜线 右斜线
    int q[n + 1], y[n + 1], l[(n << 1) + 1], r[(n << 1) + 1];

    memset(q, 0, sizeof q);
    memset(y, 0, sizeof y);
    memset(l, 0, sizeof l);
    memset(r, 0, sizeof r);

    // 中点位置 摆法数量
    int w = (n + 1) >> 1, res = 0;
    // n是否为奇数
    bool odd = n & 1;

    // 检查(i,j)是否可以放置皇后
    auto check = [&](int i, int j) {
        // 同列/左斜线(反斜线)/右斜线(正斜线)已存在皇后
        if (y[j] || l[i - j + n] || r[i + j])return false;
        // 同列、左斜线、右斜线标记为不可放置
        y[j] = l[i - j + n] = r[i + j] = 1;
        // 在(i,j)位置放置皇后
        q[i] = j;
        return true;
    };

    // 尝试放置第i行,第i行放置的上限为k
    function<void(int, int)> dfs = [&](int i, int k) {
        if (i > n && ++res)return;
        // 尝试放置第i行的每个位置
        for (int j = 1; j <= k; j++) {
            if (!check(i, j))continue;
            // 第1行放中间时,第2行必须放左边
            if (odd && i == 1 && q[1] == w)dfs(2, w - 1);
            else dfs(i + 1, n);
            // 同列、左斜线、右斜线标记为可放置
            y[j] = l[i - j + n] = r[i + j] = 0;
        }
    };

    // 第1行的上限为w
    dfs(1, w);

    return res << 1;
}

时间复杂度:O(n^n)

空间复杂度:O(n)

可用优化

解题思路

前面两种实现,总是从当前行的第一个位置开始尝试,即使当前行没有位置可以放置,也需尝试完当前行每一个位置,这显然是没有必要的。新增 next 数组,next[i] 表示位置 i 的下一个可用位置(可用列),next[0] 表示第一个可用位置,next[i]=0 表示 i 是最后一个可用位置,特别的,next[0]=0 表示无可用位置,此时需要回溯。既然已经知道哪些位置可用,那就不再需要数组 Y 来判断某列是否可用。

代码实现

/**
 * N皇后问题:可用优化(所有下标均从1开始)
 * @param n 皇后的数量
 * @return 摆法的数量
 */
int queen(int n) {

    // 特判
    if (n == 1)return 1;

    // 棋盘 列 左斜线 右斜线
    int q[n + 1], nex[n + 1], l[(n << 1) + 1], r[(n << 1) + 1];

    memset(q, 0, sizeof q);
    for (int i = nex[n] = 0; i < n; ++i) nex[i] = i + 1;
    memset(l, 0, sizeof l);
    memset(r, 0, sizeof r);

    // 中点位置 摆法数量
    int w = (n + 1) >> 1, res = 0;
    // n是否为奇数
    bool odd = n & 1;

    // 尝试放置第i行,第i行放置的上限为k
    function<void(int, int)> dfs = [&](int i, int k) {
        if (i > n && ++res)return;
        // cur 下一个可放置的位置 pre cur的前一个位置
        int pre = 0, cur = nex[pre];
        while (cur > 0 && cur <= k) {
            if (!l[i - cur + n] && !r[i + cur]) {
                // 左斜线、右斜线置为不可放置
                l[i - cur + n] = r[i + cur] = 1;
                // 在(i,cur)位置放置皇后
                q[i] = cur;
                // 删除cur
                nex[pre] = nex[cur];
                // 第一行棋子放中间时
                if (odd && i == 1 && q[1] == w)dfs(2, w - 1);
                else dfs(i + 1, n);
                // 将cur添加回备选位置
                nex[pre] = cur;
                // 左斜线、右斜线置为可放置
                l[i - cur + n] = r[i + cur] = 0;
            }
            // 下一节点的前导
            pre = cur;
            // 下一节点
            cur = nex[pre];
        }
    };

    // 第1行的上限为w
    dfs(1, w);

    return res << 1;
}

时间复杂度:O(n!)

空间复杂度:O(n)

位运算

解题思路

3×3 的棋盘为例,最左上角的左斜线记作第一条左斜线,最右上角的第一条右斜线记作第一条右斜线。为了便于叙述,以下涉及到的二进制均只有 n 位(棋盘大小),第几位是从左往右数。

将列、左斜线(/)、右斜线(\)的可用状态分别用二进制表示,1 表示占用,0 表示可用,以列为例,010 表示第 1,3 列可用,第 2 列占用。

将斜线状态转换为列状态,以左斜线为例,如下表所示

第1行第2行第3行
第1条左斜线100000000
第2条左斜线010100000
第3条左斜线001010100
第4条左斜线000001010
第5条左斜线000000001

(第 1 条左斜线,第 1 行)= 100 的解释为,若第 1 条左斜线不可用,对于第 1 行的影响是 100,即,第 1 列不能放置,第 2,3 列可以放置。

对于第 i 行而言,必须要放置一个皇后(放置不了就直接回溯了),放置完皇后,其对应左斜线状态必然不是 000,因为放置的这个皇后必然会导致某左斜线不可用,所以,假设第 i 行到第 i+1 行,左斜线状态状态由 A➡B,则 A 必定不为 000,在上表所有状态转换(由第 j 行到第 j+1)中,排除起始状态为 000 的转换,(i,j+1) 可由 (i,j) 左移一位得到。

同理可得,对于右斜线而言,(i,j+1) 可由 (i,j) 右移一位得到。

设考虑第 i 行时,列、左斜线、右斜线状态分别为 C,L,R,则

  • i 行可选的位置为 pos = ~(C | L | R) & ((1<<n)-1) 的二进制中 1 对应的列,假设选的是第 k 列,则记为 PP 的二进制中只有第 k 位为 1
  • 考虑第 i 行时,C = C|PL = (L|P)<<1R = (R|P)>>1

注意,C,L,R 需要始终保持只有 n 位有效,由于整数 int32 位,那么除开低 n 位,其余各位均需保持为 0

代码实现

/**
 * N皇后问题:位运算
 * @param n 皇后的数量
 * @return 摆法的数量
 */
int queen(int n) {
    int res = 0, mk = (1 << n) - 1;
    // c 列 l 左斜线  r 右斜线
    function<void(int, int, int)> dfs = [&](int c, int l, int r) {
        // 每行都放完则列状态低n位均为1,即等于mk
        if (c == mk && ++res)return;
        // 放置的位置
        int pos = ~(c | l | r) & mk, p;
        while (pos) {
            // 取pos最低位的1
            p = pos & (-pos);
            // 将pos最低位的1置为0
            pos &= pos - 1;
            // 放置第k+1行
            dfs(c | p, (l | p) << 1, (r | p) >> 1);
        }
    };
    // 列、左斜线、右斜线初始状态均为0,即均未被占用
    dfs(0, 0, 0);
    return res;
}

时间复杂度:O(n!)

空间复杂度:O(n)

统计与分析

五种解法均采用递归实现,为了直观比较五种解法的效率,分别统计五种解法在 N=10N=18 的情况下的求解时间(单位为毫秒),测试结果如下表所示。

解法\N1011121314151161718
回溯法4291631000641843895313615238245318972771
对称优化2147444728641998614250810961718902861
标记优化01632270158910214707695129743898774
可用优化14211247264513311002212711633623
位运算04241357744905331372428481889411

根据上表数据制作散点图,如图 4 所示:

图4 N皇后问题递归求解时间散点图

从回溯法到可用优化,通过逐步优化求解方式,求解时间也显著减少。位运算方式的求解时间略高于可用优化,但大致相当。

END

文章文档:公众号 字节幺零二四 回复关键字可获取本文文档。


字节幺零二四
9 声望5 粉丝

talk is cheap, show me you code!