一、从肯肯说起
肯肯(KenKen,又叫 Calcudoku、贤者之数)是日本教师宫本哲也发明的一款算术逻辑谜题。它的规则只有两条:
- 在 n×n 的棋盘里填入 1~n,使每一行、每一列都不重复(即构成一个 n 阶拉丁方);
- 棋盘被划分为若干个粗线框(称为 cage,笼子),每个笼子左上角标有「目标值 + 运算符」,笼内数字必须通过该运算符组合成目标值。减法和除法笼子固定为两格,且要求「大数减小数 / 大数除以小数」得到目标值。
一个 4×4 的肯肯棋盘,其 cage 可以是这样划分的:
+-------+----+--------+
| 6× | 2- | 4× |
| (0,0) | | |
+---+---+----+----+---+
| | | | |1- |
+---+---+----+----+---+
|4÷ | 9× | | |
+---+---------+----+---+
看起来比数独简单,实际上它更难用「候选数」这种直觉手段压缩:数独的单格约束是「不等于某个数」,而肯肯的 cage 约束是跨格的算术关系,一格变化会同时影响整笼的可行性。这正是精确覆盖(Exact Cover)建模与约束传播的用武之地。
二、精确覆盖问题与 Dancing Links
精确覆盖问题的标准形式是:给定一个 0/1 矩阵,选出若干行,使每一列恰好有一个 1 被选中。
Donald Knuth 提出的 Algorithm X 用「回溯 + 最小列优先」求解它,而 Dancing Links(DLX)用双向十字链表让「删除列 / 恢复列」变成 O(1) 的指针操作,使回溯性能提升一到两个数量级。数独是这个模型的经典案例:
- 列:每格的「已填」1 组 n² + 每个「行-值」对 n² + 每个「列-值」对 n² = 3n² 列;
- 行:每个候选
(r, c, v)对应一行,它在「格 (r,c)」「行 r 取 v」「列 c 取 v」三列上各放一个 1。
选出一组行恰好覆盖 3n² 列,就等价于「每格填一个数、每行每值出现一次、每列每值出现一次」——正好是拉丁方约束。肯肯的硬约束与拉丁方完全相同,所以可以直接复用这套矩阵。
肯肯与数独的差别只有一层:额外的 cage 算术约束。精确覆盖矩阵天然只能表达「某列恰有一个 1」这种互斥关系,无法直接表达「这三格的数字之和等于 7」。所以工程上的正解是双层结构:
- 外层用 DLX 保证拉丁方合法性(硬约束);
- 内层在每次落子后对受影响的 cage 做区间可行性剪枝(软约束),把不可能完成的分支提前砍掉。
这种「精确覆盖 + 约束传播」的组合,是精确覆盖类求解器最常见的扩展手法。同样的结构可以迁移到数织(Nonogram)、帐篷谜题等一大类逻辑谜题上。
三、cage 约束如何变成剪枝条件
设笼子剩余的未填格数为 k,剩余目标值为 t,已填部分为 S。对四种运算符分别有:
| 运算符 | 可行性必要条件(剪枝) | 说明 |
|---|---|---|
+ |
sum(S) + k×minV ≤ t ≤ sum(S) + k×maxV |
minV/maxV 为剩余格子的候选值极值 |
× |
t % prod(S) == 0 且 t/prod(S) 能被 k 个 1~n 的因子分解 |
因式分解 DFS,深度仅 k |
−(两格) |
已填一格 a 时,另一格必须 ∈ {a−t, a+t} 且落在 [1,n] | 等价于配对过滤 |
÷(两格) |
已填一格 a 时,另一格必须 ∈ {a/t(需整除), a×t} | 同上 |
对于 + 和 ×,随着格子逐步填满,区间会急剧收窄,剪枝效果非常强;对于 −/÷,本质是两格配对约束,等价于在候选集里做交集过滤。
一个重要的工程细节:剪枝必须可增量恢复。DLX 在回溯时会撤销选择,cage 的已填状态也必须能随栈回滚,否则会出现状态污染。我的做法是把 cage 的「已填掩码 + 部分和 + 部分积」存成三元组,进入分支前保存旧值、回溯时还原。同样地,usedInRow/usedInCol 位图在「落子后立刻被剪枝拒绝」的分支上也必须复位——这是这类代码最容易漏掉的一行。
四、完整 Java 实现
4.1 数据结构
import java.util.*;
/** 肯肯的一个笼子:格子列表 + 目标值 + 运算符 */
final class Cage {
final int target;
final char op; // '+', '-', '*', '/'
final int[] cells; // 展平后的格子下标 r*n+c
int filledMask; // 位掩码:哪些格子已填
int sum; // 已填之和(op='+' 时使用)
int prod; // 已填之积(op='*' 时使用)
Cage(int target, char op, int[] cells) {
this.target = target;
this.op = op;
this.cells = cells;
this.prod = 1;
}
/** 保存现场,供回溯使用 */
int[] save() { return new int[]{filledMask, sum, prod}; }
/** 恢复现场 */
void restore(int[] s) { filledMask = s[0]; sum = s[1]; prod = s[2]; }
}
4.2 DLX 核心
DLX 用数组模拟十字链表。L/R/U/D 存邻居下标,col[i] 存节点所属列头,rowId[i] 存节点来自哪一行(用于回溯出解)。列下标约定:0 为根;1..n² 为「格子已填」;其后两组各 n² 为「行-值」「列-值」。
/** 数组版 Dancing Links:插入整行、cover/uncover 均为 O(1) 指针操作 */
final class DLX {
final int cols;
final int[] L, R, U, D, col, rowId, size;
int nodeCnt;
DLX(int n) {
this.cols = 3 * n * n;
int maxNodes = cols + 1 + (n * n * n) * 3 + 8; // 根 + 列头 + 每行 3 个节点
L = new int[maxNodes]; R = new int[maxNodes];
U = new int[maxNodes]; D = new int[maxNodes];
col = new int[maxNodes]; rowId = new int[maxNodes];
size = new int[cols + 1];
nodeCnt = cols + 1;
// 初始化列头横向环形链表
for (int i = 0; i <= cols; i++) {
L[i] = (i == 0) ? cols : i - 1;
R[i] = (i == cols) ? 0 : i + 1;
U[i] = D[i] = i;
col[i] = i;
}
}
/** 一次性添加一整行(候选三元组 r,c,v 的三个列号),保证水平链表正确 */
void addRow(int row, int c1, int c2, int c3) {
int[] cs = {c1, c2, c3};
int first = -1, prev = -1;
for (int c : cs) {
int p = nodeCnt++;
col[p] = c;
rowId[p] = row;
// 竖直方向插入到列尾
U[p] = U[c]; D[p] = c;
D[U[c]] = p; U[c] = p;
size[c]++;
// 水平方向拼接成环
if (first == -1) {
first = p; L[p] = p; R[p] = p;
} else {
R[prev] = p; L[p] = prev; R[p] = first; L[first] = p;
}
prev = p;
}
}
/** 从列链表中摘除列 c 及其所有行 */
void cover(int c) {
R[L[c]] = R[c]; L[R[c]] = L[c];
for (int i = D[c]; i != c; i = D[i]) {
for (int j = R[i]; j != i; j = R[j]) {
U[D[j]] = U[j]; D[U[j]] = D[j];
size[col[j]]--;
}
}
}
/** 恢复列 c(严格逆序恢复,是 DLX 正确性的关键) */
void uncover(int c) {
for (int i = U[c]; i != c; i = U[i]) {
for (int j = L[i]; j != i; j = L[j]) {
U[D[j]] = j; D[U[j]] = j;
size[col[j]]++;
}
}
R[L[c]] = c; L[R[c]] = c;
}
}
4.3 求解器:DLX + cage 剪枝
/** 肯肯求解器:Algorithm X 精确覆盖 + cage 区间可行性剪枝 */
public class KenKenSolver {
final int n;
final int[][] cellCage; // 每格所属 cage 编号
final int[][] cellIdxInCage; // 每格在 cage 内的序号(用于位掩码)
final Cage[] cages;
final DLX dlx;
final int[][] placements; // 行号 -> {r, c, v, cageId, idxInCage}
final boolean[][] usedInRow, usedInCol; // 数值占用位图
final int[] solution; // solution[r*n+c] = v,0 表示空
int nodesVisited = 0;
public KenKenSolver(int n, int[][] cellCage, int[][] cellIdxInCage, Cage[] cages) {
this.n = n; this.cellCage = cellCage; this.cellIdxInCage = cellIdxInCage;
this.cages = cages;
this.dlx = new DLX(n);
this.solution = new int[n * n];
this.usedInRow = new boolean[n][n + 1];
this.usedInCol = new boolean[n][n + 1];
int total = n * n * n;
this.placements = new int[total][5];
int base1 = 1, base2 = 1 + n * n, base3 = 1 + 2 * n * n;
int rid = 0;
for (int r = 0; r < n; r++)
for (int c = 0; c < n; c++)
for (int v = 1; v <= n; v++) {
placements[rid] = new int[]{r, c, v, cellCage[r][c], cellIdxInCage[r][c]};
// 三列:格子已填 / 行 r 已取 v / 列 c 已取 v
dlx.addRow(rid, base1 + r * n + c,
base2 + r * n + (v - 1),
base3 + c * n + (v - 1));
rid++;
}
}
/** 深度优先搜索;返回 true 表示找到一个解 */
boolean search() {
nodesVisited++;
if (dlx.R[0] == 0) return true; // 所有列已恰好覆盖一次
// 最小列优先:选节点最少的列,剪枝效果最好
int bestC = -1, bestSize = Integer.MAX_VALUE;
for (int c = dlx.R[0]; c != 0; c = dlx.R[c]) {
if (dlx.size[c] < bestSize) { bestSize = dlx.size[c]; bestC = c; }
if (bestSize == 0) return false; // 该列无候选 -> 死路
}
dlx.cover(bestC);
for (int i = dlx.D[bestC]; i != bestC; i = dlx.D[i]) {
int row = dlx.rowId[i];
int r = placements[row][0], c = placements[row][1], v = placements[row][2];
int ci = placements[row][3];
if (usedInRow[r][v] || usedInCol[c][v]) continue; // 拉丁方冲突
Cage cg = cages[ci];
int[] snap = cg.save();
solution[r * n + c] = v; // 剪枝需要读到它,必须先写入
if (!applyAndPrune(r, c, v, ci)) {
// 关键:被剪枝拒绝的分支必须完整复位,包括两张占用表
solution[r * n + c] = 0;
usedInRow[r][v] = false;
usedInCol[c][v] = false;
cg.restore(snap);
continue;
}
for (int j = dlx.R[i]; j != i; j = dlx.R[j]) dlx.cover(dlx.col[j]);
if (search()) return true;
// 回溯:严格逆序 uncover,再撤销落子
for (int j = dlx.L[i]; j != i; j = dlx.L[j]) dlx.uncover(dlx.col[j]);
solution[r * n + c] = 0;
usedInRow[r][v] = false;
usedInCol[c][v] = false;
cg.restore(snap);
}
dlx.uncover(bestC);
return false;
}
/** 落子并检查所属 cage 是否仍然可行 */
boolean applyAndPrune(int r, int c, int v, int ci) {
usedInRow[r][v] = true;
usedInCol[c][v] = true;
Cage cg = cages[ci];
cg.filledMask |= (1 << cellIdxInCage[r][c]);
if (cg.op == '+') {
cg.sum += v;
if (cg.sum > cg.target) return false; // 和已超额
} else if (cg.op == '*') {
cg.prod *= v;
if (cg.prod > cg.target || cg.target % cg.prod != 0) return false;
}
return cageFeasible(cg);
}
/** cage 可行性判定:剩余格数 + 剩余目标值 */
boolean cageFeasible(Cage cg) {
int remaining = cg.cells.length - Integer.bitCount(cg.filledMask);
if (remaining == 0) { // 全填满:精确校验
if (cg.op == '+') return cg.sum == cg.target;
if (cg.op == '*') return cg.prod == cg.target;
int a = solution[cg.cells[0]], b = solution[cg.cells[1]];
if (cg.op == '-') return Math.abs(a - b) == cg.target;
return Math.max(a, b) % Math.min(a, b) == 0
&& Math.max(a, b) / Math.min(a, b) == cg.target;
}
if (cg.op == '+') {
int[] cand = freeValues(cg);
if (cand.length == 0) return false; // 无值可填
return cg.sum + remaining * cand[0] <= cg.target
&& cg.target <= cg.sum + remaining * cand[cand.length - 1];
}
if (cg.op == '*') {
if (cg.target % cg.prod != 0) return false;
return canFactor(cg.target / cg.prod, remaining);
}
if (remaining == 1) { // '-' 或 '/':一格已定时另一格被唯一化
int first = firstFilledValue(cg);
if (first == 0) return true;
if (cg.op == '-') {
return (first - cg.target >= 1 && first - cg.target <= n)
|| (first + cg.target >= 1 && first + cg.target <= n);
}
if (first % cg.target == 0 && first / cg.target >= 1) return true;
return first * cg.target <= n;
}
return true;
}
/** 该 cage 内所有空格的可选值并集(升序) */
int[] freeValues(Cage cg) {
TreeSet<Integer> s = new TreeSet<>();
for (int idx = 0; idx < cg.cells.length; idx++) {
if ((cg.filledMask & (1 << idx)) != 0) continue;
int cell = cg.cells[idx];
int r = cell / n, c = cell % n;
for (int v = 1; v <= n; v++)
if (!usedInRow[r][v] && !usedInCol[c][v]) s.add(v);
}
return s.stream().mapToInt(Integer::intValue).toArray();
}
/** need 能否被分解为 k 个 1..n 的因子之积 */
boolean canFactor(int need, int k) {
if (k == 0) return need == 1;
if (need == 1) return true;
for (int f = 2; f <= Math.min(n, need); f++)
if (need % f == 0 && canFactor(need / f, k - 1)) return true;
return false;
}
int firstFilledValue(Cage cg) {
for (int idx = 0; idx < cg.cells.length; idx++)
if ((cg.filledMask & (1 << idx)) != 0) return solution[cg.cells[idx]];
return 0;
}
public boolean solve() { return search(); }
public void print() {
for (int r = 0; r < n; r++) {
StringBuilder sb = new StringBuilder();
for (int c = 0; c < n; c++) sb.append(solution[r * n + c]).append(' ');
System.out.println(sb.toString().trim());
}
System.out.println("搜索节点数 = " + nodesVisited);
}
}
4.4 跑一个 4×4 实例
这个实例经朴素枚举验证只有唯一解。
| cage | 格子(行,列) | 目标 | 运算符 |
|---|---|---|---|
| 0 | (2,1) (2,2) (3,1) | 9 | × |
| 1 | (3,2) (3,3) | 2 | − |
| 2 | (0,0) (1,0) | 6 | × |
| 3 | (0,2) (0,3) (1,2) | 4 | × |
| 4 | (0,1) (1,1) | 2 | − |
| 5 | (2,0) (3,0) | 4 | ÷ |
| 6 | (1,3) (2,3) | 1 | − |
public class KenKenDemo {
public static void main(String[] args) {
int n = 4;
// 每个 cage 的格子清单(行,列)
int[][][] groups = {
{{2, 1}, {2, 2}, {3, 1}}, // 0: 9×
{{3, 2}, {3, 3}}, // 1: 2-
{{0, 0}, {1, 0}}, // 2: 6×
{{0, 2}, {0, 3}, {1, 2}}, // 3: 4×
{{0, 1}, {1, 1}}, // 4: 2-
{{2, 0}, {3, 0}}, // 5: 4÷
{{1, 3}, {2, 3}} // 6: 1-
};
int[] targets = {9, 2, 6, 4, 2, 4, 1};
char[] ops = {'*', '-', '*', '*', '-', '/', '-'};
int[][] cageId = new int[n][n];
int[][] idxIn = new int[n][n];
Cage[] cages = new Cage[groups.length];
for (int i = 0; i < groups.length; i++) {
int[] flat = new int[groups[i].length];
for (int k = 0; k < groups[i].length; k++) {
int r = groups[i][k][0], c = groups[i][k][1];
cageId[r][c] = i;
idxIn[r][c] = k;
flat[k] = r * n + c;
}
cages[i] = new Cage(targets[i], ops[i], flat);
}
KenKenSolver solver = new KenKenSolver(n, cageId, idxIn, cages);
if (solver.solve()) solver.print();
else System.out.println("无解");
}
}
编译运行(JDK 17+):
$ javac KenKenDemo.java
$ java KenKenDemo
3 2 4 1
2 4 1 3
4 1 3 2
1 3 2 4
搜索节点数 = 38
38 个搜索节点意味着:DLX 的最小列优先策略几乎没走弯路——它一开始就盯住了「某行-值组合只剩一个候选」这样的强约束列,配合 cage 剪枝把大量拉丁方残局提前判死。
五、复杂度与工程结论
5.1 复杂度
- 空间:候选行 n³ 个、每行 3 个节点,共 3n³ 节点;列数 3n²。整体 O(n³)。
- 时间:精确覆盖是 NP 完全问题,最坏情况指数级。但最小列优先启发式在拉丁方类问题上通常把实际节点数压到很小——4×4 此例只需 38 个节点,9×9 数独的实测中位数在 10³ 量级。
- 剪枝开销:每落一子做一次 O(n·k) 的区间计算。它的价值在于把「已经注定失败的拉丁方」提前砍掉——尤其当 cage 很小(2~3 格)且目标值极端时(比如三格 3× 只能是 1,1,3 的一种排列),剪枝率最高。
5.2 两条容易踩的坑
- 不要试图把 cage 约束塞进 0/1 矩阵。虽然理论上可以把每个 cage 的可行组合枚举成行,但组合数随笼子大小指数增长,矩阵会爆炸;把软约束放到剪枝层才是可持续的做法。
- 剪枝状态必须可回滚。DLX 的
cover/uncover是自洽的,而 cage 状态和usedInRow/usedInCol位图不是。缺少复位这一步,回溯后会残留脏状态,导致「明明有解却报无解」的隐性 bug——而且这种 bug 不抛异常,极难定位。
5.3 可以继续做的优化
- 候选值前向检查:在
freeValues里再叠加「该值是否让本行/本列的某格无值可填」,即数独里的 naked single。 - Bitmask 化:把
usedInRow/usedInCol合成int位掩码,取候选集用位运算,实测能再快 2~3 倍。 - 对称性破除:n×n 拉丁方存在大量对称解,若只求「任意一个解」,可把首行固定为 1..n 的顺序排列,搜索空间直接除以 n!。
六、小结
肯肯是一个非常适合练手的建模题:规则简单,但逼你回答两个真实问题——哪些约束能被精确覆盖表达,哪些不能,以及不能表达的那部分如何以可回滚的方式接进回溯。把这两件事做对,你就同时掌握了 DLX 的工程用法和约束传播的基本范式。