算法典型例题: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皇后的可行解存在七种对称关系,此处仅讨论左右对称。)

image

图1

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

image

图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. 同一反斜线所占据的单元的横纵坐标之差相等。

image

图3

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

\(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]\) 中的 \(i\)\(N\) 而不是加 \(N-1\),都是为了减少计算量。

同时,再设置数组 \(Y\)\(Y[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条左斜线 100 000 000
第2条左斜线 010 100 000
第3条左斜线 001 010 100
第4条左斜线 000 001 010
第5条左斜线 000 000 001

(第 \(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\) 列,则记为 \(P\)\(P\) 的二进制中只有第 \(k\) 位为 \(1\)
  • 考虑第 \(i\) 行时,\(C = C|P\)\(L = (L|P)<<1\)\(R = (R|P)>>1\)

注意,\(C,L,R\) 需要始终保持只有 \(n\) 位有效,由于整数 \(int\)\(32\) 位,那么除开低 \(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=10\)\(N=18\) 的情况下的求解时间(单位为毫秒),测试结果如下表所示。

解法\N 10 11 12 13 14 15 116 17 18
回溯法 4 29 163 1000 6418 43895 313615 2382453 18972771
对称优化 2 14 74 447 2864 19986 142508 1096171 8902861
标记优化 0 16 32 270 1589 10214 70769 512974 3898774
可用优化 1 4 21 124 726 4513 31100 221271 1633623
位运算 0 4 24 135 774 4905 33137 242848 1889411

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

image

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

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

END

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

posted @ 2024-07-15 09:19  字节幺零二四  阅读(200)  评论(0)    收藏  举报