每日算法 — 使用java实现连连看:BFS路径连通与贪心消除策略

连连看是一款经典的益智消除游戏,规则简单却蕴含丰富的算法思想。本文将用 Java 实现一个带 AI 自动消除的连连看程序,核心讲解BFS 路径连通检测(判断两点间能否通过不超过两次拐弯连接)以及贪心消除策略(自动寻找最优消除顺序)。通过游戏场景,你将深入理解图搜索与启发式决策的实际应用。

游戏规则与问题建模

标准连连看在一个 $M \times N$ 的棋盘上随机摆放成对的图案。玩家需要找出两个相同的图案,如果它们之间的连线转折不超过两次(即最多经过三条直线段),则可以消除。消除后图案清空,当所有图案被消除时游戏胜利。

从算法角度看,连连看包含两个核心问题:

  1. 连通性判定:给定两个相同图案的坐标,判断是否存在合法路径(拐弯数 $\le 2$)。
  2. 消除顺序决策:当棋盘上存在多组可消除的图案时,如何选择消除顺序以避免死局。

核心算法一:BFS 路径连通检测

算法思路

判断两点间是否存在合法路径,最直接的方法是 BFS。不同于普通迷宫寻路,连连看的路径约束是拐弯次数而非步数。我们需要在状态中加入当前移动方向和已拐弯次数。

状态定义为 (row, col, direction, turns),其中:
direction 表示进入当前格子的方向(上/下/左/右/无)
turns 表示从起点到当前格子已经拐弯的次数

从起点出发,向四个方向扩展:
– 如果新方向与当前方向相同,拐弯次数不变,直线前进。
– 如果新方向与当前方向不同(且当前方向不是”无”),拐弯次数 $+1$。
– 一旦拐弯次数超过 $2$,该分支直接剪枝。

BFS 保证了我们找到的是拐弯数最少的路径,只要存在合法路径就一定能找到。

Java 实现

import java.util.*;

/**
 * 连连看路径连通检测 —— BFS 版本
 * 判断两点之间是否存在拐弯数 <= 2 的合法路径
 */
public class LinkUpPathFinder {

    // 四个方向:上、下、左、右
    private static final int[][] DIRS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
    // 方向编号与 DIRS 对应:0=上, 1=下, 2=左, 3=右
    private static final int DIR_NONE = -1;
    private static final int MAX_TURNS = 2;

    /**
     * 检查从 (r1,c1) 到 (r2,c2) 是否存在合法路径
     * @param board 棋盘,0 表示空,非 0 表示图案编号
     * @param r1 起点行
     * @param c1 起点列
     * @param r2 终点行
     * @param c2 终点列
     * @return 是否存在合法路径
     */
    public boolean canConnect(int[][] board, int r1, int c1, int r2, int c2) {
        int rows = board.length;
        int cols = board[0].length;

        // visited[r][c][d] 表示从某个方向 d 到达 (r,c) 时的最小拐弯数
        // d=4 表示无方向(起点状态)
        int[][][] visited = new int[rows][cols][5];
        for (int[][] layer : visited) {
            for (int[] row : layer) {
                Arrays.fill(row, Integer.MAX_VALUE);
            }
        }

        Deque<State> queue = new ArrayDeque<>();
        queue.offer(new State(r1, c1, 4, 0)); // 起点,无方向,0拐弯
        visited[r1][c1][4] = 0;

        while (!queue.isEmpty()) {
            State cur = queue.poll();

            // 到达终点(且不是起点本身)
            if (cur.r == r2 && cur.c == c2 && !(cur.r == r1 && cur.c == c1)) {
                return true;
            }

            for (int d = 0; d < 4; d++) {
                int nr = cur.r + DIRS[d][0];
                int nc = cur.c + DIRS[d][1];

                // 越界检查
                if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;

                // 计算新拐弯数
                int newTurns = cur.turns;
                if (cur.dir != 4 && cur.dir != d) {
                    newTurns++;
                }

                // 剪枝:拐弯数超过限制
                if (newTurns > MAX_TURNS) continue;

                // 下一步要么是空格(0),要么是终点(不能踩其他图案)
                boolean isTarget = (nr == r2 && nc == c2);
                if (board[nr][nc] != 0 && !isTarget) continue;

                // 如果通过方向 d 到达 (nr,nc) 的拐弯数更优,则加入队列
                if (newTurns < visited[nr][nc][d]) {
                    visited[nr][nc][d] = newTurns;
                    queue.offer(new State(nr, nc, d, newTurns));
                }
            }
        }

        return false;
    }

    /**
     * BFS 状态节点
     */
    private static class State {
        int r, c;      // 坐标
        int dir;       // 进入当前格子的方向
        int turns;     // 已拐弯次数

        State(int r, int c, int dir, int turns) {
            this.r = r;
            this.c = c;
            this.dir = dir;
            this.turns = turns;
        }
    }
}

复杂度分析

  • 时间复杂度:$O(rows \times cols \times 5)$。棋盘每个格子的每个方向最多入队一次,常数 $5$ 为方向数(含无方向状态)。
  • 空间复杂度:$O(rows \times cols \times 5)$,用于 visited 数组和队列。

