Java实现华容道自动求解:BFS与A*搜索算法实战
简介这份资源是面向高校Java课程学习者与图形界面编程初学者的华容道游戏完整实现对应大学Java课期末实验要求帮助读者在实战中掌握Swing布局管理器与事件监听机制。压缩包共3个文件均为java源码整体约3KB分别承担程序入口、人物按钮子类定义与华容道主界面逻辑结构精简、便于直接导入IDE运行调试。内容围绕界面设计与事件驱动展开通过布局管理器搭建包含曹操、关羽等十个人物按钮及边框按钮、重新开始按钮的游戏界面自定义JButton子类表示人物借助焦点事件控制人物获得焦点时变红、失去焦点时变黄利用鼠标事件与键盘事件判断指针位置或方向键实现人物上下左右移动并通过动作事件完成重新开始时的按钮复位。目前已有1534人学习适合需要提交实验作业或练习事件监听、组件交互的读者参考可直接获得可运行的完整源码与清晰的类职责划分。1. 用 Java 写华容道一个被低估的搜索算法练兵场很多人第一次听到「Java 实现华容道游戏」脑子里浮现的是拖拽方块、画个 4×5 棋盘、判断一下曹操能不能挪出去。真动手写才发现界面只是壳真正吃时间的是「怎么让程序自己找到解法」。华容道本质是一个状态空间搜索问题棋盘布局是状态方块移动是边目标状态是曹操到达底部出口。它比八数码多了一层麻烦——方块有大小、有朝向、有占位约束合法移动的判定比空格交换复杂得多。这个标题适合两类人一类是想找一个「有界面、有算法、有数据结构」的完整练手项目另一类是想把 BFS、A*、启发式搜索从课本搬到能跑起来的代码里。我见过不少开发者卡在「能手动玩但自动求解跑不出来」这一步问题往往不在算法本身而在状态表示和去重策略。下面按「先立住模型再跑通搜索最后调优」的顺序拆开讲。2. 棋盘建模把 4×5 的格子变成可计算的状态2.1 为什么用一维数组而不是二维数组华容道棋盘固定 4 列 5 行共 20 个格子。常见做法是用int[5][4]二维数组但做状态哈希和比较时二维结构会带来额外遍历开销。我一般会压成一维int[20]索引index row * 4 col。每个格子存方块编号空格存 0。这样两个状态比较可以直接用Arrays.equals哈希可以用Arrays.hashCode省掉嵌套循环。方块本身需要一张静态表来描述编号、宽、高、初始位置。比如曹操是 2×2关羽是 2×1横张飞、赵云、马超、黄忠是 1×2竖四个兵是 1×1。这张表在整局游戏里不变属于元数据。// 方块定义id, 宽, 高, 初始行, 初始列 public class Block { int id; int width; int height; int row; int col; public Block(int id, int width, int height, int row, int col) { this.id id; this.width width; this.height height; this.row row; this.col col; } } // 标准华容道初始布局4列5行压成一维 // 0 表示空格1 表示曹操(2x2)2-5 表示竖将(1x2)6 表示横将(2x1)7-10 表示兵(1x1) public static int[] INITIAL_BOARD { 2, 1, 1, 3, 2, 1, 1, 3, 2, 6, 6, 3, 2, 7, 8, 3, 9, 0, 0, 10 };这段代码里Block只描述静态属性棋盘状态用int[]单独维护。逻辑说明把方块属性和棋盘状态分离搜索时只复制 20 长度的数组不复制方块对象内存和 GC 压力小很多。参数说明INITIAL_BOARD里的数字对应方块编号0 是空位。注意曹操占四个格子编号都是 1移动时要四个格子一起动。2.2 合法移动判定四个方向加边界检查一个方块能否向某方向移动取决于它移动后覆盖的格子是否越界、是否被其他方块占用。因为方块有大小不能只检查一个点。常见做法是先根据方块当前占位算出它的「外接矩形」再判断目标矩形是否在棋盘内且目标区域内非自身占用的格子全为 0。// 判断方块 block 能否向 direction 移动 // direction: 0上 1下 2左 3右 public boolean canMove(int[] board, Block block, int direction) { int newRow block.row (direction 0 ? -1 : direction 1 ? 1 : 0); int newCol block.col (direction 2 ? -1 : direction 3 ? 1 : 0); // 边界检查 if (newRow 0 || newCol 0) return false; if (newRow block.height 5 || newCol block.width 4) return false; // 占用检查目标区域内除了自己原来的格子其他必须是 0 for (int r newRow; r newRow block.height; r) { for (int c newCol; c newCol block.width; c) { int idx r * 4 c; // 如果该格是当前方块自己跳过 if (r block.row r block.row block.height c block.col c block.col block.width) { continue; } if (board[idx] ! 0) return false; } } return true; }逻辑说明先算目标位置再做边界和占用双重检查。参数说明direction用 0 到 3 表示四个方向避免字符串比较。注意这里没有直接改棋盘只做判定真正移动时再更新board和block.row/col。这种「判定与执行分离」的写法在搜索里很重要因为搜索需要频繁试错不能每试一步就破坏原状态。2.3 状态编码与去重为什么字符串拼接会拖慢搜索搜索算法最怕重复访问同一状态。华容道状态空间虽然不大但不加去重BFS 会迅速膨胀到内存扛不住。常见做法是把int[20]转成一个可哈希的 key。有人用Arrays.toString拼字符串简单但慢因为每次都要生成新字符串对象。我一般用long做编码每个格子 4 bit20 个格子正好 80 bit超过long范围所以改用两个long或者直接用String但配合HashSet。更稳的做法是用Arrays.hashCode配合自定义包装类或者把 20 个格子的值看成 20 位五进制数每个格子 0 到 10需要 4 bit。实际测试中用String做 key 在深度 30 以内还能接受再深就明显拖慢。如果追求性能可以用ByteBuffer把int[]转成byte[]再包成ByteBuffer做 keyByteBuffer的equals和hashCode是原生实现比字符串快。// 用 String 做状态 key 的简单写法适合快速验证 public String encode(int[] board) { StringBuilder sb new StringBuilder(20); for (int v : board) { sb.append((char) (A v)); // 0-A, 1-B ... } return sb.toString(); } // 更快的写法用 ByteBuffer 包装 public ByteBuffer encodeFast(int[] board) { ByteBuffer buf ByteBuffer.allocate(20); for (int v : board) { buf.put((byte) v); } buf.flip(); return buf; }逻辑说明encode把每个格子的值映射成字符生成 20 长度字符串可读性好调试时能直接看出棋盘。encodeFast用ByteBuffer省掉字符串构造适合搜索层数深、状态多的场景。参数说明ByteBuffer的equals比较的是剩余字节内容flip之后位置归零正好用于比较。注意ByteBuffer作为HashMapkey 时不要中途修改内容否则哈希会乱。3. 搜索算法选型BFS、A* 与启发式函数怎么定3.1 BFS 能跑通但为什么我最终换了 A*BFS 按层扩展保证找到最短步数解。华容道标准局最少 81 步不同布局有差异BFS 在 20 格状态空间里跑到 80 层队列峰值能到百万级。用 Java 的ArrayDeque加HashSet去重普通笔记本跑几分钟能出结果但内存占用高。如果只是验证「程序能自动求解」BFS 够用如果想做「实时提示下一步」BFS 的响应时间不可接受。A* 在 BFS 基础上加启发式函数优先扩展「看起来更接近目标」的状态。华容道常用启发式有两种一是「曹操到出口的曼哈顿距离」二是「阻挡曹操的方块数量」。前者计算快但引导性弱后者更准但计算稍慢。我一般用加权组合h 曹操距离 * 2 阻挡数 * 1。权重靠试没有理论最优跑几组对比就能找到适合当前布局的值。// A* 启发式曹操距离 阻挡数 public int heuristic(int[] board) { // 找曹操位置编号1 int caoRow -1, caoCol -1; for (int i 0; i 20; i) { if (board[i] 1) { caoRow i / 4; caoCol i % 4; break; } } // 曹操左上角到出口(行4,列1)的曼哈顿距离 int dist Math.abs(caoRow - 4) Math.abs(caoCol - 1); // 阻挡数曹操下方两格是否有其他方块 int blockCount 0; int belowLeft (caoRow 2) * 4 caoCol; int belowRight (caoRow 2) * 4 caoCol 1; if (belowLeft 20 board[belowLeft] ! 0) blockCount; if (belowRight 20 board[belowRight] ! 0) blockCount; return dist * 2 blockCount; }逻辑说明先定位曹操左上角算它到出口的曼哈顿距离再数曹操正下方两格有没有被占。参数说明dist * 2里的 2 是权重调大更偏向「先让曹操往下走」调小更偏向「先清障碍」。注意出口位置按标准布局是第 4 行第 1 列如果自定义布局要同步改。3.2 优先队列与状态节点设计A* 需要优先队列按f g h排序g是已走步数h是启发值。节点要存棋盘状态、父节点引用、已走步数。父节点引用用于最后回溯路径。Java 的PriorityQueue配合Comparator即可但要注意如果两个节点f相同PriorityQueue不保证顺序可能影响搜索效率。常见做法是加一个自增序号做次级排序保证先进先出。public class Node implements ComparableNode { int[] board; Node parent; int g; int h; int seq; // 自增序号用于同 f 值时稳定排序 public Node(int[] board, Node parent, int g, int h, int seq) { this.board board; this.parent parent; this.g g; this.h h; this.seq seq; } public int f() { return g h; } Override public int compareTo(Node other) { int cmp Integer.compare(this.f(), other.f()); if (cmp ! 0) return cmp; return Integer.compare(this.seq, other.seq); } }逻辑说明compareTo先比f再比seq避免同优先级时顺序抖动。参数说明g从 0 开始每扩展一层加 1h由启发式函数算出seq在生成节点时全局自增。注意board数组在节点构造时要深拷贝否则多个节点共享同一数组会互相污染。3.3 从目标状态反推双向 BFS 的取舍如果只关心「最少步数」而不关心路径双向 BFS 比单向快很多。从初始状态和目标状态同时扩展某一层相遇即停止。华容道目标状态是曹操在底部中间其他方块位置不固定所以目标状态不唯一双向 BFS 的「相遇判定」要改成「曹操位置满足且其他方块不阻挡出口」。实现复杂度比单向高但搜索深度减半内存峰值明显下降。我一般先用单向 A* 跑通确认启发式有效再考虑要不要上双向。如果只是做课程设计或练手单向 A* 足够如果要做「秒出解」的演示双向 BFS 加哈希去重更稳。注意双向 BFS 的两侧去重表要分开相遇时合并路径要小心方向反转。4. 避坑与排查华容道搜索里最容易翻车的五个点4.1 现象搜索跑了几十万状态还没结果内存先爆了原因去重用的HashSet存的是字符串 key每个 key 20 字符几十万条就是几十 MB加上节点对象和棋盘数组内存迅速上去。解决换ByteBuffer或自定义long编码减少 key 体积同时限制搜索深度超过阈值就报「未找到」而不是无限跑。另外检查启发式函数是否返回了负值或过大值导致优先队列排序异常。4.2 现象手动能走通的步骤程序判定为非法原因方块移动判定时只检查了目标区域没排除「自己原来的格子」。比如 2×2 的曹操向下移动目标区域和原区域有重叠重叠部分应该视为空。解决在占用检查里加「如果该格属于当前方块自身跳过」的逻辑如 2.2 节代码所示。另一个常见原因是边界计算用了block.width和block.height搞反横将和竖将的宽高不同容易混。4.3 现象A* 找到的解比 BFS 长原因启发式函数不满足「可采纳性」即h估计值大于实际剩余步数导致 A* 提前走向非最优路径。华容道里「阻挡数」启发式容易高估因为一个方块可能挡住曹操但一步就能移开。解决给启发式乘一个小于 1 的系数或者改用更保守的曼哈顿距离。如果对最优性要求高直接用 BFS 或双向 BFS。4.4 现象回溯路径时顺序反了或丢步原因节点存了父节点引用但回溯时从目标节点往初始节点走得到的路径是反的。解决回溯时用栈或Collections.reverse反转。另一个坑是节点生成时board数组没深拷贝多个节点共享同一数组回溯时看到的棋盘状态是最后修改过的。解决每次生成新节点都Arrays.copyOf。4.5 现象界面卡死点击无响应原因搜索在主线程UI 线程里跑几十万状态循环阻塞了事件分发。解决把搜索放到SwingWorker或独立线程通过publish/process更新界面。如果只是控制台版本至少加个进度打印避免「看起来没反应」。注意搜索线程和 UI 线程共享棋盘状态时要加锁或做快照。5. 进阶技巧用位运算压缩状态与剪枝5.1 位运算编码把 20 个格子塞进两个 long每个格子取值 0 到 10需要 4 bit。20 个格子共 80 bit一个long只有 64 bit所以用两个long前 10 个格子放low后 10 个放high。编码和解码用移位和掩码速度比ByteBuffer还快而且可以直接用long做HashSet的 key省掉对象包装。// 把 int[20] 编码成两个 long public static long[] encodeBits(int[] board) { long low 0, high 0; for (int i 0; i 10; i) { low | ((long) board[i]) (i * 4); } for (int i 10; i 20; i) { high | ((long) board[i]) ((i - 10) * 4); } return new long[]{low, high}; } // 解码从两个 long 还原 int[20] public static int[] decodeBits(long low, long high) { int[] board new int[20]; for (int i 0; i 10; i) { board[i] (int) ((low (i * 4)) 0xF); } for (int i 10; i 20; i) { board[i] (int) ((high ((i - 10) * 4)) 0xF); } return board; }逻辑说明low存前 10 格每格 4 bit共 40 bithigh存后 10 格同样 40 bit。参数说明0xF是 4 bit 掩码移位量i * 4和(i - 10) * 4要算准。注意格子值不能超过 15华容道最大编号 10安全。用这个编码后去重表可以用HashSetLong存low再用HashMapLong, Long存low - high的映射或者直接用一个long组合成low * 31 high做 key但要注意哈希冲突。5.2 剪枝哪些状态可以直接丢弃搜索过程中有些状态明显不可能比当前最优解更好可以提前丢弃。常见剪枝规则一是「曹操已经在上方且下方被堵死」的状态如果曹操下方两格都是大块且无法一步移开直接跳过二是「同一步数下已经访问过相同棋盘」的状态靠去重表处理三是「g 值已经超过当前已知最优解」的状态如果只要求找一个解而不要求最优可以设一个步数上限超过就剪。我一般会在 A* 里加一个bestG变量记录目前找到的最短步数扩展节点时如果g bestG就跳过。这样在搜索后期能省不少时间。注意bestG初始设为Integer.MAX_VALUE找到第一个解后更新。5.3 验证搜索正确性的三个小方法第一用 BFS 跑一遍记录最少步数再用 A* 跑对比步数是否一致。如果 A* 步数更少说明启发式有问题如果更多说明 A* 不是最优。第二手动走一遍程序输出的路径确认每一步都合法。第三随机生成一些简单布局比如只放曹操和两个兵看程序能否在几步内解出验证基本逻辑。我自己的习惯是每改一次启发式或剪枝先跑三个标准布局记录步数和耗时对比改动前后。如果步数变长或耗时暴涨先回滚再查原因。华容道搜索的玄学在于同一个启发式在不同布局上表现差异很大没有万能参数只能靠多跑多调。希望帮到你。本文还有配套的精品资源点击获取