Алгоритми
📅 15 березня 2026 ⏱ ≈ 10 хв читання ⭐ Середній · Останнє оновлення: 23 червня 2026 р.

Алгоритм пошуку шляху A* —
від теорії до JavaScript

A* поєднує гарантовано оптимальний пошук Дейкстри з евристикою, що спрямовує його до цілі. Результат: оптимальні шляхи, знайдені на порядки швидше за повний перебір на сітках, картах і ігрових світах. Ось як він працює — і як реалізувати його з нуля.

1. Задача про найкоротший шлях

Задано граф G = (V, E), де кожне ребро (u, v) має невід'ємну вартість w(u,v); потрібно знайти шлях мінімальної вартості від початкового вузла s до цільового вузла g.

Для ігрових сіток граф неявний: клітинки — це вузли, ортогональні/діагональні сусіди — ребра з вартістю 1 (або √2 для діагоналей). Перешкоди — це просто відсутні ребра. Той самий код A* працює на явних графах (дорожні мережі, схеми метро), якщо надати список сусідів замість пошуку на сітці.

Пошук у ширину (BFS)

Знаходить найкоротший шлях за кількістю переходів на незважених графах. O(V+E). Досліджує всі напрямки однаково.

Дейкстра

Оптимальний на зважених графах. Досліджує в порядку накопиченої вартості g від старту. O((V+E) log V) з купою.

Жадібний пошук

Розгортає вузол з найменшою евристикою h. Дуже швидкий, але не оптимальний — ігнорує вже сплачену вартість.

A*

Розгортає за f = g + h. Оптимальний і ефективний. Дейкстра при h=0; жадібний при g=0.

2. Алгоритм A*

A* присвоює кожному вузлу n оцінку пріоритету:

f(n) = g(n) + h(n)

g(n) — точна вартість найдешевшого шляху від старту до n, знайденого досі
h(n) — евристична оцінка вартості від n до цілі
f(n) — оцінена повна вартість найдешевшого шляху через n

Алгоритм підтримує два набори:

На кожному кроці A* витягує з відкритого набору вузол з найменшим f, розгортає його сусідів, і для кожного сусіда обчислює кандидатне g = g(поточний) + w(поточний, сусід). Якщо це краще за будь-який раніше відомий шлях до цього сусіда, оновлює його та додає до черги.

Ключова гарантія: коли A* витягує цільовий вузол, шлях, відновлений за вказівниками cameFrom, є оптимальним (найдешевшим) — за умови, що евристика допустима (ніколи не завищує справжню вартість).

Наочний хід на сітці 5×5

S = старт (0,0), G = ціль (4,4), стіни сірі. Числа в клітинках показують f-оцінку. Синій = закритий (вже розгорнутий), зелений = відкритий (у черзі), помаранчевий = фінальний шлях.

S
f=0
f=3
f=4
f=5
f=6
f=3
f=5
f=5
f=4
f=4
f=5
f=5
f=5
f=5
f=4
f=4
f=4
f=6
f=6
f=5
f=5
G
Старт
Ціль
Закритий (розгорнутий)
Відкритий (у черзі)
Оптимальний шлях
Стіна

3. Евристичні функції

Евристика h(n) є допустимою, якщо h(n) ≤ h*(n) для всіх n, де h*(n) — справжня оптимальна вартість від n до цілі. Допустимість гарантує, що A* знайде оптимальний шлях. Узгоджена (монотонна) евристика також задовольняє h(n) ≤ w(n,n') + h(n') для кожного ребра — це означає, що значення f ніколи не спадає вздовж шляху, спрощуючи логіку закритого набору.

Манхеттенська відстань

h = |Δx| + |Δy|

Допустима на 4-напрямкових сітках (без діагоналей). Найпоширеніший вибір для ігрових карт. Швидко обчислюється.

Евклідова відстань

h = √(Δx² + Δy²)

Допустима для руху під будь-яким кутом. Трохи недооцінює на 4-напрямкових сітках (евклідова < манхеттенської).

Відстань Чебишова

h = max(|Δx|, |Δy|)

Для 8-напрямкового руху (діагоналі коштують 1). Допустима й узгоджена для 8-напрямкових сіток з рівною вартістю.

Октильна відстань

h = max(|Δx|,|Δy|) + (√2−1)·min(|Δx|,|Δy|)

Точна для 8 напрямків з діагоналями вартістю √2. Найтісніша допустима h для цього випадку.

Недопустимі евристики: використання h > h* (наприклад, множення евристики на сталу w > 1) дає зважений A*. Він знаходить шлях вартістю щонайбільше у w разів більшою за оптимальну, але працює значно швидше. Цей компроміс поширений в ігровому ШІ, де «достатньо добре в реальному часі» перемагає «оптимально, але повільно».

4. Реалізація на JavaScript

Наведена нижче реалізація працює на плоскому масиві-сітці, де grid[y * width + x] дорівнює true для прохідних клітинок. Вона повертає оптимальний шлях як масив об'єктів {x, y} або null, якщо шляху немає.

// MinHeap — проста двійкова купа з ключем за .f
class MinHeap {
  constructor() { this.data = []; }
  push(node) {
    this.data.push(node);
    this._bubbleUp(this.data.length - 1);
  }
  pop() {
    const top = this.data[0];
    const last = this.data.pop();
    if (this.data.length) { this.data[0] = last; this._siftDown(0); }
    return top;
  }
  get size() { return this.data.length; }
  _bubbleUp(i) {
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.data[p].f <= this.data[i].f) break;
      [this.data[p], this.data[i]] = [this.data[i], this.data[p]];
      i = p;
    }
  }
  _siftDown(i) {
    const n = this.data.length;
    while (true) {
      let min = i, l = 2*i+1, r = 2*i+2;
      if (l < n && this.data[l].f < this.data[min].f) min = l;
      if (r < n && this.data[r].f < this.data[min].f) min = r;
      if (min === i) break;
      [this.data[min], this.data[i]] = [this.data[i], this.data[min]];
      i = min;
    }
  }
}

