72 KiB
Поиск с возвратом
{width="3.630207786526684in" height="4.697916666666667in"}
Алгоритмы поиска с возвратом
Алгоритм поиска с возвратом -- это метод решения задач путем перебора. Его основная идея заключается в том, чтобы, начиная с начального состояния, осуществлять грубый поиск всех возможных решений, фиксируя правильное найденное решение. Процесс поиска продолжается до тех пор, пока не будет найдено решение или не будут исчерпаны все возможные варианты.
Алгоритмы поиска с возвратом обычно используют поиск в глубину для об- хода пространства решений. В разделе «Двоичные деревья» упоминалось, что прямой, симметричный и обратный обходы относятся к поиску в глубину. Да- лее, используя прямой обход, мы реализуем задачу поиска с возвратом, чтобы постепенно понять принцип работы этого алгоритма.
Для решения этой задачи мы выполняем предварительный обход дерева и проверяем, равно ли значение текущего узла 7. Если равно, то добавляем зна- чение этого узла в список результатов res. Процесс представлен на рис. 13.1 и в следующем коде.
# === File: preorder_traversal_i_compact.py === def pre_order(root: TreeNode):
""" Предварительный обход: пример 1.""" if root is None:
return
if root.val == 7:
# Запись решения. res.append(root)
pre_order(root.left)
pre_order(root.right)
Прямой порядок обхода Посетить узел в
Выполнить прямой обход двоичного дерева и записать узлы со значением 7
Рис. 13.1. Поиск узлов в предварительном обходе
- Попытка и возврат
Алгоритм называется поиском с возвратом, потому что при поиске в пространстве решений он использует стратегию попытки и возврата. Когда алгоритм сталкивается с состоянием, в котором невозможно продолжать или невозможно получить удовлетворительное решение, он отменяет преды- дущий выбор, возвращается к предыдущему состоянию и пробует другие воз- можные варианты.
В примере 1 посещение каждого узла представляет собой попытку, а пере- ход через листовой узел или возврат к родительскому узлу через return озна- чает возврат.
Стоит отметить, что откат включает не только возврат функции. Чтобы объяснить это, мы немного расширим пример 1.
Возьмем за основу код для примера 1. Нам потребуется добавить список path для записи пути посещенных узлов. Когда будет найден узел со значением 7, скопируем path и добавим его в список результатов res. После завершения об- хода res будет содержать все решения. Код реализации представлен ниже.
# === File: preorder_traversal_ii_compact.py === def pre_order(root: TreeNode):
""" Предварительный обход: пример 2.""" if root is None:
return # Попытка.
path.append(root) if root.val == 7:
# Запись решения. res.append(list(path))
pre_order(root.left)
pre_order(root.right) # Возврат.
path.pop()
В каждой попытке мы добавляем текущий узел в path для записи пути. Перед возвратом необходимо удалить этот узел из path, чтобы восстановить состо- яние до этой попытки.
Изучив процесс выполнения алгоритма на рис. 13.2, можно представить попытку и возврат как движение вперед и отмену, как два противополож- ных действия.
Рис. 13.2. Попытка и возврат. Шаги 1--3
Рис. 13.2. Продолжение. Шаги 4--6
Рис. 13.2. Продолжение. Шаги 7--9
Обрезка
Рис. 13.2. Окончание. Шаги 10--11
Сложные задачи поиска с возвратом обычно содержат одно или несколько ограничений, которые можно использовать для обрезки.
Для выполнения данного условия требуется добавить операцию обрезки: в процессе поиска, если встречается узел со значением 3, следует немедленно вернуться, не продолжая поиск. Код реализации представлен ниже.
# === File: preorder_traversal_iii_compact.py ===
def pre_order(root: TreeNode):
""" Предварительный обход: пример 3.""" # Обрезка.
if root is None or root.val == 3: return
# Попытка.
path.append(root) if root.val == 7:
# Запись решения. res.append(list(path))
pre_order(root.left) pre_order(root.right) # Возврат.
path.pop()
Обрезка является очень наглядным термином. В процессе поиска мы обре- заем ветви поиска, не удовлетворяющие заданным условиям, и избегаем множества бессмысленных попыток, тем самым повышая эффективность по- иска, как показано на рис. 13.3.
Рис. 13.3. Обрезка в соответствии с заданными условиями
Каркас кода
Далее мы попытаемся сформировать основной каркас операций «попытка, возврат, обрезка» для повышения универсальности кода.
В следующем каркасе кода state обозначает текущее состояние задачи, а choices -- возможные выборы в текущем состоянии:
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)
Теперь на основе каркаса кода решим пример 3. Состояние state -- это путь обхода узлов, выбор choices -- это левый и правый дочерние узлы текущего узла, результат res -- список путей.
# === File: preorder_traversal_iii_template.py === def is_solution(state: list[TreeNode]) -> bool:
""" Проверка, является ли текущее состояние решением.""" return state and state[-1].val == 7
def record_solution(state: list[TreeNode], res: list[list[TreeNode]]): """ Запись решения."""
res.append(list(state))
def is_valid(state: list[TreeNode], choice: TreeNode) -> bool: """ Проверка легитимности выбора в текущем состоянии.""" return choice is not None and choice.val != 3
def make_choice(state: list[TreeNode], choice: TreeNode): """ Обновление состояния."""
state.append(choice)
def undo_choice(state: list[TreeNode], choice: TreeNode): """ Восстановление состояния."""
state.pop()
def backtrack(
state: list[TreeNode], choices: list[TreeNode], res: list[list[TreeNode]]
):
""" Поиск с возвратом: пример 3."""
# Проверка, является ли состояние решением. if is_solution(state):
# Запись решения. record_solution(state, res)
# Перебор всех вариантов. for choice in choices:
# Обрезка: проверка легитимности выбора. if is_valid(state, choice):
# Попытка: сделать выбор, обновить состояние. make_choice(state, choice)
# Переход к следующему выбору.
backtrack(state, [choice.left, choice.right], res)
# Возврат: отмена выбора, возврат к предыдущему состоянию. undo_choice(state, choice)
Согласно условию задачи после нахождения узла со значением 7 необходи- мо продолжать поиск, поэтому следует удалить оператор return после запи- си решения. На рис. 13.4 сравнивается процесс поиска с сохранением и удале- нием оператора return.
После записи решения остановить поиск
Сохранение return
Возврат после записи решения, не продолжать поиск
Удаление return
Не возвращаться после записи решения, продолжить поиск
Рис. 13.4. Сравнение процесса поиска с сохранением и удалением return
По сравнению с реализацией на основе предварительного обхода, реали- зация на основе каркаса поиска с возвратом выглядит более громоздкой, но обладает большей универсальностью. На самом деле многие задачи поиска с возвратом можно решить в рамках этого каркаса. Необходимо лишь опре- делить state и choices в соответствии с конкретной задачей и реализовать ме- тоды каркаса.
Основные термины
Для более четкого понимания алгоритмических задач мы систематизируем значения часто используемых терминов обратного поиска и приведем соот- ветствующие примеры для задачи 3, как показано в табл. 13.1.
Таблица 13.1. Основные термины обратного поиска
+---------------+----------------------------------------+-----------------------------------------+ | > Термин | > Определение | > Пример 3 | +===============+========================================+=========================================+ | > Решение | > Ответ, удовлетворяющий определен- | > Все пути от корневого узла до узла 7, | +---------------+----------------------------------------+-----------------------------------------+ | | > ным условиям задачи, может быть одно | > удовлетворяющие условиям | +---------------+----------------------------------------+-----------------------------------------+ | | > или несколько решений | | +---------------+----------------------------------------+-----------------------------------------+ | > Ограничение | > Ограничение на допустимость реше- | > Путь не содержит узлов со значением 3 | +---------------+----------------------------------------+-----------------------------------------+ | | > ния, обычно используется для обрезки | | +---------------+----------------------------------------+-----------------------------------------+ | > Состояние | > Ситуация задачи в определенный мо- | > Текущий посещенный путь узлов, т. е. | +---------------+----------------------------------------+-----------------------------------------+ | | > мент, включая сделанные выборы | > список узлов path | +---------------+----------------------------------------+-----------------------------------------+ | > Попытка | > Процесс исследования пространства | > Рекурсивный доступ к левому (право- | +---------------+----------------------------------------+-----------------------------------------+ | | > решений на основе доступных вы- | > му) дочернему узлу, добавление узла | +---------------+----------------------------------------+-----------------------------------------+ | | > боров, включая выбор, обновление | > в path, проверка значения узла на | +---------------+----------------------------------------+-----------------------------------------+ | | > состояния, проверку на решение | > равенство 7 | +---------------+----------------------------------------+-----------------------------------------+ | > Возврат | > Отмена предыдущих выборов и воз- | > При переходе через листовой узел, | +---------------+----------------------------------------+-----------------------------------------+ | | > врат к предыдущему состоянию при | > завершении посещения узла, встрече | +---------------+----------------------------------------+-----------------------------------------+ | | > встрече состояния, не удовлетворяю- | > узла со значением 3 поиск прекраща- | +---------------+----------------------------------------+-----------------------------------------+ | | > щего ограничению | > ется, происходит выход из функции | +---------------+----------------------------------------+-----------------------------------------+ | > Обрезка | > Метод избегания бессмысленных путей | > При встрече узла со значением 3 по- | +---------------+----------------------------------------+-----------------------------------------+ | | > поиска на основе условий и ограниче- | > иск прекращается | +---------------+----------------------------------------+-----------------------------------------+ | | > ний задачи, повышающий эффектив- | | +---------------+----------------------------------------+-----------------------------------------+ | | > ность поиска | | +---------------+----------------------------------------+-----------------------------------------+
Преимущества и ограничения
Алгоритм поиска с возвратом, по сути, является алгоритмом поиска в глуби- ну, который пытается найти все возможные решения до тех пор, пока не будет найдено решение, удовлетворяющее условиям. Преимущество этого метода заключается в возможности нахождения всех возможных решений, и при раз- умной обрезке он обладает высокой эффективностью.
Однако при решении крупных или сложных задач эффективность работы алгоритма возврата может оказаться неприемлемой.
-
Время: алгоритм поиска с возвратом обычно требует перебора всех воз- можных состояний пространства, и временная сложность может дости- гать экспоненциального или факториального порядка.
-
Пространство: в рекурсивных вызовах необходимо сохранять текущее состояние (например, путь, вспомогательные переменные для обрезки и т. д.), и при большой глубине потребность в пространстве может стать значительной.
Тем не менее алгоритм поиска с возвратом по-прежнему является наи- лучшим решением для некоторых задач поиска и задач с ограничени- ями. В этих задачах невозможно предсказать, какие выборы могут привести к эффективному решению, поэтому необходимо перебирать все возможные варианты. В таких случаях ключевым моментом является оптимизация эф- фективности, и существуют две распространенные стратегии.
-
Обрезка: избегание поиска по путям, которые заведомо не приведут к решению, что позволяет сэкономить время и пространство.
-
Эвристический поиск: введение некоторых стратегий или оценочных значений в процессе поиска, чтобы в первую очередь исследовать пути, которые с наибольшей вероятностью могут привести к эффективному решению.
Типичные задачи поиска с возвратом
Алгоритм поиска с возвратом можно использовать для решения множества за- дач поиска, задач с ограничениями и задач комбинаторной оптимизации.
Задачи поиска: цель этих задач -- найти решение, удовлетворяющее опре- деленным условиям.
-
Задача о перестановках: дано множество, требуется найти все возмож- ные перестановки элементов.
-
Задача о сумме подмножеств: дано множество и целевая сумма, необхо- димо найти все подмножества, сумма которых равна целевой.
-
Задача о Ханойских башнях: даны три стержня и несколько дисков раз- ного размера, требуется переместить все диски с одного стержня на дру- гой, перемещая по одному диску за раз, при этом нельзя класть больший диск на меньший.
Задачи с ограничениями: цель этих задач -- найти решение, удовлетворя- ющее всем ограничениям.
- Задача об n ферзях: разместить n ферзей на шахматной доске размером
n×n так, чтобы они не рубили друг друга.
-
Судоку: заполнить числами от 1 до 9 сетку 9×9 так, чтобы в каждой стро- ке, каждом столбце и каждой подгруппе 3×3 числа не повторялись.
-
Задача о раскраске графа: дан неориентированный граф, требуется рас- красить его вершины минимальным числом цветов так, чтобы соседние вершины имели разные цвета.
Задачи комбинаторной оптимизации: цель этих задач -- найти опти- мальное решение в комбинаторном пространстве, удовлетворяющее опреде- ленным условиям.
-
Задача о рюкзаке 0-1: дано множество предметов и рюкзак. Каждый предмет имеет определенную ценность и вес, требуется выбрать пред- меты так, чтобы их общая ценность была максимальной при ограничен- ной вместимости рюкзака.
-
Задача коммивояжера: начиная с некоторой вершины графа, требуется посетить все остальные вершины ровно один раз и вернуться в началь- ную. Найдя при этом кратчайший путь.
- Задача о максимальной клике: дан неориентированный граф, требуется найти максимальный полный подграф, т. е. подграф, в котором любые две вершины соединены ребром.
Следует отметить, что для многих задач комбинаторной оптимизации алго- ритм поиска с возвратом не является оптимальным решением.
-
Задача о рюкзаке 0-1 обычно решается с помощью динамического программирования для достижения более высокой временной эффек- тивности.
-
Задача коммивояжера является известной NP-трудной задачей, для ее решения часто используются генетические и муравьиные алгоритмы.
-
Задача о максимальной клике является классической задачей теории графов и может быть решена с помощью жадных алгоритмов или других эвристических методов.
задача о перестановках
Задача о перестановках является типичным примером применения алгорит- ма поиска с возвратом. Она определяется как задача нахождения всех воз- можных перестановок элементов в заданном множестве (например, массиве или строке).
В табл. 13.2 представлено несколько примеров данных, включая входной массив и все соответствующие перестановки.
Таблица 13.2. Примеры полных перестановок
+----------------------+--------------------------------------------------------------------------------+ | > Входной массив | > Все перестановки | +======================+================================================================================+ | > [1] | > [1] | +----------------------+--------------------------------------------------------------------------------+ | > [1, 2] | > [1, 2], [2, 1] | +----------------------+--------------------------------------------------------------------------------+ | > [1, 2, 3] | > [1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1] | +----------------------+--------------------------------------------------------------------------------+
Случай без равных элементов
С точки зрения алгоритма поиска с возвратом процесс генерации пере- становок можно представить как результат серии выборов. Предполо- жим, что входной массив равен [1, 2, 3]. Если сначала выбрать 1, затем 3, а в
конце 2, то получится перестановка [1, 3, 2]. Возврат означает отмену выбора и продолжение попыток других вариантов.
С точки зрения кода реализации поиска с возвратом множество кандидатов
choices -- это все элементы входного массива, а состояние state -- это элементы,
выбранные до текущего момента. Следует отметить, что каждый элемент мо- жет быть выбран только один раз, поэтому все элементы в state должны быть уникальными.
Процесс поиска можно развернуть в виде рекурсивного дерева, в котором каждый узел представляет текущее состояние state, как показано на рис. 13.5. Начиная с корневого узла, после трех раундов выбора достигается листовой узел, а каждый листовой узел соответствует одной перестановке.
Рис. 13.5. Рекурсивное дерево полных перестановок
Обрезка повторного выбора
Чтобы обеспечить выбор каждого элемента только один раз, вводится булев массив selected, где selected[i] указывает, был ли выбран элемент choices[i], и на его основе выполняется следующая обрезка.
-
После выбора choice[i] значение selected[i] устанавливается в True, т. е. элемент помечается как выбранный.
-
При обходе списка выбора choices пропускаются все уже выбранные узлы, т. е. выполняется обрезка.
Предположим, что в первом раунде выбирается 1, во втором -- 3, а в тре- тьем -- 2, тогда во втором раунде необходимо обрезать ветвь элемента 1, а в третьем -- ветви элементов 1 и 3, как показано на рис. 13.6.
Из рис. 13.6 видно, что это отсечение уменьшает размер пространства поис- ка с O(nn) до O(n!).
Рис. 13.6. Пример обрезки в задаче полных перестановок
Код реализации
На основе вышеизложенное можно заполнить пробелы в каркасе кода. Чтобы сократить код, функции каркаса кода не реализуются отдельно, а объединены в функцию backtrack().
# === File: permutations_i.py === def backtrack(
state: list[int], choices: list[int], selected: list[bool], res:
list[list[int]]
):
""" Поиск с возвратом: полные перестановки I."""
# Когда длина состояния равна количеству элементов, фиксируется решение. if len(state) == len(choices):
res.append(list(state)) return
# Обход всех выборов.
for i, choice in enumerate(choices):
# Обрезка: не допускается повторный выбор элементов. if not selected[i]:
# Попытка: сделать выбор, обновить состояние. selected[i] = True
state.append(choice)
# Переход к следующему выбору. backtrack(state, choices, selected, res)
# Возврат: отмена выбора, восстановление предыдущего состояния. selected[i] = False
state.pop()
def permutations_i(nums: list[int]) -> list[list[int]]: """ Полные перестановки."""
res = []
backtrack(state=[], choices=nums, selected=[False] * len(nums), res=res) return res
Учет равных элементов
Предположим, что входной массив равен [1, 1, 2]. Для удобства различения двух повторяющихся элементов 1 второй 1 обозначим как 1^.
Как видно на рис. 13.7, половина перестановок, сгенерированных вышеука- занным методом, являются одинаковыми.
Рис. 13.7. Повторяющиеся перестановки
Как же избавиться от повторяющихся перестановок? Самый прямой спо- соб -- использовать хеш-набор для удаления дубликатов из результата пере- становок. Однако это не самый изящный подход, так как ветви поиска, гене- рирующие повторяющиеся перестановки, излишни, и их нужно заранее распознавать и обрезать -- это повысит эффективность алгоритма.
Обрезка равных элементов
В первом раунде выбор 1 или 1^ эквивалентен, так как все перестановки, сге- нерированные под этими двумя выборами, повторяются, как показано на рис. 13.8. Поэтому элемент 1^ нужно обрезать.
Аналогично после выбора 2 в первом раунде выбор 1 и 1^ во втором раун- де также создадут повторяющиеся ветви, поэтому 1^ во втором раунде также нужно обрезать.
По сути, наша цель -- убедиться, что в каждом раунде выбора несколько равных элементов будут выбраны только один раз.
Рис. 13.8. Обрезка повторяющихся перестановок
Код реализации
Возьмем за основу код из предыдущей задачи. В каждом раунде выбора вве- дем хеш-набор duplicated, который будет использоваться для записи элемен- тов, уже проверенных в этом раунде, и для обрезки повторяющихся элементов.
# === File: permutations_ii.py === def backtrack(
state: list[int], choices: list[int], selected: list[bool], res: list[list[int]]
):
""" Поиск с возвратом: полные перестановки II."""
# Когда длина состояния равна количеству элементов, решение фиксируется. if len(state) == len(choices):
res.append(list(state)) return
# Обход всех выборов. duplicated = set[int]()
for i, choice in enumerate(choices):
# Обрезка: не допускается повторный выбор элементов и выбор равных элементов if not selected[i] and choice not in duplicated:
# Попытка: сделать выбор, обновить состояние. duplicated.add(choice) # Запись выбранного значения элемента.
selected[i] = True state.append(choice)
# Переход к следующему выбору. backtrack(state, choices, selected, res)
# Возврат: отмена выбора, восстановление предыдущего состояния. selected[i] = False
state.pop()
def permutations_ii(nums: list[int]) -> list[list[int]]: """ Полные перестановки II."""
res = []
backtrack(state=[], choices=nums, selected=[False] * len(nums), res=res) return res
Предположим, что элементы попарно различны, тогда n элементов имеют n! перестановок (факториал). При записи результата необходимо скопировать список длиной n за время O(n). Таким образом, временная сложность со- ставляет O(n!n).
Максимальная глубина рекурсии равна n, используется O(n) пространства стека вызовов. Для selected требуется O(n) пространства. В любой момент времени в duplicated может содержаться максимум n элементов, что соответ- ствует O(n2) пространства. Таким образом, пространственная сложность составляет O(n2).
Сравнение двух видов обрезки
Обратите внимание, что, хотя и selected, и duplicated используются для обрез- ки, их цели различны.
-
Обрезка повторного выбора: в процессе всего поиска существует только один массив selected. В нем фиксируется элементы, включен- ные в текущее состояние, а его цель -- избежать повторного появления элемента в state.
-
Обрезка равных элементов: каждый раунд выбора (каждый вызов функции backtrack) включает один хеш-набор duplicated. Он фиксирует, какие элементы были выбраны в текущем обходе (цикл for), а его цель -- гарантировать, что равные элементы выбираются только один раз.
На рис. 13.9 демонстрируется область действия двух условий обрезки. Об- ратите внимание, что каждый узел в дереве представляет собой выбор, а узлы на пути от корня до листа составляют одну перестановку.
Рис. 13.9. Область действия двух условий обрезки
- задача о сумме подмножеств
Случай без повторяющихся элементов
Например, для входного множества {3, 4, 5} и целевого числа 9 решениями
будут {3, 3, 3}, {4, 5}. Следует обратить внимание на следующие два момента:
-
элементы входного множества можно выбирать неограниченное коли- чество раз;
-
порядок элементов в подмножестве не имеет значения, например {4, 5} и {5, 4} -- одно и то же подмножество.
Сравнение с решением задачи о полных перестановках
Подобно задаче о полных перестановках, процесс генерации подмножеств можно представить как серию выборов, а в процессе выбора в реальном вре- мени обновлять сумму элементов. Когда сумма элементов равна target, под- множество записывается в список результатов.
Однако, в отличие от задачи о полных перестановках, в данной задаче элементы множества можно выбирать неограниченное количество раз,
поэтому нет необходимости использовать булев список selected для записи выбранных элементов. Для получения начального решения можно просто не- много изменить код для полных перестановок.
# === File: subset_sum_i_naive.py === def backtrack(
state: list[int], target: int, total: int,
choices: list[int], res: list[list[int]],
):
""" Поиск с возвратом: сумма подмножеств I."""
# Если сумма подмножества равна target, записать решение. if total == target:
res.append(list(state)) return
# Перебор всех вариантов выбора. for i in range(len(choices)):
# Обрезка: если сумма подмножества превышает target, пропустить этот выбор. if total + choices[i] > target:
continue
# Попытка: сделать выбор, обновить сумму элементов total. state.append(choices[i])
# Переход к следующему выбору.
backtrack(state, target, total + choices[i], choices, res)
# Возврат: отмена выбора, восстановление предыдущего состояния. state.pop()
def subset_sum_i_naive(nums: list[int], target: int) -> list[list[int]]: """ Решение задачи о сумме подмножеств I (включая повторяющиеся
подмножества)."""
state = [] # Состояние (подмножество). total = 0 # Сумма подмножества.
res = [] # Список результатов (список подмножеств). backtrack(state, target, total, nums, res)
return res
При вводе в этот код массива [3, 4, 5] и целевого элемента 9 будет выведено [3, 3, 3], [4, 5], [5, 4]. Хотя удалось найти все подмножества с суммой 9, среди
них есть повторяющиеся подмножества [4, 5] и [5, 4].
Это происходит потому, что процесс поиска различает порядок выбора, тогда как в подмножествах порядок элементов не важен. Как показано на рис. 13.10, сначала выбрать 4, а затем 5 и сначала выбрать 5, а затем 4 -- это разные ветви, но они соответствуют одному и тому же подмножеству.
Рис. 13.10. Поиск подмножеств и обрезка по превышению целевого значения
Одним из очевидных подходов к устранению повторяющихся подмно- жеств является удаление дубликатов из списка результатов. Однако этот метод очень неэффективен по двум причинам.
-
Когда в массиве много элементов, особенно когда значение target ве- лико, процесс поиска генерирует множество повторяющихся подмно- жеств.
-
Сравнение подмножеств (массивов) на различия очень затратная по вре- мени операция. Она требует сначала сортировки массивов, затем срав- нения различий каждого элемента в массиве.
Обрезка повторяющихся подмножеств
Рассмотрим устранение дубликатов в процессе поиска с помощью обрез- ки. На рис. 13.11 показано, что повторяющиеся подмножества возникают при выборе элементов массива в разном порядке, например в следующих случаях:
-
пусть на первом и втором этапах выбираются 3 и 4 соответственно, соз- даются все подмножества, содержащие эти два элемента, обозначенные как [3, 4, ...];
-
затем если на первом этапе выбирается 4, то на втором этапе следует пропустить 3, так как подмножество [4, 3, ...] полностью повторяет под- множество, созданное на этапе 1.
В процессе поиска выбор на каждом уровне осуществляется слева направо.
Поэтому чем правее ветвь, тем больше она обрезается.
-
На первых двух этапах выбираются 3 и 5 и создаются подмножества [3, 5, ...].
-
На первых двух этапах выбираются 4 и 5 и создаются подмножества [4, 5, ...].
-
Если на первом этапе выбирается 5, то на втором этапе следует про- пустить 3 и 4, так как подмножества [5, 3, ...] и [5, 4, ...] полностью повто- ряют подмножества, описанные на этапах 1 и 2.
Рис. 13.11. Повторяющиеся подмножества, полученные в результате различного порядка выбора
Обобщим эту мысль. Пусть задан входной массив [x1, x2, ..., xn]. Тогда в про- цессе поиска последовательность выбора [xi1, xi2, ..., xim] должна удовлетво- рять условию i1 ≤ i2 ≤ ⋯ ≤ im, в противном случае она приведет к дубликатам, и ее нужно обрезать.
Код реализации
Для реализации этой обрезки мы инициализируем переменную start, которая указывает начальную точку обхода. После выбора xi следующая итерация начинается с индекса i. Это позволяет для последовательности выбора со- блюдать условие i1 ≤ i2 ≤ ⋯ ≤ im, обеспечивая уникальность подмножеств.
Кроме того, в код были внесены следующие две оптимизации.
-
Перед началом поиска массив nums сортируется. При обходе всех вариан- тов, если сумма подмножества превышает target, цикл завершается, так как последующие элементы больше, и их сумма также превысит target.
-
Исключение переменной total, подсчет суммы элементов осуществля- ется с помощью вычитания из target. Решение фиксируется, когда target равен 0.
# === File: subset_sum_i.py ===
def backtrack(
state: list[int], target: int, choices: list[int], start: int, res: list[list[int]]
):
""" Поиск с возвратом: сумма подмножеств I"""
# При равенстве суммы подмножества target фиксируется решение. if target == 0:
res.append(list(state)) return
# Обход всех вариантов.
# Обрезка 2: обход начинается с start, чтобы избежать создания # повторяющихся подмножеств.
for i in range(start, len(choices)):
# Обрезка 1: если сумма подмножества превышает target, цикл завершается. # Это связано с тем, что массив отсортирован, последующие элементы
# больше, и сумма подмножества обязательно превысит target. if target - choices[i] < 0:
break
# Попытка: выбор, обновление target, start. state.append(choices[i])
# Переход к следующему выбору.
backtrack(state, target - choices[i], choices, i, res)
# Возврат: отмена выбора, восстановление предыдущего состояния. state.pop()
def subset_sum_i(nums: list[int], target: int) -> list[list[int]]: """ Решение задачи суммы подмножеств I."""
state = [] # Состояние (подмножество). nums.sort() # Сортировка nums.
start = 0 # Начальная точка обхода.
res = [] # Список результатов (список подмножеств). backtrack(state, target, nums, start, res)
return res
На рис. 13.12 показан полный процесс поиска с возвратом для массива [3, 4, 5] и целевого элемента 9.
Рис. 13.12. Процесс поиска с возвратом для реализации задачи о сумме подмножеств I
Случай с повторяющимися элементами
В отличие от предыдущей задачи входной массив может содержать по- вторяющиеся элементы, что создает новую проблему. Например, для мас- сива [4, 4, 5] и целевого элемента 9, текущий код выдает результат [4, 5], [4, 5], что приводит к повторяющимся подмножествам.
Причина этих повторов в том, что равные элементы выбираются не- сколько раз на одном этапе. На рис. 13.13 показано, что на первом этапе есть три варианта выбора, два из которых равны 4. Это приводит к двум повторя- ющимся ветвям поиска и, следовательно, к повторяющимся подмножествам. Аналогично два элемента 4 на втором этапе также создают повторяющиеся подмножества.
Рис. 13.13. Повторяющиеся подмножества из-за равных элементов
Обрезка равных элементов
Для решения этой проблемы необходимо сделать выбор равных элементов на каждом этапе однократным. Реализация этого подхода довольно изящ- на: поскольку массив отсортирован, равные элементы находятся рядом друг с другом. Это означает, что если текущий элемент равен предыдущему, то он уже был выбран, и его следует пропустить.
В то же время в этой задаче предусмотрено, что каждый элемент мас- сива может быть выбран только один раз. К счастью, можно использовать переменную start для выполнения этого ограничения: после выбора xi начи- наем следующий цикл с индекса i + 1. Это позволяет исключить повторяющие- ся подмножества и избежать повторного выбора элементов.
Код реализации
# === File: subset_sum_ii.py === def backtrack(
state: list[int], target: int, choices: list[int], start: int, res: list[list[int]]
):
""" Поиск с возвратом: сумма подмножеств II."""
# Когда сумма подмножества равна target, фиксируется решение. if target == 0:
res.append(list(state)) return
# Перебор всех вариантов выбора.
# Обрезка 2: перебор начинается со start, чтобы избежать создания # повторяющихся подмножеств.
# Обрезка 3: перебор начинается со start, чтобы избежать повторного выбора # одного и того же элемента.
for i in range(start, len(choices)):
# Обрезка 1: если сумма подмножества превышает target, цикл завершается. # Это связано с тем, что массив уже отсортирован, и последующие
# элементы больше, сумма подмножества обязательно превысит target. if target - choices[i] < 0:
break
# Обрезка 4: если элемент равен левому элементу, значит, эта ветвь # поиска повторяется, и ее можно пропустить.
if i > start and choices[i] == choices[i - 1]:
continue
# Попытка: сделать выбор, обновить target, start. state.append(choices[i])
# Переход к следующему выбору.
backtrack(state, target - choices[i], choices, i + 1, res)
# Возврат: отмена выбора, восстановление предыдущего состояния. state.pop()
def subset_sum_ii(nums: list[int], target: int) -> list[list[int]]: """ Решение задачи суммы подмножеств II."""
state = [] # Состояние (подмножество).
nums.sort() # Сортировка nums.
start = 0 # Начальная точка перебора.
res = [] # Список результатов (список подмножеств). backtrack(state, target, nums, start, res)
return res
На рис. 13.14 демонстрируется процесс обратного отслеживания для масси- ва [4, 4, 5] и целевого элемента 9, включающий четыре вида обрезки. Проана- лизируйте рисунок и комментарии в коде, чтобы лучше понять весь процесс поиска и как работают различные операции обрезки.
2-й раунд выбора
Обрезка 4
В одном раунде равные элементы можно выбирать только один раз
Обрезка 2
Не допускать создания повторяющихся подмножеств
Обрезка 1
Сумма элементов
не может превышать
target
Рис. 13.14. Процесс поиска с возвратом для реализации задачи о сумме подмножеств II
- задача об n ферзях
Для n = 4 можно найти два решения, которые изображены на рис. 13.15. С точки зрения алгоритма поиска с возвратом шахматная доска размером n×n имеет n2 клеток, которые предоставляют собой все варианты выбора. В про- цессе размещения ферзей состояние доски постоянно меняется, и в каждый момент времени доска имеет состояние state.
{width="3.312998687664042in" height="1.2706244531933508in"}
Рис. 13.15. Решения задачи о 4 ферзях
На рис. 13.16 изображено три условия ограничения для данной задачи: несколько ферзей не могут находиться на одной строке, в одном столбце или на одной диагонали. Стоит отметить, что диагонали делятся на главную диа- гональ \ и побочную диагональ /.
Рис. 13.16. Ограничения задачи об n ферзях
Стратегия построчного размещения
Количество ферзей и количество строк на доске равно n, поэтому можно сделать вывод: на каждой строке доски может быть размещен только один ферзь. Из этого следует, что мы можем использовать стратегию построчного раз- мещения: размещать по одному ферзю на каждой строке, начиная с первой
и заканчивая последней.
На рис. 13.17 изображен процесс построчного размещения для задачи о 4 ферзях. Из-за ограничений на размер изображения, на рис. 13.17 развернута только одна ветвь поиска первой строки, а все решения, не удовлетворяющие ограничениям по столбцам и диагоналям, обрезаны.
Рис. 13.17. Стратегия построчного размещения
По сути**, стратегия построчного размещения выполняет функцию об- резки**, отсекая все ветви поиска, в которых на одной строке может находиться более одного ферзя.
Обрезка по столбцам и диагоналям
Чтобы выполнить ограничениям по столбцам, можно использовать булев мас- сив cols длиной n, в котором будет фиксироваться наличие ферзя в каждом столбце. На его основе перед каждым решением о размещении будут обре- заться столбцы, в которых уже есть ферзь. Состояние cols будет динамически обновляться в процессе возврата.
Как теперь отследить ограничения по диагоналям? Пусть индексы строки и столбца какой-либо клетки на доске равны (row, col). Выбрав определенную главную диагональ в матрице, можно заметить, что разность индексов строки и столбца всех клеток на этой диагонали одинакова, т. е. для всех клеток на главной диагонали значение row − col является постоянной величиной.
Это означает, что если для двух клеток выполняется условие row1 -- col2 = row2 -- col2, то они находятся на одной главной диагонали. Пользуясь этим пра- вилом, можно с помощью массива diags1 фиксировать наличие ферзя на каж- дой главной диагонали, как показано на рис. 13.18.
Аналогично сумма row + col для всех клеток на побочной диагонали яв- ляется постоянной величиной. Мы можем использовать еще один массив diags2 для обработки ограничений на побочной диагонали.
Рис. 13.18. Обработка ограничений по столбцам и диагоналям
Код реализации
Следует отметить, что в n-мерной матрице диапазон row -- col составляет [−n + 1, n − 1], а диапазон row + col составляет [0, 2n − 2], поэтому количество главных и побочных диагоналей равно 2n − 1, т. е. длина массивов diags1 и diags2 также равна 2n − 1.
# === File: n_queens.py === def backtrack(
row: int, n: int,
state: list[list[str]], res: list[list[list[str]]], cols: list[bool],
diags1: list[bool], diags2: list[bool],
):
""" Поиск с возвратом: задача об n ферзях."""
# При размещении всех строк фиксируется решение. if row == n:
res.append([list(row) for row in state]) return
# Перебор всех столбцов. for col in range(n):
# Вычисление главной и побочной диагоналей для данной клетки. diag1 = row - col + n - 1
diag2 = row + col
# Обрезка: не допускается наличие ферзя в данном столбце, на главной # или побочной диагонали.
if not cols[col] and not diags1[diag1] and not diags2[diag2]: # Попытка: размещение ферзя в данной клетке. state[row][col] = "Q"
cols[col] = diags1[diag1] = diags2[diag2] = True # Переход к следующей строке.
backtrack(row + 1, n, state, res, cols, diags1, diags2) # Возврат: восстановление клетки в пустое состояние. state[row][col] = "#"
cols[col] = diags1[diag1] = diags2[diag2] = False
def n_queens(n: int) -> list[list[list[str]]]: """ Решение задачи об n ферзях."""
# Инициализация шахматной доски размером n*n, где 'Q' обозначает ферзя,
# а '#' обозначает пустую клетку.
state = [["#" for _ in range(n)] for _ in range(n)] cols = [False] * n # Учет наличия ферзя в столбце.
diags1 = [False] * (2 * n - 1) # Учет наличия ферзя на главной диагонали. diags2 = [False] * (2 * n - 1) # Учет наличия ферзя на побочной диагонали. res = []
backtrack(0, n, state, res, cols, diags1, diags2) return res
Размещение n раз по строкам с учетом ограничений по столбцам предпо- лагает, что от первой до последней строки имеется n, n − 1, ..., 2, 1 вариантов выбора, что требует времени O(n!). При фиксации решения необходимо копи- ровать матрицу state и добавлять результат в res, что требует времени O(n2). Таким образом, общая временная сложность составляет O(n! ⋅ n2). На прак- тике обрезка по ограничениям диагоналей значительно сокращает простран- ство поиска, поэтому эффективность поиска часто превосходит указанную временную сложность.
Массив state использует O(n2) пространства, массивы cols, diags1 и diags2 ис- пользуют O(n) пространства. Максимальная глубина рекурсии составляет n, что требует O(n) пространства стека. Следовательно, пространственная слож- ность равна O(n2).
- Резюме ❖ 395
13.5. резюме
Ключевые моменты
-
Алгоритм поиска с возвратом по сути является методом полного пере- бора, который ищет подходящие решения путем обхода в глубину про- странства решений. В процессе поиска фиксируются удовлетворяющие условиям решения до тех пор, пока не будут найдены все решения или обход не будет завершен.
-
Поиск с возвратом включает в себя попытки и возвраты. Он использует по- иск в глубину и выполняет попытки для различных вариантов. При несо- ответствии заданным условиям отменяет предыдущий выбор, возвраща- ется к предыдущему состоянию и продолжает проверять другие варианты. Попытки и возвраты -- это операции в противоположных направлениях.
-
Задачи поиска с возвратом обычно содержат несколько ограничений, кото- рые можно использовать для обрезки. Обрезка позволяет заранее завершить ненужные ветви поиска, что значительно повышает эффективность поиска.
-
Алгоритм поиска с возвратом в основном применяется для решения по- исковых задач и задач с ограничениями. Задачи комбинаторной опти- мизации можно решать с помощью поиска с возвратом, но часто суще- ствуют более эффективные или более подходящие методы.
-
Задача о перестановках направлена на поиск всех возможных переста- новок элементов заданного множества. В решении используется массив для учета выбранных элементов и обрезки ветвей поиска с повторным выбором одного и того же элемента. Это позволяет обеспечить выбор каждого элемента только один раз.
-
В задаче о перестановках с повторяющимися элементами нужно отсе- кать повторяющиеся перестановки в конечном результате. Необходимо обеспечить однократный выбор равных элементов в каждом раунде, что обычно реализуется с помощью хеш-множества.
-
Цель задачи о сумме подмножеств -- найти все подмножества с суммой, равной целевому значению, в заданном множестве. Порядок элементов в множестве не важен, но процесс поиска выводит результаты во всех возможных порядках, создавая повторяющиеся подмножества. Перед выполнением поиска с возвратом данные сортируются, а также уста- навливается переменная для указания начальной точки каждого раунда, чтобы обрезать ветви поиска с повторяющимися подмножествами.
-
В задаче о сумме подмножеств равные элементы в массиве создают по- вторяющиеся множества. При наличии предварительно отсортирован- ного массива обрезка осуществляется путем проверки равенства сосед- них элементов, что гарантирует выбор равных элементов только один раз в каждом раунде.
-
Задача об n ферзях заключается в нахождении способа размещения n ферзей на шахматной доске размером n×n так, чтобы никакие два фер- зя не рубили друг друга. Ограничения задачи включают ограничения по строкам, столбцам, главным и побочным диагоналям. Для соблюдения ограничения по строкам используется стратегия размещения по стро- кам, что гарантирует размещение одного ферзя в каждой строке.
- Обработка ограничений по столбцам и диагоналям осуществляется ана- логично. Для ограничения по столбцам используется массив, который фиксирует наличие ферзя в каждом столбце. Для ограничения по диаго- налям используются два массива, которые фиксируют наличие ферзя на главной и побочной диагоналях соответственно. Сложность заключается в нахождении закономерности индексов строк и столбцов для клеток, находящихся на одной и той же главной (или побочной) диагонали.
Вопросы и ответы
Вопрос. Какова связь между возвратом и рекурсией?
Ответ. В общем, возврат -- это стратегия алгоритма, тогда как рекурсия ско- рее является инструментом.
-
Алгоритмы поиска с возвратом обычно реализуются на основе рекурсии. Однако поиск с возвратом -- это один из вариантов применения рекур- сии, а именно применение рекурсии в задачах поиска.
-
Структура рекурсии отражает парадигму разбиения на подзадачи и ча- сто используется для решения задач, связанных со стратегией «разделяй и властвуй», поиском с возвратом, динамическим программированием (мемоизация рекурсии) и др.
Глава 14


























