# теория · шаг 3 из 7
Путь отряда: куча, A* и честная эвристика
Коротко
Отряд идёт по карте, где луг стоит 1, лес 3, а горы непроходимы. Дейкстра с кучей из раздела 11 находит путь ценой 46, раскрыв 359 клеток из 400. A с эвристикой «манхэттенское расстояние» находит тот же путь за 258: эвристика честна - не больше настоящего остатка, - потому что дешевле луга клетки нет. Завышенная втрое эвристика раскрывает всего 41 клетку, но путь стоит 52: честность эвристики - условие кратчайшего ответа. Если цель недостижима, A обходит всю свою компоненту - 337 клеток. Метки компонент, посчитанные один раз на карту, отвечают «пути нет» без поиска.
Если ты знаешь, почему эвристика A* должна быть не больше остатка пути и что делать с недостижимой целью, листай до «Теперь сам».
Как думаешь, что даст эвристика быстрее
У отряда есть цель в противоположном углу карты 20 × 20. Дейкстра расходится от старта кругом и раскрывает почти всю карту. Если добавить к приоритету клетки «сколько ещё идти по прямой», поиск потянется к цели. А если добавить «втрое больше, чем идти по прямой», - потянется ещё сильнее. Что потеряем?
Ответь себе до чтения. Сильнее тянуть к цели - меньше клеток раскрыть, и это правда. Но тогда поиск слишком рано решает, что обход через луг «не стоит того», и может пройти прямо через лес. Эвристика, которая обещает больше, чем на самом деле осталось, отсекает настоящий кратчайший путь. Правило игры в этой точке простое: эвристика не должна переоценивать.
На что это похоже
Навигатор в машине. Он предлагает ехать «в сторону дома», и почти всегда это верно. Но если он думает, что каждый километр в сторону от дома стоит втрое дороже, чем на деле, он упрямо поведёт тебя по пробке напрямик, хотя объезд по кольцу быстрее. Чем увереннее навигатор в прямом пути, тем быстрее он считает - и тем чаще ошибается.
Механизм: f = g + h и условие честности
A из раздела 11 берёт из кучи клетку с наименьшим f = g + h: g - пройденная цена, h - оценка
остатка. Эвристика честная (допустимая), если h никогда не больше настоящей цены остатка - тогда первый
раз, когда цель достаётся из кучи, найден кратчайший путь. Программа ниже не открывает обработанную клетку
повторно, и для этого нужно больше - согласованность* из раздела 11: h(v) ≤ цена хода в u + h(u) для
каждого соседа. Манхэттен, умноженный на цену самой дешёвой клетки, согласован: шаг уменьшает его не больше
чем на эту цену.
Для карты «Архипелага» честную эвристику строят из двух фактов: отряд ходит по четырём сторонам, и самая
дешёвая клетка стоит 1. Значит, любой путь из (r, c) в цель делает хотя бы |r − r₀| + |c − c₀| шагов, и
каждый стоит хотя бы 1 - манхэттенское расстояние не больше настоящего остатка. Если бы на карте была
дорога ценой 0,5, честной была бы только половина манхэттенского расстояния: эвристику масштабируют по
самой дешёвой клетке, а не по типичной.
Цена стартовой клетки в путь не входит: g старта - 0. Ключ кучи - одно число f · 1000 + клетка, цены
целые (дорогу ценой 0,5 делают ценой 1, а остальные цены удваивают), клеток меньше тысячи; на карте 128 ×
128 множитель берут не меньше 16 384 - числа клеток, - иначе номер клетки при распаковке испортится. Когда
клетке нашли путь короче, в кучу кладут новую запись, а старую не удаляют: она «устареет» и будет пропущена
при извлечении, если клетка уже обработана.
Правило
Эвристика A должна быть честной: не больше настоящей цены остатка. На клеточной карте это манхэттенское расстояние, умноженное на цену самой дешёвой клетки. Завышенная эвристика ускоряет поиск, но перестаёт гарантировать кратчайший путь; недостижимую цель A не отличит от далёкой, пока не обойдёт всю компоненту.
Разбор: три поиска на одной карте
Карта 20 × 20: каждая клетка с вероятностью 12% гора, 33% лес, остальное луг. Ищем путь из угла в угол тремя способами.
import random
N = 20
random.seed(41)
karta = [] # цена входа: 1 луг, 3 лес, 0 - гора
for r in range(N):
stroka = []
for c in range(N):
x = random.random()
if x < 0.12:
stroka.append(0)
elif x < 0.45:
stroka.append(3)
else:
stroka.append(1)
karta.append(stroka)
START = 0 # клетка - число r * N + c
CEL = N * N - 1
karta[0][0] = 1
karta[N - 1][N - 1] = 1
def v_kuchu(k, x):
k.append(x)
i = len(k) - 1
while i > 0 and k[(i - 1) // 2] > k[i]:
k[i], k[(i - 1) // 2] = k[(i - 1) // 2], k[i]
i = (i - 1) // 2
def iz_kuchi(k):
verh = k[0]
k[0] = k[len(k) - 1]
k.pop()
i = 0
while True:
m = i
for d in [2 * i + 1, 2 * i + 2]:
if d < len(k) and k[d] < k[m]:
m = d
if m == i:
return verh
k[i], k[m] = k[m], k[i]
i = m
def poisk(ves_h):
# A* с эвристикой ves_h · (манхэттенское расстояние до цели);
# ves_h = 0 - это Дейкстра. Ключ кучи: f * 1000 + клетка
g = {}
g[START] = 0
kucha = [0 * 1000 + START]
raskryto = 0
gotovo = set()
while len(kucha) > 0:
v = iz_kuchi(kucha) % 1000
if v in gotovo:
continue # устаревшая запись в куче
gotovo.add(v)
raskryto = raskryto + 1
if v == CEL:
return [g[v], raskryto]
r = v // N
c = v % N
for d in [[-1, 0], [1, 0], [0, -1], [0, 1]]:
nr = r + d[0]
nc = c + d[1]
if 0 <= nr < N and 0 <= nc < N and karta[nr][nc] > 0:
u = nr * N + nc
ng = g[v] + karta[nr][nc]
if u not in g or ng < g[u]:
g[u] = ng
h = ves_h * (abs(N - 1 - nr) + abs(N - 1 - nc))
v_kuchu(kucha, (ng + h) * 1000 + u)
return [-1, raskryto]
print("Дейкстра: ", poisk(0),
"- [цена пути, раскрыто клеток]")
print("A*, h = расстояние: ", poisk(1))
print("A*, h = 3·расстояние:", poisk(3))
Дейкстра: [46, 359] - [цена пути, раскрыто клеток]
A*, h = расстояние: [46, 258]
A*, h = 3·расстояние: [52, 41]
Дейкстра раскрыла 359 клеток - почти все доступные. A* с честной эвристикой нашёл путь той же цены 46,
раскрыв 258, на 28% меньше. Это и есть цена, которую даёт эвристика при честности: путь не хуже, работы
меньше. Программа печатает только цену: сам путь, нужный отряду, восстанавливают по родителям, как в разделе
11, - для этого рядом с g хранят, откуда пришли в клетку. Завышенная втрое эвристика раскрыла всего 41
клетку - почти идёт по прямой, - но её путь стоит 52, на 13% дороже. Есть известная граница: если эвристику
умножить на w, найденный путь не дороже w кратчайших. Иногда это выгодный обмен - для отрядов, которые
идут далеко и «примерно» сойдёт, - но это решение дизайнера, а не оптимизация незаметно для него.
Разбор: недостижимая цель и метки компонент
На другой карте цель в углу отрезана горами. A* об этом не знает - он ищет, пока куча не опустеет. Метки компонент из урока о генерации отвечают на «достижима ли» до поиска: один обход по всей карте раздаёт клеткам номера компонент, и дальше каждый запрос - одно сравнение.
import random
N = 20
random.seed(44)
karta = [] # цена входа: 1 луг, 3 лес, 0 - гора
for r in range(N):
stroka = []
for c in range(N):
x = random.random()
if x < 0.12:
stroka.append(0)
elif x < 0.45:
stroka.append(3)
else:
stroka.append(1)
karta.append(stroka)
karta[0][0] = 1
karta[N - 1][N - 1] = 1
def v_kuchu(k, x):
k.append(x)
i = len(k) - 1
while i > 0 and k[(i - 1) // 2] > k[i]:
k[i], k[(i - 1) // 2] = k[(i - 1) // 2], k[i]
i = (i - 1) // 2
def iz_kuchi(k):
verh = k[0]
k[0] = k[len(k) - 1]
k.pop()
i = 0
while True:
m = i
for d in [2 * i + 1, 2 * i + 2]:
if d < len(k) and k[d] < k[m]:
m = d
if m == i:
return verh
k[i], k[m] = k[m], k[i]
i = m
def poisk(START, CEL):
# A* с эвристикой «манхэттенское расстояние до цели»
g = {}
g[START] = 0
kucha = [START]
raskryto = 0
gotovo = set()
while len(kucha) > 0:
v = iz_kuchi(kucha) % 1000
if v in gotovo:
continue # устаревшая запись в куче
gotovo.add(v)
raskryto = raskryto + 1
if v == CEL:
return [g[v], raskryto]
r = v // N
c = v % N
for d in [[-1, 0], [1, 0], [0, -1], [0, 1]]:
nr = r + d[0]
nc = c + d[1]
if 0 <= nr < N and 0 <= nc < N and karta[nr][nc] > 0:
u = nr * N + nc
ng = g[v] + karta[nr][nc]
if u not in g or ng < g[u]:
g[u] = ng
h = abs(CEL // N - nr) + abs(CEL % N - nc)
v_kuchu(kucha, (ng + h) * 1000 + u)
return [-1, raskryto]
def metki():
# один обход по всей карте: номер компоненты у каждой клетки
m = [-1] * (N * N)
nomer = 0
for v in range(N * N):
if m[v] == -1 and karta[v // N][v % N] > 0:
m[v] = nomer
och = [v]
golova = 0
while golova < len(och):
x = och[golova]
golova = golova + 1
for u in [x - N, x + N, x - 1, x + 1]:
# сосед: та же строка или тот же столбец
ryadom = u // N == x // N or u % N == x % N
if 0 <= u < N * N and ryadom and m[u] == -1:
if karta[u // N][u % N] > 0:
m[u] = nomer
och.append(u)
nomer = nomer + 1
return m
zaprosy = [[0, N * N - 1], [0, 5 * N + 7], [N * N - 1, 2 * N]]
komp = metki()
for z in zaprosy:
# гора компоненты не имеет (-1): две горы не «связаны» между собой
dostizhima = komp[z[0]] != -1 and komp[z[0]] == komp[z[1]]
print("из", z[0], "в", z[1], "| одна компонента:", dostizhima,
"| A*:", poisk(z[0], z[1]))
из 0 в 399 | одна компонента: False | A*: [-1, 337]
из 0 в 107 | одна компонента: True | A*: [14, 27]
из 399 в 40 | одна компонента: False | A*: [-1, 2]
Первый запрос - худший случай A*: цели нет в компоненте старта, и поиск раскрыл все 337 клеток этой компоненты, прежде чем сдался. Метки ответили бы «нет» сразу. Третий запрос стартует из угла 399 в крошечном кармане: там две клетки, поиск сдался быстро - но лишь потому, что повезло с компонентой старта. Метки считаются один раз на карту и обновляются, когда карта меняется. Граница правила: если горы рушатся и строятся каждый ход, метки придётся пересчитывать; на добавлении переходов поможет объединение непересекающихся множеств, а на удалении - нет, и тогда метки пересчитывают целиком.
Что ломается без этого
Эвристика по типичной клетке. На карте появилась дорога ценой 0,5, а эвристика осталась «расстояние × 1» - и отряды перестали её замечать: путь по дороге казался дороже, чем он есть.
Поиск в недостижимую цель. Игрок кликает по острову без моста - и сервер на каждом таком клике обходит весь материк. Двадцать кликов в секунду - и ход затянулся.
Удаление из кучи. Попытка честно удалить устаревшую запись из середины кучи стоит линейного поиска по куче на каждую вставку; ленивое удаление при извлечении поиска не требует, а платит лишь памятью под устаревшие записи и их пустым извлечением.
Зачем это в работе
Навигация роботов на складе, маршрутизация в сети с метриками, поиск ходов в головоломках - везде, где цель известна и есть дешёвая нижняя оценка остатка, A* экономит работу без потери точности. А проверка «достижима ли цель» по заранее посчитанным компонентам - стандартный фильтр перед дорогим поиском.
Теперь сам
- На карте добавили болото ценой 5 и дорогу ценой 1/2. Какую эвристику взять, чтобы она осталась честной?
- Почему метка компоненты отвечает на «достижима ли», но не на «как далеко»?
- Эвристику умножили на 1,5. Насколько дороже кратчайшего может оказаться найденный путь?
Ответ. 1. Манхэттенское расстояние, умноженное на 1/2 - цену самой дешёвой клетки. Болото на честность не влияет: оно только дороже.
-
Метка - одна для всей компоненты: две клетки с одной меткой могут быть рядом или на разных концах материка. Расстояние зависит от пары клеток, и его даёт только поиск или поле расстояний.
-
Не дороже, чем в 1,5 раза кратчайшего.
Главное
Эвристика A* ускоряет поиск, пока она честна - не больше настоящего остатка. А недостижимость цели дешевле узнать по меткам компонент, чем по исчерпанному поиску.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий