Поиск с возвратом — это эффективный метод для решения алгоритмических задач, обычно задаваемых на собеседовании. Данный вид поиска ищет решения в глубину и, достигнув последнего узла, возвращается к ближайшему верному пути. При применении этого метода уменьшается область поиска, поскольку из пространства решений удаляются все неверные пути.
Тем не менее для использования обратного поиска необходима возможность тестировать частичные решения — метод не может найти глобальный оптимум, если отсутствует индикатор прогресса, указывающий ведет ли текущий путь к решению или нет. Несмотря на это, с помощью обратного поиска можно решать примеры вроде судоку — в таких задачах сразу видно, верен ли путь текущего решения, например, когда в строке, столбце или блоке присутствует два одинаковых числа.
Обратный поиск состоит из трех частей: рекурсивной функции, получающей набор решений, который может быть незавершенным n, мутации — дополнения к n, добавляемой к набору n, а также проверки решения на завершенность и верность, которая проверяет завершение мутаций набора решений — полноценно ли данное решение? и верность — является ли решение приемлемым с подставленными на данный момент значениями?.
Обратный поиск автоматически создает чистое и эффективное, с точки зрения вычислений, дерево поиска, основанное на очень простых элементах.
В данной статье я продемонстрирую и опишу, как этот вид поиска может использоваться для решения трех классических поисковых вопросов, задаваемых на собеседовании.
Это стандартная задача для обратного поиска: дана шахматная доска размера N на N. Сколькими способами можно расположить N-ферзей так, чтобы они не угрожали друг другу, т.е. чтобы один ферзь не находился в одном ряду, столбце или по диагонали с другим?
Например, данная сетка представляет неверный вариант, потому что по меньшей мере два из восьми ферзей находятся в ряду, столбце или диагонали других и поэтому отмечены красными квадратами.
Решение методом грубой силы (brute-force) будет следующим: нужно перебрать каждую комбинацию N-ферзей в каждой из N на N точек — N² и выбрать N возможных вариантов.
Если изобразить график, где x представляет N, а y — число вероятностей для поиска, то он устремится вверх.
Только для стандартной сетки 8 на 8 существует 4,426,165,368 возможных комбинаций, перебор которых займет непозволительно много времени. Это можно частично улучшить, никогда не располагая двух ферзей в одном ряду или столбце, но нам по-прежнему потребуется перебрать каждый ряд и каждую клетку — время выполнения O(N²) будет недостаточно быстрым.
Теперь давайте пройдемся по чек листу вопросов, чтобы определить, поможет ли нам здесь обратный поиск.
Да. Мы можем частично построить решение, поместив на доску определенное число ферзей.
Да, можно выяснить, является ли такое решение верным, проверив не угрожает ли один ферзь другому. Этот процесс можно ускорить, приняв за условие, что изначально выставленные ферзи не угрожают друг другу, и проверив на угрозу только вновь добавленные.
Да, решение будет считаться завершенным, когда все N-ферзей будут помещены на доску. В обратном поиске хорошо то, что, когда решение конечное, оно также и верное, поскольку все неверные пути решений были исключены ранее.
Давайте построим алгоритм обратного поиска для решения этой задачи за более короткое время.
Функция n_queens
:
N
и список чисел board
.board
как пустой список, если он не определен, и count
как 0
.x
в N
x
к board
.board
верна — добавляет вывод n_queens
с вводами N
и board
к count
, что инициализирует очередной цикл функции и представляет туннелирование или процесс построения дерева обратного поиска. Поскольку частичное решение рассматривается в данном кейсе как верное, оно инициирует еще один экземпляр функции n_queens
.x
. count
.Функция для оценки верности доски действует следующим образом:
board
, где каждое число соответствует одному столбцу.R
и столбца C
на доске, состоящей только из расставленных ферзей.C
. Если она равна 0
, т.е. два столбца одинаковы, то возвращает False
и выходит из функции.R
. Если она равняется 0
, т.е. оба столбца одинаковы, то возвращает False
и выходит из функции.True
.Рекурсивно перебирая новые экземпляры функции построения внутри функции построения, обратный поиск способен найти умное дерево, которое не продолжится там, где ситуация окажется безнадежной, поскольку основан он на процессе, а не на выходном результате.
Результаты, где индекс указывает значение N:
1, 1, 0, 0, 2, 10, 4, 40, 92, 352Дается начальный аэропорт и неупорядоченный список рейсов, каждый из которых представлен парой откуда-куда. Нужно вычислить маршрут пассажира. Если таковой не существует — вернуть na
, при этом в маршруте должны быть использованы все рейсы.
Давайте в качестве примера возьмем эти рейсы:
С помощью грубой силы переберем пермутацию рейсов и проверим, является ли она верным маршрутом, делая это O(n!) раз.
Давайте снова пройдем по чек листу вопросов.
Да, такие решения можно построить путем составления неполного маршрута наподобие SFO ➔ HNL ➔ ORD ➔ ?.
Решение можно оценить как неверное, если из последнего пункта “куда” маршрута нет доступных рейсов и при этом остаются рейсы, которые можно осуществить. А поскольку использовать необходимо все рейсы, можно считать, что мы достигли заключительного узла.
Да, решение оценивается как конечное, когда маршрут включает все рейсы.
Построение логики решения:
Функция get_itinerary
:
flights
с элементами в форме origin/destination
(откуда/куда) и current_itinerary
, представляющим список маршрута, где первый элемент представляет начальный аэропорт, а второй пункт “куда” первого рейса (он же пункт “откуда” для второго рейса и т.д.).flights
были использованы, возвращает current_itinerary
.last_stop
будет равна последнему элементу current_itinerary
.origin/destination
в рейсах:destination
к current_itinerary
.origin
равняется last_stop
(это проверка, определяющая верность связи рейсов) — get_itinerary
со всеми рейсами за исключением текущей пары origin/destination
и выходит из функции.destination
.None
, если ни один из предыдущих поисков по дереву не смог вернуть решение.Вы можете заметить, что большая часть реализации поиска с возвратом связана с многократным использованием функции и построением автоматизированного рекурсивного дерева поиска.
Обратите внимание, что для конкретно этой задачи существует гораздо более эффективное решение, и поиск с возвратом здесь мы использовали просто в качестве примера. В реальности быстрее было бы просто взять начальный аэропорт (например, SFO), найти среди рейсов связанный с ним аэропорт (SFO в HNL) и добавить пункт “куда” в расписание, продолжая это делать рекурсивно.
Можем ли мы применить поиск с возвратом для решения стандартной загадки судоку?
Да, можно частично заполнить позиции на доске судоку.
Да, если в частичном решении присутствуют какие-либо числа, совпадающие с числами в строке, столбце или блоке — такое решение будет неверным. В противном случае — верным.
Да, решение будет конечным, когда вся доска будет заполнена.
Давайте попробуем заполнять каждую пустую ячейку одну за другой и возвращаться при достижении неверного состояния. Выглядеть это будет так:
Функция sudoku
:
board
, являющуюся массивом, где каждое значение представляет ячейку доски.board
заполнена, возвращает board
и выходит из функции.r
и c
представляют строку и столбец первого пустого значения на доске.i
в [1, 2, 3, 4, 5, 6, 7, 8, 9]
r
строки и c
столбца как i
.board
остается верна,sudoku
для данной board
.Простота и чистота применения поиска с возвратом для решения таких сложных задач, как судоку, весьма впечатляют. Достаточно выделить рекурсивную функцию, проверку частичного решения, а также функцию для оценки конечности решения, и программа автоматически построит за вас дерево и найдет лучшее решение, если таковое вообще присутствует.
Надеюсь, что данная статья помогла сформировать понимание того, как, выделив три простых компонента поиска с возвратом, можно наметить для программы правила, по которым она выполнит перебор и найдет лучшее решение.
Перевод статьи Andre Ye: Backtracking: How to Approach Search Programming Interview Questions
Комментарии