4. Backtracking

Введение: Умный брутфорс и генерация конфигураций

В предшествующих главах мы исследовали 2. Greedy алгоритмы, стремительно несущиеся к локально выгодной цели, и 3. Dynamic programming - динамическое программирование, методично аккумулирующее промежуточные состояния, чтобы за полиномиальное время ($O(N^2)$ или $O(N \cdot M)$) вычислить глобальный оптимум.

Обе эти парадигмы превосходно отвечают на оптимизационные вопросы: «Какова максимальная пропускная способность?» или «Каково минимальное количество монет для выплаты сдачи?».

Но как поступить, если бизнес-требования звучат иначе: «Сгенерируй все валидные расписания дежурств инженеров», «Найди все возможные маршруты доставки пакетов через сеть шлюзов» или «Выведи все комбинации прав доступа RBAC»?
Здесь динамическое программирование оказывается бессильно, поскольку системе требуются сами комбинаторные траектории, а не скалярное число вариантов.

Когда архитектуре необходим полный перебор, но мы стремимся выполнить его максимально рационально — мгновенно отсекая заведомо бесперспективные ветви, — в игру вступает парадигма Backtracking (Поиск с возвратом).


Концепция: Брутфорс с мозгами

Backtracking представляет собой систематическое исследование Дерева пространств состояний (State Space Tree) с использованием поиска в глубину (DFS).

Алгоритм циклически опирается на три фундаментальных рекурсивных шага:

  1. Choose (Выбор): Сделать элементарный шаг вперед (добавить кандидата в текущий путь или накапливаемую комбинацию).
  2. Explore (Исследование): Рекурсивно углубиться в поддерево состояний для дальнейшего расширения траектории.
  3. Unchoose / Backtrack (Откат): Удалить добавленного кандидата, восстановить исходное состояние и попробовать следующую альтернативу.

Главная созидательная мощь алгоритма — Отсечение (Pruning). Если в некоторой точке рекурсивного спуска предикат обнаруживает, что текущий частичный путь гарантированно нарушает инварианты задачи, алгоритм немедленно обрывает дальнейший спуск по этой ветке, экономя миллиарды инструкций центрального процессора.

flowchart TD
    Root["Старт<br/>Путь: [ ]"]

    Root -->|"Choose A"| NodeA["Путь: [A]"]
    Root -->|"Choose B"| NodeB["Путь: [B]"]

    NodeA -->|"Explore (A, B)"| NodeAB["Путь: [A, B]<br/>УСПЕХ! Сохраняем"]
    NodeA -->|"Explore (A, C)"| NodeAC["Путь: [A, C]<br/>НАРУШЕНИЕ ПРАВИЛ"]

    NodeAC -.->|"Pruning (Отсечение)<br/>Backtrack к [A]"| NodeA
    NodeAB -.->|"Backtrack к [A]"| NodeA

    NodeB -->|"Explore (B, A)"| NodeBA["Путь: [B, A]<br/>УСПЕХ! Сохраняем"]

    classDef default fill:#0f172a,stroke:#334155,color:#f8fafc
    classDef branch fill:#1e293b,stroke:#818cf8,color:#f8fafc
    classDef success fill:#1e293b,stroke:#10b981,color:#10b981
    classDef pruned fill:#1e293b,stroke:#f43f5e,color:#f43f5e

    class Root default
    class NodeA,NodeB branch
    class NodeAB,NodeBA success
    class NodeAC pruned
  

Идиоматичный Go: Каркас алгоритма и ловушка слайсов

Рассмотрим классическую задачу: генерацию всех возможных подмножеств (Subsets / Powerset) из набора уникальных целых чисел.

В языке Go реализация поиска с возвратом таит в себе классическую ловушку, обусловленную низкоуровневым устройством срезов (slice).

Правильная реализация (Zero-Allocation Blueprint)

Чтобы избежать непрерывных аллокаций на каждом шаге рекурсивного спуска, мы передаем единственный срез path как рабочий черновик (scratch space) и производим физическое глубокое копирование (copy) строго в момент нахождения валидного ответа:

Go
package backtracking

// Subsets возвращает все возможные подмножества массива.
func Subsets(nums []int) [][]int {
    var result [][]int

    // Предварительно выделяем память под черновик, чтобы избежать
    // реаллокаций внутри рекурсии. cap = len(nums), так как 
    // максимальная длина пути не превысит длину исходного массива.
    path := make([]int, 0, len(nums))

    // Запускаем рекурсию
    backtrack(&result, path, nums, 0)

    return result
}

func backtrack(result *[][]int, path []int, nums []int, start int) {
    // БАЗОВЫЙ СЛУЧАЙ (Goal)
    // В задаче о подмножествах каждый промежуточный путь является валидным ответом.

    // КРИТИЧЕСКИ ВАЖНО: Создаем глубокую копию текущего пути!
    temp := make([]int, len(path))
    copy(temp, path)
    *result = append(*result, temp)

    // ИССЛЕДОВАНИЕ (Explore)
    for i := start; i < len(nums); i++ {
        // 1. CHOOSE (Выбор)
        // Добавляем элемент в наш черновик
        path = append(path, nums[i])

        // 2. EXPLORE (Рекурсия)
        // Уходим вглубь, передавая обновленный путь и следующий стартовый индекс
        backtrack(result, path, nums, i+1)

        // 3. BACKTRACK (Отмена выбора)
        // "Срезаем" последний добавленный элемент.
        // Базовый массив остается тем же, мы просто уменьшаем len.
        path = path[:len(path)-1]
    }
}

Mechanical Sympathy: Нагрузка на стек и GC

Алгоритмы поиска с возвратом обладают экспоненциальной или факториальной теоретической сложностью: $O(2^N)$ для подмножеств, $O(N!)$ для перестановок (Permutations). Для аппаратного обеспечения это влечет два критических следствия:

  1. Глубина стека вызовов: Предельная глубина рекурсии определяется длиной генерируемой последовательности (в нашей задаче это $N$). Для $N = 64$ глубина стека в 64 кадра абсолютно безопасна для горутины Go. Однако если задача требует глубины в $100\,000$ вызовов, сервис столкнется с ощутимым падением производительности из-за непрерывных копирований стека рантаймом.
  2. Давление на сборщик мусора (GC Pressure): Даже при безупречной реализации с аллокацией только финальных срезов (temp), алгоритм генерирует огромное количество объектов в управляемой куче. При $N = 20$ генерируется $1\,048\,576$ отдельных срезов! Это вызывает мощный спайк активности GC. В сверхнагруженных бэкендах найденные комбинации транслируют потоком напрямую в интерфейс io.Writer (сетевой сокет или файл), избегая накопления монструозного массива result в оперативной памяти.

Реальный Бэкенд: Катастрофический Backtracking (ReDoS)

Зачем инженеру распределенных систем глубоко понимать механику поиска с возвратом, если он не пишет генераторы перестановок в повседневном коде?

Потому что Backtracking лежит в фундаменте классических движков Регулярных выражений (Regular Expressions). И незнание принципов работы алгоритма способно обрушить всю инфраструктуру сервиса.


Итог

  1. Суть парадигмы: Систематический исчерпывающий перебор пространства состояний (DFS) с отсечением тупиковых ветвей (Pruning).
  2. Архитектурный шаблон: Триада Choose -> Explore -> Unchoose.
  3. Специфика языка Go: Обязательное глубокое копирование среза-пути в момент сохранения результата, предотвращающее мутацию разделяемого базового массива (Backing Array).
  4. Риски: Экспоненциальный рост времени вычислений ($O(2^N)$, $O(N!)$) и угроза исчерпания памяти (OOM) при накоплении результатов в памяти вместо потоковой передачи через io.Writer.

Классический Backtracking отсекает ветви строго по факту нарушения логических правил. Но что, если мы научимся оценивать математическую перспективность ветви еще до того, как спустимся по ней? Что если мы сможем отсекать гигантские поддеревья просто потому, что доказали: ни одно решение в этой ветке гарантированно не превзойдет уже найденный нами рекорд?

Эта эволюция переводит нас от простого перебора к элитному методу дискретной оптимизации: 5. Branch and bound.