A* 알고리즘 — 휴리스틱으로 탐색을 빠르게 하기
지도에서 길을 찾을 때, 모든 경로를 다 탐색하지 않고도 최단 경로를 찾을 수 있을까요?
개념 정의
A*는 시작점에서 목표까지의 최단 경로 를 찾는 알고리즘입니다. Dijkstra처럼 최단 거리를 보장하면서도, 휴리스틱(heuristic) 을 활용해 불필요한 탐색을 줄입니다.
핵심 공식은 간단합니다.
f(n) = g(n) + h(n)
g(n): 시작점에서 n까지의 실제 비용
h(n): n에서 목표까지의 추정 비용 (휴리스틱)
f(n): 총 예상 비용
f(n)이 가장 작은 노드를 우선 탐색합니다.
왜 필요한가
BFS와 Dijkstra도 최단 경로를 찾지만, 방향성 없이 모든 방향으로 퍼져나갑니다.
BFS/Dijkstra: 시작점에서 동심원처럼 전 방향 탐색
A*: 목표 방향으로 우선 탐색, 필요할 때만 다른 방향 확인
실제로 내비게이션에서 서울→부산 경로를 찾을 때, 서울 북쪽 방향까지 다 탐색하는 건 낭비입니다. A*는 남쪽 방향을 우선 탐색하므로 훨씬 빠릅니다.
BFS vs Dijkstra vs A*
| 알고리즘 | 가중치 | 휴리스틱 | 최적해 보장 | 시간 복잡도 |
|---|---|---|---|---|
| BFS | 동일 가중치 | 없음 | O | O(V + E) |
| Dijkstra | 양의 가중치 | 없음 | O | O(E log V) |
| A* | 양의 가중치 | 있음 | 조건부 O | O(E log V) 이하* |
A의 실제 성능은 휴리스틱의 품질에 따라 달라집니다.
동작 원리
알고리즘 흐름
- 시작 노드를 우선순위 큐(open set)에 넣습니다
- f(n)이 가장 작은 노드를 꺼냅니다
- 목표 노드면 종료합니다
- 이웃 노드들의 g, h, f를 계산하여 큐에 넣습니다
- 2~4를 반복합니다
public class AStar {
// 노드 정보
static class Node implements Comparable<Node> {
int x, y;
int g; // 시작→현재 실제 비용
int f; // g + h (총 예상 비용)
Node(int x, int y, int g, int f) {
this.x = x; this.y = y;
this.g = g; this.f = f;
}
@Override
public int compareTo(Node o) {
return Integer.compare(this.f, o.f);
}
}
// 4방향 이동
private static final int[] dx = {0, 0, 1, -1};
private static final int[] dy = {1, -1, 0, 0};
public List<int[]> findPath(int[][] grid, int[] start, int[] goal) {
int rows = grid.length, cols = grid[0].length;
int[][] gCost = new int[rows][cols];
int[][] parentX = new int[rows][cols];
int[][] parentY = new int[rows][cols];
for (int[] row : gCost) Arrays.fill(row, Integer.MAX_VALUE);
PriorityQueue<Node> openSet = new PriorityQueue<>();
boolean[][] closed = new boolean[rows][cols];
// 시작점 초기화
gCost[start[0]][start[1]] = 0;
int h = heuristic(start[0], start[1], goal[0], goal[1]);
openSet.offer(new Node(start[0], start[1], 0, h));
parentX[start[0]][start[1]] = -1;
parentY[start[0]][start[1]] = -1;
while (!openSet.isEmpty()) {
Node curr = openSet.poll();
// 목표 도달
if (curr.x == goal[0] && curr.y == goal[1]) {
return reconstructPath(parentX, parentY, goal);
}
if (closed[curr.x][curr.y]) continue;
closed[curr.x][curr.y] = true;
// 이웃 탐색
for (int d = 0; d < 4; d++) {
int nx = curr.x + dx[d];
int ny = curr.y + dy[d];
if (nx < 0 || nx >= rows || ny < 0 || ny >= cols) continue;
if (grid[nx][ny] == 1 || closed[nx][ny]) continue; // 벽이면 스킵
int newG = curr.g + 1; // 이동 비용 1
if (newG < gCost[nx][ny]) {
gCost[nx][ny] = newG;
int newF = newG + heuristic(nx, ny, goal[0], goal[1]);
openSet.offer(new Node(nx, ny, newG, newF));
parentX[nx][ny] = curr.x;
parentY[nx][ny] = curr.y;
}
}
}
return Collections.emptyList(); // 경로 없음
}
// 맨해튼 거리 휴리스틱
private int heuristic(int x1, int y1, int x2, int y2) {
return Math.abs(x1 - x2) + Math.abs(y1 - y2);
}
// 경로 역추적
private List<int[]> reconstructPath(int[][] parentX, int[][] parentY, int[] goal) {
List<int[]> path = new ArrayList<>();
int x = goal[0], y = goal[1];
while (x != -1) {
path.add(new int[]{x, y});
int px = parentX[x][y];
int py = parentY[x][y];
x = px; y = py;
}
Collections.reverse(path);
return path;
}
}
휴리스틱 함수 설계
휴리스틱의 품질이 A*의 성능을 결정합니다.
허용 가능(admissible) 조건
h(n)이 실제 비용을 절대 초과하지 않아야 최적해를 보장합니다.
h(n) ≤ 실제 비용(n → 목표) → 최적해 보장 (admissible)
h(n) > 실제 비용 → 최적해 미보장 (하지만 더 빠를 수 있음)
일관성(consistent) 조건
모든 이웃 n'에 대해 h(n) ≤ cost(n, n') + h(n')이면 일관성이 있습니다. 일관성이 있으면 한 번 방문한 노드를 재방문할 필요가 없어져 효율적입니다.
대표적인 휴리스틱
// 맨해튼 거리 — 4방향 격자에 적합
int manhattan(int x1, int y1, int x2, int y2) {
return Math.abs(x1 - x2) + Math.abs(y1 - y2);
}
// 유클리드 거리 — 자유로운 이동에 적합
double euclidean(int x1, int y1, int x2, int y2) {
return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
}
// 체비셰프 거리 — 8방향 격자에 적합
int chebyshev(int x1, int y1, int x2, int y2) {
return Math.max(Math.abs(x1 - x2), Math.abs(y1 - y2));
}
| 이동 방식 | 적합한 휴리스틱 |
|---|---|
| 4방향 (상하좌우) | 맨해튼 거리 |
| 8방향 (대각선 포함) | 체비셰프 거리 |
| 자유 이동 | 유클리드 거리 |
휴리스틱과 성능의 관계
h(n) = 0 → Dijkstra와 동일 (가장 느림, 최적해 보장)
h(n) < 실제비용 → 적당히 빠름, 최적해 보장
h(n) = 실제비용 → 최적 (직선으로 목표까지 탐색)
h(n) > 실제비용 → 매우 빠르지만 최적해 미보장
IDA* (Iterative Deepening A*)
메모리가 부족할 때 사용하는 변형입니다. A는 열린 집합에 많은 노드를 저장하므로 메모리가 문제가 될 수 있습니다. IDA는 깊이 제한을 f값 기준으로 점진적으로 늘려가며 DFS를 반복합니다.
// IDA* 개념적 구현
public int idaStar(int[] start, int[] goal) {
int threshold = heuristic(start[0], start[1], goal[0], goal[1]);
while (true) {
int result = dfsSearch(start, 0, threshold, goal);
if (result == -1) return threshold; // 목표 도달
if (result == Integer.MAX_VALUE) return -1; // 경로 없음
threshold = result; // 다음 임계값
}
}
백엔드/실무 연결
게임 길찾기
A는 게임에서 가장 많이 사용되는 길찾기 알고리즘입니다. NPC의 이동 경로, 전략 게임의 유닛 이동 등에 핵심적으로 쓰입니다. Unity, Unreal Engine 모두 A 기반 네비게이션 시스템을 내장하고 있습니다.
내비게이션 시스템
실제 내비게이션에서는 A*의 변형들이 쓰입니다.
- *ALT (A with Landmarks and Triangle inequality)**: 랜드마크를 미리 정하고 전처리
- Contraction Hierarchies: 도로 네트워크의 계층 구조를 이용한 전처리
로봇 경로 계획
로봇의 움직임 계획(motion planning)에서 장애물을 피해 목적지까지의 경로를 찾을 때 A* 또는 그 변형(D*, Theta*)을 사용합니다.
API 게이트웨이 라우팅
요청을 여러 마이크로서비스를 거쳐 처리할 때, 지연 시간을 최소화하는 경로를 찾는 것도 A*와 유사한 원리입니다. 각 서비스의 예상 응답 시간이 휴리스틱이 됩니다.
주의할 점
허용 불가능(inadmissible) 휴리스틱을 사용하여 최적해를 놓치는 실수
h(n)이 실제 비용보다 크면(과대평가), A*가 최단 경로가 아닌 경로를 반환할 수 있습니다. 최적해가 필요하면 반드시 허용 가능(admissible) 휴리스틱을 사용해야 합니다.
4방향 이동에서 유클리드 거리를 휴리스틱으로 사용하는 실수
4방향 이동만 가능한 격자에서 유클리드 거리는 실제 이동 거리보다 작아 비효율적입니다. 맨해튼 거리(|dx| + |dy|)가 정확한 하한이므로 더 적절합니다.
정리
- A*는
f(n) = g(n) + h(n)공식으로 최단 경로를 효율적으로 찾는 알고리즘입니다 - g(n) 은 실제 비용, h(n) 은 추정 비용(휴리스틱)입니다
- 휴리스틱이 admissible(과소평가)하면 최적해를 보장합니다
- h(n) = 0이면 Dijkstra, h(n)이 정확할수록 탐색이 빨라집니다
- 격자 유형에 따라 맨해튼, 유클리드, 체비셰프 거리를 선택합니다
- 게임 길찾기, 내비게이션, 로봇 경로 계획 등 광범위하게 활용됩니다