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

Persistent Segment Tree (영속 세그): 시점별 스냅샷

· 수정 · 📖 약 3분 · 1,056자/단어 #algorithm #data-structure #segment-tree #persistent
Persistent Segtree, Persistent Segment Tree, 영속 세그, PST

정의

Persistent Segment Tree 는 매 갱신마다 이전 버전을 그대로 유지하면서 새 버전을 만드는 세그먼트 트리입니다. 각 버전은 루트 포인터 로 식별됩니다.

핵심 트릭: path copying. 갱신 경로 (O(log N) 노드) 만 복사하고 나머지는 이전 버전과 공유.

  • 공간: 갱신 1 회당 O(log N) 노드 추가
  • 시간: 조회/갱신 모두 O(log N)

문제 상황

배열에서 아래 두 가지를 동시에 처리해야 할 때:

  • 과거 버전 조회: “버전 v 시점의 구간 합을 구하라”
  • K-th smallest in range: 구간 [l, r] 에서 k 번째 작은 값을 O(log N) 에

단순 세그먼트 트리는 현재 상태만 저장 → 과거 조회 불가.

방식갱신 1회 추가 공간시간과거 조회
배열 전체 복사O(N)O(log N)
Persistent Segment TreeO(log N)O(log N)
기본 세그먼트 트리O(1)O(log N)

시각화

버전 0 에서 위치 i 를 갱신해 버전 1 을 만드는 path copying 과정.

flowchart TD
    subgraph v0["버전 0"]
        A0["루트 v0"]
        B0["내부 v0-L"]
        C0["내부 v0-R"]
        D0["리프 i v0"]
        E0["리프 i+1 v0"]
    end
    subgraph v1["버전 1, 위치 i 갱신"]
        A1["루트 v1 (새 노드)"]
        B1["내부 v1-L (새 노드)"]
        D1["리프 i v1 (새 노드)"]
    end
    A0 --> B0 & C0
    B0 --> D0 & E0
    A1 -->|"복사된 경로"| B1
    A1 -->|"공유"| C0
    B1 -->|"복사된 경로"| D1
    B1 -->|"공유"| E0
    style A1 fill:#d4edda
    style B1 fill:#d4edda
    style D1 fill:#d4edda

초록 노드만 새로 할당. C0, E0 는 두 버전이 공유해 공간을 절약.

핵심 아이디어

path copying: 갱신이 필요한 루트에서 리프까지의 경로만 새 노드로 복사. 나머지 자식은 이전 버전 노드를 그대로 가리킴.

버전 v 루트: root[v]
버전 v+1 생성:
  1. 루트 노드 새로 할당 (기존 복사)
  2. 갱신 방향 자식: 재귀적으로 새 노드 생성
  3. 반대 방향 자식: 이전 버전 노드 포인터 그대로 유지
  4. root[v+1] = 새 루트

두 버전의 차이로 범위 쿼리: query(root[l-1], root[r], ...) 처럼 두 버전 PST 의 합 차이를 이용하면 구간 통계를 O(log N) 에 계산.

알고리즘

Build

build(l, r):
    id = 새 노드 할당
    if l == r: return id
    m = (l + r) / 2
    node[id].left  = build(l, m)
    node[id].right = build(m+1, r)
    return id

Update (path copying)

update(prev, l, r, pos, val):
    id = 새 노드 (pool[prev] 복사)
    node[id].sum += val
    if l == r: return id
    m = (l + r) / 2
    if pos <= m:
        node[id].left  = update(node[prev].left,  l, m, pos, val)
    else:
        node[id].right = update(node[prev].right, m+1, r, pos, val)
    node[id].sum = node[node[id].left].sum + node[node[id].right].sum
    return id

Query (두 버전 차이)

query(u, v, l, r, L, R):
    // node[v] - node[u] = 이 두 버전 사이 갱신 합
    if L <= l and r <= R:
        return node[v].sum - node[u].sum
    m = (l + r) / 2
    res = 0
    if L <= m: res += query(node[u].left,  node[v].left,  l, m, L, R)
    if R > m:  res += query(node[u].right, node[v].right, m+1, r, L, R)
    return res

K-th Smallest in Range

좌표 압축 후, a[1..i] 에 값 삽입한 누적 PST 를 구축.

kth(u, v, l, r, k):
    // [l-1 버전]과 [r 버전] 차이로 구간 원소 카운트
    if l == r: return l   // 좌표 압축값 -> 실제 값 복원
    m = (l + r) / 2
    left_cnt = node[node[v].left].sum - node[node[u].left].sum
    if k <= left_cnt:
        return kth(node[u].left,  node[v].left,  l, m, k)
    else:
        return kth(node[u].right, node[v].right, m+1, r, k - left_cnt)

구현

BOJ 7469: K번째 수 (구간 k-th smallest)
C++ (PST, K-th Smallest)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 6000005;
struct Node { int l, r, sum; } pool[MAXN];
int root[100005], sz;

