引言
Shikaku(数邻,又称 Rectangles)是一种由日本 Nikoli 公司发明的逻辑谜题。棋盘为 n×m 的网格,部分格子上标有数字,要求将棋盘划分为若干个矩形区域,每个矩形区域恰好包含一个数字,且该数字等于矩形的面积(格子数)。本文将用 Java 实现一个 Shikaku 求解器,核心采用BFS 连通性检测与贪心区域划分策略。
核心算法思路
1. 问题建模
Shikaku 的约束包括:
– 每个标有数字的格子必须是某个矩形的”锚点”
– 包含数字 k 的矩形面积必须恰好为 k
– 所有矩形互不重叠且恰好覆盖整个棋盘
2. 矩形枚举
对于每个数字格子 (r, c, k),枚举所有以该格子为内部一点的、面积为 k 的矩形。矩形由左上角 (r1, c1) 和右下角 (r2, c2) 确定,需满足:
– r1 <= r <= r2, c1 <= c <= c2
– (r2 - r1 + 1) * (c2 - c1 + 1) == k
3. 回溯搜索 + 剪枝
为每个数字格子选择一个合法矩形,检查是否与已选矩形重叠。若全部数字格子都被覆盖后棋盘仍有空格,则尝试扩展相邻矩形或回溯。
4. BFS 连通性检测(剪枝)
在部分矩形已划分的情况下,检查剩余未划分区域是否被分割成多个”孤岛”。若某个孤岛上没有未分配的数字格子,则当前状态无解,可提前剪枝。
Java 完整实现
import java.util.*;
/**
* Shikaku(数邻/矩形划分)求解器
* 使用矩形枚举 + 回溯搜索 + BFS连通性剪枝
*/
public class ShikakuSolver {
private int rows, cols;
private int[][] grid; // 0表示无数字,>0表示该格子的目标面积
private int[][] region; // 每个格子属于哪个区域编号,-1表示未分配
private List<int[]> numberCells; // 每个元素为 {r, c, value}
private List<List<int[]>> allRectangles; // 每个数字格子对应的所有可能矩形
public ShikakuSolver(int rows, int cols) {
this.rows = rows;
this.cols = cols;
this.grid = new int[rows][cols];
this.region = new int[rows][cols];
this.numberCells = new ArrayList<>();
this.allRectangles = new ArrayList<>();
for (int i = 0; i < rows; i++) {
Arrays.fill(region[i], -1);
}
}
public void setNumber(int r, int c, int value) {
grid[r][c] = value;
numberCells.add(new int[]{r, c, value});
}
/**
* 预计算每个数字格子的所有合法矩形
* 矩形表示为 int[]{r1, c1, r2, c2}
*/
private void precomputeRectangles() {
for (int[] cell : numberCells) {
int r = cell[0], c = cell[1], k = cell[2];
List<int[]> rects = new ArrayList<>();
// 枚举所有面积为k的矩形,且包含(r,c)
for (int h = 1; h <= rows; h++) {
if (k % h != 0) continue;
int w = k / h;
if (w > cols) continue;
// 枚举左上角位置,使得(r,c)在矩形内
for (int r1 = Math.max(0, r - h + 1); r1 <= r && r1 + h - 1 < rows; r1++) {
for (int c1 = Math.max(0, c - w + 1); c1 <= c && c1 + w - 1 < cols; c1++) {
int r2 = r1 + h - 1;
int c2 = c1 + w - 1;
// 检查矩形内是否有其他数字格子的数字不匹配
boolean valid = true;
for (int[] other : numberCells) {
if (other[0] == r && other[1] == c) continue;
if (other[0] >= r1 && other[0] <= r2 && other[1] >= c1 && other[1] <= c2) {
// 另一个数字格子也在这个矩形内,但该矩形只属于当前数字
// 这意味着另一个数字格子不能在矩形边界内
valid = false;
break;
}
}
if (valid) {
rects.add(new int[]{r1, c1, r2, c2});
}
}
}
}
allRectangles.add(rects);
}
}
/**
* BFS检测未划分区域中,每个连通块是否至少包含一个未分配的数字格子
*/
private boolean isConnectedValid() {
boolean[][] visited = new boolean[rows][cols];
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (region[r][c] == -1 && !visited[r][c]) {
// BFS这个连通块
List<int[]> component = new ArrayList<>();
Queue<int[]> q = new LinkedList<>();
q.add(new int[]{r, c});
visited[r][c] = true;
while (!q.isEmpty()) {
int[] cur = q.poll();
component.add(cur);
int cr = cur[0], cc = cur[1];
int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
for (int[] d : dirs) {
int nr = cr + d[0], nc = cc + d[1];
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols
&& region[nr][nc] == -1 && !visited[nr][nc]) {
visited[nr][nc] = true;
q.add(new int[]{nr, nc});
}
}
}
// 检查该连通块是否包含未分配的数字格子
boolean hasNumber = false;
for (int[] p : component) {
if (grid[p[0]][p[1]] > 0) {
hasNumber = true;
break;
}
}
if (!hasNumber) return false; // 孤岛上没有数字,无解
}
}
}
return true;
}
/**
* 检查当前所有已分配区域是否有重叠
*/
private boolean hasOverlap(int rectIdx, int[] rect) {
for (int r = rect[0]; r <= rect[2]; r++) {
for (int c = rect[1]; c <= rect[3]; c++) {
if (region[r][c] != -1 && region[r][c] != rectIdx) {
return true;
}
}
}
return false;
}
/**
* 分配矩形区域
*/
private void assignRect(int rectIdx, int[] rect) {
for (int r = rect[0]; r <= rect[2]; r++) {
for (int c = rect[1]; c <= rect[3]; c++) {
region[r][c] = rectIdx;
}
}
}
private void unassignRect(int[] rect) {
for (int r = rect[0]; r <= rect[2]; r++) {
for (int c = rect[1]; c <= rect[3]; c++) {
region[r][c] = -1;
}
}
}
private boolean isComplete() {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (region[r][c] == -1) return false;
}
}
return true;
}
/**
* 回溯搜索
* @param idx 当前处理第idx个数字格子
*/
private boolean solve(int idx) {
if (idx == numberCells.size()) {
return isComplete() && isConnectedValid();
}
// 按矩形选择最少的数字格子优先(MRV)
int minOptions = Integer.MAX_VALUE;
int bestIdx = -1;
for (int i = idx; i < numberCells.size(); i++) {
int options = 0;
for (int[] rect : allRectangles.get(i)) {
if (!hasOverlap(i, rect)) options++;
}
if (options < minOptions) {
minOptions = options;
bestIdx = i;
if (options == 0) return false;
}
}
// 交换到当前位置
if (bestIdx != idx) {
Collections.swap(numberCells, idx, bestIdx);
Collections.swap(allRectangles, idx, bestIdx);
// 同时交换region中的标记...简化处理:重新编号太复杂,我们不用idx直接作为region编号
// 改为使用numberCells的原始顺序索引作为region编号
}
int originalIdx = -1;
for (int i = 0; i < numberCells.size(); i++) {
if (numberCells.get(i) == numberCells.get(idx)) {
// 找到原始索引...这个方式有问题
}
}
// 简化:直接使用 idx 作为临时区域编号,最后重新映射
// 但由于上面交换导致原始顺序改变,我们改为不交换,只记录bestIdx并优先遍历
// 重写:不物理交换,只改变遍历顺序
// ... 为简化代码,重新实现
return solveMRV(idx);
}
private boolean solveMRV(int assignedCount) {
if (assignedCount == numberCells.size()) {
return isComplete() && isConnectedValid();
}
// 找到未分配且合法矩形最少的数字格子
int bestIdx = -1;
int minOptions = Integer.MAX_VALUE;
List<int[]> bestRects = null;
for (int i = 0; i < numberCells.size(); i++) {
int[] cell = numberCells.get(i);
if (region[cell[0]][cell[1]] != -1) continue; // 已分配
List<int[]> validRects = new ArrayList<>();
for (int[] rect : allRectangles.get(i)) {
if (!hasOverlap(i, rect)) validRects.add(rect);
}
if (validRects.size() < minOptions) {
minOptions = validRects.size();
bestIdx = i;
bestRects = validRects;
if (minOptions == 0) return false;
}
}
if (bestIdx == -1) {
return isComplete() && isConnectedValid();
}
for (int[] rect : bestRects) {
assignRect(bestIdx, rect);
if (isConnectedValid() && solveMRV(assignedCount + 1)) {
return true;
}
unassignRect(rect);
}
return false;
}
public boolean solvePuzzle() {
precomputeRectangles();
return solveMRV(0);
}
public void printGrid() {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] > 0) {
System.out.printf("[%2d]", grid[r][c]);
} else {
System.out.printf(" %2d ", region[r][c]);
}
}
System.out.println();
}
}
public void printRegions() {
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
System.out.printf("%2d ", region[r][c]);
}
System.out.println();
}
}
public static void main(String[] args) {
// 6x6 Shikaku 示例
ShikakuSolver solver = new ShikakuSolver(6, 6);
// 设置数字格子
solver.setNumber(0, 0, 4);
solver.setNumber(0, 3, 3);
solver.setNumber(1, 5, 4);
solver.setNumber(2, 2, 6);
solver.setNumber(3, 0, 3);
solver.setNumber(3, 4, 2);
solver.setNumber(4, 1, 2);
solver.setNumber(5, 3, 3);
solver.setNumber(5, 5, 4);
System.out.println("初始棋盘([]内为数字格子):");
solver.printGrid();
long start = System.currentTimeMillis();
if (solver.solvePuzzle()) {
System.out.println("\n区域划分结果(数字表示区域编号):");
solver.printRegions();
System.out.println("\n耗时:" + (System.currentTimeMillis() - start) + " ms");
} else {
System.out.println("无解!");
}
}
}
算法复杂度分析
- 矩形枚举:每个数字格子的合法矩形数量为 O(rows × cols),通常较少。
- 回溯搜索:最坏为 O(R^N),R 为平均矩形数,N 为数字格子数。BFS 连通性剪枝可大幅削减搜索空间。
- BFS 连通性检测:每次检查 O(rows × cols)。
- 空间复杂度:O(rows × cols) 用于存储棋盘状态。
扩展思考
- 唯一解判定:可在找到第一个解后继续搜索,检查是否存在第二个解。生成谜题时需确保唯一解。
- 贪心启发式:对于某些简单谜题,可先尝试”唯一矩形”(只有一个合法矩形的数字格子)先行确定。
- 与数织(Nonogram)对比:Shikaku 是区域划分问题,而数织是线条填充问题,两者都体现了组合数学的魅力。
小结
本文通过 Java 实现了 Shikaku 求解器,核心采用矩形枚举、MRV 启发式回溯与 BFS 连通性剪枝。代码结构清晰,展示了如何将几何划分问题转化为组合搜索问题,并运用图论算法(BFS)进行剪枝优化。