1

我正在编写一个运动时间表生成器。给定T队(偶数)、每轮G场比赛( T /2 的倍数)和R轮,我想生成一个符合条件的时间表:

  1. 所有球队在一轮比赛中的比赛数量相同。
  2. 团队组合在重复之前完全用尽。

我有一个算法在大多数情况下都有效,但并非总是如此。在这个问题的末尾有详细说明。如何修复(或替换)此算法以稳健地适用于所有合理的输入

这个问题类似于Sorting pair of teams with non-repeating | 循环赛算法:从一组比赛中选择成对的球队,但有不同的要求。


例如,假设有T =4 个团队。这给了我们 6 种可能的游戏:

(T0,T1)  (T0,T2)  (T0,T3)  (T1,T2)  (T1,T3)  (T2,T3)

如果每轮有G =4场比赛,那么第一轮一定不是这组比赛……</p>

(T0,T1)  (T0,T2)  (T0,T3)  (T1,T2)

…因为 T0 可以玩 3 次,而 T3 只能玩一次(违反了要求 #1)。相反,第一轮可能看起来像这样,每支球队都可以参加两场比赛:

(T0,T1)  (T2,T3)  (T0,T2)  (T1,T3)

如果在第二轮中重复相同的一组游戏,那么这两个游戏(T1,T2)(T0,T3)永远不会发生(违反要求 #2)。因此,我们要确保在我们挑选新游戏之前将它们包含在第二轮中。T =4、G =4、R =5的有效时间表将是:

(T0,T1)  (T2,T3)  (T0,T2)  (T1,T3)
(T0,T3)  (T1,T2)  (T0,T1)  (T2,T3)
(T0,T2)  (T1,T3)  (T0,T3)  (T1,T2)
(T0,T1)  (T2,T3)  (T0,T2)  (T1,T3)
(T0,T3)  (T1,T2)  (T0,T1)  (T2,T3)

如所见,对于较大的R值,最终重复一轮中的一组游戏是可以接受的。


我的算法是这样工作的:

  • 计算所有独特的团队配对组合(可能的游戏)。将此列表另存为currentPool.
  • 创建一个空列表,名为otherPool.
  • 对于每一轮,G次执行以下操作:
    • currentPool将本轮比赛中每支球队的出场次数相加,找出总和最低的比赛。
    • 将游戏添加到回合中。
    • 将游戏从 移动currentPoolotherPool
      • 如果currentPool为空,则交换currentPoolotherPool

对于TGR的许多合理值,此算法有效。但是,有些组合会失败。例如,当T =6、G =3、R =5 时,它会生成以下调度:

(T0,T1)  (T2,T3)  (T4,T5)
(T0,T2)  (T1,T3)  (T0,T4)
(T0,T3)  (T1,T2)  (T0,T5)
(T1,T4)  (T2,T5)  (T3,T4)
(T1,T5)  (T2,T4)  (T3,T5)

第一轮是正确的,但在第二轮中,T0 玩了两次,而 T5 从来没有玩过。问题很容易发现——在第 2 轮中选择后(T0,T2)(T1,T3)唯一可能满足要求 #1(T4,T5)的游戏是 ,但该游戏已经在第一轮中使用过,并且每个要求 #2 直到全部 15 个才能重新使用独特的游戏用完了。算法从一个死胡同开始,无法回溯。


最后,为了完整起见,这里是所描述算法的 JavaScript 版本。这是成功运行的示例输出:

let schedule = singleFieldSchedule({
    teams            : 8,
    maxGamesPerRound : 12,
    rounds           : 8
})
console.log(schedule.map(round => round.map(game => `(T${game[0]},T${game[1]})`).join('  ')).join('\n') )

(T0,T1) (T2,T3) (T4,T5) (T6,T7) (T0,T2) (T1,T3) (T4,T6) (T5,T7) (T0,T3) (T1,T2) (T4,T7) (T5,T6)
(T0,T4) (T1,T5) (T2,T6) (T3,T7) (T0,T5) (T1,T4) (T2,T7) (T3,T6) (T0,T6) (T1,T7) (T2,T4) (T3,T5)
(T0,T7) (T1,T6) (T2,T5) (T3,T4) (T0,T1) (T2,T3) (T4,T5) (T6,T7) (T0,T2) (T1,T3) (T4,T6) (T5,T7)
(T0,T3) (T1,T2) (T4,T7) (T5,T6) (T0,T4) (T1,T5) (T2,T6) (T3,T7) (T0,T5) (T1,T4) (T2,T7) (T3,T6)
(T0,T6) (T1,T7) (T2,T4) (T3,T5) (T0,T7) (T1,T6) (T2,T5) (T3,T4) (T0,T1) (T2,T3) (T4,T5) (T6,T7)
(T0,T2) (T1,T3) (T4,T6) (T5,T7) (T0,T3) (T1,T2) (T4,T7) (T5,T6) (T0,T4) (T1,T5) (T2,T6) (T3,T7)
(T0,T5) (T1,T4) (T2,T7) (T3,T6) (T0,T6) (T1,T7) (T2,T4) (T3,T5) (T0,T7) (T1,T6) (T2,T5) (T3,T4)
(T0,T1) (T2,T3) (T4,T5) (T6,T7) (T0,T2) (T1,T3) (T4,T6) (T5,T7) (T0,T3) (T1,T2) (T4,T7) (T5,T6)
function singleFieldSchedule({teams=8, maxGamesPerRound=12, rounds=8}={}) {
    const uniquePairs = a => a.reduce((res,o1,i,a) => res.concat(a.slice(i+1).map(o2 => [o1,o2])), [])
    const teamNames = Array.from(Array(teams).keys())
    const fullExposure = uniquePairs(teamNames)
    const zeroTeamCounts = teamNames.map(n => [n,0])

    // Calculate how many games can be played by each team while keeping things fair
    const gamesPerTeam = Math.floor(maxGamesPerRound / teams * 2)
    const gamesPerRound = gamesPerTeam * teams/2    

    const schedule = []

    const pools = [fullExposure, []]
    let poolIndex = 0
    for (let r=0; r<rounds; ++r) {
        const round = []
        schedule.push(round)
        const gamesPerTeam = new Map(zeroTeamCounts)
        for (let g=0; g<gamesPerRound; ++g) {
            let pool = pools[poolIndex]
            if (!pool.length) pool = pools[poolIndex=((poolIndex+1)%2)]

            // Find the game whose teams have been seen the least
            let bestGameSum = Infinity
            let bestGameIndex
            for (i=0; i<pool.length; ++i) {
                const game = pool[i];
                // We square the times seen to favor a game where each team has been seen once
                // over a game where one team has been seen twice and the other team has never been seen
                const gameSum = gamesPerTeam.get(game[0])**2 + gamesPerTeam.get(game[1])**2
                if (gameSum < bestGameSum) {
                    bestGameSum = gameSum
                    bestGameIndex = i
                }
            }
            let bestGame = pool.splice(bestGameIndex, 1)[0]
            round.push(bestGame)
            gamesPerTeam.set(bestGame[0], gamesPerTeam.get(bestGame[0])+1);
            gamesPerTeam.set(bestGame[1], gamesPerTeam.get(bestGame[1])+1);

            // Put this game into the 'other' pool, to be used once this pool of games is used up
            pools[(poolIndex+1) % 2].push(bestGame)
        }

        // Check to see if any team got screwed this round
        const shortedTeams = teamNames.filter(t => gamesPerTeam.get(t)<gamesPerTeam)
        shortedTeams.forEach( t => {
            const ct = gamesPerTeam.get(t)
            console.warn(`Team ${t} only got to play ${ct}/${gamesPerTeam} games in round #${r}`)
        })
    }

    return schedule
}
4

3 回答 3

4

制定标准的循环赛时间表。然后只需按照每轮比赛所需的配对数量进行配对。

“标准时间表”将球队配对,并在每轮比赛中轮换除第一支球队之外的所有球队。对于六支球队,日程安排是这样的;配对垂直相邻:

0 1 2
5 4 3

0 5 1
4 3 2

0 4 5
3 2 1

0 3 4
2 1 5

0 2 3
1 5 4

就是这样:五轮比赛,每支球队与对方球队打一场。

如果您有奇数个团队,则将团队 0 指定为“再见”。

如果您需要 6 轮比赛,只需按照上面给出的顺序选择它们,每行从左到右:

0-5  1-4  2-3  0-4  5-3  1-2 
0-3  4-2  5-1  ... etc.

联盟中有 2N 支球队,比赛之间的时差为 N-1、N 或 N+1 场比赛。

于 2020-01-03T22:18:55.663 回答
2

我没有理论上完美的解决方案,但我有一个应该更好的多项式时间方法。

它的核心是用于最大匹配的Blossom 算法。在每一轮中,将其与代表尚未玩过的游戏的边一起使用。这将更有可能找到当前算法可能失败的简单案例的有效解决方案。特别是你已经保证球队不能在一轮中打两场比赛,并且尽可能多地使用未使用的比赛。

但是我们可以通过注意到我们可以使用变体来找到最大权重匹配来改进这一点。如果您将每条边的权重设为G^i其中G是已玩的游戏数,并且i是自上次玩特定游戏以来的轮数,那么团队不能在一轮中玩 2 场比赛,我们尽可能多地玩旧游戏.

该算法保证您的第一个条件,并真诚地努力做到第二个。但它不保证第二个。(但是,如果您确实有早期重复,它们会很好地分散开来。)

如果您有大量回合,您可以调整权重条件,以确保每个回合平均使用正确的次数。

于 2020-01-03T21:56:35.127 回答
1

用图的术语来说,所有球队可能进行的所有比赛的集合是一个“完全图”,即每个球队有一个顶点,边连接每对球队的图。

标记为 A-F 的 6 支球队的图表,每对球队都相连,同样有 8 支球队
            T=6 和 T=8 的完整图表

找到所有团队都只玩一次的游戏对就是找到图形的“完美匹配”:找到接触每个顶点的边,没有一个顶点被多个边接触。

与之前相同,连接 A&F、B&E 和 C&D 的边为 6 队图突出显示,连接 H&G、A&E、B&D 和 C&F 的边为 8 队图突出显示

            T=6 和 T=8 的完美匹配示例

确保玩所有可能的游戏——找到一组唯一选择每条边的“完美匹配”——是图的 1 因子分解。以下是T = 6 和T = 8 情况的两种不同的 1 因子分解。第一个是手工创建的,而第二个使用接受的答案中描述的循环算法。

T=6 的两组 1 分解

T=8 的两组 1 分解

鉴于能够为图生成任何单个 1 因子分解,问题解决如下:

  1. 创建代表团队数量的完整图表的 1 分解。
  2. 计算单个团队每轮N的比赛次数为 2* G / T
  3. 对于每一轮,从 1-factorization 中选择N个完美匹配,并使用这些边作为游戏来玩该轮。
    • 在随后的回合中,使用后续的完美匹配,以便稍后使用第一轮中未使用的那些。
    • 一旦所有完美匹配都用完了,重复完美匹配的选择。

不需要计算所有 1 分解。这样做会提供团队中球员没有体验过的多样性。例如,上面T = 6 的两个 1 因子分解在 A 队与 F 队比赛的情况下显示了不同的完美匹配。然而,当 A 队和 F 队互相比赛时,它们可能不受 B 队是否与 D 队比赛的影响或 C 队。


该算法的 JavaScript 版本如下:

// Calculate a round-robin schedule using the 'circle' algorithm
// https://en.wikipedia.org/wiki/Round-robin_tournament#Scheduling_algorithm
function roundRobin(teams) {
    const loop = Array.from(Array(teams).keys())
    const rounds = []
    for (let i=0; i<(teams-1); ++i) {
        const round = []
        for (let j=0; j<teams/2; ++j) {
            round.push([loop[j], loop[teams-j-1]].sort())
        }
        loop.splice(1, 0, loop.pop()) // rotate the 'table'
        rounds.push(round)
    }
    return rounds
}

// Play multiple rounds of a round-robin tournament per a single 'round',
// while ensuring that every team plays the same number of games each round,
// and that every team plays every other team as soon as possible.
function multiGameRobin({teams=8, maxGamesPerRound=12, rounds=8}={}) {
    if (teams%2) console.error('number of teams must be even')
    const subrounds = roundRobin(teams)
    const gamesPerTeam = Math.floor(maxGamesPerRound / teams * 2)
    const schedule = []
    for (let r=0; r<rounds; ++r) {
        let round = []
        for (let i=0; i<gamesPerTeam; ++i) {
            round = round.concat(subrounds[(r*gamesPerTeam+i) % subrounds.length])
        }
        schedule[r] = round
    }

    return schedule
}

有趣的是——虽然不是原始问题的要求——是在后续轮次中提供完美匹配的不同组合。例如,对于T =6,有 5 种不同的完美匹配,我们可以称之为 PM1、PM2、PM3、PM4 和 PM5。如果在第 1 轮中我们使用 PM1、PM2 和 PM3,在第 6 轮中我们可能会使用 PM1、PM3 和 PM5 来提供更多种类,因此它不是直接重复第 1 轮的游戏。

于 2020-01-05T17:54:53.490 回答