问题定义

在一个演讲或歌唱比赛中,通常有 N 个评委M 个选手进行评分。每个选手在 R 个轮次中表演,每个轮次有 D 个评分维度(如发音、内容、表现力)。系统需要:

  1. 实时收集评委打分
  2. 对每个评分维度应用去高低分算法
  3. 计算各维度加权总分
  4. 汇总多轮次成绩得到最终排名
  5. 处理同分并列
  6. 通过 SignalR 实时推送排名到现场大屏和移动端
与传统 Excel 计分的区别 传统手动计分需要人工收集评分表 → 录入 Excel → 公式计算 → 发布结果,耗时 5-10 分钟。本系统实现全流程自动化,从评委提交到排名公布 不超过 1 秒

核心算法:去高低分 + 加权求和

这是评分引擎的核心。假设一个评分维度收到 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();
}

同分并列处理

当两个选手总分相同时,需要二级排序规则来打破平局。我们的策略:

  1. 第一级:总分 (WeightedScore) 降序
  2. 第二级:去掉权重后的原始分 (RawTotal) 降序 — 裁判原始评价
  3. 第三级:最高单个维度得分降序 — 有突出亮点的选手优先
  4. 第四级:出场顺序升序 — 先出场的选手排名靠前
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 实时推送评分变化到:

// 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());
}

性能特性

场景选手数评委数轮次数计算耗时
小型比赛1052< 5ms
中型比赛50103< 15ms
大型比赛200205< 50ms
极限压力测试10005010< 500ms

评分计算是 O(N × J × R × D) 的复杂度(N = 选手数,J = 评委数,R = 轮次数,D = 维度数)。对于典型的比赛规模(N=200, J=20, R=5, D=4),计算量约 80,000 次操作,在 .NET 8 上轻松控制在 50ms 以内。

优化技巧 使用 Dictionary<string, List<double>> 按维度预聚合分数,避免多次遍历评分列表。LINQ 的 GroupBy + ToDictionary 在此场景比手动循环慢约 30%。

总结

ContestScoring 的评分引擎设计遵循 "简单可靠优于复杂精巧" 的原则。去高低分 + 加权求和的算法虽然简单,但配合多级 tie-breaking 和智能奖项分配,足以应对绝大多数比赛场景。

关键设计决策: