# Поиск с возвратом Алгоритм поиска с возвратом (backtracking algorithm) -- это метод решения задач путем перебора. Его основная идея заключается в том, чтобы, начиная с начального состояния, осуществлять грубый поиск всех возможных решений, фиксируя правильное найденное решение. Процесс поиска продолжается до тех пор, пока не будет найдено решение или не будут исчерпаны все возможные варианты. Алгоритмы поиска с возвратом обычно используют поиск в глубину для обхода пространства решений. В разделе «Двоичные деревья» упоминалось, что прямой, симметричный и обратный обходы относятся к поиску в глубину. Далее, используя прямой обход, мы реализуем задачу поиска с возвратом, чтобы постепенно понять принцип работы этого алгоритма. !!! question "Пример 1" Дано двоичное дерево, найти и записать все узлы со значением $7$, вернуть список узлов. Для решения этой задачи мы выполняем предварительный обход дерева и проверяем, равно ли значение текущего узла $7$. Если равно, то добавляем значение этого узла в список результатов `res`. Процесс представлен на следующем рисунке и в коде ниже: ```src [file]{preorder_traversal_i_compact}-[class]{}-[func]{pre_order} ``` ![Поиск узлов в предварительном обходе](../assets/preorder_find_nodes.png) ## Попытка и возврат **Алгоритм называется поиском с возвратом, потому что при поиске в пространстве решений он использует стратегию попытки и возврата**. Когда алгоритм сталкивается с состоянием, в котором невозможно продолжать или невозможно получить удовлетворительное решение, он отменяет предыдущий выбор, возвращается к предыдущему состоянию и пробует другие возможные варианты. В примере 1 посещение каждого узла представляет собой попытку, а переход через листовой узел или возврат к родительскому узлу через `return` означает возврат. Стоит отметить, что **откат включает не только возврат функции**. Чтобы объяснить это, мы немного расширим пример 1. !!! question "Пример 2" В двоичном дереве найти все узлы со значением $7$, **вернуть пути от корневого узла до этих узлов**. Возьмем за основу код для примера 1. Нам потребуется добавить список `path` для записи пути посещенных узлов. Когда будет найден узел со значением $7$, скопируем `path` и добавим его в список результатов `res`. После завершения обхода `res` будет содержать все решения. Код реализации представлен ниже: ```src [file]{preorder_traversal_ii_compact}-[class]{}-[func]{pre_order} ``` В каждой попытке мы добавляем текущий узел в `path` для записи пути. Перед возвратом необходимо удалить этот узел из `path`, чтобы **восстановить состояние до этой попытки**. Изучив процесс выполнения алгоритма на следующем рисунке, **можно представить попытку и возврат как движение вперед и отмену**, как два противоположных действия. === "<1>" ![Попытка и возврат](../assets/preorder_find_paths_step1.png) === "<2>" ![preorder_find_paths_step2](../assets/preorder_find_paths_step2.png) === "<3>" ![preorder_find_paths_step3](../assets/preorder_find_paths_step3.png) === "<4>" ![preorder_find_paths_step4](../assets/preorder_find_paths_step4.png) === "<5>" ![preorder_find_paths_step5](../assets/preorder_find_paths_step5.png) === "<6>" ![preorder_find_paths_step6](../assets/preorder_find_paths_step6.png) === "<7>" ![preorder_find_paths_step7](../assets/preorder_find_paths_step7.png) === "<8>" ![preorder_find_paths_step8](../assets/preorder_find_paths_step8.png) === "<9>" ![preorder_find_paths_step9](../assets/preorder_find_paths_step9.png) === "<10>" ![preorder_find_paths_step10](../assets/preorder_find_paths_step10.png) === "<11>" ![preorder_find_paths_step11](../assets/preorder_find_paths_step11.png) ## Обрезка Сложные задачи поиска с возвратом обычно содержат одно или несколько ограничений, **которые можно использовать для обрезки**. !!! question "Пример 3" В двоичном дереве найти все узлы со значением $7$, вернуть пути от корневого узла до этих узлов, **при этом путь не должен содержать узлы со значением $3$**. Для выполнения данного условия **требуется добавить операцию обрезки**: в процессе поиска, если встречается узел со значением $3$, следует немедленно вернуться, не продолжая поиск. Код реализации представлен ниже: ```src [file]{preorder_traversal_iii_compact}-[class]{}-[func]{pre_order} ``` Обрезка является очень наглядным термином. В процессе поиска, как показано на следующем рисунке, **мы обрезаем ветви поиска, не удовлетворяющие заданным условиям**, и избегаем множества бессмысленных попыток, тем самым повышая эффективность поиска. ![Обрезка в соответствии с заданными условиями](../assets/preorder_find_constrained_paths.png) ## Каркас кода Далее мы попытаемся сформировать основной каркас операций «попытка, возврат, обрезка» для повышения универсальности кода. В следующем каркасе кода `state` обозначает текущее состояние задачи, а `choices` -- возможные выборы в текущем состоянии: === "Python" ```python title="" def backtrack(state: State, choices: list[choice], res: list[state]): """Каркас алгоритма поиска с возвратом""" # Проверка, является ли состояние решением if is_solution(state): # Запись решения record_solution(state, res) # Не продолжать поиск return # Перебор всех вариантов for choice in choices: # Обрезка: проверка легитимности выбора if is_valid(state, choice): # Попытка: сделать выбор, обновить состояние make_choice(state, choice) backtrack(state, choices, res) # Возврат: отмена выбора, возврат к предыдущему состоянию undo_choice(state, choice) ``` === "C++" ```cpp title="" /* Каркас алгоритма поиска с возвратом */ void backtrack(State *state, vector &choices, vector &res) { // Проверка, является ли состояние решением if (isSolution(state)) { // Запись решения recordSolution(state, res); // Не продолжать поиск return; } // Перебор всех вариантов for (Choice choice : choices) { // Обрезка: проверка легитимности выбора if (isValid(state, choice)) { // Попытка: сделать выбор, обновить состояние makeChoice(state, choice); backtrack(state, choices, res); // Возврат: отмена выбора, возврат к предыдущему состоянию undoChoice(state, choice); } } } ``` === "Java" ```java title="" /* Каркас алгоритма поиска с возвратом */ void backtrack(State state, List choices, List res) { // Проверка, является ли состояние решением if (isSolution(state)) { // Запись решения recordSolution(state, res); // Не продолжать поиск return; } // Перебор всех вариантов for (Choice choice : choices) { // Обрезка: проверка легитимности выбора if (isValid(state, choice)) { // Попытка: сделать выбор, обновить состояние makeChoice(state, choice); backtrack(state, choices, res); // Возврат: отмена выбора, возврат к предыдущему состоянию undoChoice(state, choice); } } } ``` === "C#" ```csharp title="" /* Каркас алгоритма поиска с возвратом */ void Backtrack(State state, List choices, List res) { // Проверка, является ли состояние решением if (IsSolution(state)) { // Запись решения RecordSolution(state, res); // Не продолжать поиск return; } // Перебор всех вариантов foreach (Choice choice in choices) { // Обрезка: проверка легитимности выбора if (IsValid(state, choice)) { // Попытка: сделать выбор, обновить состояние MakeChoice(state, choice); Backtrack(state, choices, res); // Возврат: отмена выбора, возврат к предыдущему состоянию UndoChoice(state, choice); } } } ``` === "Go" ```go title="" /* Каркас алгоритма поиска с возвратом */ func backtrack(state *State, choices []Choice, res *[]State) { // Проверка, является ли состояние решением if isSolution(state) { // Запись решения recordSolution(state, res) // Не продолжать поиск return } // Перебор всех вариантов for _, choice := range choices { // Обрезка: проверка легитимности выбора if isValid(state, choice) { // Попытка: сделать выбор, обновить состояние makeChoice(state, choice) backtrack(state, choices, res) // Возврат: отмена выбора, возврат к предыдущему состоянию undoChoice(state, choice) } } } ``` === "Swift" ```swift title="" /* Каркас алгоритма поиска с возвратом */ func backtrack(state: inout State, choices: [Choice], res: inout [State]) { // Проверка, является ли состояние решением if isSolution(state: state) { // Запись решения recordSolution(state: state, res: &res) // Не продолжать поиск return } // Перебор всех вариантов for choice in choices { // Обрезка: проверка легитимности выбора if isValid(state: state, choice: choice) { // Попытка: сделать выбор, обновить состояние makeChoice(state: &state, choice: choice) backtrack(state: &state, choices: choices, res: &res) // Возврат: отмена выбора, возврат к предыдущему состоянию undoChoice(state: &state, choice: choice) } } } ``` === "JS" ```javascript title="" /* Каркас алгоритма поиска с возвратом */ function backtrack(state, choices, res) { // Проверка, является ли состояние решением if (isSolution(state)) { // Запись решения recordSolution(state, res); // Не продолжать поиск return; } // Перебор всех вариантов for (let choice of choices) { // Обрезка: проверка легитимности выбора if (isValid(state, choice)) { // Попытка: сделать выбор, обновить состояние makeChoice(state, choice); backtrack(state, choices, res); // Возврат: отмена выбора, возврат к предыдущему состоянию undoChoice(state, choice); } } } ``` === "TS" ```typescript title="" /* Каркас алгоритма поиска с возвратом */ function backtrack(state: State, choices: Choice[], res: State[]): void { // Проверка, является ли состояние решением if (isSolution(state)) { // Запись решения recordSolution(state, res); // Не продолжать поиск return; } // Перебор всех вариантов for (let choice of choices) { // Обрезка: проверка легитимности выбора if (isValid(state, choice)) { // Попытка: сделать выбор, обновить состояние makeChoice(state, choice); backtrack(state, choices, res); // Возврат: отмена выбора, возврат к предыдущему состоянию undoChoice(state, choice); } } } ``` === "Dart" ```dart title="" /* Каркас алгоритма поиска с возвратом */ void backtrack(State state, List, List res) { // Проверка, является ли состояние решением if (isSolution(state)) { // Запись решения recordSolution(state, res); // Не продолжать поиск return; } // Перебор всех вариантов for (Choice choice in choices) { // Обрезка: проверка легитимности выбора if (isValid(state, choice)) { // Попытка: сделать выбор, обновить состояние makeChoice(state, choice); backtrack(state, choices, res); // Возврат: отмена выбора, возврат к предыдущему состоянию undoChoice(state, choice); } } } ``` === "Rust" ```rust title="" /* Каркас алгоритма поиска с возвратом */ fn backtrack(state: &mut State, choices: &Vec, res: &mut Vec) { // Проверка, является ли состояние решением if is_solution(state) { // Запись решения record_solution(state, res); // Не продолжать поиск return; } // Перебор всех вариантов for choice in choices { // Обрезка: проверка легитимности выбора if is_valid(state, choice) { // Попытка: сделать выбор, обновить состояние make_choice(state, choice); backtrack(state, choices, res); // Возврат: отмена выбора, возврат к предыдущему состоянию undo_choice(state, choice); } } } ``` === "C" ```c title="" /* Каркас алгоритма поиска с возвратом */ void backtrack(State *state, Choice *choices, int numChoices, State *res, int numRes) { // Проверка, является ли состояние решением if (isSolution(state)) { // Запись решения recordSolution(state, res, numRes); // Не продолжать поиск return; } // Перебор всех вариантов for (int i = 0; i < numChoices; i++) { // Обрезка: проверка легитимности выбора if (isValid(state, &choices[i])) { // Попытка: сделать выбор, обновить состояние makeChoice(state, &choices[i]); backtrack(state, choices, numChoices, res, numRes); // Возврат: отмена выбора, возврат к предыдущему состоянию undoChoice(state, &choices[i]); } } } ``` === "Kotlin" ```kotlin title="" /* Каркас алгоритма поиска с возвратом */ fun backtrack(state: State?, choices: List, res: List?) { // Проверка, является ли состояние решением if (isSolution(state)) { // Запись решения recordSolution(state, res) // Не продолжать поиск return } // Перебор всех вариантов for (choice in choices) { // Обрезка: проверка легитимности выбора if (isValid(state, choice)) { // Попытка: сделать выбор, обновить состояние makeChoice(state, choice) backtrack(state, choices, res) // Возврат: отмена выбора, возврат к предыдущему состоянию undoChoice(state, choice) } } } ``` === "Ruby" ```ruby title="" ### Каркас алгоритма поиска с возвратом ### def backtrack(state, choices, res) # Проверка, является ли состояние решением if is_solution?(state) # Запись решения record_solution(state, res) return end # Перебор всех вариантов for choice in choices # Обрезка: проверка легитимности выбора if is_valid?(state, choice) # Попытка: сделать выбор, обновить состояние make_choice(state, choice) backtrack(state, choices, res) # Возврат: отмена выбора, возврат к предыдущему состоянию undo_choice(state, choice) end end end ``` Теперь на основе каркаса кода решим пример 3. Состояние `state` -- это путь обхода узлов, выбор `choices` -- это левый и правый дочерние узлы текущего узла, результат `res` -- список путей: ```src [file]{preorder_traversal_iii_template}-[class]{}-[func]{backtrack} ``` Согласно условию задачи после нахождения узла со значением $7$ необходимо продолжать поиск, **поэтому следует удалить оператор `return` после записи решения**. На следующем рисунке сравнивается процесс поиска с сохранением и удалением оператора `return`. ![Сравнение процесса поиска с сохранением и удалением return](../assets/backtrack_remove_return_or_not.png) По сравнению с реализацией на основе предварительного обхода, реализация на основе каркаса поиска с возвратом выглядит более громоздкой, но обладает большей универсальностью. На самом деле **многие задачи поиска с возвратом можно решить в рамках этого каркаса**. Необходимо лишь определить `state` и `choices` в соответствии с конкретной задачей и реализовать методы каркаса. ## Основные термины Для более четкого понимания алгоритмических задач мы систематизируем значения часто используемых терминов обратного поиска и приведем соответствующие примеры для задачи 3, как показано в следующей таблице.

