
В данной серии статей я изложу мои наработки по решению задачи про Винтика и Шпунтика в рамках челленджа @vvvphoenix. Наработок достаточно много, и изложение их всех в одной статье получилось бы слишком объемным, либо же пришлось описывать всё достаточно сжато. Ни того, ну другого не хотелось бы, поэтому разбиваю изложение на части. Пока планируется 4 части, возможно в ходе их написания появятся идеи для новых частей или новые продвижения в решении задачи. И тогда частей будет больше. Данная первая часть скорее вводная, в которой я опишу такой подход к подсчету числа вариантов в различных комбинаторных задачах, как "формула включения-исключения".
Описывать все буду достаточно подробно с самого начала, чтобы было понятно даже людям не совсем "в теме". Однако, такие подробности не только для читателей, сколько для себя. Для того, чтобы разложить у себя в голове всё последовательно и по полочкам. И ничего не пропустить.
Постановка задачи и совершенные паросочетания
Формулировка задачи в редакции челленджа звучит следующим образом:
«Незнайка записывает 9 разрядов 10-значного десятичного числа и пропускает один по своему выбору. Пропущенный разряд он предлагает записать Винтику, а затем показывает полученное 10-значное число Шпунтику. Сколькими способами могут Винтик и Шпунтик договориться, чтобы Шпунтик угадал, какой именно разряд записал Винтик?»
Предполагается, что Винтик и Шпунтик договариваются заранее, до того как Незнайка показывает Винтику число с пропущенным знаком, при этом зная "правила игры". То есть на каждый вариант числа с пропуском, которое может показать Незнайка, у Винтика должен быть такой вариант заполнения пропуска, чтобы потом Шпунтик, глядя на получившееся число уже без пропуска, смог однозначно установить, в каком месте был пропуск.
Давайте разберёмся с "ослабленной" версии задачи, когда Незнайка записывает всего лишь 2-значное число с пропуском (в двоичной системе счисления). Варианты того, что мог записать Незнайка:,
,
,
. Всего 4 варианта. Винтик может переделать
либо в
, либо в
.
— либо в
, либо в
. И так далее. Варианты того, что может сделать Винтик в каждом из случаев, можно нарисовать в виде двудольного графа, где каждая вершина в левой доле соответствует тому, что написал Незнайка, а в правой — тому, во что это может превратить Винтик. Причём рёбра в этом графе — варианты действий Винтика.

Теперь, после заполнения пропуска, Шпунтику доступен один из вариантов,
,
,
. И по нему Шпунтику нужно определить какое число с пропуском было изначально. Причем он может выбирать только между теми вариантами, откуда из левой доли приходят рёбра.
Заметим, что в обоих долях вершин поровну, поэтому чтобы Шпунтику не ошибиться (с каким бы числом с пропуском не пришел Незнайка), на любые два варианта, что видит Шпунтик, ответы должны быть различны. Действительно, если на какие-то два "входа" для Шпунтика соответствуют одной и той же вершине слева, то какая-то вершина слева вовсе не будет "покрыта" возможными ответами Шпунтика. И как быть, если Незнайка запишет именно этот не покрытый вариант? Если же для любых двух "входов" для Шпунтика ответы различны — то любой вариант, с каким бы не пришел Незнайка, будет покрыт, и Шпунтик его угадает. Дело осталось за малым: Винтику и Шпунтику нужно договориться так, чтобы Винтик вставлял цифру в соответствии с "табличкой" всевозможных ответов Шпунтика.
В общем и целом, для Винтика и Шпунтика необходимо и достаточно какое-то взаимно-однозначное соответствие между вершинами левой и правой доли графа, причём каждую пару вершин в этом соответствии должно соединять ребро графа. Такая конструкция называется совершенным паросочетанием. Совершенными они называются потому, что покрывают все вершины графа. Ну и количество способов договориться — это количество таких попарно различных паросочетаний. Например, для нашего "ослабленного" бинарного случая совершенных паросочетаний ровно два:

В общей, не "ослабленной", постановке задачи у нас и в левой, и в правой долях повершин. Для промежуточных (так называемых
-арных) версий задачи у нас в обеих долях по
вершин. Бинарный и тернарный варианты задачи решаются достаточно легко, как показал сам @vvvphoenix. А вот уже с кватернарным случаем возникают проблемы.
Количество совершенных паросочетаний и перманент
В принципе, задачу можно попытаться решить перебором: для каждой вершины левой доли выберем исходящее ребро всеми возможными способами, а затем проверим, что все вершины правой доли покрыты ровно одним ребром. Получим решение за (здесь и далее
означает
, где
— какой-то полином от
). Даже для
получается астрономическая сложность. Впрочем, в перебор вариантов можно добавить некоторые отсечения: например, откатываться, как только какая-либо вершина из правой доли покрыта двумя выбранными ребрами. Тогда случай
можно решить очень быстро, но для больших
возникнут сложности: если мы будем перечислять все варианты, то нам в любом случае потребуется количество операций не меньше, чем количество вариантов.
Однако, чтобы найти ответ, можно не перечислять все варианты по одному. Один из вариантов так сделать: динамическое программирование.
Обозначим множество вершин левой доли графа за, а правой — за
. Пусть
для
— количество частичных паросочетаний, которые полностью покрывают
первых вершин левой доли
(для этого вершины левой доли надо заранее пронумеровать:
) и все вершины в
из правой доли
. Тогда
(упс) и
Суть формулы достаточно проста: мы всеми способами убираем "последнее" ребро из, сводя задачу к чуть меньшей по размеру. Ответ — это
. Итого получаем решение за
, что заметно быстрее перебора. Единственный недостаток этого решения — нам нужно
памяти для хранения состояний. Хорошая новость состоит в том, что есть решение, лишенное данного недостатка — это формула включений-исключений, о которой чуть ниже.
Также немного упомянем такую штуку, как перманент. Построим матрицу, в которой на пересечении
-й строки
-го столбца будет
, если
-я вершина левой доли и
-я вершина правой доли соединены ребром. То есть матрица
— это такой компактный способ записать ребра двудольного графа. Например, для нашего случая
матрица выглядит следующим образом:
По сути эта та самая "табличка Шмелёва" из публикации @vvvphoenix. Перманент матрицы находится по следующей формуле (полагая, чтоимеет размер
):
Мы перебираем все перестановки, которые по сути соответствуют всевозможным совершенным паросочетаниям в полном двудольном графе, а затем суммируем веса всех этих паросочетаний. Здесь вес паросочетания — это произведение весов ребер, которые берутся из матрица
. Таким образом, если паросочетание действительно допустимое для матрицы
(все ребра "на месте" с весом
), то к сумме прибавится
. Иначе, если хотя бы одного ребра нет — вес паросочетания будет равен
.
Проблема в том, что для произвольных матриц (и даже для-матриц) перманент быстро (за полиномиальное время) считать никто не умеет. Его можно вычислить по определению выше за
, однако есть способ чуть быстрее — через то же самое динамическое программирование за
, что описано выше. Разве что формула чуть-чуть изменится:
Отметим, что эта формула работает для любых матриц, не обязательно состоящих только из 0 и 1. В любом случае, вот эти матрицы и перманент — это просто еще одна формулировка изначально задачи, и к решению самой задачи нас не сильно приближает.
Формула включений-исключений и её доказательство
Применительно к нашей задаче решение через формулу включения-исключения описывается в слайдах лекции по ссылке. Саму лекцию я не видел, поэтому восстановлю тут необходимую нам информацию по имеющимся картинкам.
Слайд с формулой включений-исключений (и с её доказательством) следующий:

