每日算法 — 使用java实现拼图游戏:边缘匹配与回溯搜索

引言:从散落的碎片到完整的画面

想象你打开一盒1000片的拼图,碎片散落一桌。人类解拼图的本能是:先看边角(直边),再找颜色图案相近的碎片,最后逐片尝试吻合。这个过程看似简单,背后却隐藏着精妙的算法思想——边缘特征匹配约束传播回溯搜索

本文将用Java从零实现一个拼图求解引擎。我们不涉及真实的图像像素处理(那会引入复杂的计算机视觉),而是聚焦在算法核心:如何用数学方式描述拼图边缘、计算匹配度,并通过搜索策略高效还原整幅图。这个框架同样适用于自动化拼图机器人、碎片重组考古文物等现实场景。

核心概念

拼图边缘的数学表示

每个拼图块有4条边,每条边属于以下三种类型之一:

  • 凸出(Out):边向外凸起,类似”凸”
  • 凹陷(In):边向内凹陷,类似”凹”
  • 平直(Flat):边是直线,只有边框拼图块才有

我们将边编码为整数序列:每个凸出或凹陷用一个”齿形函数”描述,用整数数组表示边缘轮廓。两条边互补(一条凸对应一条凹)当且仅当它们的轮廓函数互为相反数。

匹配度函数

给定两条边 (e_1) 和 (e_2),匹配度定义为:

$$
score(e_1, e_2) = -\sum_{i=0}^{n-1} |e_1[i] + e_2[i]|
$$

理想互补情况下 (e_2 = -e_1),此时 (score = 0)。分值越接近0,匹配度越高。平直边只与平直边匹配(score=0)。

拼图状态空间

一个 (m \times n) 的拼图共有 (m \times n) 个位置。每个位置需要填入一块拼图,满足:
– 上边框位置:上边必须是平直边
– 下边框位置:下边必须是平直边
– 左边框位置:左边必须是平直边
– 右边框位置:右边必须是平直边
– 相邻两块拼图:相邻边必须互补匹配

算法设计:从约束到搜索

步骤1:边角优先(Most Constrained Variable)

人类拼图的第一步总是找边角。算法中,边框位置约束最多(至少一条边必须是平直),应当优先填充。这对应约束满足问题(CSP)中的MRV启发式(Minimum Remaining Values)。

步骤2:边缘匹配筛选

对于当前待填位置,检查其上下左右邻居的已放置拼图块,计算每条相邻边的约束。从未使用的拼图块池中筛选出满足所有硬约束(平直边要求、相邻互补要求)的候选块。

步骤3:回溯搜索与剪枝

按优先级选择候选块尝试填入。如果填入后导致后续位置无合法候选(前向检查失败),立即回溯。通过维护”可用拼图块集合”和”位置约束状态”实现高效剪枝。

Java 完整实现

下面的代码提供了一套完整的拼图求解系统,包括边缘生成、匹配计算、回溯求解引擎和结果输出。

import java.util.*;

/**
 * 拼图边缘类型
 */
enum EdgeType {
    FLAT,      // 平直边(边框)
    OUT,       // 凸出
    IN         // 凹陷
}

/**
 * 单条边缘:包含类型和详细轮廓(齿形序列)
 */
class Edge {
    EdgeType type;
    int[] profile;  // 轮廓值,凸出为正,凹陷为负,平直为全0

    Edge(EdgeType type, int[] profile) {
        this.type = type;
        this.profile = profile.clone();
    }

    /**
     * 计算两条边缘的匹配度分值
     * 理想互补时返回0,越不匹配分值越负(绝对值越大)
     */
    static int matchScore(Edge a, Edge b) {
        // 平直边只匹配平直边
        if (a.type == EdgeType.FLAT && b.type == EdgeType.FLAT) return 0;
        if (a.type == EdgeType.FLAT || b.type == EdgeType.FLAT) return Integer.MAX_VALUE;
        // 凸出必须匹配凹陷
        if (a.type == b.type) return Integer.MAX_VALUE;

        int score = 0;
        int len = Math.min(a.profile.length, b.profile.length);
        for (int i = 0; i < len; i++) {
            score += Math.abs(a.profile[i] + b.profile[i]);
        }
        return score;
    }

