Век поиска кратчайшего решения задачи о кратчайшем пути
Люди пытались найти более быстрые способы передвижения на протяжении всей своей истории. Появление качественной дорожной системы в римской империи в своё время привело к её расцвету, но со временем выяснилось, что и в продуманных дорожных системах бывают забавные изъяны, как например в небезызвестной задачи о кёнигсбергских мостах, считающейся отправной точкой возникновения теории графов. Неудивительно и то, что с развитием вычислительной техники логистические задачи стали одними из первых, над которыми трудились первопроходцы компьютерных наук. Задача о кратчайшем пути -- одна из них, звучит достаточно просто: есть несколько городов и дорог, соединяющих пару городов между собой, мы хотим попасть из города А в город Б пройдя при этом минимальное расстояние. Первый системный подход к этой задаче был описан в работе Эгервари в 1931г., спустя 25 лет Эдсгер Дейкстра придумал алгоритм, который сейчас является частью любого уважающего себя базового курса алгоритмов на графах. На нём же, будем честны, заканчиваются знания о кратчайших путях у большинства профессиональных разработчиков, ибо сценариев, где реализации с википедии/stackoverflow будет не хватать, крайне мало.
Может показаться, что на самом деле просто не было существенного прогресса с 60х годов, так как Дейкстра предоставил почти асимптотически оптимальный алгоритм решения задачи. На самом деле нет, прогресс был и придумали много чего интересного, хоть и действительно с того времени фокус сместился на другие задачи. Приглашаю под кат если интересно узнать что такого напридумывали, что используется в современных логистических системах, почему меня огорчает отсутствие учёта флага единства в HOMM3 при расчёте пути, ну и наконец, что за мужики на картинке выше рядом с Дейкстрой?
Преамбула
Для начала определим задачу формально. Дан ориентированный граф
Путь из
Есть три основные разновидности задачи о кратчайших путях:
SSSP (single source shortest path): найти кратчайшие пути от одной вершины до всех остальных
APSP (all pair shortest path): найти кратчайшие пути от всех вершин до всех
P2P (point to point): найти кратчайший путь от одной вершины до другой
Если для задачи SSSP для каждой вершины зафиксировать по одному кратчайшему пути от истока до этой вершины и обозначить
В дальнейшем в статье я буду использовать математические обозначения для псевдокода где
Dict[int, Dict[int, float]]
В частности graph[u][v]
соответствует длине
Ну и наконец будут картинки с визуализацией на OSM (надеюсь после этого все будут знать как в Санкт-Петербурге дойти от Владимирской до Юбилейного)
Базовый пример в OSM
import osmnx
G = osmnx.graph_from_point((59.93893094417527, 30.32268115454809), dist=2500)
s = 365356365
t = 8518165879
shortest_path = osmnx.routing.shortest_path(G, s, t, weight='length')
osmnx.plot.plot_graph_route(G,
shortest_path,
route_color='g',
node_size=2,
node_color='red',
edge_color='b',
edge_linewidth=0.2,
bgcolor='white')
Классические алгоритмы: Scanning method, Форд-Беллман, Уоршелл-Флойд, Дейкстра
Один из первых значимых алгоритмов для SSSP был предложен Фордом в его работах по сетевым потокам. Из-за его простоты называли его обычно либо scanning method, либо labelling method (примечание: к сожалению в рускоязычной литературе этот метод практически не встречается, поэтому оставил англоязычное название). Метод хранит два непересекающихся множества вершин: помеченные,и обработанные, для каждой вершины
: если то обновить и поместить вершину в помеченные : применить для и поместить вершину в обработанные Основной цикл: пока есть помеченная вершина просканировать её
Код scanning method
from collections import Counter
def relax(graph: Dict[int, Dict[int, float]],
u: int,
v: int,
distances: Dict[int, float],
prev: Dict[int, int],
labelled: Set,
stats: Counter = None):
if stats is not None:
stats["relax_calls"] += 1
if v not in distances or distances[v] > distances[u] + graph[u][v]:
if stats is not None:
stats["distance_updates"] += 1
distances[v] = distances[u] + graph[u][v]
prev[v] = u
labelled.add(v)
def scan(graph: Dict[int, Dict[int, float]],
node: int,
distances: Dict[int, float],
prev: Dict[int, int],
labelled: Set,
stats: Counter = None):
if stats is not None:
stats["scan_calls"] += 1
for other, length in graph[node].items():
relax(graph, node, other, distances, prev, labelled, stats)
# labelled.remove(node)
def scanning_method(graph: Dict[int, Dict[int, float]],
source: int,
target: int = None,
stats: Counter = None):
distances = {source: 0}
prev = dict()
labelled = {source}
while len(labelled) > 0:
u = labelled.pop()
if u == target:
break
scan(graph, u, distances, prev, labelled, stats)
return distances, prev
Этот метод гарантированно сходится при условии существования решения, однако при неудачном выборе порядка вершин для сканирования имеет экспоненциальную сложность. Одной из уникальных особенностей этого метода является то, что подавляющее число появившихся после методов являются его частными случаями и различаются только способом выбора вершины для сканирования.
Доказательство корректности scanning method
Расстояния
не увеличиваются по ходу работы алгоритма Если в какой-то момент
, то существует путь от до с расстоянием . При отсутствии отрицательных циклов этот путь простой (вершины в нем не повторяются) Остановка: каждый раз когда мы помечаем вершину
текущий кратчайший путь до неё изменяется, так как путей конечное число, то и количество раз когда вершина оказывается помеченной тоже ограничено Если вершина
находится в обработанных, то для всех соседних вершин так как после последнего сканирования неравенство выполнялось, а могло только уменьшиться Для
выполняется так как при обновлении расстояния меняется и образуя равенство, а может только уменьшиться Корректность: допустим после остановки алгоритма для нескольких вершин расстояние посчиталось неправильно. Тогда существует пара вершин
, что для первой расстояние неправильное, для второй -- правильное (вторая должна найтись ибо для расстояние посчитано правильно) и при этом минимальный путь от до проходит через . Тогда что противоречит пункту 4 критерию остановки.
Корректность и гарантия остановки -- это хорошо, но хочется при этом гарантии, что алгоритм не будет работать слишком долго, да и вообще хочется понять насколько быстро будет работать алгоритм. Во многом первичный прогресс как по анализу сложности, так и по разработке эффективных методов во многом основан на динамическом программировании -- общей техники для ряда оптимизационных задач, предложенный Ричардом Беллманом. Алгоритм Форда-Беллмана -- первый из алгоритмов, построенном на этом принципе, он делится на несколько стадий. На стадии
Алгоритма Форда-Беллмана
def bellman_ford_moore(graph: Dict[int, Dict[int, float]],
source: int) -> Tuple[Dict[int, float], Dict[int, int]]:
distances = {source: 0}
prev = dict()
changed = True
for k in graph:
if not changed:
break
changed = False
for u, adjacent in graph.items():
if u not in distances:
continue
for v, l in adjacent.items():
if v not in distances or distances[v] > distances[u] + l:
distances[v] = distances[u] + l
prev[v] = u
changed = True
return distances, prev
Позднее Мур заметил, что можно избавиться от лишних действий переписав его в виде scanning method с FIFO порядком обработки вершин (иногда этот метод называют shortest path faster algorithm SPFA , в англоязычной литературе алгоритм обычно обозначается как BFM от Bellman-Ford-Moore).
Довольно быстро идеи динамического программирования подхватили и остальные, больше всего выделяются два: алгоритм Дейкстры для SSSP и алгоритм Флойда-Уоршелла для APSP. Дейкстра заметил следующее: если отсортировать все вершины графа в порядке отдаления от
К сожалению такой структуры так и не придумали, из наиболее применимых на практике -- двоичная куча, с которой алгоритм Дейкстры работает за
Алгоритм Дейкстры
def dijkstra(graph: Dict[int, Dict[int, float]],
source: int,
target: int = None,
stats: Counter = None) -> Tuple[Dict[int, float], Dict[int, int]]:
distances = {source: 0}
prev = dict()
labelled = {source}
while len(labelled) > 0:
u = min(labelled, key=lambda x: distances[x])
labelled.remove(u)
if u == target:
break
scan(graph, u, distances, prev, labelled, stats)
return distances, prev
Еще одним интересным результатом является алгоритм Флойда-Уоршелла также основывающийся на динамическом программировании. Если предположить, что все вершины пронумерованы
Немного про линейное программирование
Задача о кратчайших путях является частным случаем задачи потока минимальной стоимости, которая в свою очередь является частным случаем задачи линейного программирования. Долгое время основным методом решения общих задач линейного программирования был симплекс метод, который не был эффективен для таких задач как поиск кратчайшего пути был слишком громоздким и все перечисленные выше методы были лучше него. Однако сначала в 70х-80х годах Нестеровым и Немировским был разработан метод внутренней точки (interior point method, IPM), который был быстр на практике, и при имел полиномиальную оценку сложности. Позднее этот метод стал основным для решения не только задач линейного программирования, но и более общих. Совсем недавно на его основе были получены ряд улучшений для потоковых задач и, косвенно, для APSP.
Эти работы касаются продвинутой математики, мы её опустим в этой статье, но пару вещей стоит разобрать. Во-первых, если
Интерпретация этой задачи следующая: мы пытаемся пустить единицу потока из
Интерпретация этой задачи следующая: представьте, что ваш граф состоит из небольших шариков (вершины), связанных нитками (рёбра) соответствующей длины. Если потянуть
Ключевой трюк с потенциалами: рассмотрим модифицированные длины
Длина пути в терминах нового расстояния
Таким образом пути, соединяющие одну и ту же пару вершин, отличаются на константу, из чего следует, что кратчайший путь относительно
Усеченный и двунаправленный алгоритмы Дейкстры для P2P
Несмотря на то, что оценка алгоритма Дейкстры с идеальной структурой данных для извлечения минимума имеет неулучшаемую оценку
Пример на OSM
def graph_from_osmnx(G):
graph = dict()
for n, adj in G.adjacency():
if n not in graph:
graph[n] = dict()
for e, eattr in adj.items():
for _, iattr in eattr.items():
if e not in graph[n] or graph[n][e] > iattr["length"]:
graph[n][e] = iattr["length"]
return graph
g = graph_from_osmnx(G)
distances, prev = dijkstra(g, s, t)
def print_route_and_visited(graph, source, target, distances, prev):
route = [target]
cur = target
while cur != source:
cur = prev[cur]
route.append(cur)
return osmnx.plot.plot_graph_route(G.subgraph(list(distances.keys())), list(reversed(route)), route_color='g', node_size=2, node_color='r', edge_color='b', edge_linewidth=0.2, bgcolor='white', close=True);
print_route_and_visited(G, s, t, distances, prev)
Двунаправленный алгоритм Дейкстры -- применение принципа meet-in-the-middle: пытаемся параллельно искать пути с двух концов, от
Обозначим за
То указанный выше критерий остановки становится корректным, т.е. указанная величина будет соответствовать кратчайшему пути.
Доказательство
Предположим, что
Код двунаправленного алгоритма Дейкстры
def reversed_graph(graph: Dict[int, Dict[int, float]]) -> Dict[int, Dict[int, float]]:
rev_graph = {v: dict() for v in graph}
for v, adj in graph.items():
for u, l in adj.items():
rev_graph[u][v] = l
return rev_graph
def bidir_dijkstra(graph: Dict[int, Dict[int, float]],
source: int,
target: int = None,
stats: Counter=None):
rev_graph = reversed_graph(graph)
distances_f = {source: 0}
prev_f = dict()
distances_b = {target: 0}
prev_b = dict()
scanned_f = set()
scanned_b = set()
labelled_f = {source}
labelled_b = {target}
best = float("inf")
best_f = source
best_b = target
u = source
v = target
while len(labelled_f) > 0 or len(labelled_b):
if distances_f[u] < distances_b[v]:
if u in scanned_b:
break
labelled_f.remove(u)
scan(graph, u, distances_f, prev_f, labelled_f, stats)
for p, l in graph[u].items():
if p in distances_b and distances_f[u] + distances_b[p] + l < best:
best_f = u
best_b = p
best = distances_f[u] + distances_b[p] + l
scanned_f.add(u)
u = min(labelled_f, key=lambda x: distances_f[x])
else:
if v in scanned_f:
break
labelled_b.remove(v)
scan(rev_graph, v, distances_b, prev_b, labelled_b, stats)
for p, l in rev_graph[v].items():
if p in distances_f and distances_b[v] + distances_f[p] + l < best:
best_f = p
best_b = v
best = distances_b[v] + distances_f[p] + l
scanned_b.add(v)
v = min(labelled_b, key=lambda x: distances_b[x])
best_path = [best_f]
cur = best_f
while cur != source:
cur = prev_f[cur]
best_path.append(cur)
best_path = list(reversed(best_path))
cur = best_b
if cur != best_path[-1]:
best_path.append(cur)
while cur != target:
cur = prev_b[cur]
best_path.append(cur)
return distances_f, distances_b, prev_f, prev_b, best_path, best
Пример на OSM
distances_f, distances_b, prev_f, prev_b, best_path, best = bidir_dijkstra(g, s, t)
osmnx.plot.plot_graph_route(G.subgraph(list(distances_f.keys()) + list(distances_b.keys())),
best_path,
route_color='g',
node_size=2,
node_color='r',
edge_color='b',
edge_linewidth=0.2,
bgcolor='white')
A*
Как мы обсудили раньше, задачу о кратчайших путях можно эквивалентным образом преобразовать с помощью потенциалов. Джонсон использовал это для того, чтобы была возможность применить алгоритм Дейкстры к APSP c отрицательными рёбрами. Для APSP/SSSP этот трюк к сожалению кроме неотрицательности рёбер ничего дать не может. А вот с P2P ситуация иная: при использовании разных потенциалов усеченный алгоритм Дейкстры может просматривать разное количество вершин.
Теорема 1. Если допустимые потенциалы
Доказательство
По сути нужно доказать, что если длина кратчайшего
Это в частности доказывает, что 1) если применить нетривиальные потенциалы к SSSP с неотрицательными весами, то усеченный Дейкстра с потенциалами будет не хуже, чем обычный; 2) Чем лучше потенциалы, тем лучше алгоритм.
Отметим, что при если
Пример с OSM, потенциалы взяты как евклидово (если быть точнее геодезическое) расстояние
import math
import geopy.distance
def geo_dist(graph, u, v):
coords_1 = (graph.nodes[u]['y'], graph.nodes[u]['x'])
coords_2 = (graph.nodes[v]['y'], graph.nodes[v]['x'])
return geopy.distance.distance(coords_1, coords_2).m
def a_star(graph: Dict[int, Dict[int, float]],
source: int,
target: int,
graphnx,
stats: Counter=None) -> Tuple[Dict[int, float], Dict[int, int]]:
distances = {source: 0}
prev = dict()
potentials = dict()
labelled = {source}
while len(labelled) > 0:
u = -1
best = -1
for v in labelled:
if v not in potentials:
potentials[v] = geo_dist(graphnx, v, target)
if u == -1 or distances[v] + potentials[v] < best:
best = potentials[v] + distances[v]
u = v
labelled.remove(u)
if u == target:
break
scan(graph, u, distances, prev, labelled, stats)
return distances, prev
distances, prev = a_star(g, s, t, G)
print_route_and_visited(G, s, t, distances, prev)
Насколько же хорош может быть A*? Как оказывается, с идеальными потенциалами мы можем получить оптимальный алгоритм.
Теорема 2. Если
Доказательство
Если
Несмотря на то, что теорема может показаться бессмысленной, так как если у нас уже есть кратчайшие расстояние до
Двунаправленный A*
Как и с двунаправленным Дейкстрой есть небольшая дополнительная подковырка в случае с А*. Дело в том, что если взять два произвольных потенциала для прямого и обратного прохода, то критерий остановки двунаправленного Дейкстры становится некорректным. Критерий будет корректным, если потребовать от потенциалов для прямого и обратного прохода
Код двунаправленного А* и пример с OSM
def bidir_a_star(graph: Dict[int, Dict[int, float]],
source: int,
target: int,
graphnx,
stats: Counter=None):
rev_graph = reversed_graph(graph)
distances_f = {source: 0}
prev_f = dict()
distances_b = {target: 0}
prev_b = dict()
scanned_f = set()
scanned_b = set()
labelled_f = {source}
labelled_b = {target}
best = float("inf")
best_f = source
best_b = target
u = source
v = target
potentials = dict()
while len(labelled_f) > 0 or len(labelled_b):
if distances_f[u] < distances_b[v]:
if u in scanned_b:
break
labelled_f.remove(u)
scan(graph, u, distances_f, prev_f, labelled_f, stats)
for p, l in graph[u].items():
if p in distances_b and distances_f[u] + distances_b[p] + l < best:
best_f = u
best_b = p
best = distances_f[u] + distances_b[p] + l
scanned_f.add(u)
for x in labelled_f:
if x not in potentials:
potentials[x] = 0.5 * (geo_dist(graphnx, x, target) - geo_dist(graphnx, x, source))
u = min(labelled_f, key=lambda x: distances_f[x] + potentials[x])
else:
if v in scanned_f:
break
labelled_b.remove(v)
scan(rev_graph, v, distances_b, prev_b, labelled_b, stats)
for p, l in rev_graph[v].items():
if p in distances_f and distances_b[v] + distances_f[p] + l < best:
best_f = p
best_b = v
best = distances_b[v] + distances_f[p] + l
scanned_b.add(v)
for x in labelled_b:
if x not in potentials:
potentials[x] = 0.5 * (geo_dist(graphnx, x, target) - geo_dist(graphnx, x, source))
v = min(labelled_b, key=lambda x: distances_b[x] - potentials[x])
best_path = [best_f]
cur = best_f
while cur != source:
cur = prev_f[cur]
best_path.append(cur)
best_path = list(reversed(best_path))
cur = best_b
if cur != best_path[-1]:
best_path.append(cur)
while cur != target:
cur = prev_b[cur]
best_path.append(cur)
return distances_f, distances_b, prev_f, prev_b, best_path, best
fig, ax = osmnx.plot.plot_graph_route(G.subgraph(list(distances_f.keys()) + list(distances_b.keys())),
best_path,
route_color='g',
node_size=2,
node_color='r',
edge_color='b',
edge_linewidth=0.2,
bgcolor='white')
Бонус: несколько кратчайших путей и граф почти оптимальных путей
Есть две интересные вспомогательные вещи, которые можно делать с помощью потенциалов и двунаправленного прохода.
Первая -- найти
Код
import heapq
def k_shortest_path_looply(graph: Dict[int, Dict[int, float]],
source: int,
target: int,
k: int,
min_loop_length: int = 1) -> List[List[int]]:
distances_b, _ = dijkstra(reversed_graph(graph), target)
result = []
sorted_adjacency = dict() # Dict[List[Tuple[int, float]]]
entries = [(source, -1, 0)] # vertex_id, parent entry, sorted adjacency position
path_queue = [(0, 0)] # reduced length, -entry_id
while len(result) < k:
length, entry_id_rev = heapq.heappop(path_queue)
entry_id = -entry_id_rev
v, parent_entry, pos = entries[entry_id]
if v == target:
cur = entry_id
path = [target]
while cur != 0:
cur = entries[cur][1]
path.append(entries[cur][0])
result.append(list(reversed(path)))
if v not in sorted_adjacency:
sorted_adjacency[v] = list(sorted(graph[v].items(), key=lambda x: x[1] + distances_b[x[0]]))
if parent_entry != -1 and pos < len(sorted_adjacency[entries[parent_entry][0]]) - 1:
u, length = sorted_adjacency[entries[parent_entry][0]][pos + 1]
cur = parent_entry
if not has_cycle:
entries.append((u, parent_entry, pos + 1))
heapq.heappush(path_queue, (length + distances_b[u] - distances_b[entries[parent_entry][0]], 1 - len(entries)))
if len(sorted_adjacency) > 0:
u, length = sorted_adjacency[v][0]
has_cycle = False
cur = entry_id
for i in range(min_loop_length - 1):
if cur == 0:
break
if entries[cur][0] == u:
has_cycle = True
break
cur = entries[cur][1]
if not has_cycle:
entries.append((u, entry_id, 0))
heapq.heappush(path_queue, (length + distances_b[u] - distances_b[v], 1 - len(entries)))
return result
К сожалению эта версию допускает циклы и часто на реальных графах, побочные пути -- это кратчайший путь с небольшим циклом.
Вторая -- найти подграф, который содержит все пути, которые хуже, чем кратчайший, не больше, чем на
Код и пример OSM
def best_path_subgraph(graph: Dict[int, Dict[int, float]],
source: int,
target: int,
beam: float) -> List[Tuple[int, int]]:
distances_f, _ = dijkstra(graph, source)
distances_b, _ = dijkstra(reversed_graph(graph), target)
best = distances_f[target]
edges = []
for u, adj in graph.items():
if u not in distances_f:
continue
for v, length in adj.items():
if v not in distances_b:
continue
if distances_f[u] + length + distances_b[v] < best + beam:
edges.append((u, v))
return edges
edges = best_path_subgraph(g, s, t, 500)
fig, ax = osmnx.plot.plot_graph(G.edge_subgraph([(u, v, 0) for u, v in edges]),
node_size=2,
node_color='r',
edge_color='b',
edge_linewidth=0.2,
bgcolor='white')
ALT
Последний в этой статье (но не последний известный науке) алгоритм -- продолжение концепции потенциалов и A*. В примерах я использовал естественным образом возникающее евклидово расстояние в качестве потенциалов, но остаётся вопрос, можно ли придумать потенциалы лучше? На практике PTP задача применяется в формате, что у нас есть очень много запросов на большом графе, предпосчитать их возможности нет, но есть возможность сделать какой-то более простой предпосчет. В целом можно считать, что в P2P мы на самом деле хотим структуру данных, которая тратит
Такие вершины называют ориентирами (landmarks, не уверен есть ли официальный рускоязычный термин). Хорошие ориентиры получаются из вершин, находящихся в отдалении. В примерах, которые я рассматривал, хорошим ориентиром будет вершина на границе области. Последний штрих -- это возможность использовать несколько ориентиров
Теорема 3. Если
Доказательство
применяем максимум к левой и правой части, получаем то, что нужно.
Код
def alt(graph: Dict[int, Dict[int, float]],
source: int,
target: int,
landmark_distances,
stats: Counter=None):
rev_graph = reversed_graph(graph)
distances_f = {source: 0}
prev_f = dict()
distances_b = {target: 0}
prev_b = dict()
scanned_f = set()
scanned_b = set()
labelled_f = {source}
labelled_b = {target}
best = float("inf")
best_f = source
best_b = target
u = source
v = target
potentials = dict()
while len(labelled_f) > 0 or len(labelled_b):
if distances_f[u] < distances_b[v]:
if u in scanned_b:
break
labelled_f.remove(u)
scan(graph, u, distances_f, prev_f, labelled_f, stats)
for p, l in graph[u].items():
if p in distances_b and distances_f[u] + distances_b[p] + l < best:
best_f = u
best_b = p
best = distances_f[u] + distances_b[p] + l
scanned_f.add(u)
for x in labelled_f:
if x not in potentials:
potential_f = 0
potential_b = 0
for p in landmark_distances:
potential_f = max(potential_f, landmark_distances[p][target] - landmark_distances[p][x])
potential_b = max(potential_b, landmark_distances[p][x] - landmark_distances[p][source])
potentials[x] = 0.5 * (potential_f - potential_b)
u = min(labelled_f, key=lambda x: distances_f[x] + potentials[x])
else:
if v in scanned_f:
break
labelled_b.remove(v)
scan(rev_graph, v, distances_b, prev_b, labelled_b, stats)
for p, l in rev_graph[v].items():
if p in distances_f and distances_b[v] + distances_f[p] + l < best:
best_f = p
best_b = v
best = distances_b[v] + distances_f[p] + l
scanned_b.add(v)
for x in labelled_b:
if x not in potentials:
potential_f = 0
potential_b = 0
for p in landmark_distances:
potential_f = max(potential_f, landmark_distances[p][target] - landmark_distances[p][x])
potential_b = max(potential_b, landmark_distances[p][x] - landmark_distances[p][source])
potentials[x] = 0.5 * (potential_f - potential_b)
v = min(labelled_b, key=lambda x: distances_b[x] - potentials[x])
best_path = [best_f]
cur = best_f
while cur != source:
cur = prev_f[cur]
best_path.append(cur)
best_path = list(reversed(best_path))
cur = best_b
if cur != best_path[-1]:
best_path.append(cur)
while cur != target:
cur = prev_b[cur]
best_path.append(cur)
return distances_f, distances_b, best_path, best
Пример на OSM
Бонус: pathfinding в играх
В опенсорс игре Battle for Wesnoth юниты передвигаются по гексагональному миру, каждый юнит может передвинуться на несколько хексов за ход, некоторые хексы требуют большое очков передвижения. В общем, ничего особенного, для поиска пути используется А*.
Heroes of might and magic 3 карта делится на квадратные тайлы, передвигаться можно либо по горизонтали (100 очков передвижения), либо по диагонали (141 очков передвижения). Также есть штрафы за местность. В общем то тоже ничего особенного, но в игре есть объекты, которые увеличивают количество очков передвижения (например флаг единства) и pathfinding не учитывает эти объекты, а казалось бы всего-то надо научиться решать задачу о кратчайших путях с отрицательными рёбрами.
Стратегии реального времени как оказывается тоже для поиска пути делят карту на квадратные тайлы и используют специальные версии A*. В более старых стратегиях это приводило к тому, что юниты передвигались горизонтально-вертикально-диагонально, но не по прямой.
В общем то где-то между вторым и третьим варкрафтом таки научились ходить по прямой, зачастую просто с помощью сглаживания пути после его нахождения с помощью А*.
Итог
В статью не вошли свежие методы такие как contraction hierarchies и hub labeling потому что я заколебался писать. Для показанных методов есть статистика производительности по число вызванных scan, relax и количество обновлений кратчайших путей. Эта статистика не может считаться полноценным бенчмарком, но в целом даёт хороший ориентир
+----------------+-------------+------------------+------------+
| Method | Relax calls | Distance updates | Scan calls |
+----------------+-------------+------------------+------------+
| basic | 1060170 | 437690 | 385509 |
| dijkstra | 81090 | 34065 | 29730 |
| bidir_dijkstra | 52701 | 22416 | 19307 |
| a_star | 13100 | 5904 | 4764 |
| bidir_a_star | 8858 | 4160 | 3203 |
| alt | 1231 | 671 | 463 |
+----------------+-------------+------------------+------------+
Список литературы
Bellman, R. (1958). On a routing problem. Quarterly of Applied Mathematics, 16(1), 87–90.
Cohen, M. B., Mądry, A., Sankowski, P., & Vladu, A. (2017). Negative-weight shortest paths and unit capacity minimum cost flow in õ (m 10/7 log w) time*. Proceedings of the Twenty-Eighth Annual ACM-SIAM Symposium on Discrete Algorithms, 752–771.
Dantzig, G. B. (1962). Linear programming and extensions.
Dial, R. B. (1969). Algorithm 360: shortest-path forest with topological ordering [H]. Commun. ACM, 12(11), 632–633. https://doi.org/10.1145/363269.363610
Dijkstra, E. W. (1959). A note on two problems in connexion with graphs. Numerische Mathematik, 50, 269–271.
Doran, J. (1967). An approach to automatic problem-solving. Machine Intelligence, 1, 105–127.
Edmonds, J., & Karp, R. M. (1972). Theoretical improvements in algorithmic efficiency for network flow problems. Journal of the ACM (JACM), 19(2), 248–264.
Egerváry, E. (1931). On combinatorial properties of matrices.
Floyd, R. W. (1962). Algorithm 97: Shortest path. Commun. ACM, 5(6), 345. https://doi.org/10.1145/367766.368168
Ford, L. R. (1956). Network flow theory.
Ford, L. R., & Fulkerson, D. (1962). Flows in Networks (Vol. 56). Princeton University Press.
Goldberg, A. (2005). Basic shortest path algorithms. DIKU Summer School on Shortest Paths. Microsoft Research. Silicon Valey.
Goldberg, A. V., & Harrelson, C. (2005). Computing the shortest path: A search meets graph theory. SODA, 5, 156–165.
Hart, P. E., Nilsson, N. J., & Raphael, B. (1968). A Formal Basis for the Heuristic Determination of Minimum Cost Paths. IEEE Transactions on Systems Science and Cybernetics, 4(2), 100–107. https://doi.org/10.1109/TSSC.1968.300136
Hsu, B.-J., & Ottaviano, G. (2013). Space-efficient data structures for top-k completion. Proceedings of the 22nd International Conference on World Wide Web, 583–594.
Ikeda, T., & Imai, H. (1994). Fast a algorithms for multiple sequence alignment. Genome Informatics, 5, 90–99.
Johnson, D. B. (1977). Efficient algorithms for shortest paths in sparse networks. Journal of the ACM (JACM), 24(1), 1–13.
Le, P. T. H., Truong, N. T. N., Kim, M., So, W., & Jung, J. H. (2018). Applying Theta* in Modern Game. J. Comput., 13(5), 527–536.
Moore, E. F. (1959). The shortest path through a maze. Proc. of the International Symposium on the Theory of Switching, 285–292.
Nicholson, T. A. J. (1966). Finding the shortest route between two points in a network. The Computer Journal, 9(3), 275–280.
Pohl, I. (1971). Bi-directional Search. Machine Intelligence, 6, 124–140.
Весь код в одном jupyter noteboook
Можно позапускать онлайн в биндере если не хочется локально настраивать питон
P. S.
Я не знаю, связано ли это с обширным использованием формул или чем-то ещё, но работа с формулами в WYSIWYG -- это адская боль. Уже недавно на хабре писали, что задача центрирования неразрешима, но есть еще более сложная задача: превращение -- в