Backtracking — старая, простая и часто единственно применимая техника для задач, где других путей не видно: NP-полные / NP-трудные задачи, переборные комбинаторные конструкции, точное покрытие. Если линейные техники (DP, жадный, бинпоиск) не подходят, а размер входа маленький — можно перебрать. Главное — отсекать.
Эта задача — учебный кейс. Условие звучит безобидно: распределить задач между работниками так, чтобы максимальная нагрузка была минимальна. Сложность — задача NP-трудная (вариант задачи о разбиении). На малом (до –) перебор работает; ключ — три отсечения, превращающих наивный в реалистичные узлов рекурсии.
Что дано
Есть задач длительностями и работников. Каждая задача должна быть назначена ровно одному работнику. Работник может получить любое число задач (включая ноль). Нагрузка работника — сумма длительностей всех его задач. Найти минимально возможный максимум нагрузок при оптимальном распределении.
Ограничения
- .
- .
Формат ввода
n k
a_1 a_2 ... a_n
Формат вывода
Одно целое число — минимально возможный максимум нагрузок.
Пример A
4 2
3 1 2 4
Разбиения и максимумы:
- : .
- : .
- : .
- : .
- … и так далее.
Минимум — .
Пример B
6 3
1 2 3 4 5 6
Сумма , средняя нагрузка . Распределение — максимум . Меньше быть не может (любой работник, получивший задачу , имеет нагрузку ; на двух оставшихся работников приходится , средняя , кто-то имеет — нет, ошибаюсь: 7+8 = 15 или 6+9 — но без распределим 15 на двоих, минимум максимума = ? Хм, = 8 и = 7, максимум 8. Аналогично если 6+1 = 7, проверим , — максимум 8. Значит, нужно вернуться к с максимумом 7).
Ответ — .
Идея решения
Прямой перебор. Каждой задаче независимо назначаем одного из работников. Получаем конфигураций; для каждой считаем максимум, берём минимум. На — — терпимо. На — — TL.
Отсечения превращают этот наивный перебор в практически быстрый. Три ключевых:
Отсечение 1: сортировка задач по убыванию. Назначаем задачи в порядке убывания длительности. Это не сокращает количество узлов перебора, но позволяет раньше прокрасться к глобальному оптимуму (большие задачи распределяются первыми, и плохие ветки отсекаются раньше следующими отсечениями).
Отсечение 2: branch-and-bound. Хранится текущий лучший найденный ответ best. В каждом узле перебора, перед назначением задачи работнику , проверяем: если новая нагрузка — пропускаем эту ветку. Любое продолжение даст максимум , что не улучшит результат.
Отсечение 3: симметрия по одинаковым работникам. Если в данный момент несколько работников имеют одинаковую текущую нагрузку — назначать новую задачу можно только первому из них (по индексу). Остальные дадут эквивалентные ветки перебора, рассчитанные через зеркальную перестановку работников. Это отсекает дубликатов в листьях дерева.
Эффект от отсечений
| Что включено | Узлов в дереве для ВСЁ единицы |
|---|---|
| Наивный | |
| + Сортировка | (та же асимптотика) |
| + Branch-and-bound | узлов |
| + Симметрия | узлов |
Симметрийное отсечение — самое эффективное: при равных работниках оно сразу режет -ветви. Branch-and-bound даёт основной вклад при разных .
Алгоритм
прочитать n, k, a[0..n-1]
отсортировать a по убыванию
best ← sum(a) # худший случай: один работник делает всё
loads[0..k-1] ← 0
функция backtrack(i):
если i == n:
cur_max ← max(loads)
если cur_max < best:
best ← cur_max
return
seen ← пустое множество
для j от 0 до k-1:
если loads[j] ∈ seen:
продолжить # симметрия
seen ← seen ∪ {loads[j]}
если loads[j] + a[i] >= best:
продолжить # branch-and-bound
loads[j] ← loads[j] + a[i]
backtrack(i + 1)
loads[j] ← loads[j] - a[i]
backtrack(0)
вывести best
Семантика best — лучший найденный максимум на момент рассмотрения. Старт с sum(a) гарантирует, что любое полное распределение даст не хуже.
Руками на примере A
, , . Сортируем по убыванию: . best = 10 (сумма).
backtrack(i=0), loads=[0, 0]
Задача a[0]=4:
seen={}, рассмотрим j=0:
loads[0] + 4 = 4 < best(10) — продолжаем
loads = [4, 0]
backtrack(i=1)
Задача a[1]=3:
seen={}, рассмотрим j=0:
loads[0] + 3 = 7 < 10 — продолжаем
loads = [7, 0]
backtrack(i=2)
Задача a[2]=2:
seen={}, j=0: loads[0]+2=9<10 — продолжаем; loads=[9,0]
backtrack(i=3)
Задача a[3]=1:
j=0: loads[0]+1=10 >= best(10) — пропуск
j=1: 0 не в seen; loads[1]+1=1 < 10 — продолжаем; loads=[9,1]
backtrack(i=4): cur_max=9, best=9
откат: loads=[9,0]
откат: loads=[7,0]
seen={9}; j=1: 0 не в seen; loads[1]+2=2 < 9 — продолжаем; loads=[7,2]
backtrack(i=3)
Задача a[3]=1:
j=0: loads[0]+1=8 < 9 — продолжаем; loads=[8,2]
cur_max=8, best=8
откат; j=1: 2 не в seen; loads[1]+1=3 < 8 — продолжаем; loads=[7,3]
cur_max=7, best=7
откат
откат
откат
j=1: 0 не в seen; loads[1]+3=3 < 7 — продолжаем; loads=[4,3]
backtrack(i=2)
Задача a[2]=2:
j=0: 4+2=6 < 7 — продолжаем; loads=[6,3]
backtrack(i=3)
a[3]=1: j=0: 6+1=7 >= 7 — пропуск
j=1: 3 не в seen; 3+1=4 < 7 — loads=[6,4]
cur_max=6, best=6
откат
j=1: 3 не в seen; 3+2=5 < 6 — продолжаем; loads=[4,5]
backtrack(i=3)
a[3]=1: j=0: 4+1=5 < 6 — loads=[5,5]; cur_max=5, best=5
j=1: 5 ∈ seen{4} ? нет; 5+1=6 >= 5 — пропуск
откат
откат
откат
j=1: 0 ∈ seen{0} — пропуск (симметрия)
Итог — best = 5. ✓
Симметрийное отсечение на самом верхнем уровне обрезает зеркальную половину дерева; branch-and-bound отсекает каждый раз, когда добавление задачи к какому-то работнику не улучшит результат.
Код решения
Комментарии по реализации
bestкак массив длины 1 в Python. Из-за замыкания и неизменяемостиint. В C++ — обычная глобальная переменная.seen—setв обоих языках. При можно было обойтись массивом, ноset— компактнее и читаемее. На производительность не влияет.- Сортировка по убыванию обязательна. Без неё branch-and-bound отсекает в разы меньше — большие задачи добавляются на поздних уровнях, когда уже распределены сотни мелких комбинаций.
- Тип нагрузки —
long long. При , до сумма до .intпереполнится. <vs<=в branch-and-bound.loads[j] + a[i] >= best— строго . Если поставить , можно пропустить ветку, дающую ровноbest— но она ровноbest, не улучшение, всё равно бесполезна. Условие — правильное, тоже работает (просто менее агрессивно отсекает).
Проверка на примерах
Пример A
, , . Распределение даёт максимумы . Минимум = 5. ✓
Пример B
, , . Распределение даёт нагрузки . Минимум = 7. ✓
Крайние случаи
- . Один работник делает всё — ответ . Backtracking работает без отсечений, симметрия не активируется, branch-and-bound — да: после первого полного прохода
best= , дальше всё отсекается. Узлов в дереве — . - . Каждому достанется одна задача. Ответ . Симметрия отсекает почти всё дерево: после первого назначения « → работник 0» все работники с нагрузкой эквивалентны, остаётся один путь.
- . По условию . Если допустить — лишние работники не нагружены, ответ . Симметрия снова режет: эквивалентные пустые работники игнорируются.
- Все равны. Ответ — . Симметрия отсекает все перестановки распределения, branch-and-bound — все худшие.
- Один большой . Например, , . Ответ — (большая задача всё равно у одного работника, мелкие — у другого, нагрузка большого диктует ответ).
Типичные ошибки
- Не сортировать по убыванию. Сортировка по возрастанию (или вообще без сортировки) даёт катастрофически меньшее отсечение. На это разница между и узлов.
- Забыть про симметрию. Без неё на дерево раздувается в раз. Симметрия — самое мощное отсечение для NP-трудных задач с одинаковыми контейнерами.
bestинициализирован 0. Тогда branch-and-bound отсекает всё, и ответ остаётся 0 — WA. Правильная инициализация —sum(a)илиINF(ноINFменее агрессивно отсекает).- Восстанавливать состояние неполно. После рекурсии нужно явно вычесть
a[i]изloads[j]. Забыли — другие ветки получат «отравленные» данные. - Использовать
intдля нагрузок в C++. Переполнение при . - Симметрия по
loads[j]vs по индексу. Правильно — по значению текущей нагрузки. Если делать «не назначать на , если и loads[j] == loads[j-1]» — это работает только если массив отсортирован по нагрузкам, что не гарантируется при динамических изменениях. Черезsetseen— корректно всегда. - Считать
max(loads)слишком часто. В коде выше — только в листе перебора, после полного распределения. Если считать на каждом узле — добавляется накладных, асимптотика растёт. Оптимизация: поддерживатьcur_maxрекурсивно, обновляя только при изменении. - Применять backtracking, когда задача — поли-номиально решаемая. Если в условии «равные суммы», или ограничены маленьким числом — задача решается DP (bitmask DP по подмножествам). Backtracking — последняя надежда, не первая.
Сложность
- Время. В худшем случае — экспоненциальное, без отсечений. С полным набором отсечений — на практике – узлов при , в C++ это десятки мс, в Python — секунды.
- Память. Стек рекурсии , массив нагрузок . Итого .
Главная особенность — нет точной оценки сложности с отсечениями. На «приятных» входах работает мгновенно, на специально подобранных «злых» — может приблизиться к наивному . Поэтому в задачах с большим или гарантированно «злыми» тестами backtracking — рискованный выбор.
Связанные задачи
- Bitmask DP по подмножествам . Альтернатива backtracking-у для той же задачи: = минимальное число работников, чтобы покрыть подмножество при ограничении на максимальную нагрузку. Бинпоиск по ответу + DP за на проверку. Менее эластична к большим , но детерминированная.
- Knapsack / задача о рюкзаке с branch-and-bound. Та же идея: текущая лучшая граница, отсечение нижних оценок. Branch-and-bound — общий метод, backtracking с отсечениями — частный случай.
- Раскраска графа в цветов (Chromatic number). NP-трудная, backtracking по вершинам, отсечения: симметрия по цветам, branch-and-bound, эвристика выбора следующей вершины (max-degree first).
- Точное покрытие множества (Exact Cover). Algorithm X / Dancing Links — структура данных для эффективного backtracking-а с сохранением состояния.
- N ферзей. Классическая backtracking-задача. Симметрии (повороты, отражения) могут сократить дерево в 8 раз; пропуск симметричных стартовых позиций — дисциплинированный приём.
Итого
- Идея: перебираем все распределения задач по работникам с тремя отсечениями.
- Отсечения: (1) сортировка по убыванию, (2) branch-and-bound по
best, (3) симметрия по одинаковым нагрузкам. - Сложность: в худшем , на практике после отсечений – узлов.
- Главные ловушки: забыть сортировку, забыть симметрию, начать
bestс 0, забыть восстановить состояние,intвместоlong long. - Когда не подходит: большое (> 16), есть полиномиальный алгоритм (DP), нужна гарантия по времени на адверсариальных тестах.
Источник задачи: формат задач Иннополис Open на backtracking с отсечениями (полоса ~1600).