코스

고급 트리

LCA·HLD·센트로이드·링크컷 트리.

Level 4 → Level 9 28 아이템 10 문제 18 강의 0 확인 문제
코스 진행도 0%
0 / 28 아이템 완료
01
Level 4 · Challenger

Challenger

고급 트리 · Challenger 단계

0/5 완료
Lesson 1강 · 개념과 두 가지 접근 필수 8m 현재

최소 공통 조상(LCA)이란

루트가 정해진 트리에서 두 정점 \(u, v\)최소 공통 조상(Lowest Common
Ancestor)
은, \(u\)\(v\)모두 자손으로 갖는 조상 중 가장 깊은(루트에서
가장 먼) 정점
입니다. 트리에서 \(u\)\(v\)를 잇는 유일한 경로는 항상
\(u \to \text{LCA} \to v\) 모양으로 꺾이므로, LCA는 트리 경로 질의의 핵심
도구가 됩니다.

  • 신호가 되는 표현: "두 정점 사이의 거리/경로", "공통 조상", "트리에서
    경로 위의 합/최댓값" 같은 질의가 여러 번 주어질 때.

작은 트리로 감을 잡아 봅시다. 루트를 \(1\)로 두고

        1
       / \
      2   3
     / \   \
    4   5   6
        |   / \
        9  7   8
  • \(\text{LCA}(4, 5) = 2\) (둘의 부모)
  • \(\text{LCA}(7, 8) = 6\)
  • \(\text{LCA}(4, 7) = 1\) (서로 다른 가지라 루트까지 올라감)
  • \(\text{LCA}(9, 5) = 5\) (\(5\)\(9\)의 조상이므로 자기 자신)

왜 빠른 방법이 필요한가

가장 단순한 방법은 두 정점을 같은 깊이로 맞춘 뒤 부모를 한 칸씩 함께
올리는
것입니다. 이는 질의당 \(O(\text{높이}) = O(n)\)이라, 트리가 한 줄로
길면(사슬 모양) 질의 하나에 \(O(n)\)이 걸립니다. 질의가 \(Q\)개면 \(O(nQ)\)로,
\(n, Q\)\(10^5\)만 되어도 시간 초과입니다.

그래서 전처리로 한 번 준비해 두고 질의를 빠르게 답하는 두 가지 표준
기법을 씁니다.

방법 전처리 질의당 특징
이진 트리 상승(binary lifting) \(O(n \log n)\) \(O(\log n)\) 구현이 직관적, 확장 쉬움
오일러 투어 + 희소 배열(sparse table) \(O(n \log n)\) \(O(1)\) 질의가 상수, RMQ로 환원

둘 다 아이디어는 "한 칸씩 올라가지 말고 \(2^k\)칸씩 점프하거나, LCA를
구간 최소(RMQ) 문제로 바꿔서" 로그 또는 상수 시간에 답하는 것입니다.


언제 어떤 것을 쓰나

  • 이진 트리 상승: 가장 널리 쓰이는 기본기. "\(u\)\(k\)번째 조상", "경로를
    로그 개 구간으로 분해" 같은 조상 점프가 필요한 확장에 그대로 이어집니다.
    대부분의 문제는 이걸로 충분합니다.
  • 오일러 투어 + 희소 배열: 질의를 \(O(1)\) 로 답해야 하거나, 이미 오일러
    투어/구간 최소 구조를 쓰고 있을 때. 트리를 방문 순서로 펼쳐 깊이 배열의
    구간 최소
    로 LCA를 얻습니다.

다음 강의에서 두 구현을 모두 다루고, 마지막 강의에서 거리·경로 질의로의 응용과
함정을 정리합니다.


복잡도 요약

  • 전처리: 두 방법 모두 \(O(n \log n)\) 시간·공간.
  • 질의: 이진 상승 \(O(\log n)\), 오일러+희소 배열 \(O(1)\).
  • 트리를 루트 기준으로 부모·깊이를 먼저 구해 두는 것이 공통 준비 단계이며,
    이는 DFS 또는 BFS로 \(O(n)\)에 끝납니다.
Lesson 2강 · 구현 — 이진 트리 상승과 오일러 투어 선택 8m

방법 A — 이진 트리 상승(binary lifting)

up[v][k] 를 "\(v\)\(2^k\)번째 조상"으로 정의합니다. 그러면

$$ \text{up}[v][0] = \text{parent}(v), \qquad \text{up}[v][k] = \text{up}[\,\text{up}[v][k-1]\,][k-1] $$

즉 "\(2^{k-1}\)칸 올라간 뒤 다시 \(2^{k-1}\)칸" 이 곧 "\(2^k\)칸"입니다. 전처리는
\(O(n \log n)\). 질의는 (1) 두 정점의 깊이를 맞추고, (2) 조상이 갈라지는
경계까지 함께 큰 점프부터 올라간 뒤, (3) 마지막에 부모를 취합니다.

깊이·부모는 재귀 대신 BFS로 채우면 깊은 트리에서도 스택 걱정이 없습니다.

C++

#include <bits/stdc++.h>
using namespace std;

int LOG;
vector<vector<int>> adj, up;   // up[v][k] = v의 2^k번째 조상
vector<int> dep;

void build(int n, int root) {
    LOG = 1;
    while ((1 << LOG) < n) LOG++;
    up.assign(n + 1, vector<int>(LOG));
    dep.assign(n + 1, 0);

    // BFS로 부모(up[*][0])와 깊이 채우기
    vector<char> vis(n + 1, 0);
    queue<int> q; q.push(root);
    vis[root] = 1; up[root][0] = root;         // 루트의 부모는 자기 자신(보초)
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) if (!vis[v]) {
            vis[v] = 1; dep[v] = dep[u] + 1;
            up[v][0] = u; q.push(v);
        }
    }
    // 희소 조상 표
    for (int k = 1; k < LOG; k++)
        for (int v = 1; v <= n; v++)
            up[v][k] = up[ up[v][k-1] ][k-1];
}

int lca(int a, int b) {
    if (dep[a] < dep[b]) swap(a, b);
    int d = dep[a] - dep[b];
    for (int k = 0; k < LOG; k++)            // 깊이 맞추기
        if (d >> k & 1) a = up[a][k];
    if (a == b) return a;                    // 한쪽이 다른 쪽 조상
    for (int k = LOG - 1; k >= 0; k--)       // 갈라지기 직전까지 함께 점프
        if (up[a][k] != up[b][k]) { a = up[a][k]; b = up[b][k]; }
    return up[a][0];                         // 마지막 한 칸이 LCA
}

Python

import sys
from collections import deque

def build(n, adj, root):
    LOG = max(1, n.bit_length())
    up = [[root] * LOG for _ in range(n + 1)]
    dep = [0] * (n + 1)
    vis = [False] * (n + 1)
    q = deque([root]); vis[root] = True
    while q:
        u = q.popleft()
        for v in adj[u]:
            if not vis[v]:
                vis[v] = True
                dep[v] = dep[u] + 1
                up[v][0] = u
                q.append(v)
    for k in range(1, LOG):
        for v in range(1, n + 1):
            up[v][k] = up[up[v][k-1]][k-1]
    return up, dep, LOG

def lca(a, b, up, dep, LOG):
    if dep[a] < dep[b]:
        a, b = b, a
    d = dep[a] - dep[b]
    for k in range(LOG):
        if d >> k & 1:
            a = up[a][k]
    if a == b:
        return a
    for k in range(LOG - 1, -1, -1):
        if up[a][k] != up[b][k]:
            a = up[a][k]; b = up[b][k]
    return up[a][0]

위 트리(정점 9개)에서 lca(4,9)=2, lca(7,8)=6, lca(4,7)=1, lca(9,5)=5 로,
1강의 손계산과 일치합니다.


방법 B — 오일러 투어 + 희소 배열 (\(O(1)\) 질의)

트리를 DFS로 돌며 정점을 방문할 때마다 기록하면 길이 \(2n-1\)의 오일러 투어
배열이 나옵니다. 두 정점 \(u, v\)첫 등장 위치 사이 구간에서 깊이가 가장
작은 정점
이 곧 \(\text{LCA}(u,v)\)입니다. 구간 최소는 희소 배열(sparse
table)
로 전처리 \(O(n \log n)\), 질의 \(O(1)\)에 답합니다.

C++

#include <bits/stdc++.h>
using namespace std;

vector<vector<int>> adj;
vector<int> euler, dep_e, first_occ;     // 투어, 각 위치의 깊이, 첫 등장 위치

void build_euler(int n, int root) {
    euler.clear(); dep_e.clear();
    first_occ.assign(n + 1, -1);
    vector<int> par(n + 1, 0), dep(n + 1, 0), ptr(n + 1, 0), stk;
    par[root] = root; stk.push_back(root);
    while (!stk.empty()) {
        int u = stk.back();
        euler.push_back(u); dep_e.push_back(dep[u]);   // 방문마다 기록
        if (first_occ[u] == -1) first_occ[u] = euler.size() - 1;
        bool moved = false;
        while (ptr[u] < (int)adj[u].size()) {
            int v = adj[u][ptr[u]++];
            if (v == par[u]) continue;
            par[v] = u; dep[v] = dep[u] + 1;
            stk.push_back(v); moved = true; break;
        }
        if (!moved) stk.pop_back();
    }
}

vector<vector<int>> sp;      // sp[k][i] = 구간 [i, i+2^k) 에서 깊이 최소인 위치
void build_sparse() {
    int L = euler.size(), K = 1;
    while ((1 << K) <= L) K++;
    sp.assign(K, vector<int>(L));
    for (int i = 0; i < L; i++) sp[0][i] = i;
    for (int k = 1; k < K; k++)
        for (int i = 0; i + (1 << k) <= L; i++) {
            int a = sp[k-1][i], b = sp[k-1][i + (1 << (k-1))];
            sp[k][i] = (dep_e[a] <= dep_e[b]) ? a : b;
        }
}

int lca(int u, int v) {
    int l = first_occ[u], r = first_occ[v];
    if (l > r) swap(l, r);
    int k = 31 - __builtin_clz(r - l + 1);
    int a = sp[k][l], b = sp[k][r - (1 << k) + 1];
    return euler[(dep_e[a] <= dep_e[b]) ? a : b];
}

Python (오일러 투어)

import sys
def build_euler(n, adj, root):
    euler, dep_e = [], []
    first = [-1] * (n + 1)
    par = [0] * (n + 1); dep = [0] * (n + 1); ptr = [0] * (n + 1)
    par[root] = root; stk = [root]
    while stk:
        u = stk[-1]
        euler.append(u); dep_e.append(dep[u])
        if first[u] == -1:
            first[u] = len(euler) - 1
        moved = False
        while ptr[u] < len(adj[u]):
            v = adj[u][ptr[u]]; ptr[u] += 1
            if v == par[u]:
                continue
            par[v] = u; dep[v] = dep[u] + 1
            stk.append(v); moved = True; break
        if not moved:
            stk.pop()
    return euler, dep_e, first

희소 배열은 위 C++과 같은 방식(깊이 최소 위치를 저장)으로 구성하면 되고, 질의는
first[u], first[v] 구간의 최소 깊이 위치를 꺼내 euler[...] 를 답합니다.
같은 예제 트리에서 두 방법의 답은 정확히 일치합니다.

Lesson 3강 · 심화와 변형 — 거리·경로 질의와 함정 선택 8m

응용 — 거리와 경로 질의

LCA의 진짜 쓸모는 트리 경로를 두 조각으로 쪼개는 데 있습니다.

두 정점 사이 거리

간선 가중치가 모두 1이면, \(u\)\(v\) 경로의 길이는

$$ \text{dist}(u, v) = \text{dep}[u] + \text{dep}[v] - 2\,\text{dep}[\text{LCA}(u,v)] $$

입니다. 앞 예제에서 \(\text{dist}(4,7)\)은 $\text{dep}[4]{=}2, \text{dep}[7]{=}3,\
\text{LCA}=1(\text{dep}=0)\(이므로 \)2 + 3 - 0 = 5$.

int dist(int u, int v) {            // 단위 가중치
    return dep[u] + dep[v] - 2 * dep[lca(u, v)];
}

가중 트리라면 루트에서의 가중 깊이 dw[v](루트→\(v\) 경로 합)를 미리
구해 두고 위 식의 depdw로 바꾸면 됩니다.

경로를 LCA에서 꺾어 처리

"경로 위 최댓값/합" 같은 질의는 \(u \to \text{LCA}\)\(v \to \text{LCA}\)
위로 올라가는 구간으로 나눠 각각 처리합니다. 이진 상승 표에 값을 함께
얹으면(예: mx[v][k] = \(v\)에서 \(2^k\)번째 조상까지의 최대 간선) 경로 최댓값도
\(O(\log n)\)에 구할 수 있습니다.

\(k\)번째 조상 / 특정 깊이 조상

이진 상승 표는 그 자체로 "\(u\)\(k\)번째 조상"을 \(O(\log n)\)에 줍니다 — \(k\)
이진수로 보고 켜진 비트마다 점프하면 됩니다. 트리에서 "위로 \(k\)칸" 질의는
그대로 이 코드를 씁니다.

int kth_ancestor(int v, int k) {
    for (int i = 0; i < LOG; i++)
        if (k >> i & 1) v = up[v][i];
    return v;   // k가 깊이보다 크면 루트(보초)로 수렴
}

자주 겪는 함정

  • 인덱스 규약(1-기반 vs 0-기반) — 정점 번호가 1부터인지 0부터인지에 맞춰
    배열 크기와 루프 범위를 정확히 맞추세요. 흔한 오답 원인입니다.
  • 루트의 부모 보초값up[root][0] = root(자기 자신)로 두면 깊이 맞추기와
    점프에서 배열 밖으로 나가지 않고, 답이 루트로 안전하게 수렴합니다. \(0\)
    보초로 쓰면 up[0][k]=0을 반드시 함께 정의해야 합니다.
  • 깊은 트리의 재귀 DFS — 정점이 \(10^5\)인 사슬 트리에서 재귀로 부모·깊이를
    채우면 스택 오버플로가 납니다. BFS(방법 A)나 명시적 스택
    DFS
    (방법 B)로 채우거나, Python이면 sys.setrecursionlimit을 크게 잡습니다.
  • LOG 크기 부족\(2^{\text{LOG}} \ge n\) 이 되도록 \(\text{LOG}\)를 잡아야
    합니다. 작게 잡으면 깊은 조상 점프가 불가능해 오답이 납니다.
  • 오일러 투어 길이 — 트리의 오일러 투어는 정확히 \(2n-1\)개 항목입니다.
    배열을 \(n\)으로만 잡으면 넘칩니다.
  • 깊이 맞추기와 조상 분기 두 단계 혼동 — 먼저 깊이를 같게 만든 뒤,
    같지 않으면 큰 점프부터 up[a][k] != up[b][k] 일 때만 올립니다. 이
    순서를 지켜야 정확히 LCA 바로 아래에서 멈춥니다.

다른 기법과의 관계

  • 타잔(Tarjan) 오프라인 LCA — 모든 질의를 미리 모아 유니온 파인드로 한 번의
    DFS에서 처리하면 거의 선형입니다. 질의를 온라인으로 받아야 하면 이진 상승이
    더 편합니다.
  • 오일러 투어 + 세그먼트 트리 — 희소 배열 대신 세그먼트 트리로 구간 최소를
    구해도 됩니다(값이 갱신되는 경우 유리).
  • 이 주제는 사이트의 트리(Trees) 트랙에 속하며, 트리 경로 질의·트리
    분할(HLD 등) 학습의 발판이 됩니다.

정리

  • LCA는 트리 경로를 위로 두 조각으로 쪼개는 도구 — 거리·경로 질의의 기본기.
  • 이진 상승: 전처리 \(O(n\log n)\), 질의 \(O(\log n)\), 확장성이 좋아 1순위.
  • 오일러 투어 + 희소 배열: 질의 \(O(1)\)이 필요할 때. LCA를 구간 최소로 환원.
  • 함정은 대부분 인덱스·보초값·재귀 깊이·LOG 크기에서 나옵니다.
Practice problem 알파카컵 1회: E - 알파카 여행 계획 선택 25m
A00005

알파카컵 1회: E - 알파카 여행 계획

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 알파카컵 1회: H - 알파카의 컵 보관소 선택 25m
A00008

알파카컵 1회: H - 알파카의 컵 보관소

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
02
Level 5 · Analyst

Analyst

고급 트리 · Analyst 단계

0/5 완료
Lesson 1강 · 개념 — 서브트리·경로를 구간으로 필수 8m

오일러 투어 테크닉이란

오일러 투어 테크닉(Euler tour technique, ETT) 은 트리를 DFS 방문 순서
일렬로 펴서, 트리의 서브트리(또는 경로)를 배열의 연속 구간으로 바꾸는
기법입니다. 그러면 세그먼트 트리·펜윅 트리 같은 구간 자료구조를 트리에 그대로
얹을 수 있습니다.

핵심: 각 정점 \(u\)에 DFS 진입 시각 tin[u]와 이탈 시각 tout[u]를 매깁니다.

불변식(서브트리 = 구간): 정점 \(u\)의 서브트리에 속한 모든 정점 \(v\)
\(tin[u] \le tin[v] \le tout[u]\)를 만족한다. 즉 \(u\)의 서브트리 = 배열 구간
\([\,tin[u],\ tout[u]\,]\)
.

DFS가 \(u\)에 들어가면 서브트리의 모든 정점을 방문한 뒤에야 \(u\)를 떠나므로, 그
정점들의 진입 시각이 \(tin[u]\)\(tout[u]\) 사이에 연속으로 놓입니다.


왜 유용한가 — 트리 질의를 구간 질의로

이 대응 덕분에 다음 트리 연산이 구간 연산이 됩니다.

트리 연산 구간 연산 (인덱스 = tin)
서브트리 전체에 \(+v\) 구간 \([tin[u], tout[u]]\)\(+v\)
서브트리 합/최값 질의 구간 \([tin[u], tout[u]]\) 질의
한 정점 값 변경 \(tin[u]\) 갱신

즉 "서브트리 갱신 + 서브트리 질의"가 세그먼트 트리(또는 펜윅)로 \(O(\log N)\)
처리됩니다. 트리를 한 번 펴 두면 그다음은 순수 배열 문제입니다.


두 가지 오일러 투어

  • 진입 순서만 기록(tin/tout) — 각 정점을 배열에 한 번 놓습니다(길이 \(N\)).
    서브트리 질의에 적합. 이번 강의의 주 대상.
  • 진입·이탈 모두 기록 — 각 정점을 두 번(들어갈 때·나올 때) 놓습니다(길이
    \(2N\)). 경로/LCA 질의(부분합으로 경로 합, 최소 깊이로 LCA)에 적합.

워크드 예제

트리(1이 루트): 1–2, 1–3, 2–4, 2–5, 3–6. DFS를 1→2→4→5→3→6 순으로 돌면:

정점 tin tout
1 0 5
2 1 3
4 2 2
5 3 3
3 4 5
6 5 5

정점 2의 서브트리 = \(\{2,4,5\}\) = 구간 \([tin[2], tout[2]] = [1, 3]\). 각 정점 가중치
\(w = [\,10,20,30,40,50,60\,]\)(정점 1..6)를 tin 위치에 놓고 펜윅으로 구간 합을
구하면, 서브트리(2)의 합 \(= w_2 + w_4 + w_5 = 20+40+50 = 110\). 실제로도 110.


언제 쓰는가

  • 서브트리 전체 갱신/질의(회사 조직도 하위 인원 합, 부분 트리 색칠 등).
  • 경로 질의(진입·이탈 2배 투어 또는 HLD와 결합).
  • LCA를 오일러 투어 + 희소 배열로 \(O(1)\)에.
  • "트리인데 구간 자료구조를 쓰고 싶다"가 신호입니다. 다음 강의에서 구현.
Lesson 2강 · 구현 — 서브트리 합 (오일러 투어 + 펜윅) 선택 8m

서브트리 합/갱신 (C++) — 검증된 코드

DFS로 tin/tout을 매기고, 정점 가중치를 tin 위치에 펜윅으로 올립니다. 서브트리
질의는 구간 \([tin[u], tout[u]]\) 합, 정점 갱신은 점 \(tin[u]\) 갱신.

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 100005;
int n, timer0 = 0;
vector<int> g[MAXN];
int tin[MAXN], tout[MAXN];

// 펜윅 트리 (점 갱신 + 구간 합)
ll fen[MAXN]; int N;
void upd(int i, ll v) { for (i++; i <= N; i += i & -i) fen[i] += v; }
ll qr(int i)          { ll s = 0; for (i++; i > 0; i -= i & -i) s += fen[i]; return s; }
ll range(int l, int r){ return qr(r) - (l ? qr(l - 1) : 0); }

void dfs(int u, int p) {
    tin[u] = timer0++;
    for (int v : g[u]) if (v != p) dfs(v, u);
    tout[u] = timer0 - 1;                    // 서브트리 = [tin[u], tout[u]]
}

int main() {
    n = 6;
    auto ae = [&](int a, int b) { g[a].push_back(b); g[b].push_back(a); };
    ae(1,2); ae(1,3); ae(2,4); ae(2,5); ae(3,6);
    dfs(1, 0);

    N = n;
    ll w[7] = {0, 10, 20, 30, 40, 50, 60};   // 정점 1..6 가중치
    for (int u = 1; u <= n; u++) upd(tin[u], w[u]);

    // 서브트리(2) 합 = 20+40+50 = 110, 서브트리(1) = 전체 210
    cout << range(tin[2], tout[2]) << " " << range(tin[1], tout[1]) << "\n";

    upd(tin[4], 100);                         // 정점 4 가중치 +100
    cout << range(tin[2], tout[2]) << "\n";   // 210
}

출력: 110 210 그리고 210. 정점 하나를 바꿔도 서브트리 합이 \(O(\log N)\)
갱신됩니다. 큰 트리는 재귀 DFS가 스택 오버플로를 낼 수 있으니 반복형 DFS나
스택 크기 확장을 고려하세요(3강 함정).

Python (반복형 DFS로 안전하게)

import sys
def euler_subtree(n, edges, w):
    g = [[] for _ in range(n + 1)]
    for a, b in edges:
        g[a].append(b); g[b].append(a)
    tin = [0]*(n+1); tout = [0]*(n+1)
    timer = 0
    # 반복형 DFS (재귀 한도 회피)
    stack = [(1, 0, False)]
    order = []
    while stack:
        u, p, processed = stack.pop()
        if processed:
            tout[u] = timer - 1
            continue
        tin[u] = timer; timer += 1
        stack.append((u, p, True))
        for v in g[u]:
            if v != p:
                stack.append((v, u, False))

    # 펜윅
    fen = [0]*(n+1)
    def upd(i, v):
        i += 1
        while i <= n:
            fen[i] += v; i += i & -i
    def qr(i):
        i += 1; s = 0
        while i > 0:
            s += fen[i]; i -= i & -i
        return s
    for u in range(1, n+1):
        upd(tin[u], w[u])
    def subtree_sum(u):
        l, r = tin[u], tout[u]
        return qr(r) - (qr(l-1) if l else 0)
    return subtree_sum

f = euler_subtree(6, [(1,2),(1,3),(2,4),(2,5),(3,6)], [0,10,20,30,40,50,60])
print(f(2), f(1))     # 110 210

변형 — 세그먼트 트리 + 서브트리 lazy 갱신

"서브트리 전체에 \(+v\)" 같은 구간 갱신이 필요하면 펜윅 대신 느리게 갱신되는
세그먼트 트리
tin 배열 위에 얹습니다. update(tin[u], tout[u], +v)
서브트리 전체를 \(O(\log N)\)에 더하고, 점 질의는 query(tin[v]). 트리 위의
"부하 전파"가 배열 위 lazy propagation으로 그대로 내려옵니다.

핵심은 언제나 동일합니다: 서브트리 ↔ 구간 \([tin[u], tout[u]]\) 대응을 세워
두면, 어떤 구간 자료구조든 트리에 얹을 수 있습니다.

Lesson 3강 · 심화·변형 — 경로 질의, LCA, 함정 선택 8m

변형 1 — 진입·이탈 2배 투어로 경로 합

각 정점을 진입할 때 \(+w\), 이탈할 때 \(-w\) 로 배열(길이 \(2N\))에 두면, 루트에서
정점 \(u\)까지 경로 합이 배열의 접두 합 prefix(tin[u])가 됩니다. 조상에서
더한 값이 서브트리를 벗어날 때 상쇄되기 때문입니다.

이를 이용하면 "루트→\(u\) 경로 위 정점 가중치 합"과 "서브트리 전체 \(+v\)"를 한
펜윅으로 동시에 처리할 수 있습니다(진입에 \(+v\), 이탈 다음 칸에 \(-v\)를 놓는 차분).
경로 갱신+경로 질의까지 필요하면 HLD(무거운-가벼운 분할) 와 결합합니다.


변형 2 — 오일러 투어 + 희소 배열로 O(1) LCA

각 정점을 방문할 때마다(진입·복귀 모두) 배열에 정점과 깊이를 기록하면
(길이 \(2N-1\)), 두 정점 \(u, v\)LCA는 그 사이 구간에서 깊이가 최소인 정점
입니다. 깊이 배열에 희소 배열(정적 RMQ)을 얹으면 LCA를 전처리 \(O(N\log N)\),
질의 \(O(1)\)에 답합니다.

// euler[]: 방문 순서의 정점, depth_of[]: 각 방문의 깊이
// first[u]: u가 처음 등장한 위치
// LCA(u,v) = euler[ argmin depth over [first[u], first[v]] ]

"오일러 투어로 펴고 → 구간 min으로 LCA"는 LCA의 대표 구현 중 하나입니다.


흔한 함정

  • 깊은 트리의 재귀 DFS 스택 오버플로\(N\)이 크고 트리가 사슬 모양이면 재귀
    깊이가 \(10^5\) 이상이 되어 스택이 터집니다. 반복형 DFS로 바꾸거나(파이썬은
    특히 필수), C++에서 스택 크기를 늘리세요. 이 실수는 큰 테스트에서만 터져 찾기
    어렵습니다.
  • tout의 정의 — "마지막 자손의 tin"으로 두는 방식(위 코드, 길이 \(N\))과 "이탈
    시각을 따로 증가"(길이 \(2N\))가 있습니다. 서브트리 구간은 \([tin[u], tout[u]]\)
    일관되게 서브트리를 덮어야 합니다. 두 규약을 섞지 마세요.
  • 1-indexed vs 0-indexed — 펜윅은 보통 1-based, tin은 0-based로 매기면
    변환에서 실수하기 쉽습니다. upd(tin[u], ...) 내부에서 i++로 맞추는 등
    한 곳에서만 오프셋을 처리하세요.
  • 정점 값 vs 간선 값 — 서브트리/경로 질의가 정점 가중치인지 간선
    가중치인지 구분해야 합니다. 간선 값은 보통 "자식 정점"에 얹어(각 간선을 아래쪽
    끝점에 귀속) 정점 문제로 환원합니다. 루트는 위 간선이 없으니 제외.
  • 포레스트(숲) — 여러 트리면 각 루트에서 DFS를 돌리되 timer는 이어서
    증가시켜, 전체를 하나의 배열로 다룹니다.

무엇을 언제 — 요약

필요 방법
서브트리 합/최값 + 점 갱신 ETT(tin/tout) + 펜윅/세그
서브트리 전체 갱신 + 점 질의 ETT + 세그(lazy) 또는 차분 펜윅
루트→정점 경로 합 진입 \(+w\)/이탈 \(-w\) 2배 투어
임의 두 정점 경로 갱신·질의 ETT + HLD
LCA \(O(1)\) 2배 투어 + 희소 배열(구간 min)

트리 문제에서 "서브트리 전체" 또는 "루트로부터의 경로" 라는 말이 보이면, 트리를
오일러 투어로 펴서 구간 문제로 환원하는 것을 가장 먼저 떠올리세요. 그다음은
익숙한 배열 자료구조의 몫입니다.

Practice problem 트리 거리의 합 2 선택 25m
R01281

트리 거리의 합 2

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 흔들리는 길의 지름 선택 25m
R00663

흔들리는 길의 지름

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Diamond I 다이아몬드 I 지금 풀기
03
Level 6 · Strategist

Strategist

고급 트리 · Strategist 단계

0/10 완료
Lesson 헤비-라이트 분할 — 경로를 사슬 구간으로 필수 8m

어떤 문제를 푸는가

트리에서 경로에 대한 질의/갱신 을 빠르게 처리합니다 — "\(u\)\(v\) 경로 위 값의 합/최댓값",
"\(u\)\(v\) 경로의 모든 정점에 \(x\) 더하기", "부분트리 전체 갱신" 등. 배열이라면 세그먼트 트리로
\(O(\log N)\)이지만 트리의 경로는 구조가 복잡합니다. 헤비-라이트 분할(Heavy-Light Decomposition,
HLD)
은 트리를 몇 개의 사슬(chain) 로 쪼개, 각 사슬을 배열 구간으로 눕혀 세그먼트 트리에
얹습니다. 그러면 임의의 경로가 \(O(\log N)\)개의 사슬 구간 으로 분해되어, 경로 질의가
\(O(\log^2 N)\)에 처리됩니다.

  • "트리 위 경로 합/최대/갱신을 여러 번" 물으면 HLD.
  • 세그먼트 트리·펜윅 등 구간 자료구조를 트리 경로에 이식 하는 다리 역할.

헤비 간선과 라이트 간선

각 정점 \(u\)에서, 서브트리 크기가 가장 큰 자식 으로 가는 간선을 헤비 간선(heavy edge),
나머지를 라이트 간선(light edge) 이라 합니다. 헤비 간선만 이으면 트리가 여러 개의 헤비 사슬
로 분해됩니다.

핵심 성질: 루트에서 어떤 정점까지 내려가며 라이트 간선을 타는 횟수는 \(O(\log N)\) 입니다. 라이트
간선을 하나 탈 때마다 서브트리 크기가 절반 이하 로 줄기 때문입니다(그 자식이 헤비가 아니라는 건
그 서브트리가 부모의 절반 미만이라는 뜻). 따라서 어떤 경로든 지나는 서로 다른 사슬의 수가
\(O(\log N)\)
입니다.


어떻게 경로 질의로 이어지는가

각 사슬을 DFS 방문 순서(헤비 자식을 먼저 방문)로 연속된 배열 구간 에 배치합니다. 그러면 한
사슬 위의 부분 경로는 배열의 한 구간이 되어 세그먼트 트리로 즉시 처리됩니다.

경로 \(u\)\(v\) 질의는: 두 정점 중 사슬 머리(head)가 더 깊은 쪽을 그 사슬 머리까지 끌어올리며 사슬
구간 질의를 누적
, 같은 사슬에 도달하면 둘 사이 구간을 마지막으로 처리합니다. 사슬을 바꾸는 횟수가
\(O(\log N)\)이고 각 구간 질의가 \(O(\log N)\)이라 총 \(O(\log^2 N)\)입니다.


복잡도

연산 시간
전처리(두 번의 DFS) \(O(N)\)
경로 질의/갱신 \(O(\log^2 N)\)
부분트리 질의/갱신 \(O(\log N)\) (한 구간)
LCA \(O(\log N)\)

세그먼트 트리 대신 펜윅·희소 테이블을 얹어 상수를 줄이기도 합니다. 부분트리는 DFS 순서상 한 구간
이라 로그 하나로 끝납니다.

예시. 경로 최댓값 질의가 많은 트리에서, HLD로 각 사슬을 최대 세그먼트 트리에 얹으면 각 질의가
\(O(\log^2 N)\). 다음 강의에서 두 번의 DFS로 사슬을 만들고 경로 질의를 구현합니다.

Lesson HLD 구현 — 사슬 구성과 경로 질의 선택 8m

HLD 전처리와 경로 질의 (C++)

두 번의 DFS로 구성합니다. 1차 DFS: 서브트리 크기와 헤비 자식 결정. 2차 DFS: 사슬 머리(head)와
배열 위치(pos)를 부여(헤비 자식 먼저 방문 → 사슬이 연속 구간).

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;

vector<int> g[MAXN];
int par[MAXN], dep[MAXN], sz[MAXN], heavy[MAXN];
int head_[MAXN], pos[MAXN], curPos = 0;
long long val[MAXN];                          // 정점 초기값

int dfsSize(int u, int p){
    sz[u] = 1; heavy[u] = -1; par[u] = p;
    int mx = 0;
    for (int v : g[u]) if (v != p){
        dep[v] = dep[u] + 1;
        int s = dfsSize(v, u);
        if (s > mx) { mx = s; heavy[u] = v; }  // 가장 큰 자식 = 헤비
        sz[u] += s;
    }
    return sz[u];
}
void dfsChain(int u, int h){
    head_[u] = h; pos[u] = curPos++;
    if (heavy[u] != -1) dfsChain(heavy[u], h);         // 헤비: 같은 사슬, 먼저
    for (int v : g[u]) if (v != par[u] && v != heavy[u])
        dfsChain(v, v);                                // 라이트: 새 사슬 시작
}

세그먼트 트리(update(l,r), query(l,r))는 배열 인덱스 pos[] 위에서 동작합니다. 경로 질의는
사슬 머리가 더 깊은 쪽을 끌어올리며 누적합니다.

// u-v 경로의 정점값 합 (합 세그먼트 트리 seg 가정)
long long pathQuery(int u, int v){
    long long res = 0;
    while (head_[u] != head_[v]){
        if (dep[head_[u]] < dep[head_[v]]) swap(u, v);
        res += seg.query(pos[head_[u]], pos[u]);       // 사슬 머리~u 구간
        u = par[head_[u]];                             // 라이트 간선 타고 상위 사슬로
    }
    if (dep[u] > dep[v]) swap(u, v);
    res += seg.query(pos[u], pos[v]);                  // 같은 사슬 안 마지막 구간
    return res;
}
// u-v 경로의 모든 정점에 x 더하기
void pathUpdate(int u, int v, long long x){
    while (head_[u] != head_[v]){
        if (dep[head_[u]] < dep[head_[v]]) swap(u, v);
        seg.update(pos[head_[u]], pos[u], x);
        u = par[head_[u]];
    }
    if (dep[u] > dep[v]) swap(u, v);
    seg.update(pos[u], pos[v], x);
}

초기화. dep[root]=0; dfsSize(root,-1); dfsChain(root,root); 이후 각 정점의 초기값을
segpos[u] 위치에 넣습니다.


간선 가중치 트리 처리

값이 간선 에 있으면, 각 간선을 그 자식 정점 에 저장하는 것으로 정점 문제로 바꿉니다. 이때
경로 질의에서 두 정점이 만나는 지점(LCA)의 값은 경로에 포함되면 안 되므로, 같은 사슬 마지막
구간을 pos[u]+1 .. pos[v]처럼 LCA를 한 칸 건너뛰고 질의합니다.

// 간선 버전: 같은 사슬 구간에서 LCA(=u) 제외
if (u != v) res += seg.query(pos[u] + 1, pos[v]);

이 "LCA 한 칸 건너뛰기"를 빠뜨리는 것이 간선 HLD의 최다 실수입니다.


LCA 를 HLD로

경로 질의 루프의 부산물로 LCA가 나옵니다 — 사슬을 다 끌어올려 같은 사슬에 도달했을 때 더 얕은
이 LCA입니다.

int lca(int u, int v){
    while (head_[u] != head_[v]){
        if (dep[head_[u]] < dep[head_[v]]) swap(u, v);
        u = par[head_[u]];
    }
    return dep[u] < dep[v] ? u : v;
}

흔한 함정

  • 정점 vs 간선 — 간선 가중치면 LCA를 반드시 제외(pos[u]+1). 정점이면 포함.
  • 헤비 자식 먼저 방문dfsChain에서 헤비를 먼저 내려가야 사슬이 연속 구간이 됩니다. 순서를
    어기면 사슬이 쪼개져 로그가 깨집니다.
  • swap 조건 — 끌어올릴 때 사슬 머리의 깊이(dep[head_])로 비교해야 합니다. 정점 깊이로
    비교하면 무한 루프/오답.
  • 재귀 깊이 — 편향 트리에서 DFS 깊이가 \(N\)까지. 스택 확대 또는 반복 DFS.
Lesson 심화·응용 — 부분트리·경로 혼합과 변형 선택 8m

출제 신호

  • 트리에서 "경로 합/최대/최소/GCD 질의"와 "경로/정점 갱신"이 섞여 여러 번.
  • "부분트리 전체에 더하기/질의"(DFS 순서상 한 구간).
  • LCA·정점 거리·"경로 위 조건" 질의가 반복되는 정적 트리.
  • 오프라인으로 못 미루고 온라인·갱신 이 필요해 오일러 투어+세그로는 부족한 경우.

응용 1 — 경로 합/최대 + 갱신

가장 표준적인 사용: 정점(또는 간선) 값에 대해 경로 합·최댓값을 묻고, 점/경로/부분트리 갱신을 섞습니다.
세그먼트 트리에 지연 전파(lazy) 를 얹으면 "경로에 \(x\) 더하기 + 경로 합" 같은 구간 갱신+구간 질의를
\(O(\log^2 N)\)에 처리합니다. 결합 법칙이 성립하는 어떤 모노이드(합, 최대, GCD, 행렬곱 등)든 얹을 수
있습니다.


응용 2 — 부분트리 vs 경로 동시

DFS 진입 순서(pos)에서 정점 \(u\)부분트리는 \([pos[u],\,pos[u]+sz[u]-1]\)의 한 구간 입니다.
따라서 같은 세그먼트 트리로 부분트리 질의/갱신은 \(O(\log N)\), 경로는 \(O(\log^2 N)\)을 동시에
지원할 수 있습니다. "부분트리에 더하고, 경로 합을 묻는" 혼합 문제가 HLD의 대표 무대입니다.

// u 의 부분트리 전체에 x 더하기
seg.update(pos[u], pos[u] + sz[u] - 1, x);

응용 3 — 경로 위의 "\(k\)번째"·색·조건

  • 경로 위 최댓값·kth — 최대 세그먼트 트리, 또는 병합 정렬 트리/persistent 세그를 얹어 경로의
    \(k\)번째 값 질의.
  • 경로 색칠(Chtholly/구간 대입) — 경로에 같은 값 대입 + 조각 수 세기.
  • 경로 XOR/행렬곱 — 결합 가능한 연산이면 그대로 이식.

HLD는 "경로를 \(O(\log N)\)개의 구간으로" 바꿔줄 뿐이므로, 그 위에 어떤 구간 자료구조를 얹느냐
문제를 결정합니다.


함정과 변형 총정리

  • 정점/간선 구분과 LCA 처리 — 간선 문제의 "LCA 한 칸 건너뛰기"가 최다 버그.
  • 로그² 상수\(O(\log^2 N)\)이라 상수가 크므로, 세그먼트 트리 구현(비재귀·펜윅)과 입출력
    최적화가 시간을 가릅니다.
  • 비가환 연산 주의 — 행렬곱처럼 순서가 중요한 연산은 경로를 끌어올리며 좌/우 방향 을 구분해
    두 스택에 나눠 담은 뒤 합쳐야 합니다.
  • 동적 트리 아님 — HLD는 간선 구조가 고정 된 트리 전용입니다. 간선이 link/cut으로 바뀌면
    링크-컷 트리(다음 단원)로 넘어가야 합니다. 이 경계를 아는 것이 중요합니다.
  • 변형. 오일러 투어 트리(부분트리 전용), 작은 것부터 합치기(small-to-large), 세그먼트 트리
    머지 등과 함께 트리 질의 도구상자를 이룹니다. 경로+갱신+온라인이면 HLD가 가장 무난한 선택입니다.
Practice problem 트리 거리의 합 2 선택 25m
R01281

트리 거리의 합 2

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 트리 경로 합 질의 선택 25m
R00426

트리 경로 합 질의

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 센트로이드 분할 — 무게중심으로 경로를 나누다 필수 8m

어떤 문제를 푸는가

트리에서 모든 경로 에 대한 통계를 물을 때 — "길이(또는 가중치 합)가 정확히 \(K\)인 경로의 수",
"거리가 \(K\) 이하인 정점 쌍", "어떤 조건을 만족하는 경로가 존재하는가" — 순진하게 모든 쌍을 보면
\(O(N^2)\)입니다. 센트로이드 분할(centroid decomposition) 은 트리를 무게중심(centroid) 에서
쪼개는 분할 정복으로, 이런 경로 문제를 흔히 \(O(N\log N)\) 또는 \(O(N\log^2 N)\)에 해결합니다.

  • "트리의 모든 경로를 세거나 조건을 확인"하는 문제의 표준 무기.
  • 정적 트리의 경로 카운팅, 거리 질의(센트로이드 트리) 등에 두루 쓰입니다.

무게중심(centroid)이란

트리에서 어떤 정점을 지웠을 때 남는 모든 조각(서브트리)의 크기가 전체의 절반 이하 가 되는 정점을
무게중심이라 합니다. 크기 \(N\)인 트리에는 항상 무게중심이 존재하며(1개 또는 2개), 이를 제거하면 각
조각의 크기가 \(\le N/2\)로 보장됩니다.

이 "절반 이하" 보장이 핵심입니다. 무게중심에서 쪼개고, 각 조각에서 다시 무게중심을 찾아 쪼개기를
반복하면 재귀 깊이가 \(O(\log N)\) 에 그칩니다.


왜 경로 문제에 강한가 — "무게중심을 지나는 경로"

임의의 두 정점을 잇는 경로는, 현재 조각의 무게중심 \(c\)를 기준으로 볼 때 둘 중 하나입니다.

  1. \(c\)를 지나는 경로\(c\)에서 각 정점까지의 거리를 재면, 서로 다른 가지에 속한 두 정점의
    거리 합으로 표현됩니다. 이걸 한 번에 세면 됩니다.
  2. \(c\)를 지나지 않는 경로 — 완전히 한 조각 안에 있으므로, 그 조각에서 재귀적으로 처리됩니다.

즉 각 경로는 그 경로의 두 끝점을 처음으로 분리하는 무게중심에서 정확히 한 번 계산됩니다. 각
분할 레벨에서 전체 정점을 한 번씩 훑고, 레벨이 \(O(\log N)\)개이므로 총 \(O(N\log N)\)(정렬/이분탐색이
끼면 \(O(N\log^2 N)\))입니다.


같은 가지 중복 빼기 (포함-배제)

\(c\)를 중심으로 "모든 거리 쌍"을 세면, 사실 같은 가지 안에 있어 \(c\)를 지나지 않는 쌍 까지 잘못
포함됩니다. 이를 각 가지별로 따로 세어 빼주는 포함-배제(inclusion-exclusion) 로 보정합니다.
"전체(중복 포함)에서 각 가지 내부(같은 방향 쌍)를 빼면 서로 다른 가지 쌍"이라는 논리입니다.


복잡도

단계 시간
각 조각의 무게중심 찾기 조각 크기에 선형
무게중심 지나는 경로 집계 조각 크기에 선형(정렬 시 \(\times\log\))
분할 깊이 \(O(\log N)\)
전체 \(O(N\log N)\) ~ \(O(N\log^2 N)\)

