要做战队实力排名,最简单的做法是统计胜率。但胜率有个致命问题:它不区分对手强度。赢了强队和赢了弱队记一样的分,赛程轻松的队伍排名会虚高。Elo 的核心价值就是把这个区分补上。
公式本身很短
Elo 只有两步。第一步算期望胜率,第二步按实际结果与期望的差值修正:
// 期望胜率:A 战胜 B 的概率
E_a = 1 / (1 + Math.pow(10, (R_b - R_a) / 400))
// 更新:S 为实际得分(胜 1 / 平 0.5 / 负 0)
R_a' = R_a + K * (S - E_a)
R_b' = R_b + K * ((1 - S) - E_b)
400 这个常数决定曲线的陡峭程度,差值 400 分对应约 10:1 的胜率比。两个人的分数变动互为镜像,总分守恒,这一点在实现时可以用来做自检。
真正麻烦的是这四个决定
1. 初始分怎么给
所有人给同一个值(常用 1500)会导致前几场分数剧烈波动,因为期望值一直是 0.5。如果用"逐场重放"的方式,冷启动的噪声会随着场次增加自然衰减;但如果只跑少量比赛,就得考虑按赛区基线给不同初始分。我倾向于让不同赛区的初始分有差异,否则跨赛区比赛的第一次交锋会给出很离谱的修正。
2. K 值取多少
K 决定单场结果能撬动多少分。K 大,排名反应快但抖动大;K 小,稳定但迟钝。没有一个通用正确答案,它本质上是在"快速反映现状"和"抵抗偶然性"之间取舍。比较实用的做法是让 K 随场次递减:新队伍变动快一点,打了几十场之后逐渐稳定。
3. 系列赛和单图要分开算
这是最容易被忽略的一点。一场 BO3 里打了三张图,如果你既按每张图更新一次、又按整个系列赛再更新一次,同一份信息就被计了两次。我的做法是维护两套分数:系列赛分数用较大的 K(比如 40),单图分数用较小的 K(比如 8)。单图分数只用来做地图层面的强弱分析,不参与跨队排名。
4. 别用未来数据回放过去
最容易犯的错是把所有比赛按时间排序后一次性跑完,然后拿某场比赛之前的分数去解释那场比赛——但如果预处理阶段用到了后面的数据(比如全局平均分、整赛季的统计数据),就构成了数据泄漏。严格的做法是:任何用于某场比赛的输入,只能来自这场比赛开始之前已知的信息。
把过程存下来,而不是只存结果
只保存最终的分数榜,你无法解释"为什么这支队伍涨了这么多"。我让回放器每场都输出一条记录:
{
matchId: "754730",
teamA: { before: 1620, delta: +18, after: 1638 },
teamB: { before: 1710, delta: -18, after: 1692 }
}
有了逐场明细,前端可以直接展示"这场赢了之后涨了几分",也能快速定位异常——比如某支队伍因为一次冷启动修正而虚高,一眼就能看出来。
最后做一次守恒自检
回放完成后,把所有队伍的 delta 加起来,应该严格等于 0(容差取浮点误差范围)。这个断言几乎不花成本,但能挡住绝大多数实现错误,比如漏更新了某一侧、或者平局处理时两边都按胜方算。