从游戏中学算法——鸣潮溢彩画解答算法探索

文章介绍了鸣潮2.2版本中溢彩画谜题的算法解法。通过将游戏问题转化为图论问题,采用DFS/BFS算法,并逐步优化:合并同色区域、减少无效操作、启发式搜索(A*算法)等策略,最终实现了高效解题。算法能在短时间内解决游戏中最难的关卡,并输出详细解题步骤。

算法#AStar
文章目录

玩法介绍

自从前几天鸣潮更新2.2版本后,我一如往常打开游戏跑图,这版本又更新了几个溢彩画谜题(对溢彩画谜题不熟悉的可以看看gantrol大佬的网站:溢彩画 | 鸣潮,可以在网站中体验该小游戏的玩法),简单来说就是在有限的步数内,将整个画板染成指定的颜色,每次点击时会将周围相连的同颜色方块一并染成你选择的颜色。

在之前版本中,该小游戏的谜题难度都十分克制,只要玩过两把基本可以一眼丁真:

新手教程

如上面这道新手教程,先用红色任意点击一个蓝色方块,使其连锁反应将所有相连的蓝色块染成红色,再点击黄色将剩余方块都染红即可通关

策略一般是先把分散的同色块合并

在解题时,一般遵循的思路是先把分散的同颜色块合并成一个整体,再去一步步合并其他颜色。

点击展开解题过程GIF

将分散块合并成整体

而在版本更新后,当我想继续一眼丁真解题拿奖励时,遇到了这样的关卡(在线体验该关卡):

乱花渐欲迷人眼

还有这样的关卡(在线体验该关卡)…

其中灰色的X方块是屏障,无法对其染色

不信邪的我花了不少时间去尝试,虽然最终艰难战胜,但我也开始思考,有没有可能用算法来解决溢彩画谜题🤔。

转成算法题

题目

有一个大小为 nmn * m 的矩阵,矩阵内每个元素(我们称之为格子)有b、r、y、g、x四个值之一(代表蓝红黄绿四种颜色以及屏障格),现在给你 kk 个步数,每一步你可以选择除了屏障格外任意一个格子染色为蓝、红、黄、绿四种颜色之一,染色会从该格子开始往上下左右四个方向的同颜色格子不断扩散,直到与该格子相连的一片同颜色格子都染成指定颜色。

你需要计算在 kk 步内能否将整个矩阵染成指定颜色cc,如果能则输出其中一种染色方案,如果不能则输出No solution。

其中 n,m,k<10n, m, k < 10

题目分析

本题属于经典的洪水填充(Flood Fill)问题的一类子问题,关于洪水填充问题,有一篇论文进行了详细分析。简单来说,对于颜色数c3c\geq3的情况下,该问题属于NP难问题,所以接下来的各类算法以及优化策略旨在加快小数值规模下(n,m,k<10n, m, k < 10)的计算速度,无法覆盖所有情况。

前置代码

首先设计一下与解题思路无关的代码,这不是正经的算法竞赛题目,所以尽量利用工程化设计来简化题目的主流程代码,也更方便大家理解(代码基于JDK24):

ColorPaintingSolver.java
import java.util.List;
/**
* 溢彩画解题器接口
*/
public interface ColorPaintingSolver {
/**
* 解决溢彩画问题
*
* @param grid 颜色矩阵
* @param maxStep 最大步数
* @param targetColor 目标颜色
* @return 解题步骤,找不到方案则返回null
*/
List<Step> solve(Color[][] grid, int maxStep, Color targetColor);
/**
* 对于支持记录尝试次数的解题器,返回尝试次数
*
* @return 尝试次数
*/
default long getTryCount() {
return -1;
}
}
Color.java
/**
* 颜色枚举
*/
public enum Color {
BLUE('b', "\u001B[104;30m"),
RED('r', "\u001B[101;30m"),
YELLOW('y', "\u001B[103;30m"),
GREEN('g', "\u001B[102;30m"),
BLOCK('x', "\u001B[8m"),
;
/**
* 题目中代表该颜色的字符
*/
public final char c;
/**
* 用于打印题目用的颜色码
*/
public final String asciiColor;
Color(char c, String asciiColor) {
this.c = c;
this.asciiColor = asciiColor;
}
public static Color of(char c) {
for (Color color : values()) {
if (color.c == c) {
return color;
}
}
throw new IllegalArgumentException("unknown color: " + c);
}
public String toString(int id) {
if (this == BLOCK) {
return asciiColor + " " + "\u001B[0m";
}
return asciiColor + String.format("%2d ", id) + "\u001B[0m";
}
public String toString() {
return asciiColor + name() + "\u001B[0m";
}
}
Point.java
/**
* 坐标
*/
public record Point(int row, int col) {
@Override
public String toString() {
return "(" + row + ", " + col + ")";
}
}
Step.java
/**
* 一次步骤,包含点击坐标和颜色
*/
public record Step(Point click, Color color) {
@Override
public String toString() {
return "click " + click + " with " + color;
}
}

