세그먼트 트리
배열을 완전 이진 트리 형태로 표현해, 구간 질의(query)와 단일 인덱스 갱신(update)을 모두 O(log n)에 처리하는 자료구조입니다.
동작 과정
arr = [1, 3, 5, 7, 9, 11] (index 0~5, n=6)
tree[node]는 node가 담당하는 구간 [start, end]의 합이다.
노드 번호: 루트=1, 왼쪽 자식=node*2, 오른쪽 자식=node*2+1
1,[0,5]=36
/ \
2,[0,2]=9 3,[3,5]=27
/ \ / \
4,[0,1]=4 5,[2,2]=5 6,[3,4]=16 7,[5,5]=11
/ \ / \
8,[0,0]=1 9,[1,1]=3 12,[3,3]=7 13,[4,4]=9
query(left=1, right=4) — arr[1..4] = 3+5+7+9 = 24
node=1,[0,5]: 질의 구간에 일부만 걸침 → 양쪽 자식 재귀
node=2,[0,2]: 일부만 걸침(1~2) → 양쪽 자식 재귀
node=4,[0,1]: 일부만 걸침(1) → 왼쪽(node=8,[0,0])은 범위 밖, 오른쪽(node=9,[1,1])은 완전 포함 → 3
node=5,[2,2]: 완전 포함 → 5
반환 = 3 + 5 = 8
node=3,[3,5]: 일부만 걸침(3~4) → 양쪽 자식 재귀
node=6,[3,4]: 완전 포함 → 16
node=7,[5,5]: 완전히 범위 밖 → 0
반환 = 16 + 0 = 16
반환 = 8 + 16 = 24 ✔
update(index=2, newValue=10) — arr[2]: 5 → 10
리프까지 내려가 값을 바꾸고, 올라오면서 거쳐온 조상 노드만 다시 합산한다.
node=1,[0,5] → mid=2, index(2)<=mid → 왼쪽(node=2,[0,2])
node=2,[0,2] → mid=1, index(2)>mid → 오른쪽(node=5,[2,2])
node=5,[2,2] → leaf: tree[5] = 5 → 10
복귀하며 재계산:
tree[2] = tree[4] + tree[5] = 4 + 10 = 14
tree[1] = tree[2] + tree[3] = 14 + 27 = 41
결과: arr = [1,3,10,7,9,11], 전체 합 tree[1]=41 (1+3+10+7+9+11=41 ✔)
구현
class SegmentTree {
int[] tree;
int n;
SegmentTree(int[] arr) {
n = arr.length;
tree = new int[4 * n]; // 완전 이진 트리가 아닐 수 있어 넉넉히 4n
build(arr, 1, 0, n - 1);
}
void build(int[] arr, int node, int start, int end) {
if (start == end) {
tree[node] = arr[start];
return;
}
int mid = (start + end) / 2;
build(arr, node * 2, start, mid);
build(arr, node * 2 + 1, mid + 1, end);
tree[node] = tree[node * 2] + tree[node * 2 + 1];
}
// arr[left..right] 구간 합
int query(int node, int start, int end, int left, int right) {
if (right < start || end < left) return 0; // 완전히 범위 밖
if (left <= start && end <= right) return tree[node]; // 완전히 포함
int mid = (start + end) / 2;
return query(node * 2, start, mid, left, right)
+ query(node * 2 + 1, mid + 1, end, left, right);
}
// arr[index] = value로 갱신
void update(int node, int start, int end, int index, int value) {
if (start == end) {
tree[node] = value;
return;
}
int mid = (start + end) / 2;
if (index <= mid) {
update(node * 2, start, mid, index, value);
} else {
update(node * 2 + 1, mid + 1, end, index, value);
}
tree[node] = tree[node * 2] + tree[node * 2 + 1];
}
}
// 사용: query(1, 0, n - 1, left, right), update(1, 0, n - 1, index, value)
복잡도
| 연산 | 복잡도 | 근거 |
|---|---|---|
| build | O(n) | 전체 노드를 한 번씩만 채운다 |
| query | O(log n) | 질의 구간과 겹치는 경로만 방문, 트리 높이가 log n |
| update | O(log n) | 리프까지 내려가는 경로 + 올라오며 재계산하는 경로 모두 트리 높이만큼 |
| 공간 | O(n) | tree 배열 크기 4n |
주의사항
- tree 배열 크기 4n: n이 2의 거듭제곱이 아니면 완전 이진 트리가 되지 않아 노드 번호가 2n을 넘어갈 수 있다. 4n으로 넉넉히 잡는 것이 안전한 상한이다
- 노드 번호는 1부터: 루트를 0으로 두면 왼쪽 자식이
0*2=0이 되어 자기 자신을 가리키게 된다. 루트는 반드시 1부터 시작한다 - query의 분기 순서: 완전히 범위 밖인지 먼저 확인하고, 그다음 완전히 포함되는지 확인한 뒤에야 양쪽으로 쪼개 재귀한다. 순서를 바꾸면 불필요한 탐색이 늘거나 잘못된 값을 반환한다
- 구간 갱신에는 그대로 쓸 수 없다: 구간 전체에 값을 더하는 작업을 인덱스별로
update하면 O(n log n)이 걸린다. 이 경우 지연 전파(lazy propagation)를 추가해야 O(log n)을 유지할 수 있다 — 별도로 다룰 확장 개념이라 여기서는 단일 인덱스 갱신만 다룬다 - 누적합과 차분 배열과의 차이: 누적합은 정적 배열의 구간 합을 O(1)에 구하지만 값이 바뀌면 O(n) 재계산이 필요하다. 세그먼트 트리는 갱신과 질의가 번갈아 섞여도 둘 다 O(log n)으로 유지된다
핵심 정리
- 배열을 이진 트리로 표현해 구간 질의와 단일 인덱스 갱신을 모두 O(log n)에 처리
- 노드 번호: 루트=1, 왼쪽 자식=node*2, 오른쪽 자식=node*2+1
- tree 배열 크기는 4n이 안전
- 갱신과 질의가 자주 번갈아 섞이는 상황에서 누적합과 차분 배열 대신 사용
- 구간 갱신까지 O(log n)으로 하려면 지연 전파(lazy propagation) 확장이 필요