트리에서 두 노드가 공통으로 가지는 "가장 가까운" 조상은 어떻게 빠르게 찾을 수 있을까요?

LCA란

최소 공통 조상(Lowest Common Ancestor, LCA) 은 루트 트리에서 두 노드 u, v의 공통 조상 중 가장 깊은(루트에서 가장 먼) 노드를 말합니다.

PLAINTEXT
        1
       / \
      2   3
     / \   \
    4   5   6
   /
  7

LCA(7, 5) = 2   (7의 조상: 4, 2, 1 / 5의 조상: 2, 1 → 공통 중 가장 깊은 것: 2)
LCA(7, 6) = 1   (7의 조상: 4, 2, 1 / 6의 조상: 3, 1 → 공통: 1)
LCA(4, 5) = 2

왜 필요한가

LCA는 트리 위에서의 다양한 쿼리에 기본이 되는 연산입니다.

  • ** 트리에서 두 노드 간 거리 **: dist(u, v) = depth(u) + depth(v) - 2 × depth(LCA(u, v))
  • ** 트리에서 경로 위의 값 합산 **: u→LCA→v 경로를 분리해서 계산
  • Git merge-base: 두 브랜치의 공통 조상 커밋 찾기
  • ** 파일 시스템 **: 두 경로의 공통 디렉토리 찾기

나이브 방법 — O(N)

가장 단순한 방법은 두 노드의 깊이를 맞춘 뒤, 같은 노드가 될 때까지 동시에 한 칸씩 올라가는 것입니다.

JAVA
// 나이브 LCA — O(N)
public int lcaNaive(int u, int v, int[] parent, int[] depth) {
    // 1. 깊이 맞추기
    while (depth[u] > depth[v]) u = parent[u];
    while (depth[v] > depth[u]) v = parent[v];

    // 2. 같은 노드가 될 때까지 올라가기
    while (u != v) {
        u = parent[u];
        v = parent[v];
    }
    return u;
}

트리가 일자 형태(편향 트리)이면 O(N)이 걸립니다. 쿼리가 많을 때는 너무 느립니다.

동작 원리 — 희소 테이블(Binary Lifting)

** 아이디어 **: 한 칸씩 올라가는 대신, 2^k 칸씩 점프합니다.

전처리

parent[k][v] = 정점 v에서 2^k번 위로 올라간 조상

PLAINTEXT
parent[0][v] = v의 직접 부모
parent[k][v] = parent[k-1][parent[k-1][v]]  (2^k = 2^(k-1) + 2^(k-1))

구현

JAVA
public class LCA {
    private static final int LOG = 20; // log2(최대 정점 수)
    private int[][] parent;
    private int[] depth;
    private List<List<Integer>> tree;
    private int n;

    public LCA(List<List<Integer>> tree, int root, int n) {
        this.tree = tree;
        this.n = n;
        this.parent = new int[LOG][n];
        this.depth = new int[n];

        // DFS로 depth와 parent[0] 채우기
        Arrays.fill(depth, -1);
        dfs(root, -1, 0);

        // 희소 테이블 구성
        for (int k = 1; k < LOG; k++) {
            for (int v = 0; v < n; v++) {
                if (parent[k - 1][v] != -1) {
                    parent[k][v] = parent[k - 1][parent[k - 1][v]];
                } else {
                    parent[k][v] = -1;
                }
            }
        }
    }

    private void dfs(int u, int par, int d) {
        depth[u] = d;
        parent[0][u] = par;
        for (int v : tree.get(u)) {
            if (v != par) {
                dfs(v, u, d + 1);
            }
        }
    }

    public int query(int u, int v) {
        // 1. 깊이 맞추기 — 2^k 단위로 점프
        if (depth[u] < depth[v]) {
            int temp = u; u = v; v = temp;
        }

        int diff = depth[u] - depth[v];
        for (int k = 0; k < LOG; k++) {
            if (((diff >> k) & 1) == 1) {
                u = parent[k][u];
            }
        }

        // 2. 같으면 바로 반환
        if (u == v) return u;

        // 3. 동시에 올라가기 — 큰 점프부터
        for (int k = LOG - 1; k >= 0; k--) {
            if (parent[k][u] != parent[k][v]) {
                u = parent[k][u];
                v = parent[k][v];
            }
        }
        return parent[0][u]; // 한 칸 위가 LCA
    }
}

시간 복잡도

  • 전처리: O(N log N)
  • 쿼리: O(log N)

오일러 투어 + RMQ 방식

오일러 투어로 트리를 배열로 평탄화한 뒤, ** 구간 최솟값 쿼리(RMQ)**로 LCA를 O(1)에 구하는 방법입니다.

오일러 투어

DFS를 하면서 방문할 때마다 (노드, 깊이) 쌍을 기록합니다. 자식을 모두 방문하고 돌아올 때도 기록합니다.

PLAINTEXT
트리:     1
        / \
       2   3
      / \
     4   5

오일러 투어: [1, 2, 4, 2, 5, 2, 1, 3, 1]
깊이:       [0, 1, 2, 1, 2, 1, 0, 1, 0]

LCA = 구간 최솟값

두 노드 u, v가 오일러 투어에서 처음 나타나는 위치를 각각 pos[u], pos[v]라 하면, LCA는 pos[u]~pos[v] 사이에서 깊이가 가장 작은 노드 입니다.

