한양과학기술고등학교

에듀테크 프로젝트 대시보드

스파스 테이블 (Sparse Table)

스파스 테이블 (Sparse Table)

배열이 절대 안 바뀐다면 — 구간 최솟값 질의를 O(1)에 끝내는 정적 전처리 구조

"구간 [l, r]의 최솟값은?" 같은 질의를 아주 많이 던져야 한다고 하자. 매번 min(arr[l:r+1])처럼 스캔하면 한 번에 O(r-l)이라, 질의가 수십만 번이면 감당이 안 된다. 그런데 배열 값이 중간에 절대 바뀌지 않는다는 조건이 붙으면 얘기가 달라진다 — 모든 질의에서 쓰일 법한 답을 미리 다 계산해 두면 되기 때문이다.

스파스 테이블은 구간 길이를 2의 거듭제곱(1, 2, 4, 8, …)으로만 한정해서 그 값들을 몽땅 표로 저장해 둔다. 임의의 [l, r]은 정확히 안 나눠도 2의 거듭제곱 구간 두 개로 덮을 수 있어서, 전처리에 O(n log n)을 쓰는 대신 질의는 O(1)에 끝난다. 세그먼트 트리·펜윅 트리보다 앞서 배우는 이유는 딱 하나 — 갱신이 없다는 가정 덕분에 구조가 훨씬 단순하기 때문이다.

① 핵심 아이디어 — dp[j][i] = "i부터 길이 2ʲ 구간의 최솟값"

dp[0][i] = arr[i] (길이 1)에서 시작해서, 한 층 위로 올라갈 때마다 바로 아래 층의 칸 두 개를 합쳐서 만든다:

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

길이 2ʲ짜리 구간은 길이 2ʲ⁻¹짜리 구간 두 개를 반으로 딱 붙인 것이므로, 그 두 구간의 최솟값 중 더 작은 쪽이 곧 전체의 최솟값이다. 층은 log₂n개, 각 층마다 최대 n칸이므로 전처리는 시간·공간 모두 O(n log n).

② 직접 만져보기 — dp 테이블 쌓아 올리기

맨 아래 배열 칸을 클릭하면 값이 1씩 바뀐다(9 다음은 0). 슬라이더로 배열 크기를 바꾸고 배열 랜덤 생성을 누르면 새 배열이 만들어진다. 테이블 만들기를 누르면 j=0(원본)부터 한 층씩, 각 칸이 왼쪽 소스와 오른쪽 소스 두 칸에서 어떻게 합쳐지는지 연결선으로 보여준다.

배열 칸을 편집한 뒤 테이블 만들기를 눌러보세요.

③ 왜 구간이 겹쳐도 괜찮은가 — 멱등성(idempotence)

임의의 [l, r]을 딱 맞게 자를 필요가 없다. 길이 2ᵏ ≤ (r-l+1)인 가장 큰 k를 골라 [l, l+2ᵏ-1]과 [r-2ᵏ+1, r] 두 블록으로 덮으면 끝. 두 블록이 겹칠 수도 있는데, min은 같은 원소를 두 번 봐도 답이 변하지 않는다 — 이런 성질을 멱등(idempotent)이라고 부른다. max, gcd도 마찬가지다.

그런데 합(sum)은 이 방법을 못 쓴다. 두 블록이 겹치면 겹친 부분이 두 번 더해져 답이 틀려진다. 그래서 구간 합에는 스파스 테이블이 아니라 세그먼트 트리 / 펜윅 트리처럼 구간을 정확히 겹치지 않게 나누는 구조가 필요하다. "정적 + 멱등 연산"이면 스파스 테이블, "동적이거나 합처럼 겹치면 안 되는 연산"이면 트리 계열 — 이게 이 셋을 구분하는 기준이다.

④ 직접 만져보기 — 쿼리 데모 (두 블록, O(1))

슬라이더로 [l, r]을 고르면 블록 A와 블록 B가 배열 위에 표시되고, 겹치는 칸은 세 번째 색으로 보인다. 쿼리 실행을 누르면 위쪽 dp 표에서 실제로 참조하는 두 칸도 함께 반짝인다 — 이 두 칸만 보면 끝이라 O(1)이다.

구간 [1, 6]으로 쿼리를 눌러보세요.

⑤ 코드로 보기

# 전처리: O(n log n)
n = len(arr)
LOG = n.bit_length()                    # 2^LOG-1 >= n
dp = [[0]*n for _ in range(LOG)]
dp[0] = arr[:]                        # j=0: 길이 1 구간 = 원소 자신

for j in range(1, LOG):
    half = 1 << (j - 1)                # 2^(j-1)
    for i in range(n - (1 << j) + 1):  # 길이 2^j가 안 벗어나는 i까지
        dp[j][i] = min(dp[j-1][i], dp[j-1][i + half])

# 쿼리: O(1), 겹치는 두 블록으로 덮기
def query(l, r):
    k = (r - l + 1).bit_length() - 1     # 2^k <= r-l+1 인 최대 k
    return min(dp[k][l], dp[k][r - (1 << k) + 1])

⑥ 세그먼트 트리 / 펜윅 트리와 비교

항목스파스 테이블세그먼트 트리펜윅 트리
전처리O(n log n)O(n)O(n)
구간 쿼리O(1)O(log n)O(log n) · query(r)-query(l-1)
값 갱신불가 (정적 배열 전용)O(log n)O(log n)
지원 연산min / max / gcd 등 멱등 연산만결합법칙만 성립하면 무엇이든(합 포함)합·XOR 등 역원 있는 연산
메모리O(n log n)O(n) ~ O(4n)O(n)
구현 난이도가장 쉬움중간가장 짧음

언제 스파스 테이블을 쓸까?

배열이 고정되어 있고 구간 최솟값·최댓값·GCD 같은 멱등 연산 질의만 반복된다면 스파스 테이블이 가장 간단하고 빠르다(구현도 짧고 쿼리는 상수 시간). 반대로 값이 계속 바뀌거나 구간 합이 필요하면 세그먼트 트리나 펜윅 트리로 넘어가야 한다. 실전에서는 "LCA를 위한 오일러 투어 + RMQ" 조합에서 스파스 테이블이 아주 자주 쓰인다.

한 줄 정리: 배열이 안 바뀐다는 전제를 깔면, 2의 거듭제곱 길이 구간만 미리 다 계산해 두고 겹쳐도 답이 변하지 않는 연산(min/max/gcd)에 한해 O(1) 쿼리를 얻는 것 — 그게 스파스 테이블이다. "갱신 가능"이 필요해지는 순간 세그먼트 트리로, "합"이 필요해지는 순간 펜윅 트리로 넘어가면 된다.