然后再编写解题器运行逻辑,包含了题目定义、打印题目、解题耗时统计、打印答案等逻辑

import java.util.List;
public class ColorPaintingSolverRunner {
// 题目描述
record Question(String grid, int maxStep, Color targetColor) {
}
private static final Question[] questions = new Question[]{
// 以下为贝奥海域区域的题目
new Question("""
ggbbbrbbbb
gyyyyyyyyb
brbbbrbbyb
brbbrrrryr
yryyyybbyb
brbbybbbyb
brrrrrrrrg
bbbbybbbgg
""", 4, Color.BLUE),
new Question("""
rrrrrrrggg
rgyyyyyygg
rgybbbbybb
rgybggrygb
rgybggrygb
rrybrrrygb
ggyyyyyygb
gggbbbbbbb
""", 3, Color.GREEN),
new Question("""
rrrrxrbrrr
rbbbxbbbbb
rbrryyyybr
rbryxxyyxx
xxyyxxyrbb
bbyyyyrrbr
bbbbbxbbrr
bbbbbxbrrr
""", 5, Color.YELLOW),
new Question("""
grggbbggrg
rrbbbbbbrr
rbbbbbbbbr
gbbbybbybg
gbbbybbybg
gbbbybbybg
ggbbbbbbgg
grrrggrrrg
""", 3, Color.YELLOW),
new Question("""
rryggggggr
rxyxxxxxxr
rxybbbbyxr
rxybyybyxr
rxybyybyxr
rxybbbbyxr
rxxxxxxyxr
rggggggyrr
""", 3, Color.RED),
// 2.2版本更新后无障碍格最难的一个题目
new Question("""
bbrrbbryyg
byygggbgrg
rrbgryggrr
rbbyryrbbr
rygbbyrbyg
yggrrggbgr
bryygbryyg
bggybbrygg
""", 7, Color.BLUE),
// 以下为阿维纽林区域的题目
new Question("""
rrbrrrgggy
rxxxxrbbbb
ggggggggxx
ggbbxxrrrx
gggggggggx
gggrrrbbrr
gbbxbrxxxr
yrrgrrbrbr
""", 5, Color.YELLOW),
new Question("""
yxxggggbby
ybbrrrryby
ybyggggbby
ybbrrrryyy
yyyggggbby
ybbrrrryby
ybyggggbby
ybbrrrrxxy
""", 5, Color.YELLOW),
new Question("""
yyyyggggrx
rrrbbbbgrx
ryrygyygrx
rxrxgxygrx
rxrygxggrx
rxryyxyyyx
rxxxxxbbyy
rrrbbbbbyy
""", 4, Color.YELLOW),
new Question("""
rryrryrrbr
bbybbybbbb
rryrryrrbr
bbbbbybbbb
rryrryrrbr
bbbbbbbbbb
rryrryrrbr
rryrryrrbr
""", 4, Color.RED),
new Question("""
byyybbyyyb
yyybbbbyyy
rrrbbbbrrr
brrrbbrrrb
bbbrrrrbbb
yyybrrbyyy
bbgrrrrgbb
ggggrrgggg
""", 3, Color.BLUE),
// 2.2版本更新后带障碍格最难的一个题目
new Question("""
rbbbgxrbgr
rbxrxrrxbx
xxryybygrr
rbxyrbbggr
yggggxbyyg
yrrxrryggx
grxxyyxbbr
yyyxxgxxrr
""", 7, Color.RED),
};
public static void main(String[] args) throws Exception {
int currQues = 1;
for (Question ques : questions) {
Color[][] grid = parseGrid(ques.grid());
// 打印题目
System.out.println("\nQuestion " + currQues++ + ":");
System.out.println(ques.maxStep() + " steps to make matrix ALL " + ques.targetColor());
System.out.println("grid:");
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[i].length; j++) {
System.out.print(grid[i][j].toString(i * grid[i].length + j));
}
System.out.println();
}
long currTime = System.nanoTime();
var solver = new NoOpSolver(); // 换成实际的解题算法实现类
List<Step> solution = solver.solve(grid, ques.maxStep(), ques.targetColor());
// 打印耗时
System.out.print("solve in \u001B[92m" + (System.nanoTime() - currTime) / 1000000.0 + "ms" + "\u001B[0m");
// 如果解题器支持,打印尝试次数
if (solver.getTryCount() >= 0) {
System.out.print(" with \u001B[92m" + solver.getTryCount() + "\u001B[0m tries");
}
System.out.println();
// 打印答案
if (solution == null) {
System.out.println("No solution");
} else {
for (Step step : solution) {
System.out.println(step);
}
}
}
}
private static Color[][] parseGrid(String grid) {
return grid.lines()
.map(line -> line.chars()
.mapToObj(c -> Color.of((char) c))
.toArray(Color[]::new)
)
.toArray(Color[][]::new);
}