// Евристика октильної відстані (8 напрямків, діагональ = √2)
function octile(ax, ay, bx, by) {
  const dx = Math.abs(ax - bx), dy = Math.abs(ay - by);
  return Math.max(dx, dy) + (Math.SQRT2 - 1) * Math.min(dx, dy);
}

/**
 * @param {boolean[]} grid  - плоский масив, true = прохідна
 * @param {number}    width - ширина сітки
 * @param {{x,y}}     start - старт
 * @param {{x,y}}     goal  - ціль
 * @param {boolean}   [diag=true] - дозволити діагональний рух
 * @returns {{x,y}[]|null}
 */
function aStar(grid, width, start, goal, diag = true) {
  const height = grid.length / width;
  const key = (x, y) => y * width + x;

  const gScore = new Float32Array(grid.length).fill(Infinity);
  const cameFrom = new Int32Array(grid.length).fill(-1);
  const closed  = new Uint8Array(grid.length);

  const heap = new MinHeap();
  const sk = key(start.x, start.y);
  gScore[sk] = 0;
  heap.push({ x: start.x, y: start.y, f: octile(start.x, start.y, goal.x, goal.y) });

  const dirs = diag
    ? [[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]]
    : [[0,-1],[-1,0],[1,0],[0,1]];

  while (heap.size) {
    const cur = heap.pop();
    if (cur.x === goal.x && cur.y === goal.y) return reconstruct(cameFrom, width, key(cur.x, cur.y));
    const ck = key(cur.x, cur.y);
    if (closed[ck]) continue;
    closed[ck] = 1;
    for (const [dx, dy] of dirs) {
      const nx = cur.x + dx, ny = cur.y + dy;
      if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
      const nk = key(nx, ny);
      if (!grid[nk] || closed[nk]) continue;
      const moveCost = (dx !== 0 && dy !== 0) ? Math.SQRT2 : 1;
      const tentG = gScore[ck] + moveCost;
      if (tentG < gScore[nk]) {
        gScore[nk] = tentG;
        cameFrom[nk] = ck;
        heap.push({ x: nx, y: ny, f: tentG + octile(nx, ny, goal.x, goal.y) });
      }
    }
  }
  return null; // шляху немає
}

function reconstruct(cameFrom, width, goalKey) {
  const path = [];
  let k = goalKey;
  while (k !== -1) {
    path.push({ x: k % width, y: Math.floor(k / width) });
    k = cameFrom[k];
  }
  return path.reverse();
}
Типізовані масиви для продуктивності: Float32Array для g-оцінок та Uint8Array для закритого набору уникають накладних витрат об'єктів. Для сітки 1024×1024 (~1 млн клітинок) масиви вміщуються у 5 МБ — цілком у межах обмежень браузера — а шаблони доступу до рядків кешу значно кращі за Map чи звичайний об'єкт.

5. Черга з пріоритетами та розв'язання нічиїх

Ефективність A* повністю залежить від черги з пріоритетами. Двійкова мінімальна купа дає O(log N) для додавання й витягування. Для дуже великих сіток купа Фібоначчі дає амортизовану O(1) для зменшення ключа, але на практиці рідко виправдовує складність реалізації.

Розв'язання нічиїх критично важливе для продуктивності. Коли багато вузлів мають однакову f-оцінку, A* досліджує їх у довільному порядку. Додавання невеликого розв'язувача нічиїх на основі векторного добутку (надавати перевагу вузлам, чий поточний напрямок збігається з вектором старт→ціль) зменшує кількість досліджених вузлів на 30–50% на відкритій місцевості:

// Трохи зсуваємо f у бік прямолінійного коридору
function hTieBreak(nx, ny, gx, gy, sx, sy) {
  const dx1 = nx - gx, dy1 = ny - gy;
  const dx2 = sx - gx, dy2 = sy - gy;
  const cross = Math.abs(dx1*dy2 - dx2*dy1);
  return octile(nx, ny, gx, gy) + cross * 0.001;
}

6. Дейкстра, BFS і зважений A*

A* — це узагальнення. Задаючи h різні значення, відновлюємо добре відомі алгоритми:

h(n) = 0 → алгоритм Дейкстри (оптимальний, неінформований)
g(n) = 0 → жадібний пошук (швидкий, не оптимальний)
h(n) = 0, усі w=1 → BFS (пошук у ширину, рівна вартість)
f(n) = g + w·h → зважений A* (w>1: субоптимальний, але швидший)

В ігровому ШІ ієрархічний пошук шляху A* (HPA*) будує дворівневу ієрархію: абстрактний граф на рівні кластерів + шляхи всередині кластерів. Дальні запити виконуються на малому абстрактному графі; точні шляхи обчислюються лише поблизу агента. Саме так RTS-ігри керують сотнями юнітів на картах 512×512 в реальному часі.

Jump Point Search (JPS) використовує симетрію рівномірно зваженої сітки: він пропускає надлишкові симетричні шляхи й стрибає до «цікавих» вузлів, зменшуючи кількість досліджених вузлів у 10–40 разів порівняно зі звичайним A* на відкритих сітках без попередньої обробки.

7. Складність і практичні нотатки

Емпіричне правило: використовуйте манхеттенську відстань для 4-напрямкового руху по сітці, октильну — для 8-напрямкового. Помножте евристику на 1,1–1,5 для економії 5–15% досліджених вузлів майже без втрати якості шляху на типових ігрових картах.

Пов'язаний вміст