每日算法 — 使用java实现自走棋:阵容羁绊回溯搜索与组合优化

引言

自走棋(Auto Chess)将策略棋盘与角色收集巧妙结合,玩家从共享卡池中抽取棋子,组建具有特定羁绊效果的阵容。核心算法挑战在于:给定当前持有的棋子和卡池状态,如何搜索出最强的羁绊组合? 本文用Java实现阵容羁绊的回溯搜索算法,通过剪枝策略应对组合爆炸问题,并结合贪心近似解法,为每一步决策提供算法支撑。

核心机制建模

自走棋的核心数据结构包括:棋子、羁绊、阵容规则。

基础数据模型

import java.util.*;

/**
 * 棋子:具有名称、费用、种族、职业等属性
 */
class ChessPiece {
    public final String name;
    public final int cost;           // 购买费用(1-5金币)
    public final String origin;      // 种族(如:约德尔人、虚空)
    public final String clazz;       // 职业(如:刺客、法师)
    public final int tier;           // 星级(1-3星)

    public ChessPiece(String name, int cost, String origin, String clazz) {
        this.name = name;
        this.cost = cost;
        this.origin = origin;
        this.clazz = clazz;
        this.tier = 1;
    }

    @Override
    public String toString() {
        return name + "(" + cost + "金 " + origin + "/" + clazz + ")";
    }
}

/**
 * 羁绊定义:达到特定数量棋子时触发效果
 */
class Synergy {
    public final String name;              // 羁绊名称
    public final String type;              // "origin"或"clazz"
    public final int[] thresholds;         // 触发阈值,如[2,4,6]
    public final String[] descriptions;    // 各等级的描述
    public final double[] powerBonus;      // 各等级的战力加成系数

    public Synergy(String name, String type, int[] thresholds, 
                   String[] descriptions, double[] powerBonus) {
        this.name = name;
        this.type = type;
        this.thresholds = thresholds;
        this.descriptions = descriptions;
        this.powerBonus = powerBonus;
    }

    /**
     * 根据棋子数量计算当前激活的羁绊等级(0表示未激活)
     */
    public int getActiveLevel(int count) {
        int level = 0;
        for (int t : thresholds) {
            if (count >= t) level++;
        }
        return level;
    }

    /**
     * 获取指定等级的战力加成
     */
    public double getPowerAtLevel(int level) {
        if (level <= 0) return 0;
        return powerBonus[Math.min(level - 1, powerBonus.length - 1)];
    }
}

/**
 * 阵容:场上的棋子组合
 */
class Team {
    public static final int MAX_SIZE = 9;  // 最大上场人数
    private final List<ChessPiece> pieces = new ArrayList<>();

    public boolean addPiece(ChessPiece piece) {
        if (pieces.size() >= MAX_SIZE) return false;
        pieces.add(piece);
        return true;
    }

    public List<ChessPiece> getPieces() {
        return new ArrayList<>(pieces);
    }

    /**
     * 统计各羁绊的触发数量
     */
    public Map<String, Integer> countSynergies() {
        Map<String, Integer> counts = new HashMap<>();
        for (ChessPiece p : pieces) {
            counts.merge(p.origin, 1, Integer::sum);
            counts.merge(p.clazz, 1, Integer::sum);
        }
        return counts;
    }

    /**
     * 计算阵容总战力(基础战力 + 羁绊加成)
     */
    public double calculatePower(Map<String, Synergy> synergyMap) {
        double basePower = pieces.stream().mapToDouble(p -> p.cost * 10.0 * p.tier).sum();

        Map<String, Integer> counts = countSynergies();
        double synergyBonus = 0;
        for (Map.Entry<String, Integer> entry : counts.entrySet()) {
            Synergy syn = synergyMap.get(entry.getKey());
            if (syn != null) {
                int level = syn.getActiveLevel(entry.getValue());
                synergyBonus += basePower * syn.getPowerAtLevel(level);
            }
        }

        return basePower + synergyBonus;
    }
}

羁绊数据库

/**
 * 羁绊数据库:预定义所有可用的羁绊规则
 */
