问题定义
在一个演讲或歌唱比赛中,通常有 N 个评委对 M 个选手进行评分。每个选手在 R 个轮次中表演,每个轮次有 D 个评分维度(如发音、内容、表现力)。系统需要:
- 实时收集评委打分
- 对每个评分维度应用去高低分算法
- 计算各维度加权总分
- 汇总多轮次成绩得到最终排名
- 处理同分并列
- 通过 SignalR 实时推送排名到现场大屏和移动端
核心算法:去高低分 + 加权求和
这是评分引擎的核心。假设一个评分维度收到 N 个评委的分数 [s₁, s₂, ..., sₙ],处理流程为:
步骤 1:去高低分
将分数排序后,去掉最高分和最低分(可配置去掉的数量),剩余分数取平均值:
sorted_scores = sort(scores)
trim_count = config.trim_count // 默认去掉 1 个最高 + 1 个最低
valid_scores = sorted_scores[trim_count : len - trim_count]
average = sum(valid_scores) / len(valid_scores)
这个算法的目的是消除评委个人偏好偏差。例如,某个评委可能对某类表演风格有强烈偏好(给出极高/极低分),去高低分可以平滑这种异常值。
步骤 2:维度加权
每个评分维度可以有独立的权重。例如在演讲比赛中,内容 (Content) 可能权重 40%,表达 (Delivery) 权重 35%,台风 (Stage Presence) 权重 25%。
weighted_score = 0
for each dimension d in dimensions:
dim_avg = trim_and_average(scores[d])
weighted_score += dim_avg * d.weight
total_score = weighted_score
步骤 3:C# 实现
public static double CalculateDimensionScore(
List<double> scores, int trimCount = 1)
{
if (scores.Count == 0) return 0;
if (scores.Count <= trimCount * 2)
return scores.Average();
var sorted = scores.OrderBy(s => s).ToList();
var valid = sorted.Skip(trimCount)
.Take(scores.Count - trimCount * 2);
return valid.Average();
}
public static ScoringResult CalculateRound(
Contestant contestant,
Round round,
List<Score> allScores,
int trimCount = 1)
{
var result = new ScoringResult
{
ContestantId = contestant.Id,
ContestantName = contestant.Name,
RoundId = round.Id,
RoundName = round.Name
};
// 获取该选手本轮所有评委的评分
var roundScores = allScores
.Where(s => s.ContestantId == contestant.Id
&& s.RoundId == round.Id)
.ToList();
// 按维度聚合所有评委的分数
var dimensionScores = new Dictionary<string, List<double>>();
foreach (var dim in round.ScoreDimensions)
{
dimensionScores[dim.Name] = roundScores
.Select(s => s.Dimensions.GetValueOrDefault(dim.Name, 0))
.ToList();
}
// 每个维度去高低分 → 加权求和
double total = 0;
foreach (var dim in round.ScoreDimensions)
{
var avg = CalculateDimensionScore(
dimensionScores[dim.Name], trimCount);
total += avg * dim.Weight;
result.DimensionDetails[dim.Name] = avg;
}
result.RawScore = total;
result.WeightedScore = total; // 后续可应用轮次权重
return result;
}
多轮次汇总排名
比赛通常有多个轮次(初赛、复赛、决赛),每个轮次有权重。例如初赛权重 30%,决赛权重 70%。
public static List<ScoringResult> CalculateOverall(
Competition competition,
int trimCount = 1)
{
var results = new List<ScoringResult>();
foreach (var contestant in competition.Contestants)
{
var roundScores = new List<(string, double, double)>();
double weightedTotal = 0;
foreach (var round in competition.Rounds
.Where(r => r.Status == RoundStatus.Completed))
{
var roundResult = CalculateRound(
contestant, round, competition.Scores, trimCount);
weightedTotal += roundResult.WeightedScore * round.Weight;
roundScores.Add((round.Name, roundResult.WeightedScore, round.Weight));
}
results.Add(new ScoringResult
{
ContestantId = contestant.Id,
ContestantName = contestant.Name,
WeightedScore = weightedTotal,
RoundDetails = roundScores
});
}
return results
.OrderByDescending(r => r.WeightedScore)
.ToList();
}
同分并列处理
当两个选手总分相同时,需要二级排序规则来打破平局。我们的策略:
- 第一级:总分 (WeightedScore) 降序
- 第二级:去掉权重后的原始分 (RawTotal) 降序 — 裁判原始评价
- 第三级:最高单个维度得分降序 — 有突出亮点的选手优先
- 第四级:出场顺序升序 — 先出场的选手排名靠前
results = results
.OrderByDescending(r => r.WeightedScore)
.ThenByDescending(r => r.RawScore)
.ThenByDescending(r => r.MaxDimensionScore)
.ThenBy(r => r.OrderNumber)
.ToList();
// 分配排名(同分并列)
int rank = 1;
for (int i = 0; i < results.Count; i++)
{
if (i > 0 &&
results[i].WeightedScore == results[i-1].WeightedScore &&
results[i].RawScore == results[i-1].RawScore)
{
results[i].Rank = results[i-1].Rank; // 并列
}
else
{
results[i].Rank = rank;
}
rank++;
}
实时评分推送架构
评分引擎运行在 Desktop 端(裁判长控制台),通过 SignalR Hub 实时推送评分变化到:
- 现场大屏(StageWindow):显示当前选手和实时排名
- 移动端评委(MAUI App):收到其他评委的提交通知
- 比赛管理端(Desktop Admin):完整的评分仪表板
// Desktop 端推送(Hub Server)
public async Task SubmitScore(ScoreSubmission submission)
{
// 1. 验证评委身份
// 2. 保存评分记录
// 3. 重新计算排名
var results = ScoringAlgorithm.CalculateOverall(_competition);
// 4. 推送到所有已连接客户端
await Clients.All.OnResultsUpdated(results);
await Clients.All.OnScoreSubmitted(judgeName);
}
智能奖项分配
除了标准排名,系统还支持智能奖项分配:
| 奖项类型 | 算法 | 示例 |
|---|---|---|
| 排名奖 | 按总分排名 | 一等奖(1名)、二等奖(2名)、三等奖(3名) |
| 单项奖 | 单维度最高分 | 最佳发音、最佳内容、最具表现力 |
| 人气奖 | 观众投票数 | 现场观众实时投票 |
| 特别奖 | 自定义规则 | 评委一致好评、进步最快 |
奖项分配确保互斥性:一个选手不能同时获得排名奖和单项奖(除非配置允许重叠),避免"马太效应"。当冲突时,按奖项优先级分配:排名奖 > 单项奖 > 人气奖。
数据持久化与导出
所有评分数据通过 SQLite 本地存储,支持导出为 Excel/CSV 格式:
public async Task ExportAsync(string format)
{
// 导出结构
// Sheet 1: 比赛信息(名称/日期/类型/评委列表)
// Sheet 2: 选手列表(编号/姓名/单位/参赛作品)
// Sheet 3: 评分明细(选手×评委×维度 矩阵)
// Sheet 4: 轮次排名(每轮结果)
// Sheet 5: 最终排名(总成绩+奖项)
// Sheet 6: 投票记录(如有观众投票)
using var package = new ExcelPackage();
// ... 6 个工作表
await File.WriteAllBytesAsync(path, package.GetAsByteArray());
}
性能特性
| 场景 | 选手数 | 评委数 | 轮次数 | 计算耗时 |
|---|---|---|---|---|
| 小型比赛 | 10 | 5 | 2 | < 5ms |
| 中型比赛 | 50 | 10 | 3 | < 15ms |
| 大型比赛 | 200 | 20 | 5 | < 50ms |
| 极限压力测试 | 1000 | 50 | 10 | < 500ms |
评分计算是 O(N × J × R × D) 的复杂度(N = 选手数,J = 评委数,R = 轮次数,D = 维度数)。对于典型的比赛规模(N=200, J=20, R=5, D=4),计算量约 80,000 次操作,在 .NET 8 上轻松控制在 50ms 以内。
总结
ContestScoring 的评分引擎设计遵循 "简单可靠优于复杂精巧" 的原则。去高低分 + 加权求和的算法虽然简单,但配合多级 tie-breaking 和智能奖项分配,足以应对绝大多数比赛场景。
关键设计决策:
- 算法层纯函数无副作用,便于单元测试(30 个测试用例全部通过)
- 评分计算与 UI 完全解耦,通过 SignalR 推送到各端
- SQLite 持久化 + 多格式导出,数据不丢失
- 支持配置化的去高低分数量、维度权重、轮次权重,灵活适应不同比赛规则