JAVA
public class EulerTourLCA {
    private int[] euler;      // 오일러 투어 순서
    private int[] eulerDepth; // 오일러 투어의 깊이
    private int[] firstPos;   // 각 노드가 처음 나타나는 위치
    private int idx;
    private int[][] sparse;   // RMQ 희소 테이블

    public void build(List<List<Integer>> tree, int root, int n) {
        euler = new int[2 * n];
        eulerDepth = new int[2 * n];
        firstPos = new int[n];
        Arrays.fill(firstPos, -1);
        idx = 0;

        dfs(tree, root, -1, 0);

        // 희소 테이블로 RMQ 전처리
        buildSparseTable();
    }

    private void dfs(List<List<Integer>> tree, int u, int par, int d) {
        euler[idx] = u;
        eulerDepth[idx] = d;
        if (firstPos[u] == -1) firstPos[u] = idx;
        idx++;

        for (int v : tree.get(u)) {
            if (v != par) {
                dfs(tree, v, u, d + 1);
                euler[idx] = u;
                eulerDepth[idx] = d;
                idx++;
            }
        }
    }

    public int query(int u, int v) {
        int l = Math.min(firstPos[u], firstPos[v]);
        int r = Math.max(firstPos[u], firstPos[v]);
        return rmq(l, r); // 깊이가 최소인 노드 반환
    }

    // 희소 테이블 RMQ 구현 (O(1) 쿼리)
    private void buildSparseTable() {
        int len = idx;
        int log = (int) (Math.log(len) / Math.log(2)) + 1;
        sparse = new int[log][len];

        for (int i = 0; i < len; i++) sparse[0][i] = i;

        for (int k = 1; k < log; k++) {
            for (int i = 0; i + (1 << k) <= len; i++) {
                int left = sparse[k - 1][i];
                int right = sparse[k - 1][i + (1 << (k - 1))];
                sparse[k][i] = eulerDepth[left] <= eulerDepth[right] ? left : right;
            }
        }
    }

    private int rmq(int l, int r) {
        int k = (int) (Math.log(r - l + 1) / Math.log(2));
        int left = sparse[k][l];
        int right = sparse[k][r - (1 << k) + 1];
        int minIdx = eulerDepth[left] <= eulerDepth[right] ? left : right;
        return euler[minIdx];
    }
}

시간 복잡도

  • 전처리: O(N log N) (오일러 투어 O(N) + 희소 테이블 O(N log N))
  • 쿼리: O(1)

방식 비교

방식전처리쿼리메모리구현 난이도
나이브O(N)O(N)O(N)쉬움
Binary LiftingO(N log N)O(log N)O(N log N)중간
오일러 투어 + RMQO(N log N)O(1)O(N log N)어려움

백엔드/실무 연결

Git merge-base

git merge-base branch1 branch2 명령은 두 브랜치의 공통 조상 커밋을 찾습니다. Git의 커밋 히스토리는 DAG이므로 정확히 LCA는 아니지만, 원리는 같습니다.

BASH
# 두 브랜치의 공통 조상 커밋 찾기
git merge-base feature main

이 커밋이 merge의 기준점이 됩니다. 3-way merge에서 base가 되는 커밋입니다.

파일 시스템 경로

두 파일 경로의 공통 디렉토리를 찾는 것도 LCA와 같습니다.

PLAINTEXT
/home/user/project/src/main/java/Service.java
/home/user/project/src/test/java/ServiceTest.java
→ 공통 조상: /home/user/project/src

조직 트리/카테고리

계층 구조(카테고리, 조직도)에서 두 항목의 공통 상위 항목을 찾을 때 LCA를 활용합니다. 댓글 트리에서 두 댓글의 공통 부모 댓글을 찾는 것도 같은 원리입니다.

주의할 점

희소 테이블(Sparse Table)을 구축하지 않고 O(n) 쿼리를 반복하는 실수

LCA를 매번 루트까지 올라가며 찾으면 쿼리당 O(n)입니다. 이진 리프팅(Binary Lifting)으로 전처리하면 쿼리당 O(log n)으로 줄어듭니다. 쿼리 수가 많을 때는 전처리가 필수입니다.

깊이 맞추기를 빠뜨리는 실수

두 노드의 깊이가 다른 상태에서 동시에 올라가면 만남 지점을 놓칩니다. 반드시 깊이를 먼저 맞춘 뒤 동시에 올라가야 합니다.


정리

  • LCA 는 트리에서 두 노드의 가장 깊은 공통 조상을 찾는 문제입니다
  • **나이브 방법 **: 한 칸씩 올라가며 O(N), 쿼리가 적으면 충분합니다
  • Binary Lifting: 2^k씩 점프하여 O(log N) 쿼리, 가장 실용적입니다
  • ** 오일러 투어 + RMQ**: 전처리 후 O(1) 쿼리, 쿼리가 매우 많을 때 유리합니다
  • dist(u, v) = depth(u) + depth(v) - 2 × depth(LCA(u, v))는 트리 문제의 핵심 공식입니다
  • Git merge-base, 파일 시스템 공통 경로 등 실무에서도 자연스럽게 등장하는 개념입니다
댓글 로딩 중...