지도, 사람 관계, 웹페이지 링크, 작업 의존성은 서로 다른 대상이지만 같은 구조입니다.
관계가 있으면 그래프입니다
그래프(graph) 는 정점과 간선으로 이루어진 구조입니다. 무엇을 정점으로 두고 무엇을 간선으로 볼지는 문제를 읽는 사람이 정합니다.
이 자유도 덕분에 그래프 알고리즘을 쓸 곳이 많습니다. 도시와 도로만 그래프인 게 아닙니다.
- 미로의 각 칸이 정점, 이동 가능한 인접 칸이 간선
- 작업이 정점, 선행 조건이 방향 간선
- 게임의 각 상태가 정점, 한 번의 조작이 간선
마지막 항목처럼 눈에 보이는 연결이 없어도, 상태와 그 상태를 바꾸는 조작이 있으면 그래프입니다. 숫자 하나에서 시작해 더하기 빼기 곱하기로 목표 숫자에 도달하는 문제도 그래프 탐색으로 풀립니다. 정점은 숫자, 간선은 연산입니다.
개발자가 매일 쓰는 도구 중에도 그래프가 많습니다. 디렉터리와 하위 디렉터리는 트리입니다. 모듈이 서로를 import하는 관계는 방향 그래프이고, 번들러가 띄우는 순환 참조 경고는 그 그래프에서 사이클을 찾았다는 뜻입니다.
문제를 그래프로 읽는 데 성공하면 나머지는 이미 있는 도구를 고르는 일이 됩니다.
표현 두 가지를 고르는 기준
인접 리스트(adjacency list) 는 각 정점마다 이어진 정점 목록을 들고 있습니다. 인접 행렬(adjacency matrix) 은 정점 수만큼의 2차원 배열을 만들어 연결 여부를 표시합니다.
| 인접 리스트 | 인접 행렬 | |
|---|---|---|
| 공간 | O(V + E) | O(V²) |
| 두 정점 연결 확인 | O(차수) | O(1) |
| 한 정점의 이웃 순회 | O(차수) | O(V) |
| 간선 추가 | O(1) | O(1) |
선택 기준은 밀도입니다.
대부분의 실제 그래프는 희소(sparse) 합니다. 정점이 십만 개여도 각 정점이 이어진 곳은 몇 개뿐입니다. 이럴 때 인접 행렬은 백억 칸을 잡아놓고 대부분을 0으로 채웁니다. 이럴 때는 인접 리스트가 맞습니다.
인접 행렬이 유리한 경우도 있습니다. 정점이 수백 개 이하로 적으면서 두 정점이 이어졌는지를 자주 물어야 할 때입니다. 전체 쌍 최단 거리를 구하는 상황이 대표적입니다.
실무에서 그래프를 다루는 도구는 대부분 인접 리스트 쪽입니다. 패키지 관리자가 의존성 트리를 출력하거나 번들러가 모듈 그래프를 만들 때, 모듈 하나가 직접 의존하는 목록만 들고 있으면 충분하기 때문입니다.
// 인접 리스트: 무방향 그래프
const graph = Array.from({ length: n }, () => []);
for (const [u, v] of edges) {
graph[u].push(v);
graph[v].push(u); // 방향 그래프면 이 줄을 뺀다
}
격자는 이미 그래프입니다
2차원 배열로 주어진 지도는 간선을 명시하지 않아도 그래프입니다. 인접한 칸이 간선이니 방향 배열만 두면 됩니다.
const dr = [-1, 1, 0, 0]; // 상하좌우
const dc = [0, 0, -1, 1];
for (let d = 0; d < 4; d++) {
const nr = r + dr[d];
const nc = c + dc[d];
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue; // 범위 밖
// nr, nc가 이웃 칸
}
대각선까지 포함해야 하면 배열을 여덟 개로 늘립니다. 범위 검사는 반복문 안에서 먼저 해야 합니다. 이 검사를 빠뜨려 생기는 인덱스 오류가 격자 문제에서 가장 흔한 실수입니다.
깊이 우선과 너비 우선은 다른 질문에 답합니다
두 탐색의 차이는 지금 보고 있는 것 다음에 어디를 볼지 고르는 방식에 있습니다.
깊이 우선 탐색(depth first search) 은 갈 수 있는 데까지 들어갔다가 막히면 돌아 나옵니다. 스택으로 동작하고 재귀로 쓰면 코드가 짧습니다.
너비 우선 탐색(breadth first search) 은 현재 위치에서 한 칸 거리의 모든 곳을 먼저 보고, 그다음 두 칸 거리를 봅니다. 큐로 동작합니다.
무엇을 물었느냐로 고릅니다.
- 두 지점이 이어져 있는가, 덩어리가 몇 개인가, 모든 경로를 나열하라: 깊이 우선
- 최소 몇 번 만에 도달하는가: 너비 우선
고르는 기준은 실무에서도 같습니다. 디렉터리를 훑어 조건에 맞는 파일을 찾거나 모듈 사이의 순환 참조를 잡을 때는 깊이 우선을 씁니다. 친구의 친구까지 몇 다리 만에 닿는지 세거나 화면 사이의 최소 이동 횟수를 구할 때는 너비 우선을 씁니다.
너비 우선이 최단 거리를 주는 근거
너비 우선 탐색이 처음 도달했을 때의 거리가 최단이라는 보장에는 조건이 있습니다. 모든 간선의 비용이 같아야 합니다.
이유는 탐색 순서에 있습니다. 거리 1인 정점을 모두 큐에 넣고, 그것들을 꺼내면서 거리 2인 정점을 넣습니다. 큐는 먼저 들어온 것이 먼저 나오므로 거리가 작은 정점부터 처리됩니다. 어떤 정점에 처음 도달했다면 그보다 짧은 경로는 이미 확인되었다는 뜻입니다.
간선마다 비용이 다르면 이 논리가 무너집니다. 간선 하나로 가는 길이 간선 세 개로 가는 길보다 비쌀 수 있기 때문입니다. 그때는 ep.04 가중치와 순서와 연결성의 다익스트라를 씁니다.
function shortestPath(grid, start, target) {
const rows = grid.length;
const cols = grid[0].length;
const dist = Array.from({ length: rows }, () => new Array(cols).fill(-1));
const queue = [start];
let head = 0; // shift 대신 읽기 위치를 옮긴다
dist[start[0]][start[1]] = 0;
const dr = [-1, 1, 0, 0];
const dc = [0, 0, -1, 1];
while (head < queue.length) {
const [r, c] = queue[head++];
if (r === target[0] && c === target[1]) return dist[r][c];
for (let d = 0; d < 4; d++) {
const nr = r + dr[d];
const nc = c + dc[d];
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
if (grid[nr][nc] === 1) continue; // 벽
if (dist[nr][nc] !== -1) continue; // 이미 방문
dist[nr][nc] = dist[r][c] + 1;
queue.push([nr, nc]);
}
}
return -1; // 도달 불가
}
shift() 대신 읽기 위치를 옮기는 이유는 자료구조 시리즈 ep.02 배열과 해시의 진짜 비용에서 다뤘습니다. 격자가 커질수록 이 차이가 그대로 실행 시간이 됩니다.
방문 표시를 큐에 넣는 시점에 하는 것도 중요합니다. 꺼낼 때 표시하면 같은 정점이 큐에 여러 번 들어가 중복 처리됩니다.
재귀 깊이에는 현실적인 제약이 있습니다
깊이 우선 탐색을 재귀로 쓰면 코드가 짧고 읽기 좋습니다. 대신 호출 스택을 씁니다.
정점이 십만 개인 그래프가 한 줄로 이어져 있으면 재귀 깊이도 십만이 됩니다. 자바스크립트 엔진의 스택 한계를 넘어 오류가 납니다. 이 한계는 엔진과 실행 환경에 따라 다르고 명세로 정해진 값이 아닙니다.
깊이가 클 수 있는 상황이라면 명시적 스택으로 바꿔야 합니다.
function dfsIterative(graph, start) {
const visited = new Set([start]);
const stack = [start];
while (stack.length > 0) {
const node = stack.pop();
for (const next of graph[node]) {
if (visited.has(next)) continue;
visited.add(next);
stack.push(next);
}
}
return visited;
}
방문 순서는 재귀 버전과 달라질 수 있습니다. 재귀는 이웃 목록의 첫 번째부터 들어가지만, 명시적 스택은 마지막에 넣은 것을 먼저 꺼내므로 목록의 마지막 이웃부터 방문합니다. 정점 0의 이웃이 1, 2, 3이면 재귀는 1로, 명시적 스택 버전은 3으로 먼저 갑니다. 사전순으로 가장 앞선 경로를 구하는 문제처럼 순서가 답에 영향을 주면, 이웃을 넣을 때 [...graph[node]].reverse()처럼 순서를 뒤집어 재귀와 같은 순서로 맞춰야 합니다.
참고 자료
- Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press.
- Sedgewick, R., & Wayne, K. (2011). Algorithms (4th ed.). Addison-Wesley.
다음 편 예고
절반을 버려도 답이 남는다는 확신은 어디서 오는지 봅니다. 이분탐색의 전제인 단조성에서 출발해 답 자체를 이분탐색하는 방법, 투 포인터, 백트래킹의 가지치기까지 하나의 관점으로 묶습니다.