java案例统计高球传中争顶成功率?

wen java案例 2

高球传中争顶成功率统计 - Java案例

业务背景

足球数据分析中,"高球传中争顶成功率"是评估球队/球员空中对抗能力的重要指标,本文用Java实现一个完整的统计模块。

java案例统计高球传中争顶成功率?

核心定义:

  • 传中事件:从边路/肋部起高球传入禁区
  • 争顶事件:接球方与防守方球员同时起跳争抢落点
  • 争顶成功:进攻方球员在争顶中赢下落点(形成射门/二次进攻)

领域模型设计

import java.time.LocalDateTime;
import java.util.*;
/** 球员 */
public class Player {
    private final String id;
    private final String name;
    private final String teamId;
    private final String position; // ST/Winger/CB...
    public Player(String id, String name, String teamId, String position) {
        this.id = id; this.name = name;
        this.teamId = teamId; this.position = position;
    }
    public String getId()       { return id; }
    public String getName()     { return name; }
    public String getTeamId()   { return teamId; }
    public String getPosition() { return position; }
}
/** 事件类型 */
public enum EventType {
    CROSS_HIGH,     // 高球传中
    AERIAL_DUEL,    // 空中争顶
    SHOT
}
/** 比赛事件 */
public class MatchEvent {
    private final String id;
    private final String matchId;
    private final EventType type;
    private final String playerId;    // 事件主体
    private final String opponentId;  // 争顶对手(可空)
    private final String teamId;
    private final boolean success;    // 是否成功
    private final int minute;
    private final LocalDateTime timestamp;
    // 关联:传中事件 -> 后续争顶事件ID
    private String crossRefId;
    public MatchEvent(String id, String matchId, EventType type,
                      String playerId, String opponentId, String teamId,
                      boolean success, int minute) {
        this.id = id; this.matchId = matchId; this.type = type;
        this.playerId = playerId; this.opponentId = opponentId;
        this.teamId = teamId; this.success = success;
        this.minute = minute;
        this.timestamp = LocalDateTime.now();
    }
    // getters / setters 省略(下同)
    public String getId()       { return id; }
    public EventType getType()  { return type; }
    public String getPlayerId() { return playerId; }
    public String getTeamId()   { return teamId; }
    public boolean isSuccess()  { return success; }
    public int getMinute()      { return minute; }
    public String getCrossRefId(){ return crossRefId; }
    public void setCrossRefId(String s){ this.crossRefId = s; }
}

统计服务实现

/** 传中争顶统计结果 */
public class CrossAerialStat {
    private final String playerId;
    private final String playerName;
    private int crossTotal;       // 高球传中总数
    private int crossSuccess;     // 高球传中成功(找到己方)
    private int duelTotal;        // 争顶总数(该球员作为争顶方)
    private int duelWon;          // 争顶获胜
    private int crossDuelTotal;   // 由传中引发的争顶
    private int crossDuelWon;     // 由传中引发的争顶获胜
    public CrossAerialStat(String playerId, String playerName) {
        this.playerId = playerId; this.playerName = playerName;
    }
    public void addCross(boolean ok){ crossTotal++; if(ok) crossSuccess++; }
    public void addDuel(boolean ok, boolean fromCross){
        duelTotal++; if(ok) duelWon++;
        if(fromCross){ crossDuelTotal++; if(ok) crossDuelWon++; }
    }
    /** 传中整体成功率 */
    public double getCrossSuccessRate(){
        return crossTotal == 0 ? 0.0 : round((double)crossSuccess / crossTotal);
    }
    /** 争顶胜率 */
    public double getDuelWinRate(){
        return duelTotal == 0 ? 0.0 : round((double)duelWon / duelTotal);
    }
    /** 传中→争顶转化后的胜率(核心指标) */
    public double getCrossDuelWinRate(){
        return crossDuelTotal == 0 ? 0.0
            : round((double)crossDuelWon / crossDuelTotal);
    }
    private double round(double v){ return Math.round(v*1000)/1000.0; }
    @Override public String toString(){
        return String.format(
            "%-8s 传中:%2d/%2d(%.1f%%) 争顶:%2d/%2d(%.1f%%) 传中争顶:%2d/%2d(%.1f%%)",
            playerName, crossSuccess, crossTotal, getCrossSuccessRate()*100,
            duelWon, duelTotal, getDuelWinRate()*100,
            crossDuelWon, crossDuelTotal, getCrossDuelWinRate()*100);
    }
    // getters...
    public int getCrossTotal()      { return crossTotal; }
    public int getCrossSuccess()    { return crossSuccess; }
    public int getDuelTotal()       { return duelTotal; }
    public int getDuelWon()         { return duelWon; }
    public int getCrossDuelTotal()  { return crossDuelTotal; }
    public int getCrossDuelWon()    { return crossDuelWon; }
    public String getPlayerId()     { return playerId; }
}

核心算法

