一、数织的难点不在「填格子」,在「排除」
数织(Nonogram,日本叫「お絵かきロジック」,国内也叫「逻辑绘画谜题」「像素拼图」)的规则极端简单:
- 每一行的左侧写着一串数字,表示这一行中连续黑块的长度,按从左到右的顺序排列;
- 每一列的上方同理,从上到下;
- 数字之间至少隔一个空格。填完全部行列线索,就得到一张像素图。
一个 10×10 的谜题,线索如下:
行: [2,2] [7] [10] [8] [6] [4] [2] [2] [] []
列: [1] [3] [5] [6] [7] [7] [6] [5] [2] [1]
它的答案是一颗像素爱心。看起来简单,但搜索空间大得惊人:
- 各行候选排列数相乘 =
21 × 4 × 1 × 3 × 5 × 7 × 9 × 9 × 1 × 1 = 714,420 - 各列候选排列数相乘 =
10 × 8 × 6 × 5 × 4 × 4 × 5 × 6 × 9 × 10 = 103,680,000
纯暴力「按行枚举 + 逐列校验」的搜索量在 10⁸ 量级。而实际上这个谜题完全不需要回溯——纯约束传播 3 轮扫描就能解出。这就是本文要讲的:为什么数织的正确解法是「先建模成有限自动机,再做约束传播」。
二、单行就是一个 DFA
这个观察是数织求解的核心洞察:一行的所有合法填充方式,恰好是某个确定性有限自动机(DFA)接受的语言。
DFA 的状态可以定义为 (j, run):
j:当前正在处理第几个线索(0-based,j == K表示所有线索都已完成);run:当前这个黑块已经连续填了几个格子(0 表示还没开始这一块)。
转移规则:
| 当前状态 | 输入 #(黑) |
输入 .(白) |
|---|---|---|
(j, run),run < clues[j] |
(j, run+1) |
仅当 run == 0 时 (j, 0);否则非法 |
(j, run),run == clues[j] |
非法(超长) | (j+1, 0) |
(K, 0) |
非法 | (K, 0)(全白收尾) |
接受条件是「读完整行后处于 (K, 0)」。这条规则自动保证了「数字之间至少隔一个空格」——因为闭合一个块之后必须先读一个 . 才能进入下一块。
把它写成 DP,就能在 O(n × K × maxClue) 的时间里完成两件事:
- 计数:这一行有多少种合法排列(用于判断约束强弱);
- 枚举:这一行所有合法排列的完整列表(用于传播)。
对比一下暴力枚举的效率:对于 [2,2] 这样一行,DP 只需约 10 × 2 × 2 = 40 个状态转移,而暴力枚举要试 C(10,2) × C(8,2) 次。DP 的收益随行长和线索数增长而放大。
三、约束传播:把「候选集」收缩
有了每行的候选列表和每列的候选列表,剩下的事情就是标准的 AC-3 风格约束传播:
重复直到无变化:
对每一行:
过滤候选集 —— 只保留与当前网格已知值不冲突的候选
若候选集为空 -> 矛盾,回溯
对每个格子: 若所有候选在该格上取值相同 -> 该格确定
对每一列: 同上
这里有两个关键点:
第一,过滤和定值是同一个循环。 不需要「先过滤所有,再统一赋值」——在过滤的同时就把「候选集中该格取值唯一」的格子定下来,下一轮传播会立刻用上这些新信息。这就是「原地传播」,收敛比批量更新快得多。
第二,「候选集全同」的判定必须按格独立。 一行有 21 个候选,不意味着 21 个格子都能定值;只有某个格子在所有候选里都是 #(或都是 .)时才能定值。这是传播的唯一信息来源。
对本文的爱心谜题,实测传播效果:
扫描 1 轮后:未知格子 22 个
扫描 2 轮后:未知格子 0 个 <- 完全解出,无需回溯
扫描 3 轮后:确认收敛(无变化)
两轮有效扫描就解决了一个 10×10 的谜题,耗时在毫秒级。这就是「建模正确」带来的红利:把 10⁸ 的搜索空间压缩成了 3 次线性扫描。
四、完整 Java 实现
4.1 线索与工具
import java.util.*;
/** 数织谜题:行列线索 + 网格状态 */
final class Nonogram {
final int h, w;
final int[][] rowClues, colClues;
/** 网格:0 = 未知, 1 = 黑, -1 = 白 */
final int[][] grid;
Nonogram(int[][] rowClues, int[][] colClues) {
this.h = rowClues.length;
this.w = colClues.length;
this.rowClues = rowClues;
this.colClues = colClues;
this.grid = new int[h][w];
}
/** 从一行字符还原线索,例如 "..##..##.." -> [2, 2] */
static int[] cluesOf(String line) {
List<Integer> out = new ArrayList<>();
int cnt = 0;
for (char ch : line.toCharArray()) {
if (ch == '#') cnt++;
else { if (cnt > 0) { out.add(cnt); cnt = 0; } }
}
if (cnt > 0) out.add(cnt);
return out.stream().mapToInt(Integer::intValue).toArray();
}
}
用 int 三态(0/1/-1)而不是 boolean,是为了表达「未知」——这是约束传播的必要条件。若用 boolean,就必须再配一个 boolean[] known,反而更啰嗦。
4.2 单行的 DFA + DP
/**
* 单行求解器:把线索建模为 DFA,用记忆化 DP 判定可达性,再反向重建所有合法排列。
* 状态 (i, j, run):位置 i、当前线索下标 j、当前块已填长度 run。
*/
final class LineSolver {
final int[] clues;
final int n; // 行长
final int[][] memo; // memo[i][j*(n+1)+run] = 0 未知 / 1 可行 / -1 不可行
LineSolver(int[] clues, int n) {
this.clues = clues;
this.n = n;
// run 的取值范围 0..n,j 的取值范围 0..K
this.memo = new int[n + 1][(clues.length + 1) * (n + 1)];
}
/** 从状态 (i,j,run) 出发能否合法读完剩余格子 */
boolean feasible(int i, int j, int run) {
if (i == n) {
// 接受条件:所有线索已完成,或最后一个块刚好收尾
return j == clues.length || (j == clues.length - 1 && run == clues[j]);
}
int key = j * (n + 1) + run;
if (memo[i][key] != 0) return memo[i][key] == 1;
boolean ok = false;
// 分支 1:填黑
if (j < clues.length && run < clues[j] && feasible(i + 1, j, run + 1)) ok = true;
// 分支 2:填白
if (!ok && j < clues.length) {
if (run == 0) {
// 还没开始这一块:可以继续空着
if (feasible(i + 1, j, 0)) ok = true;
} else if (run == clues[j]) {
// 这一块刚好长度够了:必须用白格收尾,并进入下一块
if (feasible(i + 1, j + 1, 0)) ok = true;
}
} else if (!ok && j == clues.length) {
// 所有块都完成了,剩下的必须全白
ok = true;
}
memo[i][key] = ok ? 1 : -1;
return ok;
}
/** 枚举所有合法排列,返回用 boolean[] 表示的列表(true = 黑) */
List<boolean[]> allCandidates() {
List<boolean[]> res = new ArrayList<>();
boolean[] cur = new boolean[n];
collect(0, 0, 0, cur, res);
return res;
}
private void collect(int i, int j, int run, boolean[] cur, List<boolean[]> res) {
if (i == n) {
if (j == clues.length || (j == clues.length - 1 && run == clues[j]))
res.add(cur.clone());
return;
}
if (!feasible(i, j, run)) return;
if (j == clues.length) { // 剩余全白,直接批量填充
for (int k = i; k < n; k++) cur[k] = false;
res.add(cur.clone());
return;
}
// 先试黑(剪枝更早生效)
if (run < clues[j] && feasible(i + 1, j, run + 1)) {
cur[i] = true;
collect(i + 1, j, run + 1, cur, res);
cur[i] = false;
}
// 再试白
if (run == 0) {
if (feasible(i + 1, j, 0)) collect(i + 1, j, 0, cur, res);
} else if (run == clues[j]) {
if (feasible(i + 1, j + 1, 0)) collect(i + 1, j + 1, 0, cur, res);
}
}
/** 候选排列总数(用于评估约束强度,不需真的枚举) */
long count() {
return countFrom(0, 0, 0);
}
private long countFrom(int i, int j, int run) {
if (i == n) return (j == clues.length || (j == clues.length - 1 && run == clues[j])) ? 1 : 0;
if (j == clues.length) return 1;
long t = 0;
if (run < clues[j]) t += countFrom(i + 1, j, run + 1);
if (run == 0) t += countFrom(i + 1, j, 0);
else if (run == clues[j]) t += countFrom(i + 1, j + 1, 0);
return t;
}
}
4.3 约束传播求解器
/** 约束传播求解器:行列候选集交替过滤,候选全同则定值 */
public class NonogramSolver {
final Nonogram p;
final List<boolean[]>[] rowOpts;
final List<boolean[]>[] colOpts;
int sweeps = 0, backtracks = 0;
@SuppressWarnings("unchecked")
public NonogramSolver(Nonogram p) {
this.p = p;
this.rowOpts = new List[p.h];
this.colOpts = new List[p.w];
for (int r = 0; r < p.h; r++)
rowOpts[r] = new LineSolver(p.rowClues[r], p.w).allCandidates();
for (int c = 0; c < p.w; c++) {
int[] colClue = p.colClues[c];
colOpts[c] = new LineSolver(colClue, p.h).allCandidates();
}
}
/**
* 一次传播扫描。
* 返回值:1 = 有变化(需继续扫描),0 = 已收敛(无变化),-1 = 遇到矛盾
*/
int propagate() {
boolean changedOverall = false;
for (int r = 0; r < p.h; r++) {
// 过滤:只保留与已知格子一致的候选
List<boolean[]> keep = new ArrayList<>();
for (boolean[] cand : rowOpts[r]) {
boolean ok = true;
for (int c = 0; c < p.w; c++) {
int g = p.grid[r][c];
if (g != 0 && (cand[c] ? 1 : -1) != g) { ok = false; break; }
}
if (ok) keep.add(cand);
}
if (keep.isEmpty()) return -1; // 该行无解 -> 矛盾
rowOpts[r] = keep;
// 定值:某格在所有候选中取值一致
for (int c = 0; c < p.w; c++) {
if (p.grid[r][c] != 0) continue;
boolean hasBlack = false, hasWhite = false;
for (boolean[] cand : keep) {
if (cand[c]) hasBlack = true; else hasWhite = true;
if (hasBlack && hasWhite) break;
}
if (hasBlack && !hasWhite) { p.grid[r][c] = 1; changedOverall = true; }
else if (!hasBlack && hasWhite) { p.grid[r][c] = -1; changedOverall = true; }
}
}
for (int c = 0; c < p.w; c++) {
List<boolean[]> keep = new ArrayList<>();
for (boolean[] cand : colOpts[c]) {
boolean ok = true;
for (int r = 0; r < p.h; r++) {
int g = p.grid[r][c];
if (g != 0 && (cand[r] ? 1 : -1) != g) { ok = false; break; }
}
if (ok) keep.add(cand);
}
if (keep.isEmpty()) return -1;
colOpts[c] = keep;
for (int r = 0; r < p.h; r++) {
if (p.grid[r][c] != 0) continue;
boolean hasBlack = false, hasWhite = false;
for (boolean[] cand : keep) {
if (cand[r]) hasBlack = true; else hasWhite = true;
if (hasBlack && hasWhite) break;
}
if (hasBlack && !hasWhite) { p.grid[r][c] = 1; changedOverall = true; }
else if (!hasBlack && hasWhite) { p.grid[r][c] = -1; changedOverall = true; }
}
}
return changedOverall ? 1 : 0;
}
/** 主求解:传播到不动点;若仍有未知格子,则分支回溯 */
boolean solve() {
while (true) {
sweeps++;
int r = propagate();
if (r < 0) return false; // 矛盾,此分支不可行
if (r == 0) break; // 已收敛
}
// 找一个未知格子,取候选数最少的(MRV 启发式)
int br = -1, bc = -1, bestScore = Integer.MAX_VALUE;
for (int r = 0; r < p.h; r++)
for (int c = 0; c < p.w; c++)
if (p.grid[r][c] == 0) {
int score = rowOpts[r].size() + colOpts[c].size();
if (score < bestScore) { bestScore = score; br = r; bc = c; }
}
if (br < 0) return true; // 无未知格子 -> 解完
// 分支:先试黑
for (int val : new int[]{1, -1}) {
int[][] snapshot = cloneGrid();
@SuppressWarnings("unchecked")
List<boolean[]>[] ro = rowOpts.clone();
@SuppressWarnings("unchecked")
List<boolean[]>[] co = colOpts.clone();
p.grid[br][bc] = val;
if (solve()) return true;
// 回溯
backtracks++;
for (int r = 0; r < p.h; r++) p.grid[r] = snapshot[r].clone();
System.arraycopy(ro, 0, rowOpts, 0, ro.length);
System.arraycopy(co, 0, colOpts, 0, co.length);
}
return false;
}
int[][] cloneGrid() {
int[][] g = new int[p.h][];
for (int r = 0; r < p.h; r++) g[r] = p.grid[r].clone();
return g;
}
void print() {
for (int r = 0; r < p.h; r++) {
StringBuilder sb = new StringBuilder();
for (int c = 0; c < p.w; c++)
sb.append(p.grid[r][c] == 1 ? '#' : (p.grid[r][c] == -1 ? '.' : '?'));
System.out.println(sb);
}
System.out.println("扫描轮数 = " + sweeps + ",回溯次数 = " + backtracks);
}
}
4.4 主程序
public class NonogramDemo {
public static void main(String[] args) {
int[][] rowClues = {{2,2},{7},{10},{8},{6},{4},{2},{2},{},{}};
int[][] colClues = {{1},{3},{5},{6},{7},{7},{6},{5},{2},{1}};
Nonogram p = new Nonogram(rowClues, colClues);
NonogramSolver solver = new NonogramSolver(p);
// 先看看每行候选数,评估问题难度
for (int r = 0; r < p.h; r++)
System.out.print(new LineSolver(p.rowClues[r], p.w).count() + " ");
System.out.println();
if (solver.solve()) {
solver.print();
} else {
System.out.println("无解");
}
}
}
五、实测结果
5.1 候选数分布
行候选数: 21 4 1 3 5 7 9 9 1 1
列候选数: 10 8 6 5 4 4 5 6 9 10
这一组数字解释了谜题的难度结构:
- 第 3 行
[10]只有 1 个候选——整行全黑,直接确定 10 个格子; - 第 9、10 行线索为空(
[]),也只有 1 个候选——整行全白; - 最难的是第 1 行
[2,2],有 21 个候选,是唯一「真正有歧义」的行。
传播算法的效率完全取决于这种「强约束」的分布:只要有几个 1-候选的行列,传播就能沿着它们迅速扩散。
5.2 传播过程
扫描 1 轮后待定格子 = 22
扫描 2 轮后待定格子 = 0
最终:扫描轮数 = 3(第 3 轮用于确认收敛),回溯次数 = 0
第 1 轮就能定掉 78 个格子(100 格中的 78%),靠的是「1-候选行」的强约束:第 3 行 [10] 全黑、第 9/10 行全白、第 4 列到第 7 列的 [6][7][7][6] 组合几乎无歧义。剩下的 22 个格子靠第 2 轮的行列交叉验证补齐。
解出来的图:
..##..##..
.#######..
##########
.########.
..######..
...####...
....##....
....##....
..........
..........
这个谜题的拓扑结构决定了它完全不需要回溯:线索的约束强到足以让纯传播收敛。这是数织的一个普遍性质——越「好看」的图案(大块黑、大块白),线索越强,传播越容易收敛;而像素风、噪点风的小图往往需要回溯。
5.3 为什么不用暴力枚举
用数字对比:
| 方法 | 计算量 | 是否需要回溯 |
|---|---|---|
| 暴力行列枚举 | 714,420 × 103,680,000 ≈ 7.4×10¹³ | 不需要,但等不到结果 |
| 逐行枚举 + 逐列校验 | 714,420 × 10(每列校验)≈ 7×10⁶ | 可能需要 |
| DFA + 约束传播 | 2 轮有效 × (10 行 × 21 候选 + 10 列 × 10 候选) ≈ 600 次比对 | 本谜题不需要 |
差距是 7~9 个数量级。这就是「正确的模型」带来的收益——不是算法更快,而是问题的结构被利用了。
5.4 复杂度
- 单行 DFA:状态数
n × (K+1) × (n+1),最坏 O(n²K),实际远小于此(run受clues[j]限制)。重建所有候选的复杂度与候选总数成正比。 - 传播:每轮 O(H × 候选数 × W + W × 候选数 × H),即 O(HW × avgCandidates)。
- 回溯:最坏指数级(数织也是 NP 完全问题),但 MRV(选候选最少的格子)+ 传播的剪枝通常把实际回溯次数压到 0~几十。
- 空间:所有候选排列的存储是主要开销。10×10 谜题的候选总量在几千条,每条 10 个 boolean,内存占用 < 100KB。
六、几个容易踩的坑
run == 0时的白格转移写错。 很多人把「白格」转移写成「总是可以走」,这会允许#.#这样的非法模式(块之间必须有空格,但run==0时连续白格是合法的)。正确逻辑是:run==0可走白(继续等待块开始),run==clues[j]可走白(闭合当前块并前进),其余不可走白。- 接受条件漏了「最后一块刚好收尾」。 如果线索
[3]、行长 3,读完 3 格后状态是(j=0, run=3),此时j != K,若只用j == K判定会漏掉这个解。正确条件是j == K || (j == K-1 && run == clues[j])。 - 传播时用「候选集大小」代替「格子值唯一」。 一行有 5 个候选,不代表能定值。必须逐格检查「是否所有候选取值一致」。
- 回溯时忘了快照候选集。 只快照
grid是不够的——rowOpts/colOpts也被过滤过。恢复时必须连候选集一起还原,否则回溯后的状态是脏的。 - 不要用
boolean表示网格。 必须能表达「未知」,否则传播无从下手。
七、可继续改进的方向
- 重叠法(Overlap)。对单行线索
[a,b,c],如果所有候选的最小公共黑区存在,可以一次性定值而不需要枚举全部候选。这是人工解数织的主力技巧,也可以作为传播的快速前置。 - 概率传播。对每个未知格统计「候选中为黑的比例」,优先选择最接近 50% 的格子分支——这比 MRV 在难谜题上更有效。
- 2-SAT / 精确覆盖建模。把数织转化为精确覆盖问题,用 DLX 求解,适合超大谜题。
- 线索拆分(clue splitting)。把长行线索拆成前后两段,用「前后缀 DP」加速单行求解,把 O(n²K) 降到 O(nK)。
八、小结
数织是「把约束变成自动机」这一思想的完美教材。一行天然就是一个 DFA,这个观察把「枚举所有可能」变成了「在状态图上做 DP」。DP 给出两样东西:候选总数(难度评估)和候选列表(传播素材)。
接下来是纯工程:把候选列表当作值域,做 AC-3 风格的过滤与定值。整个过程没有搜索,只有收缩。当收缩到不动点时,剩下的歧义才需要回溯——而对结构良好的谜题,这种歧义往往完全不存在。
这套「自动机建模 + 约束传播 + 必要时回溯」的三段式结构,可以直接迁移到数独、肯肯、数桥、帐篷谜题等所有格点逻辑谜题。真正需要记的不是代码,而是这个判断:问题的约束结构是否能被一个状态机表达? 能,就用 DP;不能,才回到搜索。