수업 목표
본 수업은 트리 자료구조의 구조적 특성을 깊이 이해하고, 트리 탐색, 지름 계산, 최소 공통 조상 및 선형화 기법을 학습하여 트리 상의 복잡한 쿼리를 최적의 시간 복잡도로 처리하는 것을 목표로 합니다.
- DFS와 BFS를 이용해 트리를 탐색하고 노드 간의 거리와 조상 관계를 체계적으로 계산합니다.
- 트리의 지름을 구하는 투 패스(Two-Pass) 알고리즘의 원리를 증명하고 구현합니다.
- 최소 공통 조상(LCA)을 찾기 위한 이진 도약(Binary Lifting) 테이블 전처리 및 쿼리 최적화 과정을 파악합니다.
- 오일러 투어 테크닉(Euler Tour Technique)과 Heavy-Light 분해(HLD) 개념을 활용하여 트리를 선형 배열로 변환하고 경로 쿼리를 해결합니다.
학습 동기부여
트리는 컴퓨터 과학 및 데이터 구조 설계에서 가장 빈번하게 마주치는 계층 구조입니다. 소셜 네트워크의 친구 추천 시스템, 대기업의 조직도 관리, 파일 시스템 구조 등은 모두 트리를 기반으로 작동합니다. 만약 두 노드 사이의 공통 조상을 찾는 단순 알고리즘을 사용한다면 매번 조상을 거슬러 올라가며 대조하느라 $O(N)$의 시간이 소요되어 서비스 성능에 병목이 발생할 것입니다. 이번 단원에서 학습할 기법들을 적용하면 이를 $O(\log N)$ 또는 $O(1)$ 수준으로 단축하여 대용량 트리를 실시간으로 제어할 수 있습니다.
도입 스토리
대규모 다국적 기업의 사내 메신저에서 서로 다른 팀에 소속된 두 직원이 공동 프로젝트 결재를 올리려 합니다. 이들이 결재를 기안하기 위해서는 두 사람 모두의 직속 상사 중 가장 말단에 있는 공임 상사(최소 공통 조상, LCA)의 결재가 필요합니다. 만약 사장실(루트)에서 시작해 한 명씩 아래로 대조하거나, 두 직원의 부모를 타고 올라가며 대조한다면 직원 수 수십만 명 규모의 조직에서 결재선을 찾는 일 자체가 거대한 작업이 됩니다. 그러나 전 사원의 $2^k$단계 직속상사 계보(이진 도약 테이블)를 엑셀에 정리해 두면, 단 몇 번의 이동만으로 공통 결재권자를 지목할 수 있습니다. 본 수업에서는 이러한 트리의 다양한 조율 기법을 다룹니다.
1. 트리 탐색과 서브트리 크기
트리는 사이클이 없는 연결 그래프이므로, 임의의 루트 노드에서 DFS 또는 BFS를 시작하여 모든 노드를 1회 방문할 수 있습니다. 특히 문제 조건에서 부모 노드의 번호 $p_i$가 항상 $i$보다 작다는 관계($p_i < i$)가 보장될 때, 노드 번호의 역순($N$부터 $2$까지)으로 순회하면서 `size[parent] += size[child] + 1`과 같이 서브트리의 크기를 $O(N)$ 시간에 누적하는 기법은 재귀 호출이 없어 스택 오버플로우로부터 안전하고 효율적인 해법이 됩니다.
# 8053번: 트리 탐색과 서브트리 크기 — 재귀 없는 O(N) 서브트리 크기 계산
import sys
input = sys.stdin.readline
n = int(input())
if n > 1:
parent = [0] + list(map(int, input().split()))
else:
parent = []
size = [0] * (n + 1)
# 노드 번호의 역순으로 순회하며 부모에게 크기를 전달
for i in range(n, 1, -1):
p = parent[i - 2]
size[p] += size[i] + 1
print(*(size[1:]))
2. 트리의 지름 (Tree Diameter)
트리 내에 존재하는 모든 노드 쌍 중 가장 먼 두 노드 사이의 거리(간선의 개수)를 트리의 지름이라고 합니다. 이는 두 번의 그래프 탐색(BFS/DFS)으로 $O(N)$에 찾을 수 있습니다.
- 임의의 시작점(예: $1$번 노드)에서 탐색을 시작하여 가장 멀리 있는 노드 $u$를 구합니다.
- 노드 $u$에서 다시 탐색을 시작하여 가장 먼 노드 $v$를 찾습니다. $u$와 $v$ 사이의 거리가 트리의 지름이 됩니다.
# 8054번: 트리의 지름 — 투 패스 BFS 구현
import sys
from collections import deque
input = sys.stdin.readline
n = int(input())
if n <= 1:
print(0)
sys.exit()
adj = [[] for _ in range(n + 1)]
for _ in range(n - 1):
u, v = map(int, input().split())
adj[u].append(v)
adj[v].append(u)
def bfs(start):
dist = [-1] * (n + 1)
dist[start] = 0
q = deque([start])
max_d = 0
far_node = start
while q:
curr = q.popleft()
d = dist[curr]
if d > max_d:
max_d = d
far_node = curr
for nxt in adj[curr]:
if dist[nxt] == -1:
dist[nxt] = d + 1
q.append(nxt)
return far_node, max_d
u, _ = bfs(1)
v, diameter = bfs(u)
print(diameter)
3. 최소 공통 조상 (LCA, Lowest Common Ancestor)
두 노드가 공동으로 가지는 조상 중 가장 깊은(가까운) 노드를 의미합니다. 이진 도약(Binary Lifting) 기법을 사용하면 전처리 $O(N \log N)$, 각 LCA 쿼리를 $O(\log N)$에 처리할 수 있습니다.
- `depth[i]`: 루트에서 노드 $i$까지의 깊이를 기록합니다.
- `up[k][i]`: 노드 $i$의 $2^k$번째 조상 노드를 기록합니다. (`up[k][i] = up[k-1][up[k-1][i]]`)
- 쿼리 $(a, b)$가 주어지면, 두 노드의 깊이를 동일하게 맞춘 뒤 $k$를 큰 값부터 줄여가며 두 노드를 동시에 도약시킵니다.
# 8055번: 최소 공통 조상 — 이진 도약을 통한 O(log N) LCA 탐색
import sys
sys.setrecursionlimit(300000)
input = sys.stdin.readline
n, q = map(int, input().split())
parent = [0, 1]
if n > 1:
parent += list(map(int, input().split()))
# Depth 계산 (parent[i] < i 성질 활용)
depth = [0] * (n + 1)
for i in range(2, n + 1):
depth[i] = depth[parent[i]] + 1
LOG = 18
up = [[0] * (n + 1) for _ in range(LOG + 1)]
for i in range(1, n + 1):
up[0][i] = parent[i]
for k in range(1, LOG + 1):
for i in range(1, n + 1):
up[k][i] = up[k - 1][up[k - 1][i]]
def get_lca(a, b):
if depth[a] < depth[b]:
a, b = b, a
# 높이 맞추기
diff = depth[a] - depth[b]
for k in range(LOG, -1, -1):
if (diff >> k) & 1:
a = up[k][a]
if a == b:
return a
# 공통 조상 바로 아래까지 동시에 도약
for k in range(LOG, -1, -1):
if up[k][a] != up[k][b]:
a = up[k][a]
b = up[k][b]
return up[0][a]
for _ in range(q):
u, v = map(int, input().split())
print(get_lca(u, v))
4. Heavy-Light 분해와 오일러 경로 테크닉
트리 상의 경로 가중치 수정 및 구간 쿼리를 선형 배열 구조로 다루기 위해 트리를 일차원으로 선형화(Linearization)하는 기법입니다.
- **오일러 경로 테크닉(Euler Tour Technique)**: DFS를 돌면서 노드에 진입하는 시간(`in[i]`)과 빠져나오는 시간(`out[i]`)을 기록하여 트리의 서브트리를 선형 배열의 연속된 구간 `[in[i], out[i]]`으로 매핑합니다.
- **경로 쿼리 응용**: 특정 노드 $s$의 가중치 변경 $x$는 $s$의 서브트리 전체에 경로 가중치 변화량을 더하는 것과 같으므로 펜윅 트리의 구간 업데이트(두 지점 변경) 및 점 쿼리(`in
` 지점 조사)로 루트에서 $u$까지의 경로 합을 $O(\log N)$에 고속 처리할 수 있습니다.
# 8056번: 트리 경로 가중치 쿼리 — 오일러 경로 테크닉과 펜윅 트리
import sys
input = sys.stdin.readline
n, q = map(int, input().split())
v = [0] + list(map(int, input().split()))
adj = [[] for _ in range(n + 1)]
for _ in range(n - 1):
a, b = map(int, input().split())
adj[a].append(b)
adj[b].append(a)
# 반복문 기반 DFS로 트리 계층 구조 및 Euler Tour 계산 (재귀 회피)
children = [[] for _ in range(n + 1)]
visited = [False] * (n + 1)
visited[1] = True
bfs_queue = [1]
head = 0
while head < len(bfs_queue):
curr = bfs_queue[head]
head += 1
for nxt in adj[curr]:
if not visited[nxt]:
visited[nxt] = True
children[curr].append(nxt)
bfs_queue.append(nxt)
# DFS 방문 타임스탬프 기록
tin = [0] * (n + 1)
tout = [0] * (n + 1)
timer = 0
stack = [[1, 0]]
while stack:
u, c_idx = stack[-1]
if c_idx == 0:
timer += 1
tin[u] = timer
if c_idx < len(children[u]):
stack[-1][1] += 1
stack.append([children[u][c_idx], 0])
else:
tout[u] = timer
stack.pop()
# 펜윅 트리 구현 (구간 업데이트, 점 쿼리)
bit = [0] * (n + 2)
def bit_add(i, val):
while i <= n:
bit[i] += val
i += i & (-i)
def bit_query(i):
s = 0
while i > 0:
s += bit[i]
i -= i & (-i)
return s
def range_add(l, r, val):
bit_add(l, val)
bit_add(r + 1, -val)
# 초기 노드 가중치 적용
for i in range(1, n + 1):
range_add(tin[i], tout[i], v[i])
# 쿼리 응답
for _ in range(q):
line = list(map(int, input().split()))
if line[0] == 1:
s, x = line[1], line[2]
diff = x - v[s]
v[s] = x
range_add(tin[s], tout[s], diff)
else:
u = line[1]
print(bit_query(tin[u]))
자주 하는 실수 & 오류 예방
- 재귀 제한과 스택 오버플로우: 트리의 최대 노드가 $200,000$이고 일자 트리인 경우, 평범한 재귀 DFS는 즉시 런타임 에러를 냅니다. 파이썬에서는 sys.setrecursionlimit(300000) 설정이 필수적이며, 가능한 경우 큐나 스택을 이용한 반복문 방식으로 우회해 작성하십시오.
- 이진 도약 테이블의 루트 부모 지정: 도약 시 경계를 벗어나 인덱스 에러가 발생하지 않도록 루트 노드(1번)의 0번째 조상은 자기 자신 1 또는 0으로 명확히 고정해야 합니다.
- 경로 쿼리의 업데이트 범위 설정: 펜윅 트리에 r + 1 위치에 누적 연산을 할 때, r + 1이 배열 경계 크기인 $N + 1$을 초과하지 않는지 예외 제어 처리를 확인하십시오.
핵심 정리
| 알고리즘 및 개념 |
전처리 복잡도 |
쿼리 시간 복잡도 |
공간 복잡도 |
핵심 용도 |
| 역순 서브트리 DP |
지원 안 함 |
$O(N)$ (전체 스캔) |
$O(N)$ |
부모 노드가 자식보다 작을 때 재귀 없는 서브트리 크기 산출 |
| 투 패스 BFS/DFS 지름 |
지원 안 함 |
$O(N)$ (전체 스캔) |
$O(N)$ |
트리 내의 최대 노드 간 거리(지름) 탐색 |
| 이진 도약 LCA |
$O(N \log N)$ |
$O(\log N)$ |
$O(N \log N)$ |
트리 내 임의의 두 노드의 최소 공통 조상 탐색 |
| 오일러 경로 테크닉 (ETT) |
$O(N)$ |
$O(\log N)$ (펜윅 연계) |
$O(N)$ |
트리를 구간(배열)으로 펴서 서브트리 업데이트 및 경로 합 쿼리 해결 |
실습 문제
| 문제 번호 |
문제 제목 |
출처 |
난이도 |
핵심 알고리즘 |
| 8053 |
트리 탐색과 서브트리 크기 |
CSES |
쉬움 |
역순 트리 DP / DFS |
| 8054 |
트리의 지름 |
CSES |
보통 |
투 패스 BFS / DFS |
| 8055 |
최소 공통 조상 (LCA) |
CSES |
보통 |
이진 도약 (Binary Lifting) |
| 8056 |
트리 경로 가중치 쿼리 |
CSES |
어려움 |
오일러 투어 테크닉 + 펜윅 트리 |