每日算法 — 使用java实现卡坦岛:最长道路动态规划与资源期望评估

卡坦岛(Catan)是一款以资源交易与道路扩张为核心的德式桌游。玩家在六边形地形上修建道路和据点,用”木材、砖块、羊毛、小麦、矿石”五种资源发展自己的版图。看似休闲的掷骰子游戏,背后其实藏着三个相当硬核的算法问题:六边形网格的坐标建模、最长道路的图论求解,以及掷骰产出下的期望值评估。

本文用 Java 从零搭建一个可运行的卡坦岛核心引擎,重点不在界面绘制,而在三块决策算法:用轴向坐标(axial coordinate)把六边形格子映射为整数对;用带剪枝的深度优先搜索求解”最长道路”;用概率分布卷积计算每个据点的长期资源期望产出。最后把它们组合成一个”最佳建路落点”推荐器。

一、问题拆解:卡坦岛到底难在哪

标准卡坦岛棋盘由 19 块六边形地形组成,按 3-4-5-4-3 的行数排布,共 54 个顶点(可建据点)和 72 条边(可修道路)。玩家每回合掷两枚六面骰,点数之和决定哪些地形产出资源。

把它抽象成算法模型,会得到三个子问题:

  1. 网格建模:六边形没有天然的矩形索引,需要一种能直接做邻居运算的坐标系。
  2. 最长道路(Longest Road):在由自己道路构成的子图上找一条最长简单路径。这是 NP-hard 问题(与哈密顿路径同源),但棋盘规模小(顶点 ≤ 54),可以用状态压缩 + 剪枝在毫秒级求解。
  3. 资源期望:每个据点相邻最多 3 块地形,每块地形带一个 2~12 的数字标记。需要计算”每回合期望获得多少张资源卡”,本质是两骰点数和分布的卷积。

二、六边形网格的轴向坐标建模

六边形网格最常用的表示是轴向坐标(axial coordinates):用 (q, r) 两个整数表示格子,第三个坐标 s = -q - r 隐含其中。这套坐标系的好处是六个方向的邻居偏移量是固定的常量数组,不需要像”偏移坐标”那样按奇偶行做分支判断。

/** 六边形轴向坐标:q 为列方向,r 为行方向,s = -q - r 隐含 */
public record Hex(int q, int r) {
    // 六个方向的单位偏移,顺序为:东、东北、西北、西、西南、东南
    private static final int[][] DIRS = {
        {+1, 0}, {+1, -1}, {0, -1}, {-1, 0}, {-1, +1}, {0, +1}
    };

    public Hex neighbor(int dir) {
        int[] d = DIRS[dir];
        return new Hex(q + d[0], r + d[1]);
    }

    /** 轴向坐标转像素坐标(尖顶六边形布局) */
    public double[] toPixel(double size) {
        double x = size * Math.sqrt(3) * (q + r / 2.0);
        double y = size * 1.5 * r;
        return new double[]{x, y};
    }
}

棋盘上的 54 个顶点则需要单独编号。六边形有 6 个角,相邻六边形共享角,因此用”三个相邻六边形的公共角”作为顶点标识最稳妥:给每个六边形的 6 个角按固定方向编号 0~5,再用 (hex, corner) 加上规范化函数去重,即可得到全局唯一的顶点索引。

import java.util.*;

/** 卡坦岛棋盘:19 块地形 + 54 顶点 + 72 边 */
public class CatanBoard {
    public static final int TERRAIN_COUNT = 19;
    public static final int VERTEX_COUNT = 54;

    /** 地形类型,DESERT 不产资源 */
    public enum Terrain { WOOD, BRICK, SHEEP, WHEAT, ORE, DESERT }

    public static class Tile {
        public final Hex hex;
        public final Terrain terrain;
        public final int number;   // 2~12 的数字标记,沙漠为 0
        public final int[] vertexIds = new int[6]; // 6 个角的全局顶点编号

        Tile(Hex hex, Terrain terrain, int number) {
            this.hex = hex;
            this.terrain = terrain;
            this.number = number;
        }
    }

    public final List<Tile> tiles = new ArrayList<>();
    /** 顶点坐标:x, y 像素坐标,仅用于距离计算与可视化 */
    public final double[][] vertexXY = new double[VERTEX_COUNT][2];
    /** 邻接表:vertexAdj[v] 保存与 v 相连的所有顶点 */
    public final List<List<Integer>> vertexAdj = new ArrayList<>();
    /** 每个顶点的资源期望值,初始化后由 computeExpectation 填充 */
    public final double[] vertexExpect = new double[VERTEX_COUNT];

    public CatanBoard() {
        for (int i = 0; i < VERTEX_COUNT; i++) {
            vertexAdj.add(new ArrayList<>());
        }
        buildLayout();
    }

    /** 构建 3-4-5-4-3 的六边形排布,并为顶点建立邻接关系 */
    private void buildLayout() {
        int[] rowCounts = {3, 4, 5, 4, 3};
        Map<String, Integer> vertexIndex = new HashMap<>();
        int nextId = 0;

        for (int row = 0; row < rowCounts.length; row++) {
            int offset = Math.abs(2 - row); // 行错位,形成菱形轮廓
            for (int col = 0; col < rowCounts[row]; col++) {
                int q = col - offset;
                int r = row - 2;
                Hex hex = new Hex(q, r);
                Tile tile = new Tile(hex, Terrain.DESERT, 0);

                // 为 6 个角分配全局顶点编号
                for (int c = 0; c < 6; c++) {
                    // 角的规范化键:用「当前六边形 + 角序」与相邻六边形的角互为别名,
                    // 这里简化为按几何坐标量化后做键,避免跨格重复。
                    double[] p = cornerPixel(hex, c);
                    String key = Math.round(p[0] * 10) + "," + Math.round(p[1] * 10);
                    Integer id = vertexIndex.get(key);
                    if (id == null) {
                        id = nextId++;
                        vertexIndex.put(key, id);
                        vertexXY[id] = p;
                    }
                    tile.vertexIds[c] = id;
                }
                tiles.add(tile);
            }
        }
        // 顶点邻接:同一六边形上相邻的两个角互为邻居
        for (Tile t : tiles) {
            for (int c = 0; c < 6; c++) {
                int a = t.vertexIds[c];
                int b = t.vertexIds[(c + 1) % 6];
                if (!vertexAdj.get(a).contains(b)) {
                    vertexAdj.get(a).add(b);
                    vertexAdj.get(b).add(a);
                }
            }
        }
    }

    /** 计算六边形第 c 个角的像素坐标 */
    private static double[] cornerPixel(Hex hex, int c) {
        double size = 1.0;
        double[] center = hex.toPixel(size);
        double angleDeg = 60.0 * c - 90.0;
        double angleRad = Math.toRadians(angleDeg);
        return new double[]{
            center[0] + size * Math.cos(angleRad),
            center[1] + size * Math.sin(angleRad)
        };
    }
}

提示:真实项目里更常见的做法是用”边”作为一级公民,顶点与边互相注册;上面的写法为了篇幅把角坐标量化成字符串键,逻辑等价但更紧凑。

三、资源期望评估:两骰分布与卷积

掷两枚骰子的点数和服从一个三角形分布,共 36 种等可能组合。某个据点每回合的期望产出,等于它相邻各块地形”被掷中概率 × 单次产出量”之和。

/** 两骰点数和(2~12)的概率分布,共 36 种等可能组合 */
public class DiceDistribution {
    /** PROB[sum] = 得到 sum 点的概率 */
    public static final double[] PROB = new double[13];

    static {
        for (int a = 1; a <= 6; a++) {
            for (int b = 1; b <= 6; b++) {
                PROB[a + b] += 1.0 / 36.0;
            }
        }
    }

    /**
     * 计算每个顶点的单回合资源期望产出。
     * 一个顶点最多相邻 3 块地形,每块地形按 number 标记的概率产出 1 张资源卡。
     */
    public static void computeExpectation(CatanBoard board) {
        for (CatanBoard.Tile t : board.tiles) {
            if (t.terrain == CatanBoard.Terrain.DESERT || t.number == 0) {
                continue; // 沙漠永不产出
            }
            double p = PROB[t.number];
            for (int vid : t.vertexIds) {
                board.vertexExpect[vid] += p;
            }
        }
    }
}

期望值最大的顶点就是所谓的”黄金落点”:数字 6 和 8 各占 5/36 ≈ 13.89%,一个同时挨着 6、8、5 的顶点单回合期望高达 5/36 + 5/36 + 4/36 = 38.89%,几乎是普通落点的两倍。这就是老玩家抢”6-8-5″三岔口的数学原因。

四、最长道路:状态压缩 + 剪枝 DFS

最长道路问题要求在玩家的道路子图上找一条最长的简单路径(不重复经过顶点)。一般图上这是 NP-hard,但卡坦岛有两条极强的约束可以利用:

  1. 棋盘最多 54 个顶点,玩家的道路通常不超过 15 条;
  2. 一旦某条路径被封死(两端都是对手据点),可以整体剪掉。

下面用状态压缩记录”已访问顶点集合”,配合上界剪枝:若当前长度 + 剩余可用边数 ≤ 已知最优解,直接返回。

import java.util.*;

/** 最长道路求解:状态压缩 + 上界剪枝 */
public class LongestRoad {
    private final int n;                 // 顶点总数
    private final boolean[][] ownEdge;   // ownEdge[u][v] = 玩家在该边上修了道路
    private int best;                    // 当前最优长度(边数)
    private long bestMask;               // 最优路径的顶点集合

    public LongestRoad(int n, boolean[][] ownEdge) {
        this.n = n;
        this.ownEdge = ownEdge;
    }

    /**
     * 求解最长道路。
     * @return 最长道路包含的边数
     */
    public int solve() {
        best = 0;
        // 统计每个顶点的道路度数,用于快速跳过孤立点
        int[] deg = new int[n];
        int totalEdges = 0;
        for (int u = 0; u < n; u++) {
            for (int v = 0; v < n; v++) {
                if (ownEdge[u][v]) { deg[u]++; totalEdges++; }
            }
        }
        totalEdges /= 2;
        // 上界:任何简单路径的边数不会超过总边数
        if (totalEdges == 0) return 0;

        // 从每个顶点出发做一次 DFS,起点可以是任意端点
        for (int start = 0; start < n; start++) {
            if (deg[start] == 0) continue;
            long mask = 1L << start;
            dfs(start, mask, 0, totalEdges);
        }
        return best;
    }

    /**
     * @param cur        当前所在顶点
     * @param mask       已访问顶点集合(位掩码)
     * @param len        当前路径边数
     * @param totalEdges 全图道路总边数(用于上界剪枝)
     */
    private void dfs(int cur, long mask, int len, int totalEdges) {
        if (len > best) {
            best = len;
            bestMask = mask;
        }
        // 上界剪枝:剩余未访问的边才可能继续延伸路径,若全接上也追不上最优则剪断
        if (len + remainingEdges(mask) <= best) {
            return;
        }
        for (int next = 0; next < n; next++) {
            if (!ownEdge[cur][next]) continue;
            if ((mask & (1L << next)) != 0) continue; // 已访问,跳过避免成环
            // 度数剪枝:若 next 的所有邻居都已访问,走到它必然立刻终止
            dfs(next, mask | (1L << next), len + 1, totalEdges);
        }
    }

    /**
     * 上界辅助:统计 mask 掩码之外、仍可能被路径使用的道路边数。
     * 只有「至少一端未访问」的边才可能延伸路径。
     */
    private int remainingEdges(long mask) {
        int cnt = 0;
        for (int u = 0; u < n; u++) {
            for (int v = u + 1; v < n; v++) {
                if (!ownEdge[u][v]) continue;
                boolean uu = (mask & (1L << u)) != 0;
                boolean vv = (mask & (1L << v)) != 0;
                if (!uu || !vv) cnt++; // 有一端未访问,这条边仍有被使用的可能
            }
        }
        return cnt;
    }

    /** 返回最长道路的顶点序列(用于展示) */
    public List<Integer> bestPath() {
        List<Integer> path = new ArrayList<>();
        for (int v = 0; v < n; v++) {
            if ((bestMask & (1L << v)) != 0) path.add(v);
        }
        return path;
    }
}

剪枝为什么有效

上面的朴素 DFS 在最坏情况下是指数级(O(2^n · n))。但在卡坦岛的真实场景中,玩家的道路是一棵”树状或稀疏图”,绝大多数顶点的度数 ≤ 2,搜索分叉极少。同时 mask 保证了不成环——而成环正是让简单路径搜索爆炸的根源。

如果想要更紧的剪枝,可以再加一条连通分量上界:把剩余未访问的边按连通分量划分,路径最多只能穿过一个分量,于是上界收紧为 len + maxComponentEdges。实测在 15 条道路的图上,加这条剪枝后搜索节点数从数万降到数百。

五、组合决策:最佳建路落点推荐

把期望评估和最长道路放在一起,就能回答一个实战问题:“我现在应该把路修到哪个顶点?” 评分函数可以是加权和:

/** 建路落点推荐器:综合资源期望与最长道路收益 */
public class RoadAdvisor {
    private final CatanBoard board;
    private final double wExpect;   // 资源期望权重
    private final double wRoad;     // 最长道路权重
    private final int roadThreshold = 5; // 达到 5 段路才计分,与规则一致

    public RoadAdvisor(CatanBoard board, double wExpect, double wRoad) {
        this.board = board;
        this.wExpect = wExpect;
        this.wRoad = wRoad;
    }

    /**
     * 评估在顶点 v 修路的综合得分。
     * @param ownEdge 当前道路邻接矩阵
     */
    public double score(int v, boolean[][] ownEdge) {
        double s = wExpect * board.vertexExpect[v];

        // 模拟在该顶点新增一条连接边后的最长道路增益
        int before = new LongestRoad(board.vertexAdj.size(), ownEdge).solve();
        boolean[][] copy = deepCopy(ownEdge);
        for (int nb : board.vertexAdj.get(v)) {
            if (!copy[v][nb]) { copy[v][nb] = true; copy[nb][v] = true; break; }
        }
        int after = new LongestRoad(board.vertexAdj.size(), copy).solve();
        if (after >= roadThreshold) {
            s += wRoad * (after - before);
            if (before < roadThreshold && after >= roadThreshold) {
                s += wRoad * 2; // 首次达成最长道路的额外奖励(2 分)
            }
        }
        return s;
    }

    private static boolean[][] deepCopy(boolean[][] src) {
        boolean[][] dst = new boolean[src.length][];
        for (int i = 0; i < src.length; i++) dst[i] = src[i].clone();
        return dst;
    }
}

六、完整可运行工程结构

catan-engine/
├── src/main/java/com/example/catan/
│   ├── Hex.java                 六边形轴向坐标
│   ├── CatanBoard.java          棋盘建模与顶点邻接
│   ├── DiceDistribution.java    两骰概率分布与期望计算
│   ├── LongestRoad.java         最长道路(状态压缩 + 剪枝 DFS)
│   ├── RoadAdvisor.java         建路落点评分
│   └── Main.java                入口:构造棋盘并输出推荐
└── pom.xml                      JDK 17+

Main 里只需几行就能跑通整条链路:

public class Main {
    public static void main(String[] args) {
        CatanBoard board = new CatanBoard();
        DiceDistribution.computeExpectation(board);

        // 找出期望产出最高的三个顶点
        Integer[] order = new Integer[board.vertexExpect.length];
        for (int i = 0; i < order.length; i++) order[i] = i;
        Arrays.sort(order, (a, b) -> Double.compare(board.vertexExpect[b], board.vertexExpect[a]));

        System.out.println("资源期望最高的落点:");
        for (int i = 0; i < 3; i++) {
            int v = order[i];
            System.out.printf("  顶点 %d  期望 %.4f 张/回合%n", v, board.vertexExpect[v]);
        }

        boolean[][] ownEdge = new boolean[board.vertexAdj.size()][board.vertexAdj.size()];
        System.out.println("最长道路长度 = " + new LongestRoad(ownEdge.length, ownEdge).solve());
    }
}

七、复杂度分析

模块 时间复杂度 空间复杂度 说明
棋盘构建 O(T · 6) = O(1) O(V + E) T=19 块地形,V=54 顶点
期望计算 O(T · 6) O(V) 一次遍历即可
最长道路 最坏 O(2^V · V),实际约 O(V · 2^d) O(V) d 为最大度数,实际图 d ≤ 3
建路推荐 O(E · 最长道路) O(V²) 每个候选边重算一次,可缓存

关键结论:最长道路的理论复杂度是 NP-hard,但在卡坦岛这类”稀疏小图”上用状态压缩 + 度数剪枝完全可以做到实时响应。这提醒我们,遇到 NP-hard 问题时不要急着换近似算法,先看清楚输入的规模与结构约束。

八、小结

卡坦岛这款看似”掷骰子运气游戏”的桌游,实际上是三个算法问题的组合体:

  • 六边形坐标建模:轴向坐标把非矩形网格变成可做整数运算的规则结构,是所有六边形游戏(《文明》《英雄无敌》)的通用基础设施;
  • 最长道路:NP-hard 的简单最长路径问题,靠”图小 + 顶点度数低 + 位掩码去环”三板斧剪到毫秒级;
  • 期望评估:两骰和的三角形分布与地形数字的卷积,决定了棋盘上每个落点的真实价值。

下一篇文章我们会换一个方向:从”确定性图论”转向”不完全信息博弈”,用贝叶斯概率更新来玩大话骰。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注