如果你也玩过那种把彩色液体倒来倒去的手机小游戏,大概率有过这种体验:明明只差最后几步,结果一个手滑,整局报废。水排序的规则很简单,但乱局一旦到了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 状态去重:必须自己造 key
BFS 在搜索时最怕重复访问同一个状态。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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.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[type=range] { 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 id="app"> <h1>水排序 · 简易求解器</h1> <div id="toolbar"> <button id="btnSolve">自动求解</button> <button id="btnNext" class="secondary">下一步</button> <button id="btnReset" class="secondary">重置</button> <button id="btnRandom" class="secondary">随机关卡</button> <label>速度 <input type="range" id="speed" min="80" max="1200" step="20" value="450"></label> <span id="stepsInfo">步数 0</span> </div> <div id="tubes"></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 色 / 容量 2 | 3 步 | 约 14 个 | 1ms 以内 |
| 6 试管 / 4 色 / 容量 4 | 20-40 步 | 8000-30000 个 | 10-30ms |
| 8 试管 / 6 色 / 容量 4 | 40-80 步 | 5 万-20 万个 | 100-500ms |
| 10 试管 / 8 色 / 容量 4 | 80-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 页面,多试几次。速度调慢一点,看着求解器一条条地倒水,比你自己硬想要省脑子得多,还挺解压的。