每日算法 — 使用java实现League of Legends:Elo评分系统与Glicko-2匹配平衡

一、公平匹配是在线竞技的灵魂

MOBA游戏的核心体验建立在实力均衡的对局之上。本文实现两大经典评分系统:

  • Elo评分:1959年Arpad Elo为国际象棋设计的评分算法,至今仍是竞技游戏标准
  • Glicko-2:Mark Glickman对Elo的改进,引入评分偏差(Rating Deviation)衡量置信度

最终目标是构建一个能动态评估玩家实力、快速收敛真实水平、生成均衡团队的匹配系统。

二、Elo评分系统核心算法

Elo的核心假设:玩家表现服从逻辑分布,预期胜率仅由双方评分差决定。

/**
 * Elo评分系统实现
 * 核心公式:E_A = 1 / (1 + 10^((R_B - R_A)/400))
 */
public class EloSystem {

    static class Player {
        String id;
        double rating;      // 当前评分(通常1500为新手基准)
        int gamesPlayed;    // 对局数

        public Player(String id, double initialRating) {
            this.id = id;
            this.rating = initialRating;
            this.gamesPlayed = 0;
        }
    }

    private final double kFactorBase = 32; // 基础K值(评分变化幅度因子)

    /**
     * 计算玩家A对玩家B的预期胜率
     * @param ratingA 玩家A的当前评分
     * @param ratingB 玩家B的当前评分
     * @return [0,1] 范围内的预期胜率
     */
    public double expectedScore(double ratingA, double ratingB) {
        return 1.0 / (1.0 + Math.pow(10.0, (ratingB - ratingA) / 400.0));
    }

    /**
     * 单场比赛后更新两位玩家的评分
     * @param playerA 玩家A
     * @param playerB 玩家B
     * @param scoreA  A的实际得分:1=胜, 0.5=平, 0=负
     */
    public void updateRating(Player playerA, Player playerB, double scoreA) {
        double expectedA = expectedScore(playerA.rating, playerB.rating);
        double expectedB = 1.0 - expectedA;
        double scoreB = 1.0 - scoreA;

        // 动态K值:新手变化快,老手变化慢
        double kA = calculateKFactor(playerA);
        double kB = calculateKFactor(playerB);

        // Elo更新公式
        double newRatingA = playerA.rating + kA * (scoreA - expectedA);
        double newRatingB = playerB.rating + kB * (scoreB - expectedB);

        playerA.rating = newRatingA;
        playerB.rating = newRatingB;
        playerA.gamesPlayed++;
        playerB.gamesPlayed++;

        System.out.printf("Update: %s %.0f->%.0f (exp=%.3f, actual=%.1f, K=%.0f)%n",
            playerA.id, playerA.rating - kA*(scoreA-expectedA), newRatingA, expectedA, scoreA, kA);
        System.out.printf("Update: %s %.0f->%.0f (exp=%.3f, actual=%.1f, K=%.0f)%n",
            playerB.id, playerB.rating - kB*(scoreB-expectedB), newRatingB, expectedB, scoreB, kB);
    }

    /**
     * 动态K因子计算
     * 策略:前10局用K=40快速收敛,之后K=32,高分段(2400+)用K=16
     */
    double calculateKFactor(Player p) {
        if (p.gamesPlayed < 10) return 40;
        if (p.rating >= 2400) return 16;
        return kFactorBase;
    }

    /**
     * 5v5团队评分:假设团队实力为成员评分之和(简化模型)
     * 实际应用中可引入位置权重、协同系数等
     */
    public double teamRating(Player[] team) {
        double sum = 0;
        for (Player p : team) sum += p.rating;
        return sum / team.length; // 团队平均评分
    }

    /**
     * 计算两队的预期胜率
     */
    public double[] teamExpectedScore(Player[] teamA, Player[] teamB) {
        double rA = teamRating(teamA);
        double rB = teamRating(teamB);
        double eA = expectedScore(rA, rB);
        return new double[]{eA, 1.0 - eA};
    }
}

三、Glicko-2:带置信度的动态评分

Elo的缺陷:所有评分被视为同等精确。新手打3局后的1500分与老手打3000局后的1500分完全不同。Glicko-2用RD(Rating Deviation)量化不确定性。

/**
 * Glicko-2评分系统
 * 扩展了Elo:每个玩家有评分μ、偏差φ、波动率σ三个参数
 */
public class Glicko2System {

    static class GlickoPlayer {
        String id;
        double mu;      // 评分(Glicko-2内部使用,通常比Elo低1500)
        double phi;     // 评分偏差 RD(越大越不确定)
        double sigma;   // 波动率(衡量评分的不稳定性)

        // 转换常量:Glicko-2内部尺度 → 常规评分
        static final double SCALE = 173.7178;

        public GlickoPlayer(String id, double rating, double rd, double volatility) {
            this.id = id;
            // 转换为Glicko-2内部尺度
            this.mu = (rating - 1500) / SCALE;
            this.phi = rd / SCALE;
            this.sigma = volatility;
        }

        /** 转换回常规评分显示 */
        double getRating() { return mu * SCALE + 1500; }
        double getRD() { return phi * SCALE; }
    }

    private final double tau = 0.5; // 波动率约束参数(通常0.3~1.2)

    /**
     * Glicko-2核心更新函数
     * @param player   待更新玩家
     * @param opponents 对手列表
     * @param scores   对每位对手的实际得分(1=胜, 0.5=平, 0=负)
     */
    public void updateRating(GlickoPlayer player, GlickoPlayer[] opponents, double[] scores) {
        // 1. 计算每个对手的g(φ_j)和E(μ, μ_j, φ_j)
        double[] gPhi = new double[opponents.length];
        double[] expected = new double[opponents.length];

        for (int i = 0; i < opponents.length; i++) {
            gPhi[i] = g(opponents[i].phi);
            expected[i] = E(player.mu, opponents[i].mu, opponents[i].phi);
        }

        // 2. 计算方差估计 v
        double v = 0;
        for (int i = 0; i < opponents.length; i++) {
            v += sq(gPhi[i]) * expected[i] * (1 - expected[i]);
        }
        v = 1.0 / v;

        // 3. 计算评分改进量 Δ
        double delta = 0;
        for (int i = 0; i < opponents.length; i++) {
            delta += gPhi[i] * (scores[i] - expected[i]);
        }
        delta *= v;

        // 4. 更新波动率 σ(通过迭代法求解)
        double newSigma = updateVolatility(player.phi, delta, v, player.sigma);

        // 5. 更新偏差 φ* → φ
        double phiStar = Math.sqrt(sq(player.phi) + sq(newSigma));
        double newPhi = 1.0 / Math.sqrt(1.0 / sq(phiStar) + 1.0 / v);

        // 6. 更新评分 μ
        double newMu = player.mu + sq(newPhi) * delta / v;

        player.mu = newMu;
        player.phi = newPhi;
        player.sigma = newSigma;
    }

    /** g函数:削弱高RD对手的影响力 */
    double g(double phi) {
        return 1.0 / Math.sqrt(1.0 + 3.0 * sq(phi) / sq(Math.PI));
    }

    /** E函数:预期胜率 */
    double E(double mu, double muJ, double phiJ) {
        return 1.0 / (1.0 + Math.exp(-g(phiJ) * (mu - muJ)));
    }

    /**
     * 波动率更新:通过 Illinois 算法求解非线性方程
     */
    double updateVolatility(double phi, double delta, double v, double sigma) {
        double a = Math.log(sq(sigma));
        double eps = 0.000001;

        double A = a;
        double B = (sq(delta) > sq(phi) + v) ? Math.log(sq(delta) - sq(phi) - v) : a;
        if (B <= a) B = a + tau;

        double fA = f(A, phi, delta, v, a, tau);
        double fB = f(B, phi, delta, v, a, tau);

        // Illinois 算法(二分法的改进)
        while (Math.abs(B - A) > eps) {
            double C = A + (A - B) * fA / (fB - fA);
            double fC = f(C, phi, delta, v, a, tau);

            if (fC * fB < 0) {
                A = B; fA = fB;
            } else {
                fA = fA / 2.0;
            }
            B = C; fB = fC;
        }

        return Math.exp(A / 2.0);
    }

    double f(double x, double phi, double delta, double v, double a, double tau) {
        double ex = Math.exp(x);
        double num = ex * (sq(delta) - sq(phi) - v - ex);
        double den = 2.0 * sq(sq(phi) + v + ex);
        return num / den - (x - a) / sq(tau);
    }

    double sq(double x) { return x * x; }
}

四、5v5团队匹配与平衡算法

将10位等待玩家划分为两队,使团队评分期望最接近50:50,同时最小化等待时间与位置冲突。

/**
 * MOBA匹配系统
 * 目标:将队列中的玩家划分为两队,实力差最小化
 */
public class MatchmakingSystem {

    static class QueuedPlayer {
        String id;
        double rating;      // Glicko-2评分
        double rd;          // 评分偏差
        long queueTime;     // 入队时间戳(毫秒)
        String[] roles;     // 偏好位置 [上单, 打野, 中单, ADC, 辅助]

