华容道求解器
2022年,我写了一个求解滑块游戏华容道的Python程序,作为线上Python课程的教学演示。选择一个开局,这个程序的移植版就会在你的浏览器中找出最短的解法。
关于华容道
华容道是一种滑块游戏,与Klotski是近亲。它得名于《三国演义》中的一段情节:赤壁之战兵败后,曹操沿华容道逃走,受过他恩惠的关羽在此放了他一马。
棋盘有四列五行。曹操是最大的正方形方块;将军是占两格的方块,或竖或横;兵卒各占一格;另有两格是空的。目标是把曹操从底部中央的出口移出去。按照惯例,移动一个棋子算一步,即使它滑动了两格,或者是兵卒拐了一个弯。按这种算法,经典开局“横刀立马”(关羽横卧在曹操正下方)需要81步。
程序的工作原理
- 用坐标元组表示局面。每个格子有一个0到19的编号,每个棋子用它左上角格子的编号来记录。同类棋子可以互换,所以它们的坐标按大小排序,整个局面就成了一个元组,可以作为字典的键。
- 从空格出发寻找走法。只有与空格相邻的棋子才能移动,所以
Config.getNeighbors只在两个空格周围寻找,而不逐个检查棋子,由此列出一步之内可以到达的所有局面。 - 广度优先搜索。
Solution维护一个待检查局面的队列,以及一个记录每个局面从何而来的字典。局面按到达所需的步数依次检查,所以曹操第一次到达出口时,得到的解法就是最短的。随后再通过字典回溯出路径。 - 搜索空间很小。对于经典的棋子组合,可能的布局只有65880种(由
count_possible_configs计数),所以搜索用时远不到一秒。
原程序用Matplotlib画出每一步,再用OpenCV把图片合成视频。本页则改为直接绘制棋盘。
列表中的开局
这份列表是借助AI(Claude)挑选的。对于横、竖将军数量的每一种组合,先列出所有可能的布局,再按原程序的移动规则,求出每种布局让曹操脱身所需的最少步数。列表从简单的开局一直排到现有最难的开局,每个步数都经原程序确认。除“横刀立马”外,其余名称都是描述性的,并非传统名称。
| 将军(横 + 竖) | 布局数 | 可解布局数 | 最多所需步数 |
|---|---|---|---|
| 0 + 5 | 15,660 | 7,462 | 19 |
| 1 + 4(经典) | 65,880 | 53,954 | 93 |
| 2 + 3 | 109,260 | 83,972 | 138 |
| 3 + 2 | 106,800 | 82,835 | 135 |
| 4 + 1 | 51,660 | 30,778 | 97 |
| 5 + 0 | 14,220 | 8,114 | 56 |
65880种布局这一数字与原程序自己的计数一致,138步的最大值也与已知最难的华容道开局一致。
代码点评
应我的要求,由移植这个程序的AI助手Claude撰写。
- 优点。设计很清晰:
Board负责几何,Config负责走法,Solution负责搜索。以排好序的元组作为字典的键,可以轻松跳过重复的局面;从空格出发搜索,让生成走法的代码保持简短。测试函数,以及在运行前估算搜索规模的count_possible_configs,都是好习惯。 - 无解时给出假解。如果队列在曹操逃出之前就空了,
__find_path会把final_coords设为最后检查的局面,程序随后会画出一条通向它的路径,仿佛那就是解。移植版则返回无解。 - 对无效布局只给出警告。有效性检查只打印一条警告,然后继续运行。移植版有单独的
isValid方法,而且本页只提供有效、可解的开局。 - 缓存从未命中。
getNeighbors把结果存为self.__Neighbors,却用'__Neighbors' in self.__dict__来检查它。Python会改写以双下划线开头的属性名,所以__dict__中的键是'_Config__Neighbors',这个检查永远为假。不过这在这里无伤大雅,因为每个局面的相邻局面只需要计算一次。 - 不必要的清理。清空
__dict__的__del__方法以及那些del语句都是不必要的:Python会自动释放不再使用的对象。 - 更简单的队列。
queue.Queue是为在线程之间传递任务而设计的。在单线程程序中,collections.deque更简单,也更快。
JavaScript移植版
移植版是借助AI(Claude)编写的。它保留了原程序的类、方法和移动规则,只是改用JavaScript风格命名(getNeighbors、findPath),以便对照阅读两个程序。它省去了绘图部分,用短字符串而不是元组作为字典的键,并在曹操无法逃出时返回无解。对于列表中的每一个开局,以及随机选取的1800个布局(其中603个无解),它找到的解法都与原程序步数相同。由于它遵循原程序的设计,没有专门优化,速度只比Python程序快一些;上方的状态栏会显示每次搜索的用时。
源代码
原始Python程序(2022年)
from timeit import default_timer as timer
start_time = timer()
elapsed_time = lambda: timer() - start_time
from itertools import combinations
from math import comb
from queue import Queue
from sys import exit
import cv2
import os
import matplotlib.pyplot as plt
from matplotlib import rcParams
rcParams.update({'font.family' : 'serif', 'mathtext.fontset' : 'dejavuserif',
'font.size' : 12, 'text.latex.preamble' : r"\usepackage{amsmath}",
'xtick.major.pad' : 2, 'ytick.major.pad' : 2, 'xtick.major.size' : 6, 'ytick.major.size' : 6,
'xtick.minor.size' : 3, 'ytick.minor.size' : 3, 'axes.linewidth' : 2, 'axes.labelpad' : 1})
from matplotlib.patches import FancyBboxPatch, BoxStyle
def count_possible_configs():
ctr = 0
for generals in combinations(range(4, 20), 4):
occupied = [general + shift for general in generals for shift in [0, -4]]
if len(set(occupied)) < 8: continue
for Caocao in range(4, 20):
if Caocao % 4 == 3: continue
occupied1 = occupied + [Caocao + shift for shift in [0, 1, -4, -3]]
if len(set(occupied1)) < 12: continue
for Guanyu in range(20):
if Guanyu % 4 == 3: continue
occupied2 = occupied1 + [Guanyu, Guanyu + 1]
if len(set(occupied2)) < 14: continue
ctr += 1
ctr *= comb(6, 4)
print(ctr) # 65880
class Board():
def __init__(self, width: int=4, height: int=5):
self.width = width
self.height = height
def __del__(self):
self.__dict__.clear()
def __getCol(self, coord: int) -> int:
return coord % self.width
def __getRow(self, coord: int) -> int:
return coord // self.width
def getColRow(self, coord: int) -> (int, int):
return self.__getCol(coord), self.__getRow(coord)
# def __getLeft(self, coord: int, shift: int=1):
# col = self.__getCol(coord)
# if col < shift: return -1
# return coord - shift
# def __getRight(self, coord: int, shift: int=1):
# col = self.__getCol(coord)
# if col >= self.width - shift: return -1
# return coord + shift
# def __getUp(self, coord: int, shift: int=1):
# row = self.__getRow(coord)
# if row >= self.height - shift: return -1
# return coord + shift * self.width
# def __getDown(self, coord: int, shift: int=1):
# row = self.__getRow(coord)
# if row < shift: return -1
# return coord - shift * self.width
def getNeighbor(self, coord: int, hshift: int=0, vshift: int=0) -> int:
col, row = self.getColRow(coord)
col += hshift; row += vshift
if not 0 <= col < self.width: return -1
if not 0 <= row < self.height: return -1
return row * self.width + col
def getVicinity(self, coord: int) -> dict:
vicinity = {}
for hshift in [-2, -1, 1, 2]:
vicinity[(hshift, 0)] = self.getNeighbor(coord, hshift=hshift)
for vshift in [-2, -1, 1, 2]:
vicinity[(0, vshift)] = self.getNeighbor(coord, vshift=vshift)
return vicinity
def getVicinity2(self, coords: (int, int)) -> (str, dict):
coord1, coord2 = coords
adjacent = ''
if coord1 == self.getNeighbor(coord2, hshift=-1):
adjacent = 'H'
elif coord1 == self.getNeighbor(coord2, vshift=-1):
adjacent = 'V'
vicinity = {}
for shift, coord in self.getVicinity(coord1).items():
vicinity[(1,) + shift] = coord
for shift, coord in self.getVicinity(coord2).items():
vicinity[(2,) + shift] = coord
if adjacent == 'H':
for key in [(2, -2, 0), (2, -1, 0), (1, 1, 0), (1, 2, 0)]:
vicinity[key] = -1
elif adjacent == 'V':
for key in [(2, 0, -2), (2, 0, -1), (1, 0, 1), (1, 0, 2)]:
vicinity[key] = -1
return adjacent, vicinity
def test_Board_methods():
board = Board()
for coord in range(20):
print(coord, board.getVicinity(coord))
print(board.getVicinity2((6, 10)))
class Config():
def __init__(self, board: Board, coords: (int, tuple, tuple, tuple, tuple)\
=(17, (9,), (8, 11, 16, 19), (0, 3, 5, 6), (1, 2))):
self.__board = board
self.__Caocao = coords[0]
self.__HGenerals = list(coords[1])
self.__VGenerals = list(coords[2])
self.__Soldiers = list(coords[3])
self.__Vacancies = list(coords[4])
def __del__(self):
self.__dict__.clear()
def display(self, figname: str):
fig, ax = plt.subplots(figsize=(self.__board.width + 0.1,
self.__board.height))
x, y = self.__board.getColRow(self.__Caocao)
ax.add_artist(FancyBboxPatch([x + 0.1, y - 0.9], 1.8, 1.8,
boxstyle=BoxStyle("Round", pad=0.05), color='tab:blue', alpha=0.7))
ax.text(x + 1.0, y - 0.05, figname, ha='center', va='center',
color='tab:blue', fontsize=36)
for Hgeneral in self.__HGenerals:
x, y = self.__board.getColRow(Hgeneral)
ax.add_artist(FancyBboxPatch([x + 0.1, y + 0.1], 1.8, 0.8,
boxstyle=BoxStyle("Round", pad=0.05), color='tab:red', alpha=0.7))
for Vgeneral in self.__VGenerals:
x, y = self.__board.getColRow(Vgeneral)
ax.add_artist(FancyBboxPatch([x + 0.1, y - 0.9], 0.8, 1.8,
boxstyle=BoxStyle("Round", pad=0.05), color='tab:orange', alpha=0.7))
for soldier in self.__Soldiers:
x, y = self.__board.getColRow(soldier)
ax.add_artist(FancyBboxPatch([x + 0.1, y + 0.1], 0.8, 0.8,
boxstyle=BoxStyle("Round", pad=0.05), color='tab:green', alpha=0.7))
ax.axis('equal'); ax.axis('off')
ax.set_xlim((-0.1, 4.1)); ax.set_xticks([])
ax.set_ylim((-0.0, 5.0)); ax.set_yticks([])
ax.patch.set_alpha(0.0)
fig.tight_layout()
fig.savefig(f'{figname}.png', dpi=128)
plt.close(fig)
def __moveCaocao(self, shift: (int, int)):
new_Caocao = self.__board.getNeighbor(self.__Caocao, *shift)
if shift == (1, 0):
new_vacancies = (self.__board.getNeighbor(self.__Caocao, vshift=-1),
self.__Caocao)
elif shift == (-1, 0):
new_vacancies = (self.__board.getNeighbor(self.__Caocao, hshift=1, vshift=-1),
self.__board.getNeighbor(self.__Caocao, hshift=1))
elif shift == (0, -1):
new_vacancies = (self.__Caocao,
self.__board.getNeighbor(self.__Caocao, hshift=1))
elif shift == (0, 1):
new_vacancies = (self.__board.getNeighbor(self.__Caocao, vshift=-1),
self.__board.getNeighbor(self.__Caocao, hshift=1, vshift=-1))
coords = (new_Caocao, tuple(self.__HGenerals), tuple(self.__VGenerals),
tuple(self.__Soldiers), new_vacancies)
self.__Neighbors.append(coords)
@staticmethod
def list_replace(array: list, old: int, new: int):
array[array.index(old)] = new
array.sort()
def __moveHGeneral(self, old_coord: int, shift: (int, int)):
new_HGenerals = self.__HGenerals.copy()
Config.list_replace(new_HGenerals, old_coord,
self.__board.getNeighbor(old_coord, *shift))
if shift == (1, 0):
new_vacancies = self.__Vacancies.copy()
Config.list_replace(new_vacancies,
self.__board.getNeighbor(old_coord, hshift=2),
old_coord)
elif shift == (-1, 0):
new_vacancies = self.__Vacancies.copy()
Config.list_replace(new_vacancies,
self.__board.getNeighbor(old_coord, hshift=-1),
self.__board.getNeighbor(old_coord, hshift=1))
else:
new_vacancies = (old_coord,
self.__board.getNeighbor(old_coord, hshift=1))
coords = (self.__Caocao, tuple(new_HGenerals), tuple(self.__VGenerals),
tuple(self.__Soldiers), tuple(sorted(new_vacancies)))
self.__Neighbors.append(coords)
def __moveVGeneral(self, old_coord: int, shift: (int, int)):
new_VGenerals = self.__VGenerals.copy()
Config.list_replace(new_VGenerals, old_coord,
self.__board.getNeighbor(old_coord, *shift))
if shift == (0, 1):
new_vacancies = self.__Vacancies.copy()
Config.list_replace(new_vacancies,
self.__board.getNeighbor(old_coord, vshift=1),
self.__board.getNeighbor(old_coord, vshift=-1))
elif shift == (0, -1):
new_vacancies = self.__Vacancies.copy()
Config.list_replace(new_vacancies,
self.__board.getNeighbor(old_coord, vshift=-2),
old_coord)
else:
new_vacancies = [old_coord,
self.__board.getNeighbor(old_coord, vshift=-1)]
new_vacancies.sort()
coords = (self.__Caocao, tuple(self.__HGenerals), tuple(new_VGenerals),
tuple(self.__Soldiers), tuple(new_vacancies))
self.__Neighbors.append(coords)
def __moveSoldier(self, old_coord: int, new_coord: int):
new_Soldiers = self.__Soldiers.copy()
Config.list_replace(new_Soldiers, old_coord, new_coord)
new_vacancies = self.__Vacancies.copy()
Config.list_replace(new_vacancies, new_coord, old_coord)
coords = (self.__Caocao, tuple(self.__HGenerals), tuple(self.__VGenerals),
tuple(new_Soldiers), tuple(new_vacancies))
self.__Neighbors.append(coords)
def getNeighbors(self, allow_twice: bool=True):
if '__Neighbors' in self.__dict__:
return self.__Neighbors
self.__Neighbors = []
adjacent, vicinity = self.__board.getVicinity2(self.__Vacancies)
vacancy1, vacancy2 = self.__Vacancies
# try to move Caocao
if adjacent == 'V':
if vicinity[(2, -2, 0)] == self.__Caocao:
self.__moveCaocao((1, 0))
elif vicinity[(2, 1, 0)] == self.__Caocao:
self.__moveCaocao((-1, 0))
elif adjacent == 'H':
if vicinity[(1, 0, 2)] == self.__Caocao:
self.__moveCaocao((0, -1))
elif vicinity[(1, 0, -1)] == self.__Caocao:
self.__moveCaocao((0, 1))
# try to move an HGeneral
for v in [1, 2]:
if vicinity[(v, -2, 0)] in self.__HGenerals:
self.__moveHGeneral(vicinity[(v, -2, 0)], (1, 0))
if vicinity[(v, 1, 0)] in self.__HGenerals:
self.__moveHGeneral(vicinity[(v, 1, 0)], (-1, 0))
if adjacent == 'H':
if vicinity[(1, 0, 1)] in self.__HGenerals:
self.__moveHGeneral(vicinity[(1, 0, 1)], (0, -1))
if vicinity[(1, 0, -1)] in self.__HGenerals:
self.__moveHGeneral(vicinity[(1, 0, -1)], (0, 1))
if allow_twice:
if vicinity[(1, -2, 0)] in self.__HGenerals:
self.__moveHGeneral(vicinity[(1, -2, 0)], (2, 0))
if vicinity[(2, 1, 0)] in self.__HGenerals:
self.__moveHGeneral(vicinity[(2, 1, 0)], (-2, 0))
# try to move a VGeneral
for v in [1, 2]:
if vicinity[(v, 0, -1)] in self.__VGenerals:
self.__moveVGeneral(vicinity[(v, 0, -1)], (0, 1))
if vicinity[(v, 0, 2)] in self.__VGenerals:
self.__moveVGeneral(vicinity[(v, 0, 2)], (0, -1))
if adjacent == 'V':
if vicinity[(2, -1, 0)] in self.__VGenerals:
self.__moveVGeneral(vicinity[(2, -1, 0)], (1, 0))
if vicinity[(2, 1, 0)] in self.__VGenerals:
self.__moveVGeneral(vicinity[(2, 1, 0)], (-1, 0))
if allow_twice:
if vicinity[(1, 0, -1)] in self.__VGenerals:
self.__moveVGeneral(vicinity[(1, 0, -1)], (0, 2))
if vicinity[(2, 0, 2)] in self.__VGenerals:
self.__moveVGeneral(vicinity[(2, 0, 2)], (0, -2))
# try to move a Soldier
for v, vacancy in enumerate(self.__Vacancies, start=1):
for shift in [(1, 0), (-1, 0), (0, -1), (0, 1)]:
old_coord = vicinity[(v,) + shift]
if old_coord in self.__Soldiers:
self.__moveSoldier(old_coord, vacancy)
if allow_twice and adjacent:
self.__moveSoldier(old_coord, sum(self.__Vacancies) - vacancy)
return self.__Neighbors
def test_Config_methods():
board = Board()
# init_coords = (17, (9,), (8, 11, 16, 19), (0, 3, 5, 6), (1, 2))
init_coords = (17, (9,), (4, 6, 16, 19), (1, 3, 5, 11), (7, 8))
config = Config(board, init_coords)
config.display('0')
neighbors = config.getNeighbors()
del config
for i, neighbor in enumerate(neighbors, start=1):
print(i, neighbor)
config = Config(board, neighbor)
config.display(repr(i))
del config
del board
class Solution():
def __init__(self, board: Board, init_coords: (int, tuple, tuple, tuple, tuple)=\
(17, (9,), (8, 11, 16, 19), (0, 3, 5, 6), (1, 2)),
max_nsteps: int=100000, allow_twice: bool=True):
self.board = board
self.init_coords = init_coords
self.max_nsteps = max_nsteps
self.allow_twice = allow_twice
validity_check = [self.board.getNeighbor(init_coords[0], *shift)
for shift in [(0, 0), (1, 0), (0, -1), (1, -1)]]
for i in range(1, 5):
for coord in init_coords[i]:
validity_check.append(coord)
if i == 1: validity_check.append(self.board.getNeighbor(coord, hshift=1))
if i == 2: validity_check.append(self.board.getNeighbor(coord, vshift=-1))
validity_check.sort()
if validity_check != list(range(self.board.width * self.board.height)):
print(f' > Warning: Config({init_coords}) is invalid')
def __del__(self):
self.__dict__.clear()
def __find_path(self):
self.__coords_dict = {self.init_coords: None}
self.__coords_queue = Queue()
self.__coords_queue.put(self.init_coords)
self.final_coords = None
for i in range(self.max_nsteps):
coords = self.__coords_queue.get()
if i % 1000 == 0: print(i, coords)
if coords[0] == 5:
print(i, coords)
self.final_coords = coords
del self.__coords_queue
break
config = Config(self.board, coords)
# config.display(repr(i))
# print(i, coords, self.__coords_dict[coords])
neighbors = config.getNeighbors(allow_twice=self.allow_twice)
for neighbor in neighbors:
if neighbor in self.__coords_dict: continue
self.__coords_dict[neighbor] = coords
self.__coords_queue.put(neighbor)
del config
if self.__coords_queue.empty():
self.final_coords = coords
break
self.path = []
if self.final_coords is not None:
coords = self.final_coords
while coords:
self.path.append(coords)
coords = self.__coords_dict[coords]
self.path.reverse()
self.__coords_dict.clear()
def __display(self):
print(' > Visualizing', '@', elapsed_time())
images = []
for i, coords in enumerate(self.path):
print(i, coords)
config = Config(self.board, coords)
config.display(repr(i))
images.append(f'{i}.png')
del config
frame = cv2.imread(images[0])
height, width, layers = frame.shape
video = cv2.VideoWriter('huarongdao.mp4', 0, 5, (width, height))
for image in images:
video.write(cv2.imread(image))
os.remove(image)
cv2.destroyAllWindows()
video.release()
def __call__(self):
self.__find_path()
if self.path: self.__display()
if __name__ == "__main__":
# count_possible_configs()
# test_Board_methods()
# test_Config_methods()
board = Board()
solution = Solution(board,
# init_coords=(17, (1, 5, 9), (11, 19), (4, 8, 12, 16), (0, 3)),
allow_twice=True)
solution()
del solution
print(' > Finished!', '@', elapsed_time())
JavaScript移植版
下载:solver.js
// Huarongdao solver: a JavaScript port of Kaili Cao's Python program
// (huarongdao_v22.11.16.py), ported with AI assistance (Claude).
//
// The port keeps the original's classes (Board, Config, Solution), methods and
// move rules, so the two can be read side by side. It leaves out the drawing
// (matplotlib, OpenCV), which the web page does instead, and it changes two
// things: Solution.findPath returns null when no solution exists, and the
// queue is a plain array with a moving head instead of queue.Queue.
//
// Coordinates follow the original: the board is 4 columns by 5 rows, a cell's
// coordinate is row * 4 + col, and row 0 is the bottom row. A piece's
// coordinate is its upper-left cell. A configuration ("coords") is
// [caocao, hGenerals, vGenerals, soldiers, vacancies]
// with every list sorted, e.g. the classic 横刀立马:
// [17, [9], [8, 11, 16, 19], [0, 3, 5, 6], [1, 2]]
// Cao Cao escapes when his coordinate is 5 (bottom center).
'use strict';
class Board {
constructor(width = 4, height = 5) {
this.width = width;
this.height = height;
}
getColRow(coord) {
return [coord % this.width, Math.floor(coord / this.width)];
}
// The cell shifted by (hshift, vshift) from coord, or -1 if off the board.
getNeighbor(coord, hshift = 0, vshift = 0) {
let [col, row] = this.getColRow(coord);
col += hshift; row += vshift;
if (!(col >= 0 && col < this.width)) return -1;
if (!(row >= 0 && row < this.height)) return -1;
return row * this.width + col;
}
// Cells up to two steps away from coord in each direction,
// keyed by "hshift,vshift".
getVicinity(coord) {
const vicinity = {};
for (const hshift of [-2, -1, 1, 2]) {
vicinity[`${hshift},0`] = this.getNeighbor(coord, hshift, 0);
}
for (const vshift of [-2, -1, 1, 2]) {
vicinity[`0,${vshift}`] = this.getNeighbor(coord, 0, vshift);
}
return vicinity;
}
// The vicinity of both vacancies, keyed by "v,hshift,vshift" (v = 1 or 2),
// and whether the vacancies are adjacent horizontally ('H') or
// vertically ('V'). Cells that are the other vacancy, or lie beyond it,
// are masked with -1.
getVicinity2(coords) {
const [coord1, coord2] = coords;
let adjacent = '';
if (coord1 === this.getNeighbor(coord2, -1, 0)) {
adjacent = 'H';
} else if (coord1 === this.getNeighbor(coord2, 0, -1)) {
adjacent = 'V';
}
const vicinity = {};
for (const [shift, coord] of Object.entries(this.getVicinity(coord1))) {
vicinity[`1,${shift}`] = coord;
}
for (const [shift, coord] of Object.entries(this.getVicinity(coord2))) {
vicinity[`2,${shift}`] = coord;
}
if (adjacent === 'H') {
for (const key of ['2,-2,0', '2,-1,0', '1,1,0', '1,2,0']) vicinity[key] = -1;
} else if (adjacent === 'V') {
for (const key of ['2,0,-2', '2,0,-1', '1,0,1', '1,0,2']) vicinity[key] = -1;
}
return { adjacent, vicinity };
}
}
const numeric = (a, b) => a - b;
class Config {
constructor(board, coords) {
this.board = board;
this.caocao = coords[0];
this.hGenerals = [...coords[1]];
this.vGenerals = [...coords[2]];
this.soldiers = [...coords[3]];
this.vacancies = [...coords[4]];
this.neighbors = null;
}
static listReplace(array, oldValue, newValue) {
array[array.indexOf(oldValue)] = newValue;
array.sort(numeric);
}
moveCaocao(shift) {
const b = this.board, c = this.caocao;
const newCaocao = b.getNeighbor(c, ...shift);
let newVacancies;
if (shift[0] === 1 && shift[1] === 0) {
newVacancies = [b.getNeighbor(c, 0, -1), c];
} else if (shift[0] === -1 && shift[1] === 0) {
newVacancies = [b.getNeighbor(c, 1, -1), b.getNeighbor(c, 1, 0)];
} else if (shift[0] === 0 && shift[1] === -1) {
newVacancies = [c, b.getNeighbor(c, 1, 0)];
} else { // (0, 1)
newVacancies = [b.getNeighbor(c, 0, -1), b.getNeighbor(c, 1, -1)];
}
this.neighbors.push([newCaocao, [...this.hGenerals], [...this.vGenerals],
[...this.soldiers], newVacancies]);
}
moveHGeneral(oldCoord, shift) {
const b = this.board;
const newHGenerals = [...this.hGenerals];
Config.listReplace(newHGenerals, oldCoord, b.getNeighbor(oldCoord, ...shift));
let newVacancies;
if (shift[0] === 1 && shift[1] === 0) {
newVacancies = [...this.vacancies];
Config.listReplace(newVacancies, b.getNeighbor(oldCoord, 2, 0), oldCoord);
} else if (shift[0] === -1 && shift[1] === 0) {
newVacancies = [...this.vacancies];
Config.listReplace(newVacancies, b.getNeighbor(oldCoord, -1, 0),
b.getNeighbor(oldCoord, 1, 0));
} else { // vertical moves, and horizontal moves by two cells
newVacancies = [oldCoord, b.getNeighbor(oldCoord, 1, 0)];
}
this.neighbors.push([this.caocao, newHGenerals, [...this.vGenerals],
[...this.soldiers], newVacancies.sort(numeric)]);
}
moveVGeneral(oldCoord, shift) {
const b = this.board;
const newVGenerals = [...this.vGenerals];
Config.listReplace(newVGenerals, oldCoord, b.getNeighbor(oldCoord, ...shift));
let newVacancies;
if (shift[0] === 0 && shift[1] === 1) {
newVacancies = [...this.vacancies];
Config.listReplace(newVacancies, b.getNeighbor(oldCoord, 0, 1),
b.getNeighbor(oldCoord, 0, -1));
} else if (shift[0] === 0 && shift[1] === -1) {
newVacancies = [...this.vacancies];
Config.listReplace(newVacancies, b.getNeighbor(oldCoord, 0, -2), oldCoord);
} else { // horizontal moves, and vertical moves by two cells
newVacancies = [oldCoord, b.getNeighbor(oldCoord, 0, -1)].sort(numeric);
}
this.neighbors.push([this.caocao, [...this.hGenerals], newVGenerals,
[...this.soldiers], newVacancies]);
}
moveSoldier(oldCoord, newCoord) {
const newSoldiers = [...this.soldiers];
Config.listReplace(newSoldiers, oldCoord, newCoord);
const newVacancies = [...this.vacancies];
Config.listReplace(newVacancies, newCoord, oldCoord);
this.neighbors.push([this.caocao, [...this.hGenerals], [...this.vGenerals],
newSoldiers, newVacancies]);
}
// All configurations reachable in one move. With allowTwice, a piece may
// also move two cells in a straight line, or a soldier may turn a corner,
// which still counts as one move.
getNeighbors(allowTwice = true) {
if (this.neighbors !== null) return this.neighbors;
this.neighbors = [];
const { adjacent, vicinity: vic } = this.board.getVicinity2(this.vacancies);
const H = this.hGenerals, V = this.vGenerals;
// try to move Caocao
if (adjacent === 'V') {
if (vic['2,-2,0'] === this.caocao) this.moveCaocao([1, 0]);
else if (vic['2,1,0'] === this.caocao) this.moveCaocao([-1, 0]);
} else if (adjacent === 'H') {
if (vic['1,0,2'] === this.caocao) this.moveCaocao([0, -1]);
else if (vic['1,0,-1'] === this.caocao) this.moveCaocao([0, 1]);
}
// try to move an HGeneral
for (const v of [1, 2]) {
if (H.includes(vic[`${v},-2,0`])) this.moveHGeneral(vic[`${v},-2,0`], [1, 0]);
if (H.includes(vic[`${v},1,0`])) this.moveHGeneral(vic[`${v},1,0`], [-1, 0]);
}
if (adjacent === 'H') {
if (H.includes(vic['1,0,1'])) this.moveHGeneral(vic['1,0,1'], [0, -1]);
if (H.includes(vic['1,0,-1'])) this.moveHGeneral(vic['1,0,-1'], [0, 1]);
if (allowTwice) {
if (H.includes(vic['1,-2,0'])) this.moveHGeneral(vic['1,-2,0'], [2, 0]);
if (H.includes(vic['2,1,0'])) this.moveHGeneral(vic['2,1,0'], [-2, 0]);
}
}
// try to move a VGeneral
for (const v of [1, 2]) {
if (V.includes(vic[`${v},0,-1`])) this.moveVGeneral(vic[`${v},0,-1`], [0, 1]);
if (V.includes(vic[`${v},0,2`])) this.moveVGeneral(vic[`${v},0,2`], [0, -1]);
}
if (adjacent === 'V') {
if (V.includes(vic['2,-1,0'])) this.moveVGeneral(vic['2,-1,0'], [1, 0]);
if (V.includes(vic['2,1,0'])) this.moveVGeneral(vic['2,1,0'], [-1, 0]);
if (allowTwice) {
if (V.includes(vic['1,0,-1'])) this.moveVGeneral(vic['1,0,-1'], [0, 2]);
if (V.includes(vic['2,0,2'])) this.moveVGeneral(vic['2,0,2'], [0, -2]);
}
}
// try to move a Soldier
const vacancySum = this.vacancies[0] + this.vacancies[1];
this.vacancies.forEach((vacancy, i) => {
const v = i + 1;
for (const [h, vs] of [[1, 0], [-1, 0], [0, -1], [0, 1]]) {
const oldCoord = vic[`${v},${h},${vs}`];
if (this.soldiers.includes(oldCoord)) {
this.moveSoldier(oldCoord, vacancy);
if (allowTwice && adjacent) this.moveSoldier(oldCoord, vacancySum - vacancy);
}
}
});
return this.neighbors;
}
}
class Solution {
constructor(board, initCoords, { maxNsteps = 100000, allowTwice = true } = {}) {
this.board = board;
this.initCoords = initCoords;
this.maxNsteps = maxNsteps;
this.allowTwice = allowTwice;
this.path = null;
this.nsearched = 0;
}
// True if the pieces cover the board exactly once.
isValid() {
const b = this.board, c = this.initCoords;
const cells = [[0, 0], [1, 0], [0, -1], [1, -1]].map(s => b.getNeighbor(c[0], ...s));
for (let i = 1; i < 5; i++) {
for (const coord of c[i]) {
cells.push(coord);
if (i === 1) cells.push(b.getNeighbor(coord, 1, 0));
if (i === 2) cells.push(b.getNeighbor(coord, 0, -1));
}
}
cells.sort(numeric);
return cells.length === b.width * b.height && cells.every((x, i) => x === i);
}
// Breadth-first search: the first configuration found with Cao Cao at the
// exit is reached in the fewest moves. Returns the list of configurations
// from start to finish, or null if Cao Cao cannot escape.
findPath() {
// a configuration as a short string, e.g. '17|9|8,11,16,19|0,3,5,6|1,2'
const key = coords => coords.join('|');
const parents = new Map([[key(this.initCoords), null]]);
const queue = [this.initCoords];
let head = 0, finalCoords = null;
for (let i = 0; i < this.maxNsteps && head < queue.length; i++) {
const coords = queue[head++];
if (coords[0] === 5) { finalCoords = coords; break; }
const config = new Config(this.board, coords);
for (const neighbor of config.getNeighbors(this.allowTwice)) {
const k = key(neighbor);
if (parents.has(k)) continue;
parents.set(k, coords);
queue.push(neighbor);
}
}
this.nsearched = parents.size;
if (finalCoords === null) { this.path = null; return null; }
const path = [];
for (let coords = finalCoords; coords !== null; coords = parents.get(key(coords))) {
path.push(coords);
}
this.path = path.reverse();
return this.path;
}
}
if (typeof module !== 'undefined') module.exports = { Board, Config, Solution };
本页还用到了puzzles.js(开局列表)和app.js(负责绘制棋盘、响应按钮),两者都是借助AI(Claude)编写的。关于我的线上Python课程,详见学习Python。