Files
hello-algo/ru/docs/chapter_backtracking/backtracking_algorithm.md
Yudong Jin 22b3b568ef fix Ru translation (#1894)
* docs(ru): replace prose quotes with guillemets

* docs(ru): replace prose semicolons with periods

* docs(ru): align animation title forms

* docs(ru): align figure and table references
2026-04-14 18:10:12 +08:00

504 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Алгоритм поиска с возвратом
<u>Алгоритм поиска с возвратом (backtracking algorithm)</u> - это метод решения задач путем полного перебора. Его основная идея состоит в том, чтобы, начиная с некоторого исходного состояния, грубо перебрать все возможные решения, записывать корректные решения и продолжать поиск до тех пор, пока решение не будет найдено или пока не будут исчерпаны все возможные варианты.
Обычно алгоритмы поиска с возвратом используют обход в глубину для обхода пространства решений. В главе «Бинарные деревья» мы уже упоминали, что прямой, симметричный и обратный обходы относятся к обходу в глубину. Теперь мы на основе прямого обхода построим задачу поиска с возвратом и постепенно разберем принцип работы этого алгоритма.
!!! question "Пример 1"
Дано двоичное дерево. Найдите и запишите все узлы со значением $7$. Верните список этих узлов.
Для этой задачи мы выполняем прямой обход дерева и проверяем, равно ли значение текущего узла $7$. Если да, то добавляем значение этого узла в список результатов `res` . Соответствующий процесс показан на рисунке ниже и в коде:
```src
[file]{preorder_traversal_i_compact}-[class]{}-[func]{pre_order}
```
![Поиск узлов при прямом обходе](backtracking_algorithm.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>"
![Попытка и откат](backtracking_algorithm.assets/preorder_find_paths_step1.png)
=== "<2>"
![preorder_find_paths_step2](backtracking_algorithm.assets/preorder_find_paths_step2.png)
=== "<3>"
![preorder_find_paths_step3](backtracking_algorithm.assets/preorder_find_paths_step3.png)
=== "<4>"
![preorder_find_paths_step4](backtracking_algorithm.assets/preorder_find_paths_step4.png)
=== "<5>"
![preorder_find_paths_step5](backtracking_algorithm.assets/preorder_find_paths_step5.png)
=== "<6>"
![preorder_find_paths_step6](backtracking_algorithm.assets/preorder_find_paths_step6.png)
=== "<7>"
![preorder_find_paths_step7](backtracking_algorithm.assets/preorder_find_paths_step7.png)
=== "<8>"
![preorder_find_paths_step8](backtracking_algorithm.assets/preorder_find_paths_step8.png)
=== "<9>"
![preorder_find_paths_step9](backtracking_algorithm.assets/preorder_find_paths_step9.png)
=== "<10>"
![preorder_find_paths_step10](backtracking_algorithm.assets/preorder_find_paths_step10.png)
=== "<11>"
![preorder_find_paths_step11](backtracking_algorithm.assets/preorder_find_paths_step11.png)
## Обрезка
Сложные задачи поиска с возвратом обычно содержат одно или несколько ограничений, **которые часто можно использовать для «обрезки»**.
!!! question "Пример 3"
Найдите в двоичном дереве все узлы со значением $7$ , верните пути от корня до этих узлов, **причем путь не должен содержать узлы со значением $3$**.
Чтобы выполнить это ограничение, **нам нужно добавить операцию обрезки**: во время поиска, если встречается узел со значением $3$ , мы сразу возвращаемся и не продолжаем дальнейший поиск. Код выглядит так:
```src
[file]{preorder_traversal_iii_compact}-[class]{}-[func]{pre_order}
```
Термин «обрезка» очень нагляден. Как показано на рисунке ниже, во время поиска **мы отсекаем ветви, не удовлетворяющие ограничениям** , тем самым избегая множества бессмысленных попыток и повышая эффективность поиска.
![Обрезка по условиям задачи](backtracking_algorithm.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<Choice *> &choices, vector<State *> &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<Choice> choices, List<State> 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<Choice> choices, List<State> 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<Choice>, List<State> 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<Choice>, res: &mut Vec<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"
```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<Choice?>, res: List<State?>?) {
// Проверка, является ли текущее состояние решением
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](backtracking_algorithm.assets/backtrack_remove_return_or_not.png)
По сравнению с реализацией на основе прямого обхода, версия на основе общего каркаса поиска с возвратом выглядит более громоздкой, но при этом обладает лучшей универсальностью. На практике **многие задачи поиска с возвратом можно решать в рамках этого каркаса**. Для этого нужно лишь определить `state` и `choices` под конкретную задачу и реализовать соответствующие методы каркаса.
## Часто используемые термины
Чтобы яснее анализировать алгоритмические задачи, подытожим значения часто используемых терминов поиска с возвратом и сопоставим их с примером 3, как показано в таблице ниже.
<p align="center"> Таблица <id> &nbsp; Часто используемые термины алгоритма поиска с возвратом </p>
| Термин | Определение | Пример 3 |
| ------------------------ | -------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Решение (solution) | Решение - это ответ, удовлетворяющий условиям задачи. Решений может быть одно или несколько | Все пути от корня до узла $7$ , удовлетворяющие ограничениям |
| Ограничение (constraint) | Ограничение определяет допустимость решения и обычно используется для обрезки | Путь не содержит узлы со значением $3$ |
| Состояние (state) | Состояние описывает ситуацию задачи в некоторый момент времени, включая уже сделанные выборы | Текущий путь посещенных узлов, то есть список узлов `path` |
| Попытка (attempt) | Попытка - это исследование пространства решений на основе доступных выборов, включая выбор, обновление состояния и проверку, является ли состояние решением | Рекурсивный переход к левому или правому потомку, добавление узла в `path` и проверка, равно ли значение узла $7$ |
| Откат (backtracking) | Откат означает отмену предыдущих выборов и возврат к более раннему состоянию при встрече состояния, не удовлетворяющего ограничениям | Завершение поиска при проходе через лист, окончании посещения узла или встрече узла со значением $3$ , то есть возврат из функции |
| Обрезка (pruning) | Обрезка - это способ избегать бессмысленных путей поиска на основе свойств задачи и ее ограничений, повышающий эффективность | При встрече узла со значением $3$ поиск по этой ветви прекращается |
!!! tip
Такие понятия, как задача, решение и состояние, являются общими и встречаются не только в поиске с возвратом, но и в «разделяй и властвуй», динамическом программировании, жадных алгоритмах и других темах.
## Преимущества и ограничения
Алгоритм поиска с возвратом по своей сути представляет собой алгоритм обхода в глубину, который перебирает все возможные решения, пока не найдет удовлетворяющее условиям. Преимущество этого подхода в том, что он позволяет находить все возможные решения и при разумной обрезке может работать весьма эффективно.
Однако при работе с большими или сложными задачами **эффективность поиска с возвратом может оказаться неприемлемой**.
- **Время**: поиск с возвратом обычно требует обхода всех возможных состояний пространства состояний, и его временная сложность может достигать экспоненциального или факториального порядка.
- **Память**: при рекурсивных вызовах нужно хранить текущее состояние (например, путь, вспомогательные переменные для обрезки и т.д.), поэтому при большой глубине рекурсии потребность в памяти может стать значительной.
Тем не менее **поиск с возвратом по-прежнему остается лучшим решением для некоторых поисковых задач и задач удовлетворения ограничений**. В таких задачах заранее невозможно предсказать, какие выборы приведут к эффективному решению, поэтому приходится перебирать все возможные варианты. В этой ситуации **ключевым становится вопрос оптимизации эффективности** , и для этого обычно используют две стратегии.
- **Обрезка**: избегать поиска по тем путям, которые заведомо не приведут к решению, тем самым экономя время и память.
- **Эвристический поиск**: вводить во время поиска дополнительные стратегии или оценки, чтобы в первую очередь исследовать пути, наиболее вероятно ведущие к эффективному решению.
## Типичные задачи поиска с возвратом
Алгоритм поиска с возвратом можно использовать для решения множества поисковых задач, задач удовлетворения ограничений и задач комбинаторной оптимизации.
**Поисковые задачи**: целью таких задач является поиск решений, удовлетворяющих определенным условиям.
- Задача о перестановках: дано множество, требуется найти все возможные перестановки его элементов.
- Задача о сумме подмножеств: даны множество и целевая сумма. Нужно найти все подмножества, сумма элементов которых равна целевой.
- Задача о Ханойской башне: даны три стержня и набор дисков разного размера. Требуется перенести все диски с одного стержня на другой, перемещая за раз только один диск и не помещая больший диск на меньший.
**Задачи удовлетворения ограничений**: целью таких задач является поиск решений, удовлетворяющих всем ограничениям.
- Задача о $n$ ферзях: разместить $n$ ферзей на шахматной доске размера $n \times n$ так, чтобы они не атаковали друг друга.
- Судоку: заполнить сетку $9 \times 9$ числами от $1$ до $9$ так, чтобы в каждой строке, каждом столбце и каждом блоке $3 \times 3$ числа не повторялись.
- Задача раскраски графа: дан неориентированный граф. Требуется раскрасить его вершины минимальным числом цветов так, чтобы соседние вершины имели разные цвета.
**Задачи комбинаторной оптимизации**: целью таких задач является поиск оптимального решения в некотором комбинаторном пространстве при заданных ограничениях.
- Задача о рюкзаке 0-1: даны набор предметов и рюкзак. У каждого предмета есть ценность и вес, и нужно выбрать предметы так, чтобы при ограниченной вместимости рюкзака суммарная ценность была максимальной.
- Задача коммивояжера: начиная из некоторой вершины графа, требуется посетить все остальные вершины ровно по одному разу и вернуться в исходную вершину, найдя при этом кратчайший путь.
- Задача о максимальной клике: дан неориентированный граф. Требуется найти в нем максимальный полный подграф, то есть подграф, в котором любая пара вершин соединена ребром.
Стоит отметить: для многих задач комбинаторной оптимизации поиск с возвратом не является оптимальным способом решения.
- Задача о рюкзаке 0-1 обычно решается с помощью динамического программирования, что дает более высокую временную эффективность.
- Задача коммивояжера является известной NP-Hard задачей. Для ее решения часто используют генетические алгоритмы, муравьиные алгоритмы и другие методы.
- Задача о максимальной клике является классической задачей теории графов и может решаться жадными и другими эвристическими алгоритмами.