引言
连连看是考验观察力与路径规划的经典消除游戏。传统人工玩法依赖视觉扫描寻找可通过不超过两次转弯连通的对子,而AI实现则需要系统化的路径搜索算法。本文采用A*搜索求解最优消除路径,核心创新在于将”转弯次数”作为关键成本因子融入启发函数,使搜索优先找到转折少、路径直观的连通方案,更符合人类玩家的视觉习惯。
问题建模
连连看的路径连通规则要求:两个相同图案的格子之间,可以用一条不超过两次转弯的折线连接,且折线只能经过空白格子(已消除的格子视为空白)。
状态空间
- 地图状态:二维网格,每个格子为图案编号或空白(0)
- 路径状态:从起点出发,经过一系列直线段到达终点,每段直线沿单一方向延伸,方向改变即构成一次转弯
A*搜索设计
标准A*的代价函数为f = g + h,其中g为已付出代价,h为启发估计。连连看的特殊性在于:
- g成本:路径长度(经过格子数)+ 转弯次数惩罚(每次转弯额外+5,优先直线路径)
- h启发:曼哈顿距离 / 直线能力 + 最少转弯估计
通过给转弯赋予高惩罚权重,A*会优先探索直线延伸的路径,仅在必要时才转向。
核心算法设计
转弯感知的A*节点
每个搜索节点不仅记录位置坐标,还记录进入该格子的方向。方向变化时触发转弯计数:
class Node {
int row, col; // 位置
int dir; // 进入方向 0=上 1=下 2=左 3=右
int turns; // 已转弯次数
int g; // 实际成本
int f; // f = g + h
}
扩展策略:射线式延伸
从当前节点出发,沿当前方向直线延伸直到遇到边界或障碍,每遇到一个可落脚点就生成一个新节点。若延伸方向与进入方向不同,则转弯次数+1。这种”射线式扩展”大幅减少A*的扩展节点数,因为同一直线上的中间点不会被单独加入Open表。
可解性预判断
在启动A之前,先检查起点和终点是否在同一行或同一列且中间无障碍(0转弯解),或是否存在一个中转空白格使两段均为直线(1转弯解)。这些简单情况可在O(n)时间内解决,避免A搜索开销。
Java完整实现
import java.util.*;
/**
* 基于A*搜索的连连看AI
* 使用转弯次数优化启发函数,实现最优路径查找
*/
public class AStarLinkLink {
// 方向定义:上、下、左、右
private final int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
private final String[] dirNames = {"UP", "DOWN", "LEFT", "RIGHT"};
// 地图
private int[][] board;
private int rows, cols;
// 转弯惩罚权重
private final int TURN_PENALTY = 5;
private final int MAX_TURNS = 2;
public AStarLinkLink(int[][] board) {
this.board = board;
this.rows = board.length;
this.cols = board[0].length;
}
/**
* A*搜索节点
*/
static class Node implements Comparable<Node> {
int row, col;
int dir; // 进入方向,-1表示起点
int turns; // 已转弯次数
int g; // 实际成本(步数 + 转弯惩罚)
int h; // 启发估计
int f; // 总评估值
Node parent;
Node(int r, int c, int dir, int turns, int g, int h, Node parent) {
this.row = r; this.col = c; this.dir = dir;
this.turns = turns; this.g = g; this.h = h;
this.f = g + h;
this.parent = parent;
}
@Override
public int compareTo(Node o) {
return Integer.compare(this.f, o.f);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Node)) return false;
Node n = (Node) o;
return row == n.row && col == n.col && dir == n.dir && turns == n.turns;
}
@Override
public int hashCode() {
return Objects.hash(row, col, dir, turns);
}
}
/**
* 启发函数:曼哈顿距离 + 最少转弯估计
*/
private int heuristic(int r, int c, int dir, int tr, int tc) {
int manhattan = Math.abs(r - tr) + Math.abs(c - tc);
// 如果当前方向已经指向目标,最少0次额外转弯
// 否则至少1次
int minExtraTurns = 0;
if (dir >= 0) {
boolean aligned = (dir <= 1 && r == tr) || (dir >= 2 && c == tc);
if (!aligned) minExtraTurns = 1;
}
return manhattan + minExtraTurns * TURN_PENALTY;
}
/**
* 判断位置是否在地图内且可通行
*/
private boolean isPassable(int r, int c, int targetR, int targetC) {
if (r < 0 || r >= rows || c < 0 || c >= cols) return false;
// 空白格或目标格可通行
return board[r][c] == 0 || (r == targetR && c == targetC);
}
/**
* 快速预判断:0转弯或1转弯直连
*/
private List<int[]> fastCheck(int sr, int sc, int tr, int tc) {
// 0转弯:同行或同列且中间无阻碍
if (sr == tr) {
int min = Math.min(sc, tc), max = Math.max(sc, tc);
boolean clear = true;
for (int c = min + 1; c < max; c++) {
if (board[sr][c] != 0) { clear = false; break; }
}
if (clear) {
List<int[]> path = new ArrayList<>();
path.add(new int[]{sr, sc});
for (int c = min + 1; c <= max; c++) path.add(new int[]{sr, c});
if (sc > tc) Collections.reverse(path);
return path;
}
}
if (sc == tc) {
int min = Math.min(sr, tr), max = Math.max(sr, tr);
boolean clear = true;
for (int r = min + 1; r < max; r++) {
if (board[r][sc] != 0) { clear = false; break; }
}
if (clear) {
List<int[]> path = new ArrayList<>();
path.add(new int[]{sr, sc});
for (int r = min + 1; r <= max; r++) path.add(new int[]{r, sc});
if (sr > tr) Collections.reverse(path);
return path;
}
}
// 1转弯:寻找一个中转空白格
// 尝试 (sr, tc) 和 (tr, sc)
int[][] candidates = {{sr, tc}, {tr, sc}};
for (int[] cand : candidates) {
int cr = cand[0], cc = cand[1];
if (board[cr][cc] != 0 && !(cr == sr && cc == sc) && !(cr == tr && cc == tc)) continue;
// 检查两段是否都畅通
boolean seg1clear = true, seg2clear = true;
// 段1: (sr,sc) -> (cr,cc)
if (sr == cr) {
for (int c = Math.min(sc, cc) + 1; c < Math.max(sc, cc); c++) {
if (board[sr][c] != 0) { seg1clear = false; break; }
}
} else if (sc == cc) {
for (int r = Math.min(sr, cr) + 1; r < Math.max(sr, cr); r++) {
if (board[r][sc] != 0) { seg1clear = false; break; }
}
} else {
seg1clear = false;
}
// 段2: (cr,cc) -> (tr,tc)
if (cr == tr) {
for (int c = Math.min(cc, tc) + 1; c < Math.max(cc, tc); c++) {
if (board[cr][c] != 0) { seg2clear = false; break; }
}
} else if (cc == tc) {
for (int r = Math.min(cr, tr) + 1; r < Math.max(cr, tr); r++) {
if (board[r][cc] != 0) { seg2clear = false; break; }
}
} else {
seg2clear = false;
}
if (seg1clear && seg2clear) {
List<int[]> path = new ArrayList<>();
// 构建段1
if (sr == cr) {
int min = Math.min(sc, cc), max = Math.max(sc, cc);
for (int c = min; c <= max; c++) path.add(new int[]{sr, c});
} else {
int min = Math.min(sr, cr), max = Math.max(sr, cr);
for (int r = min; r <= max; r++) path.add(new int[]{r, sc});
}
// 构建段2(避免重复中转点)
if (cr == tr) {
int min = Math.min(cc, tc), max = Math.max(cc, tc);
int start = (cc == min) ? min + 1 : max - 1;
int end = (cc == min) ? max : min;
int step = (cc == min) ? 1 : -1;
for (int c = start; step > 0 ? c <= end : c >= end; c += step) {
path.add(new int[]{cr, c});
}
} else {
int min = Math.min(cr, tr), max = Math.max(cr, tr);
int start = (cr == min) ? min + 1 : max - 1;
int end = (cr == min) ? max : min;
int step = (cr == min) ? 1 : -1;
for (int r = start; step > 0 ? r <= end : r >= end; r += step) {
path.add(new int[]{r, cc});
}
}
return path;
}
}
return null;
}
/**
* A*搜索主函数:查找从起点到终点的最优路径
*/
public List<int[]> findPath(int sr, int sc, int tr, int tc) {
// 预判断快速路径
List<int[]> fast = fastCheck(sr, sc, tr, tc);
if (fast != null) return fast;
// A*搜索
PriorityQueue<Node> open = new PriorityQueue<>();
Set<String> closed = new HashSet<>();
Node start = new Node(sr, sc, -1, 0, 0, heuristic(sr, sc, -1, tr, tc), null);
open.add(start);
while (!open.isEmpty()) {
Node cur = open.poll();
String key = cur.row + "," + cur.col + "," + cur.dir + "," + cur.turns;
if (closed.contains(key)) continue;
closed.add(key);
// 到达目标
if (cur.row == tr && cur.col == tc) {
return reconstructPath(cur);
}
// 射线式扩展:沿每个方向直线延伸
for (int d = 0; d < 4; d++) {
int extraTurn = (cur.dir == -1 || cur.dir == d) ? 0 : 1;
int newTurns = cur.turns + extraTurn;
if (newTurns > MAX_TURNS) continue;
int nr = cur.row + dirs[d][0];
int nc = cur.col + dirs[d][1];
// 沿方向d直线延伸
while (isPassable(nr, nc, tr, tc)) {
int stepCost = 1 + extraTurn * TURN_PENALTY;
int newG = cur.g + stepCost;
int newH = heuristic(nr, nc, d, tr, tc);
Node next = new Node(nr, nc, d, newTurns, newG, newH, cur);
String nKey = nr + "," + nc + "," + d + "," + newTurns;
if (!closed.contains(nKey)) {
open.add(next);
}
// 到达终点则停止沿此方向延伸
if (nr == tr && nc == tc) break;
nr += dirs[d][0];
nc += dirs[d][1];
}
}
}
return null; // 无路径
}
private List<int[]> reconstructPath(Node node) {
List<int[]> path = new ArrayList<>();
while (node != null) {
path.add(new int[]{node.row, node.col});
node = node.parent;
}
Collections.reverse(path);
return path;
}
/**
* 查找地图上所有可消除的相同图案对子
*/
public List<int[]> findAllPairs() {
List<int[]> pairs = new ArrayList<>();
for (int r1 = 0; r1 < rows; r1++) {
for (int c1 = 0; c1 < cols; c1++) {
if (board[r1][c1] == 0) continue;
for (int r2 = r1; r2 < rows; r2++) {
for (int c2 = 0; c2 < cols; c2++) {
if (r1 == r2 && c2 <= c1) continue;
if (board[r1][c1] != board[r2][c2]) continue;
if (findPath(r1, c1, r2, c2) != null) {
pairs.add(new int[]{r1, c1, r2, c2});
}
}
}
}
}
return pairs;
}
/**
* 执行消除
*/
public boolean eliminate(int r1, int c1, int r2, int c2) {
if (board[r1][c1] == 0 || board[r1][c1] != board[r2][c2]) return false;
List<int[]> path = findPath(r1, c1, r2, c2);
if (path == null) return false;
System.out.println("Eliminating (" + r1 + "," + c1 + ") and (" + r2 + "," + c2 + ")");
System.out.print("Path: ");
for (int[] p : path) {
System.out.print("(" + p[0] + "," + p[1] + ") ");
}
System.out.println("(turns: " + countTurns(path) + ")");
board[r1][c1] = 0;
board[r2][c2] = 0;
return true;
}
private int countTurns(List<int[]> path) {
if (path.size() < 3) return 0;
int turns = 0;
int prevDir = -1;
for (int i = 1; i < path.size(); i++) {
int dr = path.get(i)[0] - path.get(i-1)[0];
int dc = path.get(i)[1] - path.get(i-1)[1];
int dir = (dr == -1) ? 0 : (dr == 1) ? 1 : (dc == -1) ? 2 : 3;
if (prevDir != -1 && prevDir != dir) turns++;
prevDir = dir;
}
return turns;
}
/**
* 打印地图
*/
public void printBoard() {
System.out.println(" 0 1 2 3 4 5 6 7");
for (int i = 0; i < rows; i++) {
System.out.print(i + " ");
for (int j = 0; j < cols; j++) {
if (board[i][j] == 0) System.out.print(". ");
else System.out.print(board[i][j] + " ");
}
System.out.println();
}
}
/**
* AI自动求解:贪心策略,每次消除找到的第一对
*/
public void solve() {
int steps = 0;
while (true) {
List<int[]> pairs = findAllPairs();
if (pairs.isEmpty()) break;
int[] p = pairs.get(0);
eliminate(p[0], p[1], p[2], p[3]);
steps++;
printBoard();
}
System.out.println("Solved in " + steps + " steps.");
}
public static void main(String[] args) {
// 8x8测试地图,数字表示图案类型
int[][] map = {
{1, 2, 3, 0, 0, 4, 5, 6},
{0, 0, 0, 0, 0, 0, 0, 0},
{0, 1, 0, 0, 0, 0, 5, 0},
{0, 0, 0, 0, 0, 0, 0, 0},
{0, 0, 0, 0, 0, 0, 0, 0},
{0, 3, 0, 0, 0, 0, 2, 0},
{0, 0, 0, 0, 0, 0, 0, 0},
{4, 0, 6, 0, 0, 0, 0, 0}
};
AStarLinkLink game = new AStarLinkLink(map);
System.out.println("Initial board:");
game.printBoard();
game.solve();
}
}
算法复杂度分析
| 模块 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 快速预判断(0/1转弯) | O(rows + cols) | O(path) |
| A*搜索单路径 | O(nodes × directions × maxLen) | O(nodes) |
| 全图配对检测 | O(patterns² × pathSearch) | O(pairs) |
| 单次消除决策 | O(pathSearch) | O(path) |
关键设计要点
- 射线式扩展:相比逐格扩展,沿方向直线延伸直至障碍,Open表节点数减少为O(turning_points)级别
- 转弯成本建模:将人类”喜欢直线路径”的直觉量化为A*成本函数,搜索结果更符合视觉预期
- 快速预判断层:优先检查0转弯和1转弯的简单情况,避免对简单路径启动完整的A*搜索
- 方向状态编码:节点包含进入方向信息,使转弯次数的计算精确且高效
总结
本文设计的转弯感知A*算法将连连看的路径搜索问题转化为带约束的最短路径问题。通过射线式扩展和转弯惩罚机制,在保证最优性的同时显著降低了搜索空间。读者可以进一步探索将”路径美观度”(如避免贴边、优先对称路径)融入启发函数,使AI的消除路径更接近人类高手的选择习惯。