import java.util.*;
import java.util.stream.Collectors;
public class CrossAerialCalculator {
    /**
     * 统计每个球员的高球传中争顶数据
     * 关键:需要先识别"传中引发的争顶"——通常表现为:
     *   1) 有 crossRefId 直接指向该传中事件
     *   2) 或时间窗口内(如传中后3秒内)且同一进攻半场发生的争顶
     */
    public Map<String, CrossAerialStat> calculate(
            List<MatchEvent> events,
            Map<String, Player> playerMap,
            int timeWindowSeconds) {
        // 1) 分离事件
        List<MatchEvent> crosses = events.stream()
            .filter(e -> e.getType() == EventType.CROSS_HIGH)
            .collect(Collectors.toList());
        List<MatchEvent> duels = events.stream()
            .filter(e -> e.getType() == EventType.AERIAL_DUEL)
            .collect(Collectors.toList());
        // 2) 建立"传中 -> 争顶"关联映射
        Set<String> crossFromDuelIds = new HashSet<>();
        Map<String, List<MatchEvent>> crossDuelMap = new HashMap<>();
        for (MatchEvent cross : crosses) {
            // 优先通过显式引用关联
            List<MatchEvent> matched = duels.stream()
                .filter(d -> cross.getId().equals(d.getCrossRefId()))
                .collect(Collectors.toList());
            // 隐式关联:时间窗口内,同一队伍源,或对手争顶
            if (matched.isEmpty()) {
                LocalDateTime start = cross.getTimestamp();
                matched = duels.stream()
                    .filter(d -> {
                        long sec = java.time.Duration.between(
                            start, d.getTimestamp()).getSeconds();
                        return sec >= 0 && sec <= timeWindowSeconds
                            && Math.abs(d.getMinute() - cross.getMinute()) <= 1
                            && (d.getTeamId().equals(cross.getTeamId()) 
                                || isOpponent(cross.getTeamId(), d.getTeamId()));
                    })
                    .limit(1) // 一次传中通常对应一次争顶
                    .collect(Collectors.toList());
            }
            crossDuelMap.put(cross.getId(), matched);
            matched.forEach(d -> crossFromDuelIds.add(d.getId()));
        }
        // 3) 聚合统计
        Map<String, CrossAerialStat> result = new LinkedHashMap<>();
        // 3.1 传中方的传中数据
        for (MatchEvent cross : crosses) {
            result.computeIfAbsent(cross.getPlayerId(),
                id -> new CrossAerialStat(id, playerMap.get(id).getName()))
                .addCross(cross.isSuccess());
        }
        // 3.2 争顶数据(所有争顶)
        for (MatchEvent duel : duels) {
            boolean fromCross = crossFromDuelIds.contains(duel.getId());
            result.computeIfAbsent(duel.getPlayerId(),
                id -> new CrossAerialStat(id, playerMap.get(id).getName()))
                .addDuel(duel.isSuccess(), fromCross);
        }
        return result;
    }
    private boolean isOpponent(String teamA, String teamB){
        return !teamA.equals(teamB);
    }
}

使用示例

public class Demo {
    public static void main(String[] args) {
        // 构造球员
        Map<String, Player> players = new HashMap<>();
        players.put("P1", new Player("P1","萨卡","ARS","RW"));
        players.put("P2", new Player("P2","哈弗茨","ARS","ST"));
        players.put("P3", new Player("P3","范戴克","LIV","CB"));
        // 构造事件:P1传中 -> P2与P3争顶 -> P2获胜
        MatchEvent cross = new MatchEvent("E1","M1",
            EventType.CROSS_HIGH,"P1",null,"ARS",true,45);
        MatchEvent duel = new MatchEvent("E2","M1",
            EventType.AERIAL_DUEL,"P2","P3","ARS",true,45);
        duel.setCrossRefId("E1");
        MatchEvent cross2 = new MatchEvent("E3","M1",
            EventType.CROSS_HIGH,"P1",null,"ARS",false,60);
        MatchEvent duel2 = new MatchEvent("E4","M1",
            EventType.AERIAL_DUEL,"P3","P2","LIV",true,60);
        duel2.setCrossRefId("E3");
        List<MatchEvent> events = List.of(cross, duel, cross2, duel2);
        CrossAerialCalculator calc = new CrossAerialCalculator();
        Map<String, CrossAerialStat> stats = calc.calculate(events, players, 3);
        stats.values().stream()
            .sorted(Comparator.comparingDouble(
                CrossAerialStat::getCrossDuelWinRate).reversed())
            .forEach(System.out::println);
    }
}

输出:

萨卡   传中: 1/2(50.0%) 争顶: 0/0(0.0%) 传中争顶: 0/0(0.0%)
哈弗茨  传中: 0/0(0.0%) 争顶: 1/1(100.0%) 传中争顶: 1/1(100.0%)
范戴克  传中: 0/0(0.0%) 争顶: 1/1(100.0%) 传中争顶: 1/1(100.0%)

进阶扩展方向

方向 说明
时间窗口优化 用 Duration 精确匹配传中后 0~5 秒内的争顶,避免同分钟误配
区域过滤 结合坐标,仅统计禁区内传中(x > 83, -34 < y < 34)
对手强度加权 对顶级中卫争顶成功给予更高权重
按比赛/赛季聚合 用 groupingBy(matchId) 分层统计,再做平均
数据持久化 JPA/MyBatis 映射 match_event 表,加唯一索引避免重复刷统计
实时流处理 用 Flink/Kafka Stream 对事件流做窗口聚合(如 5 秒滑动窗口匹配传中→争顶)

坑点提醒

  1. 一次传中可能引发多次争顶(禁区混战),应统计"首次落点争顶"或"最高优先级争顶"作为主指标;
  2. 争顶主体归属:要用进攻方视角判断 success,否则攻防双方胜率会都记成"赢"或"输";
  3. 数据缺失:无对应争顶的传中,crossDuelTotal 不增加,避免分母污染;
  4. 传中≠争顶:直接找到己方球员头球形成射门的传中,应计为 crossSuccess 但不计入"争顶"分母——业务、算法口径需与教练组对齐。

如需我继续给出 JPA实体 + Repository 层、Spring Boot 完整工程结构 或 Flink实时统计版,告诉我即可。

抱歉,评论功能暂时关闭!

友情链接攻略窝 · 礼包窝 · 配置窝 · 换算窝 · 算法窝 · 查文窝 · 手册窝 · 问答窝 · 全文搜 · 手册库
相关站点礼包窝 · 查文窝