1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8

# теория · шаг 3 из 8

Сочетания: монотонный индекс вместо фильтра

Коротко

Сочетание - это выбор k элементов из n без учёта порядка, и их C(n,k) = n!/(k!·(n−k)!). Перебираются они одной строкой: рекурсия получает start - номер, С КОТОРОГО можно брать, - и вызывает себя с i + 1. Монотонность индекса и есть отказ от порядка: она убирает ровно k! повторов каждого набора. Сломаешь её (i = 0 вместо start) - получишь nᵏ последовательностей вместо C(n,k) наборов: 4096 вместо 70 при n = 8, k = 4. Фильтр по маске даёт тот же ответ в 5,7 раза дороже и в другом порядке.

Если ты пишешь перебор сочетаний монотонным индексом и знаешь, на каких данных отсечение по остатку окупается, а на каких идёт в убыток, - листай до «Теперь сам».

Как думаешь, чем сочетание отличается от перестановки механически

У тебя восемь серверов, надо выбрать четыре под новый сервис. Набор {1, 3, 5, 7} и набор {7, 5, 3, 1} - это один и тот же выбор или два разных?

Ответ очевиден: один. А теперь вопрос, из которого растёт весь урок. Перебор перестановок выдал бы эти два набора как разные, и так же поступил бы с остальными 4! = 24 порядками той же четвёрки. Что надо изменить в переборе, чтобы каждая четвёрка вышла РОВНО ОДИН раз? Подумай, прежде чем читать дальше: где именно в коде живёт «порядок», от которого надо избавиться?

На что это похоже

Представь, что идёшь по полке с книгами слева направо и складываешь четыре книги в сумку. Правило одно: назад не возвращаешься. Любая четвёрка книг попадёт в сумку ровно одним способом - в том порядке, в каком она стоит на полке. Если разрешить ходить туда-сюда, та же четвёрка наберётся двадцатью четырьмя способами, и все они дадут одну и ту же сумку.

«Назад не возвращаемся» - это и есть монотонный индекс. Порядок внутри набора не исчезает по волшебству: он просто фиксируется один раз - по возрастанию номера.

Механизм: один параметр вместо фильтра

У функции перебора появляется параметр start: номер, начиная с которого разрешено брать элементы. Взяли элемент i - спускаемся с start = i + 1, и все элементы левее i становятся недоступны навсегда.

def rec(start, nabor):
    if len(nabor) == k:
        print(nabor)
        return 0
    i = start
    while i < len(a):
        rec(i + 1, nabor + [a[i]])
        i = i + 1
    return 0

rec(0, [])
пусто 12 34 1,21,31,4 2,32,43,4 из «4» ветвей нет: справа ничего не осталось листьев 6 = C(4,2); ветки идут только вправо
Монотонный индекс срезает левую половину каждого поддерева. Ветка «4» не даёт ни одного ответа вовсе - и именно за эти пустые ветки платит отсечение по остатку из конца урока.

Сколько получится ответов, видно из той же картинки: каждая пара - и вообще каждый набор - собирается ровно одним путём, значит листьев-ответов ровно C(n,k): здесь шесть, а для четвёрок из восьми серверов - семьдесят. Тупик «4» ответом не считается: в нём набор так и остался из одного элемента. А формула C(n,k) = n!/(k!·(n−k)!) читается прямо из сравнения с перестановками: упорядоченных наборов по k элементов n!/(n−k)!, каждый набор среди них встречается k! раз во всех порядках, поэтому делим на k!. Монотонность и есть механическая форма этого деления.

Правило

Сочетание - выбор k из n без учёта порядка, число сочетаний C(n,k) = n!/(k!·(n−k)!). Перебор сочетаний отличается от перебора последовательностей одним параметром: функция получает start - номер, с которого разрешено брать, - и после взятия элемента i спускается с i + 1. Это монотонность индекса, и она выбрасывает ровно k! порядков каждого набора. Замени i + 1 на i - получится перебор сочетаний С ПОВТОРЕНИЯМИ, их C(n+k−1, k). Замени start на 0 - получится перебор всех nᵏ последовательностей. Фильтровать подмножества по размеру (2ⁿ вариантов с проверкой «ровно k единиц») тоже можно, но это дороже в разы и даёт другой порядок.

Разбор: три версии на восьми серверах, выбрать четыре

Правильный ответ - C(8,4) = 70. Теперь три программы, которые его считают, и их цена по движку. Чтобы сравнение было честным, все три наборы не печатают, а СЧИТАЮТ: на дне стоит otvety.append(nabor) вместо print, в конце - print(len(otvety)). Склейка nabor + [a[i]] создаёт новый список на каждом вызове, поэтому ловушки со ссылкой из первого урока тут нет. Фильтр по маске - это счётчик из того же урока с одной добавкой: набор идёт в счёт, только если в нём ровно k элементов (код - в «Теперь сам»).

версия                                          ответ   шагов   вызовов
монотонный индекс: rec(i + 1, ...)                 70    1583       163
сломанная монотонность: i = 0 вместо start       4096   46727      4681
фильтр по маске: 2⁸ наборов, проверка размера       70    9036         0

Сломанная версия дала 8⁴ = 4096 - все последовательности из четырёх номеров, включая [1,1,1,1] и все 24 порядка каждой честной четвёрки. Ошибка выглядит безобидно: start в коде есть, он просто не используется в цикле. Ответ при этом неверный в 58 раз, и программа об этом не сообщает.

Фильтр по маске отвечает верно и платит за это шестикратной ценой: он проходит все 256 подмножеств и отбрасывает 186 из них, посчитав у каждого число единиц. Чем сильнее k отличается от n/2, тем хуже: при n = 12, k = 3 фильтр перебирает 4096 наборов ради 220 ответов - 200 930 шагов движка против 2677 у монотонного перебора на той же задаче, то есть в 75 раз дороже.

Разбор: отсечение, которое то спасает, то мешает

Из картинки видно, что часть ветвей заведомо пустая: если осталось меньше элементов, чем нужно добрать, ответа в этой ветке нет. Проверка пишется в одну строку:

def rec(start, nabor):
    if len(nabor) == k:
        otvety.append(nabor)
        return 0
    if len(a) - start < k - len(nabor):
        return 0                 # добрать уже не из чего
    i = start
    while i < len(a):
        rec(i + 1, nabor + [a[i]])
        i = i + 1
    return 0

Выглядит как явное улучшение. Замер говорит иначе:

задача        без отсечения   с отсечением   выигрыш
C(5,3)                  227            223        2%
C(8,4)                 1583           1524        4%
C(10,5)                6774           6282        7%
C(10,8)               12066           4446       63%
C(20,3)               12137          12298    −1%, стало ДОРОЖЕ

Объяснение простое, и его стоит запомнить на весь раздел. Отсечение стоит шаг на КАЖДОМ узле, а экономит только на тех ветках, которые действительно упираются в нехватку остатка. При k, близком к n, таких ветвей большинство: чтобы набрать восемь из десяти, пропустить можно всего два элемента, и почти любая ветка, начатая не с самого левого края, обречена. Отсюда 63% на C(10,8) - узлов стало 375 вместо 1013. При k около половины выигрыш скромный, 7%. При k, сильно меньшем n, тупиков почти нет: взять три элемента из двадцати можно откуда угодно, и проверка просто берёт плату, ничего не отсекая. Отсюда минус один процент на C(20,3).

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

Что ломается без этого

Первое: start объявлен и не использован. Самая частая ошибка, и мы её только что посчитали: ответ больше настоящего в k! раз и даже больше, потому что добавляются наборы с повторами. Признак на проверке - число ответов выглядит как степень, а не как биномиальный коэффициент.

Второе: rec(i, ...) вместо rec(i + 1, ...). Одна правка, и задача меняется на сочетания с повторениями: при n = 10, k = 5 ответ 2002 вместо 252. Иногда это именно то, что нужно (выбрать пять шариков из десяти цветов, цвета могут повторяться), и тогда формула другая: C(n+k−1, k). Откуда она: такой выбор - это строка из k шариков и n − 1 перегородок между цветами, и набор задаётся тем, на каких k местах из n + k − 1 стоят шарики. Проверка: C(14, 5) = 2002. Проверять такой перебор надо на маленьком примере, где ответ известен руками.

