简介:这份资源是面向高校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 bit,20 个格子正好 80 bit,超过long范围,所以改用两个long或者直接用String但配合HashSet。
更稳的做法是用Arrays.hashCode配合自定义包装类,或者把 20 个格子的值看成 20 位五进制数(每个格子 0 到 10,需要 4 bit)。实际测试中,用String做 key 在深度 30 以内还能接受,再深就明显拖慢。如果追求性能,可以用ByteBuffer把int[]转成byte[]再包成ByteBuffer做 key,ByteBuffer的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 Comparable<Node> { 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 开始,每扩展一层加 1;h由启发式函数算出;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 bit;high存后 10 格,同样 40 bit。参数说明:0xF是 4 bit 掩码,移位量i * 4和(i - 10) * 4要算准。注意格子值不能超过 15,华容道最大编号 10,安全。用这个编码后,去重表可以用HashSet<Long>存low,再用HashMap<Long, 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* 不是最优。第二,手动走一遍程序输出的路径,确认每一步都合法。第三,随机生成一些简单布局(比如只放曹操和两个兵),看程序能否在几步内解出,验证基本逻辑。
我自己的习惯是:每改一次启发式或剪枝,先跑三个标准布局,记录步数和耗时,对比改动前后。如果步数变长或耗时暴涨,先回滚再查原因。华容道搜索的玄学在于,同一个启发式在不同布局上表现差异很大,没有万能参数,只能靠多跑多调。
希望帮到你。
本文还有配套的精品资源,点击获取