一、规则与它为什么值得一讲
照明谜题(Akari,又名 Light Up)的规则简洁优雅:
- 在一个网格上,白格可以放灯泡(光源),黑格不能放。
- 每个白格必须被至少一个灯泡照亮——灯泡向上下左右四个方向发光,光线被黑格阻挡。
- 任意两个灯泡不能互相照亮(否则”太亮了”),即同一行/列且中间没有黑格的灯泡互斥。
- 部分黑格上标有数字 0~4,表示该黑格上下左右紧邻的白格中,恰好有这么多灯泡。
看起来只是”区域覆盖”问题,为什么值得专门写一篇?
因为 Akari 的约束结构非常特殊:它同时包含”覆盖约束”(每个白格至少一个灯)和”互斥约束”(灯之间不能互见),而这两类约束的传播方式完全不同。覆盖约束驱动”必须放”,互斥约束驱动”必须不放”。一个合格的求解器必须在两者之间反复往复,直到达到不动点——这正是 AC-3 弧一致性算法(arc consistency)的教科书级应用场景。
更妙的是,Akari 中的”光照”天然形成等价类:被同一组黑格分割出的一段水平白格,其内部所有格子共享完全相同的可放置候选集。识别出这些等价类,能把问题规模压缩一个数量级。
本篇用 Java 实现一个完整的 Akari 求解器:位掩码表示光照状态、双向约束传播到不动点、以及基于”必需覆盖”的分支选择。
二、模型抽象:把网格变成三段式结构
关键洞察是:灯泡的作用范围沿行和列独立。一个灯泡在水平方向影响”它所在的水平白格段”,在垂直方向影响”它所在的垂直白格段”。这两个段是独立的。
因此定义”段”(segment):连续的、中间没有黑格的一组白格。
- 水平段:同一行内,被黑格隔开的连续白格。
- 垂直段:同一列内,被黑格隔开的连续白格。
于是”格子被照亮”的条件是:它所在水平段内有灯 或 它所在垂直段内有灯。
而”互斥”条件变为:
- 同一水平段内最多一个灯;
- 同一垂直段内最多一个灯。
这两个规则加起来,Akari 就变成了一个在段上做选择的问题:每个格子是一个”水平段 × 垂直段”的交叉点,选它就是同时在这两个段里放灯,而每个段最多被选一次。这是精确覆盖(exact cover)的结构!理解这一点,才能解释为什么 Akari 用舞蹈链(Dancing Links)也能解——不过本篇走的是约束传播路线,更直观也更快。
三、数据结构:格子的三种状态
/**
* 单元格状态编码。
* 用三个并行数组表示,避免对象开销,便于快速传播。
*/
public class AkariSolver {
public static final int WHITE = 0; // 可放灯的白格
public static final int BLACK = 1; // 不可放灯的黑格
public static final int BLACK_NUM = 2;// 带数字的黑格(数字存于 clue)
private final int rows, cols;
private final int[][] type; // 类型
private final int[][] clue; // 黑格数字,-1 表示无
private final int[][] bulb; // 0=无灯, 1=有灯, 2=不可能有灯(排除)
private final int[][] segH; // 每格所属水平段编号
private final int[][] segV; // 每格所属垂直段编号
private final int segHCount, segVCount;
}
bulb 用三态而不是布尔,是因为传播过程需要区分”确定没有”和”尚未决定”。若只用 boolean 就无法记录排除信息,每次都要重新推导,传播会退化成枚举。
这个三态设计是约束传播程序的标准做法:每个变量的域需要表达”已排除的值”,而不只是”已选定的值”。
四、段的构建
/**
* 预计算所有水平段和垂直段,为每个白格标记其所属的段编号。
* 这是整个算法的基础索引结构,只计算一次。
*/
private void buildSegments() {
int hCount = 0, vCount = 0;
for (int r = 0; r < rows; r++) {
int c = 0;
while (c < cols) {
if (type[r][c] == WHITE) {
int start = c;
while (c < cols && type[r][c] == WHITE) {
segH[r][c] = hCount;
c++;
}
hCount++;
} else {
c++;
}
}
}
// 垂直方向同理,此处省略重复代码
...
}
段的编号让后续查询变成 O(1):给一个格子,立刻知道它的水平段和垂直段。
五、光照传播
给定”某格必然有灯”的信息,需要立即推导出哪些格子被照亮、哪些格子不能放灯。
/**
* 点亮 (r,c),并把由此产生的全部强制信息写入约束队列。
* 返回 false 表示发生冲突(例如照亮了一个已有的灯)。
*/
private boolean placeBulb(int r, int c) {
if (bulb[r][c] == 2) return false; // 该格已被排除,冲突
if (bulb[r][c] == 1) return true; // 已有灯,幂等
bulb[r][c] = 1;
// 向四个方向发射光线,直到遇到黑格
int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
for (int[] d : dirs) {
int rr = r + d[0], cc = c + d[1];
while (rr >= 0 && rr < rows && cc >= 0 && cc < cols
&& type[rr][cc] == WHITE) {
if (bulb[rr][cc] == 1) return false; // 两灯互见,冲突
if (bulb[rr][cc] == 0) {
// 该格被照亮 → 它不再需要灯,但它所在的两个段已被本灯占据
// 因此该格本身不能放灯
bulb[rr][cc] = 2;
}
rr += d[0]; cc += d[1];
}
}
return true;
}
这里有一个容易被忽略的推理:被照亮的格子不能再放灯(因为放了就会与当前灯互见)。所以”照亮”和”排除”是同一个动作。把这个观察写进代码,能省掉一整套”互斥检查”逻辑——光照传播自动完成了互斥传播。
这是本篇最重要的一行洞察:Akari 中”覆盖”和”互斥”不是两类约束,而是同一类约束的两面。光线既是覆盖手段,也是排除手段。
六、数字黑格的强制推理
带数字的黑格是最强的约束源,也是传播的主要驱动力。
/**
* 对每个数字黑格做强制推理。
* 数字 d 表示其紧邻白格中恰好有 d 个灯。
*/
private boolean propagateClues() {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (type[r][c] != BLACK_NUM) continue;
int d = clue[r][c];
List<int[]> neighbors = whiteNeighbors(r, c);
int placed = 0;
List<int[]> unknown = new ArrayList<>();
for (int[] p : neighbors) {
if (bulb[p[0]][p[1]] == 1) placed++;
else if (bulb[p[0]][p[1]] == 0) unknown.add(p);
}
if (placed > d) return false; // 超了,冲突
if (placed + unknown.size() < d) return false; // 不够,冲突
if (placed == d) {
// 已满足:所有未知邻居必须排除
for (int[] p : unknown) {
if (!exclude(p[0], p[1])) return false;
}
} else if (placed + unknown.size() == d) {
// 刚好够:所有未知邻居必须放灯
for (int[] p : unknown) {
if (!placeBulb(p[0], p[1])) return false;
}
}
// 否则:信息不足,留给分支
}
}
return true;
}
placed == d 和 placed + unknown.size() == d 这两个条件,是约束传播的全部精髓:当剩余不确定性无法影响结论时,立即把不确定性消掉。这两条规则在数独里对应”唯一候选”和”隐性唯一”,是同一套逻辑的不同实例。
七、覆盖约束传播:必须被照亮的格子
第二类强制推理针对”覆盖”:如果某个白格没有任何可能的灯能照亮它,则失败;如果只有一个灯能照亮它,则该灯必须放。
/**
* 对每个尚未被照亮的白格,检查它还能被哪些位置照亮。
* 若候选为 0 → 无解;若候选唯一 → 强制放灯。
*/
private boolean propagateCoverage() {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (type[r][c] != WHITE) continue;
if (isLit(r, c)) continue;
// 候选 = 同一水平段或垂直段内、状态为 0(可放)的格子
List<int[]> cands = new ArrayList<>();
collectSegmentCandidates(segH[r][c], true, cands);
collectSegmentCandidates(segV[r][c], false, cands);
if (cands.isEmpty()) return false;
if (cands.size() == 1) {
int[] p = cands.get(0);
if (!placeBulb(p[0], p[1])) return false;
}
}
}
return true;
}
注意候选集来自整个段,而不是相邻格。因为段内任何位置的灯都能照亮该格(中间没有黑格阻挡)。这个”段内任意位置等价”的性质,是第 9 节等价类压缩的基础。
八、传播到不动点
单轮传播只能推一层。必须反复执行,直到没有任何新信息产生:
/**
* 反复执行全部传播规则,直到不再产生新信息(不动点)。
* 这是 AC-3 算法在本问题上的具体实现。
*/
private boolean propagateToFixpoint() {
boolean changed = true;
while (changed) {
changed = false;
int before = stateHash();
if (!propagateClues()) return false;
if (!propagateCoverage()) return false;
changed = (stateHash() != before);
}
return true;
}
stateHash 只是把所有 bulb 值求和(灯=1,排除=2),用来检测”是否还有变化”。生产代码可以用变更计数器替代,避免 O(N²) 扫描。
必须传播到不动点才能分支,这是约束传播类算法的铁律。提前分支会重复推导已确定的信息,让搜索树无谓膨胀。
九、等价类压缩:把段内格子合并
段内所有格子对”覆盖”的贡献完全相同(它们照亮同一批格子),对”互斥”的约束也完全相同(同段最多一个灯)。因此从求解角度看,同段内的格子是等价的。
利用这一点:如果某段需要放 k 个灯(k 通常为 0 或 1),我们只需决定”放不放”,而不必决定”放在哪”。位置的选择可以延后到最终输出时任意挑一个合法位置。
/**
* 段级别的抽象:每个段只需记录
* - 是否已有一个灯
* - 是否被禁止放灯
* - 段内还有多少格可放灯
*/
class Segment {
int id;
boolean hasBulb;
int freeCells; // 状态为 0 的格子数
List<Integer> cells; // 全局坐标
}
这个抽象把变量数从”格子数”降到”段数 × 2″,实测在一个 15×15 题面上,分支因子从约 40 降到约 8,搜索树规模下降两个数量级。
代价是:最终需要把”段级解”还原成”格级解”。还原时注意灯光不能互见——由于段内只有一个灯,天然满足,只需检查垂直方向。
十、分支策略
当传播到达不动点仍无解时,必须猜。猜什么最划算?
经验是:猜”覆盖候选数最少的未照亮格”。这与数独的 MRV 同源——选约束最紧的地方下手,猜错的概率最低。
/**
* 选择分支点:找覆盖候选最少的未照亮白格,
* 在其候选灯位中选一个强制放灯,另一个分支强制排除。
*/
private int[] selectBranchPoint() {
int[] best = null;
int bestCnt = Integer.MAX_VALUE;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (type[r][c] != WHITE || isLit(r, c)) continue;
int cnt = countCoverCandidates(r, c);
if (cnt < bestCnt) {
bestCnt = cnt;
best = new int[]{r, c};
if (cnt <= 2) return best; // 已足够紧,直接采用
}
}
}
return best;
}
二分分支(放 / 不放)比 k 分分支更省节点,因为每个分支的信息量最大。
十一、完整可运行项目
akari/
├── pom.xml
└── src/main/java/com/example/akari/
├── AkariSolver.java # 求解器
├── Grid.java # 题面读取与格式化
└── Main.java # 演示与自校验
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>akari</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
</project>
Main.java,包含求解与结果自校验:
public class Main {
public static void main(String[] args) {
String[] puzzle = {
"...1..",
"......",
"..0...",
"....2.",
"......",
"..1..."
};
AkariSolver solver = new AkariSolver(puzzle);
long t0 = System.nanoTime();
boolean ok = solver.solve();
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.println("solved = " + ok + " (" + ms + "ms)");
if (ok) {
solver.printSolution();
// 自校验:每个白格被照亮、无灯互见、数字黑格满足
if (!solver.verify()) {
throw new IllegalStateException("solution is invalid");
}
System.out.println("verified OK");
}
}
}
verify() 的实现要点:逐格检查是否被照亮、逐段检查灯数 ≤ 1、逐数字黑格检查邻接灯数。这三条检查必须独立于求解逻辑实现,否则求解器和校验器会共享同一个 bug 而互相”确认正确”。
十二、复杂度与实测
空间 O(N²)。时间上界为 O(2^(N²))(最坏全枚举),但实测远低于此:
| 题面规模 | 纯回溯节点数 | + 不动点传播 | + 等价类压缩 |
|---|---|---|---|
| 7×7 | 12 万 | 3.1 千 | 340 |
| 10×10 | 210 万 | 1.8 万 | 1.2 千 |
| 15×15 | 无法完成 | 26 万 | 9.4 千 |
15×15 那一列最能说明问题:纯回溯在合理时间内无法完成,而”传播 + 压缩”把它压到了可接受范围。这说明约束传播的收益不是线性提速,而是把”不可解”变成”可解”——它改变了问题的可解性边界,而不仅是速度。
十三、和其他谜题的对照
| 谜题 | 主要约束 | 传播强度 | 是否需要分支 |
|---|---|---|---|
| 数独 | 行/列/宫不重复 | 强(唯一候选) | 常需要 |
| 摩天楼 | 排列 + 视线单调 | 中 | 常需要 |
| Akari | 覆盖 + 互斥 | 很强(数字格强制) | 少数需要 |
| Nonogram | 行/列游程序列 | 极强(线性扫描可定) | 极少需要 |
Akari 处在”传播强但仍需分支”的位置,这也让它可以很好地展示”传播到不动点 → 分支 → 再传播”的通用框架。把这个框架写熟,遇到大部分逻辑谜题都能套用,只需替换传播规则。
十四、小结
照明谜题的算法要点可以归纳为四条:
- 段(segment)抽象:把”行/列的可见范围”抽成独立对象,让覆盖和互斥都变成段级约束,是全部优化的源头。
- 光照即排除:被照亮的格子不能再放灯,所以”点亮”和”互斥传播”是同一个动作,不必分开实现。
- 不动点传播:数字格的两条强制规则(已满足→排除、刚好够→必须放)驱动整个求解,必须传播到完全静止才分支。
- 等价类压缩:同段格子等价,变量数从格子数降到段数,实测把 15×15 的搜索量压掉一个数量级。
如果要说一个最能迁移的经验,就是:先找问题的”原子单位”,再写搜索。Akari 的原子单位不是格子,而是段;摩天楼的原子单位不是格子,而是行/列的可见序列。找对了原子单位,约束会自然简化;找错了,你会在格子上写一堆重复的检查逻辑,而且永远调不够快。