这里我编写了一个解题器的空实现类NoOpSolver,直接返回null作为答案,尝试运行便能在控制台打印如下信息:

打印题目、解题时间和结果

接下来就正式进入解题环节吧!

常规思路

对于这种多步骤的图论类型题目,第一个想到的就是DFS(深度优先搜索)或BFS(广度优先搜索)算法,通过遍历每一种走法来尝试正确答案。DFS和BFS的时间复杂度是一致的,在本题中皆为 O((NM)k)O((NM)^k) ,这里以DFS为例,其伪代码如下:

public List<Step> solve(Color[][] grid, int maxStep, Color targetColor) {
List<Step> steps = new ArrayList<>();
// 遍历整个矩阵,尝试下一步
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[i].length; j++) {
Color[][] nextGrid = deepClone(grid); // 拷贝一份矩阵
Point nextPos = new Point(i, j);
for (Color nextColor : Color.values()) {
// 颜色一致没必要尝试
if (nextColor == grid[i][j])
continue;
// 开始dfs
if (dfs(nextGrid, steps, maxStep, nextPos, nextColor)) {
return steps; // 某个分支找到解了,直接返回
}
}
}
}
return null;
}
/**
* DFS搜索
*
* @param grid 颜色矩阵
* @param steps 已尝试的步骤
* @param remainingStep 剩余步数
* @param clickPos 本次点击的坐标
* @param clickColor 本次使用的颜色
* @return 是否找到解
*/
private boolean dfs(Color[][] grid, List<Step> steps, int remainingStep, Point clickPos, Color clickColor) {
// 步数用完了
if (remainingStep == 0) {
return false;
}
// 执行染色逻辑
draw(grid, clickPos, clickColor);
// 记录步骤
steps.addLast(new Step(clickPos, clickColor));
// 判断染色后是否整个矩阵都染成目标颜色
if (isAllTargetColor(grid)) {
return true;
}
// 遍历整个矩阵,尝试下一步
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[i].length; j++) {
Color[][] nextGrid = deepClone(grid); // 不能让下一个步骤影响当前状态
Point nextPos = new Point(i, j);
for (Color nextColor : Color.values()) {
// 颜色一致没必要尝试
if (nextColor == grid[i][j])
continue;
// 递归执行下一步
if (dfs(nextGrid, steps, remainingStep - 1, nextPos, nextColor)) {
return true; // 某个分支找到解了,直接返回
}
}
}
}
// 当前分支下都不能解决,则回溯步骤
steps.removeLast();
return false;
}

对于鸣潮中溢彩画的数据规模(最大步数7,每一步可选8行10列中任意一格,并且可选3种颜色(除了当前颜色)),不难得出DFS/BFS尝试的方案数最多来到了

(8103)74.591016(8 * 10 * 3) ^ 7 \approx 4.59 * 10^{16}

对于普通计算机来说,这是个非常大的数字!所以我们需要考虑优化。

合并同颜色的邻接格子

通过观察题目,我们不难发现这个游戏的特点,对于一片颜色相同并且相连的格子(下称连通分区),我们点击其中哪一格的结果都是一致的,因为最终都会使得我们选择的颜色覆盖整个连通分区,那么我们可以对颜色矩阵做预处理,将其分离成数个连通分区,并计算好邻接表用于后续的颜色扩张,这样我们在DFS搜索时只对每个连通分区做3次(颜色)尝试,便可大大降低尝试的方案数量。

还是以这个复杂关卡为例

以上图为例,经过预处理后,可以将矩阵分割成37个分区,这时DFS尝试的方案数最多来到了

(373)72.081014(37 * 3) ^ 7 \approx 2.08 * 10^{14}

以下是伪代码:

class Region {
/**
* 该分区的一个坐标
*/
public Point clickPos;
/**
* 该分区的颜色
*/
public Color color;
/**
* 该分区的邻接分区索引列表
*/
public int[] neighbors;
}
public List<Step> solve(Color[][] grid, int maxStep, Color targetColor) {
Region[] regions = findRegionsFromGrid(grid);
List<Step> steps = new ArrayList<>();
for (int i = 0; i < regions.length; i++) {
for (Color nextColor : Color.values()) {
// 颜色一致没必要尝试
if (nextColor == regions[i].color)
continue;
// 开始DFS搜索
if (dfs(regions, steps, maxStep, i, nextColor)) {
return steps; // 找到解了
}
}
}
return null;
}
/**
* DFS搜索
*
* @param regions 连通分区
* @param steps 已尝试的步骤
* @param remainingStep 剩余步数
* @param clickRegion 本次点击的分区索引
* @param clickColor 本次使用的颜色
* @return 是否找到解
*/
private boolean dfs(Region[] regions, List<Step> steps, int remainingStep, int clickRegion, Color clickColor) {
// 步数用完了
if (remainingStep == 0) {
return false;
}
// 执行染色逻辑
draw(regions, clickRegion, clickColor);
// 记录步骤
steps.addLast(new Step(regions[clickRegion].clickPos, clickColor));
// 判断染色后是否整个矩阵都染成目标颜色
if (isAllTargetColor(regions)) {
return true;
}
// 遍历所有分区,尝试下一步
for (int nextRegion = 0; nextRegion < regions.length; nextRegion++) {
for (Color nextColor : Color.values()) {
// 颜色一致没必要尝试
if (nextColor == regions[nextRegion].color)
continue;
// 递归执行下一步
if (dfs(regions, steps, remainingStep - 1, nextRegion, nextColor)) {
return true; // 某个分支找到解了,直接返回
}
}
}
// 当前分支下都不能解决,则回溯步骤
steps.removeLast();
return false;
}

虽然依旧是个很大的数字,但不管怎么说,我们实打实地将方案数减少了2个数量级。那么,还有优化的空间吗?

只尝试邻接分区的颜色+染色后再合并

在上一步中,我们将相邻的同颜色方块合并成了连通分区,但其实合并不止一次,当我们通过染色将某分区变成和它的邻接分区颜色一致时,可以理解为我们将这几个分区合并成了一个更大的连通分区。而这个游戏的目标可以理解为通过不断合并零散的分区,最终将整个矩阵变成一个分区(当然,颜色需要是目标颜色)。所以随着游戏的进行,分区数量只会越来越少。

藉由这个思路,我们可以提出以下优化思路:

  1. 每次尝试只尝试该分区的邻接分区颜色,以剔除无效操作
  2. 每次尝试后重新计算连通分区,以合并等效操作

以下是优化后的伪代码(省略和前面一致的部分):

public List<Step> solve(Color[][] grid, int maxStep, Color targetColor) {
// 将颜色矩阵解析为分区
Region[] regions = findRegionsFromGrid(grid);
List<Step> steps = new ArrayList<>();
for (int i = 0; i < regions.length; i++) {
// 获取目标分区的邻接分区颜色集合
Set<Color> neighborsColors = getNeighborsColor(regions, regions[i].netghbors);
for (Color nextColor : neighborsColors) {
// 开始DFS搜索
if (dfs(regions, steps, maxStep, i, nextColor)) {
return steps; // 找到解了
}
}
}
return null;
}
/**
* DFS搜索
*
* @param regions 连通分区
* @param steps 已尝试的步骤
* @param remainingStep 剩余步数
* @param clickRegion 本次点击的分区索引
* @param clickColor 本次使用的颜色
* @return 是否找到解
*/
private boolean dfs(Region[] regions, List<Step> steps, int remainingStep, int clickRegion, Color clickColor) {
// 步数用完了
// 执行染色逻辑
// 记录步骤
// 判断染色后是否整个矩阵都染成同一个颜色
if (isAllUniformColor(regions)) {
// 要么已经是目标色,视为找到解
// 要么有多余步数可以把整个矩阵染成目标色,也可以视为找到解(为了防止一开始场上就没有目标颜色)
if (regions[0].color == targetColor || remainingStep > 1) {
return true;
}
}
// 将相邻且同色的分区合并
Region[] nextRegions = mergeRegions(regions);
// 遍历所有分区,尝试下一步
for (int nextId = 0; nextId < nextRegions.length; nextId++) {
// 获取目标分区的邻接分区颜色集合
Set<Color> neighborsColors = getNeighborsColor(nextRegions, nextRegions[nextId].netghbors);
for (Color nextColor : neighborsColors) {
// 递归执行下一步
if (dfs(nextRegions, steps, remainingStep - 1, nextId, nextColor)) {
return true; // 某个分支找到解了,直接返回
}
}
}
// 当前分支下都不能解决,则回溯步骤
}

这样,DFS走到越深的分支,往下需要尝试的分支数也越少,不过这一步的优化没法直观地计算减少了多少状态数。

根据剩余步数和场上颜色数量提前剪枝

到这里,不知道读者还记不记得这道题用DFS解法的时间复杂度 O((NM)k)O((NM)^k) ,前面我们一直在优化内层 NMNM 的大小,但是对时间复杂度影响最大的其实是指数 kk ,有没有什么办法对其进行优化呢?

我们注意到,理想状态下,一次点击操作会把场上的颜色A全部转换成颜色B(在场上的颜色A格子全部连在一个分区内时)。因此,不难得出以下结论:

剩余 NN 种非目标颜色的情况下最少需要 NN 步才能把矩阵全部染成目标颜色

剪枝原理

因此,我们可以在染色操作后进行判断,剩余步数不满足上述规则时提前剪枝,以减少无效搜索,以下是伪代码(省略和前面一致的部分):

/**
* 颜色统计结果
* @param count 剩余颜色种类数
* @param hasTargetColor 是否包含目标颜色
*/
record ColorCountResult(int count, boolean hasTargetColor) {
}
/**
* DFS搜索
*
* @param regions 连通分区
* @param steps 已尝试的步骤
* @param remainingStep 剩余步数
* @param clickRegion 本次点击的分区索引
* @param clickColor 本次使用的颜色
* @return 是否找到解
*/
private boolean dfs(Region[] regions, List<Step> steps, int remainingStep, int clickRegion, Color clickColor) {
// 步数用完了
// 执行染色逻辑
// 记录步骤
// 判断染色后是否整个矩阵都染成同一个颜色
ColorCountResult countResult = getColorCount(currRegions);
if (countResult.count == 1) {
if (currColor == targetColor) {
return true;
}
// 还有剩余步数,可以用1步把整个矩阵染成目标色
if (remainingStep > 1) {
steps.addLast(new Step(regions.get(currRegionId).clickPos, targetColor));
return true;
}
}
// 粗略判断剩余步数是否足够,不足直接剪枝
if ((countResult.count() - (countResult.hasTargetColor ? 1 : 0)) > remainingStep) {
counter.increment();
steps.removeLast();
return false;
}
// 将相邻且同色的分区合并
// 遍历所有分区,尝试下一步
// 当前分支下都不能解决,则回溯步骤
}

可以看到,对于部分关卡,优化后的提升效果十分明显:

优化前后对比

启发式搜索——有策略地选择下一步

