[Daily morning study] 세그먼트 트리 (Segment Tree) 개념과 구현

#daily morning study

Image


세그먼트 트리란?

세그먼트 트리는 배열의 구간(segment)에 대한 쿼리를 빠르게 처리하기 위한 트리 자료구조다. 주로 구간 합, 구간 최솟값/최댓값 같은 연산을 O(log N) 시간에 처리할 수 있다.

왜 필요한가?

배열에서 구간 쿼리를 처리하는 방법은 두 가지가 있다.

방법구간 쿼리값 업데이트
단순 반복O(N)O(1)
누적 합(Prefix Sum)O(1)O(N)
세그먼트 트리O(log N)O(log N)

업데이트와 쿼리가 모두 빈번하게 발생하는 경우, 세그먼트 트리가 가장 효율적이다.


구조

세그먼트 트리는 완전 이진 트리 형태로, 각 노드는 특정 구간의 정보를 저장한다.

  • 리프 노드: 배열의 각 원소
  • 내부 노드: 자식 노드들의 구간을 합친 정보

배열 [1, 3, 5, 7, 9, 11]의 구간 합 세그먼트 트리 구조:

              36 [0,5]
           /           \
       9 [0,2]        27 [3,5]
       /    \          /    \
   4 [0,1]  5[2]   16[3,4]  11[5]
   /    \           /    \
1[0]   3[1]      7[3]   9[4]

배열 크기 N에 대해 세그먼트 트리는 보통 4 * N 크기의 배열로 구현한다.


구현 (Python)

구간 합 세그먼트 트리

class SegmentTree:
    def __init__(self, arr):
        self.n = len(arr)
        self.tree = [0] * (4 * self.n)
        self.build(arr, 1, 0, self.n - 1)

    def build(self, arr, node, start, end):
        if start == end:
            self.tree[node] = arr[start]
        else:
            mid = (start + end) // 2
            self.build(arr, 2 * node, start, mid)
            self.build(arr, 2 * node + 1, mid + 1, end)
            self.tree[node] = self.tree[2 * node] + self.tree[2 * node + 1]

    def update(self, node, start, end, idx, val):
        if start == end:
            self.tree[node] = val
        else:
            mid = (start + end) // 2
            if idx <= mid:
                self.update(2 * node, start, mid, idx, val)
            else:
                self.update(2 * node + 1, mid + 1, end, idx, val)
            self.tree[node] = self.tree[2 * node] + self.tree[2 * node + 1]

    def query(self, node, start, end, left, right):
        # 현재 구간이 쿼리 범위를 벗어남
        if right < start or end < left:
            return 0
        # 현재 구간이 쿼리 범위 안에 완전히 포함됨
        if left <= start and end <= right:
            return self.tree[node]
        mid = (start + end) // 2
        left_sum = self.query(2 * node, start, mid, left, right)
        right_sum = self.query(2 * node + 1, mid + 1, end, left, right)
        return left_sum + right_sum

    def range_sum(self, left, right):
        return self.query(1, 0, self.n - 1, left, right)

    def point_update(self, idx, val):
        self.update(1, 0, self.n - 1, idx, val)


# 사용 예시
arr = [1, 3, 5, 7, 9, 11]
st = SegmentTree(arr)

print(st.range_sum(1, 3))  # 3+5+7 = 15
st.point_update(1, 10)     # arr[1] = 10
print(st.range_sum(1, 3))  # 10+5+7 = 22

핵심 연산 흐름

빌드 (Build)

리프 노드부터 위로 올라가면서 부모 노드를 채운다. 시간 복잡도 O(N).

업데이트 (Update)

변경할 인덱스가 포함된 경로를 따라 루트까지 올라가며 값을 갱신한다.

arr[1] 값 변경 시 영향을 받는 노드:
[0,5] → [0,2] → [0,1] → [1]

루트까지의 높이가 O(log N)이므로 업데이트도 O(log N).

쿼리 (Query)

구간이 현재 노드의 범위를 벗어나면 0(합산 무관 값) 반환, 완전히 포함되면 현재 노드 값 반환, 걸쳐 있으면 재귀적으로 분할해 처리한다.


구간 최솟값 변형

구간 합 대신 최솟값을 저장하도록 수정하면 RMQ(Range Minimum Query) 트리가 된다. 병합 연산만 바꿔주면 된다.

# 빌드 & 업데이트 병합 연산 변경
self.tree[node] = min(self.tree[2 * node], self.tree[2 * node + 1])

# 쿼리 기저 조건 변경
if right < start or end < left:
    return float('inf')  # 합의 0 대신 INF 반환

시간 복잡도 정리

연산시간 복잡도
트리 빌드O(N)
포인트 업데이트O(log N)
구간 쿼리O(log N)
공간 복잡도O(N)

활용 사례

  • 구간 합 쿼리: 연속된 배열 구간의 합
  • 구간 최솟값/최댓값 (RMQ): 특정 구간 내 최솟값/최댓값 탐색
  • 구간 GCD: 특정 구간의 최대공약수
  • 레이지 프로파게이션(Lazy Propagation): 구간 업데이트가 필요한 경우 (예: 특정 구간의 모든 값에 +k)

레이지 프로파게이션 (Lazy Propagation) 개요

구간 업데이트(range update)까지 지원하려면 레이지 배열을 추가한다. 업데이트를 즉시 반영하지 않고, 해당 노드에 “나중에 처리할 변경값”을 저장해 두다가, 쿼리나 하위 노드 접근 시 실제로 반영한다.

[0,5] 구간에 +3 업데이트
→ 루트에 lazy[1] = 3 저장
→ 실제 자식에게는 필요할 때만 전파 (push down)

기본 세그먼트 트리의 구간 업데이트는 O(N)이지만, 레이지를 쓰면 O(log N)으로 줄어든다.


세그먼트 트리 vs 다른 자료구조

자료구조포인트 업데이트구간 쿼리구간 업데이트
배열O(1)O(N)O(N)
누적 합O(N)O(1)O(N)
펜윅 트리 (BIT)O(log N)O(log N)O(log N)
세그먼트 트리O(log N)O(log N)O(N) / O(log N)*

*레이지 프로파게이션 적용 시 O(log N)

펜윅 트리(Binary Indexed Tree)는 세그먼트 트리보다 구현이 간단하고 상수 배가 작지만, 세그먼트 트리가 지원하는 모든 연산 유형을 처리하지는 못한다. 복잡한 구간 연산이 필요하면 세그먼트 트리가 더 유연하다.