class SynergyDatabase {
    public static Map<String, Synergy> createDefaultSynergies() {
        Map<String, Synergy> map = new HashMap<>();

        // 种族羁绊
        map.put("虚空", new Synergy("虚空", "origin", 
            new int[]{2, 4}, 
            new String[]{"虚空单位获得护甲穿透", "虚空单位造成真实伤害"},
            new double[]{0.15, 0.40}));

        map.put("约德尔人", new Synergy("约德尔人", "origin",
            new int[]{3, 6},
            new String[]{"约德尔人有20%闪避", "约德尔人有50%闪避且攻击回复法力"},
            new double[]{0.10, 0.35}));

        map.put("海盗", new Synergy("海盗", "origin",
            new int[]{3},
            new String[]{"战斗后获得额外金币"},
            new double[]{0.05}));

        map.put("龙族", new Synergy("龙族", "origin",
            new int[]{2, 4},
            new String[]{"龙族获得魔法抗性", "龙族免疫魔法伤害"},
            new double[]{0.20, 0.60}));

        map.put("贵族", new Synergy("贵族", "origin",
            new int[]{3, 6},
            new String[]{"随机一名友军获得护甲和治疗", "所有友军获得护甲和治疗"},
            new double[]{0.15, 0.45}));

        // 职业羁绊
        map.put("刺客", new Synergy("刺客", "clazz",
            new int[]{3, 6, 9},
            new String[]{"刺客+10%暴击", "刺客+20%暴击和+50%爆伤", "刺客+30%暴击和+100%爆伤"},
            new double[]{0.08, 0.25, 0.55}));

        map.put("法师", new Synergy("法师", "clazz",
            new int[]{3, 6},
            new String[]{"法师技能伤害+40%", "法师技能伤害+100%"},
            new double[]{0.20, 0.50}));

        map.put("骑士", new Synergy("骑士", "clazz",
            new int[]{2, 4, 6},
            new String[]{"骑士格挡15伤害", "骑士格挡30伤害", "骑士格挡60伤害"},
            new double[]{0.10, 0.25, 0.50}));

        map.put("射手", new Synergy("射手", "clazz",
            new int[]{2, 4},
            new String[]{"射手额外攻击1个目标", "射手额外攻击2个目标"},
            new double[]{0.12, 0.30}));

        map.put("护卫", new Synergy("护卫", "clazz",
            new int[]{2},
            new String[]{"战斗开始时护卫为相邻友军加护甲"},
            new double[]{0.15}));

        return map;
    }
}

回溯搜索算法

回溯搜索通过递归尝试每一种可能的阵容组合,记录最优解。对于9人口上限和50个候选棋子,组合数为C(50,9) ≈ 2.5×10⁹,必须进行强力剪枝。

/**
 * 阵容搜索器:使用回溯+剪枝寻找最优阵容
 */
public class TeamCompositionSolver {
    private final List<ChessPiece> candidatePool;      // 候选棋子池
    private final Map<String, Synergy> synergyMap;     // 羁绊规则
    private final int maxTeamSize;                     // 人口上限

    // 搜索状态
    private double bestPower = 0;
    private List<ChessPiece> bestTeam = new ArrayList<>();
    private int searchCount = 0;                       // 统计搜索节点数
    private int pruneCount = 0;                        // 统计剪枝次数

    public TeamCompositionSolver(List<ChessPiece> pool, 
                                  Map<String, Synergy> synergyMap, 
                                  int maxTeamSize) {
        this.candidatePool = new ArrayList<>(pool);
        this.synergyMap = synergyMap;
        this.maxTeamSize = maxTeamSize;
    }

    /**
     * 主入口:启动回溯搜索
     */
    public SearchResult solve() {
        bestPower = 0;
        bestTeam.clear();
        searchCount = 0;
        pruneCount = 0;

        List<ChessPiece> current = new ArrayList<>();
        backtrack(0, current, 0.0, new HashMap<>());

        return new SearchResult(bestTeam, bestPower, searchCount, pruneCount);
    }

    /**
     * 回溯核心:从index开始尝试选择棋子
     * @param index 当前考虑的候选棋子索引
     * @param current 当前已选阵容
     * @param currentBasePower 当前阵容基础战力(不含羁绊,用于剪枝)
     * @param synergyCounts 当前各羁绊的计数
     */
    private void backtrack(int index, List<ChessPiece> current, 
                          double currentBasePower, Map<String, Integer> synergyCounts) {
        searchCount++;

        // 终止条件:阵容已满或候选池耗尽
        if (current.size() >= maxTeamSize || index >= candidatePool.size()) {
            double power = calculateCurrentPower(current, synergyCounts);
            if (power > bestPower) {
                bestPower = power;
                bestTeam = new ArrayList<>(current);
            }
            return;
        }

        // 剪枝策略1:剩余位置即使全选最高费棋子也无法超越当前最优
        int remainingSlots = maxTeamSize - current.size();
        int remainingCandidates = candidatePool.size() - index;
        if (remainingCandidates < remainingSlots) {
            pruneCount++;
            return; // 棋子不够填满阵容
        }

        // 计算理论上界:当前基础战力 + 剩余位置最高费棋子的战力 + 最大羁绊加成
        double upperBound = currentBasePower + getMaxPossibleAddition(index, remainingSlots);
        if (upperBound <= bestPower) {
            pruneCount++;
            return; // 即使最优情况也无法超越当前最优
        }

        ChessPiece piece = candidatePool.get(index);

        // 选择分支:尝试将当前棋子加入阵容
        if (current.size() < maxTeamSize) {
            current.add(piece);
            synergyCounts.merge(piece.origin, 1, Integer::sum);
            synergyCounts.merge(piece.clazz, 1, Integer::sum);

            backtrack(index + 1, current, 
                     currentBasePower + piece.cost * 10.0 * piece.tier, 
                     synergyCounts);

            // 回溯:撤销选择
            synergyCounts.merge(piece.origin, -1, Integer::sum);
            if (synergyCounts.get(piece.origin) == 0) synergyCounts.remove(piece.origin);
            synergyCounts.merge(piece.clazz, -1, Integer::sum);
            if (synergyCounts.get(piece.clazz) == 0) synergyCounts.remove(piece.clazz);
            current.remove(current.size() - 1);
        }

        // 不选择分支:跳过当前棋子
        backtrack(index + 1, current, currentBasePower, synergyCounts);
    }

    /**
     * 计算当前阵容总战力(含羁绊加成)
     */
    private double calculateCurrentPower(List<ChessPiece> team, Map<String, Integer> counts) {
        double basePower = team.stream().mapToDouble(p -> p.cost * 10.0 * p.tier).sum();
        double bonus = 0;
        for (Map.Entry<String, Integer> entry : counts.entrySet()) {
            Synergy syn = synergyMap.get(entry.getKey());
            if (syn != null) {
                int level = syn.getActiveLevel(entry.getValue());
                bonus += basePower * syn.getPowerAtLevel(level);
            }
        }
        return basePower + bonus;
    }

    /**
     * 剪枝策略1辅助:计算剩余位置能带来的最大战力增量(乐观估计)
     */
    private double getMaxPossibleAddition(int fromIndex, int slots) {
        // 取剩余候选中费用最高的slots个
        List<Integer> costs = new ArrayList<>();
        for (int i = fromIndex; i < candidatePool.size(); i++) {
            costs.add(candidatePool.get(i).cost);
        }
        costs.sort(Collections.reverseOrder());

        double addition = 0;
        for (int i = 0; i < Math.min(slots, costs.size()); i++) {
            addition += costs.get(i) * 10.0 * 3; // 假设3星(乐观)
        }
        // 加上最大羁绊加成(乐观估计50%)
        return addition * 1.5;
    }

    public static class SearchResult {
        public final List<ChessPiece> bestTeam;
        public final double bestPower;
        public final int searchNodes;
        public final int pruneNodes;

        public SearchResult(List<ChessPiece> team, double power, int nodes, int prunes) {
            this.bestTeam = team;
            this.bestPower = power;
            this.searchNodes = nodes;
            this.pruneNodes = prunes;
        }

        @Override
        public String toString() {
            return String.format("最优阵容战力: %.1f | 搜索节点: %d | 剪枝: %d | 效率提升: %.1fx",
                bestPower, searchNodes, pruneNodes, 
                (double)(searchNodes + pruneNodes) / Math.max(1, searchNodes));
        }
    }
}

贪心近似算法

当候选池过大时,回溯搜索仍可能太慢。贪心策略每一步选择”边际收益最高”的棋子,虽然不能保证全局最优,但在实际游戏中表现优异。

/**
 * 贪心阵容构建器:每一步选择能带来最大边际战力提升的棋子
 */
public class GreedyTeamBuilder {
    private final Map<String, Synergy> synergyMap;

    public GreedyTeamBuilder(Map<String, Synergy> synergyMap) {
        this.synergyMap = synergyMap;
    }

