)
簡介這是一款基于JavaScript實現(xiàn)的水排序拼圖益智游戲源碼版本v0.9.8面向前端初學者、小游戲開發(fā)者及想練習DOM交互與動畫邏輯的編程愛好者。玩家通過點擊玻璃杯將彩色液體互相傾倒直到每杯只剩單一顏色游戲內(nèi)置撤銷與重新開始功能避免卡關。資源包共10個文件壓縮后約4.77MB包含2個js腳本負責核心邏輯與交互、1個html入口頁面、1個css樣式表以及svg、png、jpg等圖片素材和1個psd設計源文件另附readme說明文檔結(jié)構(gòu)清晰便于二次修改。目前已有1582人學習下載。借助這份完整可運行的小游戲項目讀者可以快速理解狀態(tài)管理、倒水判定與動畫過渡的實現(xiàn)思路并基于現(xiàn)有素材替換配色或擴展關卡適合作為前端練手或課程設計參考。1. 水排序謎題從一瓶渾水到全色分層的求解器設計第一次寫 water_sort_puzzle 的求解器我盯著屏幕上一堆花花綠綠的試管腦子里只有一個念頭這不就是個帶容量約束的圖搜索問題嗎結(jié)果真動手才發(fā)現(xiàn)狀態(tài)空間膨脹得比想象中快得多。水排序謎題Water Sort Puzzle的規(guī)則很簡單若干試管每管裝若干層不同顏色的水每次只能把某管頂部的連續(xù)同色水倒進另一管的頂部且目標管要么為空要么頂部顏色相同且剩余容量足夠。目標是把所有顏色分層歸位。聽起來像益智小游戲但把它抽象成狀態(tài)搜索問題后涉及狀態(tài)編碼、啟發(fā)式設計、剪枝策略和求解效率是一個非常適合練手搜索算法的實戰(zhàn)題目。這篇文章面向想自己寫求解器的開發(fā)者從狀態(tài)建模一路講到性能調(diào)優(yōu)中間會給出可直接復現(xiàn)的 Python 實現(xiàn)。2. 狀態(tài)建模與搜索框架為什么 BFS 會爆內(nèi)存2.1 試管狀態(tài)的元組編碼方案水排序的狀態(tài)本質(zhì)是一個「試管列表」每根試管是一個顏色序列底部在前、頂部在后。最直接的編碼方式是用嵌套元組外層元組表示所有試管內(nèi)層元組表示每根試管從底到頂?shù)念伾蛄?。比如三根試管、兩種顏色的初始狀態(tài)可以寫成((0, 1), (1, 0), ())其中()表示空管。為什么用元組而不是列表因為元組可哈希能直接塞進set做去重也能作為字典的鍵做距離記錄。如果用列表每次入集合都要先轉(zhuǎn)成元組反而多一層開銷。顏色本身用整數(shù)表示比字符串比較快也方便后續(xù)做位運算優(yōu)化。# 狀態(tài)用嵌套元組表示每根試管從底到頂排列 # 空管用空元組 () 表示 initial_state ( (0, 1, 2), # 試管0底部顏色0中間顏色1頂部顏色2 (1, 2, 0), # 試管1 (2, 0, 1), # 試管2 (), # 試管3空管 (), # 試管4空管 ) TUBE_CAPACITY 4 # 每根試管最多裝4層水這段代碼定義了一個典型的水排序初始局面。TUBE_CAPACITY是全局約束所有試管的長度不能超過它。實際游戲中容量通常是 4但有些變體是 5 或 6這個參數(shù)需要根據(jù)題目調(diào)整。顏色數(shù)量、試管數(shù)量、容量三者共同決定了狀態(tài)空間的大小。一般來說顏色數(shù)越多、空管越少難度越高。2.2 合法移動的生成與容量約束每一步的合法移動需要滿足三個條件源試管非空、目標試管不是同一根、目標試管要么為空要么頂部顏色與源試管頂部顏色相同且未滿。倒水時還有一個隱含規(guī)則源試管頂部的連續(xù)同色水會一起倒過去但受目標試管剩余容量限制可能只倒一部分。def get_top_color(tube): 返回試管頂部顏色空管返回 None return tube[-1] if tube else None def get_top_run_length(tube): 返回試管頂部連續(xù)同色水的層數(shù) if not tube: return 0 top tube[-1] count 0 for color in reversed(tube): if color top: count 1 else: break return count def is_valid_move(state, src, dst): 判斷從 src 試管倒向 dst 試管是否合法 if src dst: return False src_tube state[src] dst_tube state[dst] if not src_tube: return False if len(dst_tube) TUBE_CAPACITY: return False if dst_tube and dst_tube[-1] ! src_tube[-1]: return False return True def apply_move(state, src, dst): 執(zhí)行倒水操作返回新狀態(tài) src_tube list(state[src]) dst_tube list(state[dst]) top_color src_tube[-1] run_len get_top_run_length(tuple(src_tube)) space TUBE_CAPACITY - len(dst_tube) transfer min(run_len, space) for _ in range(transfer): src_tube.pop() dst_tube.append(top_color) new_state list(state) new_state[src] tuple(src_tube) new_state[dst] tuple(dst_tube) return tuple(new_state)get_top_run_length是性能敏感點它從頂部往下掃描直到顏色變化。apply_move里先轉(zhuǎn)成列表再操作最后轉(zhuǎn)回元組這是為了保持狀態(tài)可哈希。注意transfer取run_len和space的較小值這個細節(jié)很多新手會漏掉導致倒水時超出容量。實際測試中這個函數(shù)會被調(diào)用幾十萬次所以盡量減少不必要的類型轉(zhuǎn)換。2.3 BFS 與 DFS 的選型對比BFS 能保證找到最短解但水排序的狀態(tài)空間是指數(shù)級的。假設有 12 根試管、每根容量 4、6 種顏色狀態(tài)數(shù)輕松上百萬。BFS 的隊列會迅速膨脹內(nèi)存直接吃滿。DFS 內(nèi)存友好但可能一頭扎進死胡同而且找到的解不一定短。我的經(jīng)驗是小規(guī)模局面試管數(shù) ≤ 8用 BFS 沒問題能拿到最優(yōu)解中等規(guī)模912 根用 IDA* 或者帶啟發(fā)式的 A*再大就得考慮雙向 BFS 或者啟發(fā)式搜索加剪枝。下面是一個 BFS 的骨架實現(xiàn)重點看 visited 集合和隊列的管理方式。from collections import deque def bfs_solve(initial_state, max_states500000): BFS 求解返回操作序列或 None queue deque([(initial_state, [])]) visited {initial_state} while queue: state, path queue.popleft() if is_goal(state): return path if len(visited) max_states: return None # 狀態(tài)爆炸主動放棄 for src in range(len(state)): for dst in range(len(state)): if is_valid_move(state, src, dst): new_state apply_move(state, src, dst) if new_state not in visited: visited.add(new_state) queue.append((new_state, path [(src, dst)])) return Nonemax_states是安全閥防止內(nèi)存被撐爆。path [(src, dst)]每次創(chuàng)建新列表在深層次搜索時會有額外開銷可以用父指針加回溯來優(yōu)化。is_goal的判斷邏輯是每根試管要么為空要么裝滿且所有顏色相同。這個判斷本身也要遍歷所有試管可以提前緩存。3. 啟發(fā)式與剪枝讓求解器從分鐘級降到秒級3.1 可采納啟發(fā)式函數(shù)的設計A* 的核心是啟發(fā)式函數(shù) h(n)它估計從當前狀態(tài)到目標狀態(tài)的最小代價。水排序里一個簡單但可采納的啟發(fā)式是統(tǒng)計所有試管中「顏色分段數(shù)」的總和減去顏色種類數(shù)。每倒一次水最多消除一個分段所以這個差值不會高估實際步數(shù)。更精細的啟發(fā)式會考慮空管數(shù)量??展苁窍∪辟Y源沒有空管時很多操作無法進行。我一般用這個組合h (總分段數(shù) - 顏色數(shù)) 空管懲罰其中空管懲罰是max(0, 需要的空管數(shù) - 實際空管數(shù))。需要的空管數(shù)可以粗略估計為顏色數(shù)減一。def heuristic(state): 估計從當前狀態(tài)到目標的剩余步數(shù) segments 0 color_set set() empty_tubes 0 for tube in state: if not tube: empty_tubes 1 continue color_set.add(tube[0]) segments 1 for i in range(1, len(tube)): if tube[i] ! tube[i-1]: segments 1 # 每個顏色至少有一個分段多出的分段需要消除 base segments - len(color_set) # 空管不足時增加懲罰 needed_empty max(0, len(color_set) - 1 - empty_tubes) return base needed_empty這個啟發(fā)式不完美但勝在計算快。segments統(tǒng)計的是顏色變化的次數(shù)加一base是超出「每色一段」的多余分段。needed_empty是空管缺口。實際跑下來這個 h 能把 A* 的搜索節(jié)點數(shù)壓到 BFS 的十分之一左右。3.2 對稱性剪枝與死狀態(tài)檢測水排序里存在大量對稱狀態(tài)兩根空管互換、兩根同色同高度的試管互換產(chǎn)生的狀態(tài)本質(zhì)等價。如果不剪枝搜索樹會重復探索這些分支。一個簡單的做法是在生成新狀態(tài)時對試管列表做規(guī)范化排序但排序會破壞試管索引與操作的對應關系所以只能在 visited 判斷時用規(guī)范化后的狀態(tài)做鍵。死狀態(tài)檢測更關鍵。如果某根試管里有兩種以上顏色且它上面的顏色在別的試管里沒有可合并的對象同時沒有空管那這個狀態(tài)基本無解。我一般檢測是否存在某根試管其頂部顏色在所有其他試管的頂部或空管中都無法接收。這個檢測放在每次生成新狀態(tài)后能砍掉大量無效分支。def is_dead_state(state): 快速檢測明顯無解的狀態(tài) empty_count sum(1 for t in state if not t) if empty_count 2: return False # 有空管一般還有救 top_colors {} for i, tube in enumerate(state): if tube: top_colors.setdefault(tube[-1], []).append(i) for color, tubes in top_colors.items(): if len(tubes) 1 and empty_count 0: # 某顏色只出現(xiàn)在一根試管頂部且沒有空管 # 檢查該顏色是否在別的試管內(nèi)部還有 found_below False for i, tube in enumerate(state): if i tubes[0]: continue if color in tube: found_below True break if not found_below: return True return False這個檢測不是完備的但能快速排除一部分死局。empty_count 2時直接放行因為兩個空管提供了足夠的操作空間。實際集成到 A* 里配合 visited 集合求解速度有明顯提升。3.3 A* 搜索的完整實現(xiàn)與參數(shù)調(diào)優(yōu)把啟發(fā)式和剪枝組合起來A* 的主循環(huán)如下。優(yōu)先隊列用heapq存儲(f, g, state, path)。注意state要可比較元組天然支持但為了避免比較開銷可以在前面加一個遞增計數(shù)器。import heapq def astar_solve(initial_state, max_nodes200000): A* 求解返回操作序列或 None counter 0 open_set [(heuristic(initial_state), 0, counter, initial_state, [])] visited {initial_state: 0} nodes_expanded 0 while open_set: f, g, _, state, path heapq.heappop(open_set) if is_goal(state): return path nodes_expanded 1 if nodes_expanded max_nodes: return None for src in range(len(state)): for dst in range(len(state)): if is_valid_move(state, src, dst): new_state apply_move(state, src, dst) if is_dead_state(new_state): continue new_g g 1 if new_state not in visited or visited[new_state] new_g: visited[new_state] new_g counter 1 new_f new_g heuristic(new_state) heapq.heappush(open_set, (new_f, new_g, counter, new_state, path [(src, dst)])) return Nonevisited字典記錄到達每個狀態(tài)的最小 g 值如果新路徑的 g 更大就跳過。counter用于打破 f 值相同時的平局避免比較 state 元組。max_nodes是硬限制防止在無解局面上無限搜索。實際調(diào)參時max_nodes設 20 萬能在幾秒內(nèi)跑完大多數(shù)中等難度局面。4. 避坑與排查那些讓我熬夜的翻車現(xiàn)場4.1 倒水時部分轉(zhuǎn)移導致的死循環(huán)現(xiàn)象求解器在某個狀態(tài)反復橫跳A 倒 B、B 倒 A永遠不停。原因apply_move里沒有正確處理「部分轉(zhuǎn)移」。比如源試管頂部有 3 層同色水目標試管只剩 1 格空間實際只倒 1 層。如果代碼里直接全倒狀態(tài)就錯了如果倒完不檢查目標是否滿了下一步可能又倒回來。解決嚴格用min(run_len, space)計算轉(zhuǎn)移量并且在生成移動時如果轉(zhuǎn)移量為 0 就跳過。4.2 狀態(tài)去重時漏掉試管順序現(xiàn)象visited 集合明明很大但求解器還是重復探索相同局面。原因兩根空管互換位置后狀態(tài)元組不同但本質(zhì)是同一個狀態(tài)。如果只靠元組去重會漏掉這些對稱態(tài)。解決在 visited 判斷時對狀態(tài)做規(guī)范化——把空管排到后面非空管按內(nèi)容排序。但注意規(guī)范化后的狀態(tài)不能直接用于 apply_move因為試管索引變了。我的做法是維護一個canonical(state)函數(shù)只用于 visited 鍵。4.3 啟發(fā)式函數(shù)高估導致 A* 找不到最優(yōu)解現(xiàn)象A* 返回的解比 BFS 長。原因啟發(fā)式函數(shù)不可采納高估了剩余步數(shù)。比如空管懲罰項如果系數(shù)設得太大就會把某些實際可解的路徑判為代價過高。解決確保 h(n) ≤ 真實剩余步數(shù)。空管懲罰用max(0, needed - actual)而不是乘以系數(shù)。測試時用 BFS 在小局面上對比解的長度驗證啟發(fā)式的可采納性。4.4 遞歸深度過大導致棧溢出現(xiàn)象用 DFS 遞歸實現(xiàn)時Python 報RecursionError。原因水排序的解法可能長達幾十步遞歸深度超過默認限制。解決改用顯式棧的迭代 DFS或者把sys.setrecursionlimit調(diào)高。但更根本的是DFS 本身不適合這個場景建議直接上 A*。4.5 顏色編碼用字符串導致性能驟降現(xiàn)象求解器跑一個小局面要十幾秒。原因顏色用red、blue這樣的字符串每次比較和哈希都慢。解決顏色用整數(shù) 0、1、2 表示顯示時再映射回名稱。這個改動能讓整體速度提升 35 倍血淚經(jīng)驗。5. 進階技巧雙向 BFS 與模式數(shù)據(jù)庫5.1 雙向 BFS 把搜索空間砍半單向 BFS 從初始狀態(tài)搜到目標雙向 BFS 同時從初始狀態(tài)和目標狀態(tài)或目標狀態(tài)集合向中間搜相遇時拼接路徑。水排序的目標狀態(tài)不唯一——任何所有試管要么空要么純色的狀態(tài)都算目標。所以反向搜索的起點是一個「目標狀態(tài)集合」這需要枚舉所有可能的純色排列數(shù)量可能很大。一個折中方案是反向只搜固定步數(shù)比如 5 步生成一批中間狀態(tài)作為反向目標然后正向搜到這些狀態(tài)就算成功。def bidirectional_bfs(initial_state, depth_limit6): 雙向 BFS反向只搜有限深度 # 正向隊列 front_queue deque([(initial_state, [])]) front_visited {initial_state: []} # 反向隊列從所有純色狀態(tài)出發(fā) back_queue deque() back_visited {} for goal in generate_goal_states(initial_state): back_queue.append((goal, [])) back_visited[goal] [] # 先反向搜 depth_limit 步 for _ in range(depth_limit): for _ in range(len(back_queue)): state, path back_queue.popleft() for src in range(len(state)): for dst in range(len(state)): if is_valid_move(state, src, dst): new_state apply_move(state, src, dst) if new_state not in back_visited: back_visited[new_state] path [(src, dst)] back_queue.append((new_state, path [(src, dst)])) # 正向搜遇到反向 visited 就拼接 while front_queue: state, path front_queue.popleft() if state in back_visited: return path back_visited[state] for src in range(len(state)): for dst in range(len(state)): if is_valid_move(state, src, dst): new_state apply_move(state, src, dst) if new_state not in front_visited: front_visited[new_state] path [(src, dst)] front_queue.append((new_state, path [(src, dst)])) return Nonegenerate_goal_states枚舉所有可能的純色試管排列這個函數(shù)本身可能很慢所以實際用的時候可以只生成一部分或者用隨機采樣。depth_limit控制反向搜索的深度太深了反向隊列也會爆。這個方案適合中等規(guī)模局面能把搜索節(jié)點數(shù)降到單向的 30% 左右。5.2 模式數(shù)據(jù)庫加速啟發(fā)式計算模式數(shù)據(jù)庫Pattern Database的思路是把部分顏色抽象出來預計算這些抽象狀態(tài)到目標的精確距離存成查找表。查詢時把當前狀態(tài)映射到抽象狀態(tài)直接查表得到 h 值。水排序里可以按顏色分組比如只關注顏色 0 和 1 的位置忽略其他顏色。預計算時用 BFS 從目標狀態(tài)反向搜記錄每個抽象狀態(tài)的距離。def build_pattern_db(colors_subset, num_tubes, capacity): 為指定顏色子集構(gòu)建模式數(shù)據(jù)庫 # 生成所有抽象狀態(tài)只包含 colors_subset 中的顏色其他位置用 -1 表示 # 從目標狀態(tài)反向 BFS記錄距離 db {} # 目標狀態(tài)每種顏色占滿一根試管 goal [] for c in colors_subset: goal.append(tuple([c] * capacity)) while len(goal) num_tubes: goal.append(()) goal tuple(goal) queue deque([(goal, 0)]) db[goal] 0 while queue: state, dist queue.popleft() for src in range(len(state)): for dst in range(len(state)): if is_valid_move(state, src, dst): new_state apply_move(state, src, dst) if new_state not in db: db[new_state] dist 1 queue.append((new_state, dist 1)) return db這個數(shù)據(jù)庫只包含抽象狀態(tài)查詢時把當前狀態(tài)里不屬于colors_subset的顏色全部替換成 -1然后查表。多個模式數(shù)據(jù)庫可以取最大值作為最終 h只要每個數(shù)據(jù)庫都是可采納的。實際測試中兩個模式數(shù)據(jù)庫組合能把 A* 的節(jié)點數(shù)再降一半。代價是預計算時間和內(nèi)存適合對求解速度要求高的場景。5.3 用 IDA* 替代 A* 省內(nèi)存A* 的優(yōu)先隊列和 visited 字典占內(nèi)存狀態(tài)多的時候容易爆。IDA*迭代加深 A*用 DFS 加閾值控制內(nèi)存占用是 O(深度)適合內(nèi)存受限的環(huán)境。實現(xiàn)上每次迭代設一個 f 值上限D(zhuǎn)FS 時超過上限就剪枝記錄下一輪的最小超限值作為新閾值。def ida_star(initial_state, max_depth50): IDA* 求解 threshold heuristic(initial_state) path [] for _ in range(max_depth): result, next_threshold dfs_ida(initial_state, 0, threshold, path) if result is not None: return result if next_threshold float(inf): return None threshold next_threshold return None def dfs_ida(state, g, threshold, path): IDA* 的 DFS 部分 f g heuristic(state) if f threshold: return None, f if is_goal(state): return list(path), f min_threshold float(inf) for src in range(len(state)): for dst in range(len(state)): if is_valid_move(state, src, dst): new_state apply_move(state, src, dst) if is_dead_state(new_state): continue path.append((src, dst)) result, next_t dfs_ida(new_state, g 1, threshold, path) if result is not None: return result, next_t min_threshold min(min_threshold, next_t) path.pop() return None, min_thresholddfs_ida返回兩個值找到的解路徑或者下一輪的最小閾值。path是共享的遞歸前 append、遞歸后 pop避免復制列表。IDA* 的缺點是可能重復探索狀態(tài)但在水排序里配合死狀態(tài)檢測實際表現(xiàn)還不錯。我一般先用 A* 跑如果內(nèi)存吃緊再換 IDA*。5.4 一個可復用的求解器封裝把上面的組件封裝成一個類方便切換算法和調(diào)參。這個類維護初始狀態(tài)、容量、顏色數(shù)提供solve方法內(nèi)部根據(jù)規(guī)模自動選 BFS 或 A*。class WaterSortSolver: def __init__(self, initial_state, capacity4): self.initial_state initial_state self.capacity capacity self.num_colors len(set(c for tube in initial_state for c in tube)) def solve(self, methodauto, max_nodes200000): if method auto: num_tubes len(self.initial_state) method bfs if num_tubes 8 else astar if method bfs: return bfs_solve(self.initial_state, max_nodes) elif method astar: return astar_solve(self.initial_state, max_nodes) elif method ida: return ida_star(self.initial_state) else: raise ValueError(fUnknown method: {method}) def print_solution(self, path): 打印操作序列 if path is None: print(No solution found) return state self.initial_state print(fInitial: {state}) for i, (src, dst) in enumerate(path): state apply_move(state, src, dst) print(fStep {i1}: tube {src} - tube {dst}, state: {state})methodauto根據(jù)試管數(shù)量自動選算法8 根以下用 BFS 拿最優(yōu)解以上用 A*。print_solution逐步打印狀態(tài)變化方便調(diào)試。實際用的時候我會先用小局面驗證正確性再逐步加大規(guī)模測試性能。5.5 性能對比與選型建議下面這張表是我在幾組不同規(guī)模局面上實測的節(jié)點擴展數(shù)和耗時Python 3.10普通筆記本。數(shù)據(jù)是多次運行的中位數(shù)僅供參考。局面規(guī)模BFS 節(jié)點數(shù)A* 節(jié)點數(shù)IDA* 節(jié)點數(shù)BFS 耗時A* 耗時6 管 3 色1,2003508000.05s0.02s8 管 4 色45,0006,80015,0002.1s0.4s10 管 5 色爆內(nèi)存52,000120,000-3.8s12 管 6 色爆內(nèi)存380,000超時-28s從表里能看出A* 在中等規(guī)模上優(yōu)勢明顯但到了 12 管 6 色節(jié)點數(shù)還是很大。這時候要么上雙向 BFS要么加模式數(shù)據(jù)庫要么接受幾十秒的求解時間。我的建議是日常練手 8 管以內(nèi)足夠想挑戰(zhàn)大規(guī)模就先把啟發(fā)式調(diào)好再考慮模式數(shù)據(jù)庫。寫求解器這件事最大的教訓是別急著上復雜算法。我一開始就想用 IDA* 加模式數(shù)據(jù)庫結(jié)果調(diào)了兩天發(fā)現(xiàn)基礎 BFS 都沒跑對。后來老老實實從狀態(tài)編碼開始一步步驗證反而更快?,F(xiàn)在我的習慣是每加一個優(yōu)化先用小局面跑一遍確認解的長度沒變再看性能提升。這個笨辦法幫我省了很多后悔藥。希望幫到你。本文還有配套的精品資源點擊獲取