Shogi4 是 Oca Studios 推出的公共領域 4×4 打入型將棋,其規則已從公開網路上消失。我從官方 Android 套件中復原了規則,接著依照復原後的規則打造了一套強解求解引擎。

這不是一份完整解答。它是一套經過驗證的求解器、一個 21 億局面的封閉子遊戲,以及一套分片式外部記憶體設計,該設計已在封閉遊戲上測試過,但從未以完整規模執行。這條界線才是有趣之處:Shogi4 是動物將棋之上的第一階,在這裡打入會把一次遊戲求解變成一個分散式系統問題。

可以直接在下方試玩規則。這個檢視器會強制合法著法,包括打入、升變與友方跳躍,但不會顯示理論結果。

另開獨立視窗 →

本文其餘部分把完整遊戲的求解器設計(已測試但未以完整規模執行)與最大的封閉子遊戲(確實已求解)分開說明。

規則

沒有任何二手來源以引擎級的精確度記載 Shogi4 的規則。列印遊玩用的 PDF 從未被存檔,出版商的伺服器也已停擺。我從官方 Android 套件中復原了規則:那是一個 Python/Kivy 應用程式,其遊戲程式碼以 .mp3 形式發佈,實際上是位元碼的 gzip 壓縮封裝檔。反編譯後,其中的 get_possible_squarescan_take_square 函式就是權威規則集。完整的復原規則放在 Mistboard;本文的摘要已足以理解這次求解。

遊戲在 4×4 棋盤上進行,每方五枚棋子,全部每回合走一格:

4×4 棋盤上的 Shogi4 起始局面,先手方的鶴、狐狸、狸、貘與鯉魚朝上,後手方的棋子朝下 起始局面。棋子歸屬由朝向決定,而非顏色。

棋子 走法
王(鶴/雉) 八個方向任一方向走一格(即王)
鯉魚 向前走一格
狐狸 沿直線走一格
沿斜線走一格
向前或向前斜方走一格

有三條規則讓 Shogi4 需要自己的引擎,而不是把動物將棋的引擎改指向它:

  • 打入。 被吃的棋子會換邊,並可從手中放回任何空格,因此子力總量守恆。唯一的限制是不得打入對方的底線。
  • 升變。 抵達最遠一列的棋子必須升變:鯉魚、貘與狸升為銀將的步法組,狐狸升為金將。被吃的已升變棋子會回復為原本的棋子。
  • 友方跳躍。 若合法走法方向上相鄰的格子有己方棋子,該棋子可以跳過它,落在兩格之外。只能跳一枚己方棋子,不能連跳,也不能跳過敵方棋子。動物將棋沒有對應的規則。

吃掉對方的王即獲勝。沒有將軍或將死的概念,走到可被吃掉的位置是合法的,也沒有王行軍獲勝的規則。與動物將棋相比,多出的一枚棋子、更寬的棋盤、更廣的升變組合、打入限制與友方跳躍規則,已足以要求一套新的引擎。

為何完整的 Shogi4 是個分散式問題

強解會透過逆向分析為每個局面指定一個值(勝、負或和棋),從已結束的局面往回推算,這裡指的是王被吃掉的局面。若某個著法能走到負局面,該局面為勝;若所有著法都走到勝局面,該局面為負;其餘為和棋。

在西洋棋中,吃子只會減少子力,因此求解可分解成一連串越來越小的殘局,由下往上逐層解決。打入破壞了這個結構。被吃的棋子會回來,子力固定不變,整張圖成為單一的強連通環,必須當成一個整體來求解。這個整體很大:

數量 數值
所有排列的上界(精確值) 205,148,532,253,680
從起始局面可達 ~3×10¹³
稠密排序索引定義域 N 410,297,064,507,360

求解器以稠密排序(一種局面到整數的雙射)為局面編索引,因此值表是一個平坦陣列,查找為 O(1)。索引必須涵蓋每一種合法排列,所以決定大小的是 N(約 4×10¹⁴),而不是可達局面數:每個格位兩位元約為 100 TB,套用精確的左右對稱折疊後約 50 TB。這使完整求解成為一項分散式的外部記憶體運算,而不是能放進 RAM 的工作。

完整求解的設計

求解器採推送式。每個局面保有一個計數器,記錄尚未判定的子節點數。終端局面(王在下一著就會被吃掉)先播種為勝,之後值往回流動:只要有一個著法能走進負局面,該局面立刻成為勝;而計數器歸零的局面,代表所有子節點都已是勝,則成為負。值只會從已判定流向未判定,因此整個運算是一個單調不動點。

為了擴展到多台機器,排序空間被切分成分片,各自擁有其格位的值與計數器。判定一個局面時,會以反向著法生成它的前驅局面,並把每個前驅作為訊息送往擁有它的分片。這個路由就是 shuffle,也是工作量的主體:每個局面約產生 7 則前驅訊息,在完整規模下為 15 到 30 PB。

分片式逆向求解器的一個超級步:已判定的值生成前驅局面,訊息依稠密排序分派,各分片更新磁碟上的值與計數器陣列,然後由屏障啟動下一輪 每個分片擁有稠密排序表的一段。訊息把運算搬到擁有該前驅局面的分片上。

工作者在屏障後以超級步推進。每個工作者處理完當輪訊息、判定能判定的部分,再發出下一輪訊息。由於不動點是單調的,最終的值表不取決於空間如何分片,也不取決於訊息抵達的順序。分片只改變排程,絕不改變答案,而正是這個性質讓運算能安全地分散執行。

決定運算形狀的是儲存,而不是計算。值陣列以分片形式存在磁碟上,每個超級步串流讀取後即丟棄,而不是常駐記憶體,而精確的左右折疊讓儲存與工作量都減半。

驗證

引擎會與一份由反編譯應用程式獨立改寫的 Python 版本互相比對。索引與值分別驗證。

著法生成。 perft(從起始局面在各深度的著法樹節點數)逐位相符:

深度 局面數
1 8
2 64
3 626
4 6,304
5 68,723
6 769,014

另有一項在隨機對局上進行的 4,000 局面差異測試(涵蓋打入、升變、吃子、跳躍),兩套引擎之間的著法列表沒有任何不符。

索引。 稠密排序是已驗證的雙射:在每個小型遊戲索引上,先 rank 再 unrank 都是恆等映射,而它列舉出的局面也與直接列舉相符。它在完整遊戲上的定義域正好是獨立計算的排列數的兩倍。該列舉器以動物將棋作為對照,能重現 Tanaka 發表的 1,567,925,964 個局面數,以及完整的手中棋子分類統計。

值。 三套求解器(Python 兩種演算法、Rust 一種)在每個封閉子遊戲上的結果一致。局部一致性審查沒有發現任何違例:每個勝局面都有一個著法走向負,每個負局面的所有著法都走向勝,而和棋局面兩者皆無。左右鏡像完全成立,零不符,前驅生成器也與真正的反向邊相符。

這些已完整求解的小型封閉遊戲,是 Shogi4 的首批賽局理論結果(值以行棋方為準):

子遊戲 局面數 和棋
2 王 480 35.0% 0% 65.0%
2 王 + 鯉魚 24,480 75.9% 23.3% 0.7%
2 王 + 狐狸 24,480 76.7% 23.3% 0%
2 王 + 狸 24,480 76.2% 23.8% 0%
2 王 + 鯉魚 + 狐狸 1,164,704 74.5% 17.8% 7.7%

雙王的結果沒有任何負局面,這是一項合理性檢查:場上只有王時,行棋方總能避免自己的王被強迫吃掉。

在筆電上降低完整求解設計的風險

在租用叢集之前,我先在一台筆電上測試這套分散式設計,並以記憶體內求解器作為標準答案。

  • 匯合性。 分片求解器在 1、4、16 與 64 個分片下都重現了單機的結果。傳播最多執行 54 個超級步,也就是最長強制變化的深度。單調不動點的論證表示分片數不可能有影響;測試也印證了這一點。
  • 查核。 一次一致性掃描就能為整張表背書,成本低於求解本身。在 {2 王 + 鯉魚 + 狐狸} 上求解耗時 5.5 秒、審查 3.0 秒,因此查核是求解的 55%,而我刻意破壞的每一個值都被抓到(每個會呈現為 1 到 5 個局部違例)。
  • 復原。 崩潰後重播能逐位元組重現每個超級步。建置它時抓到一個真正的錯誤:前驅局面是從一個雜湊集合收集的,因此重播順序與原本不同。把輸出排序後,求解器變成確定性的,而這正是冪等復原所需要的。

最大規模的封閉求解