    /**
     * 贪心构建阵容
     * @param pool 候选棋子池
     * @param teamSize 目标阵容大小
     */
    public List<ChessPiece> buildGreedy(List<ChessPiece> pool, int teamSize) {
        List<ChessPiece> selected = new ArrayList<>();
        Set<ChessPiece> available = new HashSet<>(pool);
        Map<String, Integer> synergyCounts = new HashMap<>();

        for (int i = 0; i < teamSize && !available.isEmpty(); i++) {
            ChessPiece bestPiece = null;
            double bestMarginalGain = -1;

            for (ChessPiece candidate : available) {
                double gain = calculateMarginalGain(candidate, selected, synergyCounts);
                if (gain > bestMarginalGain) {
                    bestMarginalGain = gain;
                    bestPiece = candidate;
                }
            }

            if (bestPiece == null) break;

            selected.add(bestPiece);
            available.remove(bestPiece);
            synergyCounts.merge(bestPiece.origin, 1, Integer::sum);
            synergyCounts.merge(bestPiece.clazz, 1, Integer::sum);
        }

        return selected;
    }

    /**
     * 计算加入某个候选棋子带来的边际战力提升
     */
    private double calculateMarginalGain(ChessPiece candidate, 
                                         List<ChessPiece> current,
                                         Map<String, Integer> currentCounts) {
        double baseGain = candidate.cost * 10.0 * candidate.tier;

        // 计算羁绊增量
        double synergyGain = 0;

        // 种族羁绊增量
        int oldOriginCount = currentCounts.getOrDefault(candidate.origin, 0);
        Synergy originSyn = synergyMap.get(candidate.origin);
        if (originSyn != null) {
            int oldLevel = originSyn.getActiveLevel(oldOriginCount);
            int newLevel = originSyn.getActiveLevel(oldOriginCount + 1);
            if (newLevel > oldLevel) {
                // 羁绊升级,计算全队因此获得的额外加成
                double teamBase = current.stream().mapToDouble(p -> p.cost * 10.0 * p.tier).sum();
                synergyGain += teamBase * (originSyn.getPowerAtLevel(newLevel) 
                                         - originSyn.getPowerAtLevel(oldLevel));
            }
        }

        // 职业羁绊增量(同理)
        int oldClassCount = currentCounts.getOrDefault(candidate.clazz, 0);
        Synergy classSyn = synergyMap.get(candidate.clazz);
        if (classSyn != null) {
            int oldLevel = classSyn.getActiveLevel(oldClassCount);
            int newLevel = classSyn.getActiveLevel(oldClassCount + 1);
            if (newLevel > oldLevel) {
                double teamBase = current.stream().mapToDouble(p -> p.cost * 10.0 * p.tier).sum();
                synergyGain += teamBase * (classSyn.getPowerAtLevel(newLevel) 
                                         - classSyn.getPowerAtLevel(oldLevel));
            }
        }

        return baseGain + synergyGain;
    }
}

完整运行示例

public class AutoChessSolver {

    /**
     * 创建测试用候选棋子池
     */
    public static List<ChessPiece> createTestPool() {
        List<ChessPiece> pool = new ArrayList<>();

        // 虚空刺客体系
        pool.add(new ChessPiece("卡兹克", 1, "虚空", "刺客"));
        pool.add(new ChessPiece("雷克塞", 2, "虚空", "斗士"));
        pool.add(new ChessPiece("卡莎", 4, "虚空", "刺客"));
        pool.add(new ChessPiece("科加斯", 4, "虚空", "斗士"));

        // 约德尔法师体系
        pool.add(new ChessPiece("波比", 1, "约德尔人", "骑士"));
        pool.add(new ChessPiece("璐璐", 2, "约德尔人", "法师"));
        pool.add(new ChessPiece("维迦", 3, "约德尔人", "法师"));
        pool.add(new ChessPiece("纳尔", 4, "约德尔人", "换形师"));
        pool.add(new ChessPiece("提莫", 4, "约德尔人", "射手"));

        // 贵族骑士体系
        pool.add(new ChessPiece("盖伦", 1, "贵族", "骑士"));
        pool.add(new ChessPiece("薇恩", 1, "贵族", "射手"));
        pool.add(new ChessPiece("剑姬", 1, "贵族", "剑士"));
        pool.add(new ChessPiece("卢锡安", 2, "贵族", "枪手"));
        pool.add(new ChessPiece("蕾欧娜", 4, "贵族", "护卫"));
        pool.add(new ChessPiece("凯尔", 5, "贵族", "骑士"));

        // 通用刺客
        pool.add(new ChessPiece("派克", 2, "海盗", "刺客"));
        pool.add(new ChessPiece("阿卡丽", 3, "忍者", "刺客"));
        pool.add(new ChessPiece("伊芙琳", 3, "恶魔", "刺客"));
        pool.add(new ChessPiece("卡特琳娜", 3, "帝国", "刺客"));

        // 通用法师
        pool.add(new ChessPiece("莫甘娜", 3, "恶魔", "法师"));
        pool.add(new ChessPiece("死歌", 5, "虚空", "法师"));

        // 护卫
        pool.add(new ChessPiece("布隆", 2, "冰川", "护卫"));

        return pool;
    }

