문제 분류 수업 진도 대회 채점현황 순위 질문게시판
게임
스도쿠 타이핑 리듬 타자게임 타이핑 테트리스 타이핑 인베이더 타이핑 지뢰찾기 8192 공룡게임 타이핑 플래피버드 랭킹
로그인
← 수업 목록
▶ 슬라이드 시작

Ch 10. 트리 알고리즘 (Tree Algorithms)

수업 목표

본 수업은 트리 자료구조의 구조적 특성을 깊이 이해하고, 트리 탐색, 지름 계산, 최소 공통 조상 및 선형화 기법을 학습하여 트리 상의 복잡한 쿼리를 최적의 시간 복잡도로 처리하는 것을 목표로 합니다. - 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 어려움 오일러 투어 테크닉 + 펜윅 트리
NOTICE  —  BFStudy OJ 정보샘
애들아, 문제 많이 풀고 게임 잠깐 하는거지?
— 정보샘