基于位运算状态压缩与最少剩余候选数(MRV)启发式剪枝的 DFS 回溯搜索,求解靶形数独最高得分。
OJ: roj
题目 ID: 1447
难度:提高
标签:DFS剪枝位运算启发式搜索
创建: 2026-03-31 01:00
更新: 2026-10-07 11:01
形式化题目
给定一个
要求在空格中填入
- 每行、每列均恰好包含
各一次且不重复; - 划分出的
个 的小九宫格中,每个宫内均恰好包含 各一次且不重复。
每个格子
目标是求出所有合法填法中所能获得的最高总分数。若该数独无合法填法,则返回
正解
思路
1. 靶形权重分布
每个格子
| 行 \ 列 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 |
| 1 | 6 | 7 | 7 | 7 | 7 | 7 | 7 | 7 | 6 |
| 2 | 6 | 7 | 8 | 8 | 8 | 8 | 8 | 7 | 6 |
| 3 | 6 | 7 | 8 | 9 | 9 | 9 | 8 | 7 | 6 |
| 4 | 6 | 7 | 8 | 9 | 10 | 9 | 8 | 7 | 6 |
| 5 | 6 | 7 | 8 | 9 | 9 | 9 | 8 | 7 | 6 |
| 6 | 6 | 7 | 8 | 8 | 8 | 8 | 8 | 7 | 6 |
| 7 | 6 | 7 | 7 | 7 | 7 | 7 | 7 | 7 | 6 |
| 8 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 |
由此可知权重计算可以直接用闭式表达:W(r, c) = 6 + min(r, 8 - r, c, 8 - c)。
2. 位运算状态压缩
标准数独的行、列、宫中数字
- 用数组
row_mask[r]记录第行已填数字; - 用数组
col_mask[c]记录第列已填数字; - 用数组
box_mask[b]记录第个九宫格( )已填数字。
对于未填空格 avail.bit_count())。
3. 最少剩余候选数原则(MRV)与可行性剪枝
如果按照固定的由左至右、由上至下的顺序枚举空格,前期可能遇到候选数多达
核心优化是引入最少剩余候选数原则(Minimum Remaining Values, MRV):
- 每一层递归搜索时,在所有尚未填入的空格中,动态计算每个空格的候选数个数;
- 选取当前候选数最少的格子进行展开:
- 若发现某个空格候选数个数为
,说明当前分支必定无合法解,立即回溯剪枝; - 若候选数个数为
(唯一候选数),则当前格子只有 个确定分支,直接顺推,几乎不产生额外回溯树分支。
- 若发现某个空格候选数个数为
- 枚举候选数字时,利用
lsb = mask & -mask与lsb.bit_length() - 1高效取出各个可选数字,更新掩码与得分后递归,回溯时复原状态。
flowchart TD
A[进入 DFS 当前状态] --> B{是否所有空格已填满?}
B -- 是 --> C[用当前得分更新 max_score 并返回]
B -- 否 --> D[遍历未填空格, 统计各格可选数字集合]
D --> E{是否存在候选数为空的格子?}
E -- 是 --> F[剪枝: 立即回溯]
E -- 否 --> G[选出候选数最少的格子 (r, c)]
G --> H[枚举 (r, c) 的各候选数字]
H --> I[更新状态与得分, 递归深入]
I --> J[恢复状态, 回溯]
代码
Python 版:
python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-03-31 01:00
# update_at: 2026-03-31 01:00
import sys
ALL_DIGITS = 0x3FE # 1~9 位掩码:1 << 1 到 1 << 9
def get_weight(r: int, c: int) -> int:
"""计算第 r 行第 c 列格子的靶形分值权重(6 到 10 分)。"""
return 6 + min(r, 8 - r, c, 8 - c)
def box_id(r: int, c: int) -> int:
"""计算第 r 行第 c 列所在的 3x3 小九宫格编号(0 到 8)。"""
return (r // 3) * 3 + (c // 3)
def search_max_score(grid: list[list[int]]) -> int:
"""回溯搜索数独的所有合法填法,返回最大加权得分;无解返回 -1。"""
row_mask = [0] * 9 # 每行已填数字集合的位掩码
col_mask = [0] * 9 # 每列已填数字集合的位掩码
box_mask = [0] * 9 # 每宫已填数字集合的位掩码
initial_score = 0
empties: list[tuple[int, int]] = []
for r in range(9):
for c in range(9):
v = grid[r][c]
if v:
b = box_id(r, c)
row_mask[r] |= 1 << v
col_mask[c] |= 1 << v
box_mask[b] |= 1 << v
initial_score += v * get_weight(r, c)
else:
empties.append((r, c))
max_score = -1
def dfs(score: int) -> None:
nonlocal max_score
best_r, best_c = -1, -1
min_choices = 10
best_mask = 0
# 最少剩余候选数原则(MRV)挑选分支最少的空格
for r, c in empties:
if grid[r][c] == 0:
avail = ALL_DIGITS & ~(row_mask[r] | col_mask[c] | box_mask[box_id(r, c)])
cnt = avail.bit_count()
if cnt == 0: # 存在无解空格,剪枝
return
if cnt < min_choices:
min_choices = cnt
best_r, best_c = r, c
best_mask = avail
if cnt == 1:
break
if min_choices == 10: # 所有空格均已填满
if score > max_score:
max_score = score
return
r, c = best_r, best_c
b = box_id(r, c)
w = get_weight(r, c)
mask = best_mask
# 逆序从 9 到 1 尝试(或按位提取):lsb 提取并回溯
while mask:
lsb = mask & -mask
d = lsb.bit_length() - 1
mask ^= lsb
grid[r][c] = d
row_mask[r] |= lsb
col_mask[c] |= lsb
box_mask[b] |= lsb
dfs(score + d * w)
grid[r][c] = 0
row_mask[r] ^= lsb
col_mask[c] ^= lsb
box_mask[b] ^= lsb
dfs(initial_score)
return max_score
def solve() -> None:
lines = [line.strip() for line in sys.stdin if line.strip()]
if not lines:
return
grid = [list(map(int, line.split())) for line in lines]
ans = search_max_score(grid)
print(ans)
if __name__ == "__main__":
solve()C++ 版(同一算法):
cpp
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-05 02:16
* update_at: 2026-10-05 02:16
*/
#include <cstdio>
typedef long long ll;
const int ALL_DIGITS = 0x3FE; // 二进制第 1~9 位表示数字 1~9 可用
// 全局数据
int grid[9][9]; // 当前棋盘,0 表示未填
int row_mask[9]; // row_mask[r]:第 r 行已填数字的位掩码
int col_mask[9]; // col_mask[c]:第 c 列已填数字的位掩码
int box_mask[9]; // box_mask[b]:第 b 宫已填数字的位掩码
int emp_r[81]; // 空格的行号列表
int emp_c[81]; // 空格的列号列表
int emp_cnt = 0; // 空格总数
int filled_cnt = 0; // 已填的空格数
ll max_score = -1; // 当前搜到的最高得分,初始 -1 表示无解
// 计算第 r 行第 c 列的靶形权重(6~10 分)
inline int get_weight(int r, int c) {
int t = r;
if (8 - r < t) t = 8 - r;
if (c < t) t = c;
if (8 - c < t) t = 8 - c;
return 6 + t;
}
// 统计掩码中置位个数
inline int bit_count(int x) {
int cnt = 0;
while (x) {
x &= x - 1;
cnt++;
}
return cnt;
}
// 取出掩码最低位的 1(即最小候选数字对应的位)
inline int lowbit(int x) {
return x & -x;
}
// MRV 搜索:每层在所有空格中选候选数最少的格子展开
// score:当前已确定的得分;无解时保持 max_score = -1
void dfs(ll score) {
// 候选数最少的空格
int best_i = -1;
int min_choices = 10;
int best_mask = 0;
// 遍历所有空格,找候选数最少的那个
for (int i = 0; i < emp_cnt; i++) {
if (grid[emp_r[i]][emp_c[i]] != 0) continue; // 已填的空格跳过
int r = emp_r[i];
int c = emp_c[i];
int b = (r / 3) * 3 + c / 3;
// 可填数字集合 = 全集 去掉 行、列、宫已用数字
int avail = ALL_DIGITS & ~(row_mask[r] | col_mask[c] | box_mask[b]);
int cnt = bit_count(avail);
if (cnt == 0) return; // 有格子无数可填,当前分支必无解,剪枝
if (cnt < min_choices) { // MRV:挑分支最少的空格
min_choices = cnt;
best_i = i;
best_mask = avail;
if (cnt == 1) break; // 唯一候选数,不用再找了
}
}
// 没有空格可填:得到一个完整解,更新最高分
if (best_i == -1) {
if (score > max_score) max_score = score;
return;
}
int r = emp_r[best_i];
int c = emp_c[best_i];
int b = (r / 3) * 3 + c / 3;
int w = get_weight(r, c);
// 依次尝试该格子的每个候选数字,回溯复原
int mask = best_mask;
while (mask) {
int lsb = lowbit(mask);
mask ^= lsb;
int d = 0;
for (int k = lsb; k > 1; k >>= 1) d++; // lsb 对应的数字
grid[r][c] = d;
row_mask[r] |= lsb;
col_mask[c] |= lsb;
box_mask[b] |= lsb;
dfs(score + (ll)d * w);
grid[r][c] = 0;
row_mask[r] ^= lsb;
col_mask[c] ^= lsb;
box_mask[b] ^= lsb;
}
}
int main() {
// 读入数独,初始化掩码、得分和空格列表
ll init_score = 0;
for (int r = 0; r < 9; r++) {
for (int c = 0; c < 9; c++) {
int v;
scanf("%d", &v);
grid[r][c] = v;
int b = (r / 3) * 3 + c / 3;
if (v != 0) {
int bit = 1 << v;
// 同一行、列、宫出现重复数字,直接无解
if (row_mask[r] & bit || col_mask[c] & bit || box_mask[b] & bit) {
printf("-1\n");
return 0;
}
row_mask[r] |= bit;
col_mask[c] |= bit;
box_mask[b] |= bit;
init_score += (ll)v * get_weight(r, c);
} else {
emp_r[emp_cnt] = r;
emp_c[emp_cnt] = c;
emp_cnt++;
}
}
}
dfs(init_score);
printf("%lld\n", max_score);
return 0;
}复杂度
- 时间复杂度:最坏情况下数独求解为指数级复杂度,但通过位运算与 MRV 启发式剪枝后,合法状态空间被极大压缩,剪枝率极高,绝大多数数独分支都在极浅层被裁减。全部 20 组官方评测数据在单用例 1.5s 甚至数十毫秒内即可求解完毕。
- 空间复杂度:
。棋盘大小固定为 ,掩码状态仅需常数级别的整型数组,递归调用栈深度至多为未填空格数 层,内存消耗小于 20MB。
总结
靶形数独是一道经典的带权精确覆盖搜索问题。相比普通数独只求任意一组解,本题需要遍历所有合法填法以求最大分值。解决本题的关键在于:
- 状态表达:用位运算将数独三个维度的合法性检查与候选数提取压缩到常数级;
- 搜索顺序(MRV):每次动态挑选候选数最少的空格填数,把确定性最高的分支往前放,提前暴露矛盾,从而将搜索树的大部分无效子树剪去。