    public static void main(String[] args) {
        List<ChessPiece> pool = createTestPool();
        Map<String, Synergy> synergies = SynergyDatabase.createDefaultSynergies();

        System.out.println("=== 自走棋阵容羁绊回溯搜索 ===\n");
        System.out.println("候选棋子池(" + pool.size() + "个):");
        for (ChessPiece p : pool) {
            System.out.println("  " + p);
        }

        // 1. 回溯搜索(适用于小规模候选池)
        System.out.println("\n--- 回溯搜索(9人口)---");
        TeamCompositionSolver solver = new TeamCompositionSolver(pool, synergies, 9);
        TeamCompositionSolver.SearchResult result = solver.solve();
        System.out.println(result);
        System.out.println("最优阵容:");
        for (ChessPiece p : result.bestTeam) {
            System.out.println("  " + p);
        }

        // 2. 贪心算法(适用于大规模候选池)
        System.out.println("\n--- 贪心近似算法(9人口)---");
        GreedyTeamBuilder greedy = new GreedyTeamBuilder(synergies);
        List<ChessPiece> greedyTeam = greedy.buildGreedy(pool, 9);

        Team tempTeam = new Team();
        for (ChessPiece p : greedyTeam) tempTeam.addPiece(p);
        double greedyPower = tempTeam.calculatePower(synergies);
        System.out.printf("贪心阵容战力: %.1f%n", greedyPower);
        System.out.println("贪心阵容:");
        for (ChessPiece p : greedyTeam) {
            System.out.println("  " + p);
        }

        // 3. 对比分析
        System.out.println("\n=== 算法对比 ===");
        System.out.printf("回溯最优战力: %.1f%n", result.bestPower);
        System.out.printf("贪心阵容战力: %.1f%n", greedyPower);
        System.out.printf("贪心解与最优解比值: %.2f%%%n", 
            greedyPower / result.bestPower * 100);

        // 4. 不同人口规模的搜索效率
        System.out.println("\n=== 不同人口规模的搜索效率 ===");
        for (int size = 3; size <= 9; size += 2) {
            TeamCompositionSolver s = new TeamCompositionSolver(pool, synergies, size);
            TeamCompositionSolver.SearchResult r = s.solve();
            System.out.printf("人口%d: 最优战力=%.1f | 节点=%d | 剪枝=%d%n",
                size, r.bestPower, r.searchNodes, r.pruneNodes);
        }
    }
}

算法复杂度分析

算法 时间复杂度 空间复杂度 适用场景
回溯搜索 O(2^N) 最坏 O(N) N≤30的小规模候选池
带剪枝回溯 O(C(N,K)) 实际远小于 O(N) 9人口、50棋子约需数秒
贪心近似 O(K × N) O(N) 大规模候选池,实时决策
动态规划 O(N × K × 2^S) O(K × 2^S) S=羁绊种类数,状态压缩

实际游戏中,由于人口上限(通常9人)和候选池大小的限制,带剪枝的回溯搜索在数十个候选棋子的情况下完全可行。对于超过100个候选的大规模场景,贪心算法在毫秒级给出接近最优的解。

扩展方向

  1. 装备与站位建模:将装备效果(如攻速、暴击)和棋盘站位(前后排)纳入战力计算,使阵容评估更精确。

  2. 对手反制搜索:不仅搜索己方最强阵容,还考虑对手可能的阵容分布,用博弈论寻找纳什均衡策略。

  3. 卡池概率动态更新:随着游戏进行,已出局的玩家释放其棋子回卡池,动态调整候选池的概率分布。

  4. 多目标优化:除战力外,同时优化经济曲线、血量容错、转型灵活性等多维度目标。

总结

本文用Java实现了自走棋的核心阵容搜索算法,展示了回溯搜索配合剪枝策略在组合优化问题中的强大威力。从理论上看,阵容选择是NP-hard的组合优化问题;但从实践角度,合理的剪枝和贪心近似使得算法在游戏中完全可用。读者可以在此基础上加入更多游戏机制(装备、站位、经济),打造更完善的自走棋AI助手。