희소 테이블 (Sparse Table)

O(n log n) 전처리로 구간 최솟값(RMQ)을 O(1)에 쿼리하는 희소 테이블의 원리, 구현, 멱등 연산 제약, LCA 응용까지 살펴봅니다.

· 4 min read · PALDYN Team

지난 글에서 다차원 공간 탐색을 위한 k-d 트리를 다뤘습니다. 이번에는 정적 배열에서 구간 최솟값(Range Minimum Query, RMQ) 쿼리를 놀라운 O(1)로 처리하는 희소 테이블(Sparse Table)을 알아봅니다.

핵심 아이디어

길이 2ʲ인 부분 배열의 최솟값을 미리 계산해 저장해 두면, 임의 구간 [l, r]에 대해 두 개의 미리 계산된 구간으로 덮을 수 있습니다. min은 멱등(idempotent) 연산이라 겹치는 원소를 두 번 세어도 결과가 변하지 않기 때문입니다.

k = floor(log₂(r - l + 1))
RMQ(l, r) = min(sp[k][l], sp[k][r - 2ᵏ + 1])

두 구간이 겹쳐도 최솟값이 올바르게 나옵니다. 이게 O(1) 쿼리의 비밀입니다.

희소 테이블 구조

전처리

const LOG = [0, 0];
for (let i = 2; i <= n; i++)
  LOG[i] = LOG[i >> 1] + 1;

// sp[j][i] = min(a[i..i + 2^j - 1])
const sp = Array.from({ length: LOG[n] + 1 }, () => new Array(n));
for (let i = 0; i < n; i++) sp[0][i] = a[i];

for (let j = 1; (1 << j) <= n; j++) {
  for (let i = 0; i + (1 << j) <= n; i++) {
    sp[j][i] = Math.min(
      sp[j - 1][i],
      sp[j - 1][i + (1 << (j - 1))]
    );
  }
}

점화식: sp[j][i] = min(sp[j-1][i], sp[j-1][i + 2^(j-1)])
→ “2ʲ 길이 구간의 최솟값 = 앞쪽 절반과 뒤쪽 절반의 최솟값 중 작은 것”

희소 테이블 구현

O(1) 쿼리

function query(l, r) {
  const k = LOG[r - l + 1];
  return Math.min(sp[k][l], sp[k][r - (1 << k) + 1]);
}

LOG[len] 조회로 k를 O(1)에 구하고, 두 번의 배열 접근으로 끝납니다.

멱등 연산만 O(1) 가능

연산O(1) 가능?이유
min, max멱등: 중복 원소를 두 번 봐도 무관
gcd, and, or멱등
sum겹치는 구간을 두 번 더하면 오류

합산 쿼리가 필요하면 펜윅 트리나 세그먼트 트리를 써야 합니다.

LCA 응용

희소 테이블의 가장 유명한 활용처는 LCA(Lowest Common Ancestor) 입니다.

  1. 오일러 투어: DFS로 트리를 배열로 변환 (방문할 때마다 깊이 기록)
  2. LCA(u, v) = 오일러 투어 배열에서 u와 v 사이 구간의 최소 깊이 노드
  3. 해당 RMQ를 희소 테이블로 O(1) 처리
# 오일러 투어로 LCA
# euler[]: DFS 방문 순서, first[v]: 노드 v의 첫 방문 위치
def lca(u, v):
    l, r = min(first[u], first[v]), max(first[u], first[v])
    return euler[rmq(l, r)]  # rmq: 깊이가 최소인 인덱스

복잡도

시간공간
전처리O(n log n)O(n log n)
쿼리O(1)

한계

  • 배열이 정적이어야 합니다 (값 업데이트 불가). 동적이면 세그먼트 트리 사용
  • 메모리가 O(n log n)으로 n이 크면 주의 (n=10⁷이면 약 23×10⁷ ≈ 920MB)

지난 글: k-d 트리

다음 글: 그래프 표현


읽어주셔서 감사합니다. 😊