核心算法二:贪心消除策略

为什么需要策略?

连连看并非总能通关。如果玩家随意消除,可能过早地堵住某些图案的唯一出口,导致死局。一个良好的 AI 消除策略应当:优先消除那些”选择最少”的图案对,或者说优先消除位于边缘、角落的图案,避免把通路堵死。

贪心启发规则

本文采用以下贪心策略:

  1. 枚举所有可消除对:遍历棋盘上所有相同图案的配对,用上述 BFS 检测是否连通。
  2. 计算每对图案的”自由度”:统计每个图案四周(上下左右)空格的数量,空格越多说明该图案越不紧迫。
  3. 优先消除四周空格最少的图案对:这类似于”排雷”思想——先处理受限最严重的图案,防止后续被堵死。

Java 实现

import java.util.*;

/**
 * 连连看 AI 消除引擎 —— 贪心策略版
 */
public class LinkUpSolver {

    private final LinkUpPathFinder pathFinder = new LinkUpPathFinder();

    /**
     * 寻找下一步最优消除
     * @param board 当前棋盘
     * @return 最优消除对的坐标,无法消除则返回 null
     */
    public int[] findBestMove(int[][] board) {
        int rows = board.length;
        int cols = board[0].length;

        // 收集所有相同图案的位置
        Map<Integer, List<int[]>> groups = new HashMap<>();
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (board[r][c] != 0) {
                    groups.computeIfAbsent(board[r][c], k -> new ArrayList<>()).add(new int[]{r, c});
                }
            }
        }

        int bestScore = Integer.MAX_VALUE;
        int[] bestPair = null;

        for (List<int[]> positions : groups.values()) {
            // 两两配对检查连通性
            for (int i = 0; i < positions.size(); i++) {
                for (int j = i + 1; j < positions.size(); j++) {
                    int[] p1 = positions.get(i);
                    int[] p2 = positions.get(j);

                    if (pathFinder.canConnect(board, p1[0], p1[1], p2[0], p2[1])) {
                        // 计算这对图案的"紧迫度" = 四周空格数之和(越少越紧迫)
                        int freedom = countEmptyNeighbors(board, p1[0], p1[1])
                                    + countEmptyNeighbors(board, p2[0], p2[1]);

                        if (freedom < bestScore) {
                            bestScore = freedom;
                            bestPair = new int[]{p1[0], p1[1], p2[0], p2[1]};
                        }
                    }
                }
            }
        }

        return bestPair;
    }

    /**
     * 统计某格子四周空格数量
     */
    private int countEmptyNeighbors(int[][] board, int r, int c) {
        int count = 0;
        int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}};
        for (int[] d : dirs) {
            int nr = r + d[0], nc = c + d[1];
            if (nr >= 0 && nr < board.length && nc >= 0 && nc < board[0].length) {
                if (board[nr][nc] == 0) count++;
            } else {
                // 边界外也算"空",因为连连看可以绕到棋盘外面(有外框)
                count++;
            }
        }
        return count;
    }

    /**
     * 自动通关:不断寻找最优消除直到棋盘清空或无法继续
     */
    public boolean autoSolve(int[][] board) {
        while (true) {
            int[] move = findBestMove(board);
            if (move == null) {
                // 检查是否已全部消除
                for (int[] row : board) {
                    for (int val : row) {
                        if (val != 0) return false; // 死局
                    }
                }
                return true; // 通关
            }
            // 执行消除
            board[move[0]][move[1]] = 0;
            board[move[2]][move[3]] = 0;
        }
    }
}

完整可运行项目

下面是一个最小化但可直接运行的完整程序,包含棋盘生成、BFS 连通检测、贪心 AI 消除和结果展示:

import java.util.*;

public class LinkUpGame {

    private static final int ROWS = 8;
    private static final int COLS = 10;
    private static final int ICON_TYPES = 18; // 图案种类数

    public static void main(String[] args) {
        LinkUpGame game = new LinkUpGame();
        int[][] board = game.generateBoard();

        System.out.println("=== 初始棋盘 ===");
        game.printBoard(board);

        LinkUpSolver solver = new LinkUpSolver();
        boolean success = solver.autoSolve(board);

        System.out.println("\n=== 最终棋盘 ===");
        game.printBoard(board);
        System.out.println(success ? "\nAI 自动通关成功!" : "\n陷入死局,AI 无法通关。");
    }

    /**
     * 生成随机可解棋盘:随机配对放置
     */
    public int[][] generateBoard() {
        int[][] board = new int[ROWS][COLS];
        List<Integer> icons = new ArrayList<>();
        int total = ROWS * COLS;
        for (int i = 0; i < total / 2; i++) {
            int type = (i % ICON_TYPES) + 1;
            icons.add(type);
            icons.add(type);
        }
        Collections.shuffle(icons);

        int idx = 0;
        for (int r = 0; r < ROWS; r++) {
            for (int c = 0; c < COLS; c++) {
                board[r][c] = icons.get(idx++);
            }
        }
        return board;
    }

    public void printBoard(int[][] board) {
        for (int[] row : board) {
            for (int val : row) {
                System.out.printf("%3d", val);
            }
            System.out.println();
        }
    }
}

// ================== 以下为辅助类,实际项目中建议拆分为独立文件 ==================

class LinkUpPathFinder {
    private static final int[][] DIRS = {{-1,0},{1,0},{0,-1},{0,1}};
    private static final int DIR_NONE = 4;
    private static final int MAX_TURNS = 2;

    public boolean canConnect(int[][] board, int r1, int c1, int r2, int c2) {
        int rows = board.length, cols = board[0].length;
        int[][][] visited = new int[rows][cols][5];
        for (int[][] layer : visited) for (int[] row : layer) Arrays.fill(row, Integer.MAX_VALUE);

        Deque<State> q = new ArrayDeque<>();
        q.offer(new State(r1, c1, DIR_NONE, 0));
        visited[r1][c1][DIR_NONE] = 0;

        while (!q.isEmpty()) {
            State cur = q.poll();
            if (cur.r == r2 && cur.c == c2 && !(cur.r == r1 && cur.c == c1)) return true;

            for (int d = 0; d < 4; d++) {
                int nr = cur.r + DIRS[d][0], nc = cur.c + DIRS[d][1];
                if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;

                int nt = cur.turns + (cur.dir != DIR_NONE && cur.dir != d ? 1 : 0);
                if (nt > MAX_TURNS) continue;
                boolean target = (nr == r2 && nc == c2);
                if (board[nr][nc] != 0 && !target) continue;
                if (nt < visited[nr][nc][d]) {
                    visited[nr][nc][d] = nt;
                    q.offer(new State(nr, nc, d, nt));
                }
            }
        }
        return false;
    }

    private static class State {
        int r, c, dir, turns;
        State(int r, int c, int dir, int turns) { this.r=r; this.c=c; this.dir=dir; this.turns=turns; }
    }
}

class LinkUpSolver {
    private final LinkUpPathFinder finder = new LinkUpPathFinder();

    public int[] findBestMove(int[][] board) {
        int rows = board.length, cols = board[0].length;
        Map<Integer, List<int[]>> groups = new HashMap<>();
        for (int r = 0; r < rows; r++)
            for (int c = 0; c < cols; c++)
                if (board[r][c] != 0)
                    groups.computeIfAbsent(board[r][c], k->new ArrayList<>()).add(new int[]{r,c});

        int best = Integer.MAX_VALUE;
        int[] pick = null;
        for (List<int[]> list : groups.values()) {
            for (int i = 0; i < list.size(); i++) {
                for (int j = i+1; j < list.size(); j++) {
                    int[] a = list.get(i), b = list.get(j);
                    if (finder.canConnect(board, a[0], a[1], b[0], b[1])) {
                        int score = freedom(board, a[0], a[1]) + freedom(board, b[0], b[1]);
                        if (score < best) { best = score; pick = new int[]{a[0],a[1],b[0],b[1]}; }
                    }
                }
            }
        }
        return pick;
    }

    private int freedom(int[][] b, int r, int c) {
        int cnt = 0;
        int[][] d = {{-1,0},{1,0},{0,-1},{0,1}};
        for (int[] v : d) {
            int nr = r+v[0], nc = c+v[1];
            if (nr<0||nr>=b.length||nc<0||nc>=b[0].length||b[nr][nc]==0) cnt++;
        }
        return cnt;
    }

    public boolean autoSolve(int[][] board) {
        while (true) {
            int[] m = findBestMove(board);
            if (m == null) {
                for (int[] row : board) for (int v : row) if (v != 0) return false;
                return true;
            }
            board[m[0]][m[1]] = 0;
            board[m[2]][m[3]] = 0;
        }
    }
}

算法延伸与思考

1. 为何用 BFS 而非 DFS?

BFS 按层扩展,天然保证找到的是拐弯数最少的路径。虽然连连看只关心是否存在($\le 2$ 拐弯),但 BFS 的状态管理更直观,且不会出现 DFS 深层递归导致的栈溢出问题。

2. 贪心策略能保证 100% 通关吗?

不能。贪心策略只关注局部最优(先消除最紧迫的图案),但连连看的全局最优消除顺序可能需要在早期牺牲局部利益。对于更高阶的 AI,可以引入回溯搜索蒙特卡洛模拟评估不同消除顺序的胜率。

3. 如何优化性能?

  • 预计算连通性:每次消除只影响周围局部区域,可用增量更新代替全盘 BFS。
  • 空间换时间:维护一个图案位置索引表,避免每次全表扫描。
  • 死局提前检测:若某种图案只剩一个,直接判负。

总结

连连看将图搜索与启发式决策融合在简单的消除规则中。本文通过 BFS 状态扩展 解决了路径连通判定问题,通过贪心自由度评估实现了 AI 自动消除。读者可以在此基础上继续探索:将贪心升级为带搜索深度的 Minimax,或引入机器学习训练估值网络,让 AI 的通关率更进一步。