EduBrick
классная работа

Перебор с отсечением

Программирование на Python: от нуля до олимпиад

0/18решено 0 из 18до зачёта осталось 10

Чему научитесь

  • Читать ограничение из условия как подсказку о виде перебора
  • Перебирать подмножества масками и наращивать суммы без цикла
  • Обрывать заведомо безнадёжные ветки, не теряя ответов
  • Разбивать перебор пополам, когда прямой уже не помещается

Как устроено занятие

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

Дальше 15 задач лестницей: разминка, основа, со звёздочкой. Занятие засчитывается, когда решено 10 — остальные не пропадают и учитываются отдельно.

После занятия — вторая часть, ещё 15 задач на те же приёмы в новых сюжетах.

Сколько это займёт

Примерно час-полтора вместе с задачами. Сроков нет: можно закрыть вкладку и вернуться когда удобно — прогресс сохранится.

теория

Цена перебора

Перебор — это когда мы честно рассматриваем все варианты. У него плохая репутация: кажется, что к нему прибегают, когда не придумали ничего умного.

Это неверно. Перебор — законный инструмент с известной ценой. Вопрос только в том, помещается ли эта цена в отведённое время.

что перебираем сколько вариантов до какого nn успеем
подмножества, 2n2^n 2201062^{20} \approx 10^6 около 20
перестановки, n!n! 9!3.61059! \approx 3.6 \cdot 10^5 около 9–10
пары, n2n^2 10810^8 при n=104n = 10^4 около 5000
тройки, n3n^3 10710^7 при n=200n = 200 около 300

Читать эту таблицу надо в обе стороны. Если в условии написано «n18n \le 18» — это почти прямая подсказка: имеется в виду перебор подмножеств. Если «n9n \le 9» — перестановки. Маленькое ограничение в условии никогда не бывает случайным.

Ещё одна цена — множитель

Вариантов 218=2621442^{18} = 262144, и это немного. Но если для каждого варианта мы делаем ещё цикл по nn элементам, получается 2nn2^n \cdot n — уже под пять миллионов, а при n=20n = 20 и все двадцать.

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

теория

Подмножества через биты

Каждое подмножество из nn элементов — это выбор «беру / не беру» для каждого. То есть строка из nn нулей и единиц. То есть, как мы знаем с прошлого занятия, — число от 0 до 2n12^n - 1.

for mask in range(1 << n):        # 1 << n — это 2 в степени n
    for i in range(n):
        if mask >> i & 1:         # взят ли элемент с номером i
            ...

Выражение mask >> i & 1 сдвигает маску вправо на ii разрядов и берёт младший бит — это ровно ii-я цифра двоичной записи.

Как убрать внутренний цикл

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

sums = [0] * (1 << n)
for mask in range(1, 1 << n):
    low = mask & -mask                       # младший установленный бит
    index = low.bit_length() - 1             # его номер
    sums[mask] = sums[mask ^ low] + a[index]

Теперь на каждое подмножество приходится одно сложение вместо цикла. Это тот самый переход от 2nn2^n \cdot n к 2n2^n.

Трюк mask & -mask выделяет младший единичный бит — стоит просто запомнить.

тест

Проверка: что подсказывает ограничение

В условии написано: «nn от 1 до 9». Что это скорее всего означает?

Войдите, чтобы ответить.
теория

Перебор с возвратом

У масок есть недостаток: они перебирают всё подряд и не дают остановиться на полпути. Рекурсия даёт.

def go(index, current):
    if index == n:
        ...            # набор собран целиком
        return
    go(index + 1, current)              # не берём a[index]
    go(index + 1, current + a[index])   # берём

Тех же 2n2^n листьев, но теперь мы находимся внутри частично собранного решения, и про него уже кое-что известно. Значит можно не спускаться дальше, если ясно, что ничего хорошего внизу нет. Это и называется отсечением.

Три вида отсечений