int build(int l, int r) {
  int id = ++sz;
  pool[id] = {0, 0, 0};
  if (l == r) return id;
  int m = (l + r) >> 1;
  pool[id].l = build(l, m);
  pool[id].r = build(m + 1, r);
  return id;
}

int update(int prev, int l, int r, int pos) {
  int id = ++sz;
  pool[id] = pool[prev];
  pool[id].sum++;
  if (l == r) return id;
  int m = (l + r) >> 1;
  if (pos <= m) pool[id].l = update(pool[prev].l, l, m, pos);
  else          pool[id].r = update(pool[prev].r, m+1, r, pos);
  return id;
}

int kth(int u, int v, int l, int r, int k) {
  if (l == r) return l;
  int m = (l + r) >> 1;
  int left_cnt = pool[pool[v].l].sum - pool[pool[u].l].sum;
  if (k <= left_cnt) return kth(pool[u].l, pool[v].l, l, m, k);
  return kth(pool[u].r, pool[v].r, m+1, r, k - left_cnt);
}

int main() {
  ios::sync_with_stdio(false);
  cin.tie(nullptr);
  int n, q;
  cin >> n >> q;
  vector<int> a(n+1);
  vector<int> coords;
  for (int i = 1; i <= n; i++) {
      cin >> a[i];
      coords.push_back(a[i]);
  }
  sort(coords.begin(), coords.end());
  coords.erase(unique(coords.begin(), coords.end()), coords.end());
  int M = (int)coords.size();
  auto comp = [&](int x) {
      return (int)(lower_bound(coords.begin(), coords.end(), x)
                   - coords.begin()) + 1;
  };
  root[0] = build(1, M);
  for (int i = 1; i <= n; i++)
      root[i] = update(root[i-1], 1, M, comp(a[i]));
  while (q--) {
      int l, r, k;
      cin >> l >> r >> k;
      int idx = kth(root[l-1], root[r], 1, M, k);
      cout << coords[idx-1] << "\n";
  }
  return 0;
}
결과
// 예: n=5, a=[1,5,2,6,3], 쿼리: l=2 r=5 k=2
// 구간 [2,5] = {5,2,6,3} 정렬 -> {2,3,5,6}
// 2번째 = 3
3

복잡도

연산시간추가 공간
BuildO(N)O(N)
Update 1회O(log N)O(log N) 노드
QueryO(log N)O(1)
K-th 1회O(log N)O(1)

전체 공간: O((N + Q) log N). 좌표 압축 값 범위 M 에 대해 초기 build O(M).

pool 배열 크기: 최소 M + Q * log(M). 실전에서는 N * 40 이 안전.

함정

WARNING

pool 크기 과소 추정: 갱신 1 회당 log N 노드 소모. N = 10^5, Q = 10^5, log N ≈ 17 이면 최소 340 만. 여유 있게 600 만 이상 잡는다.

WARNING

kth 에서 버전 순서 혼동: kth(root[l-1], root[r], ...) 에서 root[l-1] 이 “빼는” 기준 버전. 순서가 바뀌면 left_cnt 가 음수가 되어 WA.

CAUTION

좌표 압축 없이 값 범위가 클 때: 값 범위 1~10^9 이면 build 단계가 O(10^9) → MLE/TLE. 반드시 좌표 압축하거나 Dynamic Segtree 를 사용.

  • merge sort tree 는 구현이 단순하지만 O(log²N). PST 는 O(log N) 이지만 pool 관리 필요.
  • 다차원 PST 는 공간 복잡도가 기하급수로 늘어남 (실전에서 대부분 Wavelet Tree 대체).

BOJ

문제설명
BOJ 7469 K번째 수구간 k-th smallest, 대표 문제
BOJ 16978 수열과 쿼리 22과거 버전 구간 합 조회
BOJ 13557 수열과 쿼리 10구간 최대 부분합 + 버저닝
BOJ 8904 2차원 랜덤 게임머지 소트 트리 vs PST 비교

관련 위키

이 글의 용어 (4개)
머지 소트 트리 (Merge Sort Tree)algorithm
정의 머지 소트 트리 (Merge Sort Tree) 는 세그먼트 트리의 각 노드에 해당 구간의 정렬된 부분 배열을 저장하는 자료구조. 구간 내에서 k 이하 원소의 개수, k번째…
세그먼트 트리 (Segment Tree)algorithm
정의 세그먼트 트리 (Segment Tree) 는 배열의 구간 쿼리 (range query) 와 점 갱신 (point update) 를 모두 O(log N) 에 처리하는 이진 트…
Dynamic Segment Tree: 지연 노드 생성algorithm
정의 Dynamic Segment Tree 는 값 범위가 매우 크지만 실제 접근 위치가 희소할 때, 필요한 노드만 동적으로 생성 하는 세그먼트 트리. 공간: O(Q log V),…
Wavelet Tree: 값 범위 이진 분할algorithm
정의 Wavelet Tree 는 시퀀스의 값 범위를 이진 분할하며 만든 트리 자료구조입니다. k-th smallest in range, rank/select, count in r…

💬 댓글

사이트 검색 / 명령어

검색

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