Третье: собирать все сочетания в список при больших n. C(30,15) - это 155 миллионов наборов. Обрабатывать их надо по одному, на месте.

Четвёртое: считать C(n,k) через факториалы в лоб. 30! - число из 33 цифр, хотя сам C(30,15) всего девятизначный. Python такое переживёт, его целые не переполняются, но в языках с 64-битными целыми 30! просто не поместится и ответ выйдет мусорным. Считают произведением с делением на каждом шаге: начать с c = 1 и для j от 0 до k − 1 повторять c = c * (n - j) // (j + 1).

Зачем это в работе

Первый сюжет: тестовые наборы. «Проверить все пары параметров» - это C(n,2), «все тройки» - C(n,3). Разница практическая: при двадцати параметрах пар 190, троек 1140, а четвёрок уже 4845. Отсюда и берутся промышленные стратегии тестирования «все пары», а не «все комбинации».

Второй: выбор подмножества ресурсов. «Какие четыре из восьми узлов вывести в обслуживание, чтобы кластер остался живым» - перебор из 70 вариантов, посчитать можно на месте и честно, без эвристик.

Третий: оценка в разговоре. Когда кто-то говорит «переберём все сочетания», надо вслух назвать число. C(50,5) - это два миллиона, нормально. C(50,10) - десять миллиардов, уже нет. Знать формулу значит отличать первое от второго до того, как написан код.

Теперь сам

Напиши перебор, который печатает все сочетания по k из списка a в лексикографическом порядке, и объясни, почему фильтр по маске тут не подойдёт.

Ответ. Перебор - тот же монотонный индекс, печать на дне:

def rec(start, nabor):
    if len(nabor) == k:
        print(nabor)
        return 0
    i = start
    while i < len(a):
        rec(i + 1, nabor + [a[i]])
        i = i + 1
    return 0

rec(0, [])

Порядок получается лексикографическим сам: на каждом уровне элементы берутся слева направо, и значит первый набор - [1, 2, 3], последний - из трёх самых правых элементов. Фильтр по маске - вот он целиком:

mask = 0
while mask < 2 ** len(a):
    nabor = []
    i = 0
    while i < len(a):
        if mask // 2 ** i % 2 == 1:
            nabor.append(a[i])
        i = i + 1
    if len(nabor) == k:          # вся разница со счётчиком из первого урока
        print(nabor)
    mask = mask + 1

Он выдаёт те же наборы, но в порядке возрастания НОМЕРА маски, а это другой порядок: при n = 6, k = 3 после [2, 3, 4] у него идёт [1, 2, 5]. Маска набора [2, 3, 4] - это 01110, то есть 14, маска [1, 2, 5] - 10011, то есть 19, и между ними масок с тремя единицами нет. По алфавиту же [1, 2, 5] должен стоять раньше. Замер на этой же задаче: монотонный перебор 368 шагов, фильтр по маске 1750.

Главное

Монотонный индекс - это весь переход от последовательностей к сочетаниям: один параметр start, спуск с i + 1, и каждый набор выходит ровно один раз, потому что порядок внутри набора зафиксирован по возрастанию номера. Ценой одной правки задача меняется: rec(i, ...) - сочетания с повторениями (C(n+k−1, k)), i = 0 вместо start - все nᵏ последовательностей (4096 вместо 70 при n = 8, k = 4, и программа не жалуется). Фильтр по маске верен, но платит перебором всех 2ⁿ подмножеств ради C(n,k) ответов - в 5,7 раза дороже при n = 8, k = 4 и тем хуже, чем дальше k от n/2. И первый замер про отсечения, с которым пойдём в следующую главу: отсечение по остатку даёт от 63% до минус одного процента - выгода отсечения зависит от данных и проверяется замером, а не рассуждением.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.