그래프
그래프(Graph)는 정점(Vertex)과 간선(Edge)으로 이루어진 자료구조입니다. 트리와 달리 루트가 없고 사이클이 존재할 수 있으며, 하나의 정점이 여러 정점과 자유롭게 연결될 수 있습니다. 소셜 네트워크의 친구 관계, 지도의 도로망, 웹페이지의 하이퍼링크처럼 복잡하게 얽힌 관계를 표현하는 데 사용됩니다.
핵심 개념
정점과 간선
정점(Vertex)은 데이터를 담는 단위이고, 간선(Edge)은 두 정점 사이의 연결입니다. 간선에 방향이 없으면 무방향 그래프, 방향이 있으면 방향 그래프라 합니다.
핵심 개념
인접과 차수
두 정점 사이에 간선이 있으면 서로 인접(Adjacent)하다고 합니다. 차수(Degree)는 한 정점에 연결된 간선의 개수로, 해당 정점이 얼마나 많이 연결되어 있는지를 나타냅니다.
핵심 개념
인접 행렬 (Adjacency Matrix)
V×V 2차원 배열로 간선 존재 여부를 표현합니다. 공간복잡도는 O(V²)이지만 두 정점 사이 간선 조회는 O(1)로 빠릅니다. 정점 대비 간선이 적은 희소 그래프에는 비효율적입니다.
핵심 개념
인접 리스트 (Adjacency List)
각 정점마다 연결된 정점들의 목록만 저장합니다. 공간복잡도 O(V+E)로 희소 그래프에 효율적이지만, 간선 존재 조회는 O(degree)로 인접 행렬보다 느릴 수 있습니다.
핵심 개념
BFS (너비 우선 탐색)
큐(Queue, FIFO)를 사용해 시작 정점과 가까운 정점부터 순서대로 방문합니다. 최단 경로(가중치 없는 그래프)를 구할 때 유용합니다.
핵심 개념
DFS (깊이 우선 탐색)
스택(재귀 호출 스택, LIFO)을 사용해 한 방향으로 최대한 깊이 들어간 뒤 되돌아옵니다. 사이클 탐지, 위상 정렬 등에 사용됩니다.
그래프로 모델링하는 이유
그래프는 '무언가와 무언가가 연결되어 있다'는 관계를 그대로 담는 자료구조입니다. 도시와 도로, 웹 페이지와 링크, 사람과 친구 관계, 작업과 선행 작업처럼 현실의 많은 문제가 정점과 간선으로 옮겨지면 같은 알고리즘(탐색, 최단 경로, 위상 정렬)으로 풀립니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
인접 행렬과 인접 리스트 중 무엇을 쓰나요?
간선이 많아 거의 모든 쌍이 연결된 밀집 그래프이거나 간선 존재 여부를 자주 물으면 인접 행렬, 간선이 적은 희소 그래프이거나 이웃 순회가 많으면 인접 리스트를 씁니다. 대부분의 실제 그래프는 희소해서 인접 리스트가 흔합니다.
면접 질문
방향 그래프에서 사이클은 어떻게 찾나요?
DFS로 탐색하면서 현재 탐색 경로에 있는 정점으로 다시 돌아오는 간선을 만나면 사이클입니다. 무방향 그래프에서는 부모가 아닌 이미 방문한 이웃을 만날 때입니다.
면접 질문
위상 정렬은 언제 쓰나요?
작업 사이에 선행 관계가 있을 때 의존성을 지키는 실행 순서를 정하는 데 씁니다. 사이클이 없는 방향 그래프(DAG)에서만 가능합니다. 빌드 순서와 수강 과목 순서가 예입니다.
면접 질문
연결 요소의 개수는 어떻게 세나요?
방문하지 않은 정점에서 BFS나 DFS를 시작할 때마다 새 연결 요소가 하나 발견됩니다. 그 시작 횟수가 연결 요소의 개수입니다.
실제 시스템에서는
WITH RECURSIVE 문서는 재귀 질의로 그래프를 탐색하는 예를 보입니다. PostgreSQL 14부터 너비 우선/깊이 우선 정렬(SEARCH)과 사이클 탐지(CYCLE) 절을 쓸 수 있고, 사이클이 있는데 쓰지 않으면 질의가 반복될 수 있다고 경고합니다.
함께 보면 좋은 토픽
참고 자료
Java — 그래프 정의 (인접 리스트)
/* ── 그래프 정의: 정점(Integer) → 인접 정점 목록(List) ──── */ public class Graph { private final Map<Integer, List<Integer>> adj = new HashMap<>(); public void addVertex(int v) { adj.putIfAbsent(v, new ArrayList<>()); } // 무방향 그래프: 간선 하나를 양쪽 정점 리스트에 모두 추가합니다. public void addEdge(int v1, int v2) { addVertex(v1); addVertex(v2); adj.get(v1).add(v2); adj.get(v2).add(v1); } public List<Integer> neighbors(int v) { return adj.getOrDefault(v, Collections.emptyList()); } }
인접 리스트는 정점마다 연결된 정점의 목록만 저장하므로 공간복잡도가 O(V+E)입니다.
반면 인접 행렬은 boolean[][] matrix = new boolean[V][V] 형태로 O(V²) 공간을 사용하는 대신, matrix[i][j] 조회가 O(1)로 빠릅니다.
정점 수 대비 간선이 적은 희소 그래프에서는 인접 리스트가, 간선이 많은 밀집 그래프이거나 빠른 조회가 중요하면 인접 행렬이 유리합니다.
Java — BFS (너비 우선 탐색, Queue)
/* ── BFS: 큐(Queue, FIFO)를 이용해 가까운 정점부터 방문 ──── */ public List<Integer> bfs(Graph graph, int start) { List<Integer> visitOrder = new ArrayList<>(); Set<Integer> visited = new HashSet<>(); Queue<Integer> queue = new LinkedList<>(); visited.add(start); // 큐에 넣을 때 바로 방문 처리 queue.offer(start); while (!queue.isEmpty()) { int cur = queue.poll(); visitOrder.add(cur); // 1. 방문 순서에 추가 for (int next : graph.neighbors(cur)) { if (!visited.contains(next)) { visited.add(next); // 2. 중복 삽입 방지를 위해 미리 방문 처리 queue.offer(next); } } } return visitOrder; }
핵심은 visited.add(next)를 큐에 offer하는 시점에 실행하는 것입니다.
꺼낼 때(poll) 방문 처리하면 같은 정점이 큐에 여러 번 들어가 중복 방문이 발생할 수 있습니다.
시간복잡도는 모든 정점과 간선을 정확히 한 번씩 확인하므로 O(V+E), 공간복잡도는 큐와 visited 집합을 위한 O(V)입니다.
가중치가 없는 그래프에서 시작 정점으로부터의 최단 경로(간선 수 기준)를 구할 때도 BFS가 사용됩니다.
Java — DFS (깊이 우선 탐색, 재귀)
/* ── DFS: 재귀 호출 스택(LIFO)을 이용해 깊이 먼저 탐색 ───── */ public void dfs(Graph graph, int cur, Set<Integer> visited, List<Integer> visitOrder) { visited.add(cur); // 1. 진입 즉시 방문 처리 (호출 스택에 push) visitOrder.add(cur); for (int next : graph.neighbors(cur)) { if (!visited.contains(next)) { dfs(graph, next, visited, visitOrder); // 2. 재귀 호출 } } } // 3. 함수 종료 시 호출 스택에서 pop /* ── 호출 예시 ──────────────────────────────────────────── */ List<Integer> result = new ArrayList<>(); dfs(graph, start, new HashSet<>(), result);
트리의 전위 순회와 원리가 같습니다 — 방문하자마자 처리하고, 미방문 인접 정점으로 재귀 호출합니다.
시간복잡도는 O(V+E)로 BFS와 동일하지만, 재귀 호출 스택 깊이는 그래프의 형태에 따라 최악의 경우 O(V)까지 늘어나 정점 수가 매우 많으면 StackOverflowError 위험이 있습니다.
Java — 반복적 DFS (명시적 스택, 참고)
/* ── 반복적 DFS: 재귀 대신 명시적 스택(Deque) 사용 ──────── */ // 재귀 DFS의 StackOverflowError 위험을 피할 때 사용합니다. public List<Integer> dfsIterative(Graph graph, int start) { List<Integer> visitOrder = new ArrayList<>(); Set<Integer> visited = new HashSet<>(); Deque<Integer> stack = new ArrayDeque<>(); stack.push(start); while (!stack.isEmpty()) { int cur = stack.pop(); if (visited.contains(cur)) continue; // BFS와 달리 pop 시점에 중복 검사 visited.add(cur); visitOrder.add(cur); for (int next : graph.neighbors(cur)) { if (!visited.contains(next)) stack.push(next); } } return visitOrder; }
BFS는 큐에 offer하는 시점에 방문 처리해 중복 삽입을 막지만, 이 반복적 DFS는 스택에 같은 정점이 여러 번 쌓일 수 있어 pop 시점에 방문 여부를 다시 검사합니다.
이번 시각화의 핵심 범위는 재귀 DFS이지만, 재귀 호출 스택과 명시적 스택이 결국 같은 개념(LIFO)이라는 점을 이해하는 데 참고가 됩니다.
Q1
인접 행렬(Adjacency Matrix)로 그래프를 표현할 때 공간 복잡도는?
Q2
인접 리스트(Adjacency List)가 인접 행렬보다 유리한 경우는?
Q3
BFS(너비 우선 탐색)가 사용하는 자료구조는?