问题描述
我有一个围绕一个名为 Lights Out 的小游戏的任务。
游戏
游戏由一个尺寸为 3x3 的棋盘组成,其中每个单元格可以是 1 或 0,例如:
0 1 0
1 1 0
0 0 0
当所有单元格都为 1 时,游戏就被解决了,所以:
1 1 1
1 1 1
1 1 1
并且在每一轮中,用户可以单击任何单元格,这将翻转其状态以及向左、向右、上方和下方(如果存在)的邻居的状态。因此,单击第一个示例板中间的单元格将产生:
0 0 0
0 0 1
0 1 0
任务
现在我必须找到游戏中可能最糟糕的初始棋盘,并计算出如果玩得最佳,它需要多少回合才能达到已解决状态。
尝试
我尝试编写一个递归求解器,在给定初始棋盘的情况下,它会找到解决游戏的最佳回合顺序。在那之后,我想用所有可能的初始板来喂养它。
然而,递归遇到了堆栈溢出。所以我可能不得不以迭代的方式重写它。我该怎么做?
这是代码,作为最小的完整示例:
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.StringJoiner;
import java.util.stream.Collectors;
public class GameTest {
public static void main(String[] args) {
boolean[][] board = {
{false,false,false},{false,true,false}
};
List<GameState> solutionPath = GameSolver.solve(board);
printSolutionPath(solutionPath);
}
private static void printSolutionPath(List<GameState> solutionPath) {
System.out.printf("Solution path uses %d turns%n",solutionPath.get(solutionPath.size() - 1).getTurns());
String turnProgression = solutionPath.stream()
.map(state -> String.format("[%d|%d]",state.getX(),state.getY()))
.collect(Collectors.joining(" -> "));
System.out.println("Turns are: " + turnProgression);
System.out.println("Board progression is:");
for (GameState state : solutionPath) {
System.out.println(state.boardToString());
System.out.println("-----");
}
}
private static class GameSolver {
public static List<GameState> solve(boolean[][] initialBoard) {
GameState state = new GameState(initialBoard);
return solve(state);
}
public static List<GameState> solve(GameState state) {
// Base case
if (state.isSolved()) {
return List.of(state);
}
// Explore all other solutions
List<List<GameState>> solutionPaths = new ArrayList<>();
boolean[][] board = state.getBoard();
for (int x = 0; x < board.length; x++) {
for (int y = 0; y < board[x].length; y++) {
solutionPaths.add(solve(new GameState(state,x,y)));
}
}
List<GameState> bestSolutionPath = Collections.min(solutionPaths,Comparator.comparingInt(solutionPath -> solutionPath.get(solutionPath.size() - 1).getTurns()));
bestSolutionPath.add(state);
return bestSolutionPath;
}
}
private static class GameState {
private boolean[][] board;
private int turns;
private int x;
private int y;
public GameState(boolean[][] board) {
this.board = board;
turns = 0;
x = -1;
y = -1;
}
public GameState(GameState before,int x,int y) {
board = before.board;
click(x,y);
turns++;
this.x = x;
this.y = y;
}
public boolean isSolved() {
for (boolean[] row : board) {
for (boolean state : row) {
if (!state) {
return false;
}
}
}
return true;
}
public int getTurns() {
return turns;
}
public boolean[][] getBoard() {
return board;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public String boardToString() {
StringBuilder sb = new StringBuilder();
for (int x = 0; x < board.length; x++) {
StringJoiner row = new StringJoiner(" ");
for (int y = 0; y < board[x].length; y++) {
row.add(board[x][y] ? "1" : "0");
}
sb.append(row);
}
return sb.toString();
}
private void click(int centerX,int centerY) {
toggle(centerX,centerY);
toggle(centerX,centerY - 1);
toggle(centerX,centerY + 1);
toggle(centerX - 1,centerY);
toggle(centerX + 1,centerY);
}
private void toggle(int x,int y) {
if (x < 0 || y < 0 || x >= board.length || y >= board[x].length) {
return;
}
board[x][y] = !board[x][y];
}
}
}
算法
如果可能的话,我也会对解决或证明这个问题的纯数学论证感兴趣,而无需编写通过尝试解决问题的代码。
解决方法
我提出了一个基于图论的迭代解决方案来解决这个(和相关问题)。
最短路径问题 (SSP)
该问题可以重新表述为 shortest-path-problem,从而可以使用任何标准 SPP 算法解决,例如 Dijkstr's algorithm。
为此,我们将所有可能的游戏板解释为顶点,将点击单元格的动作解释为图形的边。
例如
0 1 0
1 1 0
0 0 0
将是图中的一个顶点,总共有 9 个输出边(每个单元格一个要单击的边)。所以我们将例如有一个边缘
0 1 0 0 0 0
1 1 0 --> 0 0 1
0 0 0 0 1 0
成本为 1
。所有边成本将为 1
,表示计数转弯。
给定一个初始板,如上所示,我们将 SPP 公式化为在该图中找到最短路径的任务,从代表初始板的顶点到代表求解状态的顶点
1 1 1
1 1 1
1 1 1
通过使用标准算法解决 SSP,我们可以得到最优路径及其总成本。路径是游戏状态的序列,总成本是所需的回合数。
*-1 SPP
然而,您不仅对解决给定的初始棋盘感兴趣,而且对找到最差的初始棋盘及其最佳回合数感兴趣。
这可以重新表述为 SPP 家族的一个变体,即试图找到最长最短路径到已解决状态。这是图中所有以求解状态结束的最短路径中,使总成本最大化的路径。
这可以通过 *-1
(多对一)SPP 高效计算。也就是说,计算从任何顶点到单个目的地的所有最短路径,这将是求解状态。并从那些选择总成本最高的路径中选择。
Dijkstra 的算法可以通过在以求解状态为源的反转图(所有边都反转其方向)上完全执行算法来轻松计算,直到它解决整个图(删除其停止标准)。
请注意,在您的特定情况下,不需要图形反转,因为您游戏中的图形是双向的(可以通过再次执行来撤消任何回合)。
解决方案
应用上述理论产生一个看起来像的伪代码
Graph graph = generateGraph(); // all possible game states and turns
int[][] solvedState = [[1,1,1],[1,1]];
List<Path> allShortestPaths = Dijkstra.shortestPathFromSourceToAllNodes(solvedState);
Path longestShortestPath = Collections.max(allPaths);
前段时间我创建了一个 Java 库来解决最短路径问题,Maglev。使用该库,完整代码为:
import de.zabuza.maglev.external.algorithms.Path;
import de.zabuza.maglev.external.algorithms.ShortestPathComputationBuilder;
import de.zabuza.maglev.external.graph.Graph;
import de.zabuza.maglev.external.graph.simple.SimpleEdge;
import de.zabuza.maglev.external.graph.simple.SimpleGraph;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Optional;
import java.util.StringJoiner;
public class GameTest {
public static void main(String[] args) {
Graph<GameState,SimpleEdge<GameState>> graph = generateGraph();
var algo = new ShortestPathComputationBuilder<>(graph).resetOrdinaryDijkstra()
.build();
GameState solvedState =
new GameState(new boolean[][] { { true,true,true },{ true,true } });
var pathTree = algo.shortestPathReachable(solvedState);
var longestShortestPath = pathTree.getLeaves()
.stream()
.map(pathTree::getPathTo)
.map(Optional::orElseThrow)
.max(Comparator.comparing(Path::getTotalCost))
.orElseThrow();
System.out.println("The longest shortest path has cost: " + longestShortestPath.getTotalCost());
System.out.println("The states are:");
System.out.println(longestShortestPath.iterator().next().getEdge().getSource());
for (var edgeCost : longestShortestPath) {
System.out.println("------------");
System.out.println(edgeCost.getEdge().getDestination());
}
}
private static Graph<GameState,SimpleEdge<GameState>> generateGraph() {
SimpleGraph<GameState,SimpleEdge<GameState>> graph = new SimpleGraph<>();
generateNodes(graph);
generateEdges(graph);
return graph;
}
private static void generateNodes(Graph<GameState,SimpleEdge<GameState>> graph) {
for (int i = 0; i < 1 << 9; i++) {
String boardString = String.format("%09d",Integer.parseInt(Integer.toBinaryString(i)));
graph.addNode(GameState.of(boardString,3,3));
}
}
private static void generateEdges(Graph<GameState,SimpleEdge<GameState>> graph) {
for (GameState source : graph.getNodes()) {
// Click on each field
boolean[][] board = source.getBoard();
for (int x = 0; x < board.length; x++) {
for (int y = 0; y < board[x].length; y++) {
GameState destination = new GameState(board);
destination.click(x,y);
graph.addEdge(new SimpleEdge<>(source,destination,1));
}
}
}
}
private static class GameState {
public static GameState of(String boardString,int rows,int columns) {
boolean[][] board = new boolean[rows][columns];
int i = 0;
for (int x = 0; x < rows; x++) {
for (int y = 0; y < columns; y++) {
board[x][y] = boardString.charAt(i) == '1';
i++;
}
}
return new GameState(board);
}
private final boolean[][] board;
private GameState(boolean[][] board) {
this.board = new boolean[board.length][];
for (int x = 0; x < board.length; x++) {
this.board[x] = new boolean[board[x].length];
for (int y = 0; y < board[x].length; y++) {
this.board[x][y] = board[x][y];
}
}
}
public boolean[][] getBoard() {
return board;
}
@Override
public String toString() {
StringJoiner rowJoiner = new StringJoiner("\n");
for (int x = 0; x < board.length; x++) {
StringJoiner row = new StringJoiner(" ");
for (int y = 0; y < board[x].length; y++) {
row.add(board[x][y] ? "1" : "0");
}
rowJoiner.add(row.toString());
}
return rowJoiner.toString();
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final GameState gameState = (GameState) o;
return Arrays.deepEquals(board,gameState.board);
}
@Override
public int hashCode() {
return Arrays.deepHashCode(board);
}
private void click(int x,int y) {
toggle(x,y);
toggle(x,y - 1);
toggle(x,y + 1);
toggle(x - 1,y);
toggle(x + 1,y);
}
private void toggle(int x,int y) {
if (x < 0 || y < 0 || x >= board.length || y >= board[x].length) {
return;
}
board[x][y] = !board[x][y];
}
}
}
这为您的问题提供了以下解决方案:
The longest shortest path has cost: 9.0
The states are:
1 1 1
1 1 1
1 1 1
------------
1 0 1
0 0 0
1 0 1
------------
1 0 1
1 0 0
0 1 1
------------
1 1 0
1 0 1
0 1 1
------------
1 1 0
1 0 0
0 0 0
------------
1 1 0
1 1 0
1 1 1
------------
0 0 1
1 0 0
1 1 1
------------
1 0 1
0 1 0
0 1 1
------------
0 1 1
1 1 0
0 1 1
------------
0 1 0
1 0 1
0 1 0
所以最糟糕的初始游戏状态是
0 1 0
1 0 1
0 1 0
而且,如果以最佳方式进行游戏,则需要 9 轮才能解决游戏。
一些琐事,游戏总共有 512 个状态(2^9
)和 4608 个可能的移动。
“熄灯”问题可以通过观察移动是 commutative 来简化,即如果你翻转以特定单元格为中心的加号,那么你翻转的顺序无关紧要它们进入。因此不需要通过图形的实际有序路径。我们还可以观察到每次移动都是自逆的,因此没有解决方案需要多次进行相同的移动,如果一组移动 m
是位置 p
的解决方案,那么 {{ 1}} 也从空板开始产生位置 m
。
这是基于这个观察的 Python 中的一个简短解决方案:我已经解决了所有 0 的目标,即“灯”是“灭”的,但是改变它来解决所有的目标是微不足道的1 秒。
- 常量列表
p
表示对于 9 种可能的移动中的每一种应该翻转哪些单元格。 -
masks
函数用于衡量一个解决方案需要多少个移动,给定一个位掩码,表示 9 个可能移动的子集。 -
bitcount
函数计算一组移动后的棋盘位置,使用异或运算来累积多次翻转的结果。 -
position
字典将每个可到达的棋盘位置映射到一个移动集列表,这些移动集从一个空棋盘开始生成它。事实证明,所有位置都可以通过一组移动到达,但如果事先不知道,那么列表字典会提供更通用的解决方案。 -
positions
部分根据需要找到最大化解决它所需的最小移动次数的位置。
max(...,min(...))
输出:
masks = [
int('110100000',2),int('111010000',int('011001000',int('100110100',int('010111010',int('001011001',int('000100110',int('000010111',int('000001011',]
def bitcount(m):
c = 0
while m:
c += (m & 1)
m >>= 1
return c
def position(m):
r = 0
for i in range(9):
if (1 << i) & m:
r ^= masks[i]
return r
from collections import defaultdict
positions = defaultdict(list)
for m in range(2**9):
p = position(m)
positions[p].append(m)
solution = max(positions,key=lambda p: min(map(bitcount,positions[p])))
print('board:',bin(solution))
print('moves:',','.join(map(bin,positions[solution])))
即“最差初始位置”是一个X形(四个角加上中心单元格都是1s),解决办法是9个动作都执行。
,根据 Zabuzard 的答案将拼图视为图形,然后从解决的节点开始执行广度优先搜索。您到达的最后一个节点是具有最长最短路径的集合之一。
,如果可能的话,我也会对解决或证明这个问题的纯数学论证感兴趣,而无需编写通过尝试解决问题的代码。
我提出的解决方案完全基于线性代数。
作为矩阵的板
可以将游戏解释为一组线性方程,可以使用标准线性方程求解技术求解。
总共有 9 种可能的操作(点击板的每个单元格一个)。我们在 9 个相应的矩阵中对每个动作必须翻转的单元格进行编码:
动作是可交换的和自逆的
由于条目在 中,将动作应用于给定的板就像将相应的动作矩阵添加到板矩阵一样简单。例如:
这意味着应用一组动作只不过是一些矩阵加法。矩阵加法是可交换的。这意味着它们的应用顺序无关紧要:
因此,对于任何初始游戏板,我们最多只需要应用每个动作一次,我们执行的顺序无关紧要。
线性方程组
这导致了等式:
对于初始游戏棋盘矩阵L
,系数,如果应该应用动作,则为1
,否则为0
;和 1
是表示游戏获胜的全1矩阵。
将L
移到另一边可以简化等式:
其中 L*
是 L
,但所有单元格都翻转了。
最后,这个方程可以改写为标准的线性方程组 Ax = b
,然后可以很容易地求解:
由于该矩阵具有最大秩 和非零行列式 ,因此 3x3 棋盘上的游戏始终是可解的,并且可以通过简单地求解线性方程组或应用Cramer's rule。
最差的初始板
由此可知,最差的初始棋盘是矩阵 L
,它最大化使用的系数 ,理想情况下所有 9
。
结果
0 1 0
1 0 1
0 1 0
是这样一个初始板,它需要为解决方案设置所有 9 个系数。 IE。解决系统
这也可以通过将所有系数设置为 1
并改为求解 L
来从相反方向获得:
产生的结果
0 1 0
1 0 1
0 1 0
再次为L
。