題目的難度顏色使用 Luogu 上的分級,由簡單到困難分別為 🔴🟠🟡🟢🔵🟣⚫。

🔗 🟢 AT_dp_o Matching

Problem Statement

題目簡述

NN 位男性與 NN 位女性,給定相容性矩陣 ai,ja_{i,j},其中 ai,j=1a_{i,j} = 1 表示男性 ii 與女性 jj 相容。求將所有男女配成 NN 對(每人恰屬一對)的方案數,答案對 109+710^9+7 取模。

Constraints

約束條件

  • 1N211 \le N \le 21
  • ai,j{0,1}a_{i,j} \in \{0, 1\}

思路:狀態壓縮 DP

問題分析

本題本質上是求二分圖完美匹配的計數問題。若使用暴力枚舉所有排列,時間複雜度為 O(N!)O(N!),對於 N=21N = 21 是無法接受的。

注意到 N21N \le 21,可以使用 Bitmask DP 來優化:用一個集合 S{1,2,,N}S \subseteq \{1, 2, \ldots, N\} 來表示哪些女性已經被分配(實作時以 NN 位的 bitmask 儲存)。

方法一:記憶化搜索

狀態定義

定義 dfs(S)\text{dfs}(S) 為:集合 SS 中的女性已被前 S|S| 位男性匹配時,剩餘男女完成配對的方案數。

  • 邊界條件:當 S=N|S| = N 時,所有人已配對,返回 11
  • 遞迴轉移:對於第 i=Si = |S| 位男性,枚舉所有相容且未配對的女性 jSj \notin S,累加 dfs(S{j})\text{dfs}(S \cup \{j\})
  • 答案dfs()\text{dfs}(\emptyset)
Python 實作限制

使用 @cache 裝飾器的遞迴版本會遇到遞迴深度限制雜湊效率較低的問題,無法通過本題。可將遞迴改寫為顯式堆疊模擬,避免遞迴深度問題。

複雜度分析

  • 時間複雜度:O(N2N)\mathcal{O}(N \cdot 2^N),共有 2N2^N 個狀態,每個狀態需枚舉 O(N)O(N) 位女性。
  • 空間複雜度:O(2N)\mathcal{O}(2^N),記憶化需儲存所有狀態的結果。

方法二:遞推 DP

狀態定義

f[S]f[S] 為:當前已考慮S|S| 位男性,且集合 SS 中的女性已被匹配時,到達此狀態的配對方案數。

兩種方法的狀態定義差異

  • 方法一dfs(S)\text{dfs}(S) 計算的是從狀態 SS 往後完成配對的方案數(後綴計數)。
  • 方法二f[S]f[S] 計算的是從初始狀態 \emptyset 到達 SS 的方案數(前綴計數)。

兩者本質相同,只是計數方向相反。遞推適合從小狀態逐步推向大狀態。

關鍵觀察

由於男性是依序考慮的,當我們處理第 ii 位男性時,S=i|S| = i(代表前 ii 個男性各自匹配了一位女性)。因此 不需要額外維度記錄當前處理到第幾位男性,只需透過 S|S| 即可隱式推算。

轉移方程

對於第 ii 位男性(i=Si = |S|),枚舉所有尚未配對且相容的女性 jSj \notin S

f[S{j}]ai,j=1f[S]f[S \cup \{j\}] \xleftarrow{a_{i,j} = 1} f[S]

初始與答案

  • 初始狀態f[]=1f[\emptyset] = 1(無人配對,唯一的「空」方案)
  • 最終答案f[{1,2,,N}]f[\{1, 2, \ldots, N\}](所有女性皆已配對)

複雜度分析

  • 時間複雜度:O(N2N)\mathcal{O}(N \cdot 2^N),外層遍歷 NN 位男性,每位男性需遍歷當前有效狀態(最多 (Ni)\binom{N}{i} 個)並嘗試 NN 位女性。
  • 空間複雜度:O(2N)\mathcal{O}(2^N),需儲存所有可能的集合狀態。由於處理第 ii 位男性時,只需保留 S=i|S| = i 的狀態(共 (Ni)\binom{N}{i} 個),使用滾動陣列可將空間優化為 O((NN/2))O(\binom{N}{\lfloor N/2 \rfloor})

Code

方法一:記憶化搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from functools import cache

MOD = int(1e9 + 7)

def solve():
N = int(input())
grid = [list(map(int, input().split())) for _ in range(N)]

"""1. 使用 @cache 裝飾器實現記憶化搜索,但會有遞歸深度限制以及雜湊效率較低的問題"""
# @cache
# def dfs(msk: int) -> int:
# i = msk.bit_count()
# if i == N:
# return 1
# res = 0
# for j in range(N):
# if msk & (1 << j) or grid[i][j] == 0:
# continue
# res = (res + dfs(msk | (1 << j))) % MOD
# return res
# print(dfs(0))

"""2. 將 DFS 轉換成 Stack 實現記憶化搜索"""
U = 1 << N
f = [-1] * U
st = [(0, 0)] # (msk, flag)
while st:
msk, flag = st.pop()
i = msk.bit_count()
if f[msk] != -1:
continue
if i == N:
f[msk] = 1
continue
if flag == 0:
st.append((msk, 1))
for j in range(N):
if msk & (1 << j) or grid[i][j] == 0:
continue
st.append((msk | (1 << j), 0))
else:
res = 0
for j in range(N):
if msk & (1 << j) or grid[i][j] == 0:
continue
res = (res + f[msk | (1 << j)]) % MOD
f[msk] = res
continue
print(f[0])

if __name__ == "__main__":
solve()

方法二:遞推 DP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from collections import defaultdict

MOD = int(1e9 + 7)

def solve():
N = int(input())
grid = [list(map(int, input().split())) for _ in range(N)]

U = 1 << N
f = defaultdict(int)
f[0] = 1
for i in range(N):
nf = defaultdict(int)
for msk, val in f.items():
for j in range(N):
if msk & (1 << j) or grid[i][j] == 0:
continue
nf[msk | (1 << j)] += val
nf[msk | (1 << j)] %= MOD
f = nf
print(f[U - 1])

if __name__ == "__main__":
solve()