Contents

用Java解决游戏 2048 的算法

1. 简介

**最近,我们研究了解决游戏 2048 的算法 。**我们从理论的角度讨论了这个问题,而不是背后的任何真实代码。

**在这里,我们将用 Java 编写一个实现。**这将扮演人类和计算机玩家的角色,展示如何玩出更优化的游戏。

2. 初始设置

我们需要的第一件事是一个设置,我们可以在其中玩游戏并查看进展情况。

这将为我们提供玩游戏所需的所有构造,并完全实现计算机播放器——无论如何它只会放置随机图块。这为我们提供了实现“人类”玩家玩游戏的空间。

2.1. 棋盘

首先,我们需要一个棋盘。这是一个可以放置数字的单元格网格。

为了让一些事情更容易处理,让我们从一个简单的单元格位置表示开始。这实际上只是一对坐标的包装:

public class Cell {
    private final int x;
    private final int y;
    // constructor, getters, and toString
}

我们现在可以编写一个类来表示板本身。这会将值存储在一个简单的二维数组中,但允许我们通过上面的Cell类访问它们:

public class Board {
    private final int[][] board;
    private final int score;
    public Board(int size) {
        this.board = new int[size][];
        this.score = 0;
        for (int x = 0; x < size; ++x) {
            this.board[x] = new int[size];
            for (int y = 0; y < size; ++y) {
                board[x][y] = 0;
            }
        }
    }
    public int getSize() {
        return board.length;
    }
    public int getScore() {
        return score;
    }
    public int getCell(Cell cell) {
        return board[cell.getX()][cell.getY()];
    }
    public boolean isEmpty(Cell cell) {
        return getCell(cell) == 0;
    }
    public List<Cell> emptyCells() {
        List<Cell> result = new ArrayList<>();
        for (int x = 0; x < board.length; ++x) {
            for (int y = 0; y < board[x].length; ++y) {
                Cell cell = new Cell(x, y);
                if (isEmpty(cell)) {
                    result.add(cell);
                }
            }
        }
        return result;
    }
}

**这是一个代表棋盘的不可变类,让我们查询它以找出当前状态。**它还跟踪当前的分数,我们稍后会谈到。

2.2. 电脑玩家和放置棋子

现在我们有了一个棋盘,我们希望能够玩它。我们想要的第一件事是电脑玩家,因为这是一个纯粹的随机玩家,以后会完全按照需要。

电脑玩家只不过是在一个单元格中放置了一个棋子,所以我们需要一些方法来在我们的棋盘上实现这一点。我们希望保持它是不可变的,因此放置一个图块将在新状态下生成一个全新的棋盘。

首先,我们需要一个构造函数来获取实际的棋盘状态,而不是我们之前构造一个空白棋盘的构造函数:

private Board(int[][] board, int score) {
    this.score = score;
    this.board = new int[board.length][];
    for (int x = 0; x < board.length; ++x) {
        this.board[x] = Arrays.copyOf(board[x], board[x].length);
    }
}

这是private的,因此它只能被同一类中的其他方法使用。这有助于我们封装棋盘。

**接下来,我们将添加一个放置棋子的方法。**这将返回一个与当前板相同的全新棋盘,只是它在给定单元格中具有给定编号:

public Board placeTile(Cell cell, int number) {
    if (!isEmpty(cell)) {
        throw new IllegalArgumentException("That cell is not empty");
    }
    Board result = new Board(this.board, this.score);
    result.board[cell.getX()][cell.getY()] = number;
    return result;
}

最后,**我们将编写一个代表计算机玩家的新类。**这将有一个方法可以获取当前板并返回新棋盘:

public class Computer {
    private final SecureRandom rng = new SecureRandom();
    public Board makeMove(Board input) {
        List<Cell> emptyCells = input.emptyCells();
        double numberToPlace = rng.nextDouble();
        int indexToPlace = rng.nextInt(emptyCells.size());
        Cell cellToPlace = emptyCells.get(indexToPlace);
        return input.placeTile(cellToPlace, numberToPlace >= 0.9 ? 4 : 2);
    }
}

**这会从板上获取每个空单元格的列表,随机选择一个,然后将一个数字放入其中。**我们将随机决定在 10% 的情况下将“4”放入单元格,在其他 90% 的情况下放入“2”。

2.2. 一个“人类”玩家和移动棋子

接下来我们需要的是一个“人类”玩家。**这不是最终目标,而是一个纯粹随机的玩家,每次移动时都会选择一个随机方向来移动棋子。**然后,这将成为我们可以建立最佳玩家的地方。

