[Daily morning study] 스파스 테이블(Sparse Table)과 구간 최솟값 쿼리(RMQ)

#daily morning study

Image


스파스 테이블이란?

스파스 테이블(Sparse Table)은 정적 배열에서 구간 쿼리(Range Query)를 O(1)에 처리하기 위한 자료구조다. 대표적으로 구간 최솟값 쿼리(RMQ, Range Minimum Query) 에 사용된다.

  • 전처리(build): O(n log n) 시간, O(n log n) 공간
  • 쿼리: O(1) (겹치는 구간 허용 쿼리의 경우)

세그먼트 트리가 업데이트를 지원하는 범용 자료구조라면, 스파스 테이블은 배열이 변하지 않는 상황에서 쿼리 속도를 극한으로 끌어올린 특화된 도구다.


핵심 아이디어: 2의 거듭제곱 구간

스파스 테이블은 2의 거듭제곱 길이의 구간에 대한 정보를 미리 계산해 저장한다.

sparse[i][j] = arr[i..i + 2^j - 1] 구간의 최솟값
j (구간 길이 2^j)의미
j = 0길이 1짜리 구간, 즉 arr[i] 자체
j = 1길이 2짜리 구간
j = 2길이 4짜리 구간
j = k길이 2^k짜리 구간

점화식:

sparse[i][j] = min(sparse[i][j-1], sparse[i + 2^(j-1)][j-1])

길이 2^j 구간은 길이 2^(j-1) 짜리 두 구간으로 나뉜다. 왼쪽 절반 [i, i + 2^(j-1) - 1], 오른쪽 절반 [i + 2^(j-1), i + 2^j - 1]의 최솟값을 비교한다.


전처리 (Build)

import math

def build(arr):
    n = len(arr)
    LOG = math.floor(math.log2(n)) + 1 if n > 0 else 1
    sparse = [[float('inf')] * LOG for _ in range(n)]

    # j = 0: 길이 1 구간은 자기 자신
    for i in range(n):
        sparse[i][0] = arr[i]

    # j >= 1: 2의 거듭제곱 구간 채우기
    for j in range(1, LOG):
        for i in range(n - (1 << j) + 1):
            sparse[i][j] = min(sparse[i][j-1], sparse[i + (1 << (j-1))][j-1])

    return sparse, LOG

쿼리 (Query) — O(1)

구간 [l, r]의 최솟값을 구하는 방법:

  1. 구간 길이 len = r - l + 1을 구한다.
  2. k = floor(log2(len))을 구한다.
  3. [l, l + 2^k - 1] 과 [r - 2^k + 1, r] 두 구간은 [l, r]을 완전히 덮는다.
  4. 두 구간이 겹쳐도 최솟값(min)은 중복을 허용하므로 결과가 올바르다.
def query(sparse, l, r):
    length = r - l + 1
    k = math.floor(math.log2(length))
    return min(sparse[l][k], sparse[r - (1 << k) + 1][k])

겹치는 두 구간의 최솟값을 비교하므로 중복 원소를 세도 답이 바뀌지 않는다. 이 성질을 멱등성(idempotency) 이라 하고, 이 덕분에 O(1) 쿼리가 가능하다.

합(sum)은 중복을 허용하면 답이 달라지므로 스파스 테이블로 O(1) 쿼리가 불가능하다. 대신 세그먼트 트리나 펜윅 트리를 쓴다.


전체 예시

import math

def build(arr):
    n = len(arr)
    LOG = max(1, math.floor(math.log2(n)) + 1)
    sparse = [[float('inf')] * LOG for _ in range(n)]
    for i in range(n):
        sparse[i][0] = arr[i]
    for j in range(1, LOG):
        for i in range(n - (1 << j) + 1):
            sparse[i][j] = min(sparse[i][j-1], sparse[i + (1 << (j-1))][j-1])
    return sparse, LOG

def query(sparse, l, r):
    k = math.floor(math.log2(r - l + 1))
    return min(sparse[l][k], sparse[r - (1 << k) + 1][k])

arr = [2, 4, 3, 1, 6, 7, 8, 9, 1, 7]
sparse, LOG = build(arr)

print(query(sparse, 0, 4))  # min(2,4,3,1,6) = 1
print(query(sparse, 2, 7))  # min(3,1,6,7,8,9) = 1
print(query(sparse, 5, 9))  # min(7,8,9,1,7) = 1

시간 / 공간 복잡도

단계복잡도
전처리 (Build)O(n log n)
쿼리 (Query)O(1)
공간O(n log n)

세그먼트 트리와 비교

항목스파스 테이블세그먼트 트리
전처리O(n log n)O(n)
쿼리O(1)O(log n)
업데이트불가O(log n)
공간O(n log n)O(n)
멱등 연산min, max, gcd 등모든 연산

배열이 고정된 상태에서 쿼리를 수백만 번 반복해야 한다면 스파스 테이블이 훨씬 빠르다. 반대로 배열 원소가 자주 바뀐다면 세그먼트 트리를 써야 한다.


활용 사례

LCA (Lowest Common Ancestor)
트리에서 두 노드의 최소 공통 조상을 구할 때, Euler Tour + 스파스 테이블 조합으로 O(n log n) 전처리 후 O(1) LCA 쿼리가 가능하다.

구간 GCD
gcd(a, gcd(a, b)) = gcd(a, b) 성질(멱등성)이 있어 스파스 테이블로 구간 GCD를 O(1)에 구할 수 있다.

슬라이딩 윈도우 최솟값
덱(Deque)으로도 가능하지만, 스파스 테이블이 있으면 O(1) 쿼리로 더 단순하게 처리할 수 있다.


구현 시 주의점

  • log2 계산은 매번 하면 느릴 수 있으니 log 테이블을 미리 계산해두면 쿼리가 더 빠르다.
# log 테이블 전처리
log_table = [0] * (n + 1)
for i in range(2, n + 1):
    log_table[i] = log_table[i // 2] + 1
  • 배열 크기 n이 1일 때 log2(1) = 0이므로 LOG를 최소 1로 설정해야 한다.
  • C++에서는 __builtin_clz 비트 연산으로 더 빠르게 log를 계산할 수 있다.
int k = 31 - __builtin_clz(r - l + 1);
return min(sparse[l][k], sparse[r - (1 << k) + 1][k]);