        public QueuedPlayer(String id, double rating, double rd, String[] roles) {
            this.id = id; this.rating = rating; this.rd = rd; this.roles = roles;
            this.queueTime = System.currentTimeMillis();
        }

        /** 综合评分:考虑评分不确定性的下限估计 */
        double conservativeRating() {
            return rating - 2 * rd; // 95%置信下限
        }
    }

    private final List<QueuedPlayer> queue = new ArrayList<>();
    private final double maxRatingDiff = 200; // 允许的最大团队评分差
    private final long maxWaitTime = 30000;   // 最大等待30秒后放宽匹配条件

    /** 玩家入队 */
    public void enqueue(QueuedPlayer player) {
        queue.add(player);
    }

    /**
     * 尝试从队列中构建一场均衡对局
     * 算法:枚举 + 剪枝(10选5共252种组合,计算量极小)
     */
    public MatchResult tryMatch() {
        if (queue.size() < 10) return null;

        // 取队列中最早的10+N位玩家,扩大匹配池
        int poolSize = Math.min(queue.size(), 20);
        List<QueuedPlayer> pool = new ArrayList<>(queue.subList(0, poolSize));

        MatchResult best = null;
        double bestScore = Double.NEGATIVE_INFINITY;

        // 枚举所有5人组合作为Team A
        int n = pool.size();
        int[] indices = new int[5];

        // 使用递归/回溯生成组合
        List<int[]> combinations = generateCombinations(n, 5);

        for (int[] combo : combinations) {
            List<QueuedPlayer> teamA = new ArrayList<>();
            List<QueuedPlayer> teamB = new ArrayList<>();
            boolean[] inA = new boolean[n];

            for (int idx : combo) {
                teamA.add(pool.get(idx));
                inA[idx] = true;
            }
            for (int i = 0; i < n; i++) {
                if (!inA[i]) teamB.add(pool.get(i));
            }

            if (teamB.size() != 5) continue;

            // 评估此分组的质量
            double score = evaluateMatch(teamA, teamB);
            if (score > bestScore) {
                bestScore = score;
                best = new MatchResult(teamA, teamB, score);
            }
        }

        // 如果最优匹配的评分仍低于阈值,返回null继续等待
        if (best != null && bestScore > 0) {
            // 从队列中移除已匹配玩家
            for (QueuedPlayer p : best.teamA) queue.remove(p);
            for (QueuedPlayer p : best.teamB) queue.remove(p);
            return best;
        }

        return null;
    }

    /**
     * 匹配质量评分函数(越高越好)
     * 综合:评分均衡 + 位置覆盖 + 等待时间补偿
     */
    double evaluateMatch(List<QueuedPlayer> teamA, List<QueuedPlayer> teamB) {
        double ratingA = teamA.stream().mapToDouble(p -> p.rating).sum();
        double ratingB = teamB.stream().mapToDouble(p -> p.rating).sum();
        double ratingDiff = Math.abs(ratingA - ratingB);

        // 评分均衡分数(越接近0越高)
        double balanceScore = Math.max(0, 1.0 - ratingDiff / maxRatingDiff);

        // 位置覆盖分数:每队是否5个位置齐全
        double roleScoreA = roleCoverageScore(teamA);
        double roleScoreB = roleCoverageScore(teamB);
        double roleScore = (roleScoreA + roleScoreB) / 2.0;

        // 等待时间补偿:等待越久越应该优先匹配
        double waitScore = 0;
        long now = System.currentTimeMillis();
        for (QueuedPlayer p : teamA) waitScore += (now - p.queueTime) / 1000.0;
        for (QueuedPlayer p : teamB) waitScore += (now - p.queueTime) / 1000.0;
        waitScore = Math.min(1.0, waitScore / (10 * maxWaitTime));

        // 加权综合
        return balanceScore * 0.5 + roleScore * 0.3 + waitScore * 0.2;
    }

    /**
     * 位置覆盖评分:理想情况下每队5人各一个主位置
     */
    double roleCoverageScore(List<QueuedPlayer> team) {
        // 简化为5个位置,每个位置由偏好该位置的玩家担任
        Set<String> covered = new HashSet<>();
        for (QueuedPlayer p : team) {
            if (p.roles.length > 0) covered.add(p.roles[0]);
        }
        return covered.size() / 5.0; // [0, 1]
    }

    List<int[]> generateCombinations(int n, int k) {
        List<int[]> result = new ArrayList<>();
        int[] combo = new int[k];
        generateComboHelper(n, k, 0, 0, combo, result);
        return result;
    }

