세그먼트 트리
배열에서 "i번째부터 j번째까지의 합"을 구하고 싶을 때, 매번 구간을 훑으면 쿼리 한 번에 O(n)이 걸립니다. 값이 바뀌지 않는다면 누적 합 배열로 O(1)에 답할 수 있지만, 값이 중간에 자주 바뀌면 누적 합을 매번 다시 만들어야 해서 갱신이 O(n)이 됩니다. 세그먼트 트리(segment tree)는 구간 쿼리와 값 갱신을 모두 O(log n)에 처리하는 자료구조입니다.
배열 전체 구간을 절반씩 나누는 이진 트리를 만들고, 각 노드에 자기가 담당하는 구간의 합(또는 최솟값, 최댓값 같은 집계 값)을 미리 저장해 둡니다. 쿼리는 필요한 구간을 몇 개의 노드로 쪼개 저장된 값을 합치고, 갱신은 바뀐 원소에서 루트까지의 노드만 다시 계산합니다. 이 토픽에서는 트리를 만드는 과정, 구간 합 쿼리가 노드를 고르는 방법, 값 갱신이 퍼지는 경로를 단계별로 봅니다.
핵심 개념
구간 분할
루트가 전체 구간 [0, n - 1]을 담당하고, 각 노드는 구간을 반으로 나눠 두 자식에게 맡깁니다. 리프는 원소 하나짜리 구간을 담당하며, 높이(간선 수)는 ⌈log2 n⌉이고 층 수는 ⌈log2 n⌉ + 1입니다.
핵심 개념
집계 값 저장
각 노드는 담당 구간의 합, 최솟값, 최댓값 같은 집계 결과를 저장합니다. 부모의 값은 두 자식의 값을 합쳐서 만들기 때문에 구간 전체를 다시 훑을 필요가 없습니다.
핵심 개념
구간 쿼리
루트에서 내려가며 노드 구간이 찾는 구간과 겹치지 않으면 건너뛰고, 완전히 포함되면 저장된 값을 쓰고, 일부만 겹치면 두 자식으로 나눠 확인합니다.
핵심 개념
점 갱신
원소 하나를 바꾸면 그 원소를 포함하는 구간의 노드, 즉 리프에서 루트까지의 경로에 있는 노드만 두 자식의 합으로 다시 계산합니다.
핵심 개념
배열 표현
노드 i의 자식을 2i와 2i + 1에 두면 포인터 없이 배열로 트리를 저장할 수 있습니다. 재귀 구현에서는 n이 2의 거듭제곱이 아니어도 안전하도록 보통 4n 크기를 잡습니다.
핵심 개념
결합 법칙
두 자식의 결과를 합치는 연산이 결합 법칙을 만족하고 항등원이 있으면 세그먼트 트리에 쓸 수 있습니다. 합, 최솟값, 최댓값, 최대공약수 등이 대표적입니다.
세그먼트 트리의 배경
세그먼트 트리라는 이름의 구조는 계산 기하학에서 나왔습니다. 1977년 카네기멜런대 기술 보고서에서 존 벤틀리(Jon Bentley)가 클리(Klee)의 직사각형 문제, 즉 직사각형들의 합집합 넓이를 구하는 문제를 풀며 소개한 것으로 알려져 있습니다. 원래의 구조는 구간(선분) 집합을 노드에 나눠 저장해 점 질의(stabbing)와 합집합 길이를 다룹니다. 이 토픽의 구간 합 트리는 노드에 구간의 집계 값을 저장한다는 같은 발상을 배열 구간 질의에 쓴 형태입니다. 펜윅 트리(BIT)는 같은 문제를 더 짧은 코드로 풀 수 있고, 세그먼트 트리는 합 이외의 연산과 지연 전파까지 다룰 수 있어 더 유연합니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
세그먼트 트리는 언제, 왜 쓰나요?
값이 자주 바뀌는 배열에서 구간 합이나 구간 최솟값을 반복해서 구할 때 씁니다. 구간 쿼리와 값 갱신을 모두 O(log n)에 처리해, 직접 계산하는 O(n)이나 누적 합의 갱신 O(n)보다 유리합니다.
면접 질문
구간 쿼리는 어떻게 O(log n)이 되나요?
찾는 구간을 트리 노드 몇 개의 구간으로 쪼갭니다. 완전히 포함되는 노드의 저장값만 합치고 겹치지 않는 노드는 건너뛰므로, 한 층에서 방문하는 노드 수가 최대 4개로 제한되어 전체가 층 수에 비례합니다.
면접 질문
배열로 구현할 때 왜 4n 크기를 잡나요?
n이 2의 거듭제곱이 아니면 트리가 완전 이진 트리가 아니어서 노드 번호가 2n보다 커질 수 있습니다. 층 수가 ⌈log2 n⌉ + 1이라 최대 노드 번호가 2의 (⌈log2 n⌉ + 1)제곱 미만이고, 이 값은 4n보다 작으므로 4n이면 어떤 n에서도 넘치지 않습니다.
면접 질문
구간 전체에 값을 더하는 갱신은 어떻게 하나요?
지연 전파를 씁니다. 구간을 완전히 덮는 노드는 자기 집계 값(합이면 더하는 값 × 구간 길이)을 바로 갱신하고, 자식에게 줄 값은 "나중에 전달할 값"으로 표시만 해 둡니다. 그 노드를 지나 내려갈 때 비로소 자식에게 전달하므로 구간 갱신도 O(log n)에 처리합니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
Python — 재귀로 만드는 구간 합 세그먼트 트리
class SegTree: def __init__(self, arr): self.n = len(arr) self.t = [0] * (4 * self.n) # 넉넉하게 4n self._build(1, 0, self.n - 1, arr) def _build(self, node, l, r, arr): if l == r: # 리프: 원소 하나 self.t[node] = arr[l] return m = (l + r) // 2 self._build(node * 2, l, m, arr) self._build(node * 2 + 1, m + 1, r, arr) self.t[node] = self.t[node * 2] + self.t[node * 2 + 1] def query(self, ql, qr, node=1, l=0, r=None): if r is None: r = self.n - 1 if qr < l or r < ql: # 겹치지 않음 return 0 if ql <= l and r <= qr: # 완전히 포함: 저장된 값 사용 return self.t[node] m = (l + r) // 2 # 일부만 겹침: 둘로 나눔 return (self.query(ql, qr, node * 2, l, m) + self.query(ql, qr, node * 2 + 1, m + 1, r)) def update(self, idx, val, node=1, l=0, r=None): if r is None: r = self.n - 1 if l == r: self.t[node] = val return m = (l + r) // 2 if idx <= m: self.update(idx, val, node * 2, l, m) else: self.update(idx, val, node * 2 + 1, m + 1, r) self.t[node] = self.t[node * 2] + self.t[node * 2 + 1] # 돌아오며 다시 계산 st = SegTree([5, 8, 6, 3, 2, 7, 1, 4]) print(st.query(2, 6)) # 19 st.update(3, 10) print(st.query(2, 6)) # 26 print(st.query(0, 7)) # 43
노드 번호를 1부터 매기고 노드 i의 자식을 2i와 2i + 1로 두면 포인터 없이 배열 하나로 트리를 표현할 수 있습니다. 노드는 자기가 담당하는 구간 [l, r]을 재귀 호출의 인자로 들고 다닙니다. 배열 크기를 4n으로 잡는 것은 n이 2의 거듭제곱이 아니어도 인덱스가 넘치지 않게 하는 관례적인 여유입니다. query는 노드 구간이 찾는 구간과 겹치지 않으면 0을, 완전히 포함되면 저장된 값을 돌려주고, 일부만 겹치면 두 자식에게 나눠 맡깁니다. 이 세 경우가 시각화의 빨강, 초록, 노랑 노드에 해당하며 방문하는 노드 수가 O(log n)입니다. update는 리프 하나를 바꾼 뒤 재귀에서 돌아오며 경로의 부모 값만 두 자식의 합으로 다시 계산합니다. 시각화의 갱신 탭에서 구간 [2, 3]이 9에서 16으로, 전체 합이 36에서 43으로 바뀌는 것과 같은 결과이고, 코드의 쿼리 [2, 6] 합도 19에서 26으로 바뀝니다.
Python — 반복문으로 만드는 일반 세그먼트 트리
class Seg: def __init__(self, arr, op, ident): self.n, self.op, self.ident = len(arr), op, ident self.t = [ident] * self.n + list(arr) # 리프는 n ~ 2n-1 for i in range(self.n - 1, 0, -1): self.t[i] = op(self.t[2 * i], self.t[2 * i + 1]) def update(self, i, val): i += self.n self.t[i] = val while i > 1: i //= 2 self.t[i] = self.op(self.t[2 * i], self.t[2 * i + 1]) def query(self, l, r): # 반개구간 [l, r) res = self.ident l += self.n r += self.n while l < r: if l & 1: res = self.op(res, self.t[l]) l += 1 if r & 1: r -= 1 res = self.op(res, self.t[r]) l //= 2 r //= 2 return res arr = [5, 8, 6, 3, 2, 7, 1, 4] sums = Seg(arr, lambda a, b: a + b, 0) mins = Seg(arr, min, float("inf")) print(sums.query(2, 7), mins.query(2, 7)) # 19 1 mins.update(6, 9) print(mins.query(2, 7)) # 2
이 버전은 리프를 배열의 n번부터 2n - 1번에 두고, 위로 올라가며 두 자식의 결과를 합쳐 내부 노드를 채웁니다. 크기가 2n이면 충분하고 재귀가 없어 빠르며, 구간을 반개구간 [l, r)로 받아 아래에서 위로 올라가며 합칠 노드만 골라냅니다. 합치는 연산 op와 항등원 ident를 인자로 받으므로 같은 코드로 구간 합(항등원 0)과 구간 최솟값(항등원 무한대)을 모두 처리합니다. 구간 [2, 6]의 합은 19이고 최솟값은 1이며, A[6]을 9로 바꾸면 최솟값이 2가 됩니다. 이 반복형 query는 순서를 바꿔 합쳐도 결과가 같은(교환 법칙이 성립하는) 연산에 맞는 단순한 형태입니다. 문자열 이어붙이기나 행렬 곱처럼 교환 법칙이 안 되는 연산에는 왼쪽 결과와 오른쪽 결과를 따로 모아 순서대로 합치는 변형이 필요합니다.
Python — 무작위 입력으로 정답과 비교하기
import random arr = [random.randint(1, 9) for _ in range(20)] st = SegTree(arr) # 첫 번째 코드의 클래스 for _ in range(1000): if random.random() < 0.5: i, v = random.randrange(20), random.randint(1, 9) arr[i] = v st.update(i, v) else: l = random.randrange(20) r = random.randrange(l, 20) assert st.query(l, r) == sum(arr[l:r + 1]) print("ok")
자료구조를 직접 구현했다면 느리지만 확실한 방법(배열을 직접 더하는 sum)과 무작위로 비교해 보는 것이 가장 빠른 검증 방법입니다. 이 코드는 1000번의 연산을 평균적으로 절반쯤은 값 갱신, 나머지는 구간 합 쿼리로 섞어 두 결과가 항상 같은지 확인합니다. 쿼리 한 번의 비용은 배열 직접 합산이 구간 길이에 비례하는 O(n)이고 세그먼트 트리가 O(log n)입니다. 원소가 수십만 개이고 쿼리가 수십만 번이면 O(n × q)와 O(q log n)의 차이가 실행 시간을 가릅니다. 값이 바뀌지 않고 쿼리만 여러 번 한다면 누적 합 배열이 더 단순하고 빠르며, 값 갱신과 쿼리가 섞이는 상황에서 세그먼트 트리나 펜윅 트리가 필요해집니다.
Q1
세그먼트 트리에서 구간 쿼리와 값 갱신의 시간 복잡도는?
Q2
세그먼트 트리의 각 노드가 저장하는 것은?
Q3
배열 크기가 n일 때 세그먼트 트리를 배열로 구현하려면 보통 필요한 크기는?