Нарушено ограничение. Вес уже больше вместимости рюкзака — добавлять предметы бессмысленно, дальше будет только тяжелее.

if weight + weights[index] <= capacity:
    go(index + 1, weight + weights[index], value + values[index])

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

Ответ уже найден. Если задача спрашивает «существует ли», после первой удачи можно прекращать поиск целиком.

Что делает отсечение законным

Отсечение обязано отбрасывать только заведомо безнадёжные ветки. Проверка та же, что была в занятии про окно: если я отбрасываю ветку, точно ли в ней не может оказаться ответ?

Отсечение «вес превысил вместимость» законно, потому что вес только растёт. Если бы веса могли быть отрицательными, оно стало бы неверным — и ответ бы молча испортился.

расчёт

Проверка: сколько подмножеств

Сколько всего подмножеств — включая пустое — у множества из 20 элементов? Введите целое число.

Войдите, чтобы ответить.
теория

Встреча посередине

Есть приём, который превращает недостижимый перебор в достижимый. Пусть чисел 34 и нужно посчитать наборы с суммой ss. Вариантов 2342^{34} — это семнадцать миллиардов, нельзя.

Разобьём список пополам. В каждой половине по 17 чисел, то есть по 217=1310722^{17} = 131072 набора. Переберём их отдельно — это быстро.

Теперь любой набор целого списка — это пара «набор слева плюс набор справа». Значит нужно посчитать пары с суммой ss. Складываем суммы левой половины в словарь подсчётов, идём по суммам правой и для каждой спрашиваем, сколько левых дополняют её до ss.

left = Counter(subset_sums(a[:half]))
total = 0
for value in subset_sums(a[half:]):
    total += left.get(s - value, 0)

Было 2342^{34}, стало 22172 \cdot 2^{17} — в шестьдесят пять тысяч раз меньше. Приём так и называется: встреча посередине.

Если вместо точного равенства нужно «не больше ss», словарь заменяется на отсортированный список и двоичный поиск — ровно то, чем мы занимались на тридцать первом занятии.

теория

Типичные ошибки

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

Забытый откат. В переборе с возвратом состояние меняют перед спуском и обязаны вернуть после. Пропущенный откат портит все последующие ветки, и ошибка проявляется где-то далеко от места, где сделана.

Прерванный цикл с недосчитанным значением. break выходит из цикла, оставляя переменную в промежуточном состоянии. Если её потом проверяют, сравнение идёт с полуфабрикатом.

Внутренний цикл на каждый вариант. 2nn2^n \cdot n вместо 2n2^n. При n=20n = 20 разница между «успел» и «не успел».

Оценка на глазок. «Ну, 2302^{30} — это же всего миллиард». Миллиард операций в Python — это минуты. Считайте до того, как писать.

Как проверять себя

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

Практика: пятнадцать задач

Лестница прежняя: пять разминочных, семь основных, три со звёздочкой. Зачёт при десяти решённых.

Первым делом смотрите на ограничение: 18 — это подмножества, 9 — перестановки, 200 — тройной цикл, 34 — встреча посередине. Ограничение и есть подсказка.

И прежде чем добавить отсечение, скажите себе одним предложением, почему оно ничего не теряет.

лестница задач
развёрнутый ответ

Отсечение, которое портит ответ

Задача: «дан список из n18n \le 18 натуральных чисел; посчитайте, сколько подмножеств имеют сумму ровно ss».

Ученик написал перебор по маскам и добавил отсечение, чтобы не досчитывать заведомо большие суммы:

n, s = map(int, input().split())
a = list(map(int, input().split()))

count = 0
for mask in range(1 << n):
    total = 0
    for i in range(n):
        if mask >> i & 1:
            total += a[i]
        if total >= s:
            break
    if total == s:
        count += 1

print(count)

Постройте вход, на котором программа ошибается, объясните, что именно пошло не так, и предложите исправление.

Войдите, чтобы ответить.