简体   繁体   中英

4-Player Tournament Scheduling, Python

I am trying to develop an algorithm to generate a schedule for a game tournament my family hosts every year. I have written a solution that only partially works; it seems that it works for 2^x players, but not in between.

Parcheesi is a game played with 4 people at a time, no more, no less, thus we schedule the tournament such that there is a multiple of 4 people (16, 28, 32, etc.) In round 1, there are n/4 games being played at once. Then in round 2, everybody gets shuffled up and plays new people. And in round 3, the same thing happens. Ideally, nobody plays any other person twice. That is the crux of my dilema, trying to code the property that nobody plays anybody else again.

Here is my method for doing so. I'm sure there are inefficiencies in the code, so feel free to make suggestions (I'm not worried about efficiency, though). I just want it to work for 3+ rounds and for any multiple-of-4 number of people.

import numpy as np
import itertools
import sys

num_players = 32
players = np.arange(1,num_players+1)

num_games = 3
games = np.arange(1,num_games+1)
game_matchups = {}

matchups = {}
for player in players:
    matchups[player] = []

for game in games:
    tables = [ [] for i in range(int(num_players/4)) ]
    for player in players:
        for i,table in enumerate(tables):
            if player in list(itertools.chain(*tables)):
                break
            if len(table) == 0:
                table.append(player)
                break
            if len(table) == 4:
                continue             
            else:
                for j,opp in enumerate(table):
                    if player in matchups[opp]:
                        break
                    else:
                        if j == len(table)-1:
                            table.append(player)
                            break
                        else:
                            continue

    game_matchups[game] = tables           
    for table in tables:
        if len(table) != 4:
            sys.exit((str(num_players)+' players with '+str(num_games)+' games doesnt work!'))
        for i,p in enumerate(table):
            matchups[p] = matchups[p] + (table[:i]+table[i+1:])
    order = order*-1

If the number of players is 32, I can schedule up to 5 rounds of play. But if I go up to 36 players, it breaks. It sort of "runs out" of tables in round 2, and it can't add player 33 to a table where he hasn't played someone already.

I have tried iterating through the list of players backwards, forwards/backwards, alternating, randomizing the players that are put into tables, and others, but nothing seems to work.

In practice, we've manually made this schedule and it has worked well. I want to write this program as a challenge to myself, but have gotten stuck.

You will need your number of people to be a multiple of 4 from 16 onward if you want to go more than one round without re-pairing.

For example, if you have players 1,2,3,4 on the first table (no matter how you organize the other tables), your second round will require at least 4 tables (one for each of the 4 players) to ensure that these four don't sit at the same table. You need 16 people to fill these four tables. Those 16 people should allow you to go 5 rounds without re-pairing. Given that players 1,2,3 and 4 can never meet again they will each monopolize one table for the rest of the rounds. At that point, they each have 12 more people to play against and, if you mix it perfectly, that will be 3 people per round for a total of 4 more rounds (5 rounds total). So 5 rounds is the best you can do with 16 people.

[EDIT2] I initially thought that a multiple of 16 was needed but it turns out I had made a mistake in the set manipulations. You can get multiple rounds for 20 people. I fixed it in both examples.

The following is a brute-force approach that uses backtracking to find a combination of foursomes that will not re-pair anybody. It uses sets to control the pairing collisions and itertools combinations() function to generate the foursomes (combinations of 4) and pairs (combinations of 2 within a foursome).

from itertools import combinations,chain

def arrangeTables(players, tables, alreadyPaired):
    result        = [[]] * tables # list of foursomes
    tableNumber   = 0
    allPlayers    = set(range(1,players+1))
    foursomes     = [combinations(allPlayers,4)]
    while True:
        foursome = next(foursomes[tableNumber],None)
        if not foursome:
            tableNumber -= 1
            foursomes.pop()
            if tableNumber < 0: return None
            continue
        foursome = sorted(foursome)
        pairs    = set(combinations(foursome,2))
        if not pairs.isdisjoint(alreadyPaired): continue
        result[tableNumber] = foursome
        tableNumber += 1
        if tableNumber == tables: break
        remainingPlayers = allPlayers - set(chain(*result[:tableNumber]))
        foursomes.append(combinations(remainingPlayers,4))
    return result

def tournamentTables(players, tables=None):
    tables  = tables or players//4
    rounds  = []    # list of foursome for each round (one foresome per table)
    paired  = set() # player-player tuples (lowest payer number first)
    while True:
        roundTables = arrangeTables(players,tables,paired)
        if not roundTables: break
        rounds.append(roundTables)
        for foursome in roundTables:
            pairs = combinations(foursome,2)
            paired.update(pairs)
    return rounds

This will produce the following result:

for roundNumber,roundTables in enumerate(tournamentTables(16)):
    print(roundNumber+1,roundTables)

1 [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]]
2 [[1, 5, 9, 13], [2, 6, 10, 14], [3, 7, 11, 15], [4, 8, 12, 16]]
3 [[1, 6, 11, 16], [2, 5, 12, 15], [3, 8, 9, 14], [4, 7, 10, 13]]
4 [[1, 7, 12, 14], [2, 8, 11, 13], [3, 5, 10, 16], [4, 6, 9, 15]]
5 [[1, 8, 10, 15], [2, 7, 9, 16], [3, 6, 12, 13], [4, 5, 11, 14]]

If you want to do more rounds than the number of people will allow for, you may want to adapt this to use Counter() (from collections) instead of sets to implement a "maximum re-pairing count" per player.

[EDIT] Here is a variant of the function with a maximum pairing parameter and randomization of player spread:

from itertools import combinations,chain
from collections import Counter
from random import shuffle

