Heavy-Light Decomposition
정의
Heavy-Light Decomposition (HLD) 는 트리를 O(log N) 개의 경로 (chain) 로 분해해 임의 경로 쿼리 (u-v 경로의 합/최대/최소) 를 O(log^2 N) 에 처리하는 정형. 각 체인을 세그먼트 트리 로 관리.
1984년 Sleator 와 Tarjan 이 dynamic tree (Link-Cut Tree) 분석 중 고안. PS 에서 “트리 경로 쿼리” 문제의 표준 해법.
핵심: 각 노드에서 가장 무거운 자식 (subtree 크기 최대) 으로 이어지는 간선을 heavy edge, 나머지는 light edge 로 분류. 루트에서 임의 노드까지 light edge 는 최대 O(log N) 개.
문제 상황과 동기
트리 (N=10^5 노드) 에서 Q=10^5 쿼리:
-
u-v 경로의 합 / 최댓값 / 갱신
-
naive: 매 쿼리마다 경로 순회 O(N). 총 O(N · Q) = 10^10, TLE.
-
LCA + prefix sum: LCA O(log N) 로 경로 찾지만 갱신 시 재계산 O(N).
-
HLD + Segtree: 경로를 O(log N) 개 체인으로 쪼개고 각 체인은 segtree O(log N) 쿼리. 총 O(log^2 N) per query.
핵심 통찰: 트리 = 깊이 우선 순서로 번호 매기면 각 체인은 연속 구간. heavy path 를 우선 배치하면 모든 경로는 최대 O(log N) 체인 교차.
시각화
핵심 아이디어
Heavy-Light 분류
size[v] = 1 + Σ size[child] (DFS postorder 계산)
heavy_child[v] = argmax_{child} size[child]
간선 (v, child):
heavy edge if child == heavy_child[v]
light edge otherwise
Invariant
루트에서 임의 노드 v 까지의 경로에서 light edge 는 최대 O(log N) 개.
증명: light edge 를 타고 내려갈 때마다 서브트리 크기가 절반 이하로 줄어든다 (heavy child 가 최대 크기였으므로).
DFS 번호 (chain 번호)
dfs(v):
chain_id[v] = current_chain
in[v] = dfs_order++
if heavy_child[v] exists:
dfs(heavy_child[v]) # 같은 chain 유지
for child in light_children:
current_chain++
dfs(child) # 새 chain 시작
out[v] = dfs_order
각 체인의 노드는 in[] 번호가 연속 -> segtree 로 구간 쿼리 가능.
경로 쿼리
path_query(u, v):
res = identity
while chain_id[u] != chain_id[v]:
if depth[top[chain_id[u]]] < depth[top[chain_id[v]]]:
swap(u, v)
# u 의 체인 top 까지 쿼리
res = combine(res, segtree_query(in[top[u]], in[u]))
u = parent[top[u]] # 다음 체인으로 점프
# 같은 체인 내 u-v
res = combine(res, segtree_query(min(in[u], in[v]), max(in[u], in[v])))
return res
while 루프는 최대 O(log N) 회전 (light edge 개수 bound). 각 루프에서 segtree O(log N). 총 O(log^2 N).
알고리즘
// 전처리
1. DFS 로 size[], parent[], depth[] 계산
2. DFS 로 heavy_child[] 결정
3. DFS 로 chain_id[], in[], out[] 할당 (heavy path 우선)
4. 각 체인마다 segtree 구축 (또는 전역 하나에 모두 넣기)
// 경로 쿼리 (u-v)
while chain[u] != chain[v]:
if depth[top[chain[u]]] < depth[top[chain[v]]]: swap(u, v)
ans = combine(ans, query_chain(chain[u], in[u], in[top[u]]))
u = parent[top[chain[u]]]
ans = combine(ans, query_chain(chain[u], in[u], in[v]))
구현
// HLD + Segtree (경로 합 쿼리)
#include <bits/stdc++.h>
using namespace std;
const int MX = 1e5 + 5;
vector<int> g[MX];
int sz[MX], dep[MX], par[MX], hv[MX], ch[MX], in[MX], top[MX], timer;
long long val[MX], tree[4 * MX];
void dfs_sz(int v, int p) {
sz[v] = 1; par[v] = p; hv[v] = -1;
for (int u : g[v]) if (u != p) {
dep[u] = dep[v] + 1; dfs_sz(u, v); sz[v] += sz[u];
if (hv[v] == -1 || sz[u] > sz[hv[v]]) hv[v] = u;
}
}
void dfs_hld(int v, int p, int c) {
ch[v] = c; in[v] = ++timer;
if (hv[v] != -1) dfs_hld(hv[v], v, c);
for (int u : g[v]) if (u != p && u != hv[v]) dfs_hld(u, v, u);
}
void build(int n, int s, int e) {
if (s == e) { tree[n] = val[s]; return; }
int m = (s + e) / 2;
build(2*n, s, m); build(2*n+1, m+1, e);
tree[n] = tree[2*n] + tree[2*n+1];
}
long long query(int n, int s, int e, int l, int r) {
if (r < s || e < l) return 0;
if (l <= s && e <= r) return tree[n];
int m = (s + e) / 2;
return query(2*n, s, m, l, r) + query(2*n+1, m+1, e, l, r);
}
long long path(int u, int v, int n) {
long long res = 0;
while (ch[u] != ch[v]) {
if (dep[ch[u]] < dep[ch[v]]) swap(u, v);
res += query(1, 1, n, in[ch[u]], in[u]);
u = par[ch[u]];
}
if (dep[u] > dep[v]) swap(u, v);
res += query(1, 1, n, in[u], in[v]);
return res;
}
int main() {
int n, q; cin >> n >> q;
for (int i = 1; i <= n; i++) cin >> val[i];
for (int i = 0; i < n - 1; i++) {
int a, b; cin >> a >> b;
g[a].push_back(b); g[b].push_back(a);
}
dfs_sz(1, 0); dfs_hld(1, 0, 1);
for (int i = 1; i <= n; i++) val[in[i]] = val[i]; // remap
build(1, 1, n);
while (q--) {
int u, v; cin >> u >> v;
cout << path(u, v, n) << "\n";
}
}5 3
1 2 3 4 5
1 2
1 3
2 4
2 5
1 4
3 5
4 57
11
14복잡도
| 항목 | 값 |
|---|---|
| 전처리 | O(N) - DFS 3회 + segtree 구축 |
| 경로 쿼리 | O(log^2 N) - O(log N) 체인 × O(log N) segtree |
| 점 갱신 | O(log N) - segtree 갱신 |
| 공간 | O(N) - segtree + 배열들 |
LCA + naive 경로 순회 O(N) 대비 극적 개선. Centroid Decomposition 과 비슷한 복잡도지만 HLD 가 구현 간결.
변형 / 활용
| 변형 | 설명 |
|---|---|
| HLD + Lazy Segtree | 경로 구간 갱신 O(log^2 N). “경로에 +k” 쿼리 |
| Vertex / Edge 쿼리 | 간선 값은 하위 노드에 저장. LCA 처리 주의 |
| Subtree 쿼리 | in[v]..out[v] 가 서브트리 구간. O(log N) |
| LCA 부산물 | HLD 중 depth, top 으로 LCA O(log N) 가능 |
| Dynamic Tree (LCT) | Link-Cut Tree 는 HLD 의 online 버전. 간선 추가/삭제 가능 |
함정
1. top[] 배열 설정
각 체인의 top 노드를 따로 저장해야 while 루프에서 점프 가능. top[ch[v]] 를 안 두면 구현 복잡.
2. in[] 번호 remap
원래 노드 값 val[v] 를 segtree 에 넣을 때 val[in[v]] 로 재배치. 안 하면 쿼리 결과 틀림.
3. LCA 계산 실수
경로 쿼리 마지막에 u-v (같은 체인) 를 쿼리할 때 min(in[u], in[v]) ~ max(in[u], in[v]) 로 해야 함. depth 비교 빼먹으면 WA.
4. 간선 값 문제
간선 (u, v) 의 값을 처리할 때 깊이가 더 깊은 노드에 값을 할당. LCA 포함 여부 주의.
BOJ 연습 문제
| 번호 | 제목 | 정답률 | 링크 |
|---|---|---|---|
| BOJ 13510 | 트리와 쿼리 1 | 22.8% | kokoa-lab |
| BOJ 13511 | 트리와 쿼리 2 | 18.5% | kokoa-lab |
| BOJ 17429 | 국제 메시징 회사 | 12.3% | kokoa-lab |
참고
이 글의 용어 (4개)
- 세그먼트 트리 (Segment Tree)algorithm
- 정의 세그먼트 트리 (Segment Tree) 는 배열의 구간 쿼리 (range query) 와 점 갱신 (point update) 를 모두 O(log N) 에 처리하는 이진 트…
- 중심 분해 (Centroid Decomposition)algorithm
- 정의 중심 분해 (Centroid Decomposition) 는 트리를 중심 으로 재귀적으로 분할하여 O(log N) 깊이의 분해 트리 를 만드는 기법. 각 레벨에서 트리 크기가…
- 최소 공통 조상 (Lowest Common Ancestor)algorithm
- 정의 최소 공통 조상 (LCA, Lowest Common Ancestor) 는 트리에서 두 노드 u, v 의 공통 조상 중 가장 깊은 (루트에서 가장 먼) 노드. Binary L…
- Dynamic Tree (Link/Cut Tree, Euler Tour Tree, Top Tree)algorithm
- 정의 Dynamic Tree 는 트리에 간선 추가 (link) / 제거 (cut) 가 섞이는 환경에서 경로 / 서브트리 집계 쿼리 를 O(log N) 에 처리하는 자료구조 가족.…
💬 댓글