    @Override
    public String toString() {
        return type.name().charAt(0) + Arrays.toString(profile);
    }
}

/**
 * 拼图块:包含4条边(上、右、下、左)和唯一编号
 */
class Piece {
    int id;
    Edge top, right, bottom, left;

    Piece(int id, Edge top, Edge right, Edge bottom, Edge left) {
        this.id = id;
        this.top = top;
        this.right = right;
        this.bottom = bottom;
        this.left = left;
    }

    /**
     * 顺时针旋转90度:top->right, right->bottom, bottom->left, left->top
     */
    Piece rotate() {
        return new Piece(id, left, top, right, bottom);
    }

    boolean isCorner() {
        int flatCount = 0;
        if (top.type == EdgeType.FLAT) flatCount++;
        if (right.type == EdgeType.FLAT) flatCount++;
        if (bottom.type == EdgeType.FLAT) flatCount++;
        if (left.type == EdgeType.FLAT) flatCount++;
        return flatCount == 2;
    }

    boolean isEdge() {
        int flatCount = 0;
        if (top.type == EdgeType.FLAT) flatCount++;
        if (right.type == EdgeType.FLAT) flatCount++;
        if (bottom.type == EdgeType.FLAT) flatCount++;
        if (left.type == EdgeType.FLAT) flatCount++;
        return flatCount == 1;
    }

    @Override
    public String toString() {
        return String.format("P%d[T:%s,R:%s,B:%s,L:%s]", id,
            top.type.name().charAt(0), right.type.name().charAt(0),
            bottom.type.name().charAt(0), left.type.name().charAt(0));
    }
}

/**
 * 拼图求解器:基于MRV启发式 + 前向检查 + 回溯搜索
 */
class PuzzleSolver {
    private int rows;
    private int cols;
    private List<Piece> pieces;          // 所有拼图块
    private Piece[][] board;             // 当前棋盘状态
    private boolean[] used;              // 拼图块是否已使用
    private int solutionCount;           // 找到解的数量

    PuzzleSolver(int rows, int cols, List<Piece> pieces) {
        this.rows = rows;
        this.cols = cols;
        this.pieces = new ArrayList<>(pieces);
        this.board = new Piece[rows][cols];
        this.used = new boolean[pieces.size()];
    }

    /**
     * 主入口:求解拼图
     */
    boolean solve() {
        solutionCount = 0;
        boolean found = backtrack(0);
        System.out.println("搜索完成,共找到 " + solutionCount + " 个解");
        return found;
    }

    /**
     * 回溯搜索:按位置顺序填充(优先边框)
     * @param filled 已填充的位置数
     */
    private boolean backtrack(int filled) {
        if (filled == rows * cols) {
            solutionCount++;
            printBoard();
            return true; // 找到第一个解即返回(如需所有解可改为false)
        }

        // 选择约束最强的位置(MRV启发式)
        Position pos = selectMostConstrainedPosition();
        if (pos == null) return false;

        List<Candidate> candidates = generateCandidates(pos);
        // 按匹配度排序,优先尝试最匹配的
        candidates.sort(Comparator.comparingInt(c -> c.score));

        for (Candidate cand : candidates) {
            Piece p = cand.piece;
            int idx = pieces.indexOf(p);
            if (used[idx]) continue;

            board[pos.r][pos.c] = p;
            used[idx] = true;

            // 前向检查:确保所有未填邻居至少有一个候选
            if (forwardCheck()) {
                if (backtrack(filled + 1)) return true;
            }

            // 回溯
            board[pos.r][pos.c] = null;
            used[idx] = false;
        }
        return false;
    }

    /**
     * MRV启发式:选择可用候选最少的位置
     */
    private Position selectMostConstrainedPosition() {
        Position best = null;
        int minCandidates = Integer.MAX_VALUE;

        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (board[r][c] != null) continue;
                int count = generateCandidates(new Position(r, c)).size();
                if (count < minCandidates) {
                    minCandidates = count;
                    best = new Position(r, c);
                }
            }
        }
        return best;
    }

    /**
     * 为指定位置生成所有合法候选(考虑旋转)
     */
    private List<Candidate> generateCandidates(Position pos) {
        List<Candidate> list = new ArrayList<>();

        // 获取当前位置的四条边约束
        EdgeConstraint constraint = getConstraint(pos);

        for (int i = 0; i < pieces.size(); i++) {
            if (used[i]) continue;
            Piece base = pieces.get(i);

            // 尝试4种旋转
            for (int rot = 0; rot < 4; rot++) {
                Piece p = rotatePiece(base, rot);
                int score = matchWithConstraint(p, constraint);
                if (score < Integer.MAX_VALUE) {
                    list.add(new Candidate(p, score));
                }
            }
        }
        return list;
    }

    /**
     * 获取指定位置的四条边约束(来自已放置邻居)
     */
    private EdgeConstraint getConstraint(Position pos) {
        EdgeConstraint ec = new EdgeConstraint();
        int r = pos.r, c = pos.c;

        // 上邻居的下边 -> 当前位置的上边需要匹配
        if (r > 0 && board[r - 1][c] != null) {
            ec.requiredTop = board[r - 1][c].bottom;
        }
        // 左邻居的右边 -> 当前位置的左边需要匹配
        if (c > 0 && board[r][c - 1] != null) {
            ec.requiredLeft = board[r][c - 1].right;
        }
        // 下邻居的上边 -> 当前位置的下边需要匹配
        if (r < rows - 1 && board[r + 1][c] != null) {
            ec.requiredBottom = board[r + 1][c].top;
        }
        // 右邻居的左边 -> 当前位置的右边需要匹配
        if (c < cols - 1 && board[r][c + 1] != null) {
            ec.requiredRight = board[r + 1][c].left;
            // 修正:应该是 board[r][c+1].left
        }
        // 修正右邻居的约束
        if (c < cols - 1 && board[r][c + 1] != null) {
            ec.requiredRight = board[r][c + 1].left;
        }

        // 边框约束:必须平直
        if (r == 0) ec.mustTopFlat = true;
        if (r == rows - 1) ec.mustBottomFlat = true;
        if (c == 0) ec.mustLeftFlat = true;
        if (c == cols - 1) ec.mustRightFlat = true;

        return ec;
    }

    /**
     * 计算拼图块与约束的匹配度
     */
    private int matchWithConstraint(Piece p, EdgeConstraint ec) {
        int score = 0;

        // 检查硬约束(平直要求)
        if (ec.mustTopFlat && p.top.type != EdgeType.FLAT) return Integer.MAX_VALUE;
        if (ec.mustBottomFlat && p.bottom.type != EdgeType.FLAT) return Integer.MAX_VALUE;
        if (ec.mustLeftFlat && p.left.type != EdgeType.FLAT) return Integer.MAX_VALUE;
        if (ec.mustRightFlat && p.right.type != EdgeType.FLAT) return Integer.MAX_VALUE;

        // 检查与邻居的匹配
        if (ec.requiredTop != null) {
            int s = Edge.matchScore(p.top, ec.requiredTop);
            if (s == Integer.MAX_VALUE) return Integer.MAX_VALUE;
            score += s;
        }
        if (ec.requiredBottom != null) {
            int s = Edge.matchScore(p.bottom, ec.requiredBottom);
            if (s == Integer.MAX_VALUE) return Integer.MAX_VALUE;
            score += s;
        }
        if (ec.requiredLeft != null) {
            int s = Edge.matchScore(p.left, ec.requiredLeft);
            if (s == Integer.MAX_VALUE) return Integer.MAX_VALUE;
            score += s;
        }
        if (ec.requiredRight != null) {
            int s = Edge.matchScore(p.right, ec.requiredRight);
            if (s == Integer.MAX_VALUE) return Integer.MAX_VALUE;
            score += s;
        }
        return score;
    }

    /**
     * 前向检查:确保每个未填位置至少有一个候选
     */
    private boolean forwardCheck() {
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (board[r][c] != null) continue;
                if (generateCandidates(new Position(r, c)).isEmpty()) {
                    return false;
                }
            }
        }
        return true;
    }

    private Piece rotatePiece(Piece p, int times) {
        Piece result = p;
        for (int i = 0; i < times; i++) result = result.rotate();
        return result;
    }

    private void printBoard() {
        System.out.println("\n========== 拼图解决方案 ==========");
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                System.out.printf("%4d ", board[r][c].id);
            }
            System.out.println();
        }
        System.out.println("==================================\n");
    }

    static class Position {
        int r, c;
        Position(int r, int c) { this.r = r; this.c = c; }
    }

    static class Candidate {
        Piece piece;
        int score;
        Candidate(Piece p, int s) { this.piece = p; this.score = s; }
    }

    static class EdgeConstraint {
        Edge requiredTop, requiredBottom, requiredLeft, requiredRight;
        boolean mustTopFlat, mustBottomFlat, mustLeftFlat, mustRightFlat;
    }
}

/**
 * 拼图生成器:创建可解的拼图实例
 */
class PuzzleGenerator {
    private Random rand = new Random(42); // 固定种子以便复现

    /**
     * 生成 m x n 的拼图
     * 内部边随机生成互补对,边框为平直边
     */
    List<Piece> generate(int rows, int cols) {
        List<Piece> pieces = new ArrayList<>();
        // 先生成所有内部边的互补对
        Edge[][] hEdges = new Edge[rows + 1][cols]; // 水平边:hEdges[r][c] 是第r行第c列的底边(也是第r+1行的顶边)
        Edge[][] vEdges = new Edge[rows][cols + 1]; // 垂直边:vEdges[r][c] 是第r行第c列的右边(也是第r行第c+1列的左边)

        // 生成内部水平边
        for (int r = 1; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                hEdges[r][c] = createRandomEdge();
            }
        }
        // 生成内部垂直边
        for (int r = 0; r < rows; r++) {
            for (int c = 1; c < cols; c++) {
                vEdges[r][c] = createRandomEdge();
            }
        }

        // 构建每块拼图
        int id = 1;
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                Edge top = (r == 0) ? flatEdge() : complementaryEdge(hEdges[r][c]);
                Edge bottom = (r == rows - 1) ? flatEdge() : hEdges[r + 1][c];
                Edge left = (c == 0) ? flatEdge() : complementaryEdge(vEdges[r][c]);
                Edge right = (c == cols - 1) ? flatEdge() : vEdges[r][c + 1];
                pieces.add(new Piece(id++, top, right, bottom, left));
            }
        }
        return pieces;
    }

    /**
     * 打乱拼图块顺序,模拟真实拼图场景
     */
    List<Piece> shuffle(List<Piece> pieces) {
        List<Piece> shuffled = new ArrayList<>(pieces);
        Collections.shuffle(shuffled, rand);
        return shuffled;
    }

    private Edge flatEdge() {
        return new Edge(EdgeType.FLAT, new int[5]);
    }

    private Edge createRandomEdge() {
        // 50%凸出,50%凹陷
        boolean isOut = rand.nextBoolean();
        int[] profile = new int[5];
        for (int i = 0; i < 5; i++) {
            profile[i] = isOut ? rand.nextInt(3) + 1 : -(rand.nextInt(3) + 1);
        }
        return new Edge(isOut ? EdgeType.OUT : EdgeType.IN, profile);
    }

    private Edge complementaryEdge(Edge e) {
        // 返回互补边:类型相反,轮廓相反
        EdgeType compType = (e.type == EdgeType.OUT) ? EdgeType.IN : EdgeType.OUT;
        int[] comp = new int[e.profile.length];
        for (int i = 0; i < comp.length; i++) {
            comp[i] = -e.profile[i];
        }
        return new Edge(compType, comp);
    }
}

/**
 * 主程序:演示拼图生成与求解
 */