def arrangeTables(players, maxPair, alreadyPaired):
    tables        = players//4
    result        = [[]] * tables # list of foursomes
    tableNumber   = 0
    allPlayers    = set(range(1,players+1))

    def randomFoursomes():
        remainingPlayers = list(allPlayers - set(chain(*result[:tableNumber])))
        if maxPair > 1: shuffle(remainingPlayers)
        return combinations(remainingPlayers,4)

    foursomes     = [randomFoursomes()]
    allowedPairs  = 1
    while True:
        foursome = next(foursomes[tableNumber],None)
        if not foursome and allowedPairs < maxPair:
            foursomes[tableNumber] = randomFoursomes()
            allowedPairs += 1
            continue
        if not foursome:            
            tableNumber -= 1
            if tableNumber < 0: return None
            allowedPairs = 1
            foursomes.pop()
            continue
        foursome = sorted(foursome)
        if any(alreadyPaired[pair] >= allowedPairs for pair in combinations(foursome,2)):
            continue
        result[tableNumber] = foursome
        tableNumber   += 1
        if tableNumber == tables: break
        foursomes.append(randomFoursomes())
        allowedPairs   = 1
    return result

def tournamentTables(players, maxPair=1):
    rounds  = []    # list of foursome for each round (one foresome per table)
    paired  = Counter() # of player-player tuples (lowest payer number first)
    while True:
        roundTables = arrangeTables(players,maxPair,paired)
        if not roundTables: break
        shuffle(roundTables)
        rounds.append(roundTables)
        for foursome in roundTables:
            pairs  = combinations(foursome,2)
            paired = paired + Counter(pairs)
    return rounds

This version will let you decide how many pairings you are willing to accept per player to reach a higher number of rounds.

for roundNumber,roundTables in enumerate(tournamentTables(12,2)):
    print(roundNumber+1,roundTables)

1 [[3, 6, 8, 10], [1, 2, 5, 7], [4, 9, 11, 12]]
2 [[1, 4, 5, 11], [3, 6, 7, 8], [2, 9, 10, 12]]
3 [[1, 4, 8, 9], [5, 6, 7, 12], [2, 3, 10, 11]]

Note that you can still use it with a maximum of 1 to allow no re-pairing (ie 1 pairing per player combination):

for roundNumber,roundTables in enumerate(tournamentTables(20)):
    print(roundNumber+1,roundTables)

1 [[1, 2, 3, 4], [13, 14, 15, 16], [17, 18, 19, 20], [9, 10, 11, 12], [5, 6, 7, 8]]
2 [[3, 7, 14, 18], [4, 11, 15, 19], [1, 5, 9, 13], [2, 6, 10, 17], [8, 12, 16, 20]]
3 [[2, 5, 12, 18], [1, 6, 11, 14], [4, 9, 16, 17], [3, 8, 13, 19], [7, 10, 15, 20]]

[EDIT3] Optimized version.

I experimented some more with the function and added a few optimizations. It can now finish going through the 36 player combination in reasonable time. As I suspected, most of the time is spent trying (and failing) to find a 6th round solution. This means that, if you exit the function as soon as you have 5 rounds, you will always get a fast response.

Going further, I found that, beyond 32, some player counts take much longer. They waste extra time to determine that there are no more possible rounds after finding the ones that are possible (eg 5 rounds for 36 people). So 36, 40 and 44 players take a longer time but 48 converges to a 5 round solution much faster. Mathematicians probably have an explanation for that phenomenon but it is beyond me at this point.

For now, I found that the function only produces more than 5 rounds when you have 64 people or more. (so stoping it at 5 seems reasonable)

Here is the optimized function:

def arrangeTables(players, tables, alreadyPaired):
    result        = [[]] * tables # list of foursomes
    tableNumber   = 0
    threesomes    = [combinations(range(2,players+1),3)] 
    firstPlayer   = 1     # first player at table (needs 3 opponents)
    placed        = set() # players sitting at tables so far (in result)
    while True:
        opponents = next(threesomes[tableNumber],None)
        if not opponents:
            tableNumber -= 1
            threesomes.pop()
            if tableNumber < 0: return None
            placed.difference_update(result[tableNumber])
            firstPlayer = result[tableNumber][0] 
            continue
        foursome    = [firstPlayer] + list(opponents)
        pairs       = combinations(foursome,2)
        if not alreadyPaired.isdisjoint(pairs): continue
        result[tableNumber] = foursome
        placed.update(foursome)
        tableNumber += 1
        if tableNumber == tables: break
        remainingPlayers = [ p for p in range(1,players+1) if p not in placed ]
        firstPlayer = remainingPlayers[0]
        remainingPlayers = [ p for p in remainingPlayers[1:] if (firstPlayer,p) not in alreadyPaired ]
        threesomes.append(combinations(remainingPlayers,3))       
    return result

def tournamentTables(players):
    tables  = players//4
    rounds  = []    # list of foursome for each round (one foresome per table)
    paired  = set() # player-player tuples (lowest payer number first)
    while True: # len(rounds) < 5
        roundTables = arrangeTables(players,tables,paired)
        if not roundTables: break
        rounds.append(roundTables)
        for foursome in roundTables:
            paired.update(combinations(foursome,2))
    return rounds

The optimization is based on the fact that, for each new table, the first player can be any of the remaining ones. If a valid combination of player exists, we will find it with that player at that first spot. Verifying combinations with other players at that spot is not necessary because they would merely be permutations of the remaining tables/players that will have been covered with that first player in spot 1.

This allows the logic to work with combinations of 3 instead of combinations of 4 from the list of remaining players. It also allows early filtering of the remaining players for the table by only combining opponents that have not been paired with the player occupying the first spot.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM