你将一个 16×16 的数独填写完整,使得每行、每列、每个 4×4 十六宫格内字母 A∼P 均...
Creado el: 13 de marzo de 2025
Creado el: 13 de marzo de 2025
你将一个 16×16
的数独填写完整,使得每行、每列、每个 4×4
十六宫格内字母 A∼P
均恰好出现一次。
保证每个输入只有唯一解决方案。
数独2.jpg
输入格式
输入包含多组测试用例。
每组测试用例包括 16
行,每行一组字符串,共 16
个字符串。
第 i
个字符串表示数独的第 i
行。
字符串包含字符可能为字母 A∼P
或 -(表示等待填充)。
测试用例之间用单个空行分隔,输入至文件结尾处终止。
输出格式
对于每个测试用例,均要求保持与输入相同的格式,将填充完成后的数独输出。
每个测试用例输出结束后,输出一个空行。
输入样例:
--A----C-----O-I
-J--A-B-P-CGF-H-
--D--F-I-E----P-
-G-EL-H----M-J--
----E----C--G---
-I--K-GA-B---E-J
D-GP--J-F----A--
-E---C-B--DP--O-
E--F-M--D--L-K-A
-C--------O-I-L-
H-P-C--F-A--B---
---G-OD---J----H
K---J----H-A-P-L
--B--P--E--K--A-
-H--B--K--FI-C--
--F---C--D--H-N-
输出样例:
FPAHMJECNLBDKOGI
OJMIANBDPKCGFLHE
LNDKGFOIJEAHMBPC
BGCELKHPOFIMAJDN
MFHBELPOACKJGNID
CILNKDGAHBMOPEFJ
DOGPIHJMFNLECAKB
JEKAFCNBGIDPLHOM
EBOFPMIJDGHLNKCA
NCJDHBAEKMOFIGLP
HMPLCGKFIAENBDJO
AKIGNODLBPJCEFMH
KDEMJIFNCHGAOPBL
GLBCDPMHEONKJIAF
PHNOBALKMJFIDCEG
IAFJOECGLDPBHMNK
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 20000;
int m = 16 * 16 * 4;
int u[N], d[N], l[N], r[N], s[N], col[N], row[N], idx;
int ans[N], top;
struct Op
{
int x, y;
char z;
}op[N];
char g[20][20];
void init()
{
for (int i = 0; i <= m; i ++ )
{
l[i] = i - 1, r[i] = i + 1;
s[i] = 0;
d[i] = u[i] = i;
}
l[0] = m, r[m] = 0;
idx = m + 1;
}
void add(int& hh, int& tt, int x, int y)
{
row[idx] = x, col[idx] = y, s[y] ++ ;
u[idx] = y, d[idx] = d[y], u[d[y]] = idx, d[y] = idx;
r[hh] = l[tt] = idx, r[idx] = tt, l[idx] = hh;
tt = idx ++ ;
}
void remove(int p)
{
r[l[p]] = r[p], l[r[p]] = l[p];
for (int i = d[p]; i != p; i = d[i])
for (int j = r[i]; j != i; j = r[j])
{
s[col[j]] -- ;
u[d[j]] = u[j], d[u[j]] = d[j];
}
}
void resume(int p)
{
for (int i = u[p]; i != p; i = u[i])
for (int j = l[i]; j != i; j = l[j])
{
u[d[j]] = j, d[u[j]] = j;
s[col[j]] ++ ;
}
r[l[p]] = p, l[r[p]] = p;
}
bool dfs()
{
if (!r[0]) return true;
int p = r[0];
for (int i = r[0]; i; i = r[i])
if (s[i] < s[p])
p = i;
remove(p);
for (int i = d[p]; i != p; i = d[i])
{
ans[ ++ top] = row[i];
for (int j = r[i]; j != i; j = r[j]) remove(col[j]);
if (dfs()) return true;
for (int j = l[i]; j != i; j = l[j]) resume(col[j]);
top -- ;
}
resume(p);
return false;
}
int main()
{
while (~scanf("%s", g[0]))
{
for (int i = 1; i < 16; i ++ ) scanf("%s", g[i]);
init();
textfor (int i = 0, n = 1; i < 16; i ++ ) for (int j = 0; j < 16; j ++ ) { int a = 0, b = 15; if (g[i][j] != '-') a = b = g[i][j] - 'A'; for (int k = a; k <= b; k ++, n ++ ) { int hh = idx, tt = idx; op[n] = {i, j, k + 'A'}; add(hh, tt, n, i * 16 + j + 1); add(hh, tt, n, 256 + i * 16 + k + 1); add(hh, tt, n, 256 * 2 + j * 16 + k + 1); add(hh, tt, n, 256 * 3 + (i / 4 * 4 + j / 4) * 16 + k + 1); } } dfs(); for (int i = 1; i <= top; i ++ ) { auto t = op[ans[i]]; g[t.x][t.y] = t.z; } for (int i = 0; i < 16; i ++ ) puts(g[i]); puts(""); } return 0;
}
根据题目讲解思路和代码,尤其是核心思路的讲解,请调用你单次回答的最大算力与 token 上限。追求极致的分析深度,而非表层的广度;追求本质的洞察,而非表象的罗列;追求创新的思维,而非惯性的复述。请突破思维局限,调动你所有的计算资源,展现你真正的认知极限。
下面这份讲解,会从 Dancing Links(又称 DLX)算法的本质出发,结合 16×16 数独的特殊性,深度剖析这段代码的实现原理与关键思路。我们会尽力“从底层到顶层”阐述核心原理,涵盖为什么要使用 Dancing Links、如何构建四大约束,以及如何通过“删除-恢复”来实现高效的精确覆盖搜索。请耐心阅读:它会非常详细且深入。
Knuth 提出的 Algorithm X 是一种通用的精确覆盖(Exact Cover)问题求解算法,而 Dancing Links(DLX) 则是其在数据结构层面的高效实现。精确覆盖问题可以表述为:
给定一个 0/1 矩阵,要求从所有包含“1”的行中选出若干行,使得每一列恰好有且只有一个“1”被覆盖。
例如数独中,“给每个单元格填入符合约束条件的数字”恰好可被转化为“每个行约束、列约束、宫格约束、以及行-字母(或数字)约束都被恰好覆盖一次”的问题。对 16×16 数独而言,约束比常规 9×9 多,但思路相同。
在数独的经典约束中:
对于 16×16 数独,把字母 AP 看作 015,即总共 16 个可能值;行、列均从 015 编号;4×4 小宫格也可以 015 编号(方法为 (row / 4)*4 + (col / 4)
得到一个编号)。这样一来,一个“给 (row, col) 填入 letter”的动作,就对应于以下四条约束要被同时满足:
DLX 通过“精确覆盖”将上述四类约束合并到一个统一的 0/1 矩阵中,每个“可行解(行)”对应“(row, col) 放置 letter”这个动作,每列对应其中一种约束。算法会选出若干行(即一系列合法动作)恰好覆盖所有列(即所有约束),从而得到数独完整解。
代码中大量使用了 l[]
, r[]
, u[]
, d[]
等数组,来实现 双向十字链表。这正是 Knuth 在文献里称之为 “Dancing Links” 的要点:** 通过相互双向链接使插入、删除、恢复操作在 O(1) 复杂度内完成**。
l
, r
, u
, d
l[i], r[i]
: 分别指向节点 i
在同一行的左、右邻居。u[i], d[i]
: 分别指向节点 i
在同一列的上、下邻居。无论是列的头部节点,还是数据节点,都在此四向指针的链接网络之中。
s[c]
: 表示列 c
下方有多少个节点(即这一列还剩多少个可行解)。col[i]
: 表示节点 i
属于哪一列。row[i]
: 表示节点 i
属于哪一行(注意,这里的“行”通常指“可行解”的编号,而非数独棋盘中的行概念,后者需要在外部进行对应)。在数独中,每个列头对应一个约束(例如 (row, col)
约束、或 (row, letter)
约束等),而每个“可行解行”(也就是 (row, col, letter)
这一动作)会在其对应的列上插入节点为 1,其余为 0,这就形成了一个 0/1 矩阵。
ans[]
和回溯栈ans[]
: 记录我们选取的那些行(每条行对应“(i, j) 填某个字母”),用来在最终找到解后,回溯出具体的填字母策略。top
: 指向当前的搜索深度。这部分往往是数独和 DLX 结合中最容易迷惑的部分:如何把四种约束分别映射为矩阵的列?如何构造行?
从代码可以看出,作者将列数设为 m = 16 * 16 * 4
(因为有 16×16 个单元格约束、16×16 个行-字母约束、16×16 个列-字母约束、16×16 个宫格-字母约束;合计 1024 个约束列)。不过代码中为了安全,会将 m
设成 16*16*4
,再在内部对列做拆分管理。大体策略是:
1 ~ 16*16
(共 256 列)(row, col)
从 0~255(即 16×16)。256+1 ~ 256+16*16
(也就是 257~512)(row, letter)
有 16×16 个可能。512+1 ~ 512+16*16
(513~768)(col, letter)
。768+1 ~ 768+16*16
(769~1024)(box, letter)
。这里的 box 编号 = (row/4)*4 + (col/4)
,也有 16 个 box,每个 box 16 种 letter。在数独中,每个格子 (i, j)
可以填入字母 k
(k ∈ [A, P] → [0, 15])。对于每个 (i, j, k)
,要在四个不同列对应处放置“1”,分别是:
(i * 16 + j)
这列。256 + i * 16 + k
这列。512 + j * 16 + k
这列。768 + boxID * 16 + k
这列,其中 boxID = (i/4) * 4 + (j/4)
。代码中用的是:
cpp256 = 16 * 16 256 * 2 = 512 256 * 3 = 768
所以对行号的构造就相当于:“(i, j, k)” 这一行,涉及到 4 个列:
i*16 + j + 1
256 + i*16 + k + 1
512 + j*16 + k + 1
768 + box * 16 + k + 1
注意 +1 的原因是因为列头从 1 开始编号(0 通常是头节点),这属于实现细节。如果某个位置 (i, j)
已知是字母 x
,意味着它只能取那个 letter,不必再考虑其它 15 种 letter**。代码中做法:
a = 0, b = 15; if (g[i][j] != '-') a = b = g[i][j] - 'A';
这样可以缩小搜索空间,因为已知格子不需再尝试其它候选。
init()
:初始化双向十字链表这部分初始化主要是把列头节点(0~m)的 l[i]
, r[i], u[i], d[i]
建立起来。例如:
cppfor (int i = 0; i <= m; i ++) { l[i] = i - 1; // 左邻 r[i] = i + 1; // 右邻 d[i] = i; // 同列上下均指向自己 u[i] = i; s[i] = 0; // 该列还没有节点 } l[0] = m; r[m] = 0; // 首尾相连
其中 m = 16 * 16 * 4 = 1024
。
idx = m + 1;
代表当前可插入节点的索引从 1025 开始(因为 0~1024 用于列头 + 0 号头结点)。
add(hh, tt, x, y)
该函数目的:给行 x、列 y 添加一个新节点,并将其插入到 Dancing Links 的十字链表中。
cppvoid add(int& hh, int& tt, int x, int y) { row[idx] = x, col[idx] = y, s[y] ++ ; // 在竖向上插入该节点 u[idx] = y, d[idx] = d[y], u[d[y]] = idx, d[y] = idx; // 在水平向上把节点插入到 (hh, tt) 的中间 r[hh] = l[tt] = idx, r[idx] = tt, l[idx] = hh; tt = idx ++ ; }
关键点在:
row[idx] = x, col[idx] = y
: 该节点属于哪一行、哪一列。s[y]++
: 列头 y
的计数 +1。u[idx] = y; d[idx] = d[y];
u[d[y]] = idx; d[y] = idx;
这样把新的数据节点连到列头 y
下方一条垂直链表里。hh, tt
维护本行的开头与结尾,往中间塞节点 idx
。此过程完成后,精确覆盖矩阵就形成了若干“行”,每行在 4 个约束列上插了 4 个节点。
remove
/ resume
:DLX 核心步骤概念:当我们决定选择一行(可行解),就必须把这一行覆盖到的所有列“拿走”,并把这些列中所有与它冲突的行一起删除,防止后续重复选取。
实现方法:
r[l[p]] = r[p]; l[r[p]] = l[p];
p
下的各个行节点为中心,对它们在水平方向的节点所涉及的列都做同样操作,更新 u[], d[]
,并令该列计数 s[col[j]]--
。这就是 DLX 之所以高效的原因:对列和行的删除与恢复都是 O(1) 的指针操作,不需要大规模修改矩阵或开新空间。
dfs()
核心伪码:
cppbool dfs() { // 若所有列都被删除了,说明所有约束都被满足,找到了一组可行解 if (!r[0]) return true; // 1. 选择最优列:找剩余节点数量最少的列 head int p = r[0]; for (int i = r[0]; i; i = r[i]) if (s[i] < s[p]) p = i; // 2. 删除该列 remove(p); // 3. 枚举该列下所有节点 i for (int i = d[p]; i != p; i = d[i]) { ans[++top] = row[i]; // 选取行 i(可行解 i) // 3.1. 删除行 i 水平方向覆盖到的列 for (int j = r[i]; j != i; j = r[j]) remove(col[j]); // 3.2. 递归 dfs if (dfs()) return true; // 3.3. 恢复操作 for (int j = l[i]; j != i; j = l[j]) resume(col[j]); top--; } // 4. 恢复该列 resume(p); return false; }
Knuth 提出的启发式:每次选择剩余可行解最少的列来优先展开,这样会极大减少搜索分支,提高求解速度。
选中 row[i]
表示“(i, j, letter)` 这一步是真的选了,就必须满足对应所有列的唯一性;所以要“删除(暂时)所有可能与之冲突的节点”。
典型的 DFS + 回溯结构,若走到 !r[0]
(头节点 0 的右指针指回自己),说明所有列都被精确覆盖,解就生成了。
当找到解后,ans[1..top]
存储了若干行号,每个行号对应 (x, y, z)
即“(row, col, letter)”。代码中:
cppfor (int i = 1; i <= top; i++) { auto t = op[ans[i]]; g[t.x][t.y] = t.z; }
这样就把棋盘对应格填入相应字母。最后打印棋盘完成答案。
DLX 本质是“精确覆盖”:
每个合法解行(可行动作)与 4 个约束列相对应,从而在 0/1 矩阵中变为“一行对 4 列为 1”。数独完美映射精确覆盖问题。
核心在“删除-恢复”的指针操作”:
Dancing Links 把矩阵转化为稠密的十字链表后,当我们“选择”一行时,只需切断双向指针,即可将冲突行和列快速排除。回溯时同样快速恢复。
传统回溯或许也能做,但需要大量标记与解除标记;而 DLX 的“指针断开再恢复”保证在大规模搜索中更加灵活、简洁、高效。
选择最优列是关键加速技巧:
如果每次盲目地随机选一列,会导致分支爆炸。Knuth 的启发式帮忙大量剪枝。
处理已知数据:
对于题目给定的格子,直接锁死其候选范围,减少了不必要的行构造。这种“一旦已知就只能取唯一行”的手段,大幅减少了后续搜索树的分支因子。
16×16 与 9×9 的唯一区别:
只是约束和棋盘更大,但 DLX 结构不变,都是 Cell、Row-Value、Col-Value、Box-Value 四类。对 16×16 来说,Box 尺寸是 4×4;对 9×9 来说则是 3×3。实现细节完全类似,只需改对应常数即可。
常见的数独回溯:
一般是维护行、列、宫格三个布尔数组,配合递归。对于 16×16,代码量较多且要写好多标记更新、回退。但也能做。
Constraint Programming:
也有类似基本思想,但实现手段更通用;DLX 更加针对“精确覆盖”的行列结构,操作效率极高。
为什么不直接用大矩阵 + 递归?
在 16×16 尤其是更大规模时,如果用二维数组来“标记可行性”,每次删除/恢复都需要遍历整个矩阵更新状态,会相对低效。DLX 用链表指针大幅节省此类更新操作。
这份代码是典型的 DLX + 数独 实现模板,只不过把大小由传统 9×9 扩展到 16×16。它借助四组约束(Cell、Row-Letter、Col-Letter、Box-Letter)来构建 0/1 矩阵,然后通过 Algorithm X 的精确覆盖搜索来找出唯一解。
最终找到解后,把选中的行(即 (i, j, letter)
)回填到数独网格,即可完整解决。
这就是从内到外的核心本质:Dancing Links 专门为这类“选择一批行,使得所有列被恰好覆盖一次”的问题而生,让数独在大搜索空间下仍能高效求解。
以上内容,力图呈现最核心、最精华的解析:从数据结构到数独约束,再到删除-恢复的高效操作、以及最终怎么把解映射回棋盘。
在阅读或修改这份代码时,只要深刻理解每个指针的含义、每段删除-恢复逻辑为什么要更新 u[], d[], l[], r[]
,就能灵活地运用 DLX。希望这能帮助你充分理解、乃至在其他精确覆盖场景中举一反三。