본문으로 건너뛰기
김신건의 로그

퍼시스턴트 세그먼트 트리 (Persistent Segment Tree)

· 수정 · 📖 약 5분 · 1,520자/단어 #algorithm #data-structure #segment-tree #persistent
persistent segment tree, PST, 퍼시스턴트 세그트리, 버전별 세그트리

정의

퍼시스턴트 세그먼트 트리 (Persistent Segment Tree, PST)버전별 배열 상태를 모두 유지 하면서, 각 버전마다 O(log N) 시간에 쿼리/갱신 가능한 자료구조. Copy-on-Write 기법으로 메모리 O(M log N) (M = 갱신 횟수).

대표 활용:

  • k번째 수 쿼리: 구간 [l, r] 내 k번째 작은 값, O(log^2 N).
  • Offline 2D 쿼리: x축 정렬 + y축 PST, 다차원 세그트리 참고.
  • 시간 여행 (time travel): 임의 과거 버전 쿼리.

문제 상황과 동기

배열 a[0..N-1], Q개 갱신 + 쿼리. 각 쿼리는 특정 버전 (과거 상태) 에 대해 묻는다.

  • naive: 매 갱신마다 배열 전체 복사, O(N) 메모리 × Q 버전 = O(NQ). N=Q=10^5 면 10^10, 불가능.
  • 일반 세그트리: 현재 상태만 유지, 과거 버전 쿼리 불가.
  • PST: 갱신마다 변경된 경로 (루트 ~ 리프) 만 복사, O(log N) 노드. 메모리 O(Q log N).

핵심 통찰: 세그트리의 대부분 노드는 불변. 갱신 시 경로 상 노드만 새 버전 생성, 나머지는 이전 버전 포인터 재사용. Functional programming 의 persistent data structure 와 동일.

시각화

핵심 아이디어

1. Copy-on-Write (CoW)

일반 세그트리: 노드 배열 tree[4*N], 업데이트 시 제자리 수정.

PST: 각 버전마다 루트 포인터 유지. 갱신 시:

  1. 루트 ~ 리프 경로 (O(log N) 개) 만 새 노드 생성.
  2. 형제 노드는 이전 버전 포인터 그대로.
  3. 새 루트 = 새 버전.

예: N=8, 인덱스 5 갱신.

버전 1 root → 노드 [0,7]
                ├─ [0,3] (재사용)
                └─ [4,7] (새)
                    ├─ [4,5] (새)
                    │   ├─ [4,4] (재사용)
                    │   └─ [5,5] (새, 값 변경)
                    └─ [6,7] (재사용)

메모리: 버전 M 개, 각 O(log N) → 총 O(M log N).

2. k번째 수 쿼리

문제: 배열 a[0..N-1], 쿼리 (l, r, k) = 구간 [l, r] 내 k번째 작은 값.

전략: 누적 버전 + 이진 탐색.

  1. 빌드: 각 인덱스 i 마다 a[0..i] 의 값 빈도를 PST 로 관리 (값 범위 [0, MAX] 를 세그트리로).
  2. 쿼리 (l, r, k): PST[r] - PST[l-1] 의 차이 = [l, r] 구간 빈도. 세그트리 이진 탐색으로 k번째 찾기.

복잡도: 빌드 O(N log MAX), 쿼리 O(log MAX).

좌표 압축: MAX=10^9 → 유니크 값 N개로 압축, log N.

3. 시간 여행

버전별 루트 배열 root[0..M]. 쿼리 “버전 t 의 구간 [l, r] 합” → query(root[t], l, r).

응용: 함수형 프로그래밍, 되돌리기 (undo), 병렬 버전 관리.

알고리즘

PST 빌드 + 갱신

struct Node:
    left, right: pointer to Node
    sum: value (or min, max, etc.)

build(node, l, r, arr):
    if l == r:
        node.sum = arr[l]
        return node
    mid = (l + r) / 2
    node.left = build(new Node, l, mid, arr)
    node.right = build(new Node, mid+1, r, arr)
    node.sum = node.left.sum + node.right.sum
    return node

update(prev_node, l, r, pos, val):
    new_node = clone(prev_node)
    if l == r:
        new_node.sum = val
        return new_node
    mid = (l + r) / 2
    if pos <= mid:
        new_node.left = update(prev_node.left, l, mid, pos, val)
        new_node.right = prev_node.right  # 재사용
    else:
        new_node.left = prev_node.left    # 재사용
        new_node.right = update(prev_node.right, mid+1, r, pos, val)
    new_node.sum = new_node.left.sum + new_node.right.sum
    return new_node

query(node, l, r, ql, qr):
    if qr < l or r < ql:
        return 0
    if ql <= l and r <= qr:
        return node.sum
    mid = (l + r) / 2
    return query(node.left, l, mid, ql, qr) + query(node.right, mid+1, r, ql, qr)

k번째 수 (누적 PST + 이진 탐색)

build_persistent(a):
    root[0] = empty tree (모든 값 빈도 0)
    for i = 1..N:
        root[i] = update(root[i-1], a[i], +1)

kth_smallest(left_root, right_root, node_l, node_r, k):
    # PST[r] - PST[l-1] 차이로 구간 [l, r] 빈도
    if node_l == node_r:
        return node_l  # 값
    mid = (node_l + node_r) / 2
    left_cnt = right_root.left.sum - left_root.left.sum
    if k <= left_cnt:
        return kth_smallest(left_root.left, right_root.left, node_l, mid, k)
    else:
        return kth_smallest(left_root.right, right_root.right, mid+1, node_r, k - left_cnt)

query(l, r, k):
    return kth_smallest(root[l-1], root[r], 0, MAX-1, k)

구현

PST 로 k번째 수 쿼리 (좌표 압축 포함).

// Persistent Segment Tree: k번째 수 쿼리
#include <bits/stdc++.h>
using namespace std;

struct Node {
  int sum;
  Node *left, *right;
  Node() : sum(0), left(nullptr), right(nullptr) {}
  Node(Node* l, Node* r) : left(l), right(r), sum(0) {
      if (l) sum += l->sum;
      if (r) sum += r->sum;
  }
};

Node* build(int l, int r) {
  if (l == r) return new Node();
  int mid = (l + r) / 2;
  return new Node(build(l, mid), build(mid+1, r));
}

Node* update(Node* prev, int l, int r, int pos) {
  if (l == r) {
      Node* cur = new Node();
      cur->sum = prev->sum + 1;
      return cur;
  }
  int mid = (l + r) / 2;
  if (pos <= mid)
      return new Node(update(prev->left, l, mid, pos), prev->right);
  else
      return new Node(prev->left, update(prev->right, mid+1, r, pos));
}

int kth(Node* u, Node* v, int l, int r, int k) {
  if (l == r) return l;
  int mid = (l + r) / 2;
  int cnt = v->left->sum - u->left->sum;
  if (k <= cnt)
      return kth(u->left, v->left, l, mid, k);
  else
      return kth(u->right, v->right, mid+1, r, k - cnt);
}

int main() {
  int n, q; cin >> n >> q;
  vector<int> a(n+1);
  for (int i = 1; i <= n; i++) cin >> a[i];
  
  // 좌표 압축
  vector<int> vals = a;
  sort(vals.begin(), vals.end());
  vals.erase(unique(vals.begin(), vals.end()), vals.end());
  map<int, int> comp;
  for (int i = 0; i < vals.size(); i++) comp[vals[i]] = i;
  
  // PST 빌드
  int m = vals.size();
  vector<Node*> root(n+1);
  root[0] = build(0, m-1);
  for (int i = 1; i <= n; i++)
      root[i] = update(root[i-1], 0, m-1, comp[a[i]]);
  
  while (q--) {
      int l, r, k; cin >> l >> r >> k;
      int idx = kth(root[l-1], root[r], 0, m-1, k);
      cout << vals[idx] << "\n";
  }
}
stdin
5 3
7 5 3 1 9
1 3 2
2 5 3
1 5 4
결과
5
5
7

복잡도

항목
빌드O(N log N) (초기 트리 + N번 갱신)
갱신O(log N) 시간, O(log N) 추가 메모리
쿼리O(log N)
전체 메모리O(N log N + M log N) (M = 갱신 횟수)
k번째 수O(log N) (세그트리 이진 탐색)

변형

1. 2D PST (Offline 2D Range Query)

x축 정렬 + y축 PST. “직사각형 내 k번째 수” 쿼리 O(log^2 N). 다차원 세그트리 참고.

2. Persistent Fenwick Tree

펜윅도 persistent 가능. 메모리 O(M log N), 구현 간단. 단, 역원 필요.

3. Persistent Treap

Treap (랜덤 우선순위 BST) 도 persistent 가능. split/merge 로 구간 연산. 메모리 O(M log N).

4. Functional Data Structure

Haskell, OCaml 같은 함수형 언어의 persistent list, map, tree 와 동일 원리. 불변성 + 구조 공유.

함정

1. 메모리 누수

C++ 에서 new Node() 남발, 삭제 없으면 메모리 누수. 전역 배열 풀 (pool allocator) 추천.

Node pool[20000000];
int pool_idx = 0;
Node* newNode() { return &pool[pool_idx++]; }

2. 좌표 압축 범위

k번째 수는 값 범위 MAX 가 10^9 면 세그트리 불가. 좌표 압축 필수. 중복 제거 unique() 조심.

3. 포인터 NULL 체크

node->left 접근 전 nullptr 확인. 세그먼테이션 폴트 다발.

4. 버전 관리

root 배열 인덱스 실수. l-1, r 헷갈림. 1-indexed / 0-indexed 일관성.

5. Lazy Propagation PST

PST 에 lazy 는 구현 극난. 각 버전마다 lazy 도 복사 필요. 실전 거의 안 씀.

BOJ 연습 문제

번호제목정답률링크
BOJ 2042구간 합 구하기 (일반 세그트리)-kokoa-lab
BOJ 7469k번째 수 (PST)-kokoa-lab
BOJ 13537수열과 쿼리 1 (merge sort tree, PST 응용)-kokoa-lab
BOJ 11012Egg (2D PST)-kokoa-lab
BOJ 16978수열과 쿼리 22 (offline PST)-kokoa-lab

참고

이 글의 용어 (3개)
세그먼트 트리 (Segment Tree)algorithm
정의 세그먼트 트리 (Segment Tree) 는 배열의 구간 쿼리 (range query) 와 점 갱신 (point update) 를 모두 O(log N) 에 처리하는 이진 트…
BBST (Splay Tree, Treap)algorithm
정의 BBST (Balanced Binary Search Tree) 는 균형이 amortized / expected 로 보장되는 이진 탐색 트리. PS 에서는 split / me…
Fenwick Tree (Binary Indexed Tree): 구간 합 O(log N)algorithm
정의 Fenwick Tree (또는 BIT, Binary Indexed Tree) 는 배열의 prefix sum 을 O(log N) 에 갱신·조회 하는 자료구조입니다. Peter…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기