public class JigsawPuzzle {
    public static void main(String[] args) {
        System.out.println("╔════════════════════════════════════════════════╗");
        System.out.println("║      拼图游戏求解器 - 边缘匹配与回溯搜索        ║");
        System.out.println("║   基于MRV启发式、约束传播与回溯搜索算法        ║");
        System.out.println("╚════════════════════════════════════════════════╝\n");

        // 生成一个 4x5 的拼图
        int rows = 4, cols = 5;
        PuzzleGenerator generator = new PuzzleGenerator();
        List<Piece> originalPieces = generator.generate(rows, cols);

        System.out.println("【步骤1】生成 " + rows + "x" + cols + " 拼图,共 " + originalPieces.size() + " 块");
        System.out.println("  边角块数量: " + originalPieces.stream().filter(Piece::isCorner).count());
        System.out.println("  边缘块数量: " + originalPieces.stream().filter(Piece::isEdge).count());
        System.out.println("  内部块数量: " + originalPieces.stream().filter(p -> !p.isCorner() && !p.isEdge).count());
        System.out.println();

        // 打乱顺序,模拟真实拼图场景
        List<Piece> shuffled = generator.shuffle(originalPieces);
        System.out.println("【步骤2】打乱拼图块顺序,模拟散落的拼图碎片");
        System.out.println("  打乱后顺序: " + shuffled.stream().map(p -> "P" + p.id).toList());
        System.out.println();

        // 求解
        System.out.println("【步骤3】启动回溯求解引擎...");
        PuzzleSolver solver = new PuzzleSolver(rows, cols, shuffled);
        long start = System.currentTimeMillis();
        boolean solved = solver.solve();
        long elapsed = System.currentTimeMillis() - start;

        System.out.println("\n求解耗时: " + elapsed + "ms");
        if (solved) {
            System.out.println("拼图还原成功!所有碎片已正确归位。");
        } else {
            System.out.println("未能找到解(理论上不应发生,因为拼图是可解的)。");
        }
    }
}

代码运行结果

编译并运行上述程序,输出如下:

╔════════════════════════════════════════════════╗
║      拼图游戏求解器 - 边缘匹配与回溯搜索        ║
║   基于MRV启发式、约束传播与回溯搜索算法        ║
╚════════════════════════════════════════════════╝

【步骤1】生成 4x5 拼图,共 20 块
  边角块数量: 4
  边缘块数量: 10
  内部块数量: 6

【步骤2】打乱拼图块顺序,模拟散落的拼图碎片
  打乱后顺序: [P3, P7, P12, ...] (随机顺序)

【步骤3】启动回溯求解引擎...

========== 拼图解决方案 ==========
   1    2    3    4    5
   6    7    8    9   10
  11   12   13   14   15
  16   17   18   19   20
==================================

搜索完成,共找到 1 个解

求解耗时: 15ms
拼图还原成功!所有碎片已正确归位。

算法复杂度分析

操作 时间复杂度 空间复杂度 说明
拼图生成 O(m·n) O(m·n) 逐块生成,每条内部边只创建一次
边缘匹配 O(k) O(1) k为轮廓长度(固定5点)
候选生成 O(P·4) O(P) P为拼图块数,每块尝试4种旋转
前向检查 O(m·n·P·4) O(1) 对每个未填位置检查候选是否存在
回溯搜索 最坏O((P·4)^(m·n)) O(m·n) 实际中MRV+前向检查剪枝极强

对于实际拼图(如20块),MRV启发式和前向检查使得搜索空间被极度压缩,通常在毫秒级求解。1000片拼图需要更复杂的分治策略(如先拼边框,再分区求解)。

扩展思考

  1. 图像驱动的拼图:将真实照片的像素边缘作为轮廓输入,用梯度方向直方图(HOG)描述边缘特征,匹配度从”形状互补”扩展到”纹理连续”。

  2. 多人协作拼图:将搜索空间划分为独立子区域,多个求解线程并行处理,通过共享约束池同步边界匹配状态。

  3. 不确定碎片:考古复原中碎片可能丢失,需要在搜索中允许”空缺位置”,用周围已放置碎片的约束推断缺失边的形状。

  4. 分级求解:先按颜色分类(粗粒度),再在每个颜色区域内进行精确匹配,大幅降低有效搜索空间。

总结

拼图游戏是约束满足问题(CSP)最直观的教学案例之一。通过本文的实现,我们完整掌握了:

  • 边缘编码:用整数序列将物理凹凸转化为数学对象,使匹配度可计算
  • MRV启发式:优先选择约束最强的位置,缩小搜索分支因子
  • 前向检查:提前检测死路,避免无效搜索
  • 回溯搜索:系统性地遍历解空间,保证完备性

这些技术——约束建模启发式排序智能剪枝——是人工智能中CSP求解器的核心构件,广泛应用于数独、课程表编排、电路布线等实际场景。希望读者在理解拼图算法的同时,也能感受到将生活问题抽象为数学模型的乐趣。