컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
ds · 07

그래프

자료구조그래프BFS

그래프(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)을 사용해 한 방향으로 최대한 깊이 들어간 뒤 되돌아옵니다. 사이클 탐지, 위상 정렬 등에 사용됩니다.

트리는 그래프의 특수한 형태 — 트리는 연결되어 있고 사이클이 없는 그래프로, 정점 N개에 간선이 정확히 N-1개입니다. 반대로 모든 그래프가 트리인 것은 아니며, 사이클과 다중 경로가 자유롭게 존재할 수 있습니다.
시간복잡도 — BFS와 DFS 모두 인접 리스트 기준으로 모든 정점과 간선을 정확히 한 번씩 확인하므로 O(V+E)입니다. 인접 행렬을 사용하면 각 정점마다 전체 행을 확인해야 하므로 O(V²)로 늘어납니다.
표현 방식 선택 기준 — 정점 수 대비 간선이 많은 밀집 그래프이거나 빠른 간선 조회가 중요하면 인접 행렬을, 정점 수 대비 간선이 적은 희소 그래프이거나 메모리가 중요하면 인접 리스트를 선택합니다.
활용 사례 — BFS는 최단 경로 탐색(가중치 없는 그래프), 네트워크 브로드캐스트에 사용됩니다. DFS는 사이클 탐지, 위상 정렬, 연결 요소 찾기의 기반이 되며, 다음 토픽인 다익스트라 알고리즘은 BFS의 아이디어를 가중치 그래프로 확장한 것으로 볼 수 있습니다.

그래프로 모델링하는 이유

그래프는 '무언가와 무언가가 연결되어 있다'는 관계를 그대로 담는 자료구조입니다. 도시와 도로, 웹 페이지와 링크, 사람과 친구 관계, 작업과 선행 작업처럼 현실의 많은 문제가 정점과 간선으로 옮겨지면 같은 알고리즘(탐색, 최단 경로, 위상 정렬)으로 풀립니다.

숫자로 확인하기

간선의 최대 개수 — 정점이 V개인 무방향 단순 그래프는 간선이 최대 V(V − 1) / 2개이고, 방향 그래프는 최대 V(V − 1)개입니다. V = 1,000이면 무방향 최대 499,500개입니다.
연결 그래프의 최소 간선 — 무방향 그래프에서 정점 V개가 모두 이어지려면 간선이 최소 V − 1개 필요하고, V − 1개이면 트리입니다.
표현 방식의 크기 비교 — V = 100,000인 그래프의 인접 행렬은 V² = 10¹⁰칸입니다. 칸당 1바이트만 써도 약 10GB입니다. 간선이 E = 500,000개이면 인접 리스트는 정점 V개와 간선 양쪽 기록 2E개를 합쳐 약 1,100,000개 항목입니다(10GB는 10진 단위입니다).
소셜 그래프의 밀도 — 사용자 수가 수십만이어도 한 사람의 친구는 많아야 수천 명이라, 가능한 쌍 대비 실제 간선이 매우 적은 희소 그래프가 되기 쉽습니다. 이때 인접 리스트가 맞습니다.

자주 하는 오해

그래프와 트리는 서로 다른 자료구조다 — 트리는 사이클이 없는 연결 그래프입니다. 트리에 쓰는 탐색을 그래프에도 쓰되, 그래프는 사이클 때문에 방문 표시가 필요합니다.
인접 행렬이 항상 더 빠르다 — 두 정점의 간선 조회는 O(1)이지만 한 정점의 이웃을 모두 훑는 데 O(V)가 듭니다. 희소 그래프에서는 인접 리스트가 탐색과 공간에서 모두 유리합니다.
무방향 그래프의 간선은 한 번만 저장한다 — 인접 리스트에서는 양 끝 정점의 목록에 각각 넣어야 합니다. 인접 행렬은 대칭이라 두 칸이 채워집니다.
방향 그래프의 연결은 무방향과 같다 — 방향 그래프에서는 A에서 B로 갈 수 있어도 B에서 A로 갈 수 없을 수 있습니다. 모든 정점 쌍이 방향을 따라 서로 도달 가능하면 강연결, 방향을 무시했을 때 연결이면 약연결이라고 따로 구분합니다.

