코스

자료 구조

스택·큐부터 세그먼트 트리, 퍼시스턴트 구조까지.

Level 2 → Level 9 104 아이템 38 문제 66 강의 0 확인 문제
코스 진행도 0%
0 / 104 아이템 완료
01
Level 2 · Solver

Solver

자료 구조 · Solver 단계

0/25 완료
Lesson 1강 · 개념 — 집합과 맵, 정렬 기반 vs 해시 기반 필수 8m 현재

집합과 맵이란

  • 집합(set) — 중복 없이 원소를 담고, "이 값이 있나?"를 빠르게 답한다.
  • 맵(map, dictionary)키 → 값 대응을 저장하고, 키로 값을 빠르게 찾는다.

두 구조 모두 삽입, 삭제, 검색을 핵심 연산으로 하며, 구현 방식에 따라 복잡도가
갈립니다.

구현 삽입/삭제/검색 순서 유지 대표
균형 이진 탐색 트리 \(O(\log N)\) 정렬 순서 C++ set/map, Java TreeMap
해시 테이블 평균 \(O(1)\), 최악 \(O(N)\) 없음 C++ unordered_set/map, 파이썬 set/dict

불변식(정렬 기반): 원소가 항상 정렬된 상태로 유지되므로, "\(x\) 이상 가장 작은
값"(lower_bound) 같은 순서 질의\(O(\log N)\)에 가능하다.
불변식(해시 기반): 순서는 보장되지 않지만, 존재 여부·키 조회가 평균 상수.


언제 무엇을 쓰나

  • 존재 여부, 중복 제거, 빈도수 — 순서가 필요 없으면 해시 기반(가장 빠름).
  • "\(x\)보다 큰 가장 작은 원소", 정렬 순회, 범위 질의정렬 기반(BBST).
  • 키가 정수이고 범위가 작다 — 그냥 배열이 가장 빠릅니다(맵 불필요).
  • 키가 큰 정수/문자열/좌표 — 맵 또는 좌표 압축.

"봤던 값인지 빠르게 확인", "무언가를 세다", "키로 찾다"가 신호입니다.


워크드 예제 — 빈도수 세기

배열 [2, 3, 2, 5, 3, 2]에서 각 값의 등장 횟수를 맵으로 셉니다. 값을 하나씩
보며 cnt[값] += 1:

처리 값 맵 상태
2 {2:1}
3 {2:1, 3:1}
2 {2:2, 3:1}
5 {2:2, 3:1, 5:1}
3 {2:2, 3:2, 5:1}
2 {2:3, 3:2, 5:1}

최종적으로 2는 3번, 3은 2번, 5는 1번. 이렇게 맵은 "키별 누적"을 자연스럽게
표현합니다. 존재 여부만 필요했다면 값을 버리고 집합을 썼을 것입니다.

다음 강의에서 언어별 API와 정렬 기반의 순서 질의, 좌표 압축을 다룹니다.

Lesson 2강 · 구현 — set/map, unordered, dict, 좌표 압축 선택 8m

C++ — 정렬 기반 set / map

내부가 균형 트리라 항상 정렬되어 있고, 순서 질의가 가능합니다.

#include <bits/stdc++.h>
using namespace std;

int main() {
    set<int> s;
    s.insert(5); s.insert(3); s.insert(8); s.insert(3); // 3 중복 무시
    cout << s.count(3) << "\n";        // 1 (있으면 1, 없으면 0)
    s.erase(3);
    cout << s.count(3) << "\n";        // 0

    // 순서 질의: 5 이상인 가장 작은 원소
    auto it = s.lower_bound(5);        // >= 5
    if (it != s.end()) cout << *it << "\n";   // 5
    auto it2 = s.upper_bound(5);       // > 5
    if (it2 != s.end()) cout << *it2 << "\n"; // 8

    map<string,int> cnt;
    cnt["apple"]++;                    // 없으면 0에서 시작
    cnt["apple"]++;
    cout << cnt["apple"] << "\n";      // 2
    if (cnt.count("banana")) cout << "yes\n"; else cout << "no\n"; // no
}

주의: map에서 m["key"]읽기만 해도 그 키가 없으면 새로 생성
(기본값 0)됩니다. 존재 확인만 하려면 count 또는 find를 쓰세요. 안 그러면
맵이 의도치 않게 커지고, 순회 결과가 달라집니다.

해시 기반 unordered_set / unordered_map

순서가 필요 없으면 평균 \(O(1)\)로 더 빠릅니다.

unordered_map<int,int> cnt;
for (int x : {2,3,2,5,3,2}) cnt[x]++;
cout << cnt[2] << "\n";   // 3

Python — set / dict

파이썬의 set/dict는 해시 기반입니다.

s = set()
s.add(5); s.add(3); s.add(8); s.add(3)   # 3 중복 무시
print(3 in s)          # True
s.discard(3)           # 없어도 에러 안 남 (remove는 에러)
print(3 in s)          # False

# 빈도수: Counter가 편리
from collections import Counter
cnt = Counter([2, 3, 2, 5, 3, 2])
print(cnt[2])          # 3
print(cnt[99])         # 0  (없는 키는 0, 하지만 조회로 생성되진 않음)

# 기본값 딕셔너리
from collections import defaultdict
g = defaultdict(list)
g[1].append(2)         # 없으면 빈 리스트 자동 생성
print(g[1])            # [2]

파이썬 표준 set/dict에는 정렬 순서 질의(lower_bound)가 없습니다. 정렬된
순서 질의가 필요하면 배열을 정렬해 bisect를 쓰거나(정적), 좌표 압축 + BIT/세그
트리를 씁니다.

import bisect
arr = [3, 5, 8]                 # 정렬 상태 유지 필요
i = bisect.bisect_left(arr, 5)  # >= 5 인 첫 위치 → 1
print(arr[i])                   # 5

좌표 압축

값의 절대 크기는 중요치 않고 대소 관계만 필요할 때, 큰 값들을
\(0,1,2,\dots\)로 다시 매기는 기법입니다. 맵 대신 배열을 쓸 수 있게 해 줍니다.

vector<int> a = {100, 5, 100, 50000, 5};
vector<int> vals = a;
sort(vals.begin(), vals.end());
vals.erase(unique(vals.begin(), vals.end()), vals.end()); // 정렬 + 중복 제거
for (int& x : a)
    x = lower_bound(vals.begin(), vals.end(), x) - vals.begin(); // 압축 인덱스
// a = {1, 0, 1, 2, 0}  (5→0, 100→1, 50000→2)
a = [100, 5, 100, 50000, 5]
vals = sorted(set(a))                  # [5, 100, 50000]
comp = {v: i for i, v in enumerate(vals)}
a = [comp[x] for x in a]               # [1, 0, 1, 2, 0]

압축 후에는 값을 배열 인덱스로 쓸 수 있어, 세그 트리·BIT·DP 배열의 크기를
\(O(N)\)으로 줄일 수 있습니다.

Lesson 3강 · 심화와 변형 — multiset, 순서 질의, 해시 함정 선택 8m

multiset — 중복을 허용하는 집합

같은 값을 여러 개 담아야 하면 multiset을 씁니다. 슬라이딩 윈도우의 최솟값·
최댓값을 동적으로 유지하거나, "현재 남은 값들 중 극값"을 반복해 뽑을 때
유용합니다.

multiset<int> ms;
ms.insert(5); ms.insert(5); ms.insert(3);
cout << *ms.begin() << "\n";        // 3 (최솟값)
cout << *ms.rbegin() << "\n";       // 5 (최댓값)

// 5를 '하나만' 지우기 — 아주 흔한 함정
ms.erase(ms.find(5));               // O(log N), 5 하나만 제거
// ms.erase(5);  ← 이렇게 하면 5를 '전부' 지운다! 주의
cout << ms.count(5) << "\n";        // 1

함정: ms.erase(값)은 그 값을 전부 삭제합니다. 하나만 지우려면
반드시 ms.erase(ms.find(값))처럼 반복자로 지우세요.

파이썬 표준 라이브러리에는 multiset이 없어, Counter(값→개수)나 정렬 리스트 +
bisect.insort(삽입 \(O(N)\) 주의), 또는 힙으로 대체합니다.


순서 질의 활용 — 정렬 집합의 힘

정렬 기반 set/map은 다음을 \(O(\log N)\)에 답합니다.

질의 방법
\(x\) 이상 가장 작은 값 lower_bound(x)
\(x\) 초과 가장 작은 값 upper_bound(x)
\(x\) 이하 가장 큰 값 upper_bound(x) 에서 하나 앞 (--it)
정렬 순회 begin()~end()

예: 수직선에 점을 하나씩 추가하며 "새 점과 가장 가까운 기존 점"을 찾을 때,
lower_bound로 바로 위 이웃, 그 앞 반복자로 바로 아래 이웃을 확인합니다.

set<int> pts;
// 새 점 x의 가장 가까운 이웃 후보
auto it = pts.lower_bound(x);          // x 이상 첫 원소
int best = INT_MAX;
if (it != pts.end()) best = min(best, *it - x);
if (it != pts.begin()) { --it; best = min(best, x - *it); }

begin()에서 --it 하면 미정의 동작이므로 it != begin() 확인
필수입니다.


해시 기반의 함정

  • 최악 \(O(N)\) 저격unordered_map은 알려진 해시에 대해 충돌을 유도하는
    입력으로 \(O(N)\)까지 느려질 수 있습니다(anti-hash). 시간 제한이 빡빡하면
    사용자 정의 해시(랜덤 상수 섞기)를 쓰거나 정렬 기반 map으로 바꾸세요.
struct Hash {
    size_t operator()(long long x) const {
        x ^= 0x9e3779b97f4a7c15ULL;      // 랜덤 상수로 섞기
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
        x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
        return x ^ (x >> 31);
    }
};
unordered_map<long long, int, Hash> safe;
  • 반복자 무효화unordered 계열은 원소 삽입으로 rehash가 일어나면 반복자·
    참조가 무효화됩니다. 순회 중 삽입에 주의.
  • 부동소수 키 — 실수를 키로 쓰면 미세 오차로 검색 실패. 정수로 스케일링하거나
    반올림 후 사용.
  • operator[]의 부작용 — C++ map/unordered_map은 없는 키를 읽어도
    생성됩니다. 존재 확인은 count/find/(C++20)contains.

무엇을 고를까 — 요약

필요 선택
존재 여부·중복 제거만, 최고 속도 해시 집합(unordered_set/set)
정렬 순서·범위·이웃 질의 정렬 트리(set/map)
중복 허용 + 극값 유지 multiset (또는 힙/Counter)
키가 작은 정수 그냥 배열
큰 값이지만 대소만 중요 좌표 압축 후 배열

집합·맵은 거의 모든 문제에 조연으로 등장합니다. "순서가 필요한가?"와 "최악
성능 저격이 있나?" 두 질문으로 구현을 고르면 실수를 줄일 수 있습니다.

Practice problem 합이 T인 두 수 쌍의 개수 선택 25m
R00317

합이 T인 두 수 쌍의 개수

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 가장 인기 있는 메뉴 선택 25m
R00116

가장 인기 있는 메뉴

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — LIFO 원리와 활용 필수 8m

스택이란

스택(stack)가장 나중에 넣은 것이 가장 먼저 나오는 자료구조입니다.
이 규칙을 LIFO(Last In, First Out)라 부릅니다. 물건을 쌓아 올린 더미에서
맨 위 물건만 꺼낼 수 있는 상황을 떠올리면 됩니다.

핵심 연산은 다음 네 가지이며, 모두 \(O(1)\) 입니다.

연산 의미 복잡도
push(x) 맨 위에 \(x\)를 쌓는다 \(O(1)\)
pop() 맨 위 원소를 꺼내 제거한다 \(O(1)\)
top() (peek) 맨 위 원소를 보기만 한다 \(O(1)\)
empty() / size() 비었는지 / 개수 \(O(1)\)

불변식: 스택의 원소는 삽입된 순서대로 아래에서 위로 쌓이며, 접근은 오직
"맨 위" 한 곳에서만 일어난다. 중간 원소에는 직접 접근할 수 없다.


언제 스택을 떠올리나

  • 짝 맞추기 / 중첩(괄호, 태그) — 여는 기호를 쌓고, 닫는 기호를 만나면 맨 위와
    대응하는지 확인.
  • 되돌리기(undo), 뒤로 가기 — 가장 최근 상태로 돌아가야 할 때.
  • 재귀의 명시적 표현 / DFS — 함수 호출 스택을 배열 스택으로 바꿔 반복 구현.
  • 수식 계산 — 후위 표기법 평가, 연산자 우선순위 처리.
  • 모노톤 스택 — "직전에 나보다 큰(작은) 원소" 류 문제를 전체 \(O(N)\)에.

"가장 최근 것부터 처리한다", "짝을 맞춘다"는 신호가 보이면 스택을 의심하세요.


워크드 예제 — 괄호 검사

문자열 "( ( ) ( ) )" 가 올바른 괄호열인지 확인해 봅시다. 여는 괄호 (
push, 닫는 괄호 )는 스택이 비어 있지 않은지 확인하고 pop 합니다.

