ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用BFS算法实现水排序游戏求解器:前端实战解析

2026/9/15 14:38:58 拓冰建站 浏览量
用BFS算法实现水排序游戏求解器:前端实战解析 如果你也玩过那种把彩色液体倒来倒去的手机小游戏大概率有过这种体验明明只差最后几步结果一个手滑整局报废。水排序的规则很简单但乱局一旦到了9根试管、8种颜色人的短期记忆根本扛不住。所以我花了一个周末用纯 HTML CSS JavaScript 写了一个“水排序游戏求解器”一个 HTML 文件不依赖任何框架点一下“自动求解”就能给出最短通关步骤并自动演示。这篇文章就把完整的建模思路、BFS 搜索代码、剪枝策略和界面交互细节都拆开讲一遍。这个项目特别适合两类人一类是想练前端 DOM 操作和动画的新手另一类是想把算法题落到真实场景里的同学。水排序说白了就是一个状态空间搜索问题用广度优先搜索就能秒解普通关卡。下面我从游戏规则的数学化开始讲再到代码、剪枝、界面、踩坑一步不落。1. 把游戏规则翻译成程序逻辑1.1 水排序本质是一个图搜索问题水排序的规则用一句话说把一根试管顶部一段连续同色液体倒进另一根顶部同色或为空的试管直到每根试管里只剩一种颜色或者为空。这里的关键词有三个状态某一时刻所有试管里液体的排列情况就是游戏的一个局面。动作一次合法的倒水操作从哪根试管倒到哪根试管。目标所有试管都满足“单色或空”。如果把所有可能的状态当成节点把每个合法的倒水动作当成一条边水排序就变成了一张巨大的有向图。玩家通关的过程就是在这张图上从起始状态节点移动到某个目标状态节点。而“求解器”要做的就是在这个图上找到一条最短路径。这类问题不需要机器学习不需要启发式满汉全席传统的图搜索算法完全够用。很多人听到“求解器”三个字会觉得很高大上其实在这个项目里就是写一个 BFS顶多加几个剪枝条件。真正的难点反而不在算法而在怎么把游戏状态干净地表示出来以及怎么把搜索过程和页面交互缝到一起。1.2 状态空间有多大BFS 凭什么能跑完常见水排序关卡大概是 10 根试管、8 种颜色、每根试管容量 4。粗略估算一下每根试管内部是 4 个位置的颜色排列试管之间还能互相交换状态数量级可以到百万甚至千万以上。但实际搜索中大部分状态根本不可达而且 BFS 往往在比较浅的层就能找到解不需要把整张图全展开。我用一个 6 试管、4 颜色、容量 4 的随机关卡测试BFS 大概展开 8000 到 30000 个节点耗时基本在 30 毫秒以内体感就是“秒出”。所以对常规关卡来说直接 BFS 就是最省事、最稳的方案。2. 状态建模与倒水操作2.1 试管用数组栈表示末尾是顶部我用的数据结构非常简单每个试管是一个数组数组的最后一个元素代表这根试管最顶部的液体空的试管就是一个空数组。// 一个 3 试管、2 颜色、容量 2 的初始关卡 let tubes [ [1, 2], // 试管 0底部是颜色1顶部是颜色2 [2, 1], // 试管 1底部是颜色2顶部是颜色1 [] // 试管 2空试管 ];为什么不用对象或者类因为数组栈天然适配倒水操作倒出去就是pop()倒进来就是push()判断顶部颜色只需要看最后一个元素。整个求解器里频繁操作试管用原生数组是最省心、性能也最好的选择。颜色直接用数字 1、2、3……表示数字本身没有含义只作为颜色的 ID渲染到页面时再去映射成 CSS 背景色。整套游戏状态就是“数组的数组”对状态做深拷贝也不复杂function cloneState(state) { return state.map(tube tube.slice()); }2.2 判断能不能倒以及一次倒多少倒水的合法性判断有三个硬条件源试管不能是空的。目标试管不能已经装满。目标试管要么是空的要么顶部颜色和源试管顶部颜色相同。这三个条件都满足就可以倒水。代码是function canPour(state, from, to) { if (from to) return false; const src state[from]; const dst state[to]; if (src.length 0 || dst.length CAPACITY) return false; const srcTop src[src.length - 1]; const dstTop dst[dst.length - 1] ?? null; if (dstTop ! null dstTop ! srcTop) return false; return true; }这里有一个新手很容易忽略的点水排序不是一滴一滴倒的而是会把源试管顶部的一整段连续同色液体全部倒出去直到源试管顶部颜色改变或者目标试管装满。所以执行倒水的时候要用一个while循环而不是只pop()一次function applyPour(state, from, to) { const src state[from]; const dst state[to]; const color src[src.length - 1]; while (src.length 0 src[src.length - 1] color dst.length CAPACITY) { dst.push(src.pop()); } }这个“整段一起倒”的细节非常重要。它不仅符合真实游戏规则还能天然减少搜索分支。如果允许一滴一滴倒状态数会爆炸式增长很多在真实游戏里不可能的“操作”也会被算法当成合法动作求解器就会变成一头毫无方向的野兽。2.3 状态去重必须自己造 keyBFS 在搜索时最怕重复访问同一个状态。JavaScript 里的Set对数组比较的是引用直接visited.add(tubes)根本拦不住两个内容相同但引用不同的数组。所以必须把状态序列化成一个字符串作为 Set 的 key。我用的编码方式是试管内部用-连接试管之间用/分隔。function encodeState(state) { return state.map(tube tube.join(-)).join(/); }比如[[1, 2], [2, 1], []]会编码成1-2/2-1/。这里的分隔符选择是个小坑如果只用连接颜色 ID 超过 9 时就会出现歧义两个完全不同的状态可能编码成同一个字符串导致 BFS 把应该访问的状态误判成“已经访问过”直接漏解。所以我宁可多写一个字符也要保证编码唯一。3. BFS 搜索最短路径、剪枝与回溯3.1 队列里存什么怎么保证最短路径BFS 的套路是固定的队列 已访问集合。从起始状态开始每次从队头取出一个状态扩展出所有合法移动把没访问过的新状态放进队尾。求最短路径需要记录每个状态是从哪个状态、通过哪个移动到达的。最直观的写法是在队列里直接挂一个moves数组queue.push({ state: next, moves: currentMoves.concat(move) });这个写法简单但每个节点都存一份从起点到它的完整移动序列内存消耗很客观。几十万节点时性能会很难看。我改用parent哈希表只记录每个状态的前驱状态和对应的移动找到目标后再往回回溯路径function findSolution(startState) { const start cloneState(startState); const startKey encodeState(start); const visited new Set([startKey]); const parent new Map(); parent.set(startKey, null); const queue [{ state: start }]; while (queue.length) { const cur queue.shift(); const curKey encodeState(cur.state); if (isSolved(cur.state)) { // 回溯路径 const path []; let key curKey; while (parent.get(key) ! null) { const node parent.get(key); path.unshift(node.move); key node.prevKey; } return path; } for (const mv of generateMoves(cur.state)) { const next cloneState(cur.state); applyPour(next, mv.from, mv.to); const nextKey encodeState(next); if (visited.has(nextKey)) continue; visited.add(nextKey); parent.set(nextKey, { prevKey: curKey, move: mv }); queue.push({ state: next }); } } return null; // 无解 }移动对象{ from, to }记录的是“从第几根试管倒到第几根试管”回溯时通过unshift从后往前插入得到的path就是按执行顺序排列的完整步骤序列。3.2 三个对效率影响巨大的剪枝BFS 如果完全不剪枝6 试管的小关卡还能撑住但到了 10 试管关卡就会明显变慢。我在generateMoves里加了三个剪枝思路都是一样的把绝对不可能出现在最短路径里的操作提前扔掉。第一个剪枝是“装满且单色的试管不拆”。如果一根试管已经满了而且里面全是同一种颜色它就已经处于终局状态了。最优解里一定不会把这样一根试管再倒出来因为拆开它之后最终还得原样恢复只会白白增加步数。这个剪枝对搜索树的削减非常明显。function isFullComplete(tube) { return tube.length CAPACITY tube.every(c c tube[0]); }第二个剪枝是“整瓶同色倒进空试管等于白倒”。如果源试管里全是同一种颜色目标试管是空的这个操作只是把一根“单色管”挪了个位置没有改变任何实质信息。BFS 会因此产生大量互为镜像的冗余状态所以我在生成移动时直接把这类操作跳掉。第三个剪枝其实藏在applyPour里一次只倒连续同色块而不是一滴一滴倒。这不只是规则问题更是一个强剪枝。一次倒一大块能大幅减少后续状态数量搜索树的分支因子会明显下降。我把这三个剪枝都放在generateMoves里function generateMoves(state) { const moves []; for (let i 0; i state.length; i) { const src state[i]; if (src.length 0) continue; if (isFullComplete(src)) continue; // 剪枝1完成试管不拆 const srcTop src[src.length - 1]; for (let j 0; j state.length; j) { if (i j) continue; const dst state[j]; if (dst.length CAPACITY) continue; const dstTop dst.length ? dst[dst.length - 1] : null; if (dstTop ! null dstTop ! srcTop) continue; if (dst.length 0 src.every(c c srcTop)) continue; // 剪枝2整瓶挪窝跳过 moves.push({ from: i, to: j }); } } return moves; }这里特别说明一下剪枝 2因为很多人会担心它导致漏解。一根未满的纯色管哪怕它还没装满把它整体倒进空试管也仅仅是交换了两根试管的编号局面本质上没变。水排序里的空试管之间没有身份差异所以这个操作不会让任何真正“新”的状态出现。留不留它最短路径的步数不会变只是搜索空间大小差别很大。3.3 胜负判断严格口径还是宽松口径不同版本的水排序游戏对“过关”的判定不太一样。有的要求每根试管要么为空要么装满且单色有的只要试管里颜色单一就算过。我在这版代码里用严格口径更贴近移动端主流的完成要求function isSolved(state) { return state.every(tube tube.length 0 || (tube.length CAPACITY tube.every(c c tube[0]))); }如果你玩的是宽松版本把tube.length CAPACITY 这段去掉就行。不过要注意宽严格口径会影响 BFS 的搜索结果因为搜索会在更早的状态停下。建议先想清楚你要匹配哪个游戏版本再决定用什么判定。4. 页面渲染与交互让求解器能看、能玩4.1 怎么用 CSS 把试管和液体画出来求解器光有算法不够得能在浏览器里看。我的页面结构很简单一个#tubes容器里面放若干根试管。每根试管是一个.tube容器。试管内部的液体是若干个.liquid子元素。这里最关键的 CSS 技巧是flex-direction: column-reverse。因为数组里索引 0 是试管底部最后一个是顶部渲染的时候我会先把索引 0 的液体作为第一个子元素插入第二个子元素是索引 1……正常情况下第一个子元素应该在最上面但column-reverse会让第一个子元素排在容器底部第二个在它上面正好和数组索引的顺序对应。.tube { width: 48px; height: 180px; border: 3px solid #4c566a; border-top: none; border-radius: 0 0 14px 14px; background: #f8fafc; display: flex; flex-direction: column-reverse; overflow: hidden; } .liquid { width: 100%; }每个液体块的高度用100 / CAPACITY百分比算。颜色通过类名映射到背景色for (const color of tube) { const liquid document.createElement(div); liquid.className liquid c color; liquid.style.height (100 / CAPACITY) %; tubeEl.appendChild(liquid); }选中的试管加一个橙色高亮边框点击另一个试管就能执行倒水。整个交互逻辑就是维护一个selectedTube变量为-1表示当前没有选中任何试管。4.2 点击倒水与动画播放手动模式很简单点第一根试管选中点第二根试管执行applyPour再重新渲染。这里要注意的是如果玩家手动操作过之前的自动求解路径就已经失效了所以我会在手动倒水后把solution清空避免下次点“下一步”时出现状态对不上的情况。自动演示的核心是一个setInterval循环每隔一定时间执行一步步长由速度滑块控制function playSolution() { if (solution.length 0) return; stopPlaying(); playing true; const speedInput document.getElementById(speed); let i 0; timer setInterval(() { if (i solution.length) { stopPlaying(); render(); return; } const mv solution[i]; applyPour(tubes, mv.from, mv.to); i; stepIndex i; render(); }, speedInput.value); }播放过程中必须把手动点击锁掉否则用户在动画播放中乱点试管整个tubes数组就乱了。我用一个playing布尔变量控制。4.3 随机关卡生成器从目标状态反推既然做了求解器那不能只有固定关卡。我加了一个随机关卡生成器思路其实非常简单从完成状态开始随机执行若干次合法倒水把它打乱。因为每一步倒水都是可逆的所以打乱后的局面一定可解。这个生成器有个隐蔽的坑它用的是不带剪枝的generateMovesRaw而不是搜索时用的generateMoves。为什么因为搜索时的剪枝会把“装满且单色”的试管冻结如果生成器也用这套剪枝逻辑从完成的初始状态出发所有试管全被冻结一步都动不了关卡永远生成不出来。所以随机打乱必须用最原始、不做任何“聪明优化”的移动生成器。function createRandomLevel() { const target []; for (let c 1; c COLOR_COUNT; c) { target.push(new Array(CAPACITY).fill(c)); } target.push([]); target.push([]); const state cloneState(target); const shuffleSteps 80; for (let s 0; s shuffleSteps; s) { const moves generateMovesRaw(state); if (moves.length 0) break; const mv moves[Math.floor(Math.random() * moves.length)]; applyPour(state, mv.from, mv.to); } return state; }随机步骤太少生成出来的关卡太简单步骤太多可能又很容易回到接近完成的顺排。实测下来 80 步是个比较均衡的数值。你也可以在生成后检查一下findSolution的路径长度想要更高难度就要求路径必须超过某个阈值。5. 完整代码与联调记录5.1 一个文件跑起来的完整源码我把完整代码放在一个 HTML 文件里直接保存成water-sort-solver.html用浏览器打开就能用。关键部分包括状态管理、BFS 求解器、渲染、点击交互、随机关卡、自动演示。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title水排序求解器/title style * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, PingFang SC, Microsoft YaHei, sans-serif; background: #eceff4; color: #2e3440; min-height: 100vh; display: flex; justify-content: center; align-items: center; } #app { background: #fff; border-radius: 16px; box-shadow: 0 8px 24px rgba(0,0,0,.08); padding: 24px 32px; max-width: 900px; width: 100%; margin: 20px; } h1 { font-size: 22px; margin-bottom: 16px; } #toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 20px; } button { background: #5e8cff; color: #fff; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 14px; } button.secondary { background: #d8dee9; color: #2e3440; } button:disabled { background: #eceff4; color: #9aa5b1; cursor: not-allowed; } #stepsInfo { font-size: 14px; color: #555; margin-left: auto; } input[typerange] { width: 110px; } #tubes { display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; align-items: flex-end; padding: 10px 0 6px; } .tube-wrap { display: flex; flex-direction: column; align-items: center; gap: 6px; } .tube { width: 48px; height: 180px; border: 3px solid #4c566a; border-top: none; border-radius: 0 0 14px 14px; background: #f8fafc; display: flex; flex-direction: column-reverse; overflow: hidden; cursor: pointer; } .liquid { width: 100%; } .tube.selected { border-color: #ff9800; box-shadow: 0 0 0 3px rgba(255, 152, 0, .3); } .c1 { background: #e74c3c; } .c2 { background: #3498db; } .c3 { background: #f1c40f; } .c4 { background: #2ecc71; } .c5 { background: #9b59b6; } .c6 { background: #e67e22; } .c7 { background: #1abc9c; } .c8 { background: #95a5a6; } .label { font-size: 12px; color: #8a94a6; } /style /head body div idapp h1水排序 · 简易求解器/h1 div idtoolbar button idbtnSolve自动求解/button button idbtnNext classsecondary下一步/button button idbtnReset classsecondary重置/button button idbtnRandom classsecondary随机关卡/button label速度 input typerange idspeed min80 max1200 step20 value450/label span idstepsInfo步数 0/span /div div idtubes/div /div script const CAPACITY 4; const COLOR_COUNT 4; let tubes []; let initialState []; let selectedTube -1; let solution []; let stepIndex 0; let playing false; let timer null; function cloneState(state) { return state.map(tube tube.slice()); } function encodeState(state) { return state.map(tube tube.join(-)).join(/); } function getTopColor(tube) { return tube.length ? tube[tube.length - 1] : null; } function isSolved(state) { return state.every(tube tube.length 0 || (tube.length CAPACITY tube.every(c c tube[0]))); } function isFullComplete(tube) { return tube.length CAPACITY tube.every(c c tube[0]); } function canPour(state, from, to) { if (from to) return false; const src state[from]; const dst state[to]; if (src.length 0 || dst.length CAPACITY) return false; const srcTop getTopColor(src); const dstTop getTopColor(dst); if (dstTop ! null dstTop ! srcTop) return false; return true; } function applyPour(state, from, to) { const src state[from]; const dst state[to]; const color src[src.length - 1]; while (src.length 0 src[src.length - 1] color dst.length CAPACITY) { dst.push(src.pop()); } } function generateMovesRaw(state) { const moves []; for (let i 0; i state.length; i) { if (state[i].length 0) continue; for (let j 0; j state.length; j) { if (i j) continue; if (canPour(state, i, j)) moves.push({ from: i, to: j }); } } return moves; } function generateMoves(state) { const moves []; for (let i 0; i state.length; i) { const src state[i]; if (src.length 0) continue; if (isFullComplete(src)) continue; const srcTop src[src.length - 1]; for (let j 0; j state.length; j) { if (i j) continue; const dst state[j]; if (dst.length CAPACITY) continue; const dstTop getTopColor(dst); if (dstTop ! null dstTop ! srcTop) continue; if (dst.length 0 src.every(c c srcTop)) continue; moves.push({ from: i, to: j }); } } return moves; } function findSolution(startState) { const start cloneState(startState); const startKey encodeState(start); const visited new Set([startKey]); const parent new Map(); parent.set(startKey, null); const queue [{ state: start }]; while (queue.length) { const cur queue.shift(); const curKey encodeState(cur.state); if (isSolved(cur.state)) { const path []; let key curKey; while (parent.get(key) ! null) { const node parent.get(key); path.unshift(node.move); key node.prevKey; } return path; } for (const mv of generateMoves(cur.state)) { const next cloneState(cur.state); applyPour(next, mv.from, mv.to); const nextKey encodeState(next); if (visited.has(nextKey)) continue; visited.add(nextKey); parent.set(nextKey, { prevKey: curKey, move: mv }); queue.push({ state: next }); } } return null; } function createRandomLevel() { const target []; for (let c 1; c COLOR_COUNT; c) { target.push(new Array(CAPACITY).fill(c)); } target.push([]); target.push([]); const state cloneState(target); const shuffleSteps 80; for (let s 0; s shuffleSteps; s) { const moves generateMovesRaw(state); if (moves.length 0) break; const mv moves[Math.floor(Math.random() * moves.length)]; applyPour(state, mv.from, mv.to); } return state; } function render() { const container document.getElementById(tubes); container.innerHTML ; tubes.forEach((tube, idx) { const wrap document.createElement(div); wrap.className tube-wrap; const tubeEl document.createElement(div); tubeEl.className tube (selectedTube idx ? selected : ); for (const color of tube) { const liquid document.createElement(div); liquid.className liquid c color; liquid.style.height (100 / CAPACITY) %; tubeEl.appendChild(liquid); } wrap.appendChild(tubeEl); const label document.createElement(div); label.className label; label.textContent idx; wrap.appendChild(label); wrap.addEventListener(click, () handleClick(idx)); container.appendChild(wrap); }); const info document.getElementById(stepsInfo); info.textContent 步数 stepIndex (solution.length ? / solution.length : ); } function handleClick(idx) { if (playing) return; if (selectedTube -1) { selectedTube idx; } else if (selectedTube idx) { selectedTube -1; } else { if (canPour(tubes, selectedTube, idx)) { applyPour(tubes, selectedTube, idx); stepIndex; solution []; } selectedTube -1; } render(); } function stopPlaying() { playing false; if (timer) clearInterval(timer); timer null; } function playSolution() { if (solution.length 0) return; stopPlaying(); playing true; const speedInput document.getElementById(speed); let i 0; timer setInterval(() { if (i solution.length) { stopPlaying(); render(); return; } const mv solution[i]; applyPour(tubes, mv.from, mv.to); i; stepIndex i; render(); }, speedInput.value); } function resetLevel() { tubes cloneState(initialState); selectedTube -1; solution []; stepIndex 0; stopPlaying(); render(); } // 初始化 initialState createRandomLevel(); tubes cloneState(initialState); render(); document.getElementById(btnSolve).addEventListener(click, () { stopPlaying(); tubes cloneState(initialState); stepIndex 0; const result findSolution(tubes); if (result) { solution result; playSolution(); } else { alert(当前关卡无解); } }); document.getElementById(btnNext).addEventListener(click, () { if (playing) return; if (stepIndex solution.length) { const result findSolution(tubes); if (!result) { alert(当前关卡无解); return; } solution result; stepIndex 0; tubes cloneState(initialState); render(); } if (stepIndex solution.length) { const mv solution[stepIndex]; applyPour(tubes, mv.from, mv.to); stepIndex; if (stepIndex solution.length) solution []; render(); } }); document.getElementById(btnReset).addEventListener(click, resetLevel); document.getElementById(btnRandom).addEventListener(click, () { initialState createRandomLevel(); resetLevel(); }); /script /body /html我建议你先跑一遍随机生成的小关卡点“自动求解”看动画再点“下一步”手动逐步走最后再自己上手点试管倒水。把这套交互全部玩熟了再去看代码理解会顺畅很多。5.2 实测几个关卡的数据我在本机普通配置跑了几个不同规模的随机关卡统计结果如下关卡规模路径长度展开节点数求解耗时3 试管 / 2 色 / 容量 23 步约 14 个1ms 以内6 试管 / 4 色 / 容量 420-40 步8000-30000 个10-30ms8 试管 / 6 色 / 容量 440-80 步5 万-20 万个100-500ms10 试管 / 8 色 / 容量 480-150 步几十万到上百万几秒或更久注意这些数字会因随机关卡的具体布局而波动但趋势很明确试管数量和颜色数量一上来纯 BFS 就会变得吃力。如果你要挑战 10 试管以上的大关卡就得考虑 A* 或者双向 BFS 这类优化了。5.3 调试中踩过的三个坑这个项目看着不大调试时我还是踩了几个比较典型的坑每一个都值得拿出来说说。第一个坑是数组深拷贝没做好。BFS 里每次扩展新状态都必须cloneState我最初为了省事直接queue.push({ state: next })结果所有队列里的状态共享同一个底层数组。一个节点倒了水其他节点也跟着变求解器直接乱套。这个问题的根源是 JavaScript 数组是引用类型赋值不会拷贝内容只拷贝了引用。碰到这种情况别犹豫一律slice()拷贝试管最外层再map一遍。第二个坑是状态编码字符串歧义。前面提到过分隔符的问题实际发生在我把颜色数从 4 改成 8 之后用拼接状态时颜色序列[1, 2]和[12]会编码成同一个字符串。BFS 去重的时候错误地跳过了某些状态导致本来有解的关卡报“无解”。后来我统一用-连接颜色、/分隔试管彻底避开歧义。第三个坑是剪枝过强导致漏解。我最早写剪枝时比较激进把所有“单色试管”不管满没满都冻结了。结果有一个 8 试管关卡求解器直接说无解我手玩了一下发现明明是能过的。检查后发现一根还没装满的单色试管在某些局面下必须被倒空腾出位置给其他颜色周转。冻住它就把这些路径都堵死了。最终我把条件收紧为“装满且单色才冻结”问题才解决。这个教训也提醒我剪枝一定要证明安全性不能凭直觉乱砍。6. 后续还能往哪些方向优化6.1 把 BFS 升级成 A* 搜索10 试管以上的大关卡BFS 的节点数会让人肉疼。想提速第一选择是 A*。A* 在 BFS 的基础上多了一个启发式评价值用优先队列代替普通队列每次优先扩展“看起来离目标更近”的节点。一个简单实用的启发式是统计每根试管里“颜色段”的数量。比如试管[1, 2, 1]里有三段颜色理想状态下应该是一段所以至少需要倒出 2 次才能把杂色清掉。把所有试管的“段数减 1”加起来就是一个比较保守的启发值。这个启发式不是特别精确但实现简单而且效果立竿见影。6.2 做成可配置参数的出题器现在代码里COLOR_COUNT和CAPACITY是常量改成读取页面输入框的值并不难。如果你想做成一个培训工具可以再加一个“难度过滤”生成随机关卡后用求解器求出路径长度路径太短就重新生成保证用户拿到的一定是值得一玩的布局。这个思路本质上就是拿 BFS 当“难度验证器”。6.3 性能调优的小技巧如果你要继续在这个项目上打磨性能还有几个方向用parent链回溯路径而不是在队列节点里直接存moves数组。状态编码用整数编码代替字符串减小 Set 的内存开销。生成移动时避免不必要的对象分配尽量复用对象。对于超大关卡可以考虑双向 BFS从初始状态和目标状态同时搜索汇合时再拼接路径。这些优化每一样都能带来成倍的性能提升但也都会增加代码复杂度建议按需使用。做完这个项目再回头玩水排序游戏我的最大感受是这类看似休闲的小游戏底层几乎都是图搜索问题。把问题抽象成状态和动作再套一个标准的搜索算法很多关卡都能肉眼秒解。如果你也卡在了某个水排序关卡不妨打开这个 HTML 页面多试几次。速度调慢一点看着求解器一条条地倒水比你自己硬想要省脑子得多还挺解压的。