У нас просто есть две какие-то функциии
, которые возвращают какие-то числа для каждого подмножества
. При этом
можно выразить через
согласно формуле на слайде. Тогда, как утверждает Лемма, мы можем обратить вычисление функций: наоборот, выразить
через
.
Когда это может кому-то понадобиться? Например, в случаях, когдасчитается долго, сложно и неясно как (и именно её нам надо посчитать для решения задачи), а вот
решается просто, быстро и понятно.
Теперь перейдём к доказательству. Первый переход в доказательстве — это просто подстановка выражения функциичерез функцию
. Во втором переходе мы группируем все слагаемые по входящему туда множителю
, после чего в каждой группе выносим этот множитель за скобку. Третий переход самый сложный для понимания. Там утверждается (в сноске ниже), что все слагаемые (кроме
при
) будут нулевые. Проиллюстрируем этот момент картинкой:

Итак, если, то мы берем сумму по всем множествам
, "зажатым" между
и
. В частности, слагаемых в сумме
. Давайте возьмём в "зазоре" между
и
какой-нибудь элемент
. Тогда всевозможные множества
можно разбить на пары. В каждой паре множества
и
почти одинаковы, единственное отличие: в
входит, а в
— нет. То есть,
. Одно из этих множеств будет давать вклад в общую сумму
, а другое
, то есть итоговый вклад
. И так по всем парам.
В случаеслагаемое в сумме по "зажатым"
оказывается ровно одно, и оно равно
. Именно благодаря ему до самого конца "доживает" единственное слагаемое внешней суммы
.
Применение формулы к нашей задаче
Теперь нам нужно подобрать подходящие функциии
, которые можно "приложить" к нашей задаче. Мы будем рассматривать покраски рёбер двудольного графа следующего вида: для каждой вершины из левой доли
мы будем выбирать ровно одно исходящее из неё ребро и красить его в красный цвет. При этом в некоторые вершины правой доли
в итоге могут входить несколько покрашенных рёбер, а некоторые — ни одного. Доли графа равномощны, то есть
, и в каждой раскраске у нас будет покрашено в красный цвет ровно
рёбер.

Будем называть вершинуиз правой доли
покрытой, если в неё входит хотя бы одно покрашенное ребро. Аналогично, будем называть вершину не покрытой, если в неё не входит ни одного покрашенного ребра.
Теперь для каждого подмножествавершин правой доли
определим функцию
как количество раскрасок рёбер (вида, описанного выше), в которых все вершины из
покрыты, а все вершины не их
(то есть из
) — не покрыты.
Соответственно, для каждого подмножестваопределим функцию
как количество допустимых раскрасок, в которых все вершины не из
не покрыты (некоторые вершины из
при этом могут быть покрыты, а остальные — не покрыты).
Несложно убедиться, что. Действительно, рассмотрим все допустимые раскраски, которые считает
, а затем сгруппируем их: в каждой группе будут все способы, в которых множество покрытых вершин правой доли в точности равно
(а остальные вершины из
не покрыты). Но количество раскрасок в каждой такой группе
— это в точности
.
Ответом на нашу задачу будет . И вот почему. В каждой раскраске, которую учитывает
, будут учтены все те способы, в которых все
вершин из правой доли покрыты. Но у нас в каждой раскраске ровно
покрашенных рёбер. Значит каждая вершина правой доли будет покрыта ровно одним красным ребром. А это, собственно, означает, что рассматриваемые раскраски функцией
являются паросочетаниями.
Отлично, теперь мы можем через формулу включений-исключений выразитьчерез знакопеременную сумму значений
. Как считать
? А очень просто.
Пусть дано— это множество "разрешённых" вершин, куда можно проводить покрашенные рёбра (а можно и не проводить). А вот вершины множества
— "запрещённые". Туда нельзя проводить покрашенные рёбра ни при каких обстоятельствах.
Теперь мы считаем количество покрасок так: для каждой вершины левой доли (из) мы смотрим во сколько разрешённых вершин правой доли мы можем провести ребро. Это, собственно, количество способов покрасить исходящее ребро из данной вершины левой доли. Теперь просто перемножаем эти количества способов. Это и есть значение
.

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

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

