1. Теория. Backtracking
«Управление сложностью состоит в том, чтобы уметь исследовать путь до конца, а затем аккуратно стереть свои следы, если он оказался тупиком».
— Брайан Керниган
Backtracking (поиск с возвратом) — это не просто рекурсивный перебор вариантов. Это строгая математическая техника исследования дерева пространства состояний (State-Space Tree).
В отличие от динамического программирования, где подзадачи многократно перекрываются и кэшируются в таблицах мемоизации, в поиске с возвратом каждая комбинация уникальна. Наша цель — построить все допустимые конфигурации или найти среди них оптимальную, отсекая (pruning) заведомо бесперспективные поддеревья задолго до того, как мы дойдем до листьев.
Для инженера на Go backtracking — это высший пилотаж владения рантаймом:
- как переиспользовать единый буфер среза и удерживать $0$ паразитных аллокаций на шагах спуска и отката;
- почему непрерывный стек горутины (Continuous Stack) делает рекурсию на Go на порядок дешевле классических потоков ОС;
- как семантика слайс-хидера (
SliceHeader: указатель, длина, ёмкость) защищает от порчи данных при возврате из рекурсии; - как правильно изолировать результаты через
Escape Analysis.
Анатомия Backtracking: Дерево решений и три фазы инварианта
Любой алгоритм поиска с возвратом моделирует обход дерева в глубину (DFS), где:
- Корень — пустое начальное состояние (нулевой выбор).
- Внутренние вершины — частичные префиксы решений.
- Рёбра — выбор очередного кандидата.
- Листья — завершенные валидные решения либо тупиковые ветви.
Каждый рекурсивный шаг подчиняется строгому триединству: Выбор (Choose) $\rightarrow$ Исследование (Explore) $\rightarrow$ Откат (Unchoose).
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
Root["Корень: Пустое состояние []"]:::entry --> L1_Yes["Выбор 1: [1]"]:::process
Root --> L1_No["Пропуск 1: []"]:::process
L1_Yes --> L2_Yes["Выбор 2: [1, 2]"]:::process
L1_Yes --> L2_No["Пропуск 2: [1]"]:::process
L1_No --> L2_Yes2["Выбор 2: [2]"]:::process
L1_No --> L2_No2["Пропуск 2: []"]:::process
L2_Yes --> Leaf1["Лист: [1, 2]"]:::success
L2_No --> Leaf2["Лист: [1]"]:::success
L2_Yes2 --> Leaf3["Лист: [2]"]:::success
L2_No2 --> Leaf4["Лист: []"]:::success
Канонический рекурсивный каркас на Go
В языке Go идиоматичным стандартом является использование локальной функции-замыкания (Closure) и переиспользование единственного слайса path:
package main
// SubsetsTemplate демонстрирует эталонный каркас поиска с возвратом
func SubsetsTemplate(nums []int) [][]int {
var result [][]int
// Предвыделяем буфер под путь, чтобы избежать реаллокаций при спуске
path := make([]int, 0, len(nums))
var backtrack func(start int)
backtrack = func(start int) {
// 1. Фиксация результата: ОБЯЗАТЕЛЬНОЕ копирование!
// Срез path мутирует на откатах, поэтому сохраняем независимый снимок
snapshot := make([]int, len(path))
copy(snapshot, path)
result = append(result, snapshot)
// 2. Перебор доступных вариантов
for i := start; i < len(nums); i++ {
// Фаза 1: Choose (добавляем элемент в текущий путь)
path = append(path, nums[i])
// Фаза 2: Explore (углубляемся в рекурсию)
backtrack(i + 1)
// Фаза 3: Unchoose / Backtrack (откатываем состояние назад)
path = path[:len(path)-1]
}
}
backtrack(0)
return result
}
Взгляд под капот: Mechanical Sympathy
1. Как path[:len(path)-1] работает на уровне памяти
Операция усечения среза path = path[:len(path)-1] не трогает физический массив в памяти!
Срез в Go — это 24-байтовая структура в регистрах/стеке:
type SliceHeader struct {
Data unsafe.Pointer // Указатель на массив в памяти (8 байт)
Len int // Длина (8 байт)
Cap int // Емкость (8 байт)
}
При операции path[:len(path)-1] компилятор просто декрементирует поле Len. Никакой очистки байтов, никаких вызовов сборщика мусора, никакого копирования памяти. Следующий вызов append на этом уровне или в соседней ветке просто перезапишет старое значение по индексу Len. Это дает абсолютно нулевые аллокации при откате.
2. Стек горутины: 2 КБ против 8 МБ потока ОС
Классический системный поток Linux (pthread) резервирует под стек от 2 до 8 МБ виртуальной памяти. Глубокая рекурсия в языках вроде C или Java создает серьезные риски переполнения стека (StackOverflowError).
В Go горутина стартует с крошечным непрерывным стеком размером 2 КБ (в Go 1.4+ используется Continuous Stack вместо Segmented Stack).
- При углублении рекурсии, когда 2 КБ заполняются, рантайм Go (
runtime.morestack) аллоцирует новый блок памяти вдвое большего размера (4 КБ, затем 8 КБ и т.д.), аккуратно копирует все стековые кадры и корректирует указатели. - Для задач с глубиной $N \le 30$ глубина рекурсии составляет всего несколько десятков стековых фреймов, что идеально помещается в горячем кэше первого уровня (L1 Data Cache, 32-48 КБ) процессора.
3. Escape Analysis и неизбежная плата за результат
Почему мы обязаны делать snapshot := make([]int, len(path)); copy(snapshot, path)?
Если мы выполним наивное result = append(result, path), то в массив результатов попадут множественные копии заголовка слайса, указывающие на один и тот же базовый массив памяти. После всех откатов массив path опустеет, и в result окажется набор пустых срезов!
Анализатор побега компилятора (go build -gcflags="-m") обнаруживает, что snapshot сохраняется в срез result, который возвращается из внешней функции, и отправляет память под каждый snapshot в кучу (heap). Это единственная неизбежная аллокация в алгоритме.
Pruning (Отсечение): Победа над экспонентой
Теоретическая сложность наивного перебора — факториальная $O(N!)$ или экспоненциальная $O(2^N)$. Без отсечения дерево состояний при $N=30$ содержит $2^{30} \approx 10^9$ вершин, что приведет к превышению лимита времени (Time Limit Exceeded).
Отсечение (Pruning) — это проверка инвариантов задачи, позволяющая выбросить целое поддерево еще до рекурсивного спуска:
- Отсечение по сумме: если текущая сумма превысила
target, а массив отсортирован по возрастанию, дальнейший перебор в цикле бессмысленен (break). - Отсечение по оставшейся длине: если для сбора комбинации длины $K$ в массиве осталось меньше элементов, чем требуется:
len(nums) - i < k - len(path), цикл немедленно прерывается. - Отсечение дубликатов: если входной массив отсортирован, пропуск одинаковых элементов на одном уровне дерева (
if i > start && nums[i] == nums[i-1] continue) исключает генерацию изоморфных ветвей.
Подводные камни и вопросы с собеседований
Резюме
Backtracking — это искусство контролируемого перебора:
- Choose $\rightarrow$ Explore $\rightarrow$ Unchoose: три фазы, гарантирующие чистоту состояния.
- Zero Allocations на откате: операция
path[:len(path)-1]декрементируетLenбез затрагивания памяти. - Deep Pruning: правильное отсечение превращает экспоненциальный перебор в мгновенное решение.
В следующей статье мы применим этот каркас к классической комбинаторике: 2. Генерация комбинаций.