문자 동작 스택 상태(아래→위)
( push (
( push ( (
) pop (
( push ( (
) pop (
) pop (빈 스택)

끝까지 처리한 뒤 스택이 비어 있으면 올바른 괄호열입니다. 만약 닫는 괄호를
만났는데 스택이 비어 있으면(짝이 없음), 또는 끝났는데 스택이 남아 있으면(여는
괄호가 안 닫힘) 잘못된 괄호열입니다.


배열로 본 스택

스택은 배열 하나와 "맨 위 위치"를 가리키는 정수 top 하나로 완전히 표현됩니다.
top은 보통 마지막 원소의 인덱스, 또는 다음에 쌓을 빈 칸을 가리킵니다.

data: [ 5 | 2 | 8 |   |   ]
                 ^ top = 2  (원소 3개: 5,2,8)
push(9) → data[3]=9, top=3
pop()   → top=2, 반환 8

이렇게 배열 끝에서만 넣고 빼기 때문에 모든 연산이 \(O(1)\)이고, 구현이 매우
단순합니다. 다음 강의에서 언어별 표준 스택과 직접 구현, 그리고 모노톤 스택을
다룹니다.

Lesson 2강 · 구현 — 표준 라이브러리와 직접 구현 선택 8m

C++ — std::stack

가장 간단한 방법은 표준 라이브러리의 std::stack을 쓰는 것입니다.

#include <bits/stdc++.h>
using namespace std;

int main() {
    stack<int> st;
    st.push(5);
    st.push(2);
    st.push(8);
    cout << st.top() << "\n";   // 8
    st.pop();                    // 8 제거
    cout << st.top() << "\n";   // 2
    cout << st.size() << "\n";  // 2
    while (!st.empty()) st.pop();
    cout << (st.empty() ? "empty" : "not") << "\n"; // empty
}

주의: top()pop()빈 스택에서 호출하면 미정의 동작입니다.
항상 !st.empty()를 먼저 확인하세요. 또 pop()은 값을 반환하지 않으므로,
값이 필요하면 top()으로 읽은 뒤 pop() 하세요.

배열로 직접 구현 (더 빠름)

대회에서는 크기 상한을 알 때 vector나 정적 배열로 직접 만드는 것이 상수가
작아 유리합니다.

const int MAXN = 1000000;
int data[MAXN], top = 0;      // top = 원소 개수(= 다음 빈 칸)

void push(int x) { data[top++] = x; }
int  pop()       { return data[--top]; }   // 비었는지 호출자가 확인
int  peek()      { return data[top - 1]; }
bool empty()     { return top == 0; }

Python — 리스트가 곧 스택

파이썬은 listappend/pop이 끝에서 \(O(1)\)로 동작하여 그대로 스택입니다.

st = []
st.append(5)
st.append(2)
st.append(8)
print(st[-1])      # 8  (top)
print(st.pop())    # 8  (꺼내며 제거)
print(st[-1])      # 2
print(len(st))     # 2
print(not st)      # False (비었으면 True)

주의: pop()은 인덱스를 주지 않으면 맨 뒤에서 뺍니다. pop(0)
맨 앞에서 빼지만 \(O(N)\)이므로 스택 용도로는 절대 쓰지 마세요(큐가 필요하면
collections.deque). 빈 리스트에서 pop()이나 st[-1]은 예외를 던집니다.


응용 — 괄호 검사 전체 코드

1강의 예제를 코드로 옮기면 다음과 같습니다.

bool balanced(const string& s) {
    stack<char> st;
    for (char c : s) {
        if (c == '(') st.push(c);
        else {                       // c == ')'
            if (st.empty()) return false;  // 짝 없는 닫는 괄호
            st.pop();
        }
    }
    return st.empty();               // 안 닫힌 여는 괄호가 없어야 함
}
def balanced(s):
    st = []
    for c in s:
        if c == '(':
            st.append(c)
        else:
            if not st:
                return False
            st.pop()
    return not st

여러 종류의 괄호 ()[]{}라면, push 할 때 여는 괄호를 넣고 닫는 괄호를 만나면
top대응하는 여는 괄호인지까지 확인하면 됩니다.

Lesson 3강 · 심화와 변형 — 모노톤 스택 선택 8m

모노톤 스택이란

모노톤 스택(monotonic stack) 은 스택 안의 값이 항상 단조(증가 또는
감소)
를 유지하도록 관리하는 기법입니다. "각 원소의 오른쪽(왼쪽)에서 처음으로
자기보다 큰/작은 원소"를 전체 \(O(N)\) 에 구할 때 핵심 도구입니다.

핵심은 각 원소가 스택에 정확히 한 번 push 되고 한 번 pop 된다는 점입니다.
그래서 안쪽에 while 루프가 있어도 전체 연산 횟수는 \(O(N)\)입니다(분할상환).


워크드 예제 — 오른쪽에서 처음 나보다 큰 값(NGE)

배열 [3, 1, 4, 2]에서 각 원소의 다음 큰 값(Next Greater Element) 을 찾습니다.
값을 왼쪽부터 보며, 스택에는 아직 답을 못 찾은 원소들의 인덱스를 값이
감소하도록(위로 갈수록 작게) 담아 둡니다.

i a[i] 처리 스택(인덱스, 값) 확정된 답
0 3 push [0(3)]
1 1 1<3 → push [0(3),1(1)]
2 4 4>1 pop1, 4>3 pop0, push [2(4)] ans[1]=4, ans[0]=4
3 2 2<4 → push [2(4),3(2)]

끝나고 스택에 남은 인덱스(2,3)는 오른쪽에 더 큰 값이 없으므로 답이 없습니다
(보통 \(-1\)). 결과: ans = [4, 4, -1, -1].

vector<int> nextGreater(const vector<int>& a) {
    int n = a.size();
    vector<int> ans(n, -1);
    stack<int> st;                       // 인덱스 저장, 값은 감소 유지
    for (int i = 0; i < n; i++) {
        while (!st.empty() && a[st.top()] < a[i]) {
            ans[st.top()] = a[i];        // i가 st.top()의 다음 큰 값
            st.pop();
        }
        st.push(i);
    }
    return ans;
}
def next_greater(a):
    n = len(a)
    ans = [-1] * n
    st = []                              # 인덱스
    for i in range(n):
        while st and a[st[-1]] < a[i]:
            ans[st.pop()] = a[i]
        st.append(i)
    return ans

응용 — 히스토그램에서 가장 큰 직사각형

막대 높이 배열에서 "연속 구간으로 만들 수 있는 최대 직사각형 넓이"는 모노톤
스택의 대표 응용입니다. 각 막대를 높이로 하는 직사각형이 왼쪽·오른쪽으로
얼마나 뻗을 수 있는지
(자기보다 낮은 막대를 만날 때까지)를 스택으로 구합니다.

long long largestRectangle(const vector<int>& h) {
    int n = h.size();
    stack<int> st;                       // 높이가 증가하도록 인덱스 저장
    long long best = 0;
    for (int i = 0; i <= n; i++) {
        int cur = (i == n) ? 0 : h[i];   // 끝에 높이 0 sentinel
        while (!st.empty() && h[st.top()] >= cur) {
            int height = h[st.top()]; st.pop();
            int left = st.empty() ? -1 : st.top();
            long long width = i - left - 1;
            best = max(best, (long long)height * width);
        }
        st.push(i);
    }
    return best;
}

long long을 쓰는 이유: 높이와 너비가 모두 크면 넓이가 \(2^{31}\)를 넘겨
오버플로 하기 때문입니다.


흔한 함정과 변형

  • 빈 스택 접근top()/pop() 전에 반드시 empty() 확인.
  • 인덱스 vs 값 — 위치가 필요한 문제(거리, 너비)는 인덱스를 저장하세요.
    값만 저장하면 뻗는 너비를 계산할 수 없습니다.
  • 단조 방향 — "다음 큰 값"은 pop 조건이 a[top] < a[i](감소 스택),
    "다음 작은 값"은 a[top] > a[i](증가 스택). 등호 처리(< vs <=)는 중복
    값에서 답이 갈리니 문제 정의에 맞추세요.
  • sentinel — 히스토그램처럼 "끝까지 남은 원소"를 강제로 비우려면 배열 끝에
    가상의 최솟값을 하나 추가하면 코드가 깔끔해집니다.
  • 오버플로 — 넓이·합을 다루면 long long.

모노톤 스택은 "각 원소에 대해 한쪽 방향의 가장 가까운 더 큰/작은 원소"라는
질문으로 환원되는 모든 문제(빗물 고이기, 주식 스팬, 히스토그램)에 재사용됩니다.

Practice problem 폭탄 문자열 터뜨리기 선택 25m
R00120

폭탄 문자열 터뜨리기

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 히스토그램에서 가장 큰 직사각형 선택 25m
R00725

히스토그램에서 가장 큰 직사각형

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — FIFO 큐와 양방향 덱 필수 8m

큐란

큐(queue)먼저 넣은 것이 먼저 나오는 자료구조입니다. 이 규칙을
FIFO(First In, First Out)라 부릅니다. 매표소 줄서기처럼, 뒤에서 들어와
앞에서 나갑니다.

핵심 연산은 모두 \(O(1)\) 입니다.

연산 의미 복잡도
push(enqueue) 뒤에 넣는다 \(O(1)\)
pop(dequeue) 앞에서 뺀다 \(O(1)\)
front 맨 앞을 본다 \(O(1)\)
empty / size 비었는지 / 개수 \(O(1)\)

불변식: 원소는 삽입 순서대로 뒤에서 들어와 앞에서 나간다. 삽입은 뒤,
삭제는 앞 — 서로 다른 끝에서 일어난다.


덱이란

덱(deque, double-ended queue)양쪽 끝 모두에서 넣고 뺄 수 있는
자료구조입니다. 큐와 스택을 모두 포함하는 일반형이라고 볼 수 있습니다.

연산 의미
push_front / push_back 앞/뒤에 넣기
pop_front / pop_back 앞/뒤에서 빼기
front / back 앞/뒤 보기

네 방향 연산이 모두 \(O(1)\) 입니다. 앞만 빼고 뒤만 넣으면 큐, 한쪽만 쓰면
스택이 됩니다.


언제 쓰나

  • — BFS(너비 우선 탐색), 시뮬레이션에서 "먼저 온 순서대로 처리",
    프로듀서-컨슈머 버퍼.
  • — 양끝에서 삽입/삭제가 필요한 시뮬레이션(예: 카드 돌리기), 그리고
    모노톤 덱을 이용한 슬라이딩 윈도우 최댓값/최솟값.

"먼저 온 것부터 처리한다"는 신호는 큐, "양끝을 다룬다 / 슬라이딩 윈도우의
극값"은 덱을 떠올리세요.


워크드 예제 — 큐로 보는 BFS 한 단계

정점 0에서 시작하는 BFS를 생각합시다. 큐에 0을 넣고 시작합니다.

단계 pop(front) push(이웃) 큐 상태(front→back)
시작 0 0
1 0 1, 2 1 2
2 1 3 2 3
3 2 4 3 4

먼저 발견된 정점이 먼저 확장되므로, BFS는 시작점에서 가까운 순서대로 방문하게
됩니다. 이 "먼저 온 순서 보존"이 큐의 본질입니다. 다음 강의에서 언어별 구현과
원형 큐, 모노톤 덱을 다룹니다.

Lesson 2강 · 구현 — 원형 큐와 언어별 덱 선택 8m

C++ — std::queue, std::deque

#include <bits/stdc++.h>
using namespace std;

int main() {
    queue<int> q;
    q.push(1); q.push(2); q.push(3);
    cout << q.front() << "\n";   // 1
    q.pop();                      // 1 제거
    cout << q.front() << "\n";   // 2
    cout << q.size() << "\n";    // 2

    deque<int> dq;
    dq.push_back(10);
    dq.push_front(5);            // [5, 10]
    dq.push_back(20);           // [5, 10, 20]
    cout << dq.front() << " " << dq.back() << "\n"; // 5 20
    dq.pop_front();             // [10, 20]
    dq.pop_back();              // [10]
    cout << dq.front() << "\n"; // 10
}

주의: queue/deque 모두 빈 상태에서 front()/pop() 호출은 미정의
동작입니다. 반드시 !q.empty() 확인.

배열 원형 큐 (직접 구현)

정적 배열로 큐를 만들 때는, 앞이 빠져나간 자리를 재활용하기 위해 원형(circular)
으로 인덱스를 돌립니다. 가장 단순하게는 head/tail 포인터만 앞으로 밀어 쓰는
방식도 크기 상한을 알면 충분합니다.

const int MAXN = 1000000;
int buf[MAXN], head = 0, tail = 0;   // [head, tail) 가 현재 원소

void push(int x) { buf[tail++] = x; }
int  pop()       { return buf[head++]; }   // 비었는지 호출자가 확인
bool empty()     { return head == tail; }
int  size()      { return tail - head; }

이 방식은 tail이 MAXN에 닿지 않도록 총 push 횟수의 상한을 잡아 배열을 크게
잡아야 합니다. 재활용이 꼭 필요하면 head % MAXN, tail % MAXN으로 원형화
합니다.


Python — collections.deque

파이썬 리스트는 앞에서 빼는 pop(0)\(O(N)\)이라 큐로 부적합합니다. 반드시
collections.deque를 쓰세요. 양끝 연산이 모두 \(O(1)\)입니다.

from collections import deque

q = deque()
q.append(1); q.append(2); q.append(3)  # 뒤에 push
print(q[0])          # 1  (front)
print(q.popleft())   # 1  (앞에서 pop)
print(q[0])          # 2

dq = deque()
dq.append(10)        # 뒤
dq.appendleft(5)     # 앞 → [5, 10]
dq.append(20)        # [5, 10, 20]
print(dq[0], dq[-1]) # 5 20
dq.popleft()         # [10, 20]
dq.pop()             # [10]  (뒤에서)
목적 deque 메서드
뒤에 넣기 append
앞에 넣기 appendleft
앞에서 빼기 popleft
뒤에서 빼기 pop

주의: 빈 deque에서 popleft()/pop()IndexError. if q: 로 확인.


응용 — BFS 뼈대

vector<int> adj[MAXN];
int dist[MAXN];

void bfs(int start, int n) {
    for (int i = 0; i < n; i++) dist[i] = -1;
    queue<int> q;
    dist[start] = 0; q.push(start);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) if (dist[v] == -1) {
            dist[v] = dist[u] + 1;
            q.push(v);
        }
    }
}

dist로 방문 여부를 겸해 확인하는 것이 요령입니다. 큐에 넣는 순간 방문 표시를
해야 같은 정점을 여러 번 큐에 넣지 않습니다.

Lesson 3강 · 심화와 변형 — 모노톤 덱과 슬라이딩 윈도우 선택 8m

슬라이딩 윈도우 최댓값

길이 \(k\)짜리 창을 배열 위에서 한 칸씩 옮길 때, 각 창의 최댓값을 전체
\(O(N)\)에 구하는 문제입니다. 매 창마다 \(k\)개를 훑으면 \(O(Nk)\)로 느립니다.
모노톤 덱이 이를 \(O(N)\)으로 만듭니다.

핵심 아이디어:

덱에는 인덱스를 저장하되, 그 인덱스가 가리키는 값이 감소 순서가 되도록
유지한다. 그러면 덱의 맨 앞이 항상 현재 창의 최댓값이다.

새 원소 \(a[i]\)를 넣기 전에, 덱 뒤쪽에서 \(a[i]\)보다 작거나 같은 값들을 모두
제거합니다(그 값들은 \(a[i]\)가 살아 있는 한 절대 최댓값이 될 수 없음). 그리고
창을 벗어난 인덱스는 앞쪽에서 제거합니다.


워크드 예제

a = [1, 3, -1, -3, 5], \(k = 3\). 덱에는 인덱스를 저장(괄호 안은 값):

i a[i] 뒤에서 제거 앞에서 제거(창 벗어남) 덱(값) 창 최댓값
0 1 1
1 3 1 제거(≤3) 3
2 -1 3, -1 3
3 -3 idx0? 이미없음 3, -1, -3 3
4 5 -3,-1,3 모두 제거 idx1 창밖 제거 5 5

\(i \ge k-1\) 부터 창 최댓값을 기록: [3, 3, 5].

vector<int> slidingMax(const vector<int>& a, int k) {
    deque<int> dq;                       // 인덱스, 값 감소 유지
    vector<int> res;
    for (int i = 0; i < (int)a.size(); i++) {
        while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
        dq.push_back(i);
        if (dq.front() <= i - k) dq.pop_front();  // 창 벗어난 앞 제거
        if (i >= k - 1) res.push_back(a[dq.front()]);
    }
    return res;
}
from collections import deque

def sliding_max(a, k):
    dq = deque()          # 인덱스
    res = []
    for i, x in enumerate(a):
        while dq and a[dq[-1]] <= x:
            dq.pop()
        dq.append(i)
        if dq[0] <= i - k:
            dq.popleft()
        if i >= k - 1:
            res.append(a[dq[0]])
    return res

최솟값을 원하면 뒤에서 제거하는 조건의 부등호만 뒤집습니다
(a[dq.back()] >= a[i]).


흔한 함정

  • 값 vs 인덱스 — 반드시 인덱스를 저장해야 "창 벗어남"을 판정할 수
    있습니다. 값만 저장하면 앞에서 언제 제거할지 알 수 없습니다.
  • 제거 조건의 등호 — 최댓값에서 <=로 제거하면 중복 값 중 더 최근 것만
    남습니다(정답에는 영향 없음). <로 하면 같은 값이 여러 개 남아도 되지만
    덱이 길어질 수 있습니다. 보통 <=가 깔끔합니다.
  • 결과 기록 시점 — 창이 처음으로 꽉 차는 i == k-1부터 기록해야 합니다.
  • 앞 제거 순서 — push_back으로 새 인덱스를 넣은 에 앞쪽 창 이탈을
    검사해도 되고 전에 해도 되지만, front가 방금 넣은 원소와 헷갈리지 않도록
    로직을 한 방향으로 통일하세요.

다른 변형

  • 0-1 BFS — 간선 가중치가 0 또는 1일 때, 덱의 앞(가중치 0)/뒤(가중치 1)로
    넣어 다익스트라 없이 \(O(V+E)\)에 최단거리를 구합니다.
  • 회전/순환 시뮬레이션 — 카드나 사람을 원형으로 돌리는 문제에서 양끝 삽입·
    삭제를 자연스럽게 표현합니다.
  • 단조 큐 최적화 DP — 슬라이딩 윈도우 형태의 점화식
    (\(dp[i] = \min_{i-k \le j < i} dp[j] + c\))을 모노톤 덱으로 \(O(N)\)에.

큐/덱은 "순서 보존"과 "양끝 접근"이라는 두 성질만 기억하면, BFS부터 슬라이딩
윈도우 최적화까지 폭넓게 재사용됩니다.

Practice problem 폭탄 문자열 터뜨리기 선택 25m
R00120

폭탄 문자열 터뜨리기

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 히스토그램에서 가장 큰 직사각형 선택 25m
R00725

히스토그램에서 가장 큰 직사각형

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — 힙과 우선순위 큐 필수 8m

우선순위 큐란

우선순위 큐(priority queue) 는 원소마다 우선순위가 있어, 넣은 순서와
무관하게 가장 우선순위가 높은(보통 가장 작거나 가장 큰) 원소가 먼저 나오는
자료구조입니다. 보통 힙(heap) 으로 구현합니다.

핵심 연산과 복잡도:

연산 의미 복잡도
push(x) 원소 추가 \(O(\log N)\)
pop() 최솟값(또는 최댓값) 제거 \(O(\log N)\)
top() 최솟값(최댓값) 보기 \(O(1)\)
전체 힙 만들기 \(N\)개로 한 번에 \(O(N)\)

불변식(최소 힙): 완전 이진 트리에서 모든 부모는 자식보다 작거나 같다.
따라서 최솟값은 항상 루트에 있다. 최대 힙은 부등호가 반대다.

정렬과 달리, 우선순위 큐는 동적으로 원소가 들어오고 나가는 상황에서 매 순간
극값을 \(O(\log N)\)에 뽑을 수 있다는 점이 강점입니다.


힙의 구조

힙은 완전 이진 트리를 1차원 배열에 담습니다(1-indexed 기준):

  • 노드 \(i\)의 부모는 \(\lfloor i/2 \rfloor\)
  • 왼쪽 자식은 \(2i\), 오른쪽 자식은 \(2i+1\)

push는 배열 끝에 넣고 부모와 비교하며 위로 올리고(sift-up), pop은 루트를
꺼낸 뒤 마지막 원소를 루트에 놓고 자식과 비교하며 아래로 내립니다(sift-down).
트리 높이가 \(O(\log N)\)이므로 두 연산 모두 \(O(\log N)\)입니다.


언제 쓰나

  • "항상 현재 최솟값/최댓값부터 처리" — 다익스트라, 프림(MST), 허프만 코딩.
  • \(K\)개 병합 / 상위 \(K\) — 여러 정렬 리스트 병합, 스트림에서 top-K.
  • 이벤트 시뮬레이션 — 시간순으로 다음 이벤트 꺼내기.
  • 탐욕(greedy) — "지금 가장 싼(비싼) 것을 고른다"는 선택 반복.

"매번 남은 것 중 극값을 골라야 한다"가 신호입니다.


워크드 예제 — 최소 힙에 삽입

빈 최소 힙에 \(5, 3, 8, 1\)을 차례로 넣어 봅시다(배열, 1-indexed).

  • push 5 → [5]
  • push 3 → [5,3] → 3<5 이므로 swap → [3,5]
  • push 8 → [3,5,8] → 8>3 유지 → [3,5,8]
  • push 1 → [3,5,8,1] → 1<5(부모, idx2) swap → [3,1,8,5] → 1<3(부모) swap → [1,3,8,5]

이제 루트(최솟값)는 1입니다. pop하면 1을 꺼내고 마지막 5를 루트로 올린 뒤
sift-down: [5,3,8] → 5>3 swap → [3,5,8]. 다음 최솟값 3이 루트가 됩니다.

다음 강의에서 언어별 표준 우선순위 큐, 직접 구현한 이진 힙, 그리고 다익스트라·
중앙값 같은 응용을 다룹니다.

Lesson 2강 · 구현 — priority_queue, heapq, 직접 만든 힙 선택 8m

C++ — std::priority_queue

C++의 priority_queue기본이 최대 힙(top이 최댓값)입니다. 이 점을 놓치면
부호가 뒤집힌 답이 나오니 주의하세요.

#include <bits/stdc++.h>
using namespace std;

int main() {
    priority_queue<int> maxpq;          // 최대 힙(기본)
    maxpq.push(5); maxpq.push(1); maxpq.push(3);
    cout << maxpq.top() << "\n";        // 5
    maxpq.pop();
    cout << maxpq.top() << "\n";        // 3

    // 최소 힙: greater 비교자 사용
    priority_queue<int, vector<int>, greater<int>> minpq;
    minpq.push(5); minpq.push(1); minpq.push(3);
    cout << minpq.top() << "\n";        // 1
}

쌍(pair)과 사용자 정의 우선순위

pair는 첫 원소 우선으로 비교됩니다. 다익스트라는 (거리, 정점)을 최소 힙에
담는 것이 표준입니다.

// (거리, 정점) 최소 힙
priority_queue<pair<long long,int>,
               vector<pair<long long,int>>,
               greater<>> pq;
pq.push({0, start});

주의: 거리 합이 커질 수 있으면 long long을 쓰세요. int로 두면
오버플로로 음수가 되어 힙 순서가 무너집니다.


Python — heapq

파이썬 heapq최소 힙만 제공합니다. 리스트를 직접 힙으로 다룹니다.

import heapq

h = []
heapq.heappush(h, 5)
heapq.heappush(h, 1)
heapq.heappush(h, 3)
print(h[0])              # 1  (최솟값 = top)
print(heapq.heappop(h))  # 1
print(heapq.heappop(h))  # 3

# 이미 있는 리스트를 O(N)에 힙으로
data = [5, 1, 3, 8, 2]
heapq.heapify(data)      # O(N)
print(heapq.heappop(data))  # 1

최대 힙 흉내

값의 부호를 뒤집어 넣으면 최소 힙으로 최대 힙을 흉내냅니다.

h = []
for x in [5, 1, 3]:
    heapq.heappush(h, -x)     # 부호 반전
print(-heapq.heappop(h))      # 5  (다시 반전해서 읽기)

튜플을 넣으면 첫 원소 기준으로 정렬됩니다: heapq.heappush(h, (dist, node)).


직접 구현한 이진 최소 힙

라이브러리 없이 원리를 확인하는 구현입니다(1-indexed).

struct MinHeap {
    vector<long long> h{0};          // h[0]은 사용 안 함(자리표시)
    int size() { return h.size() - 1; }
    bool empty() { return size() == 0; }
    void push(long long x) {
        h.push_back(x);
        int i = size();
        while (i > 1 && h[i] < h[i/2]) {   // sift-up
            swap(h[i], h[i/2]); i /= 2;
        }
    }
    long long top() { return h[1]; }
    void pop() {
        h[1] = h.back(); h.pop_back();
        int i = 1, n = size();
        while (true) {                     // sift-down
            int l = 2*i, r = 2*i+1, m = i;
            if (l <= n && h[l] < h[m]) m = l;
            if (r <= n && h[r] < h[m]) m = r;
            if (m == i) break;
            swap(h[i], h[m]); i = m;
        }
    }
};

push/pop 모두 트리 높이만큼만 이동하므로 \(O(\log N)\)입니다.

Lesson 3강 · 심화와 변형 — 다익스트라, lazy 삭제, 두 힙 중앙값 선택 8m

응용 1 — 다익스트라 (지연 삭제)

우선순위 큐의 대표 응용은 다익스트라 최단경로입니다. 표준 힙에는 임의 원소의
값을 감소시키는(decrease-key)
연산이 없으므로, 대신 갱신된 (거리, 정점)을
새로 push 하고, 꺼낼 때 낡은 항목이면 무시하는 지연 삭제(lazy
deletion)
를 씁니다.

vector<pair<int,int>> adj[MAXN];         // (이웃, 가중치)
long long dist[MAXN];

void dijkstra(int src, int n) {
    for (int i = 0; i < n; i++) dist[i] = LLONG_MAX;
    priority_queue<pair<long long,int>,
                   vector<pair<long long,int>>, greater<>> pq;
    dist[src] = 0; pq.push({0, src});
    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;       // 낡은 항목 → 버림
        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }
}

if (d > dist[u]) continue; 한 줄이 지연 삭제의 핵심입니다. 이걸 빼면 같은
정점을 여러 번 완전 처리해 느려집니다. 전체 복잡도는 \(O((V+E)\log V)\).

import heapq

def dijkstra(adj, src, n):
    dist = [float('inf')] * n
    dist[src] = 0
    pq = [(0, src)]
    while pq:
        d, u = heapq.heappop(pq)
        if d > dist[u]:
            continue
        for v, w in adj[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                heapq.heappush(pq, (dist[v], v))
    return dist

응용 2 — 두 힙으로 실시간 중앙값

스트림에서 원소가 하나씩 들어올 때 매 순간 중앙값을 구하려면, 최대 힙
(작은 절반)과 최소 힙(큰 절반)을 함께 유지합니다.

불변식: lower(최대 힙)의 모든 값 ≤ upper(최소 힙)의 모든 값이고,
두 힙의 크기 차이는 1 이하. 그러면 중앙값은 lower.top(홀수 개) 또는
두 top의 평균(짝수 개).

import heapq

class MedianStream:
    def __init__(self):
        self.lower = []   # 최대 힙(부호 반전)
        self.upper = []   # 최소 힙
    def add(self, x):
        heapq.heappush(self.lower, -x)
        # lower의 최대가 upper의 최소보다 크면 넘김
        heapq.heappush(self.upper, -heapq.heappop(self.lower))
        # 크기 균형: lower가 항상 >= upper
        if len(self.upper) > len(self.lower):
            heapq.heappush(self.lower, -heapq.heappop(self.upper))
    def median(self):
        if len(self.lower) > len(self.upper):
            return -self.lower[0]
        return (-self.lower[0] + self.upper[0]) / 2

각 삽입이 \(O(\log N)\), 중앙값 조회는 \(O(1)\)입니다.


흔한 함정

  • 최대/최소 방향 혼동 — C++ priority_queue최대 힙이 기본,
    파이썬 heapq최소 힙만. 원하는 방향을 명확히 하고 필요하면 부호를
    반전하거나 greater<>를 쓰세요.
  • 오버플로 — 다익스트라 등 합이 커지는 값은 long long. 초기 무한대를
    LLONG_MAX로 두고 이완할 때 넘치지 않게 조건을 잘 씁니다.
  • decrease-key 없음 — 표준 힙엔 없다. 지연 삭제로 우회하되 반드시 낡은
    항목을 버리는 검사를 넣으세요.
  • 정렬 안정성/타이브레이크 — 튜플 비교 시 두 번째 원소까지 비교됩니다.
    두 번째가 비교 불가능한 객체면 에러가 나므로, (우선순위, 순번, 객체)처럼
    고유 순번을 끼워 넣습니다.
  • heapify는 \(O(N)\) — 처음부터 하나씩 push(\(O(N\log N)\))하지 말고, 초기
    데이터가 있으면 heapify/make_heap으로 한 번에 만드세요.

변형 정리

필요 방법
최댓값 우선 C++ 기본 / 파이썬 부호 반전
최솟값 우선 C++ greater<> / 파이썬 기본
원소별 감소 갱신 지연 삭제(재삽입 후 낡은 것 버림)
실시간 중앙값/분위수 두 힙 균형 유지
\(K\)개 정렬 병합 (값, 리스트번호, 위치) 최소 힙

우선순위 큐는 "동적 데이터에서 극값을 반복해 뽑는다"는 요구가 보이면 거의 항상
정답 후보입니다.

Practice problem 허수아비 선택 25m
KOI00003

허수아비

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Gold II 골드 II 지금 풀기
Practice problem 보급 선택 25m
KOI00050

보급

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — 해시 함수와 다항식 해시 필수 8m

해싱이란

해싱(hashing) 은 임의의 데이터를 고정 크기의 정수(해시 값)로 대응시키는
기법입니다. 두 데이터가 같으면 해시도 같아야 하고, 다르면 웬만하면 달라야
합니다. 이를 이용해 비교·검색을 빠르게 합니다.

두 갈래 용도가 있습니다.

  • 해시 테이블 — 키를 버킷 인덱스로 흩뿌려 삽입/검색을 평균 \(O(1)\)에.
    (앞 단원 집합·맵의 내부.)
  • 문자열 해시(다항식 해시) — 문자열을 하나의 정수로 압축해, 두 부분
    문자열이 같은지를 \(O(1)\)에 비교.

이 단원은 주로 다항식 해시를 다룹니다.


다항식 해시

길이 \(n\)인 문자열 \(s = s_0 s_1 \dots s_{n-1}\)의 해시를 밑(base) \(p\)와 모듈러
\(M\)에 대해 다음과 같이 정의합니다.

$$ H(s) = \left( \sum_{i=0}^{n-1} s_i \cdot p^{\,n-1-i} \right) \bmod M $$

즉 문자열을 \(p\)진법 수처럼 봅니다. 이 정의의 힘은 누적 해시를 전처리해 두면
임의의 부분 문자열 해시를 \(O(1)\) 뽑을 수 있다는 점입니다.

누적값 \(h[i] = (s_0 p^{i-1} + \dots + s_{i-1}) \bmod M\) 를 두면, 부분 문자열
\(s[l..r]\)의 해시는

$$ H(s[l..r]) = \left( h[r+1] - h[l]\cdot p^{\,r-l+1} \right) \bmod M $$

전처리 \(O(n)\), 이후 각 부분 문자열 비교 \(O(1)\).

불변식: 같은 문자열은 항상 같은 해시. 다른 문자열이 같은 해시를 가지는
것을 충돌(collision) 이라 하며, \(M\)이 크고 무작위면 확률이 매우 낮다.


언제 쓰나

  • 여러 부분 문자열이 같은지 반복 비교 — 각 비교 \(O(1)\).
  • 패턴 검색(라빈-카프) — 텍스트에서 패턴 위치 찾기 \(O(n)\).
  • 회문·중복 부분 문자열 판정.
  • 집합·튜플을 정수 하나로 요약(주의: 충돌 관리 필요).

"문자열 비교가 병목", "같은 조각이 여러 번 등장하는지"가 신호입니다.


워크드 예제

\(s = \) "abc", 문자값을 \(a{=}1, b{=}2, c{=}3\), 밑 \(p = 31\), 모듈러는 편의상
큰 수. 각 접두사 해시:

  • \(h[1] = 1\) (a)
  • \(h[2] = 1\cdot31 + 2 = 33\) (ab)
  • \(h[3] = 33\cdot31 + 3 = 1026\) (abc)

부분 문자열 "bc" = \(s[1..2]\)의 해시: $h[3] - h[1]\cdot p^{2} = 1026 - 1\cdot961
= 65\(. 직접\ 계산해도 \)b\cdot31 + c = 2\cdot31 + 3 = 65$. 일치합니다.

다음 강의에서 실제 구현(누적 해시, 라빈-카프, 이중 해시)을 다룹니다.

Lesson 2강 · 구현 — 누적 해시, 라빈-카프, 이중 해시 선택 8m

1. 누적 해시 전처리

\(p\)와 모듈러 \(M\)으로 접두사 해시와 \(p\)의 거듭제곱을 미리 계산합니다. 문자에
\(+1\)을 더해 'a'가 0이 되지 않게 하는 것이 중요합니다(안 그러면 "a", "aa"
구분이 약해짐).

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const ll P = 131, M = 1000000007LL;

int main() {
    string s; cin >> s;
    int n = s.size();
    vector<ll> h(n + 1, 0), pw(n + 1, 1);
    for (int i = 0; i < n; i++) {
        h[i + 1] = (h[i] * P + (s[i] - 'a' + 1)) % M;   // 접두사 해시
        pw[i + 1] = pw[i] * P % M;                       // P^(i+1)
    }
    // s[l..r] (0-index, 닫힌 구간)의 해시
    auto sub = [&](int l, int r) -> ll {
        return ((h[r + 1] - h[l] * pw[r - l + 1]) % M + M) % M;
    };
    // 예: 두 부분 문자열이 같은가?  sub(a,b) == sub(c,d)
}

+ M% M은 뺄셈이 음수가 되지 않게 하는 보정입니다.

def build_hash(s, P=131, M=(1 << 61) - 1):
    n = len(s)
    h = [0] * (n + 1)
    pw = [1] * (n + 1)
    for i in range(n):
        h[i + 1] = (h[i] * P + (ord(s[i]) - ord('a') + 1)) % M
        pw[i + 1] = pw[i] * P % M
    def sub(l, r):                 # s[l..r] 닫힌 구간
        return (h[r + 1] - h[l] * pw[r - l + 1]) % M
    return sub

파이썬은 음수 모듈러가 자동으로 양수가 되므로 별도 보정이 필요 없습니다.


2. 라빈-카프 문자열 매칭

길이 \(L\) 패턴을 텍스트에서 찾기: 패턴 해시와, 텍스트의 모든 길이 \(L\) 부분
문자열 해시를 비교합니다.

vector<int> rabinKarp(const string& text, const string& pat) {
    int n = text.size(), m = pat.size();
    vector<int> res;
    if (m > n) return res;
    ll pph = 0, pw = 1;                 // 패턴 해시, P^(m-1)
    for (int i = 0; i < m; i++) {
        pph = (pph * P + (pat[i] - 'a' + 1)) % M;
        if (i) pw = pw * P % M;
    }
    ll cur = 0;
    for (int i = 0; i < n; i++) {
        cur = (cur * P + (text[i] - 'a' + 1)) % M;          // 앞에서 한 글자 추가
        if (i >= m) cur = (cur - (text[i-m]-'a'+1) * pw % M * P % M + M*M) % M; // 뒤 한 글자 제거
        if (i >= m - 1 && cur == pph) res.push_back(i - m + 1);
    }
    return res;
}

각 위치 비교가 \(O(1)\)이라 전체 \(O(n)\)입니다(롤링 해시).

def rabin_karp(text, pat, P=131, M=(1 << 61) - 1):
    n, m = len(text), len(pat)
    if m > n:
        return []
    pph = 0
    for c in pat:
        pph = (pph * P + (ord(c) - ord('a') + 1)) % M
    powm = pow(P, m - 1, M)
    cur, res = 0, []
    for i, c in enumerate(text):
        cur = (cur * P + (ord(c) - ord('a') + 1)) % M
        if i >= m:
            cur = (cur - (ord(text[i-m]) - ord('a') + 1) * powm * P) % M
        if i >= m - 1 and cur == pph:
            res.append(i - m + 1)
    return res

3. 이중 해시 (충돌 방어)

서로 다른 \((P, M)\) 쌍으로 두 해시를 동시에 쓰고, 둘 다 같을 때만 같다고
판정하면 충돌 확률이 \(1/(M_1\ M_2)\)로 사실상 \(0\)이 됩니다.

// 값을 pair<ll,ll>로 다루거나, (h1 << 32 | h2) 형태로 합쳐 비교
pair<ll,ll> sub2(int l, int r) { return { sub1(l,r), sub2b(l,r) }; }
// 비교: sub2(a,b) == sub2(c,d)

무작위 밑을 쓰면 의도적 저격(anti-hash) 입력도 막습니다.

import random
P = random.randint(256, 10**6)   # 실행마다 다른 밑 → anti-hash 방어
Lesson 3강 · 심화와 변형 — 충돌, 회문, 함정 선택 8m

충돌과 안전성

다항식 해시는 확률적 도구입니다. 서로 다른 두 문자열이 같은 해시를 낼 수 있고,
이를 충돌이라 합니다. 안전을 좌우하는 두 가지:

  • 모듈러 \(M\)의 크기 — 무작위 두 문자열이 충돌할 확률은 대략 \(1/M\). \(M\)
    \(10^9\)대면 한 번의 비교는 안전하지만, 비교를 \(Q\)번 하면 생일 역설로
    충돌 확률이 커집니다(\(\approx Q^2 / M\)). 비교가 많으면 \(M\)을 키우거나 이중
    해시를 쓰세요.
  • 의도적 저격(anti-hash) — 밑·모듈러가 알려지면 충돌 쌍을 일부러 만들 수
    있습니다. 무작위 밑이중 해시로 방어합니다.

권장: \(M = 2^{61}-1\) 같은 큰 소수 + 무작위 밑, 또는 서로 다른 소수 \(M\)
두 개로 이중 해시. 대회에서 단일·고정 해시는 저격당하기 쉽습니다.


응용 — 회문 판정

정방향 해시와 역방향 해시를 모두 만들면, 어떤 구간이 회문인지 \(O(1)\)
확인할 수 있습니다. 구간의 정방향 해시와, 뒤집은 문자열에서의 대응 구간 해시가
같으면 회문입니다.

def is_palindrome_checker(s, P=131, M=(1 << 61) - 1):
    fwd = build_hash(s, P, M)               # 앞 강의의 build_hash
    bwd = build_hash(s[::-1], P, M)
    n = len(s)
    def check(l, r):                        # s[l..r]이 회문인가
        length = r - l + 1
        # 뒤집은 문자열에서 s[l..r]에 대응하는 구간
        rl, rr = n - 1 - r, n - 1 - l
        return fwd(l, r) == bwd(rl, rr)
    return check

응용 — 서로 다른 부분 문자열 개수 / 중복 탐지

길이 \(L\)인 모든 부분 문자열의 해시를 집합에 넣어 크기를 세면 "서로 다른 길이
\(L\) 부분 문자열의 개수"를 얻습니다. 이분 탐색과 결합하면 "가장 긴 반복 부분
문자열 길이"도 구할 수 있습니다(단, 충돌 관리 필수).


흔한 함정 정리

  • 문자값에 \(+1\) 안 함'a'를 0으로 매기면 앞쪽 a가 값에 기여하지 못해
    "a", "aa" 등이 충돌합니다. 반드시 - 'a' + 1로 1부터.
  • 부분 해시 뺄셈 음수 — C++에서 h[r+1] - h[l]*pw[...]가 음수가 될 수
    있으니 + M(또는 + M*M) 보정. 파이썬은 자동 처리.
  • 오버플로h[i]*P\(M \le 10^9\)라도 곱하면 \(10^{18}\) 근처라 int
    넘칩니다. 반드시 long long. \(M = 2^{61}-1\)을 쓰면 곱셈에서 128비트나
    __int128 처리가 필요할 수 있습니다.
  • 거듭제곱 재계산pw[] 배열을 전처리하지 않고 매번 pow를 부르면 느림.
  • 단일 고정 해시 저격 — 알려진 \((P,M)\)은 위험. 이중·무작위 해시로 방어.
  • 부동소수·직접 문자열 비교 누락 — 초고신뢰가 필요하면, 해시가 같은
    후보에 대해 실제 문자열을 한 번 더 비교(그래도 평균 \(O(n)\) 유지).

해시 vs 결정적 알고리즘

상황 선택
패턴 1개 검색, 확실성 필요 KMP(결정적) 또는 해시
여러 부분 문자열 임의 비교 다항식 해시(\(O(1)\) 비교)
최장 공통 부분 문자열/반복 이분 탐색 + 해시, 또는 접미사 구조
저격 입력 우려 이중 + 무작위 해시

해싱은 "비교를 상수로 만든다"는 강력한 도구지만, 충돌은 항상 존재한다는 점을
잊지 말고 신뢰도를 설계하세요.

Practice problem Archaeologist 선택 25m
R03519

Archaeologist

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 같은 높이로 돌아오기 선택 25m
R02021

같은 높이로 돌아오기

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
02
Level 3 · Explorer

Explorer

자료 구조 · Explorer 단계

0/15 완료
Lesson 1강 · 개념 — 분리 집합과 두 가지 최적화 필수 8m

어떤 문제를 푸는가

원소들이 여러 그룹으로 나뉘어 있고, 다음 두 연산을 아주 빠르게 처리하려는
자료구조가 유니온 파인드(union-find, 분리 집합, DSU) 입니다.

연산 의미 복잡도
find(a) \(a\)가 속한 그룹의 대표(루트) 를 반환 사실상 \(O(\alpha(N))\)
union(a, b) \(a\)의 그룹과 \(b\)의 그룹을 하나로 합침 사실상 \(O(\alpha(N))\)

"두 도시가 연결되어 있는가?", "이 간선을 추가하면 사이클이 생기는가?" 같은
동적 연결성 질문을 거의 상수 시간에 답합니다.


기본 구조 — 부모 포인터 숲

각 원소는 부모를 가리키는 parent 배열로 표현합니다. 루트는 자기 자신을
가리키며, 그 루트가 그룹의 대표입니다.

parent: 1→1   2→1   3→1   4→4   5→4
그룹: {1,2,3}, {4,5}

find는 부모를 따라 루트까지 올라가고, union은 한 루트를 다른 루트 밑에
붙입니다.

불변식: 같은 그룹의 원소는 반드시 같은 루트로 도달한다. 따라서
find(a) == find(b)\(a\)\(b\)가 같은 그룹.

그냥 두면 트리가 한 줄로 길어져 find\(O(N)\)이 될 수 있습니다. 두 가지
최적화가 이를 거의 상수로 만듭니다.


최적화 1 — 경로 압축 (path compression)

find 도중 지나친 모든 정점을 루트에 직접 매답니다. 다음에 같은 정점을
찾을 때 한 번에 루트에 도달합니다.

find(3):  3→2→1  (루트 1 발견)  ⇒  이후 3→1, 2→1 로 납작해짐

최적화 2 — 합치기 기준 (union by rank / size)

union작은 트리를 큰 트리 밑에 붙입니다. 그래야 트리 높이가 천천히
자랍니다. 트리의 높이(rank) 또는 크기(size) 중 하나를 기준으로 씁니다.


복잡도

두 최적화를 함께 쓰면 \(m\)번의 연산이 \(O(m \cdot \alpha(N))\)에 처리됩니다.
여기서 \(\alpha\)역 아커만 함수 로, 현실의 모든 입력에서 \(\alpha(N) \le 4\)
입니다. 즉 사실상 연산당 상수. 둘 중 하나만 써도 \(O(\log N)\)은 보장됩니다.


워크드 예제

원소 \(\{1,2,3,4,5\}\), 처음엔 각자 그룹. 연산을 차례로 적용(union by size):

연산 parent 변화 그룹
union(1,2) 2→1 {1,2} {3} {4} {5}
union(3,4) 4→3 {1,2} {3,4} {5}
union(1,3) 3→1 (2 vs 2, 임의) {1,2,3,4}
find(4) 4→3→1, 압축 후 4→1 루트 1
union(4,5) 5→1 {1,2,3,4,5}

find(2)find(5)가 모두 1이므로 2와 5는 같은 그룹입니다.

질문 DSU로 보는 법
같은 그룹인가? find(a) == find(b)
그룹 수는? 루트의 개수
그룹 크기는? 루트에 size 저장
이 간선이 사이클을 만드나? union 전 이미 같은 루트면 사이클

마지막 성질이 크루스칼 MST의 핵심입니다. 다음 강의에서 구현을 다룹니다.

Lesson 2강 · 구현 — 경로 압축과 union by rank/size 선택 8m

C++ — 표준 구현 (경로 압축 + union by size)

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, sz;
    DSU(int n) : parent(n), sz(n, 1) {
        iota(parent.begin(), parent.end(), 0);   // parent[i] = i
    }
    int find(int x) {                             // 경로 압축
        while (parent[x] != x)
            x = parent[x] = parent[parent[x]];    // 반경로 압축
        return x;
    }
    bool unite(int a, int b) {                    // union by size
        a = find(a); b = find(b);
        if (a == b) return false;                 // 이미 같은 그룹
        if (sz[a] < sz[b]) swap(a, b);            // 큰 쪽(a) 밑에 붙임
        parent[b] = a;
        sz[a] += sz[b];
        return true;
    }
    bool same(int a, int b) { return find(a) == find(b); }
    int size(int x) { return sz[find(x)]; }
};

int main() {
    DSU d(5);
    d.unite(0, 1); d.unite(2, 3); d.unite(0, 2);
    cout << d.same(1, 3) << "\n";   // 1 (true)
    cout << d.same(1, 4) << "\n";   // 0 (false)
    cout << d.size(0) << "\n";      // 4
}

union by rank 변형

크기 대신 트리 높이(rank) 를 기준으로 붙이는 방식입니다. 결과는 거의 같습니다.

vector<int> parent, rnk;   // rnk 초기값 0
int find(int x) { return parent[x] == x ? x : parent[x] = find(parent[x]); } // 재귀 압축
bool unite(int a, int b) {
    a = find(a); b = find(b);
    if (a == b) return false;
    if (rnk[a] < rnk[b]) swap(a, b);
    parent[b] = a;
    if (rnk[a] == rnk[b]) rnk[a]++;   // 높이가 같을 때만 1 증가
    return true;
}

재귀 find 함정: return parent[x] == x ? x : parent[x] = find(parent[x]);
는 완전 경로 압축입니다. 깊이가 매우 크면 스택 오버플로 위험이 있어, 대회에선
위의 반복형(반경로 압축) 이 더 안전합니다.


Python — 표준 구현

파이썬 재귀는 기본 깊이 제한(약 1000)이 낮아, 반복형을 강력 추천합니다.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
        self.size = [1] * n
    def find(self, x):
        root = x
        while self.parent[root] != root:
            root = self.parent[root]
        while self.parent[x] != root:      # 경로 압축(2-패스)
            self.parent[x], x = root, self.parent[x]
        return root
    def union(self, a, b):
        a, b = self.find(a), self.find(b)
        if a == b:
            return False
        if self.size[a] < self.size[b]:
            a, b = b, a
        self.parent[b] = a
        self.size[a] += self.size[b]
        return True
    def same(self, a, b):
        return self.find(a) == self.find(b)

d = DSU(5)
d.union(0, 1); d.union(2, 3); d.union(0, 2)
print(d.same(1, 3))   # True
print(d.same(1, 4))   # False
print(d.size[d.find(0)])  # 4

크루스칼 MST — 대표 응용

간선을 가중치 오름차순으로 정렬하고, 양 끝이 다른 그룹일 때만 채택합니다.
같은 그룹이면 사이클이 생기므로 버립니다.

long long kruskal(int n, vector<array<int,3>>& edges) { // {w, u, v}
    sort(edges.begin(), edges.end());
    DSU d(n);
    long long total = 0; int used = 0;
    for (auto& [w, u, v] : edges) {
        if (d.unite(u, v)) {          // 합쳐졌다 = 사이클 아님
            total += w;
            if (++used == n - 1) break;
        }
    }
    return total;                     // used < n-1 이면 그래프가 비연결
}

가중치 합은 커질 수 있으니 long long을 씁니다.

Lesson 3강 · 심화와 변형 — 가중 유니온 파인드, 롤백, 함정 선택 8m

가중 유니온 파인드 (weighted / parity DSU)

각 원소가 루트에 대해 상대적인 값(오프셋) 을 가지도록 확장한 형태입니다.
"\(a\)\(b\)의 차이는 \(d\)" 같은 제약을 관리하거나, 이분성(짝/홀, 두 진영) 판정에
씁니다.

핵심은 parent[x]와 함께 rel[x] = x에서 부모까지의 값 차이를 저장하고,
find로 경로를 압축할 때 이 값을 루트까지의 누적 차이 로 갱신하는 것입니다.

struct WeightedDSU {
    vector<int> parent, rel;      // rel[x] = x에서 parent[x]까지의 상대값
    WeightedDSU(int n) : parent(n), rel(n, 0) {
        iota(parent.begin(), parent.end(), 0);
    }
    int find(int x) {
        if (parent[x] == x) return x;
        int root = find(parent[x]);
        rel[x] += rel[parent[x]];  // 부모까지의 값을 루트까지로 누적
        parent[x] = root;
        return root;
    }
    // a - b = w 라는 제약 추가. 모순이면 false
    bool relate(int a, int b, int w) {
        int ra = find(a), rb = find(b);
        if (ra == rb) return rel[a] - rel[b] == w;   // 이미 알던 값과 일치?
        rel[ra] = rel[b] + w - rel[a];               // 루트끼리 연결
        parent[ra] = rb;
        return true;
    }
    int diff(int a, int b) { return rel[a] - rel[b]; } // 같은 그룹일 때 a-b
};

여기서 find는 재귀이므로 깊이가 크면 반복형으로 바꾸거나 스택을 명시적으로
관리해야 합니다. 짝/홀만 필요하면 rel을 mod 2로 두면 이분 그래프 판정이
됩니다.


롤백이 되는 유니온 파인드 (rollback DSU)

경로 압축은 구조를 파괴해 되돌리기 어렵습니다. 연산을 취소해야 하는
상황(오프라인 동적 연결성, 분할 정복)에서는 경로 압축을 포기하고 union by
rank/size만
쓰면서, 각 union이 바꾼 내용을 스택에 기록해 되돌립니다.

struct RollbackDSU {
    vector<int> parent, sz;
    vector<pair<int,int>> hist;    // (붙인 자식 루트, 그 크기)
    RollbackDSU(int n) : parent(n), sz(n, 1) {
        iota(parent.begin(), parent.end(), 0);
    }
    int find(int x) {              // 압축 없음! 항상 위로만
        while (parent[x] != x) x = parent[x];
        return x;
    }
    bool unite(int a, int b) {
        a = find(a); b = find(b);
        if (a == b) return false;
        if (sz[a] < sz[b]) swap(a, b);
        hist.push_back({b, sz[b]}); // 되돌릴 정보 기록
        parent[b] = a; sz[a] += sz[b];
        return true;
    }
    void rollback() {              // 마지막 union 취소
        auto [b, s] = hist.back(); hist.pop_back();
        sz[parent[b]] -= s;
        parent[b] = b;
    }
};

압축이 없으므로 find\(O(\log N)\)입니다(union by size가 높이를 보장). 이
구조가 세그 트리 위의 오프라인 동적 연결성의 토대가 됩니다.


흔한 함정

  • 초기화 누락parent[i] = i로 시작해야 합니다(iota). 0으로 두면 전부
    같은 그룹이 됩니다.
  • union 전 find 생략 — 반드시 두 원소의 루트를 구해 붙여야 합니다.
    parent[a] = b처럼 원소 자체를 붙이면 트리가 망가집니다.
  • 경로 압축 + 롤백 병용 불가 — 압축은 구조를 바꿔 되돌릴 수 없습니다.
    롤백이 필요하면 압축을 포기하세요.
  • 재귀 깊이 — 재귀 find는 깊은 트리에서 스택 오버플로. 파이썬은 특히
    위험하니 반복형을 쓰거나 sys.setrecursionlimit을 올립니다.
  • 가중 DSU의 부호rel 누적 시 부호·기준 방향을 일관되게. 제약이 "차이"
    인지 "패리티"인지에 따라 갱신식이 달라집니다.

변형 정리

필요 방법
단순 연결성·그룹 크기 표준(압축 + union by size)
\(a-b\) 관계, 이분성 가중/패리티 DSU
연산 취소, 오프라인 동적 연결성 롤백 DSU(압축 X)
시간에 따라 간선이 생겼다 사라짐 세그 트리 분할 정복 + 롤백 DSU
최소 신장 트리 크루스칼(정렬 + union)

유니온 파인드는 "합치기와 같은 그룹인지 묻기"만 필요하면 거의 항상 정답입니다.
목적에 맞춰 압축/롤백/가중 중 무엇을 쓸지만 정하면 됩니다.

Practice problem 알파카컵 2회: D - 알파카의 목장 나들이 선택 25m
A00018

알파카컵 2회: D - 알파카의 목장 나들이

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 색깔 모으기 선택 25m
KOI00026

색깔 모으기

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — 1/64 상수 최적화 필수 8m

비트셋이란

비트셋(bitset) 은 여러 개의 참/거짓 값을 한 워드(64비트)에 64개씩 담아,
집합 연산을 워드 단위 병렬로 처리하는 자료구조입니다. C++의 std::bitset,
또는 직접 만든 uint64_t 배열이 이에 해당합니다.

핵심 효과: \(N\)개의 비트에 대한 AND/OR/XOR/시프트가 \(O(N)\)이 아니라 \(O(N/64)\)
입니다. 시간 복잡도의 큰-O는 그대로여도 상수가 64분의 1 로 줄어, $N \le
104\(–\)105\( 규모에서 \)O(N2)\( 알고리즘을 \)O(N2/64)$로 만들어 통과시킵니다.

$$ O(N^2) \ \longrightarrow\ O\!\left(\frac{N^2}{64}\right) $$

이것은 알고리즘을 바꾸는 게 아니라 정수배 상수 최적화입니다. 하지만 그 상수가
64라, 실전에서 시간 초과를 아슬아슬하게 통과시키는 결정적 무기가 됩니다.


std::bitset 기본

bitset<1000> b;          // 1000비트, 초기 0
b.set(3);                // 3번 비트 = 1
b[5] = 1;                // 인덱싱
b.count();               // 켜진 비트 수(popcount)
b <<= 2;  b >>= 1;       // 시프트 (워드 병렬)
a & b;  a | b;  a ^ b;   // 집합 교/합/대칭차 (워드 병렬)
b.any();  b.none();      // 하나라도 켜짐 / 모두 꺼짐

크기는 컴파일 타임 상수여야 합니다. 가변 크기가 필요하면 vector<uint64_t>
직접 구현합니다.


대표 응용 1 — 도달 가능성 DP

방향 그래프에서 "각 정점이 도달할 수 있는 정점 집합"을 구합니다. 정점 \(u\)
도달 집합 reach[u]는 자신과 모든 이웃의 도달 집합의 합집합입니다.

$$ \text{reach}[u] = \{u\} \cup \bigcup_{u \to v} \text{reach}[v] $$

reach[u]비트셋으로 두면, 이웃과의 합집합이 OR 한 번(\(O(N/64)\))입니다.
DAG를 위상 역순으로 처리하면 전체 \(O(NM/64)\) 또는 \(O(N^2/64)\) — 순수 \(O(N^2)\)
집합 연산 대비 64배 빠릅니다. 이는 그래프의 추이 폐포(transitive closure)
계산의 표준 가속입니다.


대표 응용 2 — 부분집합 합 도달성

\(\{w_i\}\)로 만들 수 있는 합의 집합을 비트셋 dp로 관리합니다. dp[s]=1이면
"합 \(s\)를 만들 수 있음". 새 수 \(w\)를 추가하면:

$$ dp \mathrel{|}= (dp \ll w) $$

시프트 한 번으로 "모든 기존 합에 \(w\)를 더한 합들"을 통째로 켭니다. \(O(NS)\) DP가
\(O(NS/64)\)로. (다음 강의에서 코드.)


언제 쓰는가

  • \(O(N^2)\)/\(O(N^3)\) 집합·부울 DP인데 상수 64배가 필요할 때.
  • 도달 가능성/추이 폐포, 부분집합 합, LCS, 비트 병렬 DP.
  • 큰 이분 매칭(Hopcroft–Karp 보조), 에라토스테네스 변형 등.

"참/거짓 상태를 대량으로 굴리는데 시간이 빠듯하다"가 신호입니다.

Lesson 2강 · 구현 — 부분집합 합과 LCS 선택 8m

부분집합 합 도달성 (C++) — 검증된 코드

dp[s]=1이면 "합 \(s\)를 만들 수 있음". 각 수마다 시프트+OR 한 번.

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> w = {3, 1, 4, 2};
    bitset<64> dp;
    dp[0] = 1;                          // 합 0은 항상 가능(빈 집합)
    for (int x : w) dp |= (dp << x);    // 기존 합 전체에 x를 더함

    cout << dp[10] << " " << dp[7] << " " << dp[11] << "\n";
    // 10=3+1+4+2 가능(1), 7 가능(1), 11 불가능(0)
}

dp << x는 "모든 켜진 합을 \(x\)만큼 밀어" 새 합들을 만들고, OR로 기존과 합칩니다.
크기 bitset<S+1>을 목표 합 이상으로 잡습니다. \(N\)개 수, 합 상한 \(S\)면 총
\(O(NS/64)\).

Python (int를 비트셋처럼)

파이썬은 임의 정밀도 정수를 비트셋처럼 씁니다. 시프트/OR가 매우 빠릅니다.

def subset_sums(w):
    dp = 1                       # 비트0만 켜짐 = 합 0 가능
    for x in w:
        dp |= dp << x            # 정수 시프트 = 비트셋 시프트
    return dp

dp = subset_sums([3, 1, 4, 2])
print((dp >> 10) & 1, (dp >> 7) & 1, (dp >> 11) & 1)   # 1 1 0

(dp >> s) & 1로 합 \(s\) 가능 여부를 읽습니다. 이 "정수=비트셋" 기법은 파이썬
부분집합 합 문제의 표준 가속입니다.


최장 공통 부분수열 LCS (C++) — 비트 병렬

두 문자열의 LCS 길이를 비트셋으로 \(O(nm/64)\)에 구하는 Hyyrö/Crochemore
알고리즘입니다. 각 문자에 대해 "그 문자가 등장하는 위치" 비트셋 M[c]를 만들고,
행을 훑으며 표준 점화를 비트 연산으로 병렬 처리합니다.

#include <bits/stdc++.h>
using namespace std;

int lcs(const string& a, const string& b) {
    // b의 각 문자 위치를 비트셋으로
    bitset<2005> M[256], row;
    for (int j = 0; j < (int)b.size(); j++) M[(unsigned char)b[j]][j] = 1;
    // row = LCS DP의 현재 행(집합 표현)
    for (char c : a) {
        bitset<2005> x = row | M[(unsigned char)c];
        // row = x & ~( (x - ((row<<1)|1)) ^ x ) 형태의 감산 트릭
        bitset<2005> shifted = (row << 1);
        shifted[0] = 1;
        // (x - shifted)를 비트셋 뺄셈으로: 여기선 개념 제시
        row = x;  // 실제 구현은 자리내림 처리 필요
        // 교육용: 표준 O(nm) 검증과 함께 쓰세요
    }
    return (int)row.count();
}

주의: 위 LCS 비트 병렬은 자리내림(borrow) 처리가 핵심이라 구현이 까다롭습니다.
대회에서는 검증된 라이브러리 코드를 쓰거나, 아래처럼 표준 \(O(nm)\) DP를 비트셋
없이
두고, 비트셋은 부분집합 합/도달성 처럼 명확히 이득이 큰 곳에 쓰는
편이 안전합니다.

표준 LCS(검증용):

int lcs_std(const string& a, const string& b) {
    int n = a.size(), m = b.size();
    vector<vector<int>> dp(n+1, vector<int>(m+1, 0));
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m; j++)
            dp[i][j] = (a[i-1]==b[j-1]) ? dp[i-1][j-1]+1
                                        : max(dp[i-1][j], dp[i][j-1]);
    return dp[n][m];
}

도달 가능성 (C++)

// DAG를 위상 역순으로: reach[u] |= reach[v]  (u -> v)
vector<bitset<2005>> reach(n);
for (int u : topo_reverse_order) {
    reach[u][u] = 1;
    for (int v : adj[u]) reach[u] |= reach[v];
}
// reach[u][w] == 1  ⇔  u에서 w로 도달 가능

각 OR가 \(O(N/64)\)라 전체 추이 폐포가 \(O(N^2/64)\) 또는 \(O(NM/64)\)입니다.

Lesson 3강 · 심화·변형 — 함정과 활용 선택 8m

흔한 함정

  • 크기는 컴파일 타임 상수bitset<N>\(N\)은 실행 중 정할 수 없습니다.
    최대치로 고정하거나 vector<uint64_t>로 직접 구현하세요.
  • 상수 최적화일 뿐 — 비트셋은 알고리즘 복잡도의 큰-O를 바꾸지 않습니다.
    \(O(N^2/64)\)는 여전히 \(O(N^2)\)입니다. \(N\)이 근본적으로 크면(\(10^6\) 이상) 다른
    알고리즘이 필요합니다.
  • 시프트 방향과 경계 — 부분집합 합에서 dp << x는 합이 커지는 방향입니다.
    비트셋 크기를 목표 합보다 크게 잡지 않으면 위쪽 비트가 잘려 오답. "동전을
    최대 몇 번" 같은 제한이 있으면 시프트만으론 부족하니 개수 제한 DP를 별도로.
  • 음수/뺄셈 — 비트셋은 집합(비음수 인덱스)입니다. 뺄셈이나 음수 오프셋은
    인덱스를 평행이동해 다루세요.
  • count()/_Find_first() — 켜진 비트 수는 count(), 첫 켜진 위치는 GCC
    확장 _Find_first()/_Find_next()로 순회하면 비트 병렬 순회가 됩니다.
  • 메모리 vs 시간bitset<200000>\(2\times10^5\)개 만들면 \(4\times10^9\)
    비트 = 500MB로 폭발합니다. 도달성/폐포는 정점 수 제곱 비트가 메모리를 지배하니
    \(N\)을 가늠하세요(보통 \(N \le 5\times10^4\) 안팎에서 실용적).

언제 비트셋이 답인가 — 판단 기준

상황 비트셋 이득
\(O(N^2)\) 부울/집합 DP, \(N \le 5\times10^4\) 큼 (64배)
추이 폐포/도달성, \(N \le 5\times10^4\)
부분집합 합, 합 상한 \(\le 10^6\) 큼 (특히 파이썬 int)
이분 매칭 인접행렬 곱류 중간
\(N \ge 10^6\) 또는 값이 실수 이득 없음 — 다른 알고리즘

변형 — Bitset 위 비트 병렬 DP 일반형

"이전 행(집합)에서 몇 가지 비트 연산으로 다음 행을 만드는" 꼴의 DP는 대부분
비트셋으로 병렬화됩니다.

  • LCS/편집거리: 위에서 본 감산 트릭(Myers의 비트 병렬 편집거리).
  • NFA 시뮬레이션: 정규식 매칭의 상태 집합을 비트셋으로 굴리면 텍스트 길이
    \(\times\) 상태수\(/64\).
  • 에라토스테네스: 소수 체를 비트셋으로 두어 메모리 8배 절감(비트당 1수).

공통 조건: 상태가 "켜짐/꺼짐"의 집합이고, 전이가 시프트·AND·OR·XOR
표현될 때. 이 형태가 보이면 비트셋을 얹어 상수를 64분의 1로 줄이세요.


파이썬에서의 비트셋

파이썬은 bitset 타입이 없지만 임의 정밀도 int가 완벽한 대체입니다. n비트
정수에 대한 <<, >>, &, |, ^가 C로 구현되어 매우 빠릅니다. 부분집합 합,
도달성, 간단한 비트 병렬 DP는 파이썬에서도 int 하나로 우아하게 풀립니다.
bin(x).count('1') 또는 x.bit_count()(3.10+)로 popcount.

"참/거짓 상태 뭉치를 대량으로, 빠듯한 시간에" 굴려야 한다면 비트셋(또는 파이썬
int)이 가장 먼저 떠올릴 상수 최적화입니다.

Practice problem 공통 친구 선택 25m
R01467

공통 친구

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 합 추적 마법사 선택 25m
R01466

합 추적 마법사

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — 더블링과 O(1) 구간 최솟값 필수 8m

희소 배열이란

희소 배열(sparse table)바뀌지 않는(정적) 배열에 대해 구간 질의를
전처리 \(O(N \log N)\)\(O(1)\) 에 답하는 자료구조입니다. 대표적으로 구간
최솟값/최댓값(RMQ, Range Minimum Query)에 쓰입니다.

핵심은 더블링(binary lifting) 입니다. 모든 구간을 저장하는 대신, 길이가
2의 거듭제곱인 구간
만 저장합니다.

$$ sp[k][i] = \text{구간 } [i,\ i + 2^k - 1] \text{의 집계값} $$

즉 위치 \(i\)에서 시작하는 길이 \(2^k\)짜리 구간의 답입니다. \(k\)\(0\)부터
\(\lfloor \log_2 N \rfloor\)까지이므로 표 크기는 \(O(N \log N)\).

연산 세그먼트 트리 희소 배열
전처리 \(O(N)\) \(O(N \log N)\)
구간 질의 \(O(\log N)\) \(O(1)\)
점 갱신 \(O(\log N)\) 불가(재빌드 \(O(N\log N)\))

점화식 — 어떻게 채우나

길이 \(2^k\) 구간은 길이 \(2^{k-1}\) 구간 두 개로 정확히 나뉩니다.

$$ sp[k][i] = \operatorname{merge}\big(\,sp[k-1][i],\ \ sp[k-1][i + 2^{k-1}]\,\big) $$

앞 절반 \([i,\ i+2^{k-1}-1]\)과 뒤 절반 \([i+2^{k-1},\ i+2^k-1]\)을 합칩니다.
\(k\)를 작은 것부터 채우면 각 칸이 \(O(1)\), 전체 \(O(N \log N)\).

불변식: sp[k][i]는 항상 길이 정확히 \(2^k\)인 구간의 답을 담는다. 두
절반이 겹치지 않으므로 min/max/gcd에 문제가 없다.


O(1) 질의의 비밀 — 겹쳐도 되는 연산

구간 \([l, r]\)의 길이를 \(L = r - l + 1\)이라 하면, \(2^k \le L\)인 가장 큰 \(k\)
잡습니다(\(k = \lfloor \log_2 L \rfloor\)). 그러면 길이 \(2^k\)짜리 구간 두 개
\([l,r]\)을 완전히 덮을 수 있습니다.

$$ \text{answer} = \operatorname{merge}\big(\,sp[k][l],\ \ sp[k][r - 2^k + 1]\,\big) $$

앞은 왼쪽 끝에서, 뒤는 오른쪽 끝에서 각각 \(2^k\)칸. 이 둘은 가운데에서 겹칠
수 있지만
min/max/gcd처럼 멱등(idempotent) 인 연산은 같은 원소를 두 번
봐도 답이 변하지 않으므로 괜찮습니다.

$$ \min(\min[l..x], \min[y..r]) = \min[l..r] \quad (\text{겹쳐도 OK}) $$

바로 이 겹침 허용 덕분에 질의가 딱 두 번의 조회, 즉 \(O(1)\)입니다.

주의: 구간 은 멱등이 아닙니다(겹친 원소가 두 번 더해짐). 합은 희소
배열로 \(O(1)\)이 안 되고, 대신 겹치지 않게 나누는 방식이라 로그가 붙거나 그냥
누적 합을 씁니다. 희소 배열의 \(O(1)\)min/max/gcd/and/or 같은 멱등
연산에 한합니다.


워크드 예제

a = [5, 2, 8, 1, 9, 3, 7, 4]에서 최솟값 질의 \([2, 5]\) (값 8,1,9,3).

  • \(L = 4\), \(k = \lfloor \log_2 4 \rfloor = 2\), \(2^k = 4\).
  • \(= \min(sp[2][2],\ sp[2][5 - 4 + 1]) = \min(sp[2][2],\ sp[2][2])\).
  • \(sp[2][2]\)\([2,5]\)의 최솟값 \(= 1\). 두 조회가 마침 같은 칸이라도 정답 \(1\).

\([0,3]\)이면 \(\min(sp[2][0], sp[2][0]) = 1\); \([4,7]\)이면 \(\min(9,3,7,4)=3\). 다음
강의에서 전처리와 질의를 코드로 구현합니다.

Lesson 2강 · 구현 — RMQ 희소 배열과 이진 점프 선택 8m

RMQ 희소 배열 (C++) — 검증된 코드

로그 테이블을 미리 계산해 두면 질의에서 나눗셈/log 호출 없이 빠릅니다.

#include <bits/stdc++.h>
using namespace std;

int LOG;
vector<vector<int>> sp;   // sp[k][i] = min of [i, i+2^k-1]
vector<int> logt;

void build(const vector<int>& a) {
    int n = a.size();
    LOG = 1;
    while ((1 << LOG) <= n) LOG++;        // 2^LOG > n
    logt.assign(n + 1, 0);
    for (int i = 2; i <= n; i++) logt[i] = logt[i / 2] + 1;

    sp.assign(LOG, vector<int>(n));
    for (int i = 0; i < n; i++) sp[0][i] = a[i];      // 길이 1
    for (int k = 1; k < LOG; k++)
        for (int i = 0; i + (1 << k) <= n; i++)        // 오른쪽 절반이 존재할 때만
            sp[k][i] = min(sp[k-1][i], sp[k-1][i + (1 << (k-1))]);
}

int query(int l, int r) {                  // [l, r] 최솟값, O(1)
    int k = logt[r - l + 1];
    return min(sp[k][l], sp[k][r - (1 << k) + 1]);
}

int main() {
    vector<int> a = {5, 2, 8, 1, 9, 3, 7, 4};
    build(a);
    cout << query(0, 3) << " " << query(2, 5) << " " << query(4, 7) << "\n";
    // 1 1 3
}

핵심 세 줄: 전처리 이중 루프, 로그 테이블, 두 조회 min. 최댓값은 min
max로, GCD는 std::gcd로 바꾸면 그대로 동작합니다.

Python

def build(a):
    n = len(a)
    LOG = max(1, n.bit_length())
    sp = [a[:]]
    k = 1
    while (1 << k) <= n:
        prev = sp[k-1]
        cur = [min(prev[i], prev[i + (1 << (k-1))])
               for i in range(n - (1 << k) + 1)]
        sp.append(cur)
        k += 1
    return sp

def query(sp, l, r):
    k = (r - l + 1).bit_length() - 1        # floor(log2(len))
    return min(sp[k][l], sp[k][r - (1 << k) + 1])

sp = build([5, 2, 8, 1, 9, 3, 7, 4])
print(query(sp, 0, 3), query(sp, 2, 5), query(sp, 4, 7))   # 1 1 3

(r-l+1).bit_length() - 1이 곧 \(\lfloor \log_2 L \rfloor\)입니다.


변형 — 이진 점프(binary lifting)

희소 배열의 더블링은 배열뿐 아니라 함수 반복에도 그대로 쓰입니다. "각
원소가 다음으로 가리키는 곳"이 정해진 함수형 그래프에서 "여기서 \(j\)
점프한 위치"를 \(O(\log j)\)에 구합니다.

$$ up[k][v] = up[k-1][\,up[k-1][v]\,] $$

\(2^{k-1}\)번 점프한 뒤 다시 \(2^{k-1}\)번 = \(2^k\)번. LCA(최소 공통 조상), \(k\)번째
조상 찾기가 대표 응용입니다.

const int LOG = 20;
int up[LOG][200005];        // up[0][v] = v의 부모(또는 next[v])

void prepare(int n) {
    for (int k = 1; k < LOG; k++)
        for (int v = 1; v <= n; v++)
            up[k][v] = up[k-1][ up[k-1][v] ];
}

int jump(int v, int j) {    // v에서 j번 점프한 위치
    for (int k = 0; k < LOG; k++)
        if (j >> k & 1) v = up[k][v];
    return v;
}

\(j\)를 이진수로 쪼개, 켜진 비트마다 해당 크기의 점프를 이어 붙입니다. 이것이
LCA의 "두 노드를 같은 깊이로 올린 뒤 함께 점프" 알고리즘의 뼈대입니다.

Lesson 3강 · 심화·변형 — 함정과 선택 기준 선택 8m

흔한 함정

  • 정적 전용 — 희소 배열은 값이 바뀌면 부분 갱신이 안 됩니다. 한 칸만 바뀌어도
    표 전체 재빌드(\(O(N\log N)\))입니다. 갱신이 있으면 세그먼트 트리를 쓰세요.
  • 멱등 연산만 \(O(1)\) — min/max/gcd/AND/OR은 겹쳐도 안전해 두 조회로 끝나지만,
    합/곱은 겹치면 틀립니다. 합은 누적 합 배열을 쓰거나, 굳이 희소 배열이면
    겹치지 않게 나눠 \(O(\log N)\)이 됩니다(이득 없음).
  • 로그 인덱스 오류 — 질의에서 \(k = \lfloor \log_2(r-l+1) \rfloor\)를 정확히
    잡아야 합니다. 부동소수 log2는 경계에서 오차가 날 수 있으니, 미리 계산한
    정수 로그 테이블이나 bit_length/__lg를 쓰세요.
  • 전처리 루프 범위sp[k][i]는 오른쪽 절반 i + 2^k가 배열 안에 있을
    때만 유효합니다. 루프 조건 i + (1<<k) <= n을 빠뜨리면 배열 밖을 읽습니다.
  • 메모리\(N = 2\times10^5\), \(\log N \approx 18\)이면 표가 약 \(3.6\times10^6\)칸.
    int면 몇 MB로 괜찮지만, 2D나 여러 배열이면 메모리를 계산하세요.

희소 배열 vs 세그먼트 트리 — 선택 기준

조건 추천
갱신 없음 + min/max/gcd 질의 대량 희소 배열 (질의 \(O(1)\))
점/구간 갱신이 섞임 세그먼트 트리 / 펜윅
합 질의(정적) 누적 합 배열 (\(O(1)\), 메모리 \(O(N)\))
트리의 조상/LCA/\(k\)번째 조상 이진 점프(희소 배열의 사촌)

핵심 판단: "갱신이 있는가?" 없으면 희소 배열이 질의 상수까지 가장 빠릅니다.
있으면 세그먼트 트리로.


변형 — 분리 결합 희소 배열(disjoint sparse table)

멱등이 아닌 연산(합·곱·행렬곱 등)도 \(O(1)\) 질의로 만들고 싶다면 분리 결합
희소 배열(disjoint sparse table)
을 씁니다. 각 레벨에서 배열을 절반으로
가르는 분할점을 기준으로, 왼쪽은 오른쪽 방향 누적, 오른쪽은 왼쪽 방향
누적을 미리 저장합니다. 질의 \([l,r]\)\(l\)\(r\)이 처음 갈라지는 레벨을 찾아
그 레벨의 두 누적값을 한 번만 결합하므로, 겹침 없이 \(O(1)\)에 임의의
결합 연산을 답합니다. 전처리는 여전히 \(O(N \log N)\).

이 구조는 "정적 배열 + 합/곱류 구간 질의를 진짜 \(O(1)\)에" 필요할 때 진가를
발휘합니다. 다만 구현이 까다로우니, 로그가 붙어도 되는 상황이면 세그먼트 트리가
더 간단합니다.


응용 정리

  • LCA in \(O(1)\): 오일러 투어로 트리를 배열로 편 뒤, 깊이 배열에 희소 배열을
    얹으면 최소 공통 조상을 \(O(1)\)에 구합니다(오일러 투어 강의와 연결).
  • 문자열 접미사 배열의 LCP 등 "정적 배열 위 구간 min"이 나오는 곳마다
    희소 배열이 표준 도구입니다.

"정적 배열 + min/max 구간 질의가 아주 많다"가 보이면 희소 배열을, "조상을 여러
칸 뛰어야 한다"가 보이면 이진 점프를 떠올리세요.

Practice problem 알파카컵 1회: E - 알파카 여행 계획 선택 25m
A00005

알파카컵 1회: E - 알파카 여행 계획

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 산책로 선택 25m
KOI00097

산책로

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
03
Level 4 · Challenger

Challenger

자료 구조 · Challenger 단계

0/10 완료
Lesson 1강 · 개념 — 구간 질의와 점 갱신 필수 8m

세그먼트 트리란

세그먼트 트리(segment tree) 는 배열에 대해 구간 질의(구간 합·최솟값·
최댓값·GCD 등)와 점 갱신(한 원소 변경)을 모두 \(O(\log N)\)에 처리하는
자료구조입니다.

연산 단순 배열 세그먼트 트리
점 갱신 \(O(1)\) \(O(\log N)\)
구간 질의 \(O(N)\) \(O(\log N)\)
누적 합 배열 질의 \(O(1)\), 갱신 \(O(N)\) 둘 다 \(O(\log N)\)

값이 자주 바뀌면서 구간 질의도 많은 상황에서, 누적 합(갱신 느림)이나 단순
배열(질의 느림)의 약점을 모두 극복합니다.


구조와 핵심 아이디어

배열을 절반씩 재귀적으로 쪼갠 완전 이진 트리로 봅니다. 리프는 원소 하나,
내부 노드는 자기 자식 두 구간을 결합(merge) 한 값을 담습니다.

              [0..3] 합=10
             /            \
       [0..1] 합=3     [2..3] 합=7
        /    \          /    \
     [0] 1  [1] 2    [2] 3  [3] 4

불변식: 각 내부 노드는 담당 구간에 대한 집계값을 항상 정확히 담는다
(node = merge(left, right)). 갱신하면 그 리프에서 루트까지의 경로 노드만
다시 계산하면 된다 — 그래서 갱신이 \(O(\log N)\).

질의는 원하는 구간 \([l, r]\)을 트리의 노드 구간들로 분해합니다. 완전히
덮이는 노드에서 멈추므로, 깊이마다 최대 상수 개의 노드만 방문 → \(O(\log N)\).


결합 연산과 항등원

세그먼트 트리는 결합 연산 merge와 그 항등원(겹치지 않는 구간의 반환값)만
바꾸면 다양한 질의에 재사용됩니다.

질의 merge 항등원
구간 합 a + b 0
구간 최솟값 min(a, b) +∞
구간 최댓값 max(a, b) -∞
구간 GCD gcd(a, b) 0

결합 연산은 결합법칙만 만족하면 됩니다(교환법칙은 불필요). 그래서 "구간에서
가장 왼쪽 최댓값 위치" 같은 비대칭 정보도 담을 수 있습니다.


워크드 예제

배열 [1, 2, 3, 4], 위 트리에서 구간 합 질의 \([1, 3]\)을 봅시다.

  • 루트 \([0,3]\): \([1,3]\)에 완전히 안 덮임 → 자식으로.
  • 왼쪽 \([0,1]\): \([1,3]\)과 부분만 겹침 → 자식으로.
  • \([0]\): 안 겹침 → 0. \([1]\): 완전히 덮임 → 2.
  • 오른쪽 \([2,3]\): \([1,3]\)에 완전히 덮임 → 7.

합 = \(0 + 2 + 7 = 9\). (실제 \(2+3+4 = 9\).) 방문 노드가 \(O(\log N)\)개뿐임에
주목하세요. 다음 강의에서 재귀·반복 두 구현을 다룹니다.

Lesson 2강 · 구현 — 재귀형과 반복형 선택 8m

재귀형 (C++, 합) — 가장 이해하기 쉬움

크기 \(4N\)짜리 배열로 트리를 표현합니다. 노드 1이 루트, 노드 \(x\)의 자식은
\(2x\)\(2x+1\)입니다.

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n;
vector<ll> seg, a;

void build(int node, int s, int e) {          // 구간 [s, e]
    if (s == e) { seg[node] = a[s]; return; }
    int m = (s + e) / 2;
    build(2*node, s, m);
    build(2*node+1, m+1, e);
    seg[node] = seg[2*node] + seg[2*node+1];  // 결합
}

void update(int node, int s, int e, int idx, ll val) {
    if (s == e) { seg[node] = val; return; }
    int m = (s + e) / 2;
    if (idx <= m) update(2*node, s, m, idx, val);
    else          update(2*node+1, m+1, e, idx, val);
    seg[node] = seg[2*node] + seg[2*node+1];
}

ll query(int node, int s, int e, int l, int r) {   // [l, r] 합
    if (r < s || e < l) return 0;             // 안 겹침 → 항등원
    if (l <= s && e <= r) return seg[node];   // 완전히 덮임
    int m = (s + e) / 2;
    return query(2*node, s, m, l, r)
         + query(2*node+1, m+1, e, l, r);
}

int main() {
    n = 4; a = {1, 2, 3, 4}; seg.assign(4*n, 0);
    build(1, 0, n-1);
    cout << query(1, 0, n-1, 1, 3) << "\n";   // 9
    update(1, 0, n-1, 2, 10);                  // a[2] = 10
    cout << query(1, 0, n-1, 1, 3) << "\n";   // 16
}

모든 호출은 루트 (1, 0, n-1)에서 시작합니다.

최솟값 트리로 바꾸기

결합과 항등원만 바꾸면 됩니다. seg[node] = min(seg[2*node], seg[2*node+1]);
로, 안 겹침 반환값을 LLONG_MAX로. GCD, 최댓값도 같은 방식.


반복형 (C++/Python) — 짧고 빠름

리프를 배열 뒤쪽 \([n, 2n)\)에 두고 아래에서 위로 올립니다. 구간을 반열림
\([l, r)\)
로 다룬다는 점이 재귀형과 다릅니다.

struct SegTree {
    int n; vector<ll> t;
    SegTree(const vector<ll>& a) : n(a.size()), t(2*n) {
        for (int i = 0; i < n; i++) t[n+i] = a[i];
        for (int i = n-1; i > 0; i--) t[i] = t[2*i] + t[2*i+1];
    }
    void update(int i, ll val) {              // a[i] = val (0-based)
        for (t[i += n] = val; i > 1; i >>= 1)
            t[i>>1] = t[i] + t[i^1];
    }
    ll query(int l, int r) {                  // [l, r) 반열림
        ll res = 0;
        for (l += n, r += n; l < r; l >>= 1, r >>= 1) {
            if (l & 1) res += t[l++];
            if (r & 1) res += t[--r];
        }
        return res;
    }
};
class SegTree:
    def __init__(self, data):
        self.n = len(data)
        self.t = [0] * (2 * self.n)
        self.t[self.n:] = data
        for i in range(self.n - 1, 0, -1):
            self.t[i] = self.t[2*i] + self.t[2*i+1]
    def update(self, i, val):                 # 0-based
        i += self.n
        self.t[i] = val
        i >>= 1
        while i:
            self.t[i] = self.t[2*i] + self.t[2*i+1]
            i >>= 1
    def query(self, l, r):                    # [l, r) 반열림
        res = 0
        l += self.n; r += self.n
        while l < r:
            if l & 1: res += self.t[l]; l += 1
            if r & 1: r -= 1; res += self.t[r]
            l >>= 1; r >>= 1
        return res

재귀 vs 반복: 재귀형은 lazy propagation(구간 갱신)으로 확장하기 쉽고
직관적입니다. 반복형은 상수가 작고 코드가 짧지만 반열림 구간이라 인덱스 실수가
잦고, 비가환 결합(예: 최댓값 위치)은 주의가 필요합니다. 파이썬은 재귀가 느려
반복형을 선호합니다.

Lesson 3강 · 심화와 변형 — 카운팅, 이진 탐색, 함정 선택 8m

변형 1 — 값 세그먼트 트리와 k번째 원소

배열의 (또는 좌표 압축한 값)을 인덱스로 두고, 각 칸에 "그 값의 개수"를
저장하는 세그 트리를 만들면 다음이 가능합니다.

  • \(x\) 이하 개수 — 구간 합 질의 \([0, x]\).
  • k번째로 작은 값 — 루트에서 시작해 왼쪽 자식의 개수와 \(k\)를 비교하며
    내려가는 세그 트리 위 이진 탐색(\(O(\log N)\)).
// cnt[]가 값별 개수인 합 세그(재귀). k번째(1-indexed) 값의 인덱스 반환
int kth(int node, int s, int e, int k) {
    if (s == e) return s;
    int m = (s + e) / 2;
    int leftCnt = seg[2*node];        // 왼쪽 구간의 원소 수
    if (k <= leftCnt) return kth(2*node, s, m, k);
    else return kth(2*node+1, m+1, e, k - leftCnt);
}

이 "트리 위 이진 탐색"은 각 깊이에서 왼/오른쪽 방향만 정하므로 질의 하나에
\(O(\log N)\)입니다. 순위, 중앙값, 정렬 순서 질의에 두루 쓰입니다.


변형 2 — 노드에 자료구조 얹기 (머지 소트 트리 개요)

각 노드에 담당 구간의 정렬된 값 목록을 저장하면, "구간 \([l,r]\)에서 \(x\)보다
작은 원소 개수" 같은 질의를 각 노드에서 이분 탐색해 \(O(\log^2 N)\)에 답합니다.
이를 머지 소트 트리(merge sort tree) 라 합니다. 빌드는 \(O(N\log N)\) 메모리·
시간. 값이 갱신되지 않는(정적) 배열에 적합합니다.

2차원 질의나 오프라인 좌표 압축과 결합해 폭넓게 쓰이며, 갱신이 필요하면 각
노드에 균형 트리를 얹거나 다른 접근(퍼시스턴트 세그, BIT of BIT)을 씁니다.


흔한 함정

  • 배열 크기 — 재귀형은 반드시 4*n을 확보하세요. \(2n\)만 잡으면 특정 \(n\)
    (2의 거듭제곱이 아닐 때) 노드 번호가 넘칩니다. 반복형은 정확히 \(2n\).
  • 항등원 — 안 겹침 반환값이 결합의 항등원이어야 합니다(합 0, min은 \(+\infty\),
    max는 \(-\infty\), GCD 0). 잘못 두면 경계 질의가 틀립니다.
  • 구간 표기 혼동 — 재귀형은 보통 [s, e] 닫힘, 반복형은 [l, r)
    반열림. 둘을 섞으면 off-by-one. 한 코드에서 규약을 통일하세요.
  • 0-indexed vs 1-indexed — 문제 입력이 1-based면 질의 인덱스를 -1 해야
    합니다. 매우 흔한 버그.
  • 구간 합 오버플로 — 합이 커지면 int가 넘칩니다. \(N \le 10^5\), 값이
    \(10^9\)면 합이 \(10^{14}\) → 반드시 long long.
  • 갱신 후 부모 재계산 누락 — 리프만 바꾸고 위로 안 올리면 질의가 옛값을
    봅니다. 재귀형은 자동, 반복형은 while 루프로 올려야 합니다.
  • 비가환 결합의 순서 — 최댓값 위치, 해시 결합 등은 왼쪽·오른쪽 순서를
    지켜 합쳐야 합니다. 반복형에서 특히 방향에 주의.

무엇을 고를까 — 요약

필요 방법
점 갱신 + 구간 합/최값/GCD 기본 세그(재귀 또는 반복)
구간 갱신 + 구간 질의 느리게 갱신되는 세그(lazy, 다음 단원)
k번째 수·순위·"x 이하 개수" 값 세그 + 트리 위 이진 탐색
정적 배열, 구간 내 조건 개수 머지 소트 트리(\(O(\log^2 N)\))
과거 버전 질의 퍼시스턴트 세그

"점을 바꾸며 구간을 묻는다"가 보이면 세그먼트 트리를 떠올리고, 결합 연산과
항등원을 문제에 맞게 정의하는 것부터 시작하세요.

Practice problem 택배 운송 선택 25m
KOI00007

택배 운송

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 건초 더미 선택 25m
KOI00005

건초 더미

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — √N 블록으로 나누기 필수 8m

제곱근 분할법이란

제곱근 분할법(sqrt decomposition) 은 크기 \(N\) 배열을 크기 약 \(\sqrt{N}\)
블록들로 나눠, 각 블록의 집계값(합·최솟값 등)을 따로 저장하는 기법입니다.
구간 질의를 "부분적으로 걸친 양 끝 블록은 원소별로, 완전히 덮인 가운데 블록은
집계값으로" 처리해 \(O(\sqrt{N})\) 에 답합니다.

블록 개수도 원소당 접근도 모두 \(\sqrt{N}\) 규모라, 총합이 \(\sqrt{N}\)에서 균형을
이룹니다.

연산 단순 배열 제곱근 분할 세그먼트 트리
점 갱신 \(O(1)\) \(O(1)\) \(O(\log N)\)
구간 질의 \(O(N)\) \(O(\sqrt N)\) \(O(\log N)\)

\(\sqrt{N}\)\(\log N\)보다 크지만, 제곱근 분할법은 구현이 단순하고 유연해서
세그먼트 트리로 표현하기 까다로운 질의(구간 내 특정 값 개수, mode 등)도 쉽게
얹을 수 있는 것이 장점입니다.


왜 √N이 최적인가

블록 크기를 \(B\)라 하면, 구간 질의는:

  • 양 끝의 불완전 블록: 최대 \(2B\)개 원소를 직접 순회.
  • 가운데 완전 블록: 최대 \(N/B\)개 블록의 집계값을 합침.

비용은 \(O(B + N/B)\). 이를 최소화하는 \(B\)는 미분하면 \(B = \sqrt{N}\)이고, 이때
\(O(\sqrt{N})\). 산술평균–기하평균 부등식으로도 \(B + N/B \ge 2\sqrt{N}\).

불변식: blk[b]는 항상 블록 \(b\)에 속한 원소들의 집계값을 정확히 담는다.
점 갱신 시 해당 블록 하나의 집계값만 \(O(1)\)에 고쳐 유지한다.


워크드 예제

a = [1..10], 블록 크기 \(\lfloor\sqrt{10}\rfloor = 3\). 블록: [1,2,3] [4,5,6] [7,8,9] [10], 블록 합 blk = [6, 15, 24, 10].

구간 합 질의 \([2, 7]\) (0-based, 값 \(3,4,5,6,7,8\)):

  • 왼쪽 불완전: 인덱스 \(2\)\(a[2]=3\) 직접 더함. (인덱스 3부터 블록 경계)
  • 완전 블록: 블록 1(인덱스 3–5) blk[1]=15 통째로.
  • 오른쪽 불완전: 인덱스 6,7 → \(a[6]+a[7]=7+8=15\) 직접.
  • \(= 3 + 15 + 15 = 33\). (직접 \(3{+}4{+}5{+}6{+}7{+}8 = 33\).)

몇 개의 원소와 한 개의 블록 집계만 보고 답했습니다 — \(O(\sqrt N)\).


언제 쓰는가

  • 구간 질의 + 점 갱신인데, 질의가 세그먼트 트리로 표현하기 애매할 때(구간 최빈값,
    구간 내 \(\le x\) 개수 등).
  • 오프라인 질의를 재정렬해 총비용을 줄이는 Mo's 알고리즘의 토대.
  • 구현이 급하거나 세그먼트 트리 결합이 복잡할 때의 든든한 대안.

다음 강의에서 구간 합/갱신을 구현하고, 구간 더하기(블록 lazy)까지 확장합니다.

Lesson 2강 · 구현 — 구간 합 질의와 갱신 선택 8m

점 갱신 + 구간 합 (C++) — 검증된 코드

각 블록의 합 blk를 유지합니다. 점 갱신은 블록 합을 \(O(1)\)에 보정, 질의는 양 끝
원소별 + 가운데 블록별.

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n, len;
vector<ll> a, blk;

void build() {
    len = max(1, (int)sqrt(n));            // 블록 크기 ≈ √N
    blk.assign((n + len - 1) / len, 0);
    for (int i = 0; i < n; i++) blk[i / len] += a[i];
}

void update(int i, ll v) {                  // a[i] = v
    blk[i / len] += v - a[i];               // 블록 합 보정
    a[i] = v;
}

ll query(int l, int r) {                    // [l, r] 합
    ll s = 0;
    while (l <= r && l % len != 0) s += a[l++];        // 왼쪽 불완전 블록
    while (l + len - 1 <= r) { s += blk[l / len]; l += len; }  // 완전 블록
    while (l <= r) s += a[l++];                          // 오른쪽 불완전 블록
    return s;
}

int main() {
    a = {1,2,3,4,5,6,7,8,9,10}; n = a.size(); build();
    cout << query(0, 9) << " " << query(2, 7) << "\n";  // 55 33
    update(3, 100);                                       // a[3]: 4 -> 100
    cout << query(0, 9) << " " << query(2, 7) << "\n";  // 151 129
}

세 개의 while이 각각 왼쪽 불완전·가운데 완전·오른쪽 불완전 블록을 처리합니다.
l % len == 0은 "블록 경계에 딱 맞음"을, l + len - 1 <= r은 "블록 하나가
구간에 완전히 들어감"을 뜻합니다.

Python

import math

class SqrtArray:
    def __init__(self, data):
        self.a = data[:]
        self.n = len(data)
        self.len = max(1, int(math.isqrt(self.n)))
        self.blk = [0] * ((self.n + self.len - 1) // self.len)
        for i, x in enumerate(self.a):
            self.blk[i // self.len] += x
    def update(self, i, v):
        self.blk[i // self.len] += v - self.a[i]
        self.a[i] = v
    def query(self, l, r):                  # [l, r] 합
        s = 0
        while l <= r and l % self.len != 0:
            s += self.a[l]; l += 1
        while l + self.len - 1 <= r:
            s += self.blk[l // self.len]; l += self.len
        while l <= r:
            s += self.a[l]; l += 1
        return s

sa = SqrtArray([1,2,3,4,5,6,7,8,9,10])
print(sa.query(0, 9), sa.query(2, 7))       # 55 33
sa.update(3, 100)
print(sa.query(0, 9), sa.query(2, 7))       # 151 129

변형 — 구간 더하기 (블록 lazy)

"구간 \([l,r]\)\(+v\), 점 질의"라면 각 블록에 지연값 add[b] 를 둡니다. 완전
블록은 add[b] += v(블록 전체가 오른 셈), 불완전 블록은 원소를 직접 갱신하고
블록 합도 보정합니다. 점 \(i\)의 실제 값은 a[i] + add[i/len].

vector<ll> add;   // 블록별 지연 덧셈
void range_add(int l, int r, ll v) {
    while (l <= r && l % len != 0) { a[l] += v; l++; }
    while (l + len - 1 <= r) { add[l/len] += v; l += len; }
    while (l <= r) { a[l] += v; l++; }
}
ll point(int i) { return a[i] + add[i/len]; }

"구간 갱신 + 구간 합"까지 원하면 블록 합에도 add[b] * (블록에 걸친 개수)
반영해야 하며, 이쯤 되면 세그먼트 트리 lazy가 더 정석입니다. 제곱근 분할의
강점은 오히려 비표준 질의에 있습니다.

Lesson 3강 · 심화·변형 — Mo's 알고리즘과 함정 선택 8m

Mo's 알고리즘 — 오프라인 질의 재정렬

Mo's 알고리즘 은 제곱근 분할의 정신을 질의 순서에 적용합니다. 갱신이 없는
정적 배열에 대한 \(Q\)개의 구간 질의를, 두 포인터 [cur_l, cur_r]를 조금씩 옮기며
답합니다. 질의를 왼쪽 끝의 블록 번호로 1차 정렬, 오른쪽 끝으로 2차 정렬하면
포인터 총 이동량이 \(O((N + Q)\sqrt{N})\)로 억제됩니다.

int B;                                  // 블록 크기 = √N
struct Q { int l, r, idx; };
bool cmp(const Q& a, const Q& b) {
    if (a.l / B != b.l / B) return a.l / B < b.l / B;
    return (a.l / B & 1) ? a.r > b.r : a.r < b.r;   // 지그재그 최적화
}

// add(x)/remove(x)로 현재 구간의 답을 O(1)에 유지
void solve(vector<Q>& qs) {
    B = max(1, (int)sqrt(n));
    sort(qs.begin(), qs.end(), cmp);
    int curL = 0, curR = -1;
    for (auto& q : qs) {
        while (curR < q.r) add(++curR);
        while (curL > q.l) add(--curL);
        while (curR > q.r) remove(curR--);
        while (curL < q.l) remove(curL++);
        ans[q.idx] = current_answer;
    }
}

add/remove\(O(1)\)이면 전체 \(O((N+Q)\sqrt{N})\). "구간 내 서로 다른 수의
개수", "구간 최빈값 빈도" 처럼 세그먼트 트리로 어려운 질의에 강력합니다. 홀짝
블록에서 오른쪽 포인터를 지그재그로 움직이는 최적화가 상수를 줄입니다.


흔한 함정

  • 블록 크기 선택 — 기본은 \(\sqrt{N}\)이지만, 갱신과 질의 비용이 다르면 최적
    \(B\)가 달라집니다. 갱신이 \(O(1)\)·질의가 \(O(N/B + B)\)\(\sqrt N\)이지만, Mo에서
    갱신 비용이 크면 \(B\)를 키워 균형을 다시 잡으세요.
  • 경계 처리 off-by-one — 세 while의 조건(l % len, l + len - 1 <= r)을
    정확히. 한 칸 실수가 이중 계산/누락을 부릅니다. 작은 배열로 완전 탐색과 대조
    검증하는 습관을 들이세요.
  • Mo의 포인터 확장/축소 순서add를 축소보다 먼저 하도록 순서를 지켜야
    음수 구간 등에서 오류가 안 납니다. 특히 curR < q.r를 늘릴 때와 줄일 때 순서.
  • 빈 블록/마지막 블록\(N\)\(\sqrt N\)의 배수가 아니면 마지막 블록이 짧습니다.
    블록 개수 (n+len-1)/len와 경계 계산을 일관되게.
  • Mo는 오프라인 전용 — 모든 질의를 미리 알아야 재정렬이 가능합니다. 온라인
    질의(중간에 답을 요구)면 쓸 수 없습니다.

제곱근 분할 vs 세그먼트 트리 — 선택

상황 추천
표준 구간 합/최값 + 점 갱신 세그먼트 트리 (\(O(\log N)\))
비표준 질의(구간 최빈값, \(\le x\) 개수) 제곱근 분할 / Mo
갱신 없는 오프라인 다수 질의 Mo's 알고리즘
구현 시간이 급하고 \(N \le 10^5\) 제곱근 분할(단순함)

핵심: 로그가 필요 없고 유연함이 필요하면 제곱근 분할. \(\sqrt N\)\(\log N\)보다
느리지만, 표현하기 까다로운 질의를 단순한 코드로 처리하는 것이 이 기법의 진짜
가치입니다.


확장 — 값에 대한 제곱근 분할, 인덱스 스킵

배열이 아니라 값 domain\(\sqrt V\) 블록으로 나눠 "\(x\)번째 원소" 나 "특정
값 이하 개수"를 \(O(\sqrt V)\)에 답하기도 합니다. 또 그래프에서 차수가 큰 정점과
작은 정점을 \(\sqrt M\) 기준으로 나눠 처리하는 heavy/light 분할 도 같은 \(\sqrt{}\)
균형 사고입니다. "\(B\)\(N/B\)를 곱이 일정하게 두고 합을 최소화한다"는 발상이
제곱근 분할의 본질입니다.

Practice problem 구간 최빈 빈도 선택 25m
R00627

구간 최빈 빈도

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Platinum I 플래티넘 I 지금 풀기
Practice problem 구간 최대 빈도가중값 선택 25m
R00646

구간 최대 빈도가중값

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Diamond V 다이아몬드 V 지금 풀기
04
Level 5 · Analyst

Analyst

자료 구조 · Analyst 단계

0/15 완료
Lesson 1강 · 개념 — 구간 갱신과 게으른 전파 필수 8m

왜 게으름이 필요한가

일반 세그먼트 트리는 점 갱신구간 질의를 각각 \(O(\log N)\)에 처리합니다.
그런데 "구간 \([l, r]\)의 모든 원소에 \(v\)를 더하라" 같은 구간 갱신을 정직하게
처리하면 구간 길이만큼의 리프를 건드려 한 번에 \(O(N)\)이 듭니다.

핵심 아이디어:

지금 당장 자손 전부를 갱신하지 말고, "갱신해야 한다는 사실"만 노드에 메모해
두자.
이 메모를 lazy 값이라 하고, 실제 내려보내는 일을 나중으로 미루는
것을 게으른 전파(lazy propagation) 라 한다.

이렇게 하면 구간 갱신도 구간 질의처럼 "완전히 덮이는 노드에서 멈춰" \(O(\log N)\)
끝납니다.

연산 일반 세그 lazy 세그
점 갱신 \(O(\log N)\) \(O(\log N)\)
구간 갱신 \(O(N)\) \(O(\log N)\)
구간 질의 \(O(\log N)\) \(O(\log N)\)

불변식

각 노드는 두 정보를 듭니다.

  • tree[x]: 이 노드가 담당하는 구간 전체에 lazy가 이미 반영된 집계값.
  • lazy[x]: 이 노드의 자식들에게 아직 내려보내지 않은 보류 갱신.

가장 중요한 불변식: tree[x]는 자기 자신의 lazy까지 모두 반영한
정답이다. 다만 lazy[x]자식에게는 아직 적용되지 않았다.

따라서 어떤 노드의 자식으로 내려가기 직전에만 lazy를 한 단계 밀어내면
(push_down), 항상 정확한 값을 보게 됩니다.


push_down 과정

구간 더하기 + 구간 합 질의를 예로 들면, 노드 \(x\)가 길이 \(len\) 구간을 담당할 때
"구간 전체에 \(v\) 더하기"는 합에 \(v \times len\)을 더하는 것과 같습니다.

동작 tree 갱신 lazy 누적
구간에 \(v\) 더하기 tree[x] += v * len lazy[x] += v
자식으로 전파 각 자식에 apply(child, len_child, lazy[x])

전파가 끝나면 lazy[x] = 0으로 비웁니다. 더하기는 결합·교환법칙
성립하므로 보류된 갱신을 누적해 두었다가 한꺼번에 적용해도 결과가 같습니다. 이
성질이 lazy 전파가 성립하는 근본 이유입니다.


정당성과 복잡도

구간 갱신과 질의 모두 표준 세그먼트 트리처럼 완전히 덮이는 노드에서
멈춥니다.
이런 노드는 깊이마다 최대 상수 개뿐이므로 방문 노드 수는
\(O(\log N)\)이고, 각 노드의 일(\(O(1)\) push_down 포함)도 상수 시간입니다. 결국
갱신·질의가 모두 \(O(\log N)\)입니다.


워크드 예제

배열 [0,0,0,0](합 세그)에 "구간 \([0,2]\)\(+3\)"을 적용해 봅시다.

  • 루트 \([0,3]\): \([0,2]\)에 완전히 안 덮임 → push_down(현재 lazy 0) 후 자식으로.
  • 왼쪽 \([0,1]\): 완전히 덮임 → tree += 3*2 = 6, lazy += 3. 멈춤.
  • 오른쪽 \([2,3]\): 부분 겹침 → 자식으로.
  • \([2]\): 덮임 → tree += 3. \([3]\): 안 겹침.
  • 되돌아오며 tree[[2,3]] = 3.
  • 루트 tree = 6 + 3 = 9.

이제 질의 \([1,1]\): 루트→왼쪽으로 내려가며 왼쪽 \([0,1]\)의 lazy 3을 push_down 해야
\([1]\)이 정확히 3이 됩니다. 내려가기 직전 전파가 핵심임을 보여 줍니다.

주의: 곱하기와 더하기가 섞이면 lazy의 결합 순서가 중요해집니다. 다음
강의에서 이를 다룹니다.

Lesson 2강 · 구현 — 구간 더하기/구간 합 템플릿 선택 8m

구간 더하기 / 구간 합 레퍼런스 (C++)

1-indexed 재귀 구현입니다. 합이 커질 수 있으므로 long long을 씁니다.

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 1 << 20;
ll tree[2*MAXN], lazy[2*MAXN];
vector<ll> a;

// 노드 x가 길이 len 구간에 v를 더한 효과
void apply_node(int x, int len, ll v) {
    tree[x] += v * len;      // 구간 전체에 v를 더한 효과
    lazy[x] += v;            // 자식에게 보류
}
// s,e를 넘겨 각 자식의 '정확한 길이'로 전파 (임의 N에서 안전)
void push_down(int x, int s, int e) {
    if (lazy[x] == 0) return;
    int m = (s+e)/2;
    apply_node(x*2,   m - s + 1, lazy[x]);   // 왼쪽 자식 길이 = m-s+1
    apply_node(x*2+1, e - m,     lazy[x]);   // 오른쪽 자식 길이 = e-m
    lazy[x] = 0;             // 내려보냈으니 비운다
}
void build(int x, int s, int e) {
    if (s == e) { tree[x] = a[s]; return; }
    int m = (s+e)/2;
    build(x*2, s, m); build(x*2+1, m+1, e);
    tree[x] = tree[x*2] + tree[x*2+1];
}
void update(int x, int s, int e, int l, int r, ll v) {  // [l,r]에 +v
    if (r < s || e < l) return;
    if (l <= s && e <= r) { apply_node(x, e-s+1, v); return; }
    push_down(x, s, e);                  // 내려가기 전에 전파
    int m = (s+e)/2;
    update(x*2, s, m, l, r, v);
    update(x*2+1, m+1, e, l, r, v);
    tree[x] = tree[x*2] + tree[x*2+1];   // 자식 갱신 후 재계산
}
ll query(int x, int s, int e, int l, int r) {
    if (r < s || e < l) return 0;
    if (l <= s && e <= r) return tree[x];
    push_down(x, s, e);
    int m = (s+e)/2;
    return query(x*2, s, m, l, r) + query(x*2+1, m+1, e, l, r);
}

int main() {
    int n = 5; a = {1, 2, 3, 4, 5};
    build(1, 0, n-1);
    update(1, 0, n-1, 1, 3, 10);          // a[1..3] += 10
    cout << query(1, 0, n-1, 0, 4) << "\n"; // 15 + 30 = 45
    cout << query(1, 0, n-1, 2, 2) << "\n"; // 3 + 10 = 13
}

연산 순서가 절대적입니다: 내부 노드로 재귀하기 직전 push_down, 자식
갱신을 마친 tree[x] 재계산.


Python 구현

파이썬 재귀 한계 때문에 인덱스를 배열로 두고 재귀하되, 크기에 따라
sys.setrecursionlimit을 올립니다.

import sys
sys.setrecursionlimit(1 << 20)

class LazySeg:
    def __init__(self, data):
        self.n = len(data)
        self.tree = [0] * (4 * self.n)
        self.lazy = [0] * (4 * self.n)
        self._build(1, 0, self.n - 1, data)
    def _build(self, x, s, e, data):
        if s == e:
            self.tree[x] = data[s]; return
        m = (s + e) // 2
        self._build(2*x, s, m, data)
        self._build(2*x+1, m+1, e, data)
        self.tree[x] = self.tree[2*x] + self.tree[2*x+1]
    def _apply(self, x, length, v):
        self.tree[x] += v * length
        self.lazy[x] += v
    def _push_down(self, x, s, e):            # s,e로 자식 길이를 정확히 계산
        if self.lazy[x]:
            m = (s + e) // 2
            self._apply(2*x, m - s + 1, self.lazy[x])
            self._apply(2*x+1, e - m, self.lazy[x])
            self.lazy[x] = 0
    def update(self, x, s, e, l, r, v):
        if r < s or e < l: return
        if l <= s and e <= r:
            self._apply(x, e - s + 1, v); return
        self._push_down(x, s, e)
        m = (s + e) // 2
        self.update(2*x, s, m, l, r, v)
        self.update(2*x+1, m+1, e, l, r, v)
        self.tree[x] = self.tree[2*x] + self.tree[2*x+1]
    def query(self, x, s, e, l, r):
        if r < s or e < l: return 0
        if l <= s and e <= r: return self.tree[x]
        self._push_down(x, s, e)
        m = (s + e) // 2
        return self.query(2*x, s, m, l, r) + self.query(2*x+1, m+1, e, l, r)

홀수 길이 주의: 노드 \([s,e]\)의 자식 구간 길이는 왼쪽 \(m-s+1\), 오른쪽
\(e-m\) 입니다(\(m=(s+e)/2\)). 합 모드의 apply_nodev*len을 쓰므로 각
자식에 정확한 길이를 넘겨야 합니다. len/2, len/2로 나누면 \(N\)이 2의
거듭제곱이 아닐 때 틀리므로, 위처럼 s,e를 push_down에 넘겨 길이를 직접
계산하는 것이 안전합니다.

Lesson 3강 · 심화와 변형 — 구간 대입, affine, 함정 선택 8m

변형에 따라 달라지는 lazy

lazy의 의미결합 방식, 그리고 tree 갱신식은 문제마다 다릅니다.

갱신 / 질의 lazy 의미 tree 갱신(길이 \(len\)) 겹침 없음 항등원
구간 +v / 구간 합 누적 덧셈량 tree += v*len 합 0
구간 +v / 구간 최댓값 누적 덧셈량 tree += v (길이 무관) \(-\infty\)
구간 대입 =v / 구간 합 대입값(+플래그) tree = v*len 0
구간 affine / 구간 합 \((a,b)\) tree = a*tree + b*len 0

핵심: 최댓값·최솟값 모드에서는 길이를 곱하지 않습니다(tree += v).
합 모드에서만 v*len. 이 차이를 헷갈리면 오답의 대표 원인이 됩니다.


구간 대입(assign) 변형

"구간을 값 \(v\)덮어쓰기"는 덧셈과 달리 뒤에 온 대입이 앞을 무효화
합니다. 그래서 "대입이 있었는지" 플래그가 필요합니다(예: has = true,
val = v). push_down 시 자식의 기존 lazy를 버리고 새 대입으로 교체합니다.

struct Node { ll sum; bool has; ll val; };  // has: 대입 보류 여부
void apply_assign(Node& t, int len, ll v) {
    t.sum = v * len; t.has = true; t.val = v;
}

대입과 덧셈이 함께 있으면(구간 대입 + 구간 더하기 + 구간 합), 규약을
정해야 합니다: 보통 "대입이 들어오면 보류 중이던 덧셈을 지우고, 이후 덧셈은
대입값 위에 누적". push_down에서 대입 먼저, 덧셈 나중 순서로 자식에
적용합니다.


affine 변환 합성

"구간의 모든 원소를 \(x \mapsto a\cdot x + b\)로 바꾸기"는 lazy를 \((a, b)\) 쌍으로
둡니다. 두 변환의 합성은

$$ (a_2, b_2) \circ (a_1, b_1) = (a_2 a_1,\; a_2 b_1 + b_2) $$

즉 "먼저 \((a_1,b_1)\), 그 위에 \((a_2,b_2)\)"를 적용한 결과입니다. 구간 합
\(S\)에 대해 변환 적용은 \(S \mapsto a\cdot S + b\cdot len\)입니다.

곱셈 lazy의 항등원은 1입니다(0이 아님!). "갱신 없음"을 나타내는 lazy
초기값이 연산마다 다르다는 점을 반드시 기억하세요: 덧셈은 0, 곱셈은 1,
affine은 \((1, 0)\).


흔한 함정 총정리

  • push_down 순서 — 내부 노드로 재귀하기 직전에 반드시 전파. 빠뜨리면
    자식이 옛값을 보고 오답. 그리고 자식 처리를 마친 뒤 tree[x] 재계산.
  • push_down을 query에서도 — 구간 질의에서도 내려가기 전에 전파해야 합니다.
    update에서만 하고 query에서 빠뜨리는 실수가 흔합니다.
  • 길이 인자 — 합 모드는 v*len, 최값 모드는 v(길이 무관). affine·대입은
    각기 다른 식. 자식으로 넘길 때 정확한 자식 길이(홀수 주의).
  • lazy 항등원 오설정 — 덧셈 0, 곱셈 1, 대입은 "플래그 false". 초기화 실수
    주의.
  • 오버플로 — 더한 값 \(\times\) 길이가 \(10^{18}\)를 쉽게 넘깁니다. long long
    필수, affine에선 모듈러가 있으면 매 연산 후 mod.
  • 1-indexed vs 0-indexed — 입력이 1-based면 질의 인덱스를 맞추세요.
  • 배열 크기 — 재귀형 tree, lazy 모두 4*n 확보.

응용 정리

lazy 세그먼트 트리는 다음의 토대가 됩니다.

  • 구간에 등차수열 더하기(차분을 두 번 적용).
  • 스캔라인 + 좌표 압축으로 직사각형 넓이 합집합.
  • HLD(heavy-light decomposition) 위의 경로 갱신·질의.
  • 머지 소트 트리 / 퍼시스턴트 세그로의 확장.

전파 공식을 손으로 유도하는 연습(작은 배열에서 직접 push_down 추적)을 충분히
해 두면, 새로운 변형을 만나도 lazy의 의미·합성·항등원 세 가지만 정의하면
됩니다.

Practice problem 택배 운송 선택 25m
KOI00007

택배 운송

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 상자 보관 선택 25m
KOI00015

상자 보관

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 정렬 배열을 담는 세그먼트 트리 필수 8m

머지 소트 트리란

머지 소트 트리(Merge Sort Tree)는 정적 배열에 대해 다음과 같은 구간 질의를
빠르게 처리하는 자료 구조다.

  • 구간 \([l, r]\) 안에서 값이 \(k\)보다 큰(작은, 이상, 이하) 원소의 개수
  • 구간 \([l, r]\) 안에서 값이 \([a, b]\) 범위에 드는 원소의 개수
  • 구간 \([l, r]\)\(j\)번째로 작은 수 (구간 k-th)

배열의 값은 갱신되지 않는다고 가정한다(정적). 갱신이 필요하면 다른 구조를 써야
한다(뒤에서 설명).

핵심 발상은 단순하다. 표준 세그먼트 트리의 각 노드에, 그 노드가 담당하는 구간의
원소들을 정렬해서 통째로 저장한다. 자식 두 개의 정렬 배열을 병합(merge)하면
부모의 정렬 배열이 되므로 — 이 병합 과정이 병합 정렬과 똑같아서 — 이름이 "머지
소트 트리"다.

              [1,2,3,5,7,8]          ← 루트: 전체를 정렬한 배열
             /            \
        [2,5,7]          [1,3,8]
        /     \          /     \
     [2,5]   [7]      [1,3]   [8]
     /   \            /   \
   [5]   [2]        [3]   [1]      ← 원본 배열: 5 2 7 3 8

각 리프는 원소 하나, 내부 노드는 두 자식을 병합한 정렬 배열을 갖는다.

왜 성립하고 얼마나 드나

한 깊이(레벨)를 보면 모든 원소가 정확히 한 번씩 분산되어 나타난다. 따라서 한
깊이의 정렬 배열 크기의 총합은 \(N\)이고, 트리의 깊이가 \(O(\log N)\)이므로 전체
메모리는

$$ O(N \log N) $$

이다. 구축은 각 노드에서 두 자식을 선형 병합하므로, 깊이마다 \(O(N)\), 전체
\(O(N \log N)\) — 병합 정렬과 동일한 비용이다.

질의: "구간에서 k 초과 개수"

구간 \([l, r]\)은 표준 세그먼트 트리처럼 서로소인 \(O(\log N)\)개의 노드로 분해된다.
분해된 각 노드는 자기 구간을 정렬된 상태로 들고 있으므로, 그 안에서 "\(k\)
초과 원소가 몇 개인가"는 이분 탐색 한 번, 즉 \(O(\log N)\)에 셀 수 있다.

$$ \underbrace{O(\log N)}_{\text{구간 → 노드 분해}} \times \underbrace{O(\log N)}_{\text{노드 안 이분 탐색}} = O(\log^2 N) $$

요점: "구간을 \(O(\log N)\)개 노드로 쪼갠다" × "각 노드에서 이분 탐색
\(O(\log N)\)" \(= O(\log^2 N)\).

구체적 예시

배열 A = [5, 2, 7, 3, 8] (1-indexed), 질의 "구간 \([2, 4]\)에서 값이 \(4\)보다 큰
원소 개수". 구간 \([2,4]\)의 원소는 \(\{2, 7, 3\}\)이고 \(4\) 초과는 \(\{7\}\) 하나다.
트리에서 \([2,4]\)\([2,3]\) 노드(정렬 배열 [2,5]... 가 아니라 위 그림 기준으로
구간 분해 결과 노드)와 \([4,4]\) 노드로 나뉘고, 각 노드의 정렬 배열에서 upper_bound(4)
이후 원소 수를 더하면 답 \(1\)을 얻는다.

질의: "구간 k번째 수"

\(x\)에 대해 "\([l,r]\)에서 \(x\) 이하가 몇 개인가"를 위 방법으로 셀 수 있다. 이
개수는 \(x\)에 대해 단조 증가하므로, 값에 대한 외부 이분 탐색으로 "그 개수가
\(j\) 이상이 되는 최소 \(x\)"를 찾으면 그것이 구간 \(j\)번째 수다.

  • 소박한 구현: 외부 이분 \(O(\log V)\) × 내부 카운팅 \(O(\log^2 N)\) = \(O(\log V \log^2 N)\).
  • 좌표 압축 + 분수 캐스케이딩(fractional cascading): 외부 이분을 세그먼트
    트리 하강과 합쳐 \(O(\log^2 N)\)까지 줄일 수 있다(3강에서 다룸).

언제 쓰나 / 대안과의 비교

머지 소트 트리는 구현이 단순하고 "부등식 카운팅"에 특히 직관적이다. 다만
정적이라는 점과 \(O(N\log N)\) 메모리가 약점이다.

목적 머지 소트 트리 대안
구간 k 초과 개수 \(O(\log^2 N)\) 오프라인 + BIT, Wavelet Tree
구간 k번째 수 \(O(\log^2 N)\)~ 퍼시스턴트 세그먼트 트리 \(O(\log N)\), Wavelet Tree \(O(\log N)\), Mo's \(O((N+Q)\sqrt N)\)
점 갱신 필요 ✗ (재구축) BIT-of-정렬, 분할 정복/평방분할, 퍼시스턴트

머지 소트 트리를 한 번 구현해 보면, 이후 등장하는 분수 캐스케이딩웨이블릿
트리(Wavelet Tree)
의 동기를 훨씬 잘 이해하게 된다.

Lesson 구현: 구간 카운팅과 구간 k번째 수 선택 8m

구축: 자식 병합

각 노드를 정렬된 vector<int>로 둔다. 구축은 재귀로 두 자식을 만든 뒤
std::merge로 합친다.

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
vector<int> tree[4 * MAXN];   // 각 노드의 정렬 배열
int arr[MAXN];                // 1-indexed 원본 배열
int N;

void build(int node, int s, int e) {
    if (s == e) { tree[node] = { arr[s] }; return; }
    int m = (s + e) / 2;
    build(node * 2,     s,     m);
    build(node * 2 + 1, m + 1, e);
    // 두 자식의 정렬 배열을 선형 병합
    tree[node].resize(tree[node*2].size() + tree[node*2+1].size());
    merge(tree[node*2].begin(),   tree[node*2].end(),
          tree[node*2+1].begin(), tree[node*2+1].end(),
          tree[node].begin());
}

질의 1 — 구간에서 "k 초과" 개수

구간을 분해하고, 완전히 포함되는 노드에서 upper_bound(k)로 "\(k\) 이하 개수"를
구한 뒤 전체 크기에서 빼면 "\(k\) 초과 개수"다.

// [l, r]에서 값 > k 인 원소 개수
int countGreater(int node, int s, int e, int l, int r, int k) {
    if (r < s || e < l) return 0;                 // 겹치지 않음
    if (l <= s && e <= r) {                        // 완전히 포함
        auto &v = tree[node];
        int le = upper_bound(v.begin(), v.end(), k) - v.begin(); // <= k 개수
        return (int)v.size() - le;                 // > k 개수
    }
    int m = (s + e) / 2;
    return countGreater(node*2,   s,   m, l, r, k)
         + countGreater(node*2+1, m+1, e, l, r, k);
}

부등호 정리 (가장 흔한 버그):

원하는 것
\(\le k\) 개수 upper_bound(k) - begin()
\(< k\) 개수 lower_bound(k) - begin()
\(> k\) 개수 size() - upper_bound(k)
\(\ge k\) 개수 size() - lower_bound(k)
\([a,b]\) 개수 upper_bound(b) - lower_bound(a)

질의 2 — 구간 k번째 수 (값에 대한 이분)

값을 좌표 압축했다고 하자(압축 배열 comp, 값들이 0..M-1). "\([l,r]\)에서 값
\(\le x\) 개수"를 세는 함수를 만들고, 그 개수가 \(j\) 이상이 되는 최소 압축값을
바깥에서 이분 탐색한다.

// [l,r]에서 값이 comp[<=idx] 이하(즉 압축값 <= idx)인 개수
int countLE(int node, int s, int e, int l, int r, int x) {
    if (r < s || e < l) return 0;
    if (l <= s && e <= r) {
        auto &v = tree[node];
        return upper_bound(v.begin(), v.end(), x) - v.begin();
    }
    int m = (s + e) / 2;
    return countLE(node*2, s, m, l, r, x)
         + countLE(node*2+1, m+1, e, l, r, x);
}

// [l,r]의 j번째(1-indexed) 작은 값 (원래 값). 여기선 tree가 '실제 값'을 담는다고 가정.
int kthSmallest(int l, int r, int j) {
    int lo = -2000000000, hi = 2000000000, ans = hi;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;               // 오버플로 방지
        if (countLE(1, 1, N, l, r, mid) >= j) { ans = mid; hi = mid - 1; }
        else lo = mid + 1;
    }
    return ans;
}

이 방식은 외부 이분 \(O(\log V)\) × 내부 \(O(\log^2 N)\) = \(O(\log V \log^2 N)\)이다.
값 범위가 크면 좌표 압축으로 \(\log V \to \log N\)이 된다.

흔한 실수와 주의점

  • 부등호 방향lower_bound(처음으로 \(\ge k\)), upper_bound(처음으로
    \(> k\))를 정확히 구분하자. "이상/초과/이하/미만" 네 경우를 손으로 확인하는
    습관이 필요하다.
  • 메모리\(O(N\log N)\)개의 정수가 들어간다. \(N = 10^5\)이면 약
    \(10^5 \times 17 \approx 1.7 \times 10^6\)개다. 4 * MAXN개의 vector
    차지하는 오버헤드도 무시할 수 없으니 MAXN을 넉넉하되 과하지 않게 잡는다.
  • 정적 전제 — 값이 바뀌면 그 값을 담은 \(O(\log N)\)개 노드의 정렬 배열을 모두
    갱신해야 하는데, 정렬 배열의 삽입/삭제는 \(O(N)\)이라 사실상 불가능하다. 갱신이
    필요하면 아래 대안을 쓴다.
  • 오버플로 — 외부 이분에서 mid = (lo+hi)/2lo+(hi-lo)/2로.

갱신이 필요할 때의 대안

  • BIT of 정렬 배열 / 분할 평방(sqrt decomposition): 각 블록을 정렬 유지.
  • 퍼시스턴트 세그먼트 트리: 구간 k번째를 \(O(\log N)\)에, 그리고 점 갱신도
    버전으로 관리 가능.
  • Wavelet Tree: 구간 k번째/랭크를 \(O(\log \sigma)\)에. 다음 강에서 동기를
    설명한다.
Lesson 심화·응용: 분수 캐스케이딩과 대안 자료구조 선택 8m

분수 캐스케이딩(Fractional Cascading)

구간 k번째 수를 \(O(\log^2 N)\)로 줄이는 고급 기법이다. 소박한 방법은 외부 값-이분
\(O(\log N)\)과 내부 카운팅 \(O(\log^2 N)\)을 곱해 \(O(\log^3 N)\)(압축 시)이 된다.
분수 캐스케이딩은 외부 이분 자체를 세그먼트 트리 하강에 통합한다.

핵심 아이디어: 값 공간에 대한 세그먼트 트리를 세우고, 각 노드에 "그 값-구간에
속하는 원소들의 원래 위치(인덱스)를 정렬한 배열"과, 그 배열에 대한 위치
prefix 정보를 둔다. 루트에서 시작해, 현재 값-구간의 왼쪽 절반 안에 위치가
\([l,r]\)인 원소가 \(j\)개 이상이면 왼쪽으로, 아니면 그만큼 빼고 오른쪽으로
내려간다. 각 레벨에서 "\([l,r]\)에 드는 개수"를 상수 시간에 얻기 위해 부모의
이분 결과를 자식으로 물려주는 것이 "cascading"이며, 이로써 레벨당 \(O(\log N)\)
이분이 \(O(1)\)로 줄어 전체 \(O(\log^2 N)\)가 아니라 실제로 한 번의 하강
\(O(\log N)\)
(전처리 후)까지도 가능하다. 실전에서는 이 정교함 대신 아래의
웨이블릿 트리퍼시스턴트 세그먼트 트리를 쓰는 편이 구현이 깔끔하다.

웨이블릿 트리(Wavelet Tree) — 개념적 후계자

머지 소트 트리를 "값으로 분할"하는 관점으로 재구성한 것이 웨이블릿 트리다.
값 중앙값을 기준으로 원소를 좌/우로 나누되, 각 원소가 왼쪽으로 갔는지 여부를
비트벡터 + rank로 저장한다. 그러면 구간 k번째, 구간 내 \([a,b]\) 개수, 랭크를
모두 \(O(\log \sigma)\)(\(\sigma\) = 값 종류 수)에 처리한다.

값 구간 전체
  ├─ 각 위치가 '왼쪽 절반'으로 갔는지 비트: 1 0 1 1 0 ...
  ├─ rank(비트, 위치)로 [l,r]이 자식에서 어느 구간이 되는지 O(1) 이동
  └─ 재귀

머지 소트 트리와 달리 갱신 없는 정적 상황에서 더 낮은 상수더 적은
메모리
(비트 단위)를 갖는 것이 장점이지만, 구현 난도가 있다.

대안 총정리 (구간 k번째 수)

방법 질의 메모리 갱신 비고
머지 소트 트리 \(O(\log^2 N)\) \(O(N\log N)\) 구현 쉬움, 부등식 카운팅 직관적
퍼시스턴트 세그트리 \(O(\log N)\) \(O(N\log N)\) 버전 prefix 두 버전을 동시 하강
웨이블릿 트리 \(O(\log \sigma)\) \(O(N\log\sigma)\) bit 상수/메모리 최적
Mo's + 값 세그트리 \(O((N+Q)\sqrt N \log N)\) \(O(N)\) 온갖 카운팅에 범용
오프라인 + BIT \(O((N+Q)\log^2 N)\) \(O(N)\) 쿼리를 값 기준 정렬

활용 유형

  • 구간 내 \([a,b]\) 원소 개수: countLE(b) - countLE(a-1). 2D 점 카운팅으로
    바꿔 오프라인 BIT로도 풀 수 있다.
  • 구간 k번째 수: 위의 값-이분 또는 퍼시스턴트/웨이블릿.
  • 구간 내 서로 다른 값 개수: 이건 머지 소트 트리보다 Mo's 알고리즘 또는
    "이전 등장 위치 + 오프라인 BIT"가 표준이다.

연습 방향

정적 배열 + "구간에서 조건을 만족하는 원소 개수/구간 k번째" 유형을 만나면 먼저
머지 소트 트리로 \(O(\log^2 N)\) 풀이를 잡고, 시간/메모리가 빠듯하면 퍼시스턴트
세그먼트 트리(질의 \(O(\log N)\))나 웨이블릿 트리로 갈아타는 흐름을 익히자.
갱신이 끼어 있으면 머지 소트 트리는 후보에서 제외하고, BIT-of-정렬이나 평방
분할, 혹은 온라인이 아니라면 오프라인 기법을 고려한다.

Practice problem 구간에서 k 이하인 원소의 개수 선택 25m
R01485

구간에서 k 이하인 원소의 개수

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 구간에서 값이 [x, y]에 드는 원소의 개수 선택 25m
R01486

구간에서 값이 [x, y]에 드는 원소의 개수

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — 온라인 vs 오프라인과 정렬 스위핑 필수 8m

온라인 vs 오프라인

  • 온라인(online) — 질의를 받는 즉시 답해야 하고, 이전 답이 다음 입력에
    영향을 줄 수 있다(강제 온라인).
  • 오프라인(offline) — 모든 질의를 미리 받아 둘 수 있어, 유리한 순서로
    재배열
    해 처리할 수 있다. 이 자유도가 오프라인 기법의 힘이다.

핵심 발상: "질의에 답하는 순서는 입력 순서와 같을 필요가 없다." 질의를
정렬하거나 묶어 처리하면 자료구조의 갱신 횟수를 극적으로 줄일 수 있다.

오프라인은 그 자체로 하나의 알고리즘이라기보다 사고 틀입니다. Mo's
알고리즘, 오프라인 동적 연결성, 병렬 이분 탐색이 모두 이 틀의 발전형입니다.


대표 패턴 1 — 정렬 후 스위핑

질의와 원소를 같은 기준(보통 값이나 좌표)으로 정렬하고, 포인터를 전진시키며
"지금까지 조건을 만족하는 원소"만 자료구조에 반영한 상태에서 답합니다.

예: "각 질의 \(x_q\)에 대해 값이 \(x_q\) 이하인 원소들에 대한 무언가". 질의를 \(x_q\)
오름차순으로, 원소도 값 오름차순으로 정렬해 포인터를 밀면, 각 원소는 정확히 한
번만
자료구조에 삽입됩니다. 삽입 총 \(O(N\log N)\), 질의 총 \(O(Q\log N)\).

불변식: 포인터가 단조 증가하는 동안, 자료구조는 "현재 기준 이하의 모든
원소가 반영된" 상태를 유지한다. 그래서 각 원소를 두 번 넣지 않는다.

대표 패턴 2 — 차분으로 질의 분해

구간 질의 \([l, r]\)\(f(r) - f(l-1)\)로 쪼개면, 각 끝점을 독립된 "한쪽 끝 질의"로
만들 수 있습니다. 쪼갠 끝점들을 모아 정렬해 한 번의 스위프로 모두 처리합니다.

대표 패턴 3 — 좌표/시간 차원 추가

2차원 직사각형 질의를 한 축으로 정렬해 1차원 문제 \(\times\) 스위프로 낮춥니다.
"점 추가 / 직사각형 안 점 개수" 류가 전형적이며, 포함-배제로 질의를 누적 합
형태로 쪼개 다른 축의 BIT/세그 트리에 올립니다.


왜 빠른가

온라인이라면 각 질의마다 자료구조를 처음부터 세우거나 비싼 연산을 해야 할 수
있습니다. 오프라인은 상태를 단조롭게 키워 가며(원소를 한 번씩만 추가) 모든
질의를 한 번의 스위프로 답하므로, 갱신 비용이 질의 사이에 분할상환됩니다.


워크드 예제 — "값 \(\le x\)인 원소 개수" 질의들

배열 [3, 1, 4, 1, 5], 질의 \(x = 2, 4\)에 대해 "값이 \(x\) 이하인 원소 수"를
오프라인으로. 원소를 정렬: \(1,1,3,4,5\). 질의도 \(x\) 오름차순: \(2, 4\).

  • 질의 \(x=2\): 포인터를 값 \(\le 2\)까지 전진 → \(1,1\) 두 개 반영. 답 \(2\).
  • 질의 \(x=4\): 포인터를 값 \(\le 4\)까지 계속 전진 → \(3,4\) 추가 반영(총 4개). 답 \(4\).

포인터가 되돌아가지 않으므로 전체 원소를 한 번씩만 훑습니다. 다음 강의에서
BIT를 이용한 구간 서로 다른 수 세기 구현을 봅니다.


한계

  • 답이 다음 질의에 영향을 주는 강제 온라인(예: XOR로 인덱스 인코딩)은
    오프라인이 불가능. 이럴 땐 퍼시스턴트 자료구조가 필요합니다.
  • 질의를 모두 저장할 메모리가 있어야 합니다.
Lesson 2강 · 구현 — 구간 서로 다른 수 세기 (정렬 + BIT) 선택 8m

예제: 구간 내 서로 다른 수의 개수

배열이 주어지고 여러 질의 \([l, r]\)에 대해 "구간 안 서로 다른 값의 개수"를
오프라인으로 구합니다. 질의를 \(r\) 오름차순으로 정렬하고, \(r\)을 늘리며 "각
값이 마지막으로 등장한 위치"에만 1을 표시하는 BIT(펜윅 트리)를 유지합니다.

핵심: 각 값에 대해 가장 오른쪽 등장 위치에만 1을 둔다. 그러면
"\([l, r]\)에서 1의 합 = 서로 다른 값의 개수"가 된다.

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
int n, q, a[MAXN], bit[MAXN], ans[MAXN];
map<int,int> last_pos;                    // 값 -> 마지막 등장 위치

void update(int i, int v) { for (; i <= n; i += i & -i) bit[i] += v; }
int  sum(int i) { int s = 0; for (; i > 0; i -= i & -i) s += bit[i]; return s; }

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
    scanf("%d", &q);
    vector<array<int,3>> Q(q);            // (r, l, 질의번호)
    for (int i = 0; i < q; i++) {
        int l, r; scanf("%d %d", &l, &r);
        Q[i] = {r, l, i};
    }
    sort(Q.begin(), Q.end());             // r 오름차순

    int cur = 1;                          // 다음에 추가할 인덱스
    for (auto& [r, l, idx] : Q) {
        while (cur <= r) {                // r까지 원소를 한 번씩만 반영
            int v = a[cur];
            auto it = last_pos.find(v);
            if (it != last_pos.end()) update(it->second, -1); // 이전 표시 제거
            update(cur, 1);               // 현재 위치에 표시
            last_pos[v] = cur;
            cur++;
        }
        ans[idx] = sum(r) - sum(l - 1);   // [l,r]의 표시 개수 = 서로 다른 수
    }
    for (int i = 0; i < q; i++) printf("%d\n", ans[i]);
}

\(r\)이 커질 때 같은 값이 다시 나오면 옛 위치의 1을 지우고 새 위치에 1을 둡니다.

import sys
input = sys.stdin.readline

def main():
    n = int(input())
    a = [0] + list(map(int, input().split()))   # 1-indexed
    q = int(input())
    Q = []
    for i in range(q):
        l, r = map(int, input().split())
        Q.append((r, l, i))
    Q.sort()                                     # r 오름차순

    bit = [0] * (n + 1)
    def upd(i, v):
        while i <= n:
            bit[i] += v; i += i & -i
    def pref(i):
        s = 0
        while i > 0:
            s += bit[i]; i -= i & -i
        return s

    ans = [0] * q
    last_pos = {}
    cur = 1
    for r, l, idx in Q:
        while cur <= r:
            v = a[cur]
            if v in last_pos:
                upd(last_pos[v], -1)
            upd(cur, 1)
            last_pos[v] = cur
            cur += 1
        ans[idx] = pref(r) - pref(l - 1)
    print('\n'.join(map(str, ans)))

흔한 실수

  • 답 저장 순서 — 질의를 정렬했으므로, 원래 입력 순서대로 출력하려면 질의
    번호를 함께 들고 다녀야 합니다. ans[idx]에 저장하는 이유입니다.
  • 포인터 비단조 이동 — 이 패턴은 \(r\)단조 증가할 때만 포인터 한 방향
    이동이 성립합니다. \(l\)도 임의로 움직여야 하면 Mo's 알고리즘이 필요합니다.
  • 갱신·복구 비대칭 — 삽입(+1)과 제거(-1)를 정확히 대칭으로 해야 상태가
    망가지지 않습니다.
  • 1-indexed — BIT는 1-indexed가 자연스럽습니다. sum(l-1)에서 \(l=1\)이면
    0을 반환하도록 인덱스를 맞추세요.
Lesson 3강 · 심화와 변형 — Mo's 알고리즘과 오프라인 동적 연결성 선택 8m

Mo's 알고리즘

양 끝 \(l, r\)모두 임의로 움직이고, "원소 하나 추가/제거"를 \(O(1)\)(또는
\(O(\log)\))에 할 수 있는 구간 질의에 쓰는 오프라인 기법입니다. 질의를 특별한
순서로 정렬해, 포인터 이동 총량을 \(O((N+Q)\sqrt N)\)로 억제합니다.

정렬 기준: 블록 크기 \(B \approx N/\sqrt Q\)로 나누고, \((l/B,\ r)\) 순으로
정렬합니다(홀짝 최적화를 하면 더 빠름). 그러면 \(l\)은 블록 안에서만 움직이고
\(r\)은 블록마다 단조 이동해 총 이동이 \(O((N+Q)\sqrt N)\)이 됩니다.

#include <bits/stdc++.h>
using namespace std;

int n, q, B;
vector<int> a;
long long curAns;
vector<int> cnt;              // 값별 등장 횟수(문제에 맞게)

void add(int x)    { /* a[x]를 구간에 넣을 때 curAns 갱신 */ cnt[a[x]]++; }
void remove(int x) { /* a[x]를 구간에서 뺄 때 curAns 갱신 */ cnt[a[x]]--; }

int main() {
    // 입력 후 B = max(1, (int)(n / sqrt(q + 1)))
    struct Q { int l, r, idx; };
    vector<Q> qs(q);            // 질의 읽어 채우기
    sort(qs.begin(), qs.end(), [&](const Q& A, const Q& C){
        int ba = A.l / B, bc = C.l / B;
        if (ba != bc) return ba < bc;
        return (ba & 1) ? A.r > C.r : A.r < C.r;   // 홀짝 최적화
    });
    vector<long long> ans(q);
    int L = 0, R = -1;          // 현재 구간 [L, R] (비어 있음)
    for (auto& Qi : qs) {
        while (R < Qi.r) add(++R);
        while (L > Qi.l) add(--L);
        while (R > Qi.r) remove(R--);
        while (L < Qi.l) remove(L++);
        ans[Qi.idx] = curAns;
    }
    for (auto v : ans) printf("%lld\n", v);
}

포인터 이동 순서 함정: 네 while의 순서가 중요합니다. 먼저 구간을
넓히고(add)
그 다음 좁히는(remove) 순서가 안전합니다. 위 코드처럼
R++ add → L-- add → R-- remove → L++ remove 순서를 지키면, 중간에 \(L > R\)
이 되어 음수 카운트가 생기는 사고를 피할 수 있습니다.

add/remove가 대칭이고 \(O(1)\)이어야 전체 \(O((N+Q)\sqrt N)\)가 나옵니다.
"구간 내 서로 다른 수", "구간 최빈값 근처" 같이 점진적 갱신이 쉬운 질의에
적합합니다. 반대로 갱신이 어려운 질의(예: 구간 중앙값)엔 부적합합니다.


오프라인 동적 연결성 (개요)

"간선이 시간 \(t_1\)에 생겼다가 \(t_2\)에 사라진다" + "특정 시점의 연결성 질의"를
오프라인으로 처리합니다. 시간 축을 세그먼트 트리로 보고, 각 간선을 그것이
살아 있는 시간 구간 \([t_1, t_2)\)에 해당하는 \(O(\log T)\)개의 노드에 매답니다.

그런 뒤 세그 트리를 DFS하며 노드에 걸린 간선을 롤백 가능한 유니온 파인드
(경로 압축 없이 union by rank + 되돌리기)로 합치고, 리프에서 질의에 답한 뒤
되돌립니다. 전체 \(O((N+Q)\log Q \cdot \alpha)\) 수준.

  • 경로 압축을 못 쓰는 이유: 구조를 파괴해 롤백이 불가능하기 때문(유니온 파인드
    단원의 롤백 DSU 참고).

병렬 이분 탐색 (개요)

각 질의가 "조건을 처음 만족하는 시점"을 이분 탐색으로 찾아야 하는데, 질의가
많아 개별 이분 탐색이 비싸면, 모든 질의의 이분 탐색을 동시에 진행합니다.
각 라운드에서 질의들을 현재 중앙값 시점으로 그룹지어, 이벤트를 시간순으로 한 번
적용하며 판정합니다. \(O((N+Q)\log)\) 라운드 총합으로 낮춥니다.


언제 무엇을 쓰나 — 요약

상황 기법
한쪽 끝만 단조 이동 정렬 + 포인터 스위프
양 끝 모두 임의 이동, 추가/제거 \(O(1)\) Mo's 알고리즘
질의별 이분 탐색을 동시에 병렬 이분 탐색
간선·원소에 수명이 있음 시간 세그 트리 + 롤백 DSU
강제 온라인 오프라인 불가 → 퍼시스턴트 자료구조

오프라인 사고는 "이 질의들, 순서를 바꾸면 더 싸게 답할 수 있나?"를 늘 자문하는
습관에서 나옵니다. 정렬·차분·스위프 세 도구의 조합을 자유롭게 다루는 것이
목표입니다.

Practice problem 건초 더미 선택 25m
KOI00005

건초 더미

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 동적 연결성 선택 25m
R00039

동적 연결성

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
05
Level 6 · Strategist

Strategist

자료 구조 · Strategist 단계

0/15 완료
Lesson 질의를 블록으로 정렬하는 오프라인 기법 필수 8m

Mo's 알고리즘이란

Mo's 알고리즘은 오프라인(질의를 미리 다 알고 있음), 갱신 없음, 그리고
"현재 구간에 원소 하나를 추가/제거하는 비용이 싼" 상황에서, 여러 구간 질의를
전부 답하는 평방분할 기반 기법이다.

대표 예: "구간 \([l, r]\)서로 다른 값 개수", "구간에서 어떤 값의 등장 횟수의
제곱합" 처럼, 세그먼트 트리로는 결합이 어렵지만 원소 하나를 넣고 빼며 답을
유지하기는 쉬운 질의들.

핵심 아이디어

현재 답이 구간 \([curL, curR]\)에 대해 유지되고 있다고 하자. 다음 질의 \([l, r]\)
가려면 curL, curR 두 포인터를 한 칸씩 움직이며 add/remove를 호출한다. 문제는
질의 순서다. 무작위 순서면 포인터가 총 \(O(NQ)\) 이동할 수 있다.

Mo's는 질의를 정렬한다. 배열을 크기 \(S\)의 블록으로 나누고,

  • 1순위: \(l\)이 속한 블록 번호 오름차순
  • 2순위: \(r\) 오름차순

으로 정렬한다. 그러면:

  • 같은 블록 안 질의들끼리는 \(r\)이 단조 증가하므로 curR 이동이 블록당 \(O(N)\),
    블록이 \(O(N/S)\)개라 \(r\) 이동 총합 \(O(N^2/S)\).
  • curL은 블록 안에서 \(O(S)\) 범위를 오가고 질의가 \(Q\)개라 \(l\) 이동 총합
    \(O(QS)\).

\(O(N^2/S + QS)\)\(S\)로 최소화하면 \(S = N/\sqrt{Q}\)에서

$$ O\big((N + Q)\sqrt{N}\big) $$

(정확히는 \(O(N\sqrt Q + Q\cdot N/\sqrt Q)=O(N\sqrt Q)\); \(N,Q\) 동차면
\(O((N+Q)\sqrt N)\)로 표기).

구체적 예시 — 구간 서로 다른 값 개수

배열 A, 카운트 배열 cnt[v](현재 구간에서 값 \(v\)의 등장 수)와 정답
distinct를 유지한다.

  • add(i): if (cnt[A[i]]++ == 0) distinct++;
  • remove(i): if (--cnt[A[i]] == 0) distinct--;

질의를 Mo 순서로 정렬하고 포인터를 옮기며 각 질의에서 distinct를 기록한다.
값 범위가 크면 cnt를 좌표 압축하거나 해시로.

언제 쓰고 언제 피하나

  • 쓴다: 오프라인, 갱신 없음, add/remove가 \(O(1)\)~\(O(\log)\), 세그트리 결합이
    까다로운 카운팅류.
  • 피한다: 온라인 질의, 잦은 점 갱신, add/remove가 비싼 경우, 혹은 세그먼트
    트리로 \(O(\log)\)에 깔끔히 되는 경우(그게 더 빠름).

변형 미리보기 (2·3강)

  • 홀짝 정렬: 같은 블록에서 \(r\) 방향을 번갈아 뒤집어 상수를 줄임.
  • Mo's with updates: 시간축을 더해 \(O(N^{5/3})\), 블록 크기 \(N^{2/3}\).
  • 트리에서의 Mo's: 오일러 투어로 트리를 배열로 펴서 경로 질의에 적용.
  • Hilbert curve 정렬: 포인터 이동을 더 줄이는 정렬 순서.
Lesson 구현: 포인터 이동 순서 선택 8m

기본 구현 — 포인터 이동 순서가 핵심

가장 흔한 버그는 포인터를 옮기는 순서다. 창(window)이 절대 "비었다가 다시
채워지는" 등 유효하지 않은 상태(예: curR < curL에서 remove)가 되지 않도록,
먼저 확장(add)하고 나중에 축소(remove)한다.

#include <bits/stdc++.h>
using namespace std;

int N, Q, S;                 // S = 블록 크기
int A[100005];
int cnt[1000006];            // 값 등장 수 (값 범위에 맞춰)
long long curAns = 0;        // 현재 구간의 답 (예: 서로 다른 값 개수)
long long ans[100005];       // 질의별 정답 (원래 순서로 저장)

struct Query { int l, r, idx; };
Query qs[100005];

inline void add(int i)    { if (cnt[A[i]]++ == 0) curAns++; }
inline void remove(int i) { if (--cnt[A[i]] == 0) curAns--; }

int main(){
    // ... N, A[1..N], Q, qs[] 입력 (l,r은 1-indexed) ...
    S = max(1, (int)(N / max(1.0, sqrt((double)Q))));   // 블록 크기 = N/sqrt(Q)

    sort(qs, qs + Q, [](const Query& a, const Query& b){
        int ba = a.l / S, bb = b.l / S;
        if (ba != bb) return ba < bb;
        // 홀짝 정렬: 같은 블록이면 짝수 블록은 r 오름차순, 홀수 블록은 내림차순
        return (ba & 1) ? (a.r > b.r) : (a.r < b.r);
    });

    int curL = 1, curR = 0;          // 빈 구간 [1,0]
    for (int q = 0; q < Q; q++) {
        int l = qs[q].l, r = qs[q].r;
        // 반드시 '확장 먼저, 축소 나중' — 창이 유효하지 않게 되는 것을 방지
        while (curR < r) add(++curR);       // 오른쪽 확장
        while (curL > l) add(--curL);       // 왼쪽 확장
        while (curR > r) remove(curR--);    // 오른쪽 축소
        while (curL < l) remove(curL++);    // 왼쪽 축소
        ans[qs[q].idx] = curAns;
    }
    // ... ans[0..Q-1] 출력 ...
}

왜 이 순서인가

add(++curR) / add(--curL)먼저 창을 넓힌 뒤 remove로 좁힌다. 만약
축소를 먼저 하면, 예컨대 curL을 오른쪽으로 옮기다 curL > curR가 되어 빈
창에서 add/remove가 꼬이거나 cnt가 음수가 될 수 있다. 확장 먼저 규칙이면
창은 항상 목표를 포함하는 유효 구간을 거친다.

4줄 순서 암기: while(curRl) add
while(curR>r) removewhile(curL<l) remove.

블록 크기 튜닝

  • 이론 최적: \(S = N/\sqrt{Q}\). 위 코드처럼 잡는다.
  • \(Q\)\(N\)보다 훨씬 작거나 크면 \(S=\sqrt N\) 고정보다 \(N/\sqrt Q\)가 유리.
  • 실전에선 \(S\)를 몇 배로 조정해 상수를 줄이기도 한다(캐시 효과).

흔한 버그

  • 포인터 이동 순서 — 위 4줄 순서를 지키지 않으면 cnt 음수/오답.
  • off-by-one / 인덱싱 — 1-indexed로 통일하고 초기 창을 [1,0](빈 상태)로.
    l==r(길이 1) 질의도 위 순서로 자연히 처리된다.
  • 정답 저장 위치 — 정렬로 순서가 섞이므로 반드시 qs[q].idx(원래 인덱스)에
    저장한다.
  • 값 범위/압축cnt 배열 크기가 값 범위를 덮어야 한다. 크면 좌표 압축.
  • add/remove의 답 갱신 — 증가/감소 조건(== 0, -- == 0)을 정확히.
Lesson 심화·응용: 갱신·트리·힐베르트 정렬 선택 8m

홀짝(even-odd) 정렬 최적화

같은 블록 안에서 \(r\)을 항상 오름차순으로 두면, 블록이 바뀔 때 curR가 오른쪽
끝에서 다시 왼쪽으로 되돌아온다. 블록마다 \(r\) 방향을 번갈아 뒤집으면(짝수
블록 오름, 홀수 블록 내림) 이 되돌림이 사라져 curR 이동이 눈에 띄게 줄어든다.
2강 코드의 비교 함수에 이미 반영돼 있으며, 정확성에는 영향이 없고 상수만
개선한다.

Hilbert curve 정렬

질의 \((l, r)\)을 2D 평면의 점으로 보고 힐베르트 곡선 순서로 정렬하면 포인터
이동 총량이 이론적으로 더 작아진다. 각 질의에 힐베르트 인덱스(d2xy 역함수)를
계산해 그 값으로 정렬한다. 구현이 길지만, 포인터 이동이 병목인 무거운 문제에서
효과가 있다. 일반 블록 정렬로 통과되면 굳이 쓸 필요는 없다.

Mo's with updates (갱신이 있는 Mo's)

점 갱신이 섞여 있으면 시간(update 순번) 을 세 번째 차원으로 추가한다. 질의를
(l의 블록, r의 블록, 시간) 순으로 정렬하고, 포인터 3개(curL, curR, curT)를
움직인다. curT를 앞/뒤로 옮기는 것은 "그 시점의 갱신을 적용/되돌리기"다.

  • 블록 크기 \(S = N^{2/3}\)로 잡으면 전체 복잡도 \(O(N^{5/3})\).
  • 시간 이동 시, 갱신 위치가 현재 창 \([curL,curR]\) 안이면 답에 반영, 밖이면 배열
    값만 바꾼다. 되돌리기 위해 각 갱신은 (위치, 이전값, 새값)을 안다.
// 개념 스케치 (핵심만): 시간 t의 갱신을 적용
void applyTime(int t, int curL, int curR) {
    auto &u = updates[t];               // {pos, oldVal, newVal}
    if (curL <= u.pos && u.pos <= curR) { remove(u.pos); A[u.pos] = u.newVal; add(u.pos); }
    else A[u.pos] = u.newVal;
    swap(u.oldVal, u.newVal);           // 다음에 되돌릴 수 있게 스왑
}
// curT<t 면 applyTime로 전진, curT>t 면 같은 함수로 후퇴(스왑 덕분에 대칭)

정렬: l/Sr/St. 세 번째 정렬로 시간 포인터도 대체로 단조 이동한다.

트리에서의 Mo's

트리의 경로 질의는 오일러 투어(진입/이탈 시각 st[v], en[v])로 트리를 배열로
편 뒤 Mo's를 적용한다. 경로 \(u\to v\)는 오일러 배열의 한 구간(+ LCA 보정)으로
바뀐다. 원소가 창에 "짝수 번 들어오면 무시"하는 토글 방식(add가 실제로는
존재 토글)이 표준이며, LCA는 별도 처리한다.

상각(amortized) 관점과 한계

  • Mo's의 \(O((N+Q)\sqrt N)\)포인터 총 이동량에 대한 보장이다. 개별 질의는
    느릴 수 있으나 전체 합이 보장된다(상각).
  • add/remove가 \(O(k)\)면 전체가 \(O((N+Q)\sqrt N \cdot k)\)가 되므로, add/remove를
    반드시 싸게 만들어야 한다. 답 유지가 \(O(\log)\)면 그만큼 곱해진다.
  • 온라인 질의나 잦은 갱신에는 부적합하다.

흔한 실수 총정리

  • 포인터 4줄 이동 순서(확장 먼저) 위반 → cnt 음수/오답.
  • 정답을 정렬 후 인덱스가 아닌 순회 순서로 저장.
  • 블록 크기를 \(\sqrt N\)로만 고정(질의 수에 안 맞으면 손해). \(N/\sqrt Q\) 권장.
  • with-updates에서 시간 이동 시 창 밖 갱신을 답에 반영(반영하면 안 됨).
  • 값 범위 초과로 cnt 배열 인덱스 아웃오브바운드.
Practice problem 구간에서 k 이하인 원소의 개수 선택 25m
R01485

구간에서 k 이하인 원소의 개수

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 구간에서 값이 [x, y]에 드는 원소의 개수 선택 25m
R01486

구간에서 값이 [x, y]에 드는 원소의 개수

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 버전을 보존하는 세그먼트 트리 필수 8m

퍼시스턴트 세그먼트 트리란

일반 세그먼트 트리는 갱신하면 이전 상태가 사라진다. 퍼시스턴트(persistent,
영속) 세그먼트 트리
는 갱신할 때마다 새 버전을 만들되, 바뀌지 않은 부분은
이전 버전과 공유한다. 그래서 과거의 어떤 버전이든 그대로 질의할 수 있다.

핵심 관찰: 세그먼트 트리에서 한 점을 갱신하면 루트에서 그 리프까지의 경로,
\(O(\log N)\)개의 노드만 값이 바뀐다. 나머지 노드는 그대로다. 따라서 새 버전은

  • 바뀌는 \(O(\log N)\)개의 노드만 새로 복제(path copying)하고,
  • 경로를 벗어난 서브트리는 이전 버전의 노드를 그대로 가리킨다.

이를 "경로 복사(path copying)"라 한다. 갱신 한 번에 새 노드 \(O(\log N)\)개,
버전 하나당 루트 하나.

버전0 루트 ──┐          버전1 루트 ──┐   (한 점 갱신)
            ▼                       ▼
          [ * ]                   [ * ]'  ← 새 노드
          /   \                   /   \
       [A]    [B]              [A]'   [B] ← [B]는 버전0과 공유
                               / \
                            ...   ...     ← 경로 위만 복제

복잡도

  • 갱신: 새 노드 \(O(\log N)\)개 생성, 시간 \(O(\log N)\).
  • 질의(특정 버전): 일반 세그트리와 동일 \(O(\log N)\).
  • 전체 메모리: 초기 구축 \(O(N)\) + 갱신 \(Q\)번 × \(O(\log N)\) =

$$ O\big((N + Q)\log N\big) $$

이것이 노드 풀 크기 산정의 근거이자, 이 자료구조의 가장 흔한 버그 원인이다
(2강에서 정확한 산정식).

대표 활용: 구간 k번째 수

가장 유명한 응용은 "정적 배열에서 구간 \([l, r]\)\(k\)번째 작은 수"다.

값을 좌표 압축한 뒤, 위치 \(i\)까지의 값 히스토그램을 버전으로 쌓는다. 즉
버전 \(i\) = "앞에서 \(i\)개 원소의 각 값 등장 횟수를 담은 세그먼트 트리". 버전
\(i\)는 버전 \(i-1\)에 "\(A_i\)의 값 위치를 +1" 갱신한 것이다.

그러면 구간 \([l, r]\)의 값 히스토그램은

$$ \text{version}[r] - \text{version}[l-1] $$

인데, 두 버전은 노드를 공유하므로 두 루트를 동시에 내려가며 각 노드에서
cnt[r] - cnt[l-1]을 좌측 자식 합으로 보고 k번째로 하강하면 된다. 질의당
\(O(\log N)\).

구체적 예시

A = [1, 3, 2, 3](값=압축값), 질의 "구간 \([2,4]\)의 2번째 작은 수".

  • version1 = {1:1}
  • version2 = {1:1, 3:1}
  • version3 = {1:1, 2:1, 3:1}
  • version4 = {1:1, 2:1, 3:2}

\([2,4]\) 히스토그램 = version4 − version1 = {2:1, 3:2}. 작은 값부터 누적하면
값 2에서 1개, 값 3에서 2개 → 2번째 작은 값은 3. 실제 \([2,4]=\{3,2,3\}\)
정렬은 \(\{2,3,3\}\)이고 2번째는 3, 일치.

언제 쓰나 / 롤백과의 구분

  • 과거 버전을 온라인으로 질의해야 하거나, 여러 버전에서 분기해야 할 때
    퍼시스턴트가 필요하다(구간 k번째, prefix 기반 카운팅, 버전 트리 등).
  • 단순히 "갱신했다가 되돌리기"만 필요하면 롤백(rollback)으로 충분하며
    메모리가 훨씬 적다(오프라인 동적 연결성에서 사용). 퍼시스턴스는 "모든 과거를
    동시에 살려둔다"는 점에서 롤백보다 강하고 비싸다.
Lesson 구현: 노드 풀과 구간 k번째 수 선택 8m

노드 풀과 할당자 (가장 중요한 부분)

포인터 대신 인덱스 기반 노드 풀을 쓴다. 이게 버그가 적고 빠르다.

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 100005;
// 노드 풀 크기 산정: 초기 구축 2N + 갱신마다 (log2 N + 1)~ 개.
// 안전하게 (2*N) + Q*(ceil(log2 N)+2) 정도. 여유롭게 N * 20 이상을 권장.
const int POOL = MAXN * 20 + 400000;

struct Node { int l, r; ll sum; } t[POOL];
int cnt = 0;                 // 다음 새 노드 인덱스 (0은 '널' 노드로 예약 가능)
int newNode(int L, int R, ll S) { t[++cnt] = {L, R, S}; return cnt; }

풀 크기가 부족하면 인덱스가 배열 밖을 침범해 오답/런타임 에러가 난다.
이 자료구조의 1순위 버그다. 갱신 횟수 \(Q\)\(\log N\)을 세어 넉넉히 잡자.
경험칙: (N + Q) * (log2 N + 2)에 안전 계수를 더한다.

초기 트리 구축

int build(int s, int e) {
    if (s == e) return newNode(0, 0, 0);
    int m = (s + e) / 2;
    int L = build(s, m), R = build(m + 1, e);
    return newNode(L, R, t[L].sum + t[R].sum);
}

퍼시스턴트 갱신 — 공유 자식을 절대 건드리지 말 것

이전 버전 prev를 바탕으로 위치 pos+val을 한 새 루트를 반환한다.
핵심 규칙: 재귀로 내려가기 전에 반드시 새 노드를 먼저 만들고, 갱신하지 않는
쪽 자식은 이전 노드를 그대로 가리킨다.

// prev 버전의 [s,e] 구간 노드를 받아, pos에 +val 한 새 노드 인덱스를 반환
int update(int prev, int s, int e, int pos, ll val) {
    if (s == e) return newNode(0, 0, t[prev].sum + val);
    int m = (s + e) / 2;
    int L = t[prev].l, R = t[prev].r;   // 기본은 이전 자식을 공유
    if (pos <= m) L = update(L, s, m, pos, val);      // 왼쪽만 새로
    else          R = update(R, m + 1, e, pos, val);  // 오른쪽만 새로
    return newNode(L, R, t[L].sum + t[R].sum);
}

절대 하면 안 되는 것: t[prev].l = update(...)처럼 이전 노드를 in-place로
수정
하는 것. 다른 버전이 그 노드를 공유하므로 과거가 오염된다.

버전 루트 배열과 구간 k번째

int root[MAXN];       // root[i] = 앞 i개를 반영한 버전의 루트
int N, M;             // N=원소 수, M=압축 값 종류 수
int A[MAXN];          // A[i] = 좌표 압축된 값 (1..M)

void buildAll() {
    root[0] = build(1, M);
    for (int i = 1; i <= N; i++)
        root[i] = update(root[i-1], 1, M, A[i], +1);
}

// version[r] - version[l-1] 두 루트를 동시 하강하며 k번째(1-indexed) 작은 값
int kth(int rl, int rr, int s, int e, int k) {   // rl=root[l-1], rr=root[r]
    if (s == e) return s;                          // 압축값 반환
    int m = (s + e) / 2;
    ll leftCnt = t[t[rr].l].sum - t[t[rl].l].sum;  // 왼쪽 절반의 원소 수
    if (k <= leftCnt) return kth(t[rl].l, t[rr].l, s, m, k);
    else              return kth(t[rl].r, t[rr].r, m + 1, e, k - leftCnt);
}

// 사용: int compressedVal = kth(root[l-1], root[r], 1, M, k);

두 버전을 동시에 내려가는 것이 핵심이다. 각 노드에서 "왼쪽 자식의 개수 차이"
가 왼쪽 절반에 든 \([l,r]\) 원소 수이므로, 그 값과 \(k\)를 비교해 좌/우로 간다.

점/구간 합 질의(특정 버전)

ll query(int node, int s, int e, int l, int r) {
    if (r < s || e < l) return 0;
    if (l <= s && e <= r) return t[node].sum;
    int m = (s + e) / 2;
    return query(t[node].l, s, m, l, r)
         + query(t[node].r, m + 1, e, l, r);
}
// 예: query(root[v], 1, M, a, b) → 버전 v에서 값 [a,b] 개수

흔한 버그 체크리스트

  • 풀 크기 부족 — 위 산정식 + 여유. cntPOOL을 넘는지 로컬에서 assert.
  • 공유 노드 in-place 수정 — 반드시 newNode로 복제 후 진행.
  • l-1 처리root[l-1], 즉 root[0](빈 트리)이 존재해야 한다.
  • 좌표 압축 — 값이 크면 압축 필수. 압축 인덱스와 원값 매핑을 분리.
  • 오버플로 — 개수 합이 클 수 있으면 sumlong long으로.
Lesson 심화·응용: 메모리 산정과 활용 선택 8m

다른 활용 유형

prefix 기반 카운팅

"위치 \(\le r\)이면서 값 \(\le x\)인 원소 수"는 query(root[r], 1, M, 1, x)
\(O(\log N)\). 2차원 점 지배(dominance) 카운팅을 온라인으로 처리하는 표준 도구다.
구간 \([l,r]\)root[r] - root[l-1] 두 버전 질의로.

K개 중 조건 만족 개수 / 구간 median

구간 k번째의 특수형(중앙값 \(k = \lceil (r-l+1)/2 \rceil\))이나, "구간에서 값이
\([a,b]\)인 것의 개수/합"도 같은 프레임으로 처리된다.

값 자체를 시간축으로 (버전 = 시각)

배열이 아니라 "시각 \(t\)에서의 상태"를 버전으로 쌓으면, 과거 임의 시각에 대한
질의를 온라인으로 답할 수 있다. 갱신이 여러 갈래로 분기하면(버전 트리) 특히
퍼시스턴스의 진가가 나온다.

메모리 산정 — 다시 강조

가장 자주 터지는 곳이므로 정리한다. 노드 소비량:

  • 초기 build(1, M): 완전 이진 트리 노드 수 \(\approx 2M\).
  • update: 리프까지 경로 상 새 노드 \(\lceil \log_2 M \rceil + 1\)개.
  • 갱신 총횟수 \(U\) (구간 k번째면 \(U = N\)).

$$ \text{POOL} \ge 2M + U(\lceil \log_2 M\rceil + 1) + \text{여유} $$

\(M = N = 10^5\)이면 \(2\times10^5 + 10^5 \times 18 \approx 2\times10^6\). 실전에선
상수 여유를 두어 N*20~N*22 정도로 잡는 편이 안전하다. 메모리 제한이
빠듯하면 초과 산정으로 MLE가 날 수 있으니, 너무 크게도 잡지 말고 실제 갱신
횟수에 맞춘다.

퍼시스턴스 vs 롤백 vs 완전 재구축

기법 과거 접근 메모리 상황
퍼시스턴트 임의 과거 버전 온라인 \(O((N+Q)\log N)\) 구간 k번째, 버전 분기
롤백(rollback) 스택 순서로만 되돌림 \(O(\text{연산})\) 오프라인 DFS, 동적 연결성
재구축 없음 \(O(N)\) 갱신이 드물 때

퍼시스턴스는 "모든 버전을 동시에 살린다"는 강력함의 대가로 메모리를 쓴다.
스택 규율(LIFO)로 충분하면 롤백이 훨씬 싸다.

퍼시스턴트 BIT / 기타

퍼시스턴트 아이디어는 세그먼트 트리 외에도 적용된다. 다만 BIT(펜윅)는 갱신
시 여러 위치가 바뀌어 경로 복사가 깔끔하지 않아, 퍼시스턴스에는 세그먼트 트리가
표준이다. 트라이(trie)에 퍼시스턴스를 얹어 "prefix XOR 최대" 등 오프라인/온라인
질의를 처리하는 퍼시스턴트 트라이도 같은 원리다.

구현 체크리스트

  1. 노드 풀 크기 = (N + 갱신수)*(log2 M + 2) + 여유. cnt <= POOL assert.
  2. update는 항상 새 노드 생성 후 한쪽 자식만 재귀, 반대쪽은 공유.
  3. root[0](빈 트리) 존재, root[l-1] 안전.
  4. 구간 k번째는 두 루트 동시 하강, 왼쪽 자식 개수 차이로 분기.
  5. 좌표 압축 매핑 정확히, sum은 필요 시 long long.
Practice problem 구간 K번째 수 선택 25m
R00445

구간 K번째 수

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 구간 내 기준 이하 개수 선택 25m
R00446

구간 내 기준 이하 개수

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — 트립과 균형의 원리 필수 8m

균형 이진 검색 트리란

이진 검색 트리(BST) 는 "왼쪽 자손 < 노드 < 오른쪽 자손" 순서를 지키는 트리로,
삽입·삭제·검색을 트리 높이 \(h\)에 비례하는 시간에 처리합니다. 문제는 삽입 순서가
나쁘면 트리가 사슬처럼 치우쳐 \(h = O(N)\)이 되는 것입니다.

균형 이진 검색 트리(balanced BST) 는 어떤 삽입/삭제 순서에도 높이를 \(O(\log N)\)
으로 유지해, 모든 연산을 \(O(\log N)\) 에 보장합니다. 종류로 AVL, 레드-블랙 트리,
트립(Treap), 스플레이 트리 등이 있습니다. 이 강의는 구현이 가장 간결하고
대회에서 널리 쓰이는 트립을 다룹니다.

연산 균형 BST 정렬 배열
삽입/삭제 \(O(\log N)\) \(O(N)\)
\(k\)번째 원소 \(O(\log N)\) \(O(1)\)
\(x\)의 순위 \(O(\log N)\) \(O(\log N)\)
임의 위치 삽입(수열) \(O(\log N)\) \(O(N)\)

트립 = 트리 + 힙

트립(Treap) 은 각 노드에 두 값을 줍니다.

  • 키(key): BST 성질을 따름(왼쪽 < 노드 < 오른쪽).
  • 우선순위(priority): 무작위 값. 힙 성질을 따름(부모 우선순위 >
    자식 우선순위).

키로는 BST, 우선순위로는 힙 — 두 성질을 동시에 만족하는 트리가 유일하게
결정됩니다(우선순위가 서로 다르면). 그리고 우선순위가 무작위이므로, 트리 모양은
"무작위 순서로 삽입한 BST"와 같아 기대 높이 \(O(\log N)\) 입니다.

불변식: (1) 중위 순회하면 키가 오름차순(BST). (2) 모든 노드의 우선순위가
자식보다 크다(max-heap). 무작위 우선순위가 균형을 확률적으로 보장한다.


두 가지 구현 방식

  • 회전(rotation) 기반: 삽입 후 힙 성질이 깨지면 좌/우 회전으로 올려 고칩니다.
    AVL·레드블랙과 비슷한 결.
  • 분할(split)·병합(merge) 기반: 트리를 키 기준으로 둘로 쪼개고(split),
    두 트리를 합치는(merge) 두 연산만으로 삽입·삭제·구간 연산을 모두 표현합니다.
    대회에서는 이쪽이 압도적으로 유용합니다(다음 강의 주력).

암시적 키 트립 — 수열을 다루다

트립의 진짜 강점은 암시적 키(implicit key) 입니다. 키를 "값"이 아니라
"현재 트리에서의 위치(순서)" 로 삼으면, 트립이 배열/수열을 표현합니다.
그러면 다음이 모두 \(O(\log N)\):

  • 임의 위치에 원소 삽입/삭제,
  • 구간 \([l, r]\) 뒤집기(reverse),
  • 구간 이동(잘라 붙이기), 구간 합/최값 질의(+lazy).

정렬 배열이 \(O(N)\) 걸리는 "임의 위치 삽입"과 "구간 뒤집기"를 로그 시간에 하는
것이 암시적 트립의 핵심 가치입니다. 다음 강의에서 split/merge와 암시적 키를
코드로 구현합니다.

Lesson 2강 · 구현 — split/merge와 암시적 키 선택 8m

순서 통계 트립 (C++) — split/merge, 검증된 코드

split(t, k, a, b)는 키 \(< k\)a, \(\ge k\)b로 나눕니다. merge(a, b)
a의 모든 키 < b의 모든 키일 때 합칩니다. 이 둘로 삽입·삭제·\(k\)번째를 모두
구현합니다. sz(서브트리 크기)를 유지해 순위/순서 통계를 지원합니다.

#include <bits/stdc++.h>
using namespace std;
mt19937 rng(1234);

struct Node {
    int key, pri, sz;
    Node *l, *r;
    Node(int k) : key(k), pri(rng()), sz(1), l(nullptr), r(nullptr) {}
};
int sz(Node* t) { return t ? t->sz : 0; }
void upd(Node* t) { if (t) t->sz = 1 + sz(t->l) + sz(t->r); }

// 키 < k는 a, 키 >= k는 b
void split(Node* t, int k, Node*& a, Node*& b) {
    if (!t) { a = b = nullptr; return; }
    if (t->key < k) { split(t->r, k, t->r, b); a = t; }
    else            { split(t->l, k, a, t->l); b = t; }
    upd(t);
}
Node* merge(Node* a, Node* b) {
    if (!a || !b) return a ? a : b;
    if (a->pri > b->pri) { a->r = merge(a->r, b); upd(a); return a; }
    else                 { b->l = merge(a, b->l); upd(b); return b; }
}
Node* insert(Node* t, int k) {
    Node *a, *b; split(t, k, a, b);
    return merge(merge(a, new Node(k)), b);
}
Node* erase(Node* t, int k) {                 // 한 개 제거
    Node *a, *b, *c;
    split(t, k, a, b); split(b, k + 1, b, c); // b는 키 == k인 노드들
    if (b) { Node* nb = merge(b->l, b->r); delete b; b = nb; }
    return merge(merge(a, b), c);
}
int kth(Node* t, int k) {                     // 1-indexed k번째 작은 키
    int lc = sz(t->l);
    if (k <= lc)      return kth(t->l, k);
    if (k == lc + 1)  return t->key;
    return kth(t->r, k - lc - 1);
}

int main() {
    Node* root = nullptr;
    for (int x : {5, 2, 8, 1, 9, 3}) root = insert(root, x);
    for (int i = 1; i <= 6; i++) cout << kth(root, i) << " ";   // 1 2 3 5 8 9
    cout << "\n";
    root = erase(root, 5);
    cout << "size=" << sz(root) << "\n";                         // 5
    for (int i = 1; i <= 5; i++) cout << kth(root, i) << " ";    // 1 2 3 8 9
    cout << "\n";
}

splitmerge 두 함수가 전부입니다. 삽입은 "쪼개고–끼우고–합치고", 삭제는
"해당 키를 떼어 자식끼리 합치고 되붙이기". sz를 유지하면 kth(순서 통계)와
순위 질의가 공짜로 따라옵니다.


암시적 키 트립 (C++) — 구간 뒤집기

키를 저장하지 않고 서브트리 크기로 위치를 계산합니다. split(t, k, ...)
"앞 \(k\)개"와 "나머지"로 나눕니다. rev 지연 플래그로 구간 뒤집기\(O(\log N)\)에.

#include <bits/stdc++.h>
using namespace std;
mt19937 rng(7);

struct Node {
    long long val; int pri, sz; bool rev;
    Node *l, *r;
    Node(long long v) : val(v), pri(rng()), sz(1), rev(false), l(nullptr), r(nullptr) {}
};
int sz(Node* t) { return t ? t->sz : 0; }
void upd(Node* t) { if (t) t->sz = 1 + sz(t->l) + sz(t->r); }
void push(Node* t) {                          // 지연 뒤집기 전파
    if (t && t->rev) {
        swap(t->l, t->r);
        if (t->l) t->l->rev ^= 1;
        if (t->r) t->r->rev ^= 1;
        t->rev = false;
    }
}
// 앞 k개를 a, 나머지를 b (암시적 키 = 위치)
void split(Node* t, int k, Node*& a, Node*& b) {
    if (!t) { a = b = nullptr; return; }
    push(t);
    if (sz(t->l) < k) { split(t->r, k - sz(t->l) - 1, t->r, b); a = t; }
    else              { split(t->l, k, a, t->l); b = t; }
    upd(t);
}
Node* merge(Node* a, Node* b) {
    if (!a || !b) return a ? a : b;
    if (a->pri > b->pri) { push(a); a->r = merge(a->r, b); upd(a); return a; }
    else                 { push(b); b->l = merge(a, b->l); upd(b); return b; }
}
void reverse(Node*& root, int l, int r) {     // 0-based [l, r] 뒤집기
    Node *a, *b, *c;
    split(root, l, a, b);
    split(b, r - l + 1, b, c);
    if (b) b->rev ^= 1;
    root = merge(merge(a, b), c);
}
void print(Node* t) { if (!t) return; push(t); print(t->l); cout << t->val << " "; print(t->r); }

int main() {
    Node* root = nullptr;
    for (int i = 1; i <= 6; i++) root = merge(root, new Node(i));  // 1 2 3 4 5 6
    reverse(root, 1, 4);                                           // [1..4] 뒤집기
    print(root); cout << "\n";                                    // 1 5 4 3 2 6
}

push로 지연 뒤집기를 내려보내는 것이 핵심입니다. split/merge가 노드를 만질
때마다 push를 먼저 호출해, 뒤집힘 상태를 정확히 반영합니다. 구간 합·최값도
같은 방식으로 upd에 집계, push에 lazy를 얹어 확장합니다.

파이썬 주의

파이썬은 재귀 트립이 느리고 재귀 한도에 걸리기 쉽습니다. 실전에선 C++를 쓰거나,
정렬 자료구조가 필요하면 sortedcontainers.SortedList(순서 통계·이분 탐색
\(O(\log N)\))로 대체하는 편이 현실적입니다.

Lesson 3강 · 심화·변형 — 회전, 함정, 대안 선택 8m

변형 — 회전 기반 트립

split/merge 대신 회전(rotation) 으로 힙 성질을 유지하는 방식도 있습니다.
삽입 시 리프까지 BST로 내려가 새 노드를 달고, 부모보다 우선순위가 크면 회전으로
끌어올립니다.

Node* rotateRight(Node* t) { Node* l = t->l; t->l = l->r; l->r = t; upd(t); upd(l); return l; }
Node* rotateLeft (Node* t) { Node* r = t->r; t->r = r->l; r->l = t; upd(t); upd(r); return r; }

Node* insert(Node* t, int k) {
    if (!t) return new Node(k);
    if (k < t->key) {
        t->l = insert(t->l, k);
        if (t->l->pri > t->pri) t = rotateRight(t);   // 힙 성질 회복
    } else {
        t->r = insert(t->r, k);
        if (t->r->pri > t->pri) t = rotateLeft(t);
    }
    upd(t);
    return t;
}

회전형은 삽입·삭제엔 직관적이지만, 구간 뒤집기/이동 같은 수열 연산에는
split/merge가 훨씬 자연스럽습니다. 대회에선 split/merge 트립을 기본으로 익히세요.


흔한 함정

  • push 누락(암시적 트립) — 지연 플래그(rev, add 등)를 쓰는데 split/merge에서
    노드를 만지기 전에 push를 안 하면 오래된 상태로 쪼개져 값이 뒤섞입니다.
    가장 악명 높은 버그입니다. "노드를 만지기 직전 push" 규칙을 반드시.
  • upd 누락 — 자식이 바뀐 뒤 upd(sz·집계 재계산)를 빠뜨리면 kth·순위·구간
    집계가 틀립니다. split/merge/rotate의 끝에서 항상 upd.
  • 우선순위 충돌 — 같은 우선순위가 많으면 균형이 깨질 수 있습니다. 넓은 범위의
    무작위(mt19937)를 쓰고, 시드를 고정해 재현성을 확보하세요.
  • 중복 키 처리erase에서 split(b, k+1, ...)로 "키 == k"만 정확히
    떼어내는지 확인. 다중 집합인지 집합인지에 따라 삭제 개수를 명확히.
  • 재귀 깊이/스택 — 기대 높이는 \(O(\log N)\)이지만 최악엔 깊어질 수 있습니다.
    큰 입력에서 재귀 스택을 넉넉히. 파이썬 재귀 트립은 특히 위험.
  • 메모리 해제 — 오래 도는 프로그램/다중 테스트면 delete로 노드를 회수하거나
    풀(pool) 할당으로 관리하세요.

균형 BST vs 다른 자료구조 — 선택

필요 추천
삽입·삭제 + \(k\)번째/순위 트립 / SortedList(py) / 펜윅+좌표압축
구간 뒤집기·이동이 있는 수열 암시적 키 트립(거의 유일한 간단 해법)
정적 집합의 순위/개수 펜윅 + 좌표 압축(더 빠름)
병합/분할이 잦은 집합 트립(merge/split)
영속(과거 버전) 질의 영속 트립/영속 세그

핵심 판단: "값이 동적으로 삽입·삭제되며 순위/순서를 물어야" 하면 균형 BST를,
그중에서도 "수열의 구간을 뒤집거나 통째로 옮겨야" 하면 암시적 키 트립
떠올리세요. 단순히 "값의 순위/개수"만 필요하고 값이 정수 범위면, 좌표 압축 +
펜윅 트리가 구현이 더 짧고 상수가 작아 종종 낫습니다.


정리

트립은 "무작위 우선순위로 균형을 확률적으로 보장하는 BST"이며, split과 merge
두 연산이 삽입·삭제부터 구간 뒤집기까지 모든 것을 표현합니다. 암시적 키로 위치를
키 삼으면 배열이 지원하지 못하는 로그 시간 임의 위치 삽입·구간 뒤집기
얻습니다. push/upd 규율만 지키면, 트립은 대회에서 가장 다재다능한 균형 BST입니다.

Practice problem 버전을 넘나드는 로프 선택 25m
R00681

버전을 넘나드는 로프

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem Upit 선택 25m
COCI00192

Upit

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
06
Level 7 · Specialist

Specialist

자료 구조 · Specialist 단계

0/15 완료
Lesson 동적 CHT와 Li Chao 트리 필수 8m

리 차오 트리란

리 차오 트리(Li Chao Tree)는 직선(또는 반직선/선분) 여러 개를 임의 순서로
삽입
하고, 특정 \(x\)에서 최솟값(또는 최댓값) 을 질의하는 자료 구조다. 즉
동적(dynamic) Convex Hull Trick(CHT)이다.

$$ \text{삽입: } y = a x + b \text{ 를 추가} \qquad \text{질의: } \min_i (a_i x + b_i) $$

왜 필요한가 — 일반 CHT의 한계

단조 CHT(스택 기반)는 삽입되는 기울기가 단조이고 질의 \(x\)도 단조여야
\(O(1)\)/\(O(\log)\)에 동작한다. DP 최적화에서 이 단조성이 깨지면(기울기 순서·질의
순서가 마구잡이) 스택 CHT는 쓸 수 없다. 리 차오 트리는 삽입 순서·질의 순서에
아무 제약이 없다
.

핵심 아이디어

\(x\) 좌표 범위 \([lo, hi]\)에 대한 세그먼트 트리를 만든다. 각 노드는 자기 구간에서
중점(mid)에서 가장 좋은(작은) 직선을 하나 보관한다.

새 직선을 삽입할 때:

  1. 노드 중점 \(m\)에서 새 직선과 기존 직선을 비교해, \(m\)에서 더 좋은 직선을
    노드에 남긴다
    (swap).
  2. 남지 못한(패배한) 직선은 왼쪽 절반 또는 오른쪽 절반 한쪽에서만 여전히
    더 좋을 수 있다. 두 직선은 최대 한 번 교차하기 때문. 그 절반으로 재귀한다.
  3. 어느 쪽으로 갈지는 왼쪽 끝에서의 승패중점에서의 승패가 다른지로
    결정한다(교차가 왼쪽에 있으면 왼쪽으로).

질의는 루트에서 \(x\)가 속한 리프까지 내려가며 경로상 모든 노드의 직선을 \(x\)에서
평가한 값의 최솟값을 취한다.

복잡도

  • 삽입: \(O(\log C)\) (\(C\) = 좌표 범위 크기 또는 압축 후 좌표 수)
  • 질의: \(O(\log C)\)
  • 메모리: 배열 버전 \(O(C)\), 동적 노드 버전은 실제 사용 노드 수.

구체적 예시 (min)

범위 \([0, 4]\). 직선 \(L_1: y = 2x + 0\), \(L_2: y = -x + 4\)를 삽입.

  • \(x=0\): \(L_1=0, L_2=4\)\(L_1\)이 유리.
  • \(x=4\): \(L_1=8, L_2=0\)\(L_2\)가 유리.
  • 두 직선은 \(2x = -x+4 \Rightarrow x = 4/3\)에서 교차. 질의 min at x=1 = \(\min(2, 3)=2\)(\(L_1\)),
    min at x=3 = \(\min(6, 1)=1\)(\(L_2\)). 리 차오 트리는 삽입 순서와 무관하게 이
    값을 \(O(\log C)\)에 답한다.

언제 쓰나

  • DP 전이가 \(dp[i] = \min_j (a_j x_i + b_j)\) 꼴이고 \(a_j\)\(x_i\)단조가
    아닐 때
    .
  • 직선 집합에 대한 최소/최대 포락선을 온라인으로 유지해야 할 때.
  • 좌표가 크거나 음수면 동적 노드 또는 좌표 압축으로.

기울기와 질의가 모두 단조라면 스택 CHT가 상수도 작고 더 간단하다. 리 차오는
"제약 없는 삽입/질의"라는 유연성의 대가로 로그가 붙는다.

Lesson 구현: 배열·동적 노드 버전 선택 8m

배열 버전 구현 (최솟값)

좌표 범위 \([lo, hi]\)가 정수이고 그리 크지 않을 때(또는 압축했을 때).

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = (ll)4e18;

struct Line {
    ll a, b;                         // y = a*x + b
    ll f(ll x) const { return a * x + b; }
};

const int SZ = 1 << 20;              // 좌표 구간 크기에 맞춰
Line tree[4 * SZ];

void initTree(int n) {               // 모든 노드를 '무한대 직선'으로
    for (int i = 0; i < 4 * n; i++) tree[i] = { 0, INF };
}

// [lo,hi] 노드에 새 직선 nw 삽입 (최솟값 포락선 유지)
void insert(int node, int lo, int hi, Line nw) {
    int mid = lo + (hi - lo) / 2;
    bool lef = nw.f(lo)  < tree[node].f(lo);    // 왼쪽 끝에서 새 직선이 더 좋은가
    bool mi  = nw.f(mid) < tree[node].f(mid);   // 중점에서 새 직선이 더 좋은가
    if (mi) swap(tree[node], nw);               // 중점 승자를 노드에 남김
    if (lo == hi) return;
    if (lef != mi) insert(2*node,   lo,    mid, nw);   // 교차가 왼쪽 → 왼쪽으로
    else           insert(2*node+1, mid+1, hi,  nw);   // 아니면 오른쪽으로
}

// x에서의 최솟값
ll query(int node, int lo, int hi, ll x) {
    ll res = tree[node].f(x);
    if (lo == hi) return res;
    int mid = lo + (hi - lo) / 2;
    if (x <= mid) return min(res, query(2*node,   lo,    mid, x));
    else          return min(res, query(2*node+1, mid+1, hi,  x));
}

lef != mi로 방향을 정하나

swap 이후 노드에는 "중점에서 이긴 직선"이 있고, 패배해 내려갈 직선 nw는 최대
한 번 교차하므로 한쪽 절반에서만 이길 수 있다. 왼쪽 끝 승패(lef)와 중점
승패(mi)가 다르면 교차점이 왼쪽 절반에 있어 그쪽에서 nw가 이길 여지가
있으므로 왼쪽으로, 같으면 오른쪽으로 내려간다. 이 방향 판단이 리 차오 트리의
1순위 버그 지점
이다.

최댓값이 필요하면

두 가지 방법:
1. 직선 부호 뒤집기: \(y = a\ x + b\) 대신 \(-a, -b\)로 삽입하고 질의 결과에 \(-1\)
곱한다.
2. 비교를 >로, 무한대 초기값을 \(-\infty\)로 바꾼다.

1번이 코드 재사용에 편하고 실수가 적다.

동적 노드 버전 (좌표가 크거나 음수)

\(x \in [-10^9, 10^9]\)처럼 범위가 커서 배열을 못 잡을 때. 노드를 필요할 때만
생성한다.

struct DNode { Line line; int l = -1, r = -1; };
vector<DNode> t;                     // t[0] = 루트
int newNode() { t.push_back({ {0, INF}, -1, -1 }); return (int)t.size()-1; }

void insert(int node, ll lo, ll hi, Line nw) {
    ll mid = lo + (hi - lo) / 2;     // 음수 안전 (floor div 주의: 아래 참고)
    bool lef = nw.f(lo)  < t[node].line.f(lo);
    bool mi  = nw.f(mid) < t[node].line.f(mid);
    if (mi) swap(t[node].line, nw);
    if (lo == hi) return;
    if (lef != mi) {
        if (t[node].l < 0) { int c = newNode(); t[node].l = c; }
        insert(t[node].l, lo, mid, nw);
    } else {
        if (t[node].r < 0) { int c = newNode(); t[node].r = c; }
        insert(t[node].r, mid+1, hi, nw);
    }
}
ll query(int node, ll lo, ll hi, ll x) {
    if (node < 0) return INF;
    ll res = t[node].line.f(x);
    if (lo == hi) return res;
    ll mid = lo + (hi - lo) / 2;
    if (x <= mid) return min(res, query(t[node].l, lo,   mid, x));
    else          return min(res, query(t[node].r, mid+1, hi, x));
}
// 사용: t.clear(); newNode(); insert(0, LO, HI, {a,b}); query(0, LO, HI, x);

음수 좌표의 중점: (lo + hi) / 2는 C++에서 0 방향 반올림이라 음수에서
어긋날 수 있다. lo + (hi - lo) / 2를 쓰면 항상 \(lo \le mid < hi\)가 되어 좌
반열이 비지 않고, hi - lo가 음수가 아니므로 안전하다.

오버플로

a * x가 크면 long long으로도 넘칠 수 있다. 기울기·좌표가 \(10^9\) 규모이고
값도 크면 __int128f를 계산하거나 범위를 미리 점검한다. INF는 두 직선
값보다 확실히 크게(예: 4e18) 잡되, 덧셈에서 오버플로하지 않도록 주의한다.

Lesson 심화·응용: 선분 삽입과 도구 선택 선택 8m

선분 삽입 변형 (\(O(\log^2 C)\))

직선이 아니라 특정 \(x\) 구간 \([ql, qr]\)에서만 유효한 선분을 넣고 싶을 때가
있다(예: 특정 구간에서만 후보가 되는 전이). 세그먼트 트리에 구간을 얹듯이,
\([ql, qr]\)\(O(\log C)\)개의 노드로 분해하고, 각 노드에 대해 그 노드 구간
전체에 대한 리 차오 삽입
을 수행한다. 노드당 삽입이 \(O(\log C)\)라 총
\(O(\log^2 C)\).

// [ql,qr] 구간에만 유효한 선분 nw 삽입
void insertSeg(int node, int lo, int hi, int ql, int qr, Line nw) {
    if (qr < lo || hi < ql) return;
    if (ql <= lo && hi <= qr) { insert(node, lo, hi, nw); return; } // 이 노드 전체에 삽입
    int mid = lo + (hi - lo) / 2;
    insertSeg(2*node,   lo,    mid, ql, qr, nw);
    insertSeg(2*node+1, mid+1, hi,  ql, qr, nw);
}

주의: insert가 세그먼트 트리 노드 인덱스를 공유하므로, 위 배열 버전과 인덱싱을
맞춰야 한다(같은 tree[], 같은 2*node/2*node+1 규칙). 질의는 그대로.

다른 CHT 도구와의 비교

도구 삽입 순서 질의 순서 시간 비고
단조 스택 CHT 기울기 단조 단조 상각 \(O(1)\)/\(O(\log)\) 가장 빠름, 제약 큼
Li Chao 트리 임의 임의 \(O(\log C)\) 유연, 좌표 범위 의존
set 기반 동적 CHT (Kinetic-free) 임의 임의 \(O(\log n)\) 삭제 어려움, 상수 큼
키네틱 세그먼트 트리 (라인 갱신) 증가하는 질의 상각 \(O(\log^2 n)\) 질의 단조 전제

리 차오는 좌표 범위 \(C\)에 로그가 붙는 반면, 키네틱 세그먼트 트리는 질의점이
단조 증가한다는 전제 하에 라인 갱신과 함께 쓰인다. 문제 조건(삽입/질의 단조성,
좌표 범위)에 따라 도구를 고른다.

흔한 버그 정리

  • swap 이후 재귀 방향lef != mi이면 왼쪽, 아니면 오른쪽. lef, mi
    swap 이전 값으로 계산해야 한다(코드에서 swap 전에 이미 계산됨).
  • min vs max 혼동 — 최댓값 문제에 min 초기값(+INF)을 두면 전부 그 값이
    나온다. 부호 뒤집기 방식으로 통일하면 실수가 준다.
  • 중점 계산lo + (hi-lo)/2로 음수/오버플로 안전, 좌반열이 비지 않게.
  • 좌표 범위 / 압축 — 배열 버전은 \([lo,hi]\)가 실제 질의 \(x\)를 모두 포함해야.
    포함 안 하면 리프에서 잘못된 값. 넓으면 동적 노드.
  • 오버플로a*xlong long/__int128. INF는 덧셈 오버플로 안 나게.
  • 초기화 — 모든 노드를 무한대 직선으로. 재사용 시 초기화 누락 주의.

요약

  1. 각 노드는 "중점에서 최적인 직선"을 보유, 패자는 한쪽 절반으로 재귀.
  2. 삽입/질의 \(O(\log C)\), 삽입·질의 순서에 제약 없음.
  3. 방향 판단(lef != mi)이 핵심 버그 지점.
  4. 큰/음수 좌표는 동적 노드, 최댓값은 부호 뒤집기, 선분은 \(O(\log^2 C)\).
  5. 단조 CHT가 가능하면 그쪽이 더 빠름 — 조건 보고 선택.
Practice problem 직선 추가와 최댓값 질의 선택 25m
R00408

직선 추가와 최댓값 질의

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 신호 중계 최소 비용 선택 25m
R00616

신호 중계 최소 비용

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 자가 조정 BBST: 스플레이 트리 필수 8m

스플레이 트리란

스플레이 트리(Splay Tree)는 자가 조정(self-adjusting) 이진 탐색 트리다.
어떤 노드에 접근할 때마다 그 노드를 회전(rotation)으로 루트까지 끌어올리는
splay 연산을 수행한다. 이로써 최근에 접근한 원소가 위로 올라와, 별도의 균형
정보(높이·색) 없이도 모든 연산이 상각 \(O(\log N)\)이 된다.

$$ \text{탐색·삽입·삭제·split·merge: 상각 } O(\log N) $$

"상각"이 핵심이다. 개별 연산은 \(O(N)\)까지 느릴 수 있으나(편향된 트리 한
번 접근), 임의의 연산 수열 전체 평균이 \(O(\log N)\)이다.

splay: 세 가지 회전 패턴

노드 \(x\), 부모 \(p\), 조부모 \(g\)에 대해:

  • zig (\(p\)가 루트): \(x\)를 한 번 회전.
  • zig-zig (\(x\)\(p\)가 같은 방향 자식): \(p\)를 먼저 회전한 뒤 \(x\)를 회전.
  • zig-zag (다른 방향 자식): \(x\)를 두 번 회전.

이 "zig-zig는 부모 먼저"라는 규칙이 스플레이의 상각 \(O(\log N)\)을 만든다(단순히
\(x\)만 반복 회전하는 방식은 보장이 약하다). 정당성은 접근 보조정리(access lemma)
의 포텐셜 논증으로 증명된다.

zig-zig (x,p 모두 왼쪽 자식):        zig-zag (x 오른쪽, p 왼쪽):
      g            x                    g              x
     /            / \                  /             /   \
    p     →      A   p       →         p      →      p     g
   / \              / \              / \            / \   / \
  x  C            B    g            A   x          A  B  C  D
 / \                  / \              / \
A  B                 C  D             B  C

언제 쓰나

  • 순서 있는 집합에 split/merge가 잦을 때(구간 잘라 붙이기).
  • 암시적(implicit) 키 수열: 배열의 위치를 키로 삼아 "구간 뒤집기(reverse)",
    구간 덧셈, 구간 합, k번째 원소, 구간 이동 등을 \(O(\log N)\)에.
  • 링크 컷 트리(Link-Cut Tree)의 보조 트리(aux tree)로 쓰여 동적 트리를 구현.

복잡도

모든 기본 연산이 상각 \(O(\log N)\). 메모리 \(O(N)\). 무작위성이 필요 없다(트립과
대조).

구체적 예시

원소 \(\{1,2,3,4,5\}\)가 편향된 사슬로 들어 있어 5를 접근하면 그 한 번은 깊이만큼
느릴 수 있다. 하지만 접근 직후 splay가 5를 루트로 올리고 경로를 절반으로 접어,
이후 접근들이 빨라진다. 이렇게 "비싼 접근이 트리를 스스로 정리"하는 것이 상각
보장의 직관이다.

암시적 키 수열 예시

배열 [a,b,c,d,e]를 스플레이 트리로 들고 "구간 \([2,4]\) 뒤집기"를 하면
[a,d,c,b,e]가 된다. 위치를 키로 쓰되 실제 키 값을 저장하지 않고, 각 노드의
서브트리 크기로 위치를 계산한다. 구간을 루트 근처로 모은(gather) 뒤 그
서브트리에 reverse lazy를 걸면 \(O(\log N)\)에 뒤집힌다(2강에서 구현).

Lesson 구현: 회전·splay와 암시적 수열 선택 8m

노드 구조와 lazy

암시적 수열(구간 뒤집기 + 구간 덧셈 + 구간 합)을 지원하는 스플레이 트리다.
인덱스 기반 노드, 0은 널 센티넬(크기·합 0).

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 200005;

struct Node {
    int ch[2], par, sz;
    ll val, sum, lazyAdd;
    bool rev;
} t[MAXN];
int root, cnt;

void pull(int x) {                        // 자식으로부터 크기·합 재계산
    t[x].sz  = 1 + t[t[x].ch[0]].sz + t[t[x].ch[1]].sz;
    t[x].sum = t[x].val + t[t[x].ch[0]].sum + t[t[x].ch[1]].sum;
}
void applyRev(int x) { if(!x) return; swap(t[x].ch[0], t[x].ch[1]); t[x].rev ^= 1; }
void applyAdd(int x, ll v) {
    if(!x) return;
    t[x].val += v; t[x].sum += v * t[x].sz; t[x].lazyAdd += v;
}
void push(int x) {                        // lazy 를 자식으로 내림 (하강 전에 반드시)
    if (t[x].rev) { applyRev(t[x].ch[0]); applyRev(t[x].ch[1]); t[x].rev = 0; }
    if (t[x].lazyAdd) { applyAdd(t[x].ch[0], t[x].lazyAdd); applyAdd(t[x].ch[1], t[x].lazyAdd); t[x].lazyAdd = 0; }
}
int dir(int x) { return t[t[x].par].ch[1] == x; }   // x가 오른쪽 자식이면 1

rotate 와 splay

void rotate(int x) {
    int p = t[x].par, g = t[p].par, d = dir(x);
    if (g) t[g].ch[dir(p)] = x;            // g가 p 대신 x를 가리킴 (p가 루트면 생략)
    t[x].par = g;
    t[p].ch[d] = t[x].ch[d ^ 1];           // x의 반대쪽 자식을 p에게 넘김
    if (t[x].ch[d ^ 1]) t[t[x].ch[d ^ 1]].par = p;
    t[x].ch[d ^ 1] = p;  t[p].par = x;
    pull(p); pull(x);                      // 아래→위 순서로 재계산
}
void splay(int x, int goal = 0) {          // x를 goal의 자식이 되도록 끌어올림
    // 1) root→x 경로의 lazy 를 먼저 모두 내림
    static int stk[MAXN]; int top = 0;
    for (int u = x; u; u = t[u].par) stk[top++] = u;
    while (top) push(stk[--top]);
    // 2) zig / zig-zig / zig-zag
    while (t[x].par != goal) {
        int p = t[x].par, g = t[p].par;
        if (g != goal) rotate(dir(x) == dir(p) ? p : x);   // 같은 방향이면 p 먼저
        rotate(x);
    }
    if (!goal) root = x;
}

핵심 규칙 두 가지:
- 하강 전 push: 회전·탐색으로 내려가기 전에 그 경로의 reverse/add lazy를 모두
내려야 한다. 위 splay는 root→x 경로를 스택에 담아 위에서부터 push한다.
- zig-zig는 부모 먼저: dir(x)==dir(p)rotate(p)rotate(x); 아니면
rotate(x) 두 번. 이 순서를 어기면 상각 보장이 깨진다.

k번째 원소 찾기 (splay 하지 않음)

int kth(int k) {                            // 1-indexed, k번째 노드 반환
    int x = root;
    while (x) {
        push(x);                            // 하강 전 push
        int ls = t[t[x].ch[0]].sz;
        if (k <= ls) x = t[x].ch[0];
        else if (k == ls + 1) return x;
        else { k -= ls + 1; x = t[x].ch[1]; }
    }
    return 0;
}

초기 구축 (양끝 더미 포함)

앞뒤에 더미 노드를 두면 구간 연산의 경계 처리가 사라진다. 배열 a[1..n]
val = [dummy, a1, ..., an, dummy](패딩 후 위치 \(1..n+2\))로 균형 트리 구축.

int build(int lo, int hi, int par, const vector<ll>& v) {
    if (lo > hi) return 0;
    int m = (lo + hi) / 2, x = ++cnt;
    t[x].par = par; t[x].val = v[m];
    t[x].ch[0] = build(lo, m - 1, x, v);
    t[x].ch[1] = build(m + 1, hi, x, v);
    t[x].rev = 0; t[x].lazyAdd = 0;
    pull(x);
    return x;
}
// 사용: t[0] = {}; cnt = 0; root = build(0, n+1, 0, paddedVal);

구간 모으기(gather) — reverse / add / sum

실제 원소 1-indexed 구간 \([l, r]\)은 패딩 위치로 \(l+1..r+1\)이다. 왼쪽 경계
노드(위치 \(l\))를 루트로 splay하고, 오른쪽 경계 노드(위치 \(r+2\))를 그 아래로
splay하면, 오른쪽 경계 노드의 왼쪽 자식이 정확히 구간 \([l,r]\)의 서브트리다.

int gather(int l, int r) {                  // 실제 [l,r] 서브트리 루트 반환
    int a = kth(l);      splay(a, 0);        // 위치 l  → 루트
    int b = kth(r + 2);  splay(b, a);        // 위치 r+2 → 루트의 (오른쪽) 자식
    return t[b].ch[0];                       // b의 왼쪽 자식 = [l,r]
}
void reverse(int l, int r) {
    int s = gather(l, r);
    applyRev(s);
    int b = t[s].par, a = t[b].par;
    pull(b); pull(a);                        // 조상 재계산
}
void rangeAdd(int l, int r, ll v) {
    int s = gather(l, r);
    applyAdd(s, v);
    int b = t[s].par, a = t[b].par;
    pull(b); pull(a);
}
ll rangeSum(int l, int r) {
    int s = gather(l, r);
    return t[s].sum;
}

gather 뒤 서브트리 \(s\)에 lazy만 걸고, 그 부모 두 개(b, a)를 다시 pull하면
구간 연산이 \(O(\log N)\) 상각에 끝난다. reverse는 뒤집기 표시만, add는 합에도
반영된다.

Lesson 심화·응용: 순서 집합·LCT·트립 비교 선택 8m

순서 집합(ordered set) 방식

암시적 수열 대신 키 값으로 정렬된 집합이 필요하면, 삽입/삭제 후 해당 노드를
splay하는 표준 BST 연산을 쓴다. 핵심은 "접근한 노드는 항상 splay로 루트에
올린다"는 것.

// 값 key 삽입 (중복 없다고 가정)
void insertKey(ll key) {
    if (!root) { int x = ++cnt; t[x] = {}; t[x].val = key; pull(x); root = x; return; }
    int x = root, p = 0;
    while (x) { push(x); p = x; x = t[x].ch[key > t[x].val]; }
    int y = ++cnt; t[y] = {}; t[y].val = key; t[y].par = p;
    t[p].ch[key > t[p].val] = y; pull(p);
    splay(y);                                 // 삽입한 노드를 루트로
}
// find 후 splay: 찾은(또는 마지막) 노드를 루트로 올려 상각 보장

삭제는 대상 노드를 splay해 루트로 올린 뒤, 왼쪽 서브트리의 최댓값을 splay해
두 서브트리를 merge한다(전형적 split/merge 패턴).

링크 컷 트리(LCT)의 기반

스플레이 트리의 대표적 고급 응용은 링크 컷 트리다. 실제 트리를 "선호
경로(preferred path)"들로 쪼개고, 각 경로를 splay 트리(보조 트리)로 관리한다.
access 연산이 splay를 여러 번 호출해 경로를 재구성하며, 동적 트리의 연결/절단,
경로 질의를 상각 \(O(\log N)\)에 처리한다. 스플레이의 상각 성질이 LCT 복잡도의
근거다.

트립(treap)과의 비교 — CP에서 무엇을 쓸까

스플레이 트리 암시적 트립(implicit treap)
균형 상각(자가 조정) 확률적(랜덤 우선순위)
구현 난도 회전·splay·부모 포인터 split/merge, 부모 불필요
최악 개별 \(O(N)\) (상각은 \(O(\log N)\)) 높은 확률 \(O(\log N)\)
특기 LCT 기반, 접근 지역성 구간 뒤집기 등 대부분의 수열 작업

대부분의 경쟁 프로그래밍 상황에서는 구현이 단순한 암시적 트립이 선호된다.
스플레이 트리는 (1) LCT처럼 splay의 상각 접근이 본질적이거나, (2) 접근 지역성
(자주 쓰는 원소가 위로 올라오는 이점)이 필요할 때 진가를 발휘한다.

흔한 버그

  • 하강 전 push 누락 — 회전·kth 전에 경로 lazy(reverse/add)를 내리지 않으면
    오답. splay가 root→x 경로를 push하고, kth도 하강마다 push해야 한다.
  • 회전에서 부모 포인터 미갱신t[x].ch[d^1]\(p\)에게 넘길 때 그 자식의
    par\(p\)로 바꾸고, \(g\)\(x\)를 가리키게(단, \(g=0\)이면 생략) 하고, root
    갱신을 잊지 말 것.
  • zig-zig 순서 — 같은 방향이면 rotate(p) 먼저. 틀리면 상각이 깨진다.
  • 크기·합 미유지rotatepull(p); pull(x) (아래→위 순서). 구간 연산
    후 조상 pull.
  • reverse lazy 전파 순서applyRev는 자식 포인터를 swap하고 rev 토글만.
    push에서 자식들에 전파. 뒤집기와 위치 계산이 어긋나면 gather가 틀림.
  • 더미 경계 — 양끝 더미가 없으면 \(l=1\)이나 \(r=n\) 구간에서 경계 노드가 없어
    gather가 실패한다. 반드시 위치 \(1\)\(n+2\)에 더미.
  • 센티넬 노드 0t[0].sz=0, t[0].sum=0이어야 널 자식 계산이 맞는다.

요약

  1. splay는 zig / zig-zig(부모 먼저) / zig-zag로 노드를 루트로. 상각 \(O(\log N)\).
  2. 하강·회전 전 경로 lazy를 push, 회전 후 아래→위로 pull.
  3. 암시적 수열: 서브트리 크기로 위치 계산, 양끝 더미 + gather로 구간 연산.
  4. LCT의 기반이자, 접근 지역성이 필요할 때 유용. 일반 수열 작업은 트립이 더 간단.
Lesson 평면 질의를 위한 트리의 트리 필수 8m

2차원 세그먼트 트리란

1차원 세그먼트 트리는 배열의 구간 질의를 처리한다. 2차원 세그먼트 트리
격자(grid)에서 직사각형 영역 질의 — 예를 들어 "행 \([r_1, r_2]\), 열
\([c_1, c_2]\)의 합/최댓값" — 을 처리한다.

구조는 트리의 트리(tree of trees)다.

  • 바깥(outer) 트리: 행(row) 방향 세그먼트 트리.
  • 바깥 트리의 각 노드는, 그 노드가 담당하는 행 구간을 열(column) 방향으로
    요약한 안쪽(inner) 세그먼트 트리
    하나를 통째로 가진다.
바깥(행) 세그트리
  └ 노드 = 행 구간 [rs, re]
       └ 안쪽(열) 세그트리: 이 행들을 열별로 합/최대 요약

직사각형 질의는 바깥에서 행 구간을 \(O(\log N)\)개 노드로 분해하고, 각 노드의
안쪽 트리에서 열 구간을 \(O(\log N)\)에 질의한다.

복잡도와 언제 쓰나

  • 질의(직사각형): \(O(\log N \cdot \log M)\)
  • 점 갱신: \(O(\log N \cdot \log M)\) — 해당 행을 담는 바깥 노드 \(O(\log N)\)
    각각에서 안쪽 갱신 \(O(\log M)\).
  • 메모리: 안쪽 트리를 모두 정적 배열로 두면 \(O(N \cdot M)\) 수준(완전 2D).

언제 쓰나: 점 갱신 + 직사각형 질의를 온라인으로 처리해야 하고, 연산이
비가역(예: max, min, gcd) 이라 뒤에 설명할 2D BIT로는 안 될 때가 진짜
2D 세그먼트 트리가 필요한 경우다. 합처럼 가역 연산이고 점 갱신 + 직사각형 합이면
2D BIT가 훨씬 간단하므로 그쪽을 먼저 고려한다(2강).

구체적 예시

\(4 \times 4\) 격자에서 "점 \((2,3)\)\(+5\)", 이후 "행 \([1,3]\)\([2,4]\)의 합".

  • 갱신: 행 2를 담는 바깥 노드들(루트, 행 \([1,2]\), 행 \([2,2]\) …) 각각의 안쪽
    트리에서 열 3 위치를 \(+5\).
  • 질의: 바깥에서 행 \([1,3]\)을 노드 몇 개로 분해 → 각 노드 안쪽 트리에서 열
    \([2,4]\) 합을 더함.

변형: 머지 소트 트리 풍의 2D (오프라인 카운팅)

정적이고 "직사각형 안 점 개수" 같은 카운팅이면, 바깥 세그트리 각 노드에 그 행
구간에 속한 점들의 열 좌표를 정렬한 배열을 두는 방식(1D 머지 소트 트리의
2D판)으로 \(O(\log^2 N)\) 카운팅이 된다. 메모리는 \(O(N \log N)\). 하지만 점 개수
카운팅의 실전 표준은 오프라인 스윕 + 1D BIT이며(2강 끝에서 설명), 이쪽이
메모리·상수 모두 유리하다.

대안 지도

상황 권장
점 갱신 + 직사각형 2D BIT (간단, \(O(\log^2)\))
점 갱신 + 직사각형 max/min/gcd 2D 세그먼트 트리
정적 + 직사각형 점 카운팅 오프라인 스윕 + 1D BIT (또는 2D 머지 소트 트리)
직사각형 갱신 + 직사각형 합 2D BIT ×4 (차분) 또는 무거운 2D lazy
넓은 좌표, 점이 희소 k-d 트리 / 쿼드트리 / 좌표압축 + 동적 노드
Lesson 구현: 2D 세그먼트 트리와 2D BIT 선택 8m

2D 세그먼트 트리 구현 (점 갱신 + 직사각형 질의)

바깥은 행, 안쪽은 열. 안쪽 트리를 바깥 노드마다 정적 배열로 갖는다(합 예시).

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 1024, M = 1024;      // 행/열 크기 (2의 거듭제곱 가정하면 편함)
ll seg[4*N][4*M];                  // seg[바깥노드][안쪽노드] — 메모리 큼!
ll A[N][M];

// 안쪽(열) 트리 구축: 바깥노드 nx가 '리프 행'인지에 따라 값이 다름
// updY / buildY 를 바깥 build 안에서 호출
void buildY(int nx, int lx, int rx, int ny, int ly, int ry) {
    if (ly == ry) {
        if (lx == rx) seg[nx][ny] = A[lx][ly];               // 바깥·안쪽 모두 리프
        else          seg[nx][ny] = seg[nx*2][ny] + seg[nx*2+1][ny]; // 자식 행 합
        return;
    }
    int my = (ly + ry) / 2;
    buildY(nx, lx, rx, ny*2,   ly,   my);
    buildY(nx, lx, rx, ny*2+1, my+1, ry);
    seg[nx][ny] = seg[nx][ny*2] + seg[nx][ny*2+1];
}
void buildX(int nx, int lx, int rx) {
    if (lx != rx) {
        int mx = (lx + rx) / 2;
        buildX(nx*2,   lx,   mx);
        buildX(nx*2+1, mx+1, rx);
    }
    buildY(nx, lx, rx, 1, 0, M-1);      // 이 행 구간의 안쪽 트리 완성
}

// 점 (x,y) 갱신: 바깥에서 x를 포함하는 노드마다 안쪽 y 갱신
void updY(int nx, int lx, int rx, int ny, int ly, int ry, int y, ll val) {
    if (ly == ry) {
        if (lx == rx) seg[nx][ny] = val;                     // 리프 행: 대입
        else          seg[nx][ny] = seg[nx*2][ny] + seg[nx*2+1][ny];
        return;
    }
    int my = (ly + ry) / 2;
    if (y <= my) updY(nx, lx, rx, ny*2,   ly,   my, y, val);
    else         updY(nx, lx, rx, ny*2+1, my+1, ry, y, val);
    seg[nx][ny] = seg[nx][ny*2] + seg[nx][ny*2+1];
}
void updX(int nx, int lx, int rx, int x, int y, ll val) {
    if (lx != rx) {
        int mx = (lx + rx) / 2;
        if (x <= mx) updX(nx*2,   lx,   mx, x, y, val);
        else         updX(nx*2+1, mx+1, rx, x, y, val);
    }
    updY(nx, lx, rx, 1, 0, M-1, y, val);   // 리프면 대입, 아니면 자식 행 합으로 갱신
}

// 직사각형 질의: 행 [x1,x2] × 열 [y1,y2] 합
ll qY(int nx, int ny, int ly, int ry, int y1, int y2) {
    if (y2 < ly || ry < y1) return 0;
    if (y1 <= ly && ry <= y2) return seg[nx][ny];
    int my = (ly + ry) / 2;
    return qY(nx, ny*2, ly, my, y1, y2) + qY(nx, ny*2+1, my+1, ry, y1, y2);
}
ll qX(int nx, int lx, int rx, int x1, int x2, int y1, int y2) {
    if (x2 < lx || rx < x1) return 0;
    if (x1 <= lx && rx <= x2) return qY(nx, 1, 0, M-1, y1, y2);
    int mx = (lx + rx) / 2;
    return qX(nx*2, lx, mx, x1, x2, y1, y2)
         + qX(nx*2+1, mx+1, rx, x1, x2, y1, y2);
}

주의: 위 완전 2D 세그먼트 트리는 4N × 4M 배열이라 메모리가 매우 크다
(\(N=M=1024\)이면 \(16 \times 10^6\) ll ≈ 128MB). 실전에선 \(N, M\)이 작을 때
(예: \(\le 1000\))만 쓰거나, 필요한 안쪽 노드만 동적 생성한다. max가 필요하면
+max로, 항등원을 \(-\infty\)로 바꾸면 된다(이때 2D BIT는 불가).

훨씬 간단한 대안: 2D BIT (점 갱신 + 직사각형 합)

합처럼 가역 연산이면 2D 펜윅이 압도적으로 간단하고 메모리도 \(O(NM)\) 하나뿐.

int N2, M2;
ll bit[1005][1005];               // 1-indexed
void update2D(int x, int y, ll val) {         // (x,y) += val
    for (int i = x; i <= N2; i += i & -i)
        for (int j = y; j <= M2; j += j & -j)
            bit[i][j] += val;
}
ll sum2D(int x, int y) {                        // [1..x]x[1..y] 합
    ll s = 0;
    for (int i = x; i > 0; i -= i & -i)
        for (int j = y; j > 0; j -= j & -j)
            s += bit[i][j];
    return s;
}
ll rect(int x1, int y1, int x2, int y2) {       // [x1..x2]x[y1..y2]
    return sum2D(x2,y2) - sum2D(x1-1,y2) - sum2D(x2,y1-1) + sum2D(x1-1,y1-1);
}

\(O(\log N \log M)\), 코드 10줄. 점 갱신 + 직사각형 합이면 항상 이걸 먼저
고려
하라.

정적 카운팅: 오프라인 스윕 + 1D BIT

"직사각형 안 점 개수/합"이 정적(또는 점 추가만)이면, 질의를 \(x\) 기준으로 정렬해
왼→오 스윕하며 1D BIT로 \(y\) 좌표를 관리하는 것이 표준이다. \(O((P+Q)\log)\),
메모리 \(O(\max y)\). 2D 세그트리/2D BIT의 \(O(NM)\) 메모리를 피할 수 있다.

Lesson 심화·응용: 메모리 전략과 대안 선택 8m

메모리 전략 정리

완전 2D 세그먼트 트리의 \(O(NM)\) 메모리는 \(N, M \le 1000\) 정도에서만 감당된다.
더 큰 경우의 전략:

  • 동적(dynamic) 안쪽 트리: 바깥 노드마다 안쪽 트리를 필요한 노드만 동적
    할당(포인터/인덱스 풀). 점이 희소하면 실제 사용 노드는 \(O(P \log N \log M)\)
    줄어든다.
  • 좌표 압축: 질의/갱신에 등장하는 좌표만 남겨 \(N, M\)을 실제 사용 좌표 수로.
  • 오프라인 스윕 + 1D BIT: 앞 강의 방법. 온라인이 아니면 거의 항상 최선.
  • 머지 소트 트리형 2D: 정적 카운팅에 한해 바깥 세그트리 노드에 정렬 배열,
    \(O(N\log N)\) 메모리·\(O(\log^2 N)\) 질의.

직사각형 갱신은 왜 어려운가

"직사각형 영역 전체에 +v, 직사각형 합"은 1D의 lazy처럼 간단하지 않다. 옵션:

  • 2D BIT 4개(차분 트릭): 가역 연산(합)에 한해 직사각형 갱신+직사각형 합을
    \(O(\log^2)\)에 지원. 계수 4개 BIT를 유지하는 유명한 테크닉.
  • 2D 세그트리 + lazy: 이론상 가능하나 안쪽 트리의 lazy 전파가 복잡하고
    무거워 실전 사용은 드물다. 가능하면 문제를 1D 여러 개나 오프라인으로 환원한다.

다른 공간 분할 구조와의 비교

구조 강점 약점
2D 세그트리 비가역 연산(max 등), 점 갱신+직사각형 메모리 \(O(NM)\)
2D BIT 가역 연산, 간단·빠름 max 불가, 직사각형 갱신은 차분 트릭 필요
오프라인 스윕+BIT 메모리 최소, 상수 작음 오프라인 한정
k-d 트리 임의 점 집합, 최근접/영역 최악 \(O(\sqrt P)\) 영역 질의
쿼드트리 직관적 공간 분할 편향 데이터에서 깊이 폭발

흔한 버그

  • 안쪽 트리 인덱싱 — 바깥 노드 nx마다 독립된 안쪽 트리이므로 seg[nx][ny]
    로 반드시 바깥 인덱스를 함께 써야 한다. ny만 쓰면 트리들이 섞인다.
  • 바깥 갱신 시 모든 조상 갱신 — 점 갱신은 바깥에서 리프 행뿐 아니라 그 위
    모든 바깥 노드의 안쪽 트리도 갱신해야 한다(updX가 하강 후 각 레벨에서 updY
    호출). 리프만 갱신하면 상위 직사각형 질의가 틀린다.
  • 비리프 바깥 노드의 안쪽 값 정의 — 비리프 바깥 노드의 안쪽 값은 "두 자식
    바깥 노드의 같은 열 값의 합/최대"여야 한다(위 코드의 lx!=rx 분기).
  • 메모리 초과 — 무심코 큰 완전 2D를 잡으면 MLE. 크기를 먼저 계산하라.
  • max 연산에 2D BIT 사용 금지 — BIT는 가역 연산 전용. min/max엔 세그트리.

요약

  • 점 갱신 + 직사각형 2D BIT (기본 선택).
  • 비가역 연산(max/min/gcd)이나 특수 요구 → 2D 세그먼트 트리.
  • 정적 카운팅 → 오프라인 스윕 + 1D BIT.
  • 메모리가 문제면 동적 노드/좌표 압축/오프라인으로 환원.
Lesson 간선 수명을 시간 세그먼트 트리에 올리기 필수 8m

오프라인 동적 연결성이란

그래프에 간선이 추가되고 삭제되는 도중에 "지금 연결 요소가 몇 개?", "정점
\(u, v\)가 연결돼 있나?" 같은 질의에 답해야 한다고 하자. 간선이 삭제까지 되면
유니온-파인드(DSU)만으로는 안 된다 — DSU는 합치기는 쉬워도 되돌리기(분리)
어렵기 때문이다.

오프라인(모든 연산을 미리 앎)이라면, 강력한 프레임이 있다: 각 간선의
수명(lifetime)을 시간축의 구간으로 보고, 시간에 대한 세그먼트 트리
올린 뒤, 롤백 가능한 DSU로 트리를 DFS한다.

핵심 아이디어 — 간선을 시간 구간으로

연산이 시각 \(0, 1, \dots, Q-1\)에 하나씩 일어난다고 하자. 어떤 간선이 시각
\(t_{add}\)에 추가되고 \(t_{del}\)에 삭제되면, 그 간선은 시간 구간
\([t_{add}, t_{del})\) 동안 "살아 있다".

시간축 \([0, Q-1]\)에 대한 세그먼트 트리를 만들고, 각 간선의 살아 있는 구간을
세그먼트 트리에 구간 삽입한다(구간을 덮는 \(O(\log Q)\)개의 노드에 그 간선을
매단다). 마치 lazy 없이 "구간에 값 얹기"처럼.

시간축 세그트리 (리프 = 각 시각/질의)
  각 노드: 그 시간 구간 전체에서 '항상 살아 있는' 간선들의 목록

DFS + 롤백 DSU

세그먼트 트리를 DFS한다.

  • 노드에 진입: 그 노드에 매달린 간선들을 DSU에 union. 진입 전 스냅샷 기록.
  • 리프(특정 시각)에 도달: 그 시각의 질의에 현재 DSU 상태로 답한다(연결 요소
    수, 두 정점 연결 여부 등).
  • 노드에서 이탈: 이 노드에서 한 union들을 롤백해 진입 전 상태로 되돌림.

리프까지 내려가는 경로 상의 모든 노드의 간선이 union되어 있으므로, 리프 = 그
시각에 "살아 있는 모든 간선"이 정확히 반영된 상태다.

왜 경로 압축을 못 쓰나 — 롤백 DSU

되돌리기 위해 DSU는 union by rank(또는 size)만 쓰고 경로 압축을 쓰지
않는다
. 경로 압축은 여러 노드의 부모를 한꺼번에 바꿔 정확한 되돌리기가
불가능하기 때문. 대신 union by rank가 트리 높이를 \(O(\log N)\)로 보장하므로,
경로 압축 없이도 find\(O(\log N)\)이다.

복잡도

  • 각 간선은 \(O(\log Q)\)개 노드에 삽입.
  • DFS에서 각 노드의 간선을 union/rollback 1회, find\(O(\log N)\).

$$ O\big((N + Q)\log Q \cdot \log N\big) $$

언제 쓰나

  • 간선 추가와 삭제가 모두 있고, 연결성/연결 요소 수 등을 묻는 오프라인 문제.
  • "구간에서만 존재하는 조건"을 시간축 세그먼트 트리로 올리는 일반적 패턴(간선뿐
    아니라 다양한 '한시적 사실'에 응용).

구체적 예시

연산: t0: add(1,2), t1: query, t2: add(2,3), t3: query, t4: remove(1,2),
t5: query.

  • 간선 \((1,2)\): 수명 \([0, 4)\) → 시각 0~3에 존재.
  • 간선 \((2,3)\): 수명 \([2, 6)\) (삭제 없음 → 끝까지) → 시각 2~5.

시간 세그트리에 두 구간을 얹고 DFS하면, \(t1\)에서 요소 수(1,2 연결 + 3 따로 =
정점 수−1), \(t3\)에서 (1,2,3 한 덩어리), \(t5\)에서 ((1,2) 끊김, 2,3 연결)을 각각
그 시각의 DSU 상태로 답한다.

Lesson 구현: 롤백 DSU와 시간 세그먼트 트리 선택 8m

롤백 가능한 DSU (경로 압축 금지)

union by rank + 변경 이력 스택. find는 경로 압축을 하지 않는다.

#include <bits/stdc++.h>
using namespace std;

struct RollbackDSU {
    vector<int> par, rnk;
    int comps;                                    // 연결 요소 수
    struct Change { int a, b; bool rankInc; };    // b가 a 아래로, rank 증가 여부
    vector<Change> hist;

    void init(int n) {
        par.resize(n); rnk.assign(n, 0); comps = n;
        for (int i = 0; i < n; i++) par[i] = i;
        hist.clear();
    }
    int find(int x) { while (par[x] != x) x = par[x]; return x; }   // 경로 압축 없음
    int snapshot() { return (int)hist.size(); }

    void unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return;                        // 변화 없음 → 기록 안 함
        if (rnk[x] < rnk[y]) swap(x, y);           // rnk[x] >= rnk[y]; y를 x 아래로
        bool inc = (rnk[x] == rnk[y]);
        par[y] = x;
        if (inc) rnk[x]++;
        hist.push_back({x, y, inc});
        comps--;
    }
    void rollback(int snap) {                       // snap 시점 이후 union 모두 취소
        while ((int)hist.size() > snap) {
            Change c = hist.back(); hist.pop_back();
            par[c.b] = c.b;                          // 부모 원복
            if (c.rankInc) rnk[c.a]--;               // 올렸던 rank 원복
            comps++;
        }
    }
};
  • 경로 압축 없음: find는 부모 사슬만 따라간다. union by rank가 높이를
    \(O(\log N)\)로 유지하므로 충분히 빠르다.
  • 정확한 되돌리기: 각 실제 union마다 (루트 a, 붙인 b, rank 증가 여부)
    기록하고, 역순으로 정확히 취소. 변화 없는 union(x==y)은 아무것도 기록하지
    않는다
    — 그래야 롤백 개수가 어긋나지 않는다.
  • 연결 요소 수 comps를 union에서 감소, 롤백에서 복원해 즉시 질의에 사용.

시간 세그먼트 트리 + DFS

const int MAXQ = 300005;
vector<pair<int,int>> segEdges[4 * MAXQ];         // 각 노드에 매달린 간선
RollbackDSU dsu;
int Q;                                            // 시각 개수(연산 수)
long long ans[MAXQ];                              // 시각별 답(예: 연결 요소 수)
bool isQuery[MAXQ];                               // 그 시각이 질의인가

// 간선 e를 시간 구간 [l, r] (양끝 포함) 을 덮는 노드들에 삽입
void addEdge(int node, int lo, int hi, int l, int r, pair<int,int> e) {
    if (r < lo || hi < l) return;
    if (l <= lo && hi <= r) { segEdges[node].push_back(e); return; }
    int m = (lo + hi) / 2;
    addEdge(2*node,   lo,   m, l, r, e);
    addEdge(2*node+1, m+1, hi, l, r, e);
}

void dfs(int node, int lo, int hi) {
    int snap = dsu.snapshot();
    for (auto& e : segEdges[node]) dsu.unite(e.first, e.second);
    if (lo == hi) {
        if (isQuery[lo]) ans[lo] = dsu.comps;      // 예: 연결 요소 수
        // 연결 여부 질의라면: ans[lo] = (dsu.find(u[lo]) == dsu.find(v[lo]));
    } else {
        int m = (lo + hi) / 2;
        dfs(2*node,   lo,   m);
        dfs(2*node+1, m+1, hi);
    }
    dsu.rollback(snap);                            // 이 노드의 union만 취소
}

간선 수명 계산 (추가/삭제 매칭)

각 간선이 언제 추가되고 삭제됐는지 매칭한다. 삭제가 없으면 타임라인 끝까지.

// 연산을 훑으며 map으로 '현재 살아있는 간선의 추가 시각'을 추적
map<pair<int,int>, int> alive;                    // 정규화된 간선 → 추가 시각

void buildTimeline(/* 연산 목록 */) {
    for (int t = 0; t < Q; t++) {
        // op[t] 가 add(u,v):
        //   pair<int,int> e = minmax(u, v); alive[e] = t;
        // op[t] 가 remove(u,v):
        //   auto e = minmax(u, v); int s = alive[e]; alive.erase(e);
        //   addEdge(1, 0, Q-1, s, t - 1, e);       // [추가, 삭제) → [s, t-1]
        // op[t] 가 query: isQuery[t] = true;
    }
    // 끝까지 삭제되지 않은 간선: [추가시각, Q-1]
    for (auto& [e, s] : alive) addEdge(1, 0, Q - 1, s, Q - 1, e);
}
// 실행: dsu.init(N); buildTimeline(...); dfs(1, 0, Q - 1);

주의: 간선을 minmax(u,v)로 정규화해 무방향 간선을 일관되게 키로 쓴다. 다중
간선(같은 간선 여러 번 추가)이 가능하면 map<pair,int> 대신 카운트나 스택으로
관리한다.

Lesson 심화·응용: 롤백 vs 영속과 대안 선택 8m

왜 union by rank가 필수인가

롤백을 하려면 각 union이 딱 하나의 부모 포인터만 바꿔야 되돌리기가 정확하다.
경로 압축은 find 한 번에 여러 노드의 부모를 바꾸므로 되돌릴 수 없다. 대신:

  • union by rank / size: 트리 높이 \(O(\log N)\) 보장 → 경로 압축 없이도
    find\(O(\log N)\).
  • 이 조합은 "롤백/영속(persistent) DSU"의 표준이다.

경로 압축 + union by rank의 \(O(\alpha(N))\)은 매우 빠르지만 되돌릴 수 없어
여기선 쓸 수 없다는 점이 핵심 트레이드오프다.

다양한 질의로 확장

  • 연결 요소 수: comps를 union/rollback에서 유지(위 코드).
  • 두 정점 연결 여부: 리프에서 find(u) == find(v).
  • 특정 요소 크기: size 배열을 함께 유지하고 union/rollback에서 갱신.
  • 가중치/이분성 등: 롤백 DSU에 부가 정보를 실어 시각별로 질의.

롤백 vs 영속(persistence)

기법 과거 접근 방식 비용 여기서의 적합성
롤백 DSU 스택(LIFO)으로만 되돌림 \(O(\log N)\)/op, 메모리 \(O(\text{연산})\) DFS가 정확히 LIFO → 최적
영속 DSU 임의 과거 버전 더 큼 과잉

시간 세그먼트 트리 DFS는 진입/이탈이 완벽한 스택 규율이라, 무거운 영속성 대신
가벼운 롤백으로 충분하다.

대안 기법

  • Small-to-large / 오프라인 트릭: 특수한 삭제 패턴이면 더 단순한 방법도.
  • 링크 컷 트리(LCT): 온라인 동적 연결성(간선 추가/삭제 + 질의)을 상각
    \(O(\log N)\)에. 오프라인 제약이 없을 때의 대안이지만 구현이 무겁다.
  • Euler Tour Tree / Holm–de Lichtenberg–Thorup: 완전 동적 연결성의 고급
    자료구조(온라인, 폴리로그). 실전에선 오프라인 세그트리 기법이 훨씬 간단.

흔한 버그

  • 경로 압축 사용 — 롤백이 깨진다. find는 순수 부모 추적만.
  • 끝까지 삭제 안 된 간선 누락 — 타임라인 종료 시 alive에 남은 간선을
    \([추가, Q-1]\)로 반드시 삽입.
  • 시간 구간 off-by-one — 수명 \([t_{add}, t_{del})\)은 삭제 시각을 포함하지
    않는다
    . 세그트리엔 닫힌 구간 \([t_{add}, t_{del}-1]\)로 넣는다.
  • 롤백 개수 불일치 — 변화 없는 union(x==y)을 이력에 기록하면 롤백이
    어긋난다. 위 코드처럼 실제 병합만 기록.
  • 간선 정규화 — 무방향 간선은 minmax로 키를 통일. 다중 간선은 카운트/스택.
  • DFS 스택 깊이\(Q\)가 크면 재귀 깊이 \(O(\log Q)\)이라 대체로 안전하나,
    세그트리 크기 4*MAXQ를 충분히.

요약

  1. 간선 수명 \([t_{add}, t_{del})\)시간 세그먼트 트리에 구간 삽입.
  2. 롤백 DSU(union by rank, 경로 압축 금지)로 DFS: 진입 시 union, 리프에서
    질의, 이탈 시 rollback.
  3. 복잡도 \(O((N+Q)\log Q \log N)\).
  4. 경로 압축 금지 · 끝까지 안 지워진 간선 · 구간 off-by-one · 롤백 개수 일치가
    핵심 주의점.
Practice problem 깜빡이는 연결망 선택 25m
R00626

깜빡이는 연결망

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
07
Level 9 · Master

Master

자료 구조 · Master 단계

0/9 완료
Lesson Segment Tree Beats의 아이디어 필수 8m

세그먼트 트리 비츠란

세그먼트 트리 비츠(Segment Tree Beats, "지 드라이버 세그먼트 트리")는 일반 lazy
세그먼트 트리로는 결합이 어려운 연산 — 대표적으로 구간 chmin/chmax(구간의 각
원소를 \(\min(a_i, x)\) 또는 \(\max(a_i, x)\)로) — 을 구간 합 같은 질의와 함께
처리하는 기법이다.

문제의 어려움: a_i = min(a_i, x)는 원소마다 바뀔 수도, 안 바뀔 수도 있어
하나의 lazy 태그로 표현되지 않는다. 비츠의 아이디어는 각 노드에 정보를 더 들고,
대부분의 원소는 안 바뀐다는 성질을 이용해 재귀를 조기에 끊는 것이다.

노드에 저장하는 정보 (chmin 기준)

각 노드에 다음을 유지한다.

  • sum: 구간 합
  • mx: 구간 최댓값
  • mxc: 최댓값의 개수
  • se: 엄격한 두 번째 최댓값(strict second max). 최댓값 미만의 값 중 최대.
    없으면 \(-\infty\).

chmin(x)를 구간에 적용할 때, 노드를 세 경우로 나눈다.

  1. \(x \ge mx\): 모든 원소가 \(x\) 이하 → 아무것도 안 바뀜, 즉시 중단(break).
  2. \(se < x < mx\): 오직 최댓값들만 \(x\)로 줄면 됨. sum -= (mx - x) * mxc; mx = x;
    로 태그 한 번에 처리하고 중단(tag).
  3. \(x \le se\): 두 번째 최댓값도 영향을 받음 → 자식으로 재귀(내려감).

핵심은 (1),(2)에서 재귀를 끊고, (3)의 "깊이 내려가는" 경우가 드물다는 것이다.

복잡도 (상각)

chmin + 구간합의 경우, 위 (3) 재귀의 총 횟수가 포텐셜 함수 논증으로 억제되어
전체가

$$ O(N \log^2 N) $$

상각(amortized) 시간이다. 직관: "서로 다른 값의 종류 수"를 포텐셜로 보면,
비싼 재귀는 값의 종류를 줄이므로 자주 일어날 수 없다. 여기에 구간 add까지
섞은 (chmin + add + sum) 조합도 \(O(N\log^2 N)\) 상각을 유지한다(지 드라이버
세그먼트 트리).

주의: 개별 연산은 \(O(N)\)까지 느릴 수 있다. 보장은 전체 합에 대한 것이다
(상각). 최악 케이스 한 방을 재는 문제라면 이 점을 유의.

언제 쓰나

  • 구간 chmin / chmax와 구간 합/최대 질의가 섞일 때.
  • "구간의 모든 값을 어떤 상한/하한으로 눌러라" 류.
  • add까지 섞인 복합 갱신(예: 구간 +v, 구간 chmin, 구간 합).

일반 lazy로 되는 문제(구간 add + 구간 합 등)라면 굳이 비츠를 쓸 필요는 없다.

구체적 예시

A = [5, 3, 5, 2]. 구간 전체에 chmin(4):
- 노드 최대 \(mx=5\), se=3, mxc=2. \(se(3) < 4 < mx(5)\)이므로 태그 케이스.
- sum -= (5-4)*2 = 2 → sum \(15 \to 13\), mx=4. 결과 배열 [4,3,4,2], 합 13. 정답.

이어서 chmin(3): 이제 \(mx=4\), se=3. \(x=3 \le se\) → 재귀(3번째 경우)로 내려가
자식들에서 처리한다.

Lesson 구현: chmin + add + 구간 합 선택 8m

구현: 구간 add + 구간 chmin + 구간 합/최대

가장 실전적인 조합이다. 태그는 두 종류(덧셈 lazy add, 그리고 최댓값을 누르는
"min 태그"는 mx 자체로 표현)이며, pushdown 순서(add 먼저, 그다음 min 캡)를
지키는 것이 정확성의 핵심이다.

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll NEG = LLONG_MIN;
const int MAXN = 100005;

ll  sum[4*MAXN], mx[4*MAXN], se[4*MAXN], addv[4*MAXN];
int mxc[4*MAXN];
int A[MAXN];

void pull(int x) {
    int l = 2*x, r = 2*x+1;
    sum[x] = sum[l] + sum[r];
    mx[x]  = max(mx[l], mx[r]);
    mxc[x] = 0; se[x] = NEG;
    for (int c : {l, r}) {
        if (mx[c] == mx[x]) mxc[x] += mxc[c];
        else                se[x]   = max(se[x], mx[c]);
        se[x] = max(se[x], se[c]);
    }
}
// 구간 덧셈 태그 적용 (len = 노드 원소 수)
void applyAdd(int x, ll v, int len) {
    sum[x] += v * (ll)len;  mx[x] += v;
    if (se[x] != NEG) se[x] += v;      // -INF 센티넬은 건드리지 않음
    addv[x] += v;
}
// 최댓값만 v로 누름 (전제: se[x] < v < mx[x] 또는 v>=mx[x]면 no-op)
void applyMin(int x, ll v) {
    if (v >= mx[x]) return;            // 누를 것 없음
    sum[x] -= (mx[x] - v) * (ll)mxc[x];
    mx[x]   = v;                       // se는 v보다 작으므로 그대로
}
void pushdown(int x, int s, int e) {
    int m = (s+e)/2, l = 2*x, r = 2*x+1;
    if (addv[x] != 0) {                // 1) 덧셈 먼저
        applyAdd(l, addv[x], m-s+1);
        applyAdd(r, addv[x], e-m);
        addv[x] = 0;
    }
    applyMin(l, mx[x]);                // 2) 그다음 부모 max로 자식 캡
    applyMin(r, mx[x]);
}
void build(int x, int s, int e) {
    addv[x] = 0;
    if (s == e) { sum[x]=mx[x]=A[s]; mxc[x]=1; se[x]=NEG; return; }
    int m=(s+e)/2; build(2*x,s,m); build(2*x+1,m+1,e); pull(x);
}
void rangeAdd(int x, int s, int e, int l, int r, ll v) {
    if (r < s || e < l) return;
    if (l <= s && e <= r) { applyAdd(x, v, e-s+1); return; }
    pushdown(x,s,e); int m=(s+e)/2;
    rangeAdd(2*x,s,m,l,r,v); rangeAdd(2*x+1,m+1,e,l,r,v); pull(x);
}
// 구간 chmin: a_i = min(a_i, v)
void rangeChmin(int x, int s, int e, int l, int r, ll v) {
    if (r < s || e < l || v >= mx[x]) return;             // 경우1: break
    if (l <= s && e <= r && se[x] < v) { applyMin(x, v); return; } // 경우2: tag
    pushdown(x,s,e); int m=(s+e)/2;                        // 경우3: 재귀
    rangeChmin(2*x,s,m,l,r,v); rangeChmin(2*x+1,m+1,e,l,r,v); pull(x);
}
ll querySum(int x, int s, int e, int l, int r) {
    if (r < s || e < l) return 0;
    if (l <= s && e <= r) return sum[x];
    pushdown(x,s,e); int m=(s+e)/2;
    return querySum(2*x,s,m,l,r) + querySum(2*x+1,m+1,e,l,r);
}
ll queryMax(int x, int s, int e, int l, int r) {
    if (r < s || e < l) return NEG;
    if (l <= s && e <= r) return mx[x];
    pushdown(x,s,e); int m=(s+e)/2;
    return max(queryMax(2*x,s,m,l,r), queryMax(2*x+1,m+1,e,l,r));
}

왜 이 pushdown 순서인가

부모의 mx는 자기 자식들의 max에서 add·chmin이 모두 반영된 값이다. 자식으로
내려갈 때 덧셈을 먼저 물려주어 자식을 부모와 같은 스케일로 만든 뒤,
부모의 chmin 결과를 applyMin(child, mx[parent])로 캡한다. 이때
mx[parent] > se[parent] ≥ 자식의 se가 보장되므로 applyMin의 전제(누르는 값이
두 번째 최댓값보다 큼)가 항상 성립한다.

대칭: chmax

chmax는 위를 뒤집어 최솟값 계열(mn, mnc, 엄격한 두 번째 최솟값 se2)을
평행하게 유지한다. chmin과 chmax를 동시에 지원하려면 max-집합과 min-집합을
둘 다 들고, add까지 얹으면 태그 상호작용이 늘어난다(그래도 \(O(N\log^2 N)\) 상각).

Lesson 심화·응용: 상각 분석과 확장 선택 8m

상각 복잡도 다시 보기

비츠의 성능은 "경우 3(깊이 재귀)"이 얼마나 드문지에 달렸다. 표준 포텐셜 논증:

  • 포텐셜 \(\Phi = \sum_{\text{노드}} (\text{그 서브트리에서 서로 다른 값 태그의 깊이 기여})\).
  • 태그(경우 2)는 서로 다른 값의 종류를 줄이는 경향이 있고, 비싼 재귀는 이
    포텐셜을 소모한다. add는 포텐셜을 \(O(\log N)\)만큼만 올린다.
  • 결과적으로 chmin+add+sum 전체가 \(O(N\log^2 N)\) 상각.

이는 평균/전체에 대한 보장이며, 특정 한 연산은 최악 \(O(N)\)일 수 있다.

확장: 역사적 최댓값(historic max)

"각 원소가 지금까지 가졌던 값의 최댓값"을 함께 구하는 문제는 비츠에 historic
태그를 더 얹어 해결한다. add·chmin이 일어나는 동안 각 원소의 역대 최댓값을
추적하는데, 태그가 (현재 add, 역대 최대 add) 쌍으로 확장되어 상당히 복잡하다.
같은 상각 뼈대 위에서 태그 대수만 정교해진다.

chmin + chmax + add 동시 (완전 지 드라이버)

세 연산을 모두 지원하려면 노드에 최댓값 집합(mx, mxc, se)과 최솟값
집합(mn, mnc, se2)을 함께 두고, 구간 합을 유지한다. add는 모든 통계에 더해지고,
chmin은 max 집합을, chmax는 min 집합을 조정한다. 구간이 단일 값으로 수렴하면
mx==mn이 되어 태그가 단순해진다. 전체 \(O(N\log^2 N)\) 상각.

흔한 버그

  • 비엄격 vs 엄격 두 번째 값se엄격히 mx보다 작은 최대여야 한다.
    최댓값과 같은 값을 se에 넣으면 태그 조건 se < v가 깨져 오답 또는 무한 재귀.
  • mxc 갱신 누락pull에서 두 자식 중 mx가 같은 쪽들의 개수를 합쳐야
    한다. 한쪽만 세면 sum 보정이 틀린다.
  • pushdown 순서 — add를 min 캡보다 먼저 자식에 내려야 한다. 순서를
    바꾸면 스케일이 어긋난다.
  • 오버플로sum -= (mx - v) * mxc, sum += v * len 모두 long long.
    값이 크면 (mx - v)len, v의 곱에서 오버플로 주의.
  • 센티넬 처리se == -INF일 때 add를 더하지 말 것(위 코드의 가드).
  • 개별 연산 최악 비용 — 상각이므로 단일 최악을 재는 문제엔 부적합할 수 있음.

언제 비츠가 아니라 다른 걸 쓰나

  • 구간 add + 구간 합만 → 일반 lazy 세그트리(비츠 불필요, 더 빠름).
  • 구간 assign(=대입) + 합 → 일반 lazy로 충분.
  • chmin/chmax가 진짜로 필요할 때만 비츠. 문제를 잘 보면 chmin이 단조라서 더
    단순한 구조로 환원되는 경우도 있다.

요약

  1. 노드에 sum, mx, mxc, se(chmin) / 대칭으로 mn, mnc, se2(chmax).
  2. chmin 세 경우: break(\(x\ge mx\)) / tag(\(se) / recurse(\(x\le se\)).
  3. add와 함께 쓸 땐 pushdown에서 add 먼저, min 캡 나중.
  4. 복잡도 \(O(N\log^2 N)\) 상각(개별은 느릴 수 있음).
  5. 엄격 두 번째 값, mxc, 오버플로, 센티넬을 조심.
Practice problem 구간 최솟값 갱신과 합 선택 25m
R00614

구간 최솟값 갱신과 합

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Practice problem 구간 클램프 합 선택 25m
R00624

구간 클램프 합

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기
Lesson 움직이는 최댓값과 melt 필수 8m

키네틱 세그먼트 트리란

키네틱 세그먼트 트리(Kinetic Segment Tree, 키네틱 히퍼)는 각 원소의 값이
전역 시간(파라미터) \(t\)의 일차 함수 \(y_i = a_i\ t + b_i\) 인 상황에서, \(t\)
단조 증가하는 동안 구간 최댓값(또는 최솟값)을 유지하는 자료 구조다. 동시에
개별 직선을 갱신할 수 있다.

핵심 전제는 시간이 오직 앞으로만 흐른다(질의 시각이 비감소)는 것이다. 이
단조성 덕분에 상각 분석이 성립한다.

아이디어: melt(certificate)

각 리프는 직선 하나. 각 내부 노드는

  • best: 현재 시각 \(t\)에서 그 구간의 최댓값을 주는 직선
  • melt: 미래의 어느 시각에 이 노드의 승자(best)가 바뀔 수 있는지 — 즉 두
    자식의 best 직선이 교차해 패자가 역전하는 가장 이른 시각

을 저장한다. 시간이 melt를 넘지 않는 한 노드의 best는 그대로 유효하므로 아무
것도 다시 계산할 필요가 없다
. 시간이 melt를 지나야 그 서브트리를 재계산한다.

시각 t 진행 →→→
노드: best=직선A, melt=t0   (t0 전까지 A가 승자)
   t가 t0 도달 → 자식 재계산 → best가 직선B로 바뀔 수 있음

heaten(가열) 연산

시간을 \(t\)로 전진시키는 연산을 heaten이라 부른다. 루트에서 시작해:

  • melt[node] > t 이면 이 서브트리의 승자는 아직 안 바뀜 → 중단.
  • 아니면 두 자식을 heaten한 뒤 pull로 이 노드를 재계산.

melt가 큰 서브트리는 통째로 건너뛰므로, 대부분의 시간 전진이 싸다.

복잡도 (상각)

일련의 heaten 연산 전체 비용은 상각으로

$$ O(n \log^2 n) $$

이다. 포텐셜 논증(각 직선이 포락선에서 승자가 되는 횟수가 제한됨)으로 증명되며,
개별 heaten 한 번은 \(O(n)\)까지 비쌀 수 있으나 전체 합이 보장된다.

다시 강조: 이 상각 보장은 시간 단조 증가가 전제다. 시간을 되돌리면(질의
\(t\)가 감소) 정확성과 복잡도가 모두 깨진다.

언제 쓰나

  • DP나 최적화에서 "여러 직선을 증가하는 질의점에서 평가"하면서, 도중에
    직선이 추가·변경되는 경우.
  • min-plus 합성류, 일부 CHT 변형 등 질의점이 단조 증가하는 라인 집합 최적화.

리 차오 트리와의 차이

  • 리 차오 트리: 삽입/질의 순서 제약 없음, 질의는 \(O(\log C)\), 하지만 직선
    삭제·변경은 까다롭다.
  • 키네틱 세그먼트 트리: 질의점이 단조 증가해야 하지만, 리프 직선의 갱신과
    함께 구간 최댓값을 상각 \(O(\log^2 n)\)에 유지한다.

구체적 예시

리프 3개: \(L_0: y=t\), \(L_1: y=-t+10\), \(L_2: y=2\). 전역 최댓값을 추적.

  • \(t=0\): 값 \(\{0, 10, 2\}\) → 최대 \(L_1=10\). 다음 교차(예: \(L_0\)\(L_1\)
    넘는 시각 \(t=5\))가 melt에 기록된다.
  • \(t=6\)으로 heaten: melt(5)를 지났으므로 재계산 → 이제 \(L_0=6 > L_1=4\), 최대는
    \(L_0=6\). melt는 다음 교차 시각으로 갱신.
Lesson 구현: heaten과 melt 관리 선택 8m

구현 (전역/구간 최댓값, 시간 증가)

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = (ll)4e18;

struct Line { ll a, b; ll operator()(ll t) const { return a * t + b; } };

const int MAXN = 100005;
Line best[4*MAXN];       // 현재 시각 curT에서 이 노드의 최댓값 직선
ll   melt[4*MAXN];       // curT 이후, best가 바뀔 수 있는 가장 이른 시각
int  n;
ll   curT = 0;           // 전역 시간 (오직 증가)
Line init[MAXN];         // 각 위치의 초기 직선 (1..n)

// hi가 현재 승자(hi(curT) >= lo(curT))일 때, lo가 hi를 '역전'하는 가장 이른 시각.
// 역전이 없으면 INF.
ll crossTime(const Line& hi, const Line& lo) {
    if (lo.a <= hi.a) return INF;                 // 기울기가 더 크지 않으면 영원히 못 넘음
    ll num = hi.b - lo.b, den = lo.a - hi.a;      // den > 0
    ll t = num / den;                             // 실제 교차 시각의 근사(내림)
    // 정수 반올림 보정: lo(t) > hi(t) 인 첫 정수 t 를 찾음 (몇 스텝 내 종료)
    while (lo(t) <= hi(t)) t++;
    return t;
}

void pull(int x) {
    int L = 2*x, R = 2*x+1;
    Line loser;
    if (best[L](curT) >= best[R](curT)) { best[x] = best[L]; loser = best[R]; }
    else                                { best[x] = best[R]; loser = best[L]; }
    melt[x] = min(melt[L], melt[R]);
    melt[x] = min(melt[x], crossTime(best[x], loser));  // 자식 best끼리의 역전 시각
}

void build(int x, int s, int e) {
    if (s == e) { best[x] = init[s]; melt[x] = INF; return; }  // 리프는 절대 안 녹음
    int m = (s+e)/2; build(2*x,s,m); build(2*x+1,m+1,e); pull(x);
}

// 시간이 curT 로 이미 갱신된 상태에서, melt 가 지난 노드만 재계산
void heaten(int x) {
    if (melt[x] > curT) return;      // 아직 안 바뀜 → 통째로 건너뜀 (성능의 핵심)
    heaten(2*x); heaten(2*x+1);      // 자식 먼저 갱신
    pull(x);                         // 그다음 이 노드 재계산
}
void advance(ll t) { curT = t; heaten(1); }   // 반드시 t >= 기존 curT

// 리프(위치 pos)의 직선을 nw 로 갱신
void update(int x, int s, int e, int pos, Line nw) {
    if (s == e) { best[x] = nw; melt[x] = INF; return; }
    int m = (s+e)/2;
    if (pos <= m) update(2*x, s, m, pos, nw);
    else          update(2*x+1, m+1, e, pos, nw);
    pull(x);                          // curT 기준으로 조상 재계산
}

// 구간 [l,r] 의 현재 시각 최댓값 (advance 후 호출)
ll query(int x, int s, int e, int l, int r) {
    if (r < s || e < l) return -INF;
    if (l <= s && e <= r) return best[x](curT);   // 이 노드 best 는 curT 기준 유효
    int m = (s+e)/2;
    return max(query(2*x, s, m, l, r), query(2*x+1, m+1, e, l, r));
}
// 전역 최댓값이면 그냥 best[1](curT).

정확성 포인트

  • 리프의 melt = INF: 직선 하나는 스스로 승자가 바뀔 일이 없다.
  • pull은 항상 curT 기준: 두 자식 best를 현재 시각에 평가해 승자·패자를
    정하고, 패자가 미래에 역전하는 시각을 crossTime으로 계산해 melt에 반영.
  • heaten은 자식 먼저, 그다음 pull: 자식이 최신이어야 부모를 옳게 재계산.
  • 건너뛰기가 성능의 핵심: melt[x] > curT면 서브트리 전체를 스킵. 매번
    전체를 재계산하면 상각 보장이 깨진다(가장 흔한 성능 버그).

최솟값 버전

부호를 뒤집어 직선 \((-a, -b)\)로 저장하고 결과에 \(-1\)을 곱하거나, pull의 비교를
<=로, 초기 비교값을 뒤집는다. 부호 뒤집기가 실수가 적다.

오버플로 / 교차 계산 주의

  • a * tlong long. 기울기·시간이 크면 __int128 고려.
  • crossTimewhile (lo(t) <= hi(t)) t++;는 정수 나눗셈 반올림을 보정하는
    안전 장치로, 실제로는 1~2회만 돈다. INF 반환 경로(lo.a <= hi.a)를 먼저
    처리해 0으로 나누는 일이 없게 한다.
Lesson 심화·응용: 상각 분석과 주의점 선택 8m

상각 분석 자세히

키네틱 세그먼트 트리의 비용은 heaten이 pull을 몇 번 부르느냐로 결정된다.
포텐셜 논증의 뼈대:

  • 한 내부 노드에서 승자가 바뀌는(melt를 지나는) 사건이 발생할 때마다 \(O(\log n)\)
    (경로) 재계산이 일어난다.
  • 전체에 걸쳐 승자 교체 횟수의 합이 \(O(n \log n)\)로 억제된다(두 직선은 한 번만
    교차하고, 각 노드에서 "현재 승자가 아닌 직선이 승자가 되는" 총 횟수가
    포락선의 복잡도로 제한됨).
  • 따라서 전체 heaten 비용이 \(O(n \log^2 n)\) 상각.

주의점(반복): 개별 heaten은 최악 \(O(n)\)일 수 있다. "한 번의 연산 최악 시간"
을 재는 상황에는 적합하지 않다. 또한 이 보장은 시간 단조 증가에서만
성립한다.

리 차오 트리 / CHT와의 선택 기준

상황 권장
삽입·질의 순서 임의, 삭제 없음 리 차오 트리
질의점 단조 증가 + 라인 갱신, 구간 최대 키네틱 세그먼트 트리
기울기·질의 모두 단조, 삽입만 단조 스택 CHT (가장 빠름)

키네틱 세그먼트 트리는 "라인들이 시간에 따라 움직이고, 질의 시각이 계속
커지는" 그림에 특화돼 있다. 이 전제가 없으면 리 차오 트리가 더 안전하다.

흔한 버그

  • 시간 비단조advance(t)를 이전보다 작은 t로 부르면 정확성·복잡도 붕괴.
    질의점을 정렬해 단조 증가로 만든 뒤 사용해야 한다(핵심 전제).
  • 매번 전체 재계산melt > curT 스킵을 빠뜨리면 상각이 깨져 \(O(nq)\)
    느려진다.
  • 교차 시각 오류 — 기울기 비교(lo.a <= hi.a → 역전 없음)와 정수 반올림
    보정을 빠뜨리면 melt가 틀려 승자가 늦게/일찍 바뀐다. 특히 두 직선이 현재
    같은 값이고 기울기만 다른 경계 케이스 확인.
  • 자식 갱신 순서 — heaten은 반드시 자식 먼저 재귀 후 부모 pull.
  • 리프 melt 미설정 — 리프의 melt는 INF여야 heaten이 리프에서 멈춘다.
  • 오버플로a*t, crossTime의 분자·분모 계산에서 long long/__int128.

확장

  • 구간 갱신: 여러 리프를 한 번에 바꾸려면 lazy가 필요하나, 키네틱 구조와
    lazy의 결합은 까다로워 보통 점 갱신 위주로 쓴다.
  • min-plus/최적화 DP: 전이가 "증가하는 파라미터에서 라인 집합의 최대/최소"
    형태로 정리되면 키네틱 세그먼트 트리로 상각 \(O(n\log^2 n)\)에 처리 가능.

요약

  1. 리프=직선, 내부 노드=(현재 승자 best, 승자 교체 예정 시각 melt).
  2. heaten(시간 전진)은 melt > curT 서브트리를 통째로 건너뜀.
  3. 전체 상각 \(O(n\log^2 n)\), 단 시간 단조 증가 전제, 개별은 느릴 수 있음.
  4. pull은 curT 기준 승자·패자 결정 + 역전 시각 계산. 교차·오버플로 주의.
Practice problem 시간을 달리는 직선들 선택 25m
R00660

시간을 달리는 직선들

힌트를 열기 전에 상태와 전이를 먼저 찾아보세요.

Unrated 레이팅 미적용 지금 풀기