Таблица   Основные термины обратного поиска

| Термин | Определение | Пример 3 | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Решение (solution) | Ответ, удовлетворяющий определенным условиям задачи, может быть одно или несколько решений | Все пути от корневого узла до узла $7$, удовлетворяющие условиям | | Ограничение (constraint) | Ограничение на допустимость решения, обычно используется для обрезки | Путь не содержит узлов со значением $3$ | | Состояние (state) | Ситуация задачи в определенный момент, включая сделанные выборы | Текущий посещенный путь узлов, т. е. список узлов `path` | | Попытка (attempt) | Процесс исследования пространства решений на основе доступных выборов, включая выбор, обновление состояния, проверку на решение | Рекурсивный доступ к левому (правому) дочернему узлу, добавление узла в `path`, проверка значения узла на равенство $7$ | | Возврат (backtracking) | Отмена предыдущих выборов и возврат к предыдущему состоянию при встрече состояния, не удовлетворяющего ограничению | При переходе через листовой узел, завершении посещения узла, встрече узла со значением $3$ поиск прекращается, происходит выход из функции | | Обрезка (pruning) | Метод избежания бессмысленных путей поиска на основе особенностей задачи и ограничений, может повысить эффективность поиска | При встрече узла со значением $3$ не продолжать поиск | !!! tip Понятия задачи, решения, состояния и т. д. являются универсальными и встречаются в алгоритмах «разделяй и властвуй», поиска с возвратом, динамического программирования, жадных алгоритмах и других. ## Преимущества и ограничения Алгоритм поиска с возвратом по своей сути является алгоритмом поиска в глубину, который пытается найти все возможные решения до тех пор, пока не будет найдено удовлетворяющее условиям решение. Преимущество этого метода заключается в том, что он может найти все возможные решения, и при разумной обрезке обладает высокой эффективностью. Однако при обработке крупномасштабных или сложных задач **эффективность работы алгоритма поиска с возвратом может быть неприемлемой**. - **Время**: Алгоритм поиска с возвратом обычно требует обхода всех возможных состояний пространства состояний, временная сложность может достигать экспоненциального или факториального порядка. - **Пространство**: При рекурсивных вызовах необходимо сохранять текущее состояние (например, путь, вспомогательные переменные для обрезки и т. д.), когда глубина очень велика, требования к пространству могут стать очень большими. Несмотря на это, **алгоритм поиска с возвратом остается лучшим решением для некоторых задач поиска и задач удовлетворения ограничений**. Для этих задач, поскольку невозможно предсказать, какие выборы приведут к правильному решению, необходимо перебрать все возможные варианты. В этом случае **ключевым является оптимизация эффективности**, обычно используются два метода оптимизации эффективности. - **Обрезка**: Избежание поиска путей, которые определенно не приведут к решению, тем самым экономя время и пространство. - **Эвристический поиск**: Введение некоторых стратегий или оценочных значений в процессе поиска для приоритетного поиска путей, которые с наибольшей вероятностью приведут к правильному решению. ## Типичные примеры задач поиска с возвратом Алгоритм поиска с возвратом может использоваться для решения многих задач поиска, задач удовлетворения ограничений и задач комбинаторной оптимизации. **Задачи поиска**: Цель этих задач -- найти решение, удовлетворяющее определенным условиям. - Задача о полной перестановке: Дано множество, найти все возможные перестановки и комбинации. - Задача о сумме подмножества: Дано множество и целевая сумма, найти все подмножества множества, сумма которых равна целевой сумме. - Задача о Ханойской башне: Даны три стержня и серия дисков разного размера, требуется переместить все диски с одного стержня на другой, перемещая за раз только один диск, и нельзя класть большой диск на маленький. **Задачи удовлетворения ограничений**: Цель этих задач -- найти решение, удовлетворяющее всем ограничениям. - $n$ ферзей: На шахматной доске $n \times n$ разместить $n$ ферзей так, чтобы они не атаковали друг друга. - Судоку: В сетке $9 \times 9$ заполнить числа от $1$ до $9$ так, чтобы в каждой строке, каждом столбце и каждой подсетке $3 \times 3$ числа не повторялись. - Задача о раскраске графа: Дан неориентированный граф, раскрасить каждую вершину графа минимальным количеством цветов так, чтобы смежные вершины имели разные цвета. **Задачи комбинаторной оптимизации**: Цель этих задач -- найти оптимальное решение в комбинаторном пространстве, удовлетворяющее определенным условиям. - Задача о рюкзаке 0-1: Дан набор предметов и рюкзак, каждый предмет имеет определенную ценность и вес, требуется выбрать предметы в пределах ограничения вместимости рюкзака так, чтобы общая ценность была максимальной. - Задача коммивояжера: В графе, начиная с одной точки, посетить все остальные точки ровно один раз и вернуться в начальную точку, найти кратчайший путь. - Задача о максимальной клике: Дан неориентированный граф, найти максимальный полный подграф, т. е. подграф, в котором между любыми двумя вершинами есть ребро. Обратите внимание, что для многих задач комбинаторной оптимизации поиск с возвратом не является оптимальным решением. - Задача о рюкзаке 0-1 обычно решается с помощью динамического программирования для достижения более высокой временной эффективности. - Задача коммивояжера -- это известная NP-трудная задача, обычно используются генетические алгоритмы, алгоритмы муравьиной колонии и другие методы решения. - Задача о максимальной клике -- это классическая задача теории графов, может быть решена с помощью жадных алгоритмов и других эвристических методов.