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 (i.e. 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 (e.g. 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.