首先,我们需要定义可以进行的可能移动的枚举:

public enum Move {
    UP,
    DOWN,
    LEFT,
    RIGHT
}

**接下来,我们需要增加Board类以支持通过在这些方向之一上移动棋子来进行移动。**为了降低这里的复杂性,我们想要旋转棋盘,这样我们总是在同一个方向移动棋子。

这意味着我们需要一种转置和反转板的方法:

private static int[][] transpose(int[][] input) {
    int[][] result = new int[input.length][];
    for (int x = 0; x < input.length; ++x) {
        result[x] = new int[input[0].length];
        for (int y = 0; y < input[0].length; ++y) {
            result[x][y] = input[y][x];
        }
    }
    return result;
}
private static int[][] reverse(int[][] input) {
    int[][] result = new int[input.length][];
    for (int x = 0; x < input.length; ++x) {
        result[x] = new int[input[0].length];
        for (int y = 0; y < input[0].length; ++y) {
            result[x][y] = input[x][input.length - y - 1];
        }
    }
    return result;
}

转置板将交换所有行和列,使得顶部边缘变为左侧边缘。反转板只是镜像它,使左边缘变成右边缘。

接下来,我们向Board添加一个方法,以在给定方向上移动,并以新的状态返回一个新的Board

我们首先制作棋盘状态的副本,然后我们可以使用它:

public Board move(Move move) {
    int newScore = 0;
    // Clone the board
    int[][] tiles = new int[this.board.length][];
    for (int x = 0; x < this.board.length; ++x) {
        tiles[x] = Arrays.copyOf(this.board[x], this.board[x].length);
    }

接下来,我们操纵我们的副本,以便我们总是向上移动棋子:

if (move == Move.LEFT || move == Move.RIGHT) {
    tiles = transpose(tiles);
}
if (move == Move.DOWN || move == Move.RIGHT) {
    tiles = reverse(tiles);
}

我们还需要另一组棋子——这次是我们将构建最终结果的棋子——以及一个用于跟踪此次移动获得的新分数的跟踪器:

int[][] result = new int[tiles.length][];
int newScore = 0;

现在我们已经准备好开始移动棋子了,并且我们已经操纵了一些东西,以便我们始终朝着同一个方向工作,我们可以开始了。

**我们可以独立于其他列移动每一列。**我们只需要遍历列并重复,从构建我们正在移动的棋子的另一个副本开始。

这次我们将它们构建到LinkedList中,因为我们希望能够轻松地从中弹出值。我们也只添加具有数字的实际图块并跳过空图块。

这实现了我们的平移,但还没有实现棋子的合并:

for (int x = 0; x < tiles.length; ++x) {
    LinkedList<Integer> thisRow = new LinkedList<>();
    for (int y = 0; y < tiles[0].length; ++y) {
        if (tiles[x][y] > 0) {
            thisRow.add(tiles[x][y]);
        }
    }

接下来,我们需要合并图块。我们需要与上述分开执行此操作;否则,我们可能会多次合并同一个图块。

这是通过从上面构建另一个棋子的LinkedList来实现的,但这次我们合并:

LinkedList<Integer> newRow = new LinkedList<>();
while (thisRow.size() >= 2) {
    int first = thisRow.pop();
    int second = thisRow.peek();
    if (second == first) {
        int newNumber = first * 2;
        newRow.add(newNumber);
        newScore += newNumber;
        thisRow.pop();
    } else {
        newRow.add(first);
    }
}
newRow.addAll(thisRow);

在这里,我们还计算了这一举动的新分数。这是由于合并而创建的图块的总和。

我们现在可以将其构建到结果数组中。一旦我们用完列表中的图块,其余部分将填充值“0”以表示它们是空白的:

    result[x] = new int[tiles[0].length];
    for (int y = 0; y < tiles[0].length; ++y) {
        if (newRow.isEmpty()) {
            result[x][y] = 0;
        } else {
            result[x][y] = newRow.pop();
        }
    }
}

一旦我们完成了瓷砖的移动,我们需要再次将它们操作回正确的旋转。这与我们之前所做的完全相反:

if (move == Move.DOWN || move == Move.RIGHT) {
    result = reverse(result);
}
if (move == Move.LEFT || move == Move.RIGHT) {
    result = transpose(result);
}

最后,我们可以用这组新的棋子和新计算的分数构建并返回一个新的棋盘:

    return new Board(result, this.score + newScore);
}