到目前为止,对于DFS算法的优化貌似已经到头了,但是两个最难的关卡依旧没法高效地解决(跑了1分钟依旧没给出解答)。现在回过头来思考一下,我们人为解答溢彩画谜题的过程中并不是随机地去选择格子和颜色,而是选择最有希望能把分散的分区合并到一起的走法,但目前的DFS算法则是属于一条路走到黑,不行再换下一条路的模式。那么,有没有什么方式能让算法像人为解答一样,有策略地去选择最优的下一步去尝试?

启发式搜索就是这样一种思想,启发式搜索通过引入启发式函数,对搜索的每一个分支估算代价,进而选择代价最小的分支。

概念听起来很抽象,简单来说,原先的BFS/DFS算法对当前搜索节点下的所有分支一视同仁地去尝试,而启发式搜索则对分支进行估算,优先选择代价较小(或者说较有希望)的分支去尝试,以减少尝试次数。

启发式搜索算法中最有名的当属A*算法,常用于解答图论中的最优路问题。A*算法将启发式函数分为两部分:从起点到当前节点的实际代价 g(n)g(n) ,以及从当前节点到终点的预估代价 h(n)h(n) ,两者相加组成了最终的评估代价:

f(n)=g(n)+h(n)f(n)=g(n)+h(n)

在溢彩画谜题中,实际代价 g(n)g(n) 很好确定,就是当前使用了的步数,而预估代价 h(n)h(n) 则往往需要根据经验来确定,这里我们用场上剩余的连通分区数量作为评估代价,比较符合我们的直觉:尽可能多地合并连通分区。

A*算法在BFS算法的基础上作出的改进,所以这里代码上会有较大的改动,但是前面的一系列优化思路和剪枝条件依旧可以用在A*算法中,这里贴出最终的完整代码:

AStarSolver.java
import java.util.*;
/**
* A*算法解题器
*/
public class AStarSolver implements ColorPaintingSolver {
/**
* A*算法的状态
*/
private record State(List<Step> steps, Map<Integer, Region> regions, UnionFind mergedFind, int gWeight) implements Comparable<State> {
public State(State state) {
List<Step> steps = new ArrayList<>(state.steps.size() + 1);
steps.addAll(state.steps);
this(new ArrayList<>(steps), SolverUtil.copyRegions(state.regions), new UnionFind(state.mergedFind), state.gWeight);
}
private int g() {
return steps.size() * gWeight; // 平衡与h的权重
}
private int h() {
return regions.size(); // 剩余分区数
}
/**
* 点击分区,改变颜色
*/
public void click(int regionId, Color color) {
// 点击分区,改变颜色
regions.get(regionId).color = color;
// 添加点击步骤
steps.add(new Step(regions.get(regionId).clickPos, color));
// 合并分区
SolverUtil.mergeRegions(regions, regionId, mergedFind);
}
@Override
public int compareTo(State o) {
// 计算当前状态的启发式函数值用于优先级排序
return Integer.compare(this.g() + this.h(), o.g() + o.h());
}
/**
* 判断当前状态是否相同
* 只要所有分区的颜色相同就认为是相同的状态
* 用于记录访问过的状态
*/
@Override
public boolean equals(Object o) {
State state = (State) o;
// 从mergedFind中获取分区的当前被哪个分区合并了,进而获取到分区当前的颜色
int initialSize = mergedFind.initialSize();
for (int i = 0; i < initialSize; i++) {
Color color = regions.get(mergedFind.find(i)).color;
Color otherColor = state.regions.get(state.mergedFind.find(i)).color;
if (color != otherColor) {
return false;
}
}
return true;
}
@Override
public int hashCode() {
int hash = 7;
for (int i = 0; i < mergedFind.initialSize(); i++) {
hash = 31 * hash + regions.get(mergedFind.find(i)).color.ordinal() + 1;
}
return hash;
}
}
/**
* 并查集,用于记录分区被哪个分区合并了
* 以便于在A*状态的equals方法中判断状态是否相同
*/
public static class UnionFind {
private final int[] root;
public UnionFind(int n) {
root = new int[n];
for (int i = 0; i < n; i++) {
root[i] = i;
}
}
/**
* 从另一个并查集复制
*/
public UnionFind(UnionFind other) {
this.root = new int[other.root.length];
System.arraycopy(other.root, 0, this.root, 0, other.root.length);
}
public int find(int x) {
return root[x] == x ? x : (root[x] = find(root[x])); // 路径压缩
}
public void union(int parent, int son) {
int rootParent = find(parent);
int rootSon = find(son);
if (rootParent != rootSon) {
root[rootSon] = rootParent; // 合并
}
}
public int initialSize() {
return root.length;
}
}
// 用于统计尝试次数
int tryCount = 0;
@Override
public long getTryCount() {
return tryCount;
}
@Override
public List<Step> solve(Color[][] grid, int maxStep, Color targetColor) {
// 预处理,将颜色矩阵解析为分区
Map<Integer, Region> regions = SolverUtil.findRegionsFromGrid(grid);
// 计算g函数的权重
int gWeight = Math.max(1, regions.size() / maxStep / 2);
// 初始化优先队列
PriorityQueue<State> pq = new PriorityQueue<>();
State initialState = new State(new ArrayList<>(), regions, new UnionFind(regions.size()), gWeight);
pq.add(initialState);
// 记录访问过的状态
Map<State, Integer> visited = new HashMap<>();
while (!pq.isEmpty()) {
State currentState = pq.poll();
// 判断是否全是目标色
var colorCount = SolverUtil.getColorCount(currentState.regions, targetColor);
if (colorCount.count() == 1 && colorCount.hasTargetColor()) {
return currentState.steps;
}
// 步数充足,且可以加一步全染成目标色,也是一种解法
if (colorCount.count() == 1 && currentState.steps.size() < maxStep) {
Point firstClickPos = currentState.regions.values().iterator().next().clickPos;
currentState.steps.add(new Step(firstClickPos, targetColor));
return currentState.steps;
}
// 判断是否超过最大步数
if (currentState.steps.size() >= maxStep) {
continue;
}
// 判断颜色数和步骤数,进行剪枝
if (currentState.steps.size() + (colorCount.count() - (colorCount.hasTargetColor() ? 1 : 0)) > maxStep) {
continue;
}
// 下一轮状态
for (Region region : currentState.regions.values()) {
for (Color nextColor : SolverUtil.getNeighborsColor(currentState.regions, region.id)) {
// 计算新状态
State nextState = new State(currentState);
nextState.click(region.id, nextColor);
// 判断是否已经访问过步数一致或者更优的状态
if (visited.getOrDefault(nextState, Integer.MAX_VALUE) <= nextState.g()) {
continue; // 已经访问过,跳过
}
visited.put(nextState, nextState.g()); // 记录访问过的状态
// 添加新状态到优先队列
pq.add(nextState);
tryCount++;
}
}
}
return null; // 没有找到解
}
}
Region.java
import java.util.*;
/**
* 表示一个联通分区
*/
public class Region {
/**
* 该分区的索引
*/
public int id;
/**
* 该分区的一个可点击坐标
*/
public Point clickPos;
/**
* 该分区的颜色
*/
public Color color;
/**
* 该分区的邻接分区索引列表
*/
public Set<Integer> neighbors = new HashSet<>();
public Region(int id, Point clickPos, Color color) {
this.id = id;
this.clickPos = clickPos;
this.color = color;
}
public static Region copy(Region region) {
Region newRegion = new Region(region.id, region.clickPos, region.color);
newRegion.neighbors = new HashSet<>(region.neighbors);
return newRegion;
}
}
SolverUtil.java
import java.util.*;
/**
* 工具类,和解题算法无关的部分放在这里
*/
public class SolverUtil {
private static final int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
/**
* BFS算法寻找分区
*/
public static Map<Integer, Region> findRegionsFromGrid(Color[][] grid) {
Map<Integer, Region> regions = new HashMap<>();
int n = grid.length, m = grid[0].length;
int[][] regionIds = new int[n][m];
for (int[] row : regionIds) Arrays.fill(row, -1);
int currentId = 0;
// 组合联通块
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (regionIds[i][j] == -1 && grid[i][j] != Color.BLOCK) {
Color color = grid[i][j];
Queue<Point> q = new LinkedList<>();
q.add(new Point(i, j));
regionIds[i][j] = currentId;
while (!q.isEmpty()) {
Point p = q.poll();
for (int[] d : dirs) {
int x = p.row() + d[0], y = p.col() + d[1];
if (x >= 0 && x < n && y >= 0 && y < m) {
if (regionIds[x][y] == -1) {
if (grid[x][y] == color) {
regionIds[x][y] = currentId;
q.add(new Point(x, y));
}
}
}
}
}
Region region = new Region(currentId, new Point(i, j), color);
regions.put(currentId++, region);
}
}
}
// 建立邻接关系
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (regionIds[i][j] == -1) {
continue;
}
for (int[] d : dirs) {
int x = i + d[0], y = j + d[1];
if (x >= 0 && x < n && y >= 0 && y < m) {
if (regionIds[x][y] != -1 && regionIds[x][y] != regionIds[i][j]) {
regions.get(regionIds[i][j]).neighbors.add(regionIds[x][y]);
}
}
}
}
}
return regions;
}
/**
* 深拷贝分区表
*/
public static Map<Integer, Region> copyRegions(Map<Integer, Region> regions) {
Map<Integer, Region> copyRegions = new HashMap<>(regions.size() * 2);
for (Integer key : regions.keySet()) {
copyRegions.put(key, Region.copy(regions.get(key)));
}
return copyRegions;
}
/**
* 计算当前画面颜色数量
*/
public static ColorCountResult getColorCount(Map<Integer, Region> regions, Color targetColor) {
int colorCount = 0;
boolean hasTargetColor = false;
boolean[] colors = new boolean[4];
for (Region region : regions.values()) {
colors[region.color.ordinal()] = true;
}
for (int i = 0; i < colors.length; i++) {
if (colors[i]) {
colorCount++;
if (Color.values()[i] == targetColor) {
hasTargetColor = true;
}
}
}
return new ColorCountResult(colorCount, hasTargetColor);
}
/**
* 合并分区
*
* @param regions 分区表
* @param clickRegionId 点击的分区id
* @param mergedFind 用于记录被合并分区归属分区的并查集
*/
public static void mergeRegions(Map<Integer, Region> regions, int clickRegionId, AStarSolver.UnionFind mergedFind) {
Set<Integer> clickNeighbors = regions.get(clickRegionId).neighbors;
Integer[] clickNeighborsCopy = clickNeighbors.toArray(new Integer[0]);
for (int neighborId : clickNeighborsCopy) {
Region neighborRegion = regions.get(neighborId);
// 如果邻接分区的颜色和当前分区颜色相同,则合并
if (neighborRegion.color == regions.get(clickRegionId).color) {
// 删除被合并的分区
regions.remove(neighborId);
// 更新当前分区的邻接分区列表
for (int neighborNeighborId : neighborRegion.neighbors) {
if (neighborNeighborId != clickRegionId) {
Set<Integer> neighborNeighbors = regions.get(neighborNeighborId).neighbors;
// 从被删除的分区的邻接列表中移除被删除的分区
neighborNeighbors.remove(neighborId);
// 合并后被删除分区的邻接分区加入当前分区的邻接列表
neighborNeighbors.add(clickRegionId);
// 当前分区的邻接列表加入被删除分区的邻接分区
regions.get(clickRegionId).neighbors.add(neighborNeighborId);
}
}
clickNeighbors.remove(neighborId);
mergedFind.union(clickRegionId, neighborId);
}
}
}
/**
* 获取点击分区的邻接分区颜色
*
* @param regions 分区表
* @param clickRegionId 点击的分区id
* @return 邻接分区颜色列表
*/
public static Set<Color> getNeighborsColor(Map<Integer, Region> regions, int clickRegionId) {
Set<Color> neighborsColor = new HashSet<>(8);
for (int neighborId : regions.get(clickRegionId).neighbors) {
neighborsColor.add(regions.get(neighborId).color);
}
return neighborsColor;
}
}

最终效果展示

最终,A*算法成功地在较短时间内解出所有答案,这里放上鸣潮2.2版本所有新增溢彩画的测试结果,包含解答时间、尝试次数,以及算法计算出来的解答步骤(坐标从0开始):

1-6贝奥海域,7-12阿维纽林

25/08/08 更新:由于鸣潮更新了溢彩画册小活动,笔者又不想对着图片抄关卡,便写了个小工具用于自动识别关卡:鸣潮溢彩画识别