면접에서 이렇게 물어요

면접 질문

인접 행렬과 인접 리스트 중 무엇을 쓰나요?

간선이 많아 거의 모든 쌍이 연결된 밀집 그래프이거나 간선 존재 여부를 자주 물으면 인접 행렬, 간선이 적은 희소 그래프이거나 이웃 순회가 많으면 인접 리스트를 씁니다. 대부분의 실제 그래프는 희소해서 인접 리스트가 흔합니다.

면접 질문

방향 그래프에서 사이클은 어떻게 찾나요?

DFS로 탐색하면서 현재 탐색 경로에 있는 정점으로 다시 돌아오는 간선을 만나면 사이클입니다. 무방향 그래프에서는 부모가 아닌 이미 방문한 이웃을 만날 때입니다.

면접 질문

위상 정렬은 언제 쓰나요?

작업 사이에 선행 관계가 있을 때 의존성을 지키는 실행 순서를 정하는 데 씁니다. 사이클이 없는 방향 그래프(DAG)에서만 가능합니다. 빌드 순서와 수강 과목 순서가 예입니다.

면접 질문

연결 요소의 개수는 어떻게 세나요?

방문하지 않은 정점에서 BFS나 DFS를 시작할 때마다 새 연결 요소가 하나 발견됩니다. 그 시작 횟수가 연결 요소의 개수입니다.

실제 시스템에서는

Git — Git 용어집은 커밋 객체가 부모를 가진 방향 그래프이면서 사이클이 없는 DAG를 이룬다고 설명합니다.
Gradle — Gradle은 빌드할 때 작업 사이의 의존 관계를 방향성 비순환 그래프(DAG)로 만든 뒤 실행 순서를 정한다고 설명합니다.
PostgreSQL — WITH RECURSIVE 문서는 재귀 질의로 그래프를 탐색하는 예를 보입니다. PostgreSQL 14부터 너비 우선/깊이 우선 정렬(SEARCH)과 사이클 탐지(CYCLE) 절을 쓸 수 있고, 사이클이 있는데 쓰지 않으면 질의가 반복될 수 있다고 경고합니다.

함께 보면 좋은 토픽

BFS / DFS — 그래프 탐색의 두 기본 방식을 비교합니다.
다익스트라 — 가중치 그래프의 최단 거리를 봅니다.
최소 신장 트리 — 모든 정점을 최소 비용으로 잇는 방법을 봅니다.
트리 — 그래프의 특수한 경우인 트리를 봅니다.
라우팅 — 네트워크를 그래프로 보고 경로를 구하는 방식을 봅니다.

참고 자료

Gradle 빌드 수명 주기 — Build Lifecycle — Gradle User Manual
교재 — Cormen, Leiserson, Rivest, Stein, "Introduction to Algorithms" 4판, "Elementary Graph Algorithms" 장

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)로 그래프를 표현할 때 공간 복잡도는?

정점 개수 x 정점 개수 크기의 표를 씁니다.
인접 행렬은 V×V 크기의 2차원 배열을 사용하므로 공간 복잡도가 O(V^2)이며, 간선이 적은 희소 그래프에서는 비효율적일 수 있습니다.

Q2

인접 리스트(Adjacency List)가 인접 행렬보다 유리한 경우는?

실제 존재하는 간선만큼만 공간을 씁니다.
인접 리스트는 실제 존재하는 간선의 수(O(V+E))만큼만 공간을 사용해, 간선이 적은 희소 그래프에서 인접 행렬보다 메모리 효율적입니다.

Q3

BFS(너비 우선 탐색)가 사용하는 자료구조는?

가까운 노드부터 순서대로 방문하려면 어떤 구조가 맞을까요?
BFS는 큐를 이용해 가까운 노드부터 순서대로(레벨 단위로) 방문합니다. DFS는 보통 스택(또는 재귀)을 사용합니다.