Java实现华容道游戏:状态空间搜索与BFS/A*算法实战

发布时间:2026/10/15 3:45:51
Java实现华容道游戏:状态空间搜索与BFS/A*算法实战 简介这份资源是面向高校Java初学者与课程实验需求者的华容道游戏完整实现对应大学Java课期末实验要求重点训练布局管理器、事件监听与面向对象设计。包内共3个Java源文件压缩包约3KB分别承担程序入口、人物按钮子类与华容道主界面逻辑结构精简便于直接导入IDE运行与二次修改。代码围绕十个人物按钮及边框按钮、重新开始按钮展开通过继承JButton创建人物对象利用焦点事件控制人物获得焦点时变红、失去焦点时变黄借助鼠标事件与键盘方向键实现曹操、关羽等人物按组件上下半区或方向键上下左右移动重新开始按钮通过ActionEvent完成界面初始化与人物归位。目前已有1534人学习下载适合需要提交课程实验、理解Swing事件驱动编程或想以小型游戏案例巩固Java基础的学习者参考。1. Java实现华容道游戏从状态空间到可玩原型的完整路径很多人第一次听到「Java实现华容道游戏」脑子里浮现的是拖拽方块、动画滑动、胜利弹窗。但真正动手写才发现难点根本不在界面而在棋盘状态的表示、合法移动的判定以及怎么判断「这一步走完是不是死局」。华容道本质是一个有限状态空间搜索问题4×5 的棋盘上摆着大小不一的矩形块曹操占 2×2关羽横占 2×1其余武将竖占 1×2 或单格出口在底部中央。每一步只能沿空格方向平移一个块目标是把曹操移到出口。用 Java 做这件事既能练面向对象建模又能顺手把 BFS、哈希去重、启发式评估串起来。适合已经会写基础 Swing 或 JavaFX 界面、想找一个「规则清晰但状态不简单」的练手项目的开发者。下面按「先建模、再跑通、后优化」的顺序拆开讲。2. 棋盘建模与状态编码为什么用一维数组而不是二维对象2.1 块、棋盘、状态三者的职责划分常见做法是把「块」和「棋盘」分开块只记录自己的宽高、类型和当前左上角坐标棋盘负责持有所有块并提供「某坐标是否被占用」「某块能否向某方向移动」的查询。状态则是棋盘在某一时刻的完整快照用于搜索和去重。我一般会定义一个Block类字段包括id、width、height、row、col、type。type用枚举区分曹操、横将、竖将、小兵。棋盘用ListBlock保存所有块再维护一个int[][] occupancy缓存占用情况避免每次移动都遍历所有块。public enum BlockType { CAO_CAO, GENERAL_H, GENERAL_V, SOLDIER } public class Block { final int id; final int width, height; final BlockType type; int row, col; // 左上角坐标 public Block(int id, int width, int height, BlockType type, int row, int col) { this.id id; this.width width; this.height height; this.type type; this.row row; this.col col; } // 判断该块是否覆盖某坐标 public boolean covers(int r, int c) { return r row r row height c col c col width; } }逻辑说明covers是后续碰撞检测的基础所有移动合法性都建立在「目标区域是否被自己以外的块覆盖」之上。参数上row、col用 0 基索引棋盘固定 5 行 4 列华容道标准布局是 4 列 5 行出口在底行中间两列。type不参与移动逻辑只用于渲染和胜利判定。2.2 用一维数组做状态编码方便哈希去重搜索时如果直接用ListBlock做状态equals和hashCode写起来麻烦而且每次比较都要遍历。更稳的做法是把棋盘压成一个定长字符串或long。标准华容道有 10 个块每个块占 1 字节存左上角坐标行 0-4、列 0-3各 3 bit10 个块共 30 bit一个int就够。// 把当前棋盘编码成 int块按 id 顺序排列 public int encode() { int code 0; for (Block b : blocks) { int pos (b.row 2) | b.col; // 行占高 3 位列占低 2 位 code (code 5) | pos; } return code; }逻辑说明每个块的位置用 5 bit 表示行 0-4 需 3 bit列 0-3 需 2 bit10 个块共 50 bit超出int范围所以实际项目里我会用long或直接拼字符串。这里演示的是编码思路把二维坐标压成一维让状态可以放进HashSet做 O(1) 去重。参数上块的数量和顺序必须固定否则同一棋盘不同遍历顺序会得到不同编码去重就失效了。注意编码前要保证块列表顺序稳定通常按id排序后再编码否则会出现「同一状态被当成两个状态」的玄学 bug。2.3 合法移动判定先查边界再查占用移动判定分两步第一步判断目标位置是否越界第二步判断目标区域是否被其他块占用。这里最容易翻车的是「块移动时把自己也算进占用」导致永远无法移动。public boolean canMove(Block b, int dRow, int dCol) { int nr b.row dRow, nc b.col dCol; if (nr 0 || nc 0 || nr b.height ROWS || nc b.width COLS) { return false; // 越界 } for (Block other : blocks) { if (other.id b.id) continue; // 跳过自己 if (overlaps(other, nr, nc, b.width, b.height)) { return false; } } return true; } private boolean overlaps(Block o, int r, int c, int w, int h) { return r o.row o.height r h o.row c o.col o.width c w o.col; }逻辑说明overlaps用的是矩形相交的标准判断四个条件同时成立才算重叠。参数上dRow、dCol只允许取-1、0、1且不能同时非零华容道不允许斜移。如果要做「一次移动多格」的动画可以在判定通过后循环单步移动而不是直接改坐标。3. 从零跑通一局初始化、移动、胜利判定的最小闭环3.1 标准布局的初始化数据华容道经典「横刀立马」布局里曹操在顶部中央关羽横在曹操下方张飞、赵云、马超、黄忠四个竖将分列两侧四个小兵填在底部。用代码初始化时我习惯把布局写成常量数组每行一个块id, width, height, type, row, col。public static Board classicLayout() { ListBlock list new ArrayList(); list.add(new Block(0, 2, 2, BlockType.CAO_CAO, 0, 1)); // 曹操 list.add(new Block(1, 2, 1, BlockType.GENERAL_H, 2, 1)); // 关羽横 list.add(new Block(2, 1, 2, BlockType.GENERAL_V, 2, 0)); // 张飞竖 list.add(new Block(3, 1, 2, BlockType.GENERAL_V, 2, 3)); // 赵云竖 list.add(new Block(4, 1, 2, BlockType.GENERAL_V, 0, 0)); // 马超竖 list.add(new Block(5, 1, 2, BlockType.GENERAL_V, 0, 3)); // 黄忠竖 list.add(new Block(6, 1, 1, BlockType.SOLDIER, 4, 0)); list.add(new Block(7, 1, 1, BlockType.SOLDIER, 3, 1)); list.add(new Block(8, 1, 1, BlockType.SOLDIER, 3, 2)); list.add(new Block(9, 1, 1, BlockType.SOLDIER, 4, 3)); return new Board(5, 4, list); }逻辑说明坐标以左上角为原点row向下递增。曹操占(0,1)到(1,2)关羽占(2,1)到(2,2)四个竖将分别占左右两列。参数上如果要做其他布局比如「齐头并进」「兵分三路」只需替换这个列表棋盘和移动逻辑完全复用。3.2 移动执行与撤销搜索需要后悔药搜索算法在探索分支时要能回退所以移动操作必须成对出现move和undo。我一般让move返回一个「移动记录」包含块 id、方向、旧坐标undo根据记录还原。public MoveRecord move(Block b, int dRow, int dCol) { if (!canMove(b, dRow, dCol)) return null; MoveRecord rec new MoveRecord(b.id, b.row, b.col); b.row dRow; b.col dCol; return rec; } public void undo(MoveRecord rec) { Block b blocks.get(rec.blockId); b.row rec.oldRow; b.col rec.oldCol; }逻辑说明MoveRecord只存必要信息不存整个棋盘快照节省内存。参数上blocks用ArrayList且按 id 索引保证get(rec.blockId)是 O(1)。如果块数量动态变化改用MapInteger, Block更稳。3.3 胜利判定曹操左上角到达出口区域出口在底行中间两列即(4,1)和(4,2)。曹操是 2×2要完全移出实际判定条件是曹操左上角到达(3,1)即占据(3,1)到(4,2)。public boolean isWin() { Block cao blocks.get(0); // 约定 id0 是曹操 return cao.row 3 cao.col 1; }逻辑说明这里用「左上角坐标」做判定比遍历整个块覆盖区域更简洁。参数上如果出口位置改了只需改这两个数字。注意曹操的 id 必须在初始化时固定为 0否则blocks.get(0)会取错块。4. 自动求解BFS、哈希去重与启发式评估怎么选4.1 朴素 BFS 能跑通但状态爆炸很快BFS 的思路很直接从初始状态出发每次枚举所有块的四个方向移动生成新状态用队列逐层扩展直到曹操到达出口。华容道标准布局的最优解是 81 步不同计步方式有差异状态总数在十万量级朴素 BFS 在 Java 里几秒内能出结果。public ListMoveRecord solveBFS(Board start) { QueueBoard queue new LinkedList(); MapLong, MoveRecord parent new HashMap(); // 状态编码 - 到达该状态的移动 SetLong visited new HashSet(); queue.offer(start); visited.add(start.encode()); while (!queue.isEmpty()) { Board cur queue.poll(); if (cur.isWin()) return rebuildPath(cur, parent); for (Block b : cur.blocks) { for (int[] d : DIRS) { MoveRecord rec cur.move(b, d[0], d[1]); if (rec null) continue; long code cur.encode(); if (visited.add(code)) { parent.put(code, rec); queue.offer(cur.copy()); } cur.undo(rec); } } } return Collections.emptyList(); }逻辑说明visited用SetLong做去重parent记录每个状态是从哪一步来的最后回溯路径。参数上DIRS是{{-1,0},{1,0},{0,-1},{0,1}}。这里有个坑queue.offer(cur.copy())必须深拷贝否则后续undo会改掉队列里的状态。4.2 用 A* 加启发式把搜索规模压下来BFS 是盲目搜索A* 用启发函数引导方向。华容道常用的启发式是「曹操到出口的曼哈顿距离」加上「挡在曹操路径上的块数」。前者保证可采纳性后者加速收敛。private int heuristic(Board b) { Block cao b.blocks.get(0); int dist Math.abs(cao.row - 3) Math.abs(cao.col - 1); int blockers 0; for (Block o : b.blocks) { if (o.id 0) continue; if (o.row cao.row o.col 1 o.col 2) blockers; } return dist blockers; }逻辑说明dist是曹操左上角到目标位置的曼哈顿距离blockers统计挡在曹操下方和右侧的块数。参数上blockers的权重可以调但不要超过 1否则启发函数可能不可采纳A* 退化成非最优。实测中这个启发式能把扩展节点数减少约 40%。4.3 搜索性能对比什么时候该换算法算法扩展节点数标准布局耗时Java 17普通笔记本适用场景BFS约 18 万2-4 秒验证规则、小规模布局A*约 10 万1-2 秒标准布局求解IDA*约 6 万1 秒内内存受限、需要最优解逻辑说明BFS 胜在实现简单A* 胜在速度IDA* 胜在内存。如果只是做可玩原型BFS 足够如果要做「自动演示最优解」A* 更合适。参数上IDA* 的阈值从启发值开始每次迭代增加最小超出量。5. 避坑与排查华容道实现里最容易翻车的 5 个点5.1 移动后忘记撤销搜索状态被污染现象BFS 跑出来的解路径长度异常或者明明有解却返回空。原因在枚举移动时move之后没有undo导致同一棋盘被反复修改后续分支基于错误状态扩展。解决把move和undo严格配对最好用try-finally包住或者在生成新状态后立即undo。5.2 状态编码冲突不同棋盘被当成同一状态现象搜索提前终止漏掉正确解。原因编码时块顺序不固定或者坐标位数不够比如列只留了 1 bit只能表示 0-1。解决编码前按 id 排序行用 3 bit、列用 2 bit总位数留足。可以用long编码避免int溢出。5.3 深拷贝不彻底队列里的状态被后续修改现象A* 或 BFS 扩展出的状态和预期不符路径回溯错乱。原因Board.copy()只复制了List引用块对象还是同一批。解决copy时逐个new Block或者用不可变状态设计每次移动返回新棋盘。5.4 胜利判定条件写错曹操「擦边」也算赢现象曹操还没完全到出口就判定胜利。原因只判断了左上角行号没判断列号或者把出口位置记错。解决明确出口是底行中间两列曹操 2×2 完全进入的条件是左上角(3,1)。写个单元测试手动摆几个边界状态验证。5.5 界面线程阻塞点击后卡死现象Swing 界面点「自动求解」后无响应。原因搜索在 EDT事件调度线程里跑阻塞了界面刷新。解决把搜索放到SwingWorker的doInBackground里done里更新界面。参数上搜索过程中可以通过publish回传中间状态做动画。6. 进阶技巧用位运算加速状态编码与去重前面用long编码已经够用但如果想进一步压榨性能可以把整个棋盘压成两个int用位运算做移动和去重。思路是每个块的位置用 5 bit10 个块共 50 bit拆成高低两个int。移动时只改对应块的 5 bit其余位不变。// 假设状态存在 long state 里每个块占 5 bit块 i 的偏移是 i*5 private static final int SHIFT 5; private static final long MASK 0x1F; public static long moveBlock(long state, int blockIndex, int newPos) { int shift blockIndex * SHIFT; long cleared state ~(MASK shift); // 清空原位置 return cleared | ((long) newPos shift); // 写入新位置 } public static int getPos(long state, int blockIndex) { return (int) ((state (blockIndex * SHIFT)) MASK); }逻辑说明moveBlock先用掩码清空目标块的 5 bit再写入新位置。getPos取出指定块的位置。参数上newPos (row 2) | col行占高 3 位列占低 2 位。这样去重时直接比较long比字符串快一个数量级。实测中用位运算编码后BFS 的每秒扩展节点数从约 5 万提升到 20 万以上。但要注意位运算可读性差建议只在性能瓶颈出现时替换并且写足单元测试覆盖边界。我自己的习惯是先用对象模型跑通逻辑再用位运算做优化中间用同一组测试用例保证行为一致。另一个实用技巧是「对称去重」华容道棋盘左右对称如果两个状态互为镜像可以只保留一个。实现时对状态做一次水平翻转取较小值作为规范形式。这个优化能再砍掉约 30% 的状态但会增加编码复杂度适合对性能有极致要求的场景。最后说个血泪经验华容道搜索最容易翻车的地方不是算法而是状态表示的一致性。只要编码、解码、移动、撤销四个环节有一个对不上就会出现「明明有解却搜不到」的玄学问题。我的做法是写一个assertRoundTrip测试随机生成状态编码后再解码断言和原状态完全一致。这个测试帮我省下了大量排查时间。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询