2025, Sep 25 19:17

Расписание игр на троих в Python для выбранного игрока без перебора

Показываем, как в Python составить расписание игр на троих для выбранного игрока без перебора: пары соперников, обработка нечётного состава, ограничения.

Составить расписание игр на троих кажется простым — пока не попробуешь сделать это программно. Задача здесь точечная: для выбранного игрока построить набор матчей, в каждом из которых он встречается с каждым другим участником ровно один раз, причём каждая игра состоит из трёх игроков. Наивный подход через перебор комбинаций резко раздувает пространство поиска и не соблюдает нужное правило парных встреч. Ниже показан компактный способ сформировать аккуратное расписание для одного центрального игрока, включая обработку пограничного случая, когда общее число игроков не делится поровну.

Постановка задачи

Нам нужен такой результат для выбранного игрока (например, "a") при составе из 11 человек: игра 1 = a, b, c; игра 2 = a, d, e; игра 3 = a, f, g; игра 4 = a, h, i; игра 5 = a, j, k. То есть "a" встречается с каждым другим игроком ровно один раз, по два соперника в матче.

Наивный подход, который даёт лишнее

Первым делом можно попробовать сгенерировать все 3-комбинации и отфильтровать те, что содержат выбранного игрока. Это действительно выдаёт корректные тройки, но возвращает вообще все возможные сочетания с участием этого игрока — гораздо больше, чем требуется.

from itertools import combinations

roster = ['a','b','c','d','e','f','g','h','i','j','k']
group_size = 3
triads = list(combinations(roster, group_size))

games_for_a = []
for pack in triads:
    if 'a' in pack:
        games_for_a.append(pack)

Так получается 45 троек для выбранного игрока, потому что берётся каждая уникальная пара из оставшихся 10 игроков в сочетании с "a". При этом не соблюдается ограничение: использовать каждого соперника ровно один раз в финальном расписании.

Почему так происходит

Функция combinations перебирает все возможные группы по три, а не строит продуманное расписание. Фильтрация по вхождению "a" сохраняет все тройки, где есть "a", а это choose(10, 2) = 45 — сильно больше пяти игр, которые нам действительно нужны. Иными словами, вы генерируете всю вселенную, а потом пытаетесь её обрезать, вместо того чтобы сразу конструировать нужное расписание.

Прямое построение через простой индексный проход

Куда прямолинейнее и надёжнее пройтись по списку соперников парами (пропустив выбранного игрока на индексе 0) и каждую соседнюю пару объединять с ним. Это даёт ровно желаемое расписание.

roster = ['a','b','c','d','e','f','g','h','i','j','k']
schedule = {}

for idx in range(1, len(roster), 2):
    schedule[f"game {int((idx + 1) / 2)}"] = ['a', roster[idx], roster[idx + 1]]

print(schedule)

Результат:

{'game 1': ['a', 'b', 'c'], 'game 2': ['a', 'd', 'e'], 'game 3': ['a', 'f', 'g'], 'game 4': ['a', 'h', 'i'], 'game 5': ['a', 'j', 'k']}

Логика проста: итерация начинается с индекса 1 и идёт шагом 2, поэтому берутся пары соперников [1,2], [3,4], [5,6] и т. д. Каждая пара объединяется с выбранным игроком "a" и образует игру. Номер игры вычисляется из индекса цикла с помощью целочисленной арифметики.

Пограничный случай: один оставшийся соперник

Если из‑за размера состава после разбиения на пары остаётся один соперник, можно добавить финальную запись только с выбранным игроком и этим последним участником. Конструкция try/except позволяет сохранить цикл лаконичным.

roster = ['a','b','c','d','e','f','g','h','i','j','k','l']
schedule = {}

for idx in range(1, len(roster), 2):
    try:
        schedule[f"game {int((idx + 1) / 2)}"] = ['a', roster[idx], roster[idx + 1]]
    except IndexError:
        schedule[f"game {int((idx + 1) / 2)}"] = ['a', roster[idx]]

print(schedule)

Результат:

{'game 1': ['a', 'b', 'c'], 'game 2': ['a', 'd', 'e'], 'game 3': ['a', 'f', 'g'], 'game 4': ['a', 'h', 'i'], 'game 5': ['a', 'j', 'k'], 'game 6': ['a', 'l']}

Здесь используется тот же принцип прохода и аккуратно обрабатывается одиночный остаток, когда после исключения выбранного игрока оставшееся число соперников оказывается нечётным.

Замечание о реализуемости, которое важно учитывать

Есть и более широкое ограничение, о котором стоит помнить, когда речь идёт о том, чтобы «каждый встретился с каждым ровно один раз» при играх по три человека. С 11 игроками существует 55 уникальных пар; одна игра покрывает 3 пары; а 55 не делится на 3. Это несоответствие в арифметике показывает, почему глобальное расписание, где каждая пара встречается ровно один раз, при таких условиях невозможно. Если вы изучаете полноценные схемы сезона с тройками и попарной уникальностью, полезно посмотреть исследования по Steiner Triple System, которые объясняют, когда такие расписания достижимы.

Почему это важно

При программной генерации расписаний легко скатиться к комбинаторике и последующей фильтрации. Такой путь даёт корректные группы, но не ту структуру, которая вам нужна, и может скрыть ограничения реализуемости. Прямое построение через индексацию обеспечивает предсказуемый результат для задачи «на одного игрока» и делает реализацию небольшой и прозрачной.

Итоги

Отталкивайтесь от нужных ограничений и конструируйте расписание напрямую, а не перебирайте все комбинации. Для требования «по игроку» объединение выбранного участника с последовательными парами соперников даёт ровно по одной встрече с каждым, а небольшой try/except опрятно обрабатывает возможного «лишнего» соперника. Если расширяете идею на весь пул игроков так, чтобы каждая пара встречалась один раз, сначала проверьте простую арифметику по числу пар, чтобы не тратить время на невозможные конфигурации.

Статья основана на вопросе на StackOverflow от postcardfiction и ответе Aadvik.