    void generateComboHelper(int n, int k, int start, int depth, int[] combo, List<int[]> result) {
        if (depth == k) {
            result.add(combo.clone());
            return;
        }
        for (int i = start; i <= n - k + depth; i++) {
            combo[depth] = i;
            generateComboHelper(n, k, i + 1, depth + 1, combo, result);
        }
    }

    static class MatchResult {
        List<QueuedPlayer> teamA, teamB;
        double qualityScore;

        MatchResult(List<QueuedPlayer> a, List<QueuedPlayer> b, double s) {
            teamA = a; teamB = b; qualityScore = s;
        }

        @Override
        public String toString() {
            double avgA = teamA.stream().mapToDouble(p -> p.rating).average().orElse(0);
            double avgB = teamB.stream().mapToDouble(p -> p.rating).average().orElse(0);
            return String.format("Match(Q=%.3f): TeamA[avg=%.0f] vs TeamB[avg=%.0f]",
                qualityScore, avgA, avgB);
        }
    }
}

五、赛后评分更新与置信度衰减

Glicko-2要求:长期不活跃的玩家,其RD应逐渐增大(评分不确定性增加)。

/**
 * 评分维护系统:赛后更新 + 不活跃衰减
 */
public class RatingMaintenance {

    private final Glicko2System glicko = new Glicko2System();
    private final Map<String, Glicko2System.GlickoPlayer> players = new HashMap<>();

    /** 记录比赛结果并更新所有参与者评分 */
    public void recordMatch(MatchResult match, int winner) {
        // winner: 0=平局, 1=TeamA胜, 2=TeamB胜
        List<Glicko2System.GlickoPlayer> teamAPlayers = new ArrayList<>();
        List<Glicko2System.GlickoPlayer> teamBPlayers = new ArrayList<>();

        for (MatchmakingSystem.QueuedPlayer qp : match.teamA) {
            teamAPlayers.add(players.computeIfAbsent(qp.id, 
                id -> new Glicko2System.GlickoPlayer(id, qp.rating, qp.rd, 0.06)));
        }
        for (MatchmakingSystem.QueuedPlayer qp : match.teamB) {
            teamBPlayers.add(players.computeIfAbsent(qp.id,
                id -> new Glicko2System.GlickoPlayer(id, qp.rating, qp.rd, 0.06)));
        }

        // 将5v5简化为每位玩家 vs 对方团队平均评分
        double avgA = teamAPlayers.stream().mapToDouble(p -> p.mu).average().orElse(0);
        double avgB = teamBPlayers.stream().mapToDouble(p -> p.mu).average().orElse(0);

        // 更新Team A玩家
        for (Glicko2System.GlickoPlayer p : teamAPlayers) {
            double score = (winner == 1) ? 1.0 : (winner == 0) ? 0.5 : 0.0;
            // 创建一个"虚拟对手"代表对方团队平均水平
            Glicko2System.GlickoPlayer virtualOpponent = 
                new Glicko2System.GlickoPlayer("avgB", avgB * Glicko2System.GlickoPlayer.SCALE + 1500, 50, 0.06);
            glicko.updateRating(p, new Glicko2System.GlickoPlayer[]{virtualOpponent}, new double[]{score});
        }

        // 更新Team B玩家
        for (Glicko2System.GlickoPlayer p : teamBPlayers) {
            double score = (winner == 2) ? 1.0 : (winner == 0) ? 0.5 : 0.0;
            Glicko2System.GlickoPlayer virtualOpponent =
                new Glicko2System.GlickoPlayer("avgA", avgA * Glicko2System.GlickoPlayer.SCALE + 1500, 50, 0.06);
            glicko.updateRating(p, new Glicko2System.GlickoPlayer[]{virtualOpponent}, new double[]{score});
        }
    }

    /**
     * 不活跃评分衰减:RD随时间增长
     * 公式:φ_new = sqrt(φ^2 + c^2·t),其中c为系统常数,t为未参赛周期数
     */
    public void applyInactivityDecay(String playerId, int periods) {
        Glicko2System.GlickoPlayer p = players.get(playerId);
        if (p == null) return;

        double c = 0.1; // 衰减常数
        double newPhi = Math.sqrt(sq(p.phi) + sq(c) * periods);
        // RD上限约束(通常不超过350)
        p.phi = Math.min(newPhi, 350.0 / Glicko2System.GlickoPlayer.SCALE);
    }

    double sq(double x) { return x * x; }

    /** 打印玩家评分状态 */
    public void printPlayerRating(String playerId) {
        Glicko2System.GlickoPlayer p = players.get(playerId);
        if (p == null) return;
        System.out.printf("Player %s: Rating=%.0f ± %.0f (σ=%.4f)%n",
            playerId, p.getRating(), p.getRD(), p.sigma);
    }
}

六、完整模拟与收敛验证

public class MatchmakingMain {
    public static void main(String[] args) {
        // 创建10位真实实力不同的玩家(仅模拟知道其"隐藏真实实力")
        double[] trueSkills = {1200, 1350, 1400, 1450, 1500, 1550, 1600, 1650, 1800, 2000};
        String[] names = {"P1","P2","P3","P4","P5","P6","P7","P8","P9","P10"};

        EloSystem elo = new EloSystem();
        Glicko2System glicko = new Glicko2System();

        EloSystem.Player[] eloPlayers = new EloSystem.Player[10];
        Glicko2System.GlickoPlayer[] glickoPlayers = new Glicko2System.GlickoPlayer[10];

        for (int i = 0; i < 10; i++) {
            eloPlayers[i] = elo.new Player(names[i], 1500); // 统一从1500开始
            glickoPlayers[i] = new Glicko2System.GlickoPlayer(names[i], 1500, 350, 0.06);
        }

        // 模拟1000场随机对局
        Random rnd = new Random(42);
        for (int game = 0; game < 1000; game++) {
            int a = rnd.nextInt(10);
            int b = rnd.nextInt(10);
            if (a == b) continue;

            // 胜负由真实实力决定(加入随机性模拟真实比赛)
            double skillDiff = trueSkills[a] - trueSkills[b];
            double winProb = 1.0 / (1.0 + Math.exp(-skillDiff / 200.0));
            double scoreA = rnd.nextDouble() < winProb ? 1.0 : 0.0;

            // Elo更新
            elo.updateRating(eloPlayers[a], eloPlayers[b], scoreA);

            // Glicko-2更新(批量简化:每次只更新两个玩家)
            glicko.updateRating(glickoPlayers[a], 
                new Glicko2System.GlickoPlayer[]{glickoPlayers[b]}, new double[]{scoreA});
            glicko.updateRating(glickoPlayers[b],
                new Glicko2System.GlickoPlayer[]{glickoPlayers[a]}, new double[]{1.0 - scoreA});
        }

        // 验证收敛性:评分是否接近真实实力
        System.out.println("=== Convergence Test ===");
        System.out.println("Player | TrueSkill | Elo    | Glicko | Glicko RD");
        System.out.println("-------|-----------|--------|--------|----------");
        for (int i = 0; i < 10; i++) {
            System.out.printf("%-6s | %9.0f | %6.0f | %6.0f | %8.0f%n",
                names[i], trueSkills[i], eloPlayers[i].rating,
                glickoPlayers[i].getRating(), glickoPlayers[i].getRD());
        }

        // 匹配系统测试
        System.out.println("\n=== Matchmaking Test ===");
        MatchmakingSystem matcher = new MatchmakingSystem();
        for (int i = 0; i < 10; i++) {
            String[] roles = {"上单","打野","中单","ADC","辅助"};
            matcher.enqueue(new MatchmakingSystem.QueuedPlayer(
                names[i], eloPlayers[i].rating, 50, new String[]{roles[i % 5]}));
        }

        MatchmakingSystem.MatchResult match = matcher.tryMatch();
        if (match != null) {
            System.out.println(match);
        }
    }
}

预期输出:
– Elo和Glicko-2评分都向真实实力收敛
– Glicko-2的RD随对局数增加而减小(高置信度)
– 匹配系统将2000分与1200分玩家分开,中间分段混排

七、复杂度分析

模块 时间复杂度 说明
Elo单局更新 O(1) 固定公式计算
Glicko-2单局更新 O(n) n=对手数,Illinois算法迭代
5v5匹配枚举 O(C(20,5)) = 15504 实际仅约1.5万次评估,毫秒级
匹配质量评估 O(1) 固定公式
整体系统 O(queuesize + matches) 支持每秒数万次匹配请求

核心收获:
1. Elo = 逻辑回归:预期胜率公式本质上是对数几率模型
2. K值动态化:新手需要快速收敛,高手需要稳定评分
3. Glicko-2 RD = 置信区间:将”这个玩家有多强”升级为”我们对他的实力有多确定”
4. 匹配 = 组合优化:10人分队在组合空间中最小化目标函数

从国际象棋到英雄联盟,60年来评分算法的演进证明:好的匹配系统比好的游戏设计更重要——没有公平的对局,再精美的技能也无人问津。