Как теперь вычислить? Надо из
вычесть все те варианты, когда вершина
покрыта, а вершина
— нет. Это вон тот верхний синий прямоугольник на картинке. Как посчитать эти варианты? Да точно так же, как и
, нужно только везде в множества добавить
. Получим:
Теперь введём в рассмотрение третью вершину. Картинка теперь станет трёхмерной, и нам нужно из текущего числа способов вычесть вон те в дальнем "кубике":

Формулу выписывать не буду, но нам нужно просто взять, везде в множества добавить
, после чего полученную сумму вычесть из
— и получится
. Проделав эту процедуру
раз, получим:
Это совпадает с формулой, полученной выше.
Плюс данного доказательства в следующем: числамы получаем в порядке убывания, так как все способы, соответствующие
, входят также в множество способов, соответствующих
. Это позволяет оценивать финальный ответ, посчитав формулу включений-исключений лишь частично. Да, есть способы оценивать ответ точнее, но здесь мы получаем оценку "нахаляву", всего лишь аккуратно выбрав порядок суммирования.
Обобщение "классической" задачи
Давайте напоследок сделаем обобщение изначальной задачи. Для того, чтобы было больше вариантов "входных данных". Решения для обобщений задачи могут в будущем дать какие-нибудь подсказки к решению изначальной задачи.
Будем рассматривать числа с переменной системой счисления. Например, пусть в числепервая цифра
будет принимать
различных значений,
—
различных значений, а
—
различных значений. Тогда всего всевозможных чисел у нас будет
.
Примером "чисел" с переменной системой счисления для различных разрядов является запись текущего времени, например 12:13:14. При этом старший "разряд" часов может приниматьразличных значений, а другие два "разряда" минут и секунд по
.
Итак, пусть количество способов для каждой из"цифр" числа задано последовательностью
. Тогда общее количество всевозможных чисел равно
. Теперь посчитаем количество чисел, в которых в некоторых разрядах стоят звёздочки. Если звёздочка стоит в первом (старшем) разряде, то количество таких чисел равно
. Аналогично, если звездочка стоит в
-м разряде, то количество чисел равно
. Итого, если
— это общее количество "звезданутых" чисел, то
Нас интересуют варианты задачи, где, чтобы количество вершин в долях графа было одинаково и паросочетания были совершенными (для
ответ очевидно равен
, а для случая
паросочетания получатся не совершенные, и нужно еще договориться с тем, что же считать для Винтика и Шпунтика "стратегией"). Итак, заменяя
на формулу выше и сокращая обе части равенства на
, получаем
Так это же египетские дроби! "Классическому" варианту задачи соответствует сумма издробей, каждая из которых равна
, и эта сумма действительно равна
. Будем записывать такие задачи в виде последовательности
. Для
у нас есть только классическая задача
. Для
помимо классической
можно ввести в рассмотрение также задачи
и
. Для
...
А теперь давайте, чтобы создать какую-то активность в комментариях, рассмотрим следующие задачки, одну из которых я нашел в интернете в списке задач для кружка 7 класса, загуглив словосочетание "египетские дроби". Остальные мои задачки логично вытекают из неё.
Докажите, что при каждом
уравнение
в натуральных числах имеет конечное число решений.
Придумайте алгоритм генерации всех таких решений для заданного
. Не обязательно, чтобы алгоритм был супер эффективным, но нужно чтобы он находил все решения.
Найдите все решения для
.
Решения пишите в комментариях, в следующей части я напишу свои решения. Как раз решения уравнения дляи будут составлять "бенчмарк" для тестирования последующих улучшений алгоритма решения изначальной классической задачи Винтика и Шпунтика.
Пример кода
Я набросал пример кода для трехзначных чисел на языке Python. Его можно видеть ниже:
def solve_vs( A, B, C ):
# build graph
L = []
for i in range(A):
for j in range(B):
for k in range(C):
L.append( [i, j, k] )
R = []
for j in range(B):
for k in range(C):
R.append( [-1, j, k] )
for i in range(A):
for k in range(C):
R.append( [i, -1, k] )
for i in range(A):
for j in range(B):
R.append( [i, j, -1] )
G = []
for x in L:
adj = []
for i in range(len(R)):
flag = True
for j in range(3):
if x[j]!=-1 and R[i][j]!=-1 and x[j]!=R[i][j]:
flag = False
if flag: adj.append( i )
G.append( adj )
# calc formula
ans = 0
k = 0
last_ans = 0
for s in range((1<<len(R))-1,-1,-1):
if k<len(R) and ((s>>k)&1)==0:
print( "subtotal", k, ans, file=sys.stderr )
print( "subtotal", k, ans, last_ans/ans, flush=True )
last_ans = ans
k += 1
prod = -1 if (((1<<len(R))-1-s).bit_count()&1) else 1
for adj in G:
sum = 0
for u in adj:
if ((s>>u)&1):
sum += 1
prod *= sum
ans += prod
print( "total", ans )
solve_vs( 3, 3, 3 )
Первая половина кода отвечает за построение графа. Сначала мы формируем числа, соответствующие вершинам левой и правой долей. Храним их в виде списков "цифр", звёздочку обозначаем как "-1". Граф хранится в виде списков смежности для вершин левой доли.
Далее вычисляем ответ по формуле. Вперебирается подмножество вершин правой доли. При этом единичный бит означает, что соответствующую вершину правой доли можно покрывать (а можно не покрывать), а нулевой бит — что данную вершину покрывать нельзя. Количество способов провести ребро для каждой вершины левой доли
вычисляется как количество "разрешенных" вершин правой доли, куда есть ребро из
. Мы перемножаем все эти способы по всем вершинам левой доли и прибавляем к ответу (или вычитаем, в зависимости от четности количества нулей в
).
Как только все комбинации для каких-нибудьпоследних битов
будут обработаны — мы выводим промежуточный ответ. Это именно те приближения к финальному ответу, которые обсуждались в разделе про скульптора.
В результате работы программы (скажем прямо: не быстрой) получились следующие ответы:
Задача | Ответ | Разложение на простые |
Интересное наблюдение: ответ дляровно в два раза меньше, чем для
. Совпадение или закономерность? Кто знает...
Полные логи программы для каждого случая можно посмотреть под спойлерами:
(3, 3, 3)
subtotal 0 7625597484987 0.0
subtotal 1 5366161193139 1.4210526315789473
subtotal 2 3776187506283 1.4210526315789473
subtotal 3 2657317134051 1.4210526315789473
subtotal 4 1869963909147 1.4210526315789473
subtotal 5 1315900528659 1.4210526315789473
subtotal 6 926004075723 1.4210526315789473
subtotal 7 651632497731 1.4210526315789473
subtotal 8 458556202107 1.4210526315789473
subtotal 9 322687697779 1.4210526315789473
subtotal 10 193593800315 1.6668286755771566
subtotal 11 116144990275 1.6668286755771566
subtotal 12 69680220875 1.6668286755771566
subtotal 13 40148676475 1.735554618304513
subtotal 14 23133052715 1.735554618304513
subtotal 15 13328910811 1.735554618304513
subtotal 16 7218187044 1.8465732087227413
subtotal 17 3908963376 1.8465732087227413
subtotal 18 2116874304 1.8465732087227413
subtotal 19 878041080 2.410905767643582
subtotal 20 339434928 2.586772920434399
subtotal 21 118398672 2.866881209613567
subtotal 22 40756224 2.9050451778849777
subtotal 23 12553776 3.246531083556055
subtotal 24 3287856 3.8182256157203964
subtotal 25 724464 4.53832902670112
subtotal 26 118848 6.095718901453958
total 10752
(2, 4, 4)
subtotal 0 1853020188851841 0.0
subtotal 1 1029455660473245 1.8
subtotal 2 571919811374025 1.8
subtotal 3 317733228541125 1.8
subtotal 4 176518460300625 1.8
subtotal 5 98065811278125 1.8
subtotal 6 54481006265625 1.8
subtotal 7 30267225703125 1.8
subtotal 8 16815125390625 1.8
subtotal 9 9341736328125 1.8
subtotal 10 5189853515625 1.8
subtotal 11 2883251953125 1.8
subtotal 12 1601806640625 1.8
subtotal 13 889892578125 1.8
subtotal 14 494384765625 1.8
subtotal 15 274658203125 1.8
subtotal 16 152587890625 1.8
subtotal 17 90087890625 1.6937669376693767
subtotal 18 53187890625 1.6937669376693767
subtotal 19 31402130625 1.6937669376693767
subtotal 20 18539817921 1.6937669376693767
subtotal 21 9747221346 1.902061855670103
subtotal 22 5124555396 1.902061855670103
subtotal 23 2694210696 1.902061855670103
subtotal 24 1416468496 1.902061855670103
subtotal 25 581258496 2.4368994272730595
subtotal 26 225833712 2.573834042988232
subtotal 27 81758688 2.762198336646498
subtotal 28 27007968 3.027206193372267
subtotal 29 6994368 3.8613879052403304
subtotal 30 1517184 4.610098709187548
subtotal 31 246528 6.154205607476635
total 23040
(2, 3, 6)
subtotal 0 150094635296999121 0.0
subtotal 1 83385908498332845 1.8
subtotal 2 46325504721296025 1.8
subtotal 3 25736391511831125 1.8
subtotal 4 14297995284350625 1.8
subtotal 5 7943330713528125 1.8
subtotal 6 4412961507515625 1.8
subtotal 7 2451645281953125 1.8
subtotal 8 1362025156640625 1.8
subtotal 9 756680642578125 1.8
subtotal 10 420378134765625 1.8
subtotal 11 233543408203125 1.8
subtotal 12 129746337890625 1.8
subtotal 13 72081298828125 1.8
subtotal 14 40045166015625 1.8
subtotal 15 22247314453125 1.8
subtotal 16 12359619140625 1.8
subtotal 17 6866455078125 1.8
subtotal 18 3814697265625 1.8
subtotal 19 1861572265625 2.0491803278688523
subtotal 20 908447265625 2.0491803278688523
subtotal 21 443322265625 2.0491803278688523
subtotal 22 216341265625 2.0491803278688523
subtotal 23 105574537625 2.0491803278688523
subtotal 24 51520374361 2.0491803278688523
subtotal 25 20270311224 2.5416666666666665
subtotal 26 7975204416 2.5416666666666665
subtotal 27 3137785344 2.5416666666666665
subtotal 28 1234538496 2.5416666666666665
subtotal 29 485720064 2.5416666666666665
subtotal 30 191102976 2.5416666666666665
subtotal 31 77723072 2.4587676616796617
subtotal 32 28343168 2.7422154079600416
subtotal 33 8951040 3.1664664664664666
subtotal 34 2311680 3.872093023255814
subtotal 35 437760 5.280701754385965
total 46080
Можно видеть, как количество способов при каждом удвоении числа слагаемых формулы уменьшается. Числа в конце каждой строки означают отношение числа способов в предыдущей строке к числу способов в текущей. Эти отношения постепенно увеличиваются, то есть число способов уменьшается с характерным "свистом". То есть, с ускорением. Это все мы будем использовать для эвристической оценки ответа для задачи.
Заключение
В этой части мы познакомились с формулой включений-исключений. Она даёт алгоритм решения задачи Винтика и Шпунтика за время, что значительно быстрее наивного перебора за
, и уж тем более быстрее наивного вычисления перманента матрицы смежности двудольного графа за
. Тем не менее, для
мы получаем
слагаемых в формуле, и вычислить их сумму (если считать "в лоб") за разумное время решительно невозможно. Однако мы рассмотрели самый общий подход, который работает вообще для любого двудольного графа (у которого доли равномощны). У нас же двудольный граф специального вида, и для него вычисление формулы можно ускорить как минимум до
, что для
дает порядка
операций. Это гораздо меньше
, но подробности — в следующей части.