設計驗證完成後,我求解了一個更大的封閉遊戲:雙王加上每種棋子各一枚(鯉魚、狐狸、狸、貘),共 2,100,849,024 個局面。它是一個縮減版遊戲,而不是完整 Shogi4 的一個切片,因為打入使子力守恆,真實遊戲永遠不會走到各一枚的配置。它是我能用來在規模上驗證核心的最大封閉遊戲。

   
執行環境 Hetzner Cloud CCX43(16 顆專用 vCPU、64 GB)
作業系統 Ubuntu 26.04
模式 單執行緒、完全在記憶體中
記憶體峰值 約 15 GB(工作佇列壓縮為 32 位元排序值)
實際耗時 5.3 小時
成本 約 2 美元

這是單機、記憶體內的求解,不是分散式路徑的一次執行。約 15 GB 可以放進一台筆電;我在租用的機器上執行,只是為了讓自己的機器在這五小時內保持空閒。結果以行棋方為準:

結果 局面數 占比
1,662,776,212 79.15%
339,566,116 16.16%
和棋 98,506,696 4.69%

這些計數之和正好等於局面總數,並通過審查。

這次執行也校準了每條邊的成本與工作集大小的關係:

局面數 每條邊奈秒數
1,164,704 327
51,461,568 493
2,100,849,024 697

21 億的這次執行是第一次值陣列(約 14 GB)超出 CPU 快取,因此 697 ns/邊 成為推估完整求解的基準。在快取內的基準測試會低估這類工作。

完整求解的成本會是多少

以 697 ns/邊 為錨點,加上 4×10¹⁴ 的索引,完整求解大約需要 50 到 100 TB 的工作儲存空間,以及 130 到 190 核心年的計算量:約一百萬核心小時,換算成雲端費用約為 10,000 到 15,000 美元。

這個數字經過兩次修正。原始的局面數一開始差了一個數量級。接著佔用空間被誤以可達局面數(約 3×10¹³)來估算,而非排列定義域(約 4×10¹⁴,大約大 13 倍),後者才是稠密排序索引必須涵蓋的範圍。

那個索引是彆扭但必要的部分。小型求解器可以列舉可達局面,並保留一個從局面鍵到表格格位的 HashMap。在這種規模下,建立那張映射本身就已經太大。可擴展的版本為每一種排列給出一個數學位址:rank(position) -> integer。這會在不合法或不可達的排列上浪費格位,但也把殘局庫變成可以分片、串流、設檢查點與審查的平坦陣列。

同樣的取捨也出現在往回推算的步驟。小型的記憶體內求解器可以儲存一份壓縮稀疏列(CSR)形式的所有前驅邊清單,再直接走訪那張反向圖。完整求解器負擔不起為每條邊儲存一列。它改為按需生成前驅:還原著法形狀、為每個前驅計算 rank,再把更新送往擁有該 rank 的分片。這在每條邊上花更多 CPU,但這正是受記憶體限制的捷徑與外部記憶體求解器之間的差別。

在這份估算之後,我在相關的 4×5 微型將棋的一階上實測了這個取捨:每方 K+P+G,共 869,287,068 個可達的標準局面。採用可達局面 HashMap 加上儲存式 CSR 的求解器,核心求解需要約 60.5 GiB 的峰值 RSS 與 12,385 秒。而專為 KPG 設計的稠密排序加反向著法求解器使用了 2,037,557,340 個排序格位,是可達局面數的 2.34 倍,並以約 6.86 GiB 峰值 RSS 與 8,464 秒的總求解時間重現了相同的值計數。

方法 索引 前驅局面 記憶體峰值 單核心時間
可達局面 + CSR 8.69 億個可達鍵 儲存式反向圖 約 60.5 GiB 核心求解 12,385 秒
稠密排序 + 反向著法 20.4 億個排序格位 按需生成 約 6.86 GiB 總求解 8,464 秒

這就是工程上的取捨:稠密排序花費格位與 rank/unrank 的 CPU,來避免儲存反向圖。在這一階上,記憶體降為 1/8.8,單核心執行也快了 1.46 倍,因為它不必建立與保留反向圖。

為何留著不執行

引擎、分散式設計、查核與校準都已完成。要完成整個求解,缺的是租用的運算資源,而不是另一個想法。

這筆花費過不了門檻。技術本身已為人所知,所以結果寫不成論文;遊戲本身冷門,所以對其結果的需求也很少。自掏腰包只能買到一個數字:一個幾乎沒人玩的遊戲的賽局理論值。

這個專案的價值在於能力本身。規則已復原,求解器已建置並驗證,分散式路徑也在完整叢集執行之前先以封閉遊戲檢驗過。求解這個遊戲只會確認一個數值;它不會改變方法。

資源: