演算法圖鑑
Recursion & Backtracking · 04 / 05

N-QueensN 皇后

逐列放置,用集合記錄被攻擊的欄與對角線

用在:約束滿足問題的原型:排課、排班

時間複雜度指數
空間複雜度O(n)
難度困難
前置知識Combinations & Combination Sum、Hash Table

01為什麼需要它

自動排課

每門課要選一個時段和教室,同一位老師不能同時上兩門課、同一間教室不能同時有兩班、某些課不能排在同一天。一百多門課,手排要花幾個星期。

為什麼用它一次處理一門課,從可用的時段裡挑一個不衝突的,往下排下一門;全部時段都衝突就退回上一門課換一個時段。N 皇后是這種「約束滿足問題」最小的教科書版本:每一列放一個皇后,不能和已放的同欄、同對角線。

值班表與座位安排

護理站每天要排三班,每個人有不能值的日子、連續值班的上限、和某些人不能同班的限制。要找出一份滿足所有規則的班表。

為什麼用它逐格填、每填一格就檢查所有規則、違反就回頭改上一格,這正是回溯。關鍵在「檢查衝突要快」:N 皇后用三個集合把每次檢查壓到 O(1),排班則用同樣的思路預先建立每個人、每一天的佔用表。

數獨與填字遊戲的求解器

手機上的數獨 app 要能驗證任何一盤有解,還要能給提示。人腦解法是「填一格、看看有沒有矛盾、有就擦掉重填」。

為什麼用它程式解法和人腦一模一樣:逐格嘗試 1 到 9,用列、欄、宮三組集合檢查衝突,走不通就回溯。數獨是 N 皇后的直接延伸,差別只在約束的形狀。

看到這些關鍵字就想到它:不能衝突、每列每欄只能一個、排課排班、約束滿足、放置後要檢查、走不通就換上一步、數獨。

02核心概念

N 皇后要在 n×n 的棋盤放 n 個皇后,任兩個不能在同一列、同一欄或同一對角線。第一個洞見是一列恰好一個:既然不能同列而且要放 n 個,每列必定剛好一個,所以只要決定「第 r 列的皇后放在哪一欄」。搜尋空間從「任選 n 格」變成「每列選一欄」,一下子小很多。

第二個洞見是衝突檢查要 O(1)。同欄的格子 c 相同;同一條「\」對角線的格子 r−c 相同;同一條「/」對角線的格子 r+c 相同。用三個集合記錄已放皇后的 c、r−c、r+c,判斷一格能不能放只要查三次。放皇后時把三個值加進去,撤銷時三個都要移除,漏掉一個之後的分支就會誤判。

搜尋本身就是回溯的三步:對第 r 列的每一欄 c,被攻擊就跳過(剪枝),否則做選擇、遞迴到 r+1、撤銷。若第 r+1 列每一欄都被攻擊,遞迴會直接返回,這就是「回溯」的時刻:拿掉第 r 列的皇后,換下一欄。整個過程是一棵深度 n 的樹上的 DFS,每個節點的分支數是那一列還沒被攻擊的欄數。

複雜度是指數的,粗略上界 O(n!),剪枝後實際小得多;8 皇后有 92 組解,搜尋節點約兩千個。只數解不列出棋盤時,可以用位元遮罩取代集合:三個整數的第 c 位表示欄 c 是否被攻擊,換到下一列時「\」對角線整體左移一位、「/」對角線右移一位。這是 N 皇后最快的寫法,也是「用整數當集合」這個技巧的經典範例。

03演算法步驟

  1. 1準備 queens(每列的欄)和三個集合 colsdiag1(r−c)、diag2(r+c)。dfs(r) 表示「正在放第 r 列」。
  2. 2終止條件:r == n,n 列都放好了,把 queens 轉成棋盤收進答案。
  3. 3對每個欄 c:若 c in colsr-c in diag1r+c in diag2,被攻擊,跳過。
  4. 4做選擇:queens.append(c),三個集合各加一個值,遞迴 dfs(r + 1)
  5. 5撤銷選擇:queens.pop(),三個集合各移除一個值。這一列所有欄試完仍無解,就自然返回到上一列,也就是回溯。

04互動示範

4 皇后。一列一列放,淺黃格是被現有皇后攻擊的位置,每次試到被攻擊的格子會標出原因。當某一列每一欄都被攻擊,就拿掉上一列的皇后換下一欄,直到找到第一組解。

開始4 皇后 · 逐列放置
被佔用的集合
cols
r−c
r+c

黃色底是被現有皇后攻擊的格子,深黃是這一步試到的被攻擊格。灰底是目前要放的那一列。每次判斷只查三個集合,O(1)。

步驟 0/314×4 棋盤放 4 個皇后,彼此不能同列、同欄、同對角線。一列放一個,所以只要決定每一列的欄。三個集合記錄被佔用的欄、左上右下對角線(r−c)、右上左下對角線(r+c)。

05程式碼

列出所有解的集合版本,以及只數解數量的位元遮罩版本。

# N 皇后(LeetCode 51):逐列放置,三個集合記錄被攻擊的欄與對角線
def solve_n_queens(n):
    ans = []
    queens = []                  # queens[r] = 第 r 列皇后所在的欄
    cols = set()                 # 被佔用的欄
    diag1 = set()                # r - c 相同的格子在同一條「\」對角線
    diag2 = set()                # r + c 相同的格子在同一條「/」對角線

    def dfs(r):
        if r == n:               # 每一列都放好了
            ans.append(["." * c + "Q" + "." * (n - c - 1) for c in queens])
            return
        for c in range(n):
            if c in cols or (r - c) in diag1 or (r + c) in diag2:
                continue         # 被攻擊,剪掉
            queens.append(c)     # 做選擇
            cols.add(c); diag1.add(r - c); diag2.add(r + c)
            dfs(r + 1)
            queens.pop()         # 撤銷選擇:三個集合都要復原
            cols.remove(c); diag1.remove(r - c); diag2.remove(r + c)

    dfs(0)
    return ans


# 只數解的數量(LeetCode 52):用位元遮罩代替集合
# cols / d1 / d2 是 n 位元的整數,第 c 位為 1 代表這一列的欄 c 被攻擊
def total_n_queens(n):
    full = (1 << n) - 1

    def dfs(cols, d1, d2):
        if cols == full:                     # n 個欄都放了皇后
            return 1
        count = 0
        free = full & ~(cols | d1 | d2)      # 這一列還能放的位置
        while free:
            bit = free & -free               # 取最低的 1
            free ^= bit
            # 下一列:\ 對角線往右一欄(<< 1),/ 對角線往左一欄(>> 1)
            count += dfs(cols | bit, ((d1 | bit) << 1) & full, (d2 | bit) >> 1)
        return count

    return dfs(0, 0, 0)


if __name__ == "__main__":
    for row in solve_n_queens(4)[0]:
        print(row)                           # .Q.. / ...Q / Q... / ..Q.
    print(total_n_queens(8))                 # 92

06練習題

  • LeetCode 36Valid Sudoku(先練衝突檢查)Medium
  • LeetCode 473Matchsticks to Square(每根火柴放進四條邊之一,排序後剪枝)Medium
  • LeetCode 51N-QueensHard
  • LeetCode 52N-Queens II(位元遮罩)Hard
  • LeetCode 37Sudoku Solver(列、欄、宮三組集合)Hard
  • LeetCode 1655Distribute Repeating Integers(約束滿足加剪枝)Hard