예시. 정점 값이 없는 단순 트리에서 "거리가 정확히 \(K\)인 경로 수"는, 각 무게중심에서 정점들의
거리를 배열로 모아 거리 \(d\)의 빈도를 세고 \(d+d'=K\)인 쌍을 합성곱/투 포인터로 세는 것으로 얻습니다.
다음 강의에서 무게중심 찾기와 경로 카운팅을 구현합니다.

Lesson 무게중심 찾기와 경로 카운팅 구현 선택 8m

센트로이드 분할 뼈대 (C++)

핵심 세 조각: (1) 서브트리 크기 계산, (2) 무게중심 찾기, (3) 무게중심 기준 집계 후 각 가지로 재귀.
이미 무게중심으로 쓰인(제거된) 정점은 removed[]로 막아 조각 밖으로 넘어가지 않게 합니다.

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;

vector<int> g[MAXN];
bool removed[MAXN];
int sz[MAXN];

int calcSize(int u, int p){
    sz[u] = 1;
    for (int v : g[u]) if (v != p && !removed[v]) sz[u] += calcSize(v, u);
    return sz[u];
}
// 조각 크기 tot 안에서 무게중심 찾기
int findCentroid(int u, int p, int tot){
    for (int v : g[u]) if (v != p && !removed[v])
        if (sz[v] > tot / 2) return findCentroid(v, u, tot);   // 큰 가지로 이동
    return u;
}

long long answer = 0;

void decompose(int entry){
    int tot = calcSize(entry, -1);
    int c = findCentroid(entry, -1, tot);
    removed[c] = true;

    // ---- 여기서 c 를 지나는 경로를 집계 (문제별 로직) ----
    collectThrough(c);

    for (int v : g[c]) if (!removed[v]) decompose(v);          // 각 조각 재귀
}
  • findCentroid는 "가장 큰 가지가 \(tot/2\)를 넘으면 그쪽으로 이동"을 반복해 무게중심에 도달합니다.
  • removed[c] = true로 막은 뒤 각 가지로 decompose하면 조각들이 서로 섞이지 않습니다.

예제 — 거리 \(K\) 이하(또는 정확히 \(K\))인 경로 수

무게중심 \(c\)에서 각 정점까지의 거리를 모읍니다. "\(c\)를 지나는 경로"는 서로 다른 가지의 두 거리
\(d_1,d_2\)\(d_1+d_2\le K\)를 만족하는 쌍입니다. 전체 거리 배열에서 세고, 같은 가지 내부 쌍은 빼는
포함-배제 를 씁니다.

vector<int> depthBuf;
void gather(int u, int p, int d){             // c로부터의 거리 수집
    depthBuf.push_back(d);
    for (int v : g[u]) if (v != p && !removed[v]) gather(v, u, d + 1);
}
// 정렬된 거리 배열에서 합 <= K 인 순서쌍 수 (투 포인터)
long long countPairs(vector<int>& a, int K){
    sort(a.begin(), a.end());
    long long res = 0; int l = 0, r = (int)a.size() - 1;
    while (l < r){
        if (a[l] + a[r] <= K) { res += r - l; l++; }
        else r--;
    }
    return res;
}
void collectThrough(int c){
    vector<int> all;
    all.push_back(0);                          // c 자신 (거리 0)
    for (int v : g[c]) if (!removed[v]){
        depthBuf.clear();
        gather(v, c, 1);
        // 같은 가지 내부 쌍은 c를 안 지나므로 빼준다
        answer -= countPairs(depthBuf, K);
        for (int d : depthBuf) all.push_back(d);
    }
    answer += countPairs(all, K);              // 전체(중복 포함)에서
}

answer += 전체쌍 - 각가지내부쌍 이 정확히 "\(c\)를 지나는 유효 경로 수"가 됩니다. all\(0\)(=\(c\)
자신)을 넣어 \(c\)를 한 끝으로 하는 경로도 포함시킵니다.


흔한 함정

  • removed 누락 — 재귀·크기 계산·수집 어디서든 제거된 정점을 넘어가면 조각이 섞여 시간·정답 붕괴.
  • 크기 재계산 — 각 decompose에서 조각 크기를 그 조각 기준으로 다시 계산해야 무게중심이
    올바릅니다. 전역 크기를 재사용하면 틀립니다.
  • 포함-배제 빠뜨림 — 전체 쌍만 세면 같은 가지 쌍(중복)이 남습니다. 가지별로 빼야 정답.
  • 자기 자신 처리\(c\)를 한 끝으로 하는 경로(거리 0 포함) 처리 여부를 문제 정의에 맞게.
  • 깊이 오버플로/스택\(N\)이 크면 재귀 대신 명시적 스택 또는 스택 크기 확대 고려.
Lesson 심화·응용 — 센트로이드 트리와 근접 질의 선택 8m

출제 신호

  • 트리에서 "모든 경로를 세거나 조건 확인" — 거리 \(=K\)/\(\le K\) 경로 수, 특정 색/값 패턴 경로,
    경로 위 XOR·합 조건 등.
  • 정적 트리에서 "임의의 두 점 사이 거리·경로 통계"를 여러 번 질의(→ 센트로이드 트리).
  • 순진한 풀이가 \(O(N^2)\)이고 "트리를 반씩 쪼갤 수 있으면 좋겠다"는 직관이 드는 문제.

응용 1 — 센트로이드 트리 (거리 질의 자료구조)

분할 과정에서 "이 조각의 무게중심 \(c\)의 부모는, 이 조각을 잘라낸 상위 무게중심"으로 연결하면
센트로이드 트리 라는 깊이 \(O(\log N)\)의 보조 트리가 만들어집니다. 원 트리의 임의의 두 정점
\(u,v\)의 경로는 센트로이드 트리에서 그들의 조상인 어떤 무게중심 을 반드시 지납니다.

이를 이용하면 "가장 가까운 특별한 정점까지 거리", "거리 \(\le r\) 안의 갱신/질의" 같은 동적 질의를
각 정점이 자신의 \(O(\log N)\)개 센트로이드 조상에만 정보를 저장/조회하는 방식으로 처리합니다.

int par[MAXN];                    // 센트로이드 트리에서의 부모
void build(int entry, int cpar){
    int tot = calcSize(entry, -1);
    int c = findCentroid(entry, -1, tot);
    par[c] = cpar; removed[c] = true;
    for (int v : g[c]) if (!removed[v]) build(v, c);
}

각 정점은 자신부터 루트까지 센트로이드 조상 사슬이 \(O(\log N)\)이라, 갱신/질의 시 이 사슬만 타면
됩니다. 두 정점 사이 실제 거리는 원 트리의 LCA 기반 거리로 계산합니다.


응용 2 — 갱신이 있는 근접 질의

"정점 \(v\)를 켠다(activate)", "정점 \(u\)에서 가장 가까운 켜진 정점까지 거리"처럼 갱신·질의가 섞인
문제는 센트로이드 트리의 대표 응용입니다. 켤 때는 \(u\)의 모든 센트로이드 조상 \(c\)에 "\(c\)까지 거리의
최솟값"을 갱신하고, 질의할 때도 \(u\)의 조상들만 훑어 \(\text{dist}(u,c)+\text{stored}[c]\)의 최소를
취합니다. 각 연산 \(O(\log N)\)(거리 계산이 \(O(\log N)\)이면 \(O(\log^2 N)\)).


응용 3 — 경로 조건 카운팅 변형

  • 가중치 경로 합 \(=K\) — 거리를 간선 가중치 합으로 바꾸면 됨(투 포인터 대신 해시맵/정렬).
  • 색/문자 조건 경로 — 무게중심에서 각 가지의 상태를 비트마스크·해시로 모아 매칭.
  • 트리 위 XOR 경로 — 거리 대신 루트-경로 XOR을 모아 \(x \oplus y = K\) 쌍을 트라이/해시로.

모두 "무게중심을 지나는 경로만 매칭 + 같은 가지 포함-배제"라는 동일 골격에서 집계 로직만 바꿉니다.


함정과 변형 총정리

  • 크기 재계산·removed 규율 이 정확성과 복잡도의 전부입니다. 한 번이라도 조각을 새면 무너집니다.
  • 포함-배제 일관성 — 전체에서 가지 내부를 빼는 논리를 집계마다 정확히.
  • 중복 무게중심(2개) — 크기 \(N/2\) 조각이 둘일 때 무게중심이 두 개일 수 있으나, 위 findCentroid
    결정론적으로 하나를 고르므로 문제없음.
  • 거리 계산 비용 — 센트로이드 트리 응용에서 실제 거리를 \(O(\log N)\) LCA로 구하면 질의당
    \(O(\log^2 N)\). 상수·로그 관리를 의식하세요.
  • 정적 vs 동적 — 순수 경로 카운팅은 1패스 분할, 갱신형은 센트로이드 트리로. 문제 유형을 먼저
    구분하는 것이 설계의 출발점입니다.
Practice problem 산책로 선택 25m
KOI00097

산책로

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 가장 가까운 대피소 선택 25m
R00443

가장 가까운 대피소

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Gold V 골드 V 지금 풀기
04
Level 8 · Expert

Expert

고급 트리 · Expert 단계

0/5 완료
Lesson 링크-컷 트리 — 동적 트리와 access 필수 8m

어떤 문제를 푸는가

링크-컷 트리(Link-Cut Tree, LCT)간선이 실시간으로 추가·삭제되는 숲(forest) 위에서 경로
질의를 처리하는 동적 트리 자료구조입니다. HLD가 고정된 트리의 경로 질의를 다뤘다면, LCT는 여기에
link(간선 추가)·cut(간선 삭제) 까지 얹어 연산당 \(O(\log N)\) 상각(amortized) 에 처리합니다.

지원하는 대표 연산:

  • link(u, v) — 두 다른 트리를 잇는 간선 추가.
  • cut(u, v) — 간선 삭제.
  • connected(u, v) — 같은 트리에 속하는가.
  • path query(u, v)\(u\)\(v\) 경로의 합/최대/최소 등.
  • makeRoot(u)\(u\)를 트리의 루트로 바꾸기.

"간선이 바뀌는 트리에서 연결성·경로 질의"가 필요하면 LCT입니다. 동적 연결성, 온라인 MST, 트리의
경로 갱신 등이 무대입니다.


핵심 개념 — preferred path와 splay 숲

LCT는 각 트리를 여러 개의 선호 경로(preferred path) 로 나누고, 각 선호 경로를 하나의 스플레이
트리(splay tree)
로 표현합니다. 스플레이 트리는 그 경로 위 정점들을 깊이 순으로 정렬
담습니다(중위 순회 = 경로 순서). 선호 경로들끼리는 path-parent 포인터 로 연결됩니다(자식→부모
방향만 있는 "가벼운" 연결).

  • 한 정점의 스플레이 트리 안 순서는 곧 그 선호 경로 위의 위/아래 순서.
  • 선호 경로가 아닌 간선은 스플레이 트리의 자식이 아니라 path-parent(단방향)로만 이어집니다.

access — 모든 연산의 심장

access(x) 는 "루트에서 \(x\)까지의 경로를 하나의 선호 경로로 만들고, 그 스플레이 트리의 루트로
\(x\)를 올리는" 연산입니다. 위로 올라가며 각 선호 경로를 splay하고, 위쪽 경로의 아래 부분을 현재
경로로 이어 붙입니다. access 뒤에는 "\(x\)의 스플레이 트리 = 루트~\(x\) 경로"가 되어, 그 경로 전체의
집계값이 스플레이 루트에 모입니다.

  • path query(u,v) = makeRoot(u); access(v);\(v\)의 스플레이 서브트리 집계값.
  • makeRoot(u) = access(u) 후 스플레이 트리 전체를 뒤집기(reverse) — 지연 전파로 \(O(1)\) 표시.
  • link/cut 도 access + splay 조합으로 표현됩니다.

\(O(\log N)\)인가

LCT의 시간 복잡도는 스플레이 트리의 상각 분석과 "선호 자식 변경 횟수"의 무거운/가벼운 간선 논증이
결합되어 연산당 상각 \(O(\log N)\) 이 됩니다(엄밀히는 \(O(\log N)\) 상각, 최악 한 연산은 더 걸릴 수
있으나 총합이 보장됨). HLD의 \(O(\log^2 N)\)보다 로그 하나가 빠르면서, 간선 변경까지 지원한다는
것이 LCT의 강점입니다.

연산 시간(상각)
access / makeRoot \(O(\log N)\)
link / cut / connected \(O(\log N)\)
경로 질의/갱신 \(O(\log N)\)

다음 강의에서 스플레이·access·link·cut을 뒤집기 지연 전파와 함께 구현합니다.

Lesson LCT 구현 — splay·access·link·cut 선택 8m

LCT 표준 구현 (C++) — 경로 합 + 뒤집기

정점 인덱스는 \(1..N\), 0은 널(null) 노드로 항등원(\(\text{sum}=0\))입니다. isRoot는 "이 노드가
자기 스플레이 트리의 루트인가"(부모의 자식이 아님 = path-parent로만 연결)를 뜻합니다.

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 100005;

struct LCT {
    int ch[MAXN][2], fa[MAXN];
    bool rev[MAXN];
    ll val[MAXN], sum[MAXN];

    bool isRoot(int x){ return ch[fa[x]][0] != x && ch[fa[x]][1] != x; }
    int dir(int x){ return ch[fa[x]][1] == x; }
    void pull(int x){ sum[x] = sum[ch[x][0]] ^ 0 ^ val[x] ^ 0 ^ sum[ch[x][1]]; }
    // (합이 필요하면 ^ 를 + 로 바꾸고 null sum=0 유지)
    void reverse(int x){ if(!x) return; swap(ch[x][0], ch[x][1]); rev[x] ^= 1; }
    void push(int x){
        if(rev[x]){ reverse(ch[x][0]); reverse(ch[x][1]); rev[x] = 0; }
    }
    void rotate(int x){
        int y = fa[x], z = fa[y], k = dir(x), w = ch[x][k^1];
        if(!isRoot(y)) ch[z][dir(y)] = x;
        ch[x][k^1] = y; ch[y][k] = w;
        if(w) fa[w] = y;
        fa[y] = x; fa[x] = z;
        pull(y); pull(x);
    }
    void pushAll(int x){ if(!isRoot(x)) pushAll(fa[x]); push(x); }
    void splay(int x){
        pushAll(x);
        while(!isRoot(x)){
            int y = fa[x];
            if(!isRoot(y)) rotate(dir(x) == dir(y) ? y : x);
            rotate(x);
        }
    }
    // 루트~x 를 하나의 선호 경로로, x 를 스플레이 루트로
    int access(int x){
        int last = 0;
        for(int y = x; y; y = fa[y]){
            splay(y); ch[y][1] = last; pull(y); last = y;
        }
        splay(x);
        return last;
    }
    void makeRoot(int x){ access(x); reverse(x); }
    int findRoot(int x){
        access(x);
        while(ch[x][0]){ push(x); x = ch[x][0]; }
        splay(x);
        return x;
    }
    bool connected(int x, int y){ return findRoot(x) == findRoot(y); }
    void link(int x, int y){                 // x,y 가 다른 트리라고 가정
        makeRoot(x);
        if(findRoot(y) != x) fa[x] = y;       // 안전 검사(이미 연결이면 무시)
    }
    void cut(int x, int y){                   // 간선 (x,y) 가 존재한다고 가정
        makeRoot(x); access(y);
        if(ch[y][0] == x && ch[x][1] == 0){
            ch[y][0] = fa[x] = 0; pull(y);
        }
    }
    ll query(int x, int y){                   // x-y 경로 집계
        makeRoot(x); access(y);
        return sum[y];
    }
    void modify(int x, ll v){                 // 정점 값 갱신
        splay(x); val[x] = v; pull(x);
    }
} lct;
  • pull은 왼쪽·자신·오른쪽을 결합합니다. 위 코드는 XOR 예시이며, 경로 합이면 +로, 최대면
    max로 바꾸고 널 노드의 항등원(합 0, 최대 \(-\infty\))을 맞춥니다.
  • push/reverse경로 뒤집기 지연 전파 입니다. makeRoot가 스플레이 트리 전체를 뒤집는데,
    이를 매번 실제로 뒤집지 않고 표시만 하고 내려가며 전파합니다.
  • splay 전에 pushAll로 조상들의 지연을 먼저 내려야 회전이 올바릅니다. 이 순서를 어기는 것이
    LCT 최다 버그
    입니다.

왜 이렇게 동작하는가 — access 한 줄씩

for(int y = x; y; y = fa[y]){ splay(y); ch[y][1] = last; pull(y); last = y; }

\(x\)부터 path-parent를 타고 루트까지 올라가며, 각 선호 경로를 splay해 루트로 만든 뒤 오른쪽
자식(=아래쪽 경로)을 방금 처리한 아래 경로(last)로 교체
합니다. 이렇게 하면 루트~\(x\)가 하나의
스플레이 트리(선호 경로)로 합쳐집니다. 마지막 splay(x)\(x\)를 그 트리의 루트로 올리면, \(x\)
sum에 경로 전체 집계가 모입니다.


흔한 함정

  • push 순서splay(x) 전에 루트→\(x\) 경로의 지연을 모두 내려야(pushAll) 회전이 정확합니다.
  • isRoot 판정 — path-parent와 스플레이 자식을 혼동하면 회전이 트리 밖으로 샙니다. isRoot
    "부모의 자식이 아니면 스플레이 루트"로 정확히 정의.
  • link/cut 전제link는 두 정점이 다른 트리, cut은 간선이 실제로 존재 함을 가정.
    안전하려면 connected/인접성 검사를 덧붙입니다.
  • 널 노드 항등원sum[0], val[0]을 연산의 항등원으로 초기화. 합이면 0, 최대면 아주 작은 값.
  • 비가환 연산 — 최소·합은 대칭이라 뒤집기와 무관하지만, 순서 의존 연산(행렬곱 등)은 뒤집기 시
    정/역 두 집계 를 함께 관리해야 합니다.
Lesson 심화·응용 — 동적 연결성·온라인 MST 선택 8m

출제 신호

  • 간선이 추가·삭제되는 트리/숲에서 연결성·경로 질의.
  • "두 정점이 연결되었는가"를 link/cut과 섞어 온라인 으로.
  • 동적으로 변하는 트리의 경로 합/최대/갱신.
  • 온라인 최소 신장 트리(간선을 하나씩 추가하며 사이클의 최대 간선 교체).

응용 1 — 동적 연결성 & 경로 질의

가장 직접적인 사용은 link/cut/connected와 경로 집계의 조합입니다. connected(u,v)
findRoot(u)==findRoot(v)로, query(u,v)는 makeRoot+access 후 스플레이 루트의 집계로 얻습니다.
간선 가중치는 HLD와 마찬가지로 간선을 대리 정점 으로 놓거나 자식 정점에 실어 정점 문제로 바꿉니다.

// 예: u-v 경로 위 최대 간선값 (간선을 대리 정점으로 삽입한 모델)
// link(u, e); link(e, v);  cut 시 두 간선 모두 제거

응용 2 — 온라인 최소 신장 트리 / 최대 간선 교체

간선을 하나씩 추가하며 MST를 유지하는 문제(예: 간선이 순차적으로 주어지고 각 시점의 MST 무게):
새 간선 \((u,v,w)\)를 넣을 때 이미 연결돼 있으면 경로 \(u\)\(v\)최대 간선 을 LCT로 찾아, 그보다
\(w\)가 작으면 그 최대 간선을 cut하고 새 간선을 link합니다. 각 갱신 \(O(\log N)\). "간선 삽입형 동적
MST"의 정석 패턴입니다.


응용 3 — makeRoot·LCA·거리

  • 경로 뒤집기(makeRoot) 로 임의의 정점을 루트로 바꿔 "루트가 바뀌는" 질의를 지원.
  • 거리 — 경로 위 정점 수/간선 가중치 합을 sum으로 집계.
  • 부분트리 집계 — 기본 LCT는 경로용이지만, 가상 자식 집계(subtree aggregate) 를 추가로
    유지하면 부분트리 합·크기까지 확장됩니다(상위 주제; path-parent로 매달린 서브트리 정보를 별도
    누적).

함정과 변형 총정리

  • 상각 복잡도 — LCT는 상각 \(O(\log N)\)입니다. 한 연산이 드물게 더 걸릴 수 있으나 총합이 보장.
    최악 보장이 필요하면 다른 균형 구조를 고려.
  • 지연 전파 정확성 — 뒤집기 외에 "경로에 값 더하기"(구간 덧셈 lazy)를 얹으려면 sum·lazy를
    push/pull에서 일관되게 갱신해야 합니다.
  • link/cut 전제 검사 — 실전에선 잘못된 link(이미 연결)·cut(간선 없음)을 방어적으로 검사.
  • 비가환 연산 — 뒤집기가 있으면 순서 의존 집계는 정/역 두 값을 함께 뒤집어야 정확합니다.
  • 언제 HLD 대신 LCT인가 — 간선 구조가 고정 이면 HLD(\(O(\log^2)\))가 상수도 작고 구현도
    단순합니다. 간선이 바뀌면 LCT가 유일한 선택. 이 경계 판단이 설계의 핵심입니다. 더 일반적인
    비국소(non-local) 질의가 필요하면 다음 단원의 탑 트리 로 확장됩니다.
Practice problem 동적 숲의 경로 합 선택 25m
R00623

동적 숲의 경로 합

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 무너지는 다리 선택 25m
R00698

무너지는 다리

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
05
Level 9 · Master

Master

고급 트리 · Master 단계

0/3 완료
Lesson 탑 트리 — 클러스터와 rake·compress 필수 8m

어떤 문제를 푸는가

탑 트리(Top Tree) 는 동적 숲(간선 추가·삭제) 위에서 경로 질의뿐 아니라 부분트리·트리 전체에
대한 "비국소(non-local)" 질의
까지 통합해 \(O(\log N)\)에 처리하는 자료구조입니다. 링크-컷 트리(LCT)가
주로 경로 집계를 다뤘다면, 탑 트리는 더 일반적인 트리 통계 — 트리의 지름, 무게중심, 임의
부분트리의 집계, "모든 정점까지 거리 합"
같은 질의 — 를 동적으로 유지합니다.

  • LCT로는 어색한 지름·중심·부분트리 집계 를 동적으로 원할 때.
  • "간선이 바뀌는 트리에서 트리 전체 통계"를 반복 질의하는 문제.

탑 트리는 개념·구현 난도가 매우 높아 최상위 티어에서 다뤄집니다. 이 단원은 정확한 개념 모델과
설계 인터페이스
를 세우는 데 집중합니다.


클러스터(cluster) — 탑 트리의 기본 단위

탑 트리는 트리를 클러스터 들의 계층으로 표현합니다. 클러스터는 "경계 정점(boundary vertex)이
최대 2개
인 연결된 간선 집합"입니다.

  • 경로 클러스터(path cluster) — 경계 정점이 2개. 두 경계 사이를 잇는 "경로"처럼 행동.
  • 점 클러스터(point cluster) — 경계 정점이 1개. 한 경계에 매달린 "덩어리".

각 간선은 하나의 베이스 클러스터(base cluster) 이고, 이들을 두 가지 연산으로 위로 합쳐 하나의
루트 클러스터(트리 전체)까지 쌓아 올립니다.


rake와 compress — 두 가지 합치기

  • compress(압축) — 한 정점에서 두 경로 클러스터가 일직선으로 이어질 때, 가운데 정점을
    없애며 하나의 긴 경로 클러스터로 합칩니다. (경로 + 경로 → 경로)
  • rake(갈퀴질)점 클러스터(옆가지)를 이웃 클러스터에 매달아 흡수합니다. 옆으로 뻗은
    가지를 경로/점 클러스터에 붙여 없앱니다. (점 + (경로/점) → 경계 수를 늘리지 않고 흡수)

이 두 연산으로 트리를 계속 줄여, 최종적으로 트리 전체가 하나의 루트 클러스터 가 됩니다. 클러스터
계층의 높이는 \(O(\log N)\)으로 유지됩니다(자기 균형).


왜 강력한가 — 경계에서의 집계

각 클러스터는 자신의 경계 정점 기준 요약 정보(예: 두 경계 사이 최장 거리, 경계에서 클러스터
안 가장 먼 점까지 거리, 정점 수, 거리 합 등)만 들고 있으면 됩니다. rake/compress가 자식 클러스터
둘의 요약을 결합 규칙 으로 합쳐 부모 요약을 만듭니다. 이 결합 규칙만 잘 설계하면:

  • 지름 = 각 클러스터가 "내부 최장 경로"와 "경계에서의 최장 거리"를 들고 합칠 때 자연히 유지.
  • 부분트리 집계·거리 합 = 경계 기준 누적으로 유지.

핵심은 "트리 전체를 \(O(\log N)\) 높이의 클러스터 계층으로 보고, 국소적 결합 규칙으로 전역 통계를
얻는다"는 것입니다.


복잡도

연산 시간(상각)
link / cut \(O(\log N)\)
expose(경로 노출) \(O(\log N)\)
트리/경로/부분트리 질의 \(O(\log N)\)

예시. 간선이 추가·삭제될 때마다 "현재 트리의 지름"을 즉시 답하려면, 각 클러스터가 (내부 지름,
양 경계에서의 최장 거리)를 들고 compress/rake로 합치는 규칙을 설계하면 됩니다. 다음 강의에서 이
결합 규칙을 구체화합니다.

Lesson 탑 트리 설계 — 클러스터 인터페이스와 결합 규칙 선택 8m

설계 — 클러스터 인터페이스

탑 트리 구현은 방대하므로, 실전에서는 "클러스터가 무엇을 들고, rake/compress가 어떻게 합치는가"
를 먼저 명세하고 프레임워크에 끼워 넣는 방식이 정석입니다. 아래는 클러스터가 노출해야 하는 최소
인터페이스입니다.

// 경계 정점이 최대 2개인 클러스터의 요약.
// 경로 클러스터: 경계 a, b 존재. 점 클러스터: 경계 a 만.
struct Cluster {
    // 예: 지름/거리 질의를 위한 요약
    long long pathLen;   // 두 경계 a-b 사이 경로 길이 (경로 클러스터)
    long long maxDistA;  // 경계 a 에서 클러스터 내부 가장 먼 점까지 거리
    long long maxDistB;  // 경계 b 에서 가장 먼 점까지 거리
    long long diameter;  // 클러스터 내부의 최장 경로(지름)
};

프레임워크는 아래 두 결합 함수 를 호출합니다. 사용자는 이 둘만 문제에 맞게 채웁니다.

// compress: 가운데 정점 m 에서 두 경로 클러스터 X(a-m), Y(m-b) 를 하나의 a-b 경로로
Cluster compress(const Cluster& X, const Cluster& Y, long long wm /*정점/간선 가중*/){
    Cluster R;
    R.pathLen  = X.pathLen + Y.pathLen;
    // a 에서 가장 먼 점: X 내부이거나, X 를 지나 Y 안쪽까지
    R.maxDistA = max(X.maxDistA, X.pathLen + Y.maxDistA);
    R.maxDistB = max(Y.maxDistB, Y.pathLen + X.maxDistB);
    // 지름: 각자 내부 지름, 또는 m 을 지나 X 쪽 최장 + Y 쪽 최장
    R.diameter = max({X.diameter, Y.diameter, X.maxDistB + Y.maxDistA});
    return R;
}
// rake: 점 클러스터 P(경계 a 에 매달림) 를 경로/점 클러스터 X 의 경계 a 에 흡수
Cluster rake(const Cluster& X, const Cluster& P){
    Cluster R = X;
    // 경계 a 기준 최장 거리에 옆가지 P 의 기여를 합류
    R.maxDistA = max(X.maxDistA, P.maxDistA);
    // 지름: 기존, 혹은 a 를 지나 X 쪽 최장 + P 쪽 최장
    R.diameter = max({X.diameter, P.diameter, X.maxDistA + P.maxDistA});
    return R;
}

이 세 함수(compress·rake·base)만 문제별로 정의하면, "간선이 바뀌는 트리의 지름"이 link/cut 후
루트 클러스터의 diameter\(O(\log N)\)에 유지됩니다.


내부 구현은 보통 자기 균형 이진 트리(또는 LCT 위에 얹은 rake/compress 트리) 로 클러스터 계층을
관리합니다. 사용자가 보는 연산은 다음과 같습니다.

struct TopTree {
    // 정점 u,v 사이 경로를 루트 경로 클러스터로 노출(질의 준비)
    Cluster* expose(int u, int v);
    void link(int u, int v, long long w);   // 간선 추가
    void cut(int u, int v);                 // 간선 삭제
    Cluster* root();                        // 트리 전체 요약(예: 지름)
};
  • expose(u,v) — LCT의 access에 대응. \(u\)\(v\)를 루트 경로 클러스터로 만들어 경로 질의를 준비.
  • link/cut — 클러스터 계층을 국소적으로 재구성(영향받는 \(O(\log N)\)개 클러스터만 재결합).

완전한 밑바닥 구현(수백 줄)은 이 단원의 범위를 넘습니다. 실전에서는 검증된 탑 트리 템플릿에 위
Cluster/compress/rake만 갈아 끼우는 방식이 안전하고 빠릅니다.


흔한 함정

  • 경계 정의 혼동 — 클러스터의 경계 정점이 최대 2개라는 불변식을 깨면 결합 규칙이 무너집니다.
    경로(2경계)·점(1경계)를 명확히 구분.
  • 결합 규칙의 대칭성 — compress에서 경계 a/b의 역할을 뒤집는 경우(방향 반전)를 일관되게 처리.
    경로 뒤집기(reverse) 지연 전파도 LCT처럼 필요합니다.
  • rake 방향 — 옆가지를 어느 경계에 매다는지에 따라 maxDistA/B 갱신 대상이 달라집니다.
  • 가중치 위치 — 값이 간선인지 정점인지에 따라 base 클러스터와 compress의 wm 처리 방식이
    바뀝니다.
Lesson 심화·응용 — 동적 지름·거리 합·부분트리 선택 8m

출제 신호

  • 간선이 바뀌는 트리 에서 지름·중심·부분트리 집계·거리 합 같은 비국소 질의를 반복.
  • LCT로는 경로만 되어 어색한, "트리 전체/부분트리" 통계를 동적으로.
  • 최상위 난도에서 "동적 트리 + 복잡한 집계"가 결합된 문제.

응용 1 — 동적 트리의 지름

간선 추가·삭제가 섞인 트리에서 매 시점 지름을 답합니다. 2강의 compress/rake 규칙대로 각
클러스터가 (경계별 최장 거리, 내부 지름)을 유지하면, 루트 클러스터의 diameter가 곧 트리 지름
입니다. link/cut마다 \(O(\log N)\)개 클러스터만 재결합하므로 갱신·질의 모두 \(O(\log N)\).

정적 트리의 지름은 두 번의 DFS로 \(O(N)\)에 구하지만, 간선이 계속 바뀌면 매번 DFS는 \(O(NQ)\)
폭발합니다. 탑 트리는 이를 \(O(Q\log N)\)으로 만듭니다.


응용 2 — 거리 합 / 무게중심 유지

각 클러스터가 "경계에서 클러스터 내부 모든 정점까지 거리의 합", "정점 수"를 함께 들면, 결합 규칙으로
"임의 정점에서 나머지 모두까지 거리 합" 이나 무게중심(그 합을 최소화하는 정점) 을 동적으로
유지할 수 있습니다. compress/rake에서 "경계를 지나는 거리 = 반대편 정점 수 × 경계 길이 + 반대편
내부 거리 합" 형태의 누적을 설계합니다.

// 개념: 경계 a 를 지나 X 쪽으로 가는 모든 거리 합 기여
// sumThroughA = (반대편 정점 수) * (경계까지 길이) + (반대편 내부 거리 합)

응용 3 — 부분트리 집계와 일반 비국소 질의

탑 트리의 진짜 강점은 framework 재사용성 입니다. Cluster가 드는 요약과 compress/rake 규칙만
바꾸면:

  • 부분트리 합/크기/최대,
  • "특정 조건 정점까지 최단 거리",
  • 경로+부분트리 혼합 질의

를 같은 \(O(\log N)\) 골격으로 얻습니다. LCT가 경로 전용이라 부분트리 집계에 별도 트릭(가상 자식
누적)이 필요한 반면, 탑 트리는 rake가 옆가지(부분트리)를 자연스럽게 흡수해 부분트리 통계가 1급
시민
이라는 점이 결정적 차이입니다.


함정과 변형 총정리

  • 설계가 90% — 밑바닥 구현보다 "클러스터 요약 + compress/rake 결합 규칙"을 정확히 세우는 것이
    본질입니다. 규칙이 결합적(associative)이고 경계 대칭을 지키는지 손으로 검증하세요.
  • 뒤집기·지연 전파 — 경로 클러스터의 방향 반전, 구간 갱신 lazy는 LCT와 동일한 주의가 필요.
  • 상각 복잡도 — link/cut/질의 모두 상각 \(O(\log N)\).
  • 구현 현실론 — 완전 구현은 방대하고 버그가 잦으니, 검증된 템플릿에 결합 규칙만 갈아 끼우고
    작은 케이스를 브루트포스와 대조 하는 검증이 필수입니다.
  • 언제 탑 트리인가 — 경로만이면 LCT로 충분하고 상수도 작습니다. 부분트리/지름/중심 등
    비국소 통계 + 동적 간선
    이 함께 필요할 때 탑 트리가 유일한 일반해입니다. 이 경계 판단이 최상위
    트리 문제 설계의 마지막 관문입니다.