В 39% игр в Тайного Санту двое дарят друг другу. Считаем жеребьёвку как следует
- суббота, 17 октября 2026 г. в 00:00:12
Раздать имена в Тайном Санте — задача на пять строк: перемешал список, проверил, что никто не достался сам себе, готово. Я так и думал, пока не посчитал. При таком способе почти в четырёх играх из десяти найдутся двое, которые дарят друг другу, и это не баг реализации, а свойство случайных перестановок.
Я делаю сервис для Тайного Санты (My Santa), и жеребьёвка в нём — те самые пять строк, которые разрослись до ста тридцати. Ниже — почему разрослись, как устроены и какой перекос я нашёл в собственном коде, пока готовил цифры для этой статьи.
Шляпа. Все по очереди тянут бумажку; вытянул себя — тянешь другую. Проблема в последнем: если к его очереди в шляпе осталась только его бумажка, тянуть нечего. Я смоделировал это честно, со случайным выбором на каждом шаге:
Игроков | Последний остаётся со своим именем |
|---|---|
4 | 13,8% |
8 | 9,2% |
12 | 6,5% |
20 | 4,2% |
50 | 1,9% |
В отделе из восьми человек игру придётся перетягивать примерно каждый одиннадцатый раз. После второго круга все уже видели чужие бумажки.
Перемешать и проверить. Берём случайную перестановку; если у кого‑то выпал он сам — перемешиваем заново. Перестановок без неподвижных точек примерно 1/e от всех, так что в среднем нужно около 2,7 попытки (в прогоне вышло от 2,67 для четверых до 2,72 для больших игр). Это работает и это пишут чаще всего.
Но посмотрим, что получается на выходе. Перестановка распадается на циклы, и цикл длины два — это пара людей, которые дарят друг другу. Среднее число таких циклов в случайной перестановке равно 1/2, их количество распределено почти по Пуассону, и запрет неподвижных точек на это не влияет. Вероятность, что хотя бы один двойной цикл есть: 1 − e^(−1/2) ≈ 39,3%.
Прогон на 200 тысячах игр:
Игроков | Есть взаимная пара |
|---|---|
4 | 33,3% |
6 | 39,7% |
12 | 39,3% |
50 | 39,3% |
Начиная с шести человек от размера игры это не зависит: в компании из пятидесяти шанс тот же.
Чем взаимная пара плоха? Формально ничем, правила соблюдены. Но в момент вручения оба понимают всё сразу, а до раскрытия ещё неделя. У нас к тому же есть анонимный чат: каждый может писать своему подопечному и отвечать своему Санте. Во взаимной паре это два чата с одним и тем же человеком, и анонимность живёт до первой характерной фразы. То же с тройками: три человека, замкнутые друг на друга, быстро вычисляют, кто кому.
Таблица. Тот же второй способ, только организатор видит результат. Алгоритмически обсуждать нечего.
Решение известное: строим не произвольную перестановку, а один цикл через всех. Аня дарит Боре, Боря Вере, и так до последнего, который дарит Ане. Замкнутых групп меньше всей компании не бывает по построению.
Получить такой цикл просто: перемешать список и считать, что каждый дарит следующему, а последний — первому.
rng.Shuffle(len(order), func(i, j int) { order[i], order[j] = order[j], order[i] }) for i, santa := range order { out[santa] = order[(i+1)%len(order)] }
На этом можно было бы закончить, если бы не исключения.
В семье дарить «своему» неинтересно, в отделе неловко, когда начальник достаётся подчинённому. Организатор до жеребьёвки собирает группы людей, которые друг другу не дарят. Участники этих групп не видят.
Самое простое, что можно сделать с ограничениями, — выборка с отклонением: перемешали, проверили весь круг, не подошёл — перемешали снова.
func cycleFits[T comparable](order []T, allowed func(santa, giftee T) bool) bool { for i, santa := range order { if !allowed(santa, order[(i+1)%len(order)]) { return false } } return true}
Хорошее свойство сохраняется: подошедший порядок — это равновероятный круг среди разрешённых. Вопрос только в том, как часто он подходит. Для восьми человек это можно посчитать точно, перебрав все 5040 кругов:
Игра | Доля подходящих кругов |
|---|---|
6 человек, две пары | 40% |
10 человек, пять пар | 31% |
8 человек, семья из четырёх и пара | 2,9% (144 из 5040) |
12 человек, три семьи по четыре | 2,3% |
20 человек, четыре семьи по пять | 0,9% |
12 человек, из них шестеро — один отдел | 0,2% |
Пары почти не мешают. Семьи и отделы мешают сильно: когда половина игры — один отдел, круг обязан строго чередовать «свой — чужой», и случайный порядок попадает в это примерно раз из четырёхсот.
В первой версии я остановился на 64 попытках. Дальше включался запасной путь из двух шагов.
Шаг первый: найти хоть какое‑то распределение. Это паросочетание в двудольном графе: слева все в роли Сант, справа все в роли подопечных, ребро — «этому можно дарить тому». Алгоритм Куна, классика олимпиадного программирования, только списки рёбер и порядок обхода перемешаны, чтобы результат не был одним и тем же.
Паросочетание даёт перестановку, но не обязательно один круг. Обычно это несколько кругов.
Шаг второй: склеить круги. Берём двоих из разных кругов и меняем им подопечных. Если Аня дарила Боре в одном круге, а Глеб дарил Даше в другом, то после обмена Аня дарит Даше, Глеб — Боре, и два круга стали одним. Это общее свойство перестановок: умножение на транспозицию двух элементов из разных циклов сливает циклы. Обмен делаем, только если обе новые пары разрешены.
func joinCircles[T comparable](players []T, out map[T]T, allowed func(santa, giftee T) bool) { for { circle := circlesOf(players, out) a, b, ok := joinable(players, out, circle, allowed) if !ok { return } out[a], out[b] = out[b], out[a] } }
Склеить удаётся не всегда, потому что одного круга может не существовать в принципе. Пример: два лагеря по трое, и дарить разрешено только внутри своего лагеря. Распределение есть, круга через всех нет, получится два круга по три человека.
Двенадцать человек, семеро из них — один отдел. Семерым нужны семь разных подопечных не из отдела, а таких людей пятеро. Это теорема Холла в бытовом виде: если какой‑то группе Сант на всех доступно меньше подопечных, чем их самих, распределения нет.
Паросочетание в этом случае не находится, и это точный ответ, а не «не повезло». Что делает сервис:
организатор запускает жеребьёвку сам — отказ и подсказка. Если есть человек, которому не осталось вообще никого, называем его; это самый частый случай, и его видно простым проходом;
жеребьёвка идёт по расписанию — не проводим её и оставляем игру открытой. Молча выбросить чьё‑то ограничение хуже, чем подождать организатора.
Для статьи хотелось написать фразу «распределение равномерное». Написать её, не проверив, было бы неловко, поэтому я проверил.
Для маленькой игры все разрешённые круги можно перечислить, а потом разыграть несколько миллионов жеребьёвок и посмотреть, как часто выпадает каждый. При равномерном выборе все круги выпадают одинаково часто с точностью до шума (при 4000 выпадений на круг шум — несколько процентов).
Я взял три набора ограничений без симметрии, чтобы перекос не мог спрятаться, и измерил оба пути по отдельности:
Ограничения | Разрешённых кругов | Перемешивание: самый редкий и самый частый круг | Паросочетание со склейкой |
|---|---|---|---|
7 человек: тройка, пара и ещё один запрет | 64 | 0,95× и 1,03× | 0,88× и 1,26× |
8 человек: начальник и четверо его людей, пара | 624 | 0,95× и 1,04× | 0,87× и 1,21× |
9 человек: цепочка запретов и тройка | 5328 | 0,95× и 1,06× | 0,84× и 1,18× |
Числа — отношение к ровной доле. У перемешивания это шум. У запасного пути — нет: некоторые круги выпадают на четверть чаще положенного, некоторые на шестую часть реже. Алгоритм Куна и жадная склейка никому равномерности не обещали, и было бы странно, если бы она получилась сама.
Насколько это важно, зависит от того, как часто включается запасной путь. А включался он часто — там, где ограничения тесные:
Игра | 64 попытки подряд не подошли |
|---|---|
8 человек, семья из четырёх и пара | 15% игр |
12 человек, три семьи по четыре | 22% |
20 человек, четыре семьи по пять | 57% |
12 человек, шестеро из одного отдела | 87% |
То есть в семейной игре на двадцать человек больше половины жеребьёвок шли через путь с перекосом. На целой жеребьёвке это выглядело скромнее. Игра на девятерых: отдел из четырёх, пара, и начальник отдела не дарит ещё двоим. Разрешённых кругов 816 из 40 320, запасной путь включался в 27% игр, самый частый круг выпадал в 1,13 раза чаще ровной доли, самый редкий — в 0,93. Никто из игроков этого бы не заметил. Но фразу «распределение равномерное» писать было нельзя.
Число 64 я когда‑то взял с потолка. Одна попытка для двенадцати игроков — доли микросекунды, так что экономить их незачем.
const ( minShuffles = 64 shuffleWork = 400_000 ) func shuffles(n int) int { return max(minShuffles, shuffleWork/n) }
Теперь бюджет задан в работе, а не в попытках: около 400 тысяч шагов «игрок на попытку». Для игры на двенадцать человек это 33 тысячи перемешиваний вместо 64. Вероятность, что при доле 0,2% ни одно не подойдёт, — порядка e^(−70).
После правки та же игра на девятерых: самый редкий круг 0,95×, самый частый 1,04× — шум. Запасной путь остался для случаев, где одного круга нет вовсе.
Цена. Обычная жеребьёвка:
Игра | Время |
|---|---|
12 человек без ограничений | около 1 мкс |
1000 человек, семьи по четыре | 0,15 мс |
10 000 человек без ограничений | 0,29 мс |
Худший случай — когда круга нет или раздать нельзя, и бюджет сгорает целиком: 2–3 мс. Для операции, которая выполняется один раз за игру, это ничто.
Равномерности в запасном пути. Он по‑прежнему с перекосом, просто теперь до него почти не доходит. Честно выбрать случайное паросочетание равномерно — задача другого уровня сложности (подсчёт совершенных паросочетаний #P‑полон), и для игры в подарки я её решать не стал.
Криптостойкого генератора. Генератор — PCG из math/rand/v2, зерно берётся из глобального источника рантайма. Предсказать жеребьёвку, не зная зерна, нельзя, но PCG не криптографический. Если бы на кону стояло что‑то дороже свечки с запахом ёлки, я бы взял ChaCha8 с зерном из crypto/rand.
Защиты от организатора с доступом к базе. Пары лежат в таблице. Организатор игры их не видит, но тот, кто администрирует сервер, технически может. Это вопрос доверия к сервису, а не алгоритма.
Число, взятое с потолка, стоит хотя бы раз проверить измерением. У меня оно прожило в коде незамеченным, потому что тесты проверяли, что результат допустимый, а равномерность — только для игры без ограничений, где запасной путь не включается никогда. Тест на равномерность занял тридцать строк: перечислить круги, разыграть двести тысяч раз, сравнить доли.
И вопрос к тем, кто дочитал: есть ли дешёвый способ выбрать случайный гамильтонов цикл равномерно, когда доля подходящих совсем мала? Выборка с отклонением упирается в долю, цепи Маркова по перестановкам кажутся избыточными для задачи про подарки. Если знаете что‑то изящное — расскажите.