**我们现在可以编写随机的“人类”玩家。**这只不过是生成一个随机移动并调用上述方法来播放该移动:

public class Human {
    private SecureRandom rng = new SecureRandom();
    public Board makeMove(Board input) {
        Move move = Move.values()[rng.nextInt(4)];
        return input.move(move);
    }
}

2.3. 玩游戏

**我们有足够的组件来玩这个游戏,虽然不是很成功。**但是,很快我们将改进Human类的游戏方式,这将使我们能够轻松地看到差异。

首先,我们需要一种打印棋盘的方法。

对于这个例子,我们只是要打印到控制台,所以System.out.print已经足够好了。对于一个真正的游戏,我们想要做更好的图形:

private static void printBoard(Board board) {
    StringBuilder topLines = new StringBuilder();
    StringBuilder midLines = new StringBuilder();
    for (int x = 0; x < board.getSize(); ++x) {
        topLines.append("+--------");
        midLines.append("|        ");
    }
    topLines.append("+");
    midLines.append("|");
    for (int y = 0; y < board.getSize(); ++y) {
        System.out.println(topLines);
        System.out.println(midLines);
        for (int x = 0; x < board.getSize(); ++x) {
            Cell cell = new Cell(x, y);
            System.out.print("|");
            if (board.isEmpty(cell)) {
                System.out.print("        ");
            } else {
                StringBuilder output = new StringBuilder(Integer.toString(board.getCell(cell)));
                while (output.length() < 8) {
                    output.append(" ");
                    if (output.length() < 8) {
                        output.insert(0, " ");
                    }
                }
                System.out.print(output);
            }
        }
        System.out.println("|");
        System.out.println(midLines);
    }
    System.out.println(topLines);
    System.out.println("Score: " + board.getScore());
}

我们差不多准备好了。我们只需要进行设置。

这意味着创建棋盘、两名玩家,并让计算机进行两个初始动作——即在棋盘上放置两个随机数:

Board board = new Board(4);
Computer computer = new Computer();
Human human = new Human();
for (int i = 0; i < 2; ++i) {
    board = computer.makeMove(board);
}

现在我们有了实际的游戏循环。这将是人类和计算机玩家轮流进行的重复,只有在没有空单元格时才停止:

printBoard(board);
do {
    System.out.println("Human move");
    System.out.println("==========");
    board = human.makeMove(board);
    printBoard(board);
    System.out.println("Computer move");
    System.out.println("=============");
    board = computer.makeMove(board);
    printBoard(board);
} while (!board.emptyCells().isEmpty());
System.out.println("Final Score: " + board.getScore());

此时,如果我们要运行该程序,我们会看到正在玩 2048 的随机游戏。

3. 实现 2048 播放器

一旦我们有了玩游戏的基础,我们就可以开始实现“人类”玩家并玩更好的游戏,而不仅仅是选择随机方向。

3.1. 模拟动作

我们在这里实现的算法是基于Expectimax 算法的。因此,算法的核心是模拟每一个可能的动作,为每一个动作分配一个分数,然后选择一个做得最好的动作。

我们将大量使用Java 8 Streams 来帮助构建此代码,原因我们稍后会看到。

我们将从在Human类中重写 *makeMove()*方法开始:

public Board makeMove(Board input) {
    return Arrays.stream(Move.values())
      .map(input::move)
      .max(Comparator.comparingInt(board -> generateScore(board, 0)))
      .orElse(input);
}

对于我们可以移动的每一个可能的方向,我们生成新的棋盘,然后开始评分算法——通过这个棋盘,深度为 0。然后我们选择得分最高的棋步。

然后,我们的*generateScore()*方法模拟每一个可能的计算机移动——也就是说,将“2”或“4”放入每个空单元格——然后看看接下来会发生什么:

private int generateScore(Board board, int depth) {
    if (depth >= 3) {
        return calculateFinalScore(board);
    }
    return board.emptyCells().stream()
      .flatMap(cell -> Stream.of(new Pair<>(cell, 2), new Pair<>(cell, 4)))
      .mapToInt(move -> {
          Board newBoard = board.placeTile(move.getFirst(), move.getSecond());
          int boardScore = calculateScore(newBoard, depth + 1);
          return (int) (boardScore * (move.getSecond() == 2 ? 0.9 : 0.1));
      })
      .sum();
}

如果我们达到了我们的深度限制,那么我们将立即停下来计算这个板有多好的最终分数;否则,我们继续我们的模拟。

然后,我们的*calculateScore()*方法是我们模拟的延续,运行等式的人类移动方面。

这与上面的*makeMove()*方法非常相似,但我们返回的是正在进行的分数而不是实际的棋盘:

private int calculateScore(Board board, int depth) {
    return Arrays.stream(Move.values())
      .map(board::move)
      .mapToInt(newBoard -> generateScore(newBoard, depth))
      .max()
      .orElse(0);
}

3.2. 计分板

我们现在处于可以模拟人类和计算机玩家来回移动的情况,当我们模拟足够多时停止。我们需要能够为每个模拟分支中的最终板生成一个分数,以便我们可以看到哪个分支是我们想要追求的分支。

我们的评分是多个因素的组合,我们将把每个因素应用到板上的每一行和每一列。这些都加在一起,然后返回总数。

因此,我们需要生成要评分的行和列列表:

List<List<Integer>> rowsToScore = new ArrayList<>();
for (int i = 0; i < board.getSize(); ++i) {
    List<Integer> row = new ArrayList<>();
    List<Integer> col = new ArrayList<>();
    for (int j = 0; j < board.getSize(); ++j) {
        row.add(board.getCell(new Cell(i, j)));
        col.add(board.getCell(new Cell(j, i)));
    }
    rowsToScore.add(row);
    rowsToScore.add(col);
}

然后我们取出我们建立的列表,对每个列表进行评分,然后将分数相加。这是我们即将填写的占位符:

return rowsToScore.stream()
    .mapToInt(row -> {
        int score = 0;
        return score;
    })
    .sum();

最后,我们实际上需要生成我们的分数。这进入了上面的 lambda,并且是几个不同的因素都有助于

  • 每行的固定分数
  • 行中每个数字的总和
  • 行中可能的每个合并
  • 行中的每个空单元格
  • 行的单调性。这表示该行按数字升序排列的数量。

在计算分数之前,我们需要构建一些额外的数据。

首先,我们想要一个删除了空白单元格的数字列表:

List<Integer> preMerged = row.stream()
  .filter(value -> value != 0)
  .collect(Collectors.toList());

然后我们可以从这个新列表中进行一些计数,给出具有相同数字的相邻单元格的数量,数字严格递增,数字严格递减:

int numMerges = 0;
int monotonicityLeft = 0;
int monotonicityRight = 0;
for (int i = 0; i < preMerged.size() - 1; ++i) {
    Integer first = preMerged.get(i);
    Integer second = preMerged.get(i + 1);
    if (first.equals(second)) {
        ++numMerges;
    } else if (first > second) {
        monotonicityLeft += first - second;
    } else {
        monotonicityRight += second - first;
    }
}

现在我们可以计算这一行的分数:

int score = 1000;
score += 250 * row.stream().filter(value -> value == 0).count();
score += 750 * numMerges;
score -= 10 * row.stream().mapToInt(value -> value).sum();
score -= 50 * Math.min(monotonicityLeft, monotonicityRight);
return score;

这里选择的数字是比较随意的。不同的数字将对游戏的表现产生影响,在我们的游戏方式中优先考虑不同的因素。

4. 算法改进

**到目前为止,我们所拥有的东西是有效的,我们可以看到它玩得很好,但是速度很慢。**每个人的移动大约需要 1 分钟。我们可以做得比这更好。

4.1. 并行处理

**我们可以做的显而易见的事情是并行工作。**这是使用 Java Streams 的巨大好处——我们可以通过向每个流添加单个语句来并行工作。

仅此更改就可以将我们的每次移动时间缩短到 20 秒左右。

4.2. 修剪不可播放的分支

**接下来我们可以做的就是修剪掉所有无法播放的分支。**也就是说,任何时候人类移动都会导致棋盘不变。几乎可以肯定,这些分支会导致更糟糕的结果——它们有效地让计算机自由移动——但它们花费了我们处理它们的时间。

为此,我们需要在Board上实现一个 equals 方法,以便我们可以比较它们:

@Override
public boolean equals(Object o) {
    if (this == o) {
        return true;
    }
    if (o == null || getClass() != o.getClass()) {
        return false;
    }
    Board board1 = (Board) o;
    return Arrays.deepEquals(board, board1.board);
}

然后,我们可以向我们的流管道添加一些过滤器,以停止处理任何未更改的内容。

return Arrays.stream(Move.values())
    .parallel()
    .map(board::move)
    .filter(moved -> !moved.equals(board))
    ........

这对游戏的早期部分影响很小——当填充的单元格很少时,可以修剪的动作也很少。然而,后来,这开始产生更大的影响,将移动时间减少到只有几秒钟。