Понимание и применение эвристик при разработке алгоритмов
Эвристика - это приближение, которое мы используем, и не гарантируется, что оно будет оптимальным для достижения цели. В этой статье я подробно объясню на простом для понимания примере, как мы можем использовать эвристику для разработки алгоритмов. Поэтому я буду использовать алгоритм A-Star (A *), чтобы объяснить использование и реализацию эвристики на полном примере, который включает кратчайший путь.
Детерминированный подход
Детерминированные подходы гарантированно предоставят оптимальное решение. Например, алгоритм Дейкстры можно рассматривать как детерминированный алгоритм для получения кратчайшего пути между любыми двумя точками в графе с положительными весами ребер.
Алгоритм Дейкстры как детерминированный алгоритм
Алгоритм в его основной форме выглядит следующим образом.
function Dijkstra(Graph, Start) vertex_set = {} for each vertex v in Graph dist[v] = INFINITY parent[v] = NULL dist[start] = 0 while vertex_set not empty u = min dist vertex from vertex_set remove u from vertex_set for neighbour v of u path = dist[u]+length(u,v) if path < dist[v] dist[v] = path parent[v] = u return parent, dist
Мы видим, что в начале алгоритма никакие сведения о целевом узле не учитываются. Следовательно, мы, по сути, выполняем исчерпывающий поиск кратчайших путей к каждой другой проходимой вершине графа. Представьте, что на графике изображена дорожная сеть вашей страны. Будет ли это масштабироваться до этого уровня? Оптимальная реализация алгоритма Дейкстры для очередей с минимальным приоритетом имеет временную сложность O (| V | + | E | log | V |).
Введение в алгоритм Дейкстры эвристики
Вместо того, чтобы полностью искать всех соседей исходной вершины, мы могли бы также выбрать соседей, которые МОГУТ дать нам более короткий путь к целевая вершина на графике. Слово МОЖЕТ важно, поскольку эвристика часто приводит к неоптимальным результатам.
Как мы можем угадать, может ли конкретный узел привести нас к более короткому пути?
Вот где приходит эвристика. Например, в случае дорожной сети мы можем предположить, что чем ближе выбранный нами перекресток, тем быстрее он может добраться до целевого города. Для этого мы можем использовать легкодоступную информацию GPS для вычисления не элитного, а приблизительного расстояния по воздуху между точками. Хотя это может быть неверно, учитывая, что вы можете видеть повороты и кольцевые развязки, мы можем быть уверены, что общее направление движения должно быть в сторону целевого города.
Сравнивая вышеприведенное предположение, вы можете заметить, что если бы мы выбрали алгоритм Дейкстры, мы также вычислили бы расстояния до целевых узлов, которые нигде не находятся в направлении намеченной цели. Надежная реализация самого известного алгоритма кратчайшего пути с эвристикой называется A * или алгоритм A-Star.
Алгоритм A-Star
Псевдокод будет выглядеть, как показано ниже. Подробнее читайте в wiki.
function AStar(start, end, heuristic=h) open_set = {start} closed_set = {} # distance so far to node distance = lookup_table(default=INFINITY) # guess to the end guess = lookup_table(default=INFINITY) distance[start] = 0 guess[start] = h(start) while open_set not empty current = node with lowest guess from open_set if current is goal: END open_set.remove(current) closed_set.add(current) for neighbour of current score = distance[current]+length(current,neighbour) if score < guess[neighbour] distance[neighbour] = score parent[neighbour] = current guess[neighbour] = distance[neighbour]+h(neighbour) if neighbour not in closed_set open_set.add(neighbour)
Уловка в приведенном выше коде заключается в том, что вам нужно сканировать массив, чтобы получить ближайшего соседа к целевому узлу. Однако, если вы будете хранить соседей в минимальной куче, используя расстояние до целевого узла в качестве ключа, вы можете сделать это за O (1) времени. Довольно замечательно, как мы можем улучшить алгоритмы, зная структуры данных (Прочтите эту статью, чтобы узнать больше). Давайте посмотрим на реализацию этого алгоритма на Python.
Реализация A-Star на Python
Я буду использовать следующий импорт в моей реализации алгоритма.
import numpy as np import matplotlib.pyplot as plt # for visualizations import matplotlib.cm as cm # for visualizations from collections import defaultdict import networkx as nx # storing the graph structure import heapq # pythons heap implementation
Я создам класс-оболочку, содержащий вершины (узлы) графа, чтобы я мог сразу использовать их в heapq
реализации python. Я отменяю функцию __lt__
оболочки, чтобы heapq
использовал свой атрибут val
для элементов heappush
и heappop
. Это val
соответствует расстоянию до целевого узла.
class NodeWrap: def __init__(self, nid, dist_to_end): self.nid = nid self.val = dist_to_end def __lt__(self, other): return self.val < other.val
Я использую следующий код для отслеживания кратчайшего пути после запуска алгоритма и наличия родителей вершин, которые мы пересекаем.
def trace_path(parent_map, end): node = end path = [] while (node in parent_map): path.append(node) node = parent_map[node] return path[::-1]
Реализация графа и матрицы визуализации
Поскольку это сделано в учебных целях, я подумал, что было бы очень интересно посмотреть, как алгоритм работает в реальном времени. Поэтому давайте смоделируем нашу проблему, как показано ниже. Представьте, что у нас есть 2D-ландшафт, и мы хотим попасть в верхний правый угол из нижнего левого угла. У нас есть препятствие на пути, показанное черным.
Я заполняю эту местность с помощью следующего кода. Это простая матрица размером 50x50
. Изображение представляет собой матрицу с 3 каналами для RGB. Таким образом, препятствия - это просто индексы с [0, 0, 0]
.
mat = np.zeros((50, 50, 3), dtype=np.float32) for x in range(50): for y in range(50): mat[x, y, 0] = 0.95 mat[x, y, 1] = 0.95 mat[x, y, 2] = 0.95 g.add_node("{},{}".format(x,y), x=x, y=y) # end mat[49, 0, 0] = .0 mat[49, 0, 1] = .0 mat[49, 0, 2] = 0.8 # start mat[0, 49, 0] = .0 mat[0, 49, 1] = 0.8 mat[0, 49, 2] = .0 for x in range(20, 50): for y in range(20, 50): if x>y+5: mat[x, y, 0] = .0 mat[x, y, 1] = .0 mat[x, y, 2] = .0 for x in range(0, 30): for y in range(0, 30): if x<y-5: mat[x, y, 0] = .0 mat[x, y, 1] = .0 mat[x, y, 2] = .0 for x in range(26, 30): for y in range(10, 20): mat[x, y, 0] = .0 mat[x, y, 1] = .0 mat[x, y, 2] = .0 mat_copy = np.copy(mat)
Создание графика NetworkX
Поскольку операции с графами лучше всего выполнять на графах, таких как структура данных с функциями графа, давайте перенесем нашу местность в граф. Каждый пиксель будет узлом / вершиной на нашем графике. Из каждого узла / вершины мы можем пройти во всех 8 направлениях, как ферзь на шахматной доске. В нашем случае ребра существуют, если доступный обратимый сосед не черный.
g = nx.Graph() def is_black(arr): return sum(arr) == 0 for x in range(50): for y in range(50): xi = g.nodes["{},{}".format(x,y)]['x'] yi = g.nodes["{},{}".format(x,y)]['y'] for xj in range(xi-1, xi+2): for yj in range(yi-1, yi+2): if xi==xj and yi==yj or \ xj<0 or yj<0 or \ xj>49 or yj>49: continue # if black not a neighbour if is_black(mat[xi, yi]) \ or is_black(mat[xj, yj]): continue g.add_edge("{},{}".format(xi,yi), "{}, \ {}".format(xj,yj))
Реализация алгоритма с контрольными точками
Теперь, когда я подготовил свой график, я хочу реализовать и запустить A-Star. В то же время, когда алгоритм ищет соседей, я хочу сделать снимок исходного 2D-ландшафта для последующей визуализации. Полный код Python для этого выглядит следующим образом. Здесь я использую евклидово расстояние в качестве эвристики расстояния, которое является предположением от любого узла до цели.
def store_image(world, node, i): global mat x = world.nodes[node]['x'] y = world.nodes[node]['y'] mat[x, y, 0] = 1 mat[x, y, 1] = .5 mat[x, y, 2] = .0 fig = plt.figure(figsize=(10, 10)) plt.imshow(mat) plt.axis('off') plt.savefig("im-{}.png".format(i)) plt.close() def euclidean_dist(world, node1, node2): x1 = world.nodes[node1]['x'] x2 = world.nodes[node2]['x'] y1 = world.nodes[node1]['y'] y2 = world.nodes[node2]['y'] return ((x1-x2)**2 + (y1-y2)**2)**.5 def a_star(world, start, end): stepper = 1 open_set = [] close_set = set() parent_map = {} start_node = NodeWrap(start, 0) heapq.heappush(open_set, start_node) store_image(world, start, stepper) stepper += 1 cost_to_reach_node = defaultdict(lambda: float('inf')) cost_to_reach_node[start] = 0 guess_to_destination = defaultdict(lambda: float('inf')) guess_to_destination[start] = euclidean_dist(world, start, end) while len(open_set) > 0: current = heapq.heappop(open_set) if current.nid == end: path = trace_path(parent_map, end) return path close_set.add(current.nid) for neighbor in world.neighbors(current.nid): tentative_score = cost_to_reach_node[current.nid] + \ euclidean_dist(world, current.nid, neighbor) if tentative_score < cost_to_reach_node[neighbor]: parent_map[neighbor] = current.nid cost_to_reach_node[neighbor] = tentative_score guess_to_destination[neighbor] = cost_to_reach_node[neighbor] + \ euclidean_dist(world, neighbor, end) if neighbor not in close_set: neighbor_node = NodeWrap(neighbor, euclidean_dist(world, neighbor, end)) heapq.heappush(open_set, neighbor_node) store_image(world, neighbor, stepper) stepper += 1
Мы можем запустить вышеуказанный алгоритм, используя следующий фрагмент бегуна. Обратите внимание, что я использую копию матрицы, поскольку внутри алгоритма мы обновляем исходный объект mat
.
path = a_star(g, "49,0", "0,49") # trace path and visualize on terrain for node in path: x = g.nodes[node]['x'] y = g.nodes[node]['y'] mat_copy[x, y, 0] = 1 mat_copy[x, y, 1] = .5 mat_copy[x, y, 2] = .0 fig = plt.figure(figsize=(10, 10)) plt.imshow(mat_copy) plt.axis('off')
Окончательное выполнение и создание GIF
Красота становится более заметной, когда мы видим, как все происходит в анимации. Поэтому я объединю все созданные мной изображения, чтобы сформировать великолепный GIF. Я использую следующий код для создания GIF из моих снимков.
import imageio import glob anim_file = 'out.gif' with imageio.get_writer(anim_file, mode='I', fps=10) as writer: filenames = glob.glob('im-*.png') filenames = sorted(filenames, \ key=lambda x: int(x.replace("im-", "").replace(".png", ""))) last = -1 for i,filename in enumerate(filenames): frame = 2*(i**0.5) if round(frame) > round(last): last = frame else: continue image = imageio.imread(filename) writer.append_data(image) image = imageio.imread(filename) writer.append_data(image) image = imageio.imread(filename) writer.append_data(image) image = imageio.imread(filename) writer.append_data(image)
После этого у нас будет следующая анимация.
Заключение
Мы ясно видим, что обход происходит в очень узком пространстве поиска. Это значительно ускоряет поиск, и нам не нужно выполнять исчерпывающий поиск по всем узлам.
В гораздо более крупных графах используются такие методы, как маркировка узлов и индексация, так что графу не нужно будет полностью загружаться в оперативную память. Надеюсь, что в будущем напишу больше статей по этой теме. А пока я надеюсь, что вам понравилось читать эту статью. Полную записную книжку можно посмотреть здесь.