코스

정렬과 탐색

정렬·이분탐색·투포인터·분할정복 등 핵심 탐색 패러다임.

Level 1 → Level 7 70 아이템 28 문제 42 강의 0 확인 문제
코스 진행도 0%
0 / 70 아이템 완료
01
Level 1 · Beginner

Beginner

정렬과 탐색 · Beginner 단계

0/5 완료
Lesson 내장 정렬의 개념과 언제 쓰는가 필수 8m 현재

정렬이란

정렬(sorting) 은 원소들을 어떤 기준(대소, 사전순 등)에 따라 한 줄로
줄 세우는 것입니다. 거의 모든 알고리즘 풀이의 첫 줄이 되는 전처리로,
"정렬해 놓고 보면 규칙이 보이는" 경우가 압도적으로 많습니다.

직접 정렬 알고리즘을 짤 일은 실전에서 거의 없습니다. 언어가 제공하는
내장 정렬(C++ std::sort, Python sorted/list.sort)이 빠르고
검증돼 있기 때문입니다. 이 강의는 "정렬을 어떻게 짜느냐"가 아니라
"내장 정렬을 정확히 불러 쓰는 법" 을 다룹니다.

복잡도

내장 정렬은 비교 기반이며 시간 복잡도는 \(O(N \log N)\)입니다. \(N = 10^6\)
정도까지도 순식간에 처리됩니다. 비교 함수가 \(O(1)\)이 아니라 문자열 비교처럼
\(O(L)\)이면 전체는 \(O(N L \log N)\)이 되니 주의하세요.

  • 원소 \(N\)개 정렬: \(O(N \log N)\)
  • \(O(N^2)\) 정렬(버블·삽입 등)은 \(N\)이 커지면 시간 초과. 절대 직접 짜지 마세요.

언제 정렬을 떠올리는가

정렬은 그 자체가 목적이기보다 다른 기법의 전제 조건인 경우가 많습니다.

  • 이분 탐색을 하려면 데이터가 정렬돼 있어야 합니다.
  • 투 포인터 / 그리디의 상당수는 "정렬 후" 성립합니다.
  • 중복 제거·최빈값·인접 원소 비교: 정렬하면 같은 값이 붙습니다.
  • "\(K\)번째로 큰/작은 값", "차이가 가장 작은 두 수" 같은 질문.

막혔을 때 "이 데이터를 정렬하면 뭐가 보이지?"를 먼저 던져 보세요.

가장 단순한 예

배열을 오름차순으로 정렬하기.

#include <bits/stdc++.h>
using namespace std;
int main() {
    vector<int> a = {5, 2, 9, 1, 5, 6};
    sort(a.begin(), a.end());        // 1 2 5 5 6 9
}
a = [5, 2, 9, 1, 5, 6]
a.sort()                              # 제자리 정렬: [1, 2, 5, 5, 6, 9]
b = sorted(a)                         # 새 리스트를 반환 (원본 보존)

sort(제자리)와 sorted(새 리스트 반환)의 차이를 기억하세요. C++의
sort는 항상 제자리 정렬이며 반복자 구간 [begin, end) 를 받습니다.

Lesson 비교 기준 지정: 비교 함수·키·구조체 정렬 선택 8m

오름차순이 전부가 아니다

실전에서는 "무엇을 기준으로" 정렬하느냐가 관건입니다. 내림차순, 여러 필드
정렬, 구조체 정렬을 모두 내장 정렬로 해결합니다.

내림차순

sort(a.begin(), a.end(), greater<int>());   // 큰 값부터
a.sort(reverse=True)
b = sorted(a, reverse=True)

키(key)로 정렬

각 원소를 어떤 값으로 "환산"해 그 값 기준으로 줄 세웁니다. 절댓값 기준
정렬을 예로 들면:

// C++에는 key가 없으므로 비교 함수(람다)로 표현
sort(a.begin(), a.end(), [](int x, int y) {
    return abs(x) < abs(y);          // |x| 가 작은 것이 앞
});
a.sort(key=abs)                       # 절댓값 기준
words.sort(key=len)                   # 길이 기준

Python의 key원소당 한 번만 호출돼 캐시되므로 비교 함수보다
빠르고 안전합니다. 되도록 key를 쓰세요.

구조체·튜플·다중 기준 정렬

"나이 오름차순, 같으면 이름 사전순" 같은 다중 기준은 튜플로 표현합니다.

struct Person { int age; string name; };
vector<Person> v;
sort(v.begin(), v.end(), [](const Person& a, const Person& b) {
    if (a.age != b.age) return a.age < b.age;   // 1순위: 나이 오름차순
    return a.name < b.name;                      // 2순위: 이름 사전순
});

C++ pair/tuple은 사전식 비교가 기본 제공되므로, 순서만 맞추면 비교 함수가
필요 없습니다.

vector<pair<int,int>> p;
sort(p.begin(), p.end());             // first 오름차순, 같으면 second 오름차순
people = [(30, "kim"), (20, "lee"), (30, "ahn")]
people.sort()                         # 튜플 사전식: 나이→이름
# 나이는 오름, 이름은 내림 같은 혼합 기준:
people.sort(key=lambda x: (x[0], x[1]))          # 둘 다 오름
nums.sort(key=lambda x: (-x[0], x[1]))           # 첫째 내림, 둘째 오름

여러 기준의 방향이 섞이면 Python은 key음수 부호 트릭을(수치일 때)
쓰고, C++은 비교 함수에서 부등호 방향을 필드마다 지정합니다.

안정 정렬(stable sort)

안정 정렬은 같은 키를 가진 원소들의 원래 순서를 보존합니다. Python의
sort/sorted는 항상 안정입니다. C++ std::sort불안정이므로 순서
보존이 필요하면 stable_sort를 씁니다.

stable_sort(v.begin(), v.end(), cmp); // 같은 키는 입력 순서 유지

안정성은 "이미 A 기준으로 정렬된 것을 B 기준으로 다시 정렬해 2차 기준을
살리는" 다단계 정렬에서 중요합니다.

Lesson 정렬 함정과 실전 팁 — 비교 함수는 엄격 약순서여야 한다 선택 8m

비교 함수의 철칙: 엄격 약순서(strict weak ordering)

C++ sort에 넘기는 비교 함수 cmp(a, b)"a가 b보다 엄격히 앞서면
true"
를 반환해야 하며 다음을 만족해야 합니다.

  • cmp(a, a)항상 false (자기 자신엔 < 사용 금지, <= 쓰면 안 됨).
  • cmp(a,b) 가 true면 cmp(b,a) 는 false.

이 규칙을 어기면(예: return a <= b;) 표준 라이브러리가 정의되지 않은
동작
을 일으켜 런타임 크래시가 나기도 합니다. 흔한 실수입니다.

// 틀림 — 등호를 넣으면 엄격 약순서 위반
sort(a.begin(), a.end(), [](int x, int y){ return x <= y; }); // 위험!
// 맞음
sort(a.begin(), a.end(), [](int x, int y){ return x <  y; });

비교 함수 안의 오버플로

내림차순을 return b - a; 처럼 뺄셈으로 구현하면 안 됩니다. 첫째로 비교
함수는 bool을 반환해야 하고, 둘째로 b - aint 범위를 넘으면 부호가
뒤집힙니다. 항상 부등호 비교로 쓰세요.

// 나쁨: 오버플로 + 타입 오류
// 좋음:
sort(a.begin(), a.end(), [](ll x, ll y){ return x > y; });

Python: cmp_to_key

Python 3는 비교 함수 인자를 직접 받지 않습니다. 꼭 필요하면
functools.cmp_to_key로 감쌉니다(느리므로 key로 표현 가능하면 그쪽이 우선).

from functools import cmp_to_key
def cmp(a, b):
    return -1 if a < b else (1 if a > b else 0)
a.sort(key=cmp_to_key(cmp))

문자열을 이어 붙여 가장 큰 수를 만드는 "\(a{+}b\) vs \(b{+}a\)" 류 문제는 이
cmp_to_key 패턴의 대표 사례입니다.

자주 만나는 함정 정리

  • 부분 정렬로 충분한데 전체 정렬 — 상위 \(K\)개만 필요하면
    nth_element(C++)나 힙을 쓰면 \(O(N)\) 또는 \(O(N \log K)\)로 줄어듭니다.
  • 정렬 후 인덱스 소실 — 원래 위치가 필요하면 (값, 인덱스) 쌍으로 묶어
    정렬하세요.
  • 문자열 정렬은 사전순 — 숫자 문자열 "10" < "9"(사전순)에 주의. 숫자로
    비교하려면 정수로 변환 후 정렬.
  • 부동소수 비교 — 오차가 있으면 근소한 차이의 순서가 흔들릴 수 있습니다.
  • 큰 구조체 복사 비용 — C++에서 무거운 객체는 인덱스만 정렬하거나
    포인터/참조를 정렬해 복사 비용을 줄입니다.

요약

  • 실전 정렬은 곧 내장 정렬 호출 + 비교 기준 설계.
  • C++는 비교 함수(엄격 약순서!), Python은 key를 우선 사용.
  • 다중 기준은 튜플, 순서 보존은 안정 정렬.
Practice problem 수 정렬하기 2 — 퀵정렬 킬러 선택 25m
R02012

수 정렬하기 2 — 퀵정렬 킬러

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

Unrated 레이팅 미적용 지금 풀기
Practice problem 등교 선택 25m
KOI00018

등교

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

Bronze IV 브론즈 IV 지금 풀기
02
Level 2 · Solver

Solver

정렬과 탐색 · Solver 단계

0/35 완료
Lesson 정렬의 원리와 복잡도 — 비교 정렬의 한계 필수 8m

왜 원리를 알아야 하나

실전에선 내장 정렬을 쓰지만, \(O(N \log N)\)인지, 안정성이 무엇인지,
언제 \(O(N)\) 정렬이 가능한지를 알아야 정렬을 응용한 문제(역전 세기, 좌표
압축, 스위핑 등)를 설계할 수 있습니다.

비교 정렬의 하한 \(\Omega(N \log N)\)

원소를 오직 비교로만 구분하는 정렬은 어떤 알고리즘도
\(\Omega(N \log N)\)번의 비교가 필요합니다. 직관은 이렇습니다. \(N\)개의 서로
다른 원소가 만들 수 있는 순서(순열)는 \(N!\)가지이고, 비교 한 번은 후보를
많아야 절반으로 줄입니다. 모든 순열을 구별하려면

$$ \log_2(N!) = \Theta(N \log N) $$

번의 비교가 필요합니다. 따라서 병합 정렬·힙 정렬의 \(O(N \log N)\)
비교 정렬의 이론적 최선입니다.

주요 비교 정렬 한눈에

알고리즘 평균 최악 추가 메모리 안정성
삽입 정렬 \(O(N^2)\) \(O(N^2)\) \(O(1)\) 안정
병합 정렬 \(O(N \log N)\) \(O(N \log N)\) \(O(N)\) 안정
퀵 정렬 \(O(N \log N)\) \(O(N^2)\) \(O(\log N)\) 불안정
힙 정렬 \(O(N \log N)\) \(O(N \log N)\) \(O(1)\) 불안정
  • 삽입 정렬: 느리지만 거의 정렬된 데이터나 아주 작은 \(N\)에선 오히려 빠릅니다.
    실제 라이브러리도 작은 구간은 삽입 정렬로 마무리합니다.
  • 병합 정렬: 최악에도 \(O(N \log N)\) 보장 + 안정. 추가 메모리 \(O(N)\) 필요.
  • 퀵 정렬: 캐시 친화적이라 평균이 가장 빠르지만 피벗이 나쁘면 \(O(N^2)\).
  • 힙 정렬: 추가 메모리 없이 최악 보장. 상수가 커서 평균 속도는 뒤처짐.

안정성이란

안정 정렬은 키가 같은 원소들의 상대 순서를 보존합니다. 다단계 정렬
("먼저 이름순, 그다음 점수순으로 재정렬해 동점자는 이름순 유지")에서 핵심
성질입니다. 병합·삽입은 안정, 퀵·힙은 불안정합니다.

실전 라이브러리의 정체

C++ std::sort는 대개 인트로소트(introsort) — 퀵 정렬로 시작해 재귀가
깊어지면 힙 정렬로 전환(최악 \(O(N \log N)\) 보장)하고 작은 구간은 삽입
정렬로 처리합니다. std::stable_sort는 병합 정렬 계열입니다. Python의
sort팀소트(Timsort) — 병합 정렬 기반의 안정 정렬로, 실제 데이터의
"이미 정렬된 조각(run)"을 활용해 매우 빠릅니다.

Lesson 병합 정렬·퀵 정렬 직접 구현하기 선택 8m

병합 정렬 (merge sort)

분할 정복의 대표. 반으로 쪼개 각각 정렬한 뒤 병합(merge) 합니다.
최악에도 \(O(N \log N)\)이며 안정입니다. 역전 카운팅 등 응용의 토대라
반드시 손으로 짤 수 있어야 합니다.

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

void merge_sort(vector<int>& a, int lo, int hi, vector<int>& tmp) {
    if (hi - lo <= 1) return;              // 원소 0~1개면 정렬됨
    int mid = (lo + hi) / 2;
    merge_sort(a, lo, mid, tmp);
    merge_sort(a, mid, hi, tmp);
    int i = lo, j = mid, k = lo;
    while (i < mid && j < hi)              // 두 정렬된 절반을 병합
        tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];  // <= 로 안정성 유지
    while (i < mid) tmp[k++] = a[i++];
    while (j < hi)  tmp[k++] = a[j++];
    for (int t = lo; t < hi; t++) a[t] = tmp[t];
}
int main() {
    vector<int> a = {5,2,9,1,5,6};
    vector<int> tmp(a.size());
    merge_sort(a, 0, a.size(), tmp);       // 반열린 구간 [0, n)
}

병합에서 a[i] <= a[j]등호가 안정성을 보장합니다. <로 바꾸면
불안정해집니다.

def merge_sort(a):
    if len(a) <= 1:
        return a
    mid = len(a) // 2
    left, right = merge_sort(a[:mid]), merge_sort(a[mid:])
    res, i, j = [], 0, 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            res.append(left[i]); i += 1
        else:
            res.append(right[j]); j += 1
    res.extend(left[i:]); res.extend(right[j:])
    return res

퀵 정렬 (quicksort)

피벗을 하나 골라 그보다 작은 것/큰 것으로 나눈 뒤 재귀. 평균 \(O(N \log N)\),
피벗이 매번 최악이면 \(O(N^2)\). 실전에서는 피벗을 무작위로 골라 최악을 피합니다.

void quick_sort(vector<int>& a, int lo, int hi) {   // 닫힌 구간 [lo, hi]
    if (lo >= hi) return;
    int p = a[lo + rand() % (hi - lo + 1)];          // 무작위 피벗
    int i = lo, j = hi;
    while (i <= j) {                                  // Hoare 스타일 분할
        while (a[i] < p) i++;
        while (a[j] > p) j--;
        if (i <= j) swap(a[i++], a[j--]);
    }
    quick_sort(a, lo, j);
    quick_sort(a, i, hi);
}

무작위 피벗이 없으면 이미 정렬된 입력에서 \(O(N^2)\)로 저격당합니다.
대회에서 직접 짠 퀵 정렬이 저격받는 사례가 있으니 무작위화는 필수입니다.

어느 것을 언제

  • 안정성이 필요하거나 최악 보장이 필요 → 병합 정렬.
  • 순수 속도·메모리 절약 → 퀵/힙(실전은 그냥 내장 정렬).
  • 응용(역전 수, 분할 정복 뼈대) → 병합 정렬 골격을 재사용.
Lesson 선형 시간 정렬과 안정성 활용 — 계수·기수 정렬 선택 8m

비교를 넘어서: \(O(N)\) 정렬

비교 정렬의 하한은 \(\Omega(N \log N)\)이지만, 비교를 하지 않고 값 자체를
이용
하면 그 벽을 넘을 수 있습니다. 값의 범위가 작을 때 유효합니다.

계수 정렬 (counting sort)

값의 범위가 \([0, K]\)로 작을 때, 각 값의 개수를 세어 곧바로 위치를
정합니다. 시간 \(O(N + K)\), 값 범위가 원소 수와 비슷하면 사실상 \(O(N)\)입니다.

// 0..K 범위 정수 계수 정렬 (안정 버전)
vector<int> counting_sort(const vector<int>& a, int K) {
    vector<int> cnt(K + 1, 0), out(a.size());
    for (int x : a) cnt[x]++;
    for (int v = 1; v <= K; v++) cnt[v] += cnt[v - 1];   // 누적합 = 끝 위치
    for (int i = (int)a.size() - 1; i >= 0; i--)         // 뒤에서 순회 → 안정
        out[--cnt[a[i]]] = a[i];
    return out;
}
def counting_sort(a, K):
    cnt = [0] * (K + 1)
    for x in a:
        cnt[x] += 1
    out = []
    for v in range(K + 1):
        out.extend([v] * cnt[v])
    return out

뒤에서부터 채우는 것이 안정성의 비결입니다. 값 범위 \(K\)가 크면
(\(10^9\) 등) 메모리가 폭발하니 쓸 수 없습니다 — 그때는 좌표 압축
사용하거나 비교 정렬로 돌아갑니다.

기수 정렬 (radix sort)

여러 자리 수를 낮은 자리부터 계수 정렬로 반복 정렬합니다. 각 패스가
안정 정렬이어야 전체가 정확해집니다. \(d\)자리 수 \(N\)개면 \(O(d(N + b))\)
(\(b\)는 진법). 32비트 정수를 8비트씩 4패스로 정렬하는 식입니다.

안정성의 실전 활용: 다중 기준 정렬

안정 정렬을 이용하면 다중 기준 정렬을 "낮은 우선순위부터 차례로"
정렬해 만들 수 있습니다.

# "점수 내림차순, 동점은 이름 오름차순"
people.sort(key=lambda p: p.name)          # 2순위 먼저
people.sort(key=lambda p: -p.score)        # 1순위 나중 (안정 정렬이라 이름순 유지)

Timsort가 안정이기에 가능한 기법입니다. C++이라면 stable_sort로 같은
효과를 냅니다.

정리: 무엇을 고를까

  • 일반적인 경우 → 내장 정렬(\(O(N \log N)\)).
  • 값 범위가 작은 정수 → 계수/기수 정렬로 \(O(N)\).
  • 다단계 기준 → 안정 정렬 활용.
  • 값 범위가 크고 순위만 필요 → 좌표 압축 후 처리.

정렬 알고리즘 자체를 짜기보다, 각 정렬의 성질(복잡도·안정성·전제)
알고 상황에 맞게 고르는 것이 이 단원의 목표입니다.

Practice problem 수 정렬하기 2 — 퀵정렬 킬러 선택 25m
R02012

수 정렬하기 2 — 퀵정렬 킬러

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

Unrated 레이팅 미적용 지금 풀기
Practice problem 등교 선택 25m
KOI00018

등교

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

Bronze IV 브론즈 IV 지금 풀기
Lesson 반씩 줄이는 탐색의 원리와 단조성 필수 8m

이분 탐색이란

이분 탐색(binary search)정렬된(더 정확히는 단조로운) 구간에서
찾는 범위를 매번 절반으로 줄여 답을 찾는 기법입니다. \(N\)개 중에서
\(O(\log N)\)번 만에 목표를 찾습니다. \(N = 10^9\)이어도 약 \(30\)번이면 끝납니다.

직관

\(1\)부터 \(100\) 사이 숫자를 맞히는 업다운 게임을 떠올려 보세요. 매번 가운데
값을 부르고 "위/아래" 답을 들으면 후보가 절반으로 줄어, 최대 \(7\)
(\(\lceil \log_2 100 \rceil\))이면 맞힙니다. 이것이 이분 탐색입니다.

핵심 전제: 단조성(monotonicity)

이분 탐색이 성립하는 유일한 조건은 "찾는 성질이 한 방향으로 단조"라는
것입니다.

  • 값 탐색: 배열이 정렬돼 있어야 함 (오름차순이면 커지는 방향으로 단조).
  • 판정 탐색: 어떤 경계 \(x\)를 기준으로 조건이 false … false, true … true
    처럼 한 번만 바뀌면 됩니다. 이 경계를 이분 탐색으로 찾습니다.

정렬돼 있지 않거나 조건이 들쭉날쭉하면 이분 탐색은 틀린 답을 냅니다.
"정렬 먼저"가 이 단원 전체의 대전제입니다.

복잡도

  • 한 번 탐색: \(O(\log N)\).
  • 정렬까지 포함하면 \(O(N \log N)\)(정렬) \(+ Q \cdot O(\log N)\)(질의 \(Q\)개).
  • \(N\)\(10^5\) 이상인데 \(O(N^2)\)가 필요해 보이는 상황이 이분 탐색의 신호입니다.

가장 단순한 예: 값이 있는가

정렬된 배열 a에서 target이 존재하는지 확인.

bool found(const vector<int>& a, int target) {
    int lo = 0, hi = (int)a.size() - 1;   // 닫힌 구간 [lo, hi]
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;      // 오버플로 안전한 중간값
        if (a[mid] == target) return true;
        if (a[mid] < target) lo = mid + 1; // 오른쪽 절반
        else                 hi = mid - 1; // 왼쪽 절반
    }
    return false;
}

(lo + hi) / 2는 두 값이 크면 오버플로할 수 있어 lo + (hi - lo) / 2
씁니다. 이 습관 하나가 많은 버그를 예방합니다.

언제 떠올리나

  • "정렬된 데이터에서 찾기/개수 세기"
  • "조건을 만족하는 경계(최초/최후 위치)를 찾아라"
  • "최댓값을 최소화 / 최솟값을 최대화" (→ 매개 변수 탐색)
Lesson lower_bound·upper_bound와 실전 구현 선택 8m

off-by-one을 이기는 법

이분 탐색은 원리는 쉬워도 off-by-one 실수가 잦습니다. 구간 표기법을
하나로 통일하는 것이 최선의 방어입니다. 여기서는 반열린 구간
[lo, hi)
스타일을 권장합니다.

lower_bound: target 이상 첫 위치

int lower_bound_impl(const vector<int>& a, int target) {
    int lo = 0, hi = (int)a.size();        // hi 는 "끝 다음" (반열림)
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] < target) lo = mid + 1; // mid 부족 → 버림
        else                 hi = mid;     // mid 후보 → 남김
    }
    return lo;                             // target 이상인 첫 인덱스
}

upper_bound: target 초과 첫 위치

조건의 등호 위치만 바꾸면 됩니다.

int upper_bound_impl(const vector<int>& a, int target) {
    int lo = 0, hi = (int)a.size();
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] <= target) lo = mid + 1; // <= 로 바꾼 것이 전부
        else                  hi = mid;
    }
    return lo;
}

두 함수로 값의 개수존재 여부를 모두 얻습니다.

$$ \text{count}(x) = \text{upper\_bound}(x) - \text{lower\_bound}(x) $$

실전 권장: 라이브러리를 쓰자

직접 짜기보다 검증된 라이브러리가 안전합니다.

#include <algorithm>
sort(a.begin(), a.end());                        // 반드시 정렬!
auto lo = lower_bound(a.begin(), a.end(), x);    // x 이상 첫 위치
auto hi = upper_bound(a.begin(), a.end(), x);    // x 초과 첫 위치
int idx     = lo - a.begin();                    // 인덱스로 변환
int count_x = hi - lo;                           // x 의 개수
bool exists = (lo != a.end() && *lo == x);       // 존재 여부
import bisect
a.sort()                                   # 반드시 정렬!
lo = bisect.bisect_left(a, x)              # x 이상 첫 위치 (lower_bound)
hi = bisect.bisect_right(a, x)             # x 초과 첫 위치 (upper_bound)
count_x = hi - lo
exists  = lo < len(a) and a[lo] == x
idx     = bisect.insort(a, x)              # 정렬 유지하며 삽입 (필요 시)

좌표에 없는 값 다루기

lower_bound가 돌려주는 위치는 "그 값을 넣는다면 들어갈 자리"입니다.
x보다 작은 값 중 가장 큰 것(직전 원소)이 필요하면 lower_bound(x) - 1
보되, 그 인덱스가 \(0\) 미만이 되는 경계(모두가 \(x\) 이상)를 반드시 확인하세요.

i = bisect.bisect_left(a, x)
prev = a[i - 1] if i > 0 else None         # x 미만 최대값
succ = a[i]     if i < len(a) else None    # x 이상 최소값
Lesson 심화: 답을 이분 탐색하기, 실수 탐색, 그리고 함정 선택 8m

값이 아니라 "답"을 이분 탐색한다

이분 탐색의 진짜 위력은 답 자체를 탐색하는 데 있습니다. "가능한가?"를
묻는 판정 ok(x)가 단조(x가 커질수록 쭉 가능/불가능)이면, 그 경계를 이분
탐색으로 찾습니다. 이것이 매개 변수 탐색(parametric search) 의 뼈대입니다.

정수 답: 최댓값 골격

"ok(x)가 참인 가장 \(x\)"를 찾을 때는 mid올림해야 합니다.

int lo = 0, hi = 1'000'000'000, ans = lo;
while (lo <= hi) {
    int mid = lo + (hi - lo) / 2;
    if (ok(mid)) { ans = mid; lo = mid + 1; } // 되면 더 키워 본다
    else         hi = mid - 1;                // 안 되면 줄인다
}
// ans = 가능한 최대 x

반열린 스타일이라면 mid = lo + (hi - lo + 1) / 2처럼 올림해야 lo = mid에서
무한 루프에 빠지지 않습니다.

정수 답: 최솟값 골격

"ok(x)가 참인 가장 작은 \(x\)"는 방향을 뒤집습니다.

int lo = 0, hi = 1'000'000'000;
while (lo < hi) {
    int mid = lo + (hi - lo) / 2;      // 내림
    if (ok(mid)) hi = mid;             // 되면 더 작게
    else         lo = mid + 1;         // 안 되면 키운다
}
// lo == hi 가 답

실수 답: 고정 횟수 반복

답이 실수면 "lo < hi" 대신 충분히 많은 반복으로 정밀도를 맞춥니다.
매 반복마다 구간이 절반이 되므로 \(100\)번이면 \(2^{-100}\)까지 좁혀집니다.

double lo = 0, hi = 1e9;
for (int it = 0; it < 100; it++) {        // eps 비교보다 안전
    double mid = (lo + hi) / 2;
    if (ok(mid)) lo = mid; else hi = mid;
}
// lo (또는 hi) 가 답
lo, hi = 0.0, 1e9
for _ in range(100):
    mid = (lo + hi) / 2
    if ok(mid):
        lo = mid
    else:
        hi = mid

while (hi - lo > eps)도 가능하지만, 부동소수 정체로 무한 루프가 날 수
있어 고정 횟수가 더 안전합니다.

흔한 함정 총정리

  • 정렬 안 함 — 값 탐색의 대전제. 빼먹으면 답이 엉망.
  • off-by-onelo <= hi(닫힘)와 lo < hi(반열림)를 섞지 말고 한 스타일로 통일.
  • 무한 루프 — 최댓값 탐색에서 mid를 올림하지 않아 lo = mid가 진전 없이 반복.
  • 오버플로(lo + hi) / 2 대신 lo + (hi - lo) / 2, 판정 내부 합은 long long.
  • lower vs upper 혼동 — "이상"은 lower, "초과"는 upper.
  • 탐색 범위(hi)를 너무 작게 — 이론적 상한을 넉넉히 잡을 것.
  • 단조성 미확인 — 판정이 단조가 아니면 이분 탐색 자체가 틀립니다. 먼저 논증하세요.

패턴 알아보기

  • "정렬된 데이터에서 찾기/세기" → lower_bound/upper_bound.
  • "\(N\)이 큰데 \(O(N^2)\)가 필요해 보임" → 정렬 후 이분 탐색.
  • "최대를 최소화 / 최소를 최대화 + 판정이 단조" → 답을 이분 탐색(매개 변수 탐색).
Practice problem 알파카컵 1회: B - 알파카 농장 선택 25m
A00002

알파카컵 1회: B - 알파카 농장

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

Silver II 실버 II 지금 풀기
Practice problem 건초 더미 선택 25m
KOI00005

건초 더미

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

Unrated 레이팅 미적용 지금 풀기
Lesson 전처리로 구간 합을 O(1)에 필수 8m

누적 합이란

누적 합(prefix sum) 은 배열의 "앞에서부터의 합"을 한 번 미리 계산해 두어,
이후 임의 구간의 합을 \(O(1)\)에 답하는 전처리 기법입니다. 구간 질의가 많을 때
필수 도구입니다.

아이디어

\(P_i = a_0 + a_1 + \dots + a_{i-1}\) (앞 \(i\)개의 합, \(P_0 = 0\))을 만들어 두면,
구간 \([l, r]\)의 합은 뺄셈 한 번으로 나옵니다.

$$ \sum_{k=l}^{r} a_k = P_{r+1} - P_l $$

전처리 \(O(N)\), 이후 질의마다 \(O(1)\). 질의가 \(Q\)개면 순진한 방법 \(O(NQ)\)
\(O(N + Q)\)로 줄어듭니다.

언제 쓰는가

  • "구간의 합/평균을 여러 번 물어본다" → 즉시 누적 합.
  • "값을 바꾸지 않고" 여러 구간 질의만 있는 정적 배열.
  • 슬라이딩/투 포인터로 안 되는(음수 포함 등) 구간 합 문제의 토대.
  • 2차원 격자에서 직사각형 영역 합.

값이 자주 바뀌면 누적 합 대신 펜윅 트리/세그먼트 트리를 씁니다. 누적 합은
"정적 배열 + 많은 구간 질의"에 최적입니다.

1차원 누적 합 구현

1-based 인덱스\(P_0 = 0\)을 두면 경계가 깔끔합니다.

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
int main() {
    int n, q; cin >> n >> q;
    vector<ll> a(n + 1), P(n + 1, 0);
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        P[i] = P[i - 1] + a[i];       // 앞 i개의 합
    }
    while (q--) {
        int l, r; cin >> l >> r;       // 1-based 구간 [l, r]
        cout << P[r] - P[l - 1] << '\n';
    }
}
import sys
input = sys.stdin.readline
n, q = map(int, input().split())
a = list(map(int, input().split()))
P = [0] * (n + 1)
for i in range(n):
    P[i + 1] = P[i] + a[i]            # P[i] = 앞 i개 합
out = []
for _ in range(q):
    l, r = map(int, input().split())  # 1-based [l, r]
    out.append(P[r] - P[l - 1])
print("\n".join(map(str, out)))

반드시 기억할 것

  • 오버플로: 구간 합은 쉽게 커집니다. C++은 long long 필수.
  • 경계: \(P_0 = 0\)을 두면 \(l = 1\)일 때 \(P_{l-1} = P_0 = 0\)으로 자연스럽습니다.
  • 인덱스 규약(0-based/1-based)을 한 가지로 통일하세요. 혼용이 버그의 근원.
Lesson 2차원 누적 합과 차이 배열 선택 8m

2차원 누적 합

격자에서 직사각형 영역의 합을 \(O(1)\)에 구합니다. 포함-배제로 계산합니다.

$$ P_{i,j} = \sum_{x

// 1-based, P[i][j] = (1..i, 1..j) 영역 합
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= m; j++)
        P[i][j] = a[i][j] + P[i-1][j] + P[i][j-1] - P[i-1][j-1];

// 직사각형 (r1,c1)-(r2,c2) 합
ll area = P[r2][c2] - P[r1-1][c2] - P[r2][c1-1] + P[r1-1][c1-1];

- P[i-1][j-1]을 다시 더해 주는 포함-배제가 핵심입니다. 두 번 빠진
겹침 영역을 한 번 복원합니다.

차이 배열(difference array) — 구간 갱신을 \(O(1)\)

누적 합의 쌍대입니다. "구간 \([l, r]\)에 값 \(v\)를 더하기"를 매번 \(O(N)\)
대신, 차이 배열에 표시만 해 두고 마지막에 누적 합으로 복원해 전체 \(O(N + Q)\)
처리합니다.

$$ D_l \mathrel{+}= v, \quad D_{r+1} \mathrel{-}= v, \qquad a_i = \sum_{k \le i} D_k $$

vector<ll> D(n + 2, 0);
// 각 갱신: 구간 [l, r] 에 +v (1-based)
D[l] += v;  D[r + 1] -= v;
// 모든 갱신 후 한 번에 복원
vector<ll> a(n + 1);
for (int i = 1; i <= n; i++) a[i] = a[i - 1] + D[i];
D = [0] * (n + 2)
for l, r, v in updates:              # 1-based
    D[l] += v
    D[r + 1] -= v
a = [0] * (n + 1)
for i in range(1, n + 1):
    a[i] = a[i - 1] + D[i]

r + 1 인덱스 때문에 배열 크기를 \(n + 2\)로 잡는 것에 주의하세요. 오프바이원의
단골 지점입니다.

2차원 차이 배열(imos 법)

직사각형 영역에 한꺼번에 값을 더하는 갱신도, 네 꼭짓점에 부호를 찍고 2차원
누적 합으로 복원하면 갱신당 \(O(1)\)입니다.

// 직사각형 (r1,c1)-(r2,c2) 에 +v
D[r1][c1]     += v;  D[r1][c2+1]   -= v;
D[r2+1][c1]   -= v;  D[r2+1][c2+1] += v;
// 이후 2차원 누적 합으로 실제 값 복원
for (int i = 1; i <= n; i++)
    for (int j = 1; j <= m; j++)
        D[i][j] += D[i-1][j] + D[i][j-1] - D[i-1][j-1];

겹치는 구간 갱신이 많은 문제(좌석 예약, 지도 칠하기)에서 위력적입니다.

Lesson 심화·함정: 접두사 합 응용과 실수 정리 선택 8m

구간 합을 넘어서는 접두사 아이디어

누적 합은 단순 합만이 아닙니다. 접두사 XOR, 접두사 최댓값(제한적),
접두사 개수 등으로 확장됩니다.

  • 접두사 XOR: \(PX_i = a_0 \oplus \dots \oplus a_{i-1}\), 구간 XOR은
    \(PX_{r+1} \oplus PX_l\). XOR은 뺄셈이 곧 자기 자신이라 성립합니다.
  • "합이 \(S\)인 구간 수" / "합이 \(S\)로 나누어떨어지는 구간 수": 접두사 합의
    값(또는 나머지)을 해시맵으로 세면서 진행하면 \(O(N)\)입니다.
// 합이 정확히 S인 연속 구간의 개수 (음수 포함 가능)
long long count_sum_equal(const vector<int>& a, long long S) {
    unordered_map<long long,long long> freq;
    freq[0] = 1;                       // 빈 접두사
    long long pre = 0, ans = 0;
    for (int x : a) {
        pre += x;
        ans += freq[pre - S];          // pre - prev = S 인 prev 개수
        freq[pre]++;
    }
    return ans;
}
from collections import defaultdict
def count_sum_equal(a, S):
    freq = defaultdict(int); freq[0] = 1
    pre = ans = 0
    for x in a:
        pre += x
        ans += freq[pre - S]
        freq[pre] += 1
    return ans

이 "접두사 합 + 해시맵" 패턴은 음수가 섞여 투 포인터가 안 될 때의 정석
대안입니다.

흔한 함정

  • 오버플로 — 누적 합은 \(N \cdot \max\)까지 커집니다. long long 필수.
  • 인덱스 규약 혼용 — 0-based와 1-based를 섞으면 off-by-one. 하나로 통일.
  • 차이 배열 크기D[r+1] 때문에 \(n + 2\) 크기로. 범위 밖 접근 주의.
  • 2차원 포함-배제 부호+ P[i-1][j-1] 복원 항을 빠뜨리는 실수.
  • 질의가 정적이 아님 — 값이 바뀌면 누적 합은 매번 재계산 \(O(N)\)이 되어
    비효율. 갱신이 있으면 펜윅/세그먼트 트리로 전환.
  • 평균·개수 질의 — 합뿐 아니라 개수 접두사(조건 만족 원소 수)도 같은 원리.

무엇을 언제

상황 도구
정적 배열 + 구간 합 질의 다수 1D 누적 합
정적 격자 + 직사각형 합 2D 누적 합
구간 더하기 갱신 다수 + 마지막에 조회 차이 배열(imos)
갱신과 질의가 섞임 펜윅/세그먼트 트리
합 조건 구간 세기(음수 포함) 접두사 합 + 해시맵

요약

누적 합의 정신은 "한 번 전처리로 반복 질의를 \(O(1)\)"입니다. 정방향(합
질의)과 역방향(차이 배열, 구간 갱신)을 함께 익히면 정적 구간 문제 대부분을
선형 시간에 처리할 수 있습니다.

Practice problem 알파카컵 2회: C - 알파카의 조명 설치 선택 25m
A00013

알파카컵 2회: C - 알파카의 조명 설치

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

Gold IV 골드 IV 지금 풀기
Practice problem 등교 선택 25m
KOI00018

등교

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

Bronze IV 브론즈 IV 지금 풀기
Lesson 1강 · 개념 — 이모스법과 구간 더하기 O(1) 필수 8m

차이 배열이란

차이 배열(difference array) 은 원본 배열 a인접 원소 차이를 담는
배열 d입니다. 정의는 간단합니다.

$$ d[0] = a[0], \qquad d[i] = a[i] - a[i-1] \ (i \ge 1) $$

핵심은 이 역연산입니다. d누적 합이 곧 원본이 됩니다.

$$ a[i] = \sum_{k=0}^{i} d[k] $$

d를 앞에서부터 누적하면 a복원됩니다. 누적 합 배열(prefix sum)이
"구간 합을 \(O(1)\)에 묻는" 도구라면, 차이 배열은 그 쌍대(dual) 로 "구간을
\(O(1)\)에 더하는" 도구입니다. 일본식 이름으로 이모스법(いもす法) 이라고도
부릅니다.


왜 구간 더하기가 O(1)인가

구간 \([l, r]\)에 값 \(v\)를 더하고 싶다고 합시다. 단순 배열은 \(r - l + 1\)칸을
일일이 더해 \(O(N)\)입니다. 하지만 차이 배열에서는 양 끝 두 곳만 건드립니다.

$$ d[l] \mathrel{+}= v, \qquad d[r+1] \mathrel{-}= v $$

연산 단순 배열 차이 배열
구간 더하기 \(O(N)\) \(O(1)\)
특정 위치 값 \(O(1)\) 복원 후 \(O(1)\) (복원은 \(O(N)\) 1회)

불변식: d[l] += v는 "\(l\)번째 지점부터 값이 \(v\)만큼 올라간다", d[r+1] -= v
"\(r+1\)번째 지점부터 그 상승분을 되돌린다"를 뜻합니다. 누적 합을 취하면 정확히
\([l, r]\) 구간에서만 \(v\)가 더해진 결과가 나옵니다.

이 기법은 갱신(구간 더하기)이 여러 번 몰려 있고, 최종 배열을 한 번만 읽으면
되는
오프라인 상황에 최적입니다. \(Q\)번의 구간 더하기 후 전체 복원까지
\(O(N + Q)\).


워크드 예제

길이 6인 배열(초기 0)에 다음을 적용합니다: \([1,3]\)\(+5\), \([2,4]\)\(+2\),
\([0,5]\)\(+1\).

차이 배열 d(길이 7, 끝 여유 한 칸):

  • \([1,3]\,{+}5\): d[1]+=5, d[4]-=5
  • \([2,4]\,{+}2\): d[2]+=2, d[5]-=2
  • \([0,5]\,{+}1\): d[0]+=1, d[6]-=1

결과 d = [1, 5, 2, 0, -5, -2, -1]. 누적 합을 취하면:

$$ a = [1,\ 6,\ 8,\ 8,\ 3,\ 1] $$

세 번의 구간 더하기를 상수 시간에 기록하고, 마지막에 딱 한 번 훑어 복원했습니다.
직접 손으로 각 구간을 더해 봐도 같은 결과가 나옵니다.


언제 쓰는가

  • 버스 승하차/예약처럼 "구간 \([l,r]\)\(+1\)" 형태 갱신이 대량으로 주어지고
    마지막 상태만 필요할 때 (전형적 이모스법).
  • 2차원으로 확장하면 직사각형 영역 더하기\(O(1)\)에 (2강에서).
  • 나중에 값이 바뀌지 않는 정적 배열이면 차이 배열이 세그먼트 트리보다
    훨씬 간단하고 빠릅니다. 중간에 값을 읽으면서 갱신해야 하면 세그(느리게
    갱신) 쪽을 봐야 합니다.
Lesson 2강 · 구현 — 1D 복원과 2D 차분 선택 8m

1D 차이 배열 (C++)

끝 인덱스 여유를 위해 크기 \(N+1\)(또는 \(N+2\))로 잡습니다. r+1이 배열을 넘지
않도록 주의합니다.

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

int main() {
    int n = 6;
    vector<ll> d(n + 1, 0);              // d[r+1]까지 접근하므로 여유 한 칸

    auto range_add = [&](int l, int r, ll v) {
        d[l] += v;
        if (r + 1 <= n) d[r + 1] -= v;   // r == n-1이면 넘어가지 않게
    };

    range_add(1, 3, 5);
    range_add(2, 4, 2);
    range_add(0, 5, 1);

    vector<ll> a(n);
    ll cur = 0;
    for (int i = 0; i < n; i++) {         // 누적 합으로 복원
        cur += d[i];
        a[i] = cur;
    }
    for (ll x : a) cout << x << " ";       // 1 6 8 8 3 1
    cout << "\n";
}

d[r+1] -= v에서 r+1이 배열 범위를 넘지 않도록 크기를 넉넉히 잡거나 조건을
답니다. 복원은 cur에 계속 더하는 한 번의 선형 스캔입니다.

Python

def solve(n, updates):                    # updates: (l, r, v) 리스트
    d = [0] * (n + 1)
    for l, r, v in updates:
        d[l] += v
        if r + 1 <= n:
            d[r + 1] -= v
    a, cur = [0] * n, 0
    for i in range(n):
        cur += d[i]
        a[i] = cur
    return a

print(solve(6, [(1, 3, 5), (2, 4, 2), (0, 5, 1)]))  # [1, 6, 8, 8, 3, 1]

파이썬에서는 itertools.accumulate로 복원 루프를 대체할 수도 있습니다
(list(accumulate(d))[:n]).


2차원 차분 (직사각형 더하기)

2D에서는 각 직사각형 더하기가 네 꼭짓점만 건드립니다. 좌상단 \((r_1,c_1)\),
우하단 \((r_2,c_2)\)\(v\)를 더하려면:

$$ d[r_1][c_1]\,{+}{=}v,\quad d[r_2{+}1][c_1]\,{-}{=}v,\quad d[r_1][c_2{+}1]\,{-}{=}v,\quad d[r_2{+}1][c_2{+}1]\,{+}{=}v $$

복원은 2D 누적 합입니다.

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

int n = 4, m = 4;
vector<vector<ll>> d(6, vector<ll>(6, 0));   // (n+2) x (m+2)

void rect_add(int r1, int c1, int r2, int c2, ll v) {
    d[r1][c1]     += v;
    d[r2+1][c1]   -= v;
    d[r1][c2+1]   -= v;
    d[r2+1][c2+1] += v;                       // 겹친 부분 보정
}

int main() {
    rect_add(0, 0, 1, 1, 1);
    rect_add(1, 1, 3, 3, 2);
    for (int i = 0; i < n; i++)
        for (int j = 0; j < m; j++) {         // 2D prefix sum으로 복원
            if (i) d[i][j] += d[i-1][j];
            if (j) d[i][j] += d[i][j-1];
            if (i && j) d[i][j] -= d[i-1][j-1];
        }
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < m; j++) cout << d[i][j] << " ";
        cout << "\n";
    }
    // 1 1 0 0 / 1 3 2 2 / 0 2 2 2 / 0 2 2 2
}

네 꼭짓점 보정은 포함–배제(inclusion–exclusion)와 같은 원리입니다: 우하단 밖
두 방향에서 뺀 만큼이 한 번 겹쳐 빠지므로, 대각 꼭짓점에서 한 번 다시 더해 줍니다.


변형 — 등차수열 더하기

"구간 \([l,r]\)\(v, v{+}d, v{+}2d, \dots\)" 같은 등차수열을 더하려면 차이
배열을 두 번 겹쳐 씁니다. 1차 차분에 공차를 심고(d[l]+=v, d[l+1..]에 공차),
끝에서 되돌린 뒤 두 번 누적 합을 취하면 됩니다. 상수항·1차항을 분리해 각각 차이
배열로 처리하는 것이 실전에서 가장 헷갈리지 않는 방법입니다.

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

흔한 함정

  • r+1 범위 초과 — 구간 끝이 배열 마지막 칸이면 d[r+1]이 배열 밖입니다.
    크기를 \(N+1\) 이상으로 잡거나 조건으로 막으세요. 가장 흔한 런타임 에러/오답
    원인입니다.
  • 1-indexed 입력 — 문제 입력이 1-based 좌표면 그대로 d[l], d[r+1]
    쓰되 배열 크기를 \(N+2\)로. 0-based로 바꾼다면 모든 좌표에 일관되게 \(-1\).
  • 좌표가 매우 큰 경우 — 좌표 범위가 \(10^9\)인데 갱신이 몇 개뿐이면 배열을
    통째로 잡을 수 없습니다. 좌표 압축 후 차이 배열을 얹거나, 이벤트를 정렬해
    스위핑(별도 강의)으로 처리합니다.
  • 오버플로 — 구간 더하기가 누적되면 값이 커집니다. \(v\)와 갱신 횟수를 곱한
    크기를 가늠해 long long을 쓰세요.
  • 갱신 도중 값을 읽어야 할 때 — 차이 배열은 "모든 갱신 후 한 번 복원"에
    최적입니다. 중간중간 특정 위치를 읽으며 갱신도 해야 하면 매번 복원(\(O(N)\))은
    느립니다. 이럴 땐 펜윅 트리(BIT) 또는 느리게 갱신되는 세그먼트 트리
    가야 합니다.

차이 배열 vs 다른 도구

상황 알맞은 도구
구간 더하기 여러 번 → 마지막에 전체 복원 차이 배열 \(O(N+Q)\)
점 갱신 + 구간 합 질의 반복 펜윅 트리 / 세그먼트 트리
구간 갱신 + 구간 질의 반복 느리게 갱신되는 세그먼트 트리
구간 더하기 + 점 질의(반복) 펜윅 트리에 차분 아이디어 결합

마지막 행이 중요합니다. 차이 배열의 아이디어를 펜윅 트리에 얹으면 "구간
\([l,r]\)\(+v\)" 갱신과 "점 \(i\)의 값 질의"를 각각 \(O(\log N)\)에 처리할 수
있습니다. 펜윅에 range_add(l,r,v)add(l,+v); add(r+1,-v)로, 점 질의를
접두 합 sum(i)로 구현하면 됩니다 — 차이 배열의 동적 버전인 셈입니다.

// 펜윅 위의 차분: 구간 더하기 + 점 질의
ll bit[100005]; int N;
void add(int i, ll v){ for(i++; i<=N; i+=i&-i) bit[i]+=v; }
ll sum(int i){ ll s=0; for(i++; i>0; i-=i&-i) s+=bit[i]; return s; }

void range_add(int l, int r, ll v){ add(l, v); add(r+1, -v); }
ll point_query(int i){ return sum(i); }        // 점 i의 현재 값

응용 정리

  • 이모스법 스케줄링: 시간대 \([s,e)\)\(+1\)을 대량으로 기록해, 각 시각의
    동시 사용량(회의실, 서버 접속 수)을 한 번에 구합니다.
  • 2D 히트맵/스탬프: 격자에 직사각형 스탬프를 여러 번 찍고 최종 값 맵을
    뽑을 때 2D 차분이 표준입니다.
  • 구간 합 배열과의 짝: "구간 더하기"는 차분, "구간 합 질의"는 누적 합.
    둘은 서로의 역연산이라는 관점을 가지면 언제 무엇을 쓸지 바로 보입니다.

"구간을 통째로 더하는데 마지막 결과만 필요하다"가 보이면 차이 배열을 먼저
떠올리세요. 코드가 짧고, 상수가 작고, 실수할 여지가 적습니다.

Practice problem 알파카컵 2회: C - 알파카의 조명 설치 선택 25m
A00013

알파카컵 2회: C - 알파카의 조명 설치

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

Gold IV 골드 IV 지금 풀기
Practice problem 강의실 배정 선택 25m
R00153

강의실 배정

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

Unrated 레이팅 미적용 지금 풀기
Lesson 두 포인터로 구간을 훑는 기술 필수 8m

투 포인터란

투 포인터(two pointers) 는 배열 위에 포인터(인덱스) 두 개를 두고, 조건에
따라 한쪽씩 움직여 \(O(N^2)\)처럼 보이는 탐색을 \(O(N)\)에 끝내는 기법입니다.
"모든 쌍/구간을 다 보지 않고도" 답을 얻는 것이 핵심입니다.

왜 빠른가 — 단조성의 재활용

두 포인터가 각각 한 방향으로만 전진하면, 둘이 배열을 훑는 총 이동 횟수는
많아야 \(2N\)입니다. 이중 반복문이 \(N^2\)인 것과 대조적으로 \(O(N)\)입니다.
성립 조건은 "한쪽을 움직였을 때 다른 쪽이 되돌아갈 필요가 없다"는
단조성입니다.

두 가지 큰 유형

  1. 마주 보고 좁히기(opposite direction)lo는 왼쪽 끝, hi는 오른쪽
    끝에서 시작해 가운데로 좁힙니다. 정렬된 배열의 두 수 합 문제가 대표.
  2. 같은 방향으로 따라가기(same direction, 슬라이딩 구간)l, r 모두
    왼→오. 조건을 만족하는 연속 구간을 유지합니다. "합이 \(S\) 이하인 가장 긴
    구간" 같은 문제.

대표 예: 정렬 배열에서 합이 target인 두 수

정렬된 a에서 a[i] + a[j] == target인 쌍 찾기. 이중 반복 \(O(N^2)\) 대신
\(O(N)\).

bool two_sum(const vector<int>& a, int target) {   // a 는 정렬됨
    int lo = 0, hi = (int)a.size() - 1;
    while (lo < hi) {
        int s = a[lo] + a[hi];
        if (s == target) return true;
        if (s < target) lo++;         // 합이 작다 → 큰 쪽 필요 → lo 전진
        else            hi--;         // 합이 크다 → 작은 쪽 필요 → hi 후퇴
    }
    return false;
}

정렬돼 있기에 "합이 작으면 왼쪽을 키우고, 크면 오른쪽을 줄인다"는 결정이
절대 후회 없이 단조롭게 진행됩니다.

def two_sum(a, target):               # a 는 정렬됨
    lo, hi = 0, len(a) - 1
    while lo < hi:
        s = a[lo] + a[hi]
        if s == target:
            return True
        if s < target:
            lo += 1
        else:
            hi -= 1
    return False

언제 떠올리나

  • "정렬된 배열에서 두 수의 합/차" → 마주 보고 좁히기.
  • "조건을 만족하는 연속 구간의 개수/최장/최단" → 같은 방향(슬라이딩).
  • "\(N\)이 큰데 모든 쌍/구간을 봐야 할 것 같다" → 투 포인터로 \(O(N)\) 시도.
Lesson 구현 패턴: 같은 방향 슬라이딩과 3-sum 선택 8m

같은 방향 투 포인터 (슬라이딩 구간)

"합이 \(S\) 이하인 가장 긴 연속 구간의 길이"를 구합니다. 오른쪽 포인터
r로 구간을 넓히고, 조건이 깨지면 왼쪽 l을 당겨 회복합니다. 두 포인터
모두 전진만 하므로 \(O(N)\).

int longest_le(const vector<int>& a, long long S) {
    int n = a.size(), l = 0, best = 0;
    long long sum = 0;
    for (int r = 0; r < n; r++) {
        sum += a[r];                  // 오른쪽 확장
        while (sum > S) {             // 조건 위반 → 왼쪽 축소
            sum -= a[l];
            l++;
        }
        best = max(best, r - l + 1);  // [l, r] 이 유효
    }
    return best;
}
def longest_le(a, S):
    l, best, sum_ = 0, 0, 0
    for r, x in enumerate(a):
        sum_ += x
        while sum_ > S:
            sum_ -= a[l]
            l += 1
        best = max(best, r - l + 1)
    return best

이 골격은 "정확히 \(S\)", "\(K\)개 이하의 서로 다른 값", "곱이 \(S\) 미만" 등으로
판정만 바꿔 재사용됩니다. 음수가 섞이면 단조성이 깨질 수 있으니 주의
(그때는 누적 합 + 이분 탐색이나 다른 기법).

"정확히 \(K\)개" 세기 트릭

"합이 정확히 \(S\)인 구간 수", "서로 다른 값이 정확히 \(K\)개인 구간 수"는

$$ (\text{at most } K) - (\text{at most } K-1) $$

로 계산하면 각 항을 같은 방향 투 포인터로 \(O(N)\)에 셀 수 있습니다.

두 정렬 배열 병합·교집합

정렬된 두 배열의 교집합·합집합·차집합도 투 포인터의 전형입니다.

vector<int> intersect(const vector<int>& a, const vector<int>& b) {
    vector<int> res; int i = 0, j = 0;
    while (i < (int)a.size() && j < (int)b.size()) {
        if (a[i] < b[j]) i++;
        else if (a[i] > b[j]) j++;
        else { res.push_back(a[i]); i++; j++; }   // 같으면 채택
    }
    return res;
}

3-sum: 정렬 + 투 포인터

"합이 \(0\)인 세 수"는 한 수를 고정하고 나머지 둘을 투 포인터로 찾아
\(O(N^2)\)에 해결합니다(무작정 \(O(N^3)\) 대신).

sort(a.begin(), a.end());
for (int i = 0; i < n; i++) {
    int lo = i + 1, hi = n - 1;
    while (lo < hi) {
        int s = a[i] + a[lo] + a[hi];
        if (s == 0) { /* 채택 */ lo++; hi--; }
        else if (s < 0) lo++;
        else hi--;
    }
}
Lesson 심화·함정: 단조성, 경계, 그리고 변형 대응 선택 8m

투 포인터가 성립하려면

투 포인터의 정당성은 단조성에 달려 있습니다. 같은 방향 슬라이딩에서
"오른쪽을 늘리면 합/개수가 (약)증가하고, 왼쪽을 당기면 (약)감소한다"는
성질이 있어야 l을 되돌릴 필요가 없습니다.

  • 양수 배열의 구간 합 → 단조 성립.
  • 음수가 섞인 구간 합 → 단조 깨짐. 투 포인터 대신 누적 합 + 정렬/이분
    탐색이나 다른 접근을 쓰세요. 이 함정에 특히 유의.

흔한 함정

  • 무한 루프/포인터 역전while 축소 조건에서 lr을 넘어가지
    않도록 경계(l <= r)를 확인.
  • 구간 길이 off-by-one — 닫힌 구간 [l, r]의 길이는 r - l + 1.
  • 오버플로 — 구간 합은 long long. 곱은 특히 위험.
  • 정렬 전제 망각 — 마주 보고 좁히는 유형은 정렬이 필수. 안 하면 오답.
  • 중복 처리 — 3-sum류에서 같은 답 중복 방지를 위해 같은 값 건너뛰기
    (while (lo < hi && a[lo] == a[lo-1]) lo++;).
  • 빈 구간/전체 구간 — 답이 없거나 배열 전체가 답인 경계를 테스트.

변형 대응 요령

문제 유형 포인터 방향 이동 규칙
정렬 배열 두 수 합 = target 마주 봄 합 작으면 lo++, 크면 hi--
합/개수 조건 만족 최장 구간 같은 방향 r 확장, 위반 시 l 축소
서로 다른 값 \(K\)개 이하 구간 수 같은 방향 해시맵으로 종류 관리
두 정렬 배열 병합/교집합 같은 방향(두 배열) 작은 쪽 전진
3-sum / 4-sum 고정 + 투 포인터 안쪽 두 포인터 좁히기

핵심 요약

  • 두 포인터가 한 방향으로만 움직여 \(O(N)\) 또는 \(O(N \log N)\).
  • 성립의 열쇠는 단조성 — 되돌아갈 필요가 없어야 함.
  • 마주 보기(정렬 필수)와 같은 방향(슬라이딩) 두 골격을 상황에 맞게 변형.
Practice problem 알파카컵 2회: B - 알파카의 간식 구간 선택 25m
A00012

알파카컵 2회: B - 알파카의 간식 구간

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

Bronze I 브론즈 I 지금 풀기
Practice problem 등교 선택 25m
KOI00018

등교

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

Bronze IV 브론즈 IV 지금 풀기
Lesson 고정·가변 창으로 구간 정보를 유지하기 필수 8m

슬라이딩 윈도우란

슬라이딩 윈도우(sliding window) 는 연속 구간(창, window)을 배열 위에서
한 칸씩 밀며, 구간의 요약 정보(합·최대·개수 등)를 처음부터 다시 계산하지
않고
갱신하는 기법입니다. 같은 방향 투 포인터의 대표적 응용입니다.

직관: 다시 세지 마라

길이 \(K\) 구간의 합을 모든 시작점마다 새로 더하면 \(O(NK)\)입니다. 하지만 창을
한 칸 밀 때 나가는 원소를 빼고 들어오는 원소를 더하면 갱신이 \(O(1)\)이라
전체가 \(O(N)\)이 됩니다.

$$ \text{sum}[l{+}1, r{+}1] = \text{sum}[l, r] - a_l + a_{r+1} $$

두 종류의 창

  1. 고정 크기 창(fixed window) — 창 길이 \(K\)가 고정. "길이 \(K\) 구간의 최대
    합", "\(K\)일 이동 평균".
  2. 가변 크기 창(variable window) — 조건을 만족하도록 창을 늘였다 줄임.
    "합이 \(S\) 이하인 최장 구간", "서로 다른 문자가 \(K\)개 이하인 최장 부분
    문자열". 이 유형은 사실상 같은 방향 투 포인터입니다.

복잡도

원소마다 창에 한 번 들어오고 한 번 나가므로 대부분 \(O(N)\)입니다. 창 안에서
최댓값을 유지하려면 단조 덱을 써도 여전히 분할 상환 \(O(N)\) 입니다.

가장 단순한 예: 고정 창 합의 최댓값

long long max_window_sum(const vector<int>& a, int K) {
    long long sum = 0, best = LLONG_MIN;
    for (int i = 0; i < (int)a.size(); i++) {
        sum += a[i];                  // 새 원소 편입
        if (i >= K) sum -= a[i - K];  // K칸 벗어난 원소 방출
        if (i >= K - 1) best = max(best, sum);  // 창이 꽉 찼을 때만
    }
    return best;
}
def max_window_sum(a, K):
    sum_, best = 0, float("-inf")
    for i, x in enumerate(a):
        sum_ += x
        if i >= K:
            sum_ -= a[i - K]
        if i >= K - 1:
            best = max(best, sum_)
    return best

i >= K - 1(창이 처음으로 꽉 참)과 i >= K(방출 시작)의 경계를 정확히
맞추는 것이 실수 포인트입니다.

언제 떠올리나

  • "길이 \(K\)의 연속 구간"이라는 표현 → 고정 창.
  • "조건을 만족하는 연속 구간의 최장/최단/개수" → 가변 창.
  • "연속 부분 배열/부분 문자열"이라는 말이 나오면 우선 창을 의심하세요.
Lesson 구현 패턴: 가변 창과 창 안의 최댓값(단조 덱) 선택 8m

가변 창 골격

조건이 유지되는 동안 r로 창을 넓히고, 깨지면 l을 당겨 회복합니다.
"서로 다른 값이 \(K\)개 이하인 최장 구간"을 해시맵으로 구현.

int longest_at_most_k_distinct(const vector<int>& a, int K) {
    unordered_map<int,int> cnt;        // 값 -> 창 내 개수
    int l = 0, best = 0;
    for (int r = 0; r < (int)a.size(); r++) {
        cnt[a[r]]++;                   // 편입
        while ((int)cnt.size() > K) {  // 종류가 K 초과 → 축소
            if (--cnt[a[l]] == 0) cnt.erase(a[l]);
            l++;
        }
        best = max(best, r - l + 1);
    }
    return best;
}
from collections import defaultdict
def longest_at_most_k_distinct(a, K):
    cnt = defaultdict(int)
    l = best = 0
    for r, x in enumerate(a):
        cnt[x] += 1
        while len(cnt) > K:
            cnt[a[l]] -= 1
            if cnt[a[l]] == 0:
                del cnt[a[l]]
            l += 1
        best = max(best, r - l + 1)
    return best

창 안의 최댓값 — 단조 덱(monotonic deque)

"길이 \(K\) 창마다 최댓값"은 정렬이나 힙 없이 단조 감소 덱으로 \(O(N)\)
구합니다. 덱에는 인덱스를 담고, 새 값보다 작은 뒤쪽 원소는 쓸모없으니
버립니다.

vector<int> max_of_windows(const vector<int>& a, int K) {
    deque<int> dq;                     // 값이 감소하도록 인덱스 유지
    vector<int> res;
    for (int i = 0; i < (int)a.size(); i++) {
        if (!dq.empty() && dq.front() <= i - K) dq.pop_front();  // 창 이탈
        while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();// 열등 제거
        dq.push_back(i);
        if (i >= K - 1) res.push_back(a[dq.front()]);            // 최댓값
    }
    return res;
}
from collections import deque
def max_of_windows(a, K):
    dq, res = deque(), []
    for i, x in enumerate(a):
        if dq and dq[0] <= i - K:
            dq.popleft()
        while dq and a[dq[-1]] <= x:
            dq.pop()
        dq.append(i)
        if i >= K - 1:
            res.append(a[dq[0]])
    return res

각 인덱스가 덱에 한 번 들어오고 한 번 나가므로 분할 상환 \(O(N)\)입니다.
최솟값은 부등호 방향만 뒤집으면 됩니다.

문자열 창

아스키/알파벳이면 해시맵 대신 크기 26(또는 128) 배열로 개수를 관리하면
더 빠릅니다. "모든 문자를 포함하는 최소 창"류(minimum window) 문제도 필요
개수를 카운트하며 같은 골격으로 풉니다.

Lesson 심화·함정: 경계, 음수, 그리고 유형 선택 선택 8m

창 유형을 먼저 판별하라

  • 고정 \(K\) → 편입/방출 인덱스 경계(i - K, i >= K - 1)만 정확히.
  • 가변(조건)r 확장 후 위반 시 l 축소. "최장"인지 "최단"인지에 따라
    갱신 위치가 다릅니다(최장은 축소 후, 최단은 조건 만족 즉시).

최장 vs 최단 구간

같은 슬라이딩이라도 목적에 따라 갱신 시점이 다릅니다.

// 최단: 조건(sum >= S)을 만족하는 즉시 길이 갱신하고 왼쪽을 최대한 당김
int shortest_ge(const vector<int>& a, long long S) {
    int l = 0, best = INT_MAX; long long sum = 0;
    for (int r = 0; r < (int)a.size(); r++) {
        sum += a[r];
        while (sum >= S) {                 // 만족하는 동안 계속 줄여 본다
            best = min(best, r - l + 1);
            sum -= a[l]; l++;
        }
    }
    return best == INT_MAX ? 0 : best;
}

흔한 함정

  • 음수 원소 — "합이 \(S\) 이상/이하" 유형에서 음수가 있으면 단조성이 깨져
    슬라이딩이 틀립니다. 그때는 누적 합 + 정렬/이분 탐색, 또는 접두사 합의
    최솟값 관리 같은 다른 기법으로.
  • 경계 인덱스 — 고정 창의 편입/방출/첫 완성 시점 off-by-one이 잦습니다.
  • 오버플로 — 구간 합은 long long.
  • 덱에 값 대신 인덱스 — 단조 덱은 창 이탈을 판단하려면 반드시 인덱스를 담아야 함.
  • 빈 창/K가 배열보다 큼 — 경계 입력을 별도 확인.

유형별 도구 선택

목표 도구
고정 \(K\) 구간 합 편입-방출 슬라이딩 \(O(N)\)
조건 만족 최장/최단 구간 가변 창(투 포인터) \(O(N)\)
창 안 최대/최소 단조 덱 \(O(N)\)
창 안 \(K\)번째/중앙값 멀티셋·두 힙 \(O(N \log K)\)
음수 포함 구간 합 조건 누적 합 + 이분/자료구조

요약

슬라이딩 윈도우는 "연속 구간을 다시 계산하지 않는다"는 한 가지 아이디어의
여러 변주입니다. 창 종류(고정/가변)와 유지할 정보(합/개수/최대)를 먼저 정하고,
편입·방출 경계와 단조성 전제를 정확히 지키면 \(O(N)\)으로 해결됩니다.

Practice problem 알파카컵 2회: B - 알파카의 간식 구간 선택 25m
A00012

알파카컵 2회: B - 알파카의 간식 구간

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

Bronze I 브론즈 I 지금 풀기
Practice problem 합이 정확히 K인 연속 부분배열 최대 길이 선택 25m
R01513

합이 정확히 K인 연속 부분배열 최대 길이

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

Unrated 레이팅 미적용 지금 풀기
Lesson 값을 등수로 바꾸는 전처리 필수 8m

좌표 압축이란

좌표 압축(coordinate compression) 은 값의 크기 자체는 중요하지 않고
대소 관계(순서)만 중요할 때
, 실제 값을 "몇 번째로 작은가"라는 등수(순위)
로 바꿔 값의 범위를 \([0, M)\)으로 줄이는 전처리입니다.

왜 필요한가

값이 \(-10^9 \sim 10^9\)처럼 넓게 퍼져 있으면, 값을 인덱스로 쓰는 자료구조(배열,
펜윅 트리, 세그먼트 트리, 계수 정렬)를 그 범위만큼 잡을 수 없습니다. 하지만
서로 다른 값이 \(N\)개뿐이라면, 그 값들을 \(0, 1, \dots, N-1\)로 재번호 매기면
크기가 \(N\)인 자료구조로 충분합니다.

$$ \{5,\ 1000000,\ -7,\ 5,\ 42\} \;\Rightarrow\; \{1,\ 3,\ 0,\ 1,\ 2\} $$

대소 관계는 그대로 보존되므로 정렬·순위·구간 카운팅 등 순서 기반 연산에는
전혀 지장이 없습니다.

언제 쓰는가 — 신호

  • "값의 범위가 \(10^9\)인데 값을 인덱스로 쓰고 싶다."
  • 펜윅/세그먼트 트리로 "\(X\) 이하 값의 개수"를 세야 하는데 값이 크다.
  • 역전 카운팅, 스위핑, 오프라인 질의에서 좌표를 인덱스화해야 한다.
  • 서로 다른 값의 개수가 \(N\) 이하로 작다.

복잡도

정렬 \(O(N \log N)\) + 각 값 조회 \(O(\log N)\)(이분 탐색). 전체 \(O(N \log N)\).

핵심 3단계

  1. 모든 값을 모아서 정렬한다.
  2. 중복을 제거한다(서로 다른 값만 남김).
  3. 각 원래 값을, 정렬된 목록에서의 위치(이분 탐색) 로 바꾼다.

이 세 줄이 좌표 압축의 전부입니다. 다음 강에서 언어별로 정확히 구현합니다.

Lesson 구현: sort+unique+lower_bound / sorted+bisect 선택 8m

C++ 표준 관용구: sort + unique + lower_bound

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n);
    for (auto& x : a) cin >> x;

    vector<int> vals(a);                        // 값 복사
    sort(vals.begin(), vals.end());             // 1) 정렬
    vals.erase(unique(vals.begin(), vals.end()), vals.end());  // 2) 중복 제거

    // 3) 각 값을 등수(0-based)로 변환
    for (int i = 0; i < n; i++) {
        int rank = lower_bound(vals.begin(), vals.end(), a[i]) - vals.begin();
        cout << rank << ' ';                    // 0..M-1
    }
}

unique인접 중복만 제거하므로 반드시 정렬 후 호출해야 합니다.
등수가 필요하면 lower_bound로 위치를 찾습니다. 압축된 값의 개수는
M = vals.size()입니다.

Python 관용구: sorted(set) + 딕셔너리/bisect

a = [...]
vals = sorted(set(a))                    # 정렬 + 중복 제거 한 번에
rank = {v: i for i, v in enumerate(vals)}   # 값 -> 등수 사전
comp = [rank[x] for x in a]              # 압축 결과 (0..M-1)

큰 배열이면 사전 대신 bisect로도 조회할 수 있습니다.

import bisect
vals = sorted(set(a))
comp = [bisect.bisect_left(vals, x) for x in a]

압축 + 펜윅 트리로 "X 이하 개수" 세기

좌표 압축의 대표 용도. 값이 커도 압축 후 인덱스로 펜윅 트리를 사용합니다.

// vals: 정렬·중복 제거된 좌표, bit: 크기 M+1 펜윅 트리
auto id = [&](int v){ return (int)(lower_bound(vals.begin(), vals.end(), v)
                                   - vals.begin()) + 1; }; // 1-based
// 삽입:   update(id(x), +1);
// 질의:   query(id(x));   // x 이하(=이하 등수) 누적 개수

1-based로 압축하기

펜윅 트리는 인덱스 \(0\)을 쓰지 못하므로 등수에 +1 하여 \(1 \dots M\)으로
매기는 것이 편합니다. 세그먼트 트리·계수 배열도 마찬가지로 경계를 맞추세요.

중복을 등수에 반영해야 할 때

같은 값을 "같은 등수"로 볼지, "서로 다른 등수(입력 순서 등)"로 볼지는 문제에
따라 다릅니다. 위 방법은 같은 값 = 같은 등수입니다. 역전 카운팅처럼 같은
값끼리는 세지 않아야 한다면 "이하"와 "미만"을 정확히 구분해야 합니다.

Lesson 심화·함정: 중복·경계·값 복원 선택 8m

압축은 순서만 보존한다 — 값 자체가 필요하면?

좌표 압축은 대소 관계만 보존합니다. 만약 실제 값(예: 거리, 좌표 차이)이
계산에 필요하다면, 원래 값 배열 vals를 버리지 말고 보관했다가 등수를
다시 실제 값으로 되돌려(vals[rank]) 사용해야 합니다. "압축은 인덱스용,
계산은 원값으로"를 기억하세요.

int r = comp[i];        // 압축된 등수
int original = vals[r]; // 실제 값 복원

흔한 함정

  • 정렬 없이 uniqueunique는 인접 중복만 제거. 정렬을 빼먹으면 중복이
    남습니다. Python set은 무관하지만 순서가 사라지므로 반드시 sorted.
  • 0-based vs 1-based — 펜윅/세그먼트 트리에 넣을 땐 대개 +1. 경계 확인.
  • 같은 값의 처리 — "이하(\(\le\))"와 "미만(\(<\))"을 혼동하면 역전/카운팅에서
    오답. lower_bound(이상 첫), upper_bound(초과 첫)를 정확히 선택.
  • 값 복원 누락 — 등수로만 계산하면 실제 거리·차이가 틀립니다.
  • 여러 배열의 좌표를 함께 압축 — 두 배열(예: 질의 값과 데이터 값)을 같은
    좌표계로 다루려면 모든 값을 한 리스트에 모아 함께 압축해야 합니다. 따로
    압축하면 좌표계가 어긋납니다.
  • 범위 밖 질의 — 압축 목록에 없는 값을 물을 때 lower_bound가 "이상 첫
    위치"를 주므로, 그 위치의 의미(그 값 미만 개수 등)를 정확히 해석하세요.

오프라인 좌표 압축 패턴

질의를 미리 다 읽어(오프라인) 등장하는 모든 값을 한 번에 압축하는 것이
정석입니다. 온라인(질의를 즉석에서 받아야 함)이라면 압축이 불가능하니 값을
직접 다루는 자료구조(동적 세그먼트 트리, 균형 BST)로 대체합니다.

어디서 만나는가

  • 역전 카운팅 — 값을 압축해 펜윅 트리로 "앞의 더 큰 수" 세기.
  • 스위핑/구간 문제\(x\)좌표들을 압축해 이벤트 인덱스로.
  • 오프라인 구간 개수 질의 — 값과 질의 경계를 함께 압축.

요약

좌표 압축은 "값을 등수로" 바꿔 넓은 값 범위를 \(N\) 크기로 접는 전처리입니다.
sort + unique + lower_bound(C++) 또는 sorted(set) + bisect/dict(Python)
세 줄이 뼈대이며, 중복 처리(이하/미만)와 값 복원만 조심하면 큰 값 범위 문제를
표준 자료구조로 끌어옵니다.

Practice problem 택배 운송 선택 25m
KOI00007

택배 운송

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

Unrated 레이팅 미적용 지금 풀기
Practice problem 알파카컵 1회: I - 알파카의 식사 선택 25m
A00009

알파카컵 1회: I - 알파카의 식사

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

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

Explorer

정렬과 탐색 · Explorer 단계

0/20 완료
Lesson 쪼개고 풀고 합치기 — 분할 정복의 뼈대 필수 8m

분할 정복이란

분할 정복(divide and conquer) 은 문제를 작은 부분 문제로 쪼개(divide),
각각을 재귀로 풀고(conquer), 그 결과를 합쳐(combine) 전체 답을 만드는
설계 패러다임입니다. 병합 정렬, 거듭제곱, 최근접 점 쌍 등 수많은 알고리즘의
뼈대입니다.

세 단계

  1. 분할(divide): 입력을 대개 절반씩 두 개(또는 여러 개)로 나눈다.
  2. 정복(conquer): 각 부분을 재귀 호출로 해결한다. 충분히 작으면(기저
    사례) 직접 답한다.
  3. 합병(combine): 부분 해를 결합해 원래 문제의 답을 만든다. 여기가 핵심
    설계 지점
    — 특히 "경계를 가로지르는" 부분을 어떻게 처리하느냐가 관건.

복잡도: 재귀식과 마스터 정리

크기 \(n\) 문제를 \(a\)개의 크기 \(n/b\) 부분으로 나누고 합병에 \(f(n)\)이 든다면

$$ T(n) = a\,T(n/b) + f(n) $$

마스터 정리(master theorem) 로 곧장 복잡도를 얻습니다. 대표적으로
\(a = b = 2\), \(f(n) = O(n)\)이면(병합 정렬) \(T(n) = O(n \log n)\)입니다.

재귀식 복잡도
\(2T(n/2) + O(n)\) \(O(n \log n)\) 병합 정렬
\(2T(n/2) + O(1)\) \(O(n)\) 이진 트리 순회
\(T(n/2) + O(1)\) \(O(\log n)\) 이분 탐색
\(T(n/2) + O(1)\) (곱셈) \(O(\log n)\) 빠른 거듭제곱

언제 쓰는가

  • 문제에 "절반으로 나누면" 자연스러운 구조가 있을 때.
  • "왼쪽 안의 답 + 오른쪽 안의 답 + 경계를 가로지르는 답"으로 분해될 때
    (최대 구간 합, 최근접 점 쌍, 역전 수).
  • 거듭제곱·행렬 거듭제곱처럼 지수를 반씩 줄일 수 있을 때.

일반 골격

Result solve(int lo, int hi) {           // 반열린 구간 [lo, hi)
    if (hi - lo <= 1) return base(lo);   // 기저 사례
    int mid = lo + (hi - lo) / 2;
    Result L = solve(lo, mid);
    Result R = solve(mid, hi);
    return combine(L, R, lo, mid, hi);   // 경계 처리 포함
}

combine이 상수/선형이면 대개 효율적입니다. 기저 사례를 빠뜨리면 무한
재귀에 빠지니 가장 먼저 정의하세요.

Lesson 구현 사례: 빠른 거듭제곱과 최대 구간 합 선택 8m

빠른 거듭제곱 (분할 정복의 정수)

\(a^n\)\(O(n)\)번 곱하는 대신, 지수를 반씩 줄여 \(O(\log n)\)에 계산합니다.

$$ a^n = \begin{cases} (a^{n/2})^2 & n \text{ 짝수} \\ a \cdot a^{n-1} & n \text{ 홀수} \end{cases} $$

모듈러 거듭제곱(대회 단골)까지 포함한 반복 구현.

using ll = long long;
ll power_mod(ll a, ll n, ll MOD) {
    a %= MOD;
    ll res = 1;
    while (n > 0) {
        if (n & 1) res = res * a % MOD;   // 홀수 비트면 곱함
        a = a * a % MOD;                  // 밑을 제곱
        n >>= 1;                          // 지수를 절반으로
    }
    return res;
}
def power_mod(a, n, MOD):
    res = 1
    a %= MOD
    while n > 0:
        if n & 1:
            res = res * a % MOD
        a = a * a % MOD
        n >>= 1
    return res
# 파이썬은 내장 pow(a, n, MOD) 로도 동일 (권장)

행렬 거듭제곱

점화식(\(F_n = F_{n-1} + F_{n-2}\) 등)을 행렬로 표현하면, \(n\)번째 항을
\(O(k^3 \log n)\)에 구합니다(\(k\)는 행렬 크기). 위 빠른 거듭제곱의 곱셈을 행렬
곱으로 바꾸기만 하면 됩니다.

최대 구간 합 (divide and conquer 버전)

배열을 반으로 나누고 "왼쪽 최대 구간 합 / 오른쪽 최대 구간 합 / 중앙을
가로지르는
최대 구간 합" 셋 중 최댓값을 취합니다. 경계 처리가 핵심임을
보여 주는 교과서적 예제입니다.

long long solve(const vector<int>& a, int lo, int hi) {  // [lo, hi)
    if (hi - lo == 1) return a[lo];
    int mid = (lo + hi) / 2;
    long long L = solve(a, lo, mid);
    long long R = solve(a, mid, hi);
    // 중앙을 반드시 포함하는 최대 합
    long long lsum = LLONG_MIN, cur = 0;
    for (int i = mid - 1; i >= lo; i--) { cur += a[i]; lsum = max(lsum, cur); }
    long long rsum = LLONG_MIN; cur = 0;
    for (int i = mid; i < hi; i++)      { cur += a[i]; rsum = max(rsum, cur); }
    return max({L, R, lsum + rsum});
}

재귀식 \(T(n) = 2T(n/2) + O(n) = O(n \log n)\). (실전에서는 Kadane \(O(n)\)
더 빠르지만, 분할 정복 사고의 훈련으로 훌륭합니다.)

최근접 점 쌍 (개요)

평면의 가장 가까운 두 점은, \(x\) 기준으로 반씩 나눠 각 절반의 최소 거리
\(d\)를 구한 뒤, 경계 띠(band) 안의 점들만 \(y\) 기준으로 훑어 \(O(n \log n)\)
찾습니다. 역시 "경계 처리"가 알고리즘의 정수입니다.

Lesson 심화·함정: 경계·기저·재귀 깊이 선택 8m

설계의 90%는 "경계 처리"

분할 정복 문제의 난이도는 대부분 combine 단계 — 경계를 가로지르는 부분
있습니다. 왼쪽 안·오른쪽 안은 재귀가 알아서 풀어 주지만, "왼쪽 끝과 오른쪽
시작을 걸친" 답은 직접 계산해야 합니다.

  • 최대 구간 합 → 중앙을 포함하는 최대 합.
  • 역전 수 세기 → 왼쪽 원소가 오른쪽 원소보다 큰 교차 역전을 merge에서 셈.
  • 최근접 점 쌍 → 경계 띠 안의 점 쌍.

이 "경계 항"을 얼마나 효율적으로 계산하느냐가 곧 \(f(n)\)이고, 마스터 정리로
전체 복잡도가 결정됩니다.

흔한 함정

  • 기저 사례 누락/오류 — 구간이 원소 0~1개일 때를 처리 안 하면 무한 재귀
    또는 잘못된 답. 가장 먼저 정의.
  • 구간 표기 혼용 — 반열린 [lo, hi)와 닫힌 [lo, hi]를 섞으면 off-by-one.
    한 스타일로 통일하고 mid 정의도 그에 맞추세요.
  • 재귀 깊이 — 깊이는 \(O(\log n)\)이라 보통 안전하지만, 분할이 불균형하면
    (\(T(n) = T(n-1) + \dots\)) 깊이가 \(O(n)\)이 되어 스택 오버플로. 균형 분할을 확인.
  • 오버플로 — 합·곱 결과는 long long. 모듈러 거듭제곱은 곱하기 전에
    % MOD.
  • 불필요한 복사 — 부분 배열을 매번 복사하면 \(O(n \log n)\) 메모리·시간
    낭비. 인덱스 구간 (lo, hi)만 넘기세요.

분할 정복 vs 다른 기법

  • 분할 정복 vs DP: 부분 문제가 겹치지 않으면 분할 정복, 겹치면
    메모이제이션(DP). 병합 정렬은 겹침이 없어 순수 분할 정복.
  • 분할 정복 최적화(DnC optimization): 특정 DP 점화식은 결정점의 단조성을
    이용해 \(O(n^2)\)\(O(n \log n)\)으로 줄입니다(심화 주제).

사고 체크리스트

  1. 문제를 절반(또는 부분)으로 나누는 자연스러운 방법이 있는가?
  2. 기저 사례는 무엇인가?
  3. 부분 해를 어떻게 합치는가 — 특히 경계를 가로지르는 답은?
  4. combine 비용 \(f(n)\)과 재귀식으로 복잡도가 목표 안에 드는가?

요약

분할 정복은 "쪼개고, 각각 풀고, 경계까지 신경 써서 합친다"는 한 문장으로
요약됩니다. 빠른 거듭제곱(\(O(\log n)\))과 병합 기반(최대 구간 합·역전 수·최근접
점 쌍, \(O(n \log n)\)) 두 부류를 익히고, 기저 사례와 경계 항만 정확히 다루면
됩니다.

Practice problem 수 정렬하기 2 — 퀵정렬 킬러 선택 25m
R02012

수 정렬하기 2 — 퀵정렬 킬러

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

Unrated 레이팅 미적용 지금 풀기
Practice problem 외곽 순환 도로 선택 25m
KOI00058

외곽 순환 도로

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

Unrated 레이팅 미적용 지금 풀기
Lesson 답을 이분 탐색한다 — 최적화를 판정으로 필수 8m

매개 변수 탐색이란

매개 변수 탐색(parametric search) 은 "무엇을 최대/최소로 하라"는
최적화 문제를 "어떤 값 \(X\)가 가능한가?"라는 판정 문제로 바꾼 뒤,
그 답 \(X\)이분 탐색하는 기법입니다. 흔히 "결정 문제로의 환원"이라
부릅니다.

핵심 아이디어

최적화 문제를 곧바로 풀기는 어렵지만, "답이 \(X\) 이상/이하로 가능한가?"라는
예/아니오 질문은 쉬운 경우가 많습니다. 그리고 이 판정이 단조라면 —
\(X\)가 커질수록 쭉 "가능→불가능"(또는 그 반대)으로 한 번만 바뀐다면 —
그 경계가 곧 최적해입니다.

$$ \underbrace{\text{가능, 가능, } \dots \text{, 가능}}_{X \le X^*},\quad \underbrace{\text{불가능}, \dots}_{X > X^*} $$

경계 \(X^*\)를 이분 탐색으로 \(O(\log(\text{범위}))\)번의 판정에 찾습니다.

언제 쓰는가 — 신호

다음 표현이 보이면 매개 변수 탐색을 의심하세요.

  • "~의 최댓값을 최소화하라" / "~의 최솟값을 최대화하라"
  • "가능한 가장 큰/작은 \(X\)를 구하라"
  • 답 자체를 정하면 검사는 쉬운데(그리디·시뮬레이션 \(O(N)\)), 답 후보가 너무 많다.

대표 유형: 랜선 자르기(길이 \(X\)로 자르면 \(K\)개 이상 나오나?),
공유기 설치(간격 \(X\) 이상으로 배치 가능한가?), 나무 자르기(높이 \(H\)
자르면 목표량이 나오나?).

복잡도

판정 한 번이 \(O(f)\), 답의 범위가 \([lo, hi]\)면 전체는
\(O(f \cdot \log(hi - lo))\)입니다. 예컨대 \(O(N)\) 판정에 답 범위가 \(10^9\)
\(30N\)\(N\)\(10^5\)여도 순식간입니다.

판정 함수부터 설계하라

풀이의 90%는 feasible(X) 를 정확히 정의하는 데 있습니다.

// 길이 X로 잘랐을 때 나오는 도막 수가 K개 이상인가?
bool feasible(const vector<long long>& a, long long X, long long K) {
    if (X == 0) return true;              // 경계: 길이 0은 무한정
    long long cnt = 0;
    for (long long len : a) cnt += len / X;
    return cnt >= K;
}

feasible단조 방향(X가 커지면 쉬워지나 어려워지나)을 먼저 논증하고,
그 방향에 맞춰 이분 탐색의 갱신 방향을 정하는 것이 전부입니다.

Lesson 구현 골격: 최댓값·최솟값·실수 답 선택 8m

세 가지 골격을 외워라

매개 변수 탐색은 판정만 바뀔 뿐 뼈대는 세 종류로 고정됩니다.

1) 정수 — 최댓값 찾기 (mid 올림)

"조건을 만족하는 가장 \(X\)".

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

bool feasible(const vector<ll>& a, ll X, ll K) {
    if (X == 0) return true;
    ll cnt = 0;
    for (ll v : a) cnt += v / X;
    return cnt >= K;
}
int main() {
    int n; ll K; cin >> n >> K;
    vector<ll> a(n);
    for (auto& v : a) cin >> v;

    ll lo = 1, hi = *max_element(a.begin(), a.end());
    while (lo < hi) {
        ll mid = lo + (hi - lo + 1) / 2;      // 위로 올림 (핵심!)
        if (feasible(a, mid, K)) lo = mid;    // 되면 더 크게
        else                     hi = mid - 1;// 안 되면 줄인다
    }
    cout << lo << '\n';                        // lo == hi 가 답
}

mid 올림 (hi - lo + 1)/2이 핵심입니다. 최댓값 탐색에서 내림하면
lo = mid가 진전 없이 반복돼 무한 루프에 빠집니다. 이 단원 1순위 버그.

2) 정수 — 최솟값 찾기 (mid 내림)

"조건을 만족하는 가장 작은 \(X\)".

while (lo < hi) {
    ll mid = lo + (hi - lo) / 2;      // 내림
    if (feasible(mid)) hi = mid;      // 되면 더 작게
    else               lo = mid + 1;  // 안 되면 키운다
}
// lo == hi 가 답

3) 실수 — 고정 횟수 반복

double lo = 0, hi = 1e9;
for (int it = 0; it < 100; it++) {    // 매번 절반 → 100번이면 충분
    double mid = (lo + hi) / 2;
    if (feasible(mid)) lo = mid;      // (최댓값을 찾는 경우)
    else               hi = mid;
}
// lo (또는 hi) 가 답

while (hi - lo > eps)보다 고정 횟수가 부동소수 정체 위험이 없어 안전합니다.

파이썬 (정수 최댓값)

import sys
input = sys.stdin.readline

def feasible(a, X, K):
    if X == 0:
        return True
    return sum(v // X for v in a) >= K

n, K = map(int, input().split())
a = [int(input()) for _ in range(n)]
lo, hi = 1, max(a)
while lo < hi:
    mid = (lo + hi + 1) // 2          # 올림
    if feasible(a, mid, K):
        lo = mid
    else:
        hi = mid - 1
print(lo)

Python은 큰 정수 오버플로가 없지만, C++은 판정 내부 누적합이 커질 수 있으니
long long을 반드시 씁니다.

Lesson 심화·함정: 단조성 증명, 경계, K번째 값 선택 8m

가장 먼저 할 일: 단조성 증명

이분 탐색이 성립하려면 판정이 단조여야 합니다. 랜선 자르기라면 "길이
\(X\)가 커지면 도막 수는 줄어든다"는 사실 — 즉 $X_1 \le X_2 \Rightarrow
\text{cnt}(X_1) \ge \text{cnt}(X_2)$ — 를 확인해야 feasible이 단조가
됩니다. 단조가 아니면 이분 탐색 자체가 틀립니다. 먼저 논증하고 코딩하세요.

\(K\)번째 값 찾기로의 변형

"작은 것부터 \(K\)번째 값"류도 매개 변수 탐색으로 풉니다. 판정을
"\(X\) 이하인 원소가 \(K\)개 이상인가?"로 잡으면, 이를 만족하는 최소 \(X\)
\(K\)번째 값입니다. 곱셈표의 \(K\)번째 수, 두 배열 합의 \(K\)번째 수 등이 대표
사례입니다.

// N x N 곱셈표에서 X 이하 원소 개수 (각 행 i: i, 2i, ..., Ni)
long long count_le(long long X, int N) {
    long long c = 0;
    for (int i = 1; i <= N; i++) c += min((long long)N, X / i);
    return c;                          // 단조 증가 → 최소 X 이분 탐색
}

자주 만나는 함정

  • 무한 루프 — 최댓값 탐색에서 mid 올림 누락. lo = mid가 반복.
  • 단조성 미확인 — 판정이 단조가 아닌데 이분 탐색을 쓴 경우. 답이 틀림.
  • 탐색 범위(hi) 과소 — 이론적 상한을 넉넉히. 답을 놓치면 원인 찾기 어려움.
  • 오버플로 — 판정 내부 합·곱은 long long. midlo + (hi - lo) / 2.
  • 경계 \(X = 0\) — 나눗셈 판정에서 \(0\)으로 나누기. 별도 처리.
  • 실수 정밀도eps 비교 대신 고정 횟수. 출력 자릿수 요구도 확인.

응용 패턴 정리

문제 표현 판정 feasible(X) 찾을 것
길이를 최대로 잘라 \(K\) \(X\)로 잘라 \(K\)개 이상? 최대 \(X\)
간격을 최대로 배치 간격 \(X\) 이상 배치 가능? 최대 \(X\)
최대 하중을 최소로 하중 \(X\)\(D\)일 내 운반? 최소 \(X\)
\(K\)번째 작은 값 \(X\) 이하가 \(K\)개 이상? 최소 \(X\)

한 문장 요약

"최적화를 판정으로, 판정을 이분 탐색으로." 단조성 확인과 경계(올림/내림)
처리만 정확하면 광범위한 최적화 문제에 곧장 적용됩니다.

Practice problem 공유기 설치 선택 25m
R00764

공유기 설치

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

Unrated 레이팅 미적용 지금 풀기
Practice problem 대나무 수확기 선택 25m
R00990

대나무 수확기

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

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — 이벤트 정렬과 훑기 필수 8m

스위핑이란

스위핑(sweeping, 쓸기) 은 좌표축을 따라 가상의 선(sweep line) 을 한쪽
끝에서 반대쪽으로 훑으며, 선이 지나는 이벤트를 순서대로 처리하는 기법입니다.
2차원 문제를 "정렬된 1차원 이벤트 처리"로 차원을 낮춰 푸는 것이 핵심입니다.

절차는 대개 세 단계입니다.

  1. 이벤트 추출 — 구간의 시작/끝, 점의 좌표, 선분의 등장/퇴장 등을 이벤트로.
  2. 정렬 — 훑을 축(보통 \(x\)) 기준으로 이벤트를 정렬. \(O(N \log N)\).
  3. 훑기 — 정렬 순서대로 이벤트를 처리하며 활성 집합(현재 선이 걸친
    것들)을 갱신하고 답을 모읍니다.

불변식: 스위프 라인이 위치 \(x\)에 있을 때, 활성 집합은 "\(x\)에서 현재 열려
있는 구간/선분들"을 정확히 담는다. 시작 이벤트에서 넣고, 끝 이벤트에서 뺀다.


대표 유형 1 — 구간들의 합집합 길이

여러 구간 \([l_i, r_i]\)합집합 길이(겹친 부분은 한 번만)를 구합니다. 각
구간을 시작 이벤트 \((+1)\), 끝 이벤트 \((-1)\)로 만들고 좌표순 정렬합니다.
덮임 카운터 cnt를 유지하며, cnt > 0인 구간의 길이만 더합니다.

$$ \text{합집합} = \sum_{\text{인접 이벤트 } x_{i} \to x_{i+1}} [\,cnt > 0\,]\cdot (x_{i+1} - x_i) $$

정렬 \(O(N \log N)\) + 훑기 \(O(N)\).


대표 유형 2 — 가장 가까운 두 점

평면의 \(N\)개 점 중 가장 가까운 쌍의 거리를 구합니다. 점을 \(x\)좌표로 정렬해
왼쪽에서 오른쪽으로 훑되, 현재 최솟값 \(d\) 보다 \(x\)가 멀어진 점은 활성 집합
(보통 \(y\)로 정렬된 set)에서 버립니다. 새 점과는 \(y\)\([y-d, y+d]\)인 후보만
비교합니다. 기하적으로 그런 후보가 상수 개임이 보장되어 전체 \(O(N \log N)\).


언제 쓰는가

  • 구간 합집합/교집합, 겹침 개수, 최대 동시 겹침(회의실 문제).
  • 직사각형 넓이 합집합(스위프 + 세그먼트 트리).
  • 가장 가까운 점쌍, 선분 교차 검출(Bentley–Ottmann).
  • "정렬해 놓고 한 번 훑으면 풀리는" 거의 모든 계수/누적 문제.

핵심 신호: "어떤 좌표를 기준으로 정렬한 뒤 순서대로 처리하면 상태를 조금씩만
갱신하며 답이 모인다"
가 보이면 스위핑입니다. 다음 강의에서 두 유형을 코드로.

Lesson 2강 · 구현 — 합집합 길이와 최근접 점쌍 선택 8m

구간 합집합 길이 (C++)

이벤트를 \((좌표, \pm 1)\)로 만들고 정렬합니다. 시작을 끝보다 먼저 처리하도록
정렬 규칙에 주의합니다(같은 좌표에서 시작 먼저 넣어야 접점도 이어짐).

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

int main() {
    vector<pair<ll,int>> ev;                 // (x, +1 시작 / -1 끝)
    auto add = [&](ll l, ll r) { ev.push_back({l, +1}); ev.push_back({r, -1}); };
    add(1, 3); add(2, 5); add(7, 9);

    sort(ev.begin(), ev.end());              // 같은 x면 -1(끝)이 먼저 → 접점 분리
    ll total = 0, cnt = 0, prev = 0;
    for (auto& [x, t] : ev) {
        if (cnt > 0) total += x - prev;      // 덮인 구간만 누적
        cnt += t;
        prev = x;
    }
    cout << total << "\n";                    // [1,5]=4 + [7,9]=2 = 6
}

접점 처리: 위 정렬은 같은 좌표에서 \(-1\)(끝)이 \(+1\)(시작)보다 먼저 옵니다
(pair 비교로 \(-1 < +1\)). 접하는 구간 \([1,3]\)\([3,5]\)끊어서 셀지
이어서 셀지에 따라 시작/끝 우선순위를 바꿔야 합니다. 폐구간 합집합처럼
"접점도 이어짐"을 원하면 시작을 먼저 처리하도록 규칙을 뒤집으세요.

Python

def union_length(intervals):
    ev = []
    for l, r in intervals:
        ev.append((l, 1)); ev.append((r, -1))
    ev.sort()
    total = cnt = prev = 0
    for x, t in ev:
        if cnt > 0:
            total += x - prev
        cnt += t
        prev = x
    return total

print(union_length([(1, 3), (2, 5), (7, 9)]))   # 6

가장 가까운 점쌍 (C++) — 스위프 + 정렬된 set

\(x\)로 정렬해 훑으며, 활성 집합을 \((y, x)\)로 정렬된 set으로 둡니다. 현재
최솟값 best보다 \(x\)가 멀어진 점은 왼쪽에서 제거하고, 새 점과는 $y \in
[y_i - best, y_i + best]$ 후보만 비교합니다.

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

int main() {
    int n;
    vector<pair<ll,ll>> p = {{2,3},{12,30},{40,50},{5,1},{12,10},{3,4}};
    n = p.size();
    sort(p.begin(), p.end());                 // x 기준

    auto dist2 = [](auto& a, auto& b) {
        ll dx = a.first - b.first, dy = a.second - b.second;
        return dx*dx + dy*dy;
    };
    set<pair<ll,ll>> active;                   // (y, x)
    ll best = LLONG_MAX;
    int left = 0;
    for (int i = 0; i < n; i++) {
        ll d = (ll)ceil(sqrt((double)best));
        while (left < i && p[i].first - p[left].first > d) {
            active.erase({p[left].second, p[left].first});
            left++;
        }
        auto lo = active.lower_bound({p[i].second - d, LLONG_MIN});
        auto hi = active.upper_bound({p[i].second + d, LLONG_MAX});
        for (auto it = lo; it != hi; ++it) {
            pair<ll,ll> q = {it->second, it->first};   // (x, y)
            best = min(best, dist2(p[i], q));
        }
        active.insert({p[i].second, p[i].first});
    }
    cout << best << "\n";                       // 최소 거리의 제곱
}

활성 집합에 남는 후보가 기하적으로 상수 개라 전체 \(O(N \log N)\)입니다. 거리
제곱으로 비교해 부동소수 오차를 피하는 것도 실전 요령입니다.


변형 — 직사각형 넓이 합집합

직사각형들의 합집합 넓이는 \(x\)로 스위프 + \(y\)축 세그먼트 트리로 풉니다.
왼변에서 \(+1\), 오른변에서 \(-1\) 이벤트를 만들어 \(y\)구간에 덮임을 기록하고, 인접
\(x\) 사이에서 "한 번 이상 덮인 \(y\) 길이 \(\times\) \(\Delta x\)"를 더합니다. 이는
1강 합집합 길이의 2차원 확장으로, 세그먼트 트리가 "덮인 \(y\) 길이"를 \(O(\log N)\)
관리합니다.

Lesson 3강 · 심화·변형 — 함정과 이벤트 설계 선택 8m

흔한 함정

  • 동률 좌표의 처리 순서 — 같은 \(x\)에서 시작과 끝이 겹칠 때, 어느 것을 먼저
    처리하느냐로 답이 갈립니다. "접점을 이어 셀지/끊어 셀지", "폐구간/반열림
    구간"에 맞춰 정렬 보조키(이벤트 타입)를 명확히 정하세요. 스위핑 오답의 1순위
    원인입니다.
  • 부동소수 좌표 — 가능하면 정수/거리 제곱으로 비교해 오차를 없앱니다. 부득이
    실수면 eps 비교를 일관되게.
  • 활성 집합 갱신 누락 — 끝 이벤트에서 활성 집합에서 제거를 빠뜨리면
    이후 계산이 오염됩니다. 시작–끝을 짝으로 관리하세요.
  • 좌표 압축 필요 — 좌표가 \(10^9\)인데 세그먼트 트리를 얹어야 하면 등장하는
    좌표만 압축해 인덱스로 씁니다.
  • 정렬 안정성/보조키 — 여러 이벤트가 같은 좌표에 몰릴 때 결정적 순서를 위해
    보조키(타입, 인덱스)를 넣어 정렬을 완전히 결정지으세요.

이벤트 설계 패턴

스위핑의 90%는 "무엇을 이벤트로 삼고, 어떤 활성 자료구조를 유지하느냐"
결정됩니다.

문제 이벤트 활성 자료구조 훑으며 하는 일
구간 합집합 길이 구간 시작/끝 덮임 카운터 cnt>0인 폭 누적
최대 동시 겹침 시작 \(+1\)/끝 \(-1\) 카운터 cnt 최댓값
직사각형 넓이 합집합 세로변 등장/퇴장 \(y\) 세그먼트 트리 덮인 \(y\) 길이 \(\times \Delta x\)
최근접 점쌍 점(x순) \(y\) 정렬 set \([y-d,y+d]\) 후보 비교
선분 교차 검출 끝점/교차점 \(y\) 정렬 상태 이웃 선분만 교차 검사

각도 스위핑(회전 스위프)

축이 아니라 각도를 훑는 변형도 있습니다. 한 점을 중심으로 다른 점들을
편각(atan2) 으로 정렬한 뒤, 각을 키우며 활성 구간을 관리합니다. "한 점에서
볼 때 각도 범위 안의 점 수", "최대 각도 커버" 같은 문제에 쓰입니다. 원리는
동일합니다 — 정렬된 순서로 이벤트를 훑으며 상태를 조금씩 갱신.


다른 기법과의 관계

  • 차이 배열과 사촌: "구간 \(+1\)을 이벤트로" 보는 관점은 이모스법과 같습니다.
    좌표가 작으면 차이 배열, 크거나 순서 처리가 필요하면 스위핑.
  • 투 포인터의 일반화: 정렬 후 두 포인터로 훑는 것도 좁은 의미의 스위핑입니다.
  • 세그먼트 트리/set 를 활성 자료구조로 얹으면 스위핑의 표현력이 크게
    넓어집니다(넓이 합집합, 선분 교차).

"정렬하고 한 번 훑으면 상태가 매끄럽게 변한다"가 보이면 스위핑입니다. 이벤트의
정의와 동률 순서만 명확히 하면 대부분 깔끔하게 풀립니다.

Practice problem 알파카컵 2회: E - 알파카 게임 (Easy) 선택 25m
A00014

알파카컵 2회: E - 알파카 게임 (Easy)

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

Unrated 레이팅 미적용 지금 풀기
Practice problem 마법 격자의 넓이 선택 25m
R01452

마법 격자의 넓이

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

Unrated 레이팅 미적용 지금 풀기
Lesson 1강 · 개념 — 절반으로 나눠 정복 필수 8m

중간에서 만나기란

중간에서 만나기(meet in the middle, MITM) 는 지수 시간 완전 탐색을 절반씩
둘로 나눠
각각 \(2^{N/2}\)로 처리한 뒤, 두 절반의 결과를 정렬·이분 탐색/해시
결합하는 기법입니다. 시간 복잡도를

$$ O(2^N) \ \longrightarrow\ O\!\left(2^{N/2} \cdot N\right) $$

로 줄여, \(N \approx 40\)처럼 완전 탐색(\(2^{40} \approx 10^{12}\))이 불가능하지만
\(2^{20} \approx 10^6\)은 가능한 범위를 공략합니다.

핵심 아이디어: 지수의 밑은 그대로여도 지수가 절반이 되면 제곱근만큼 빨라집니다.
\(2^{40}\)의 제곱근은 \(2^{20}\).


대표 문제 — 부분집합 합

\(N\)개의 수에서 부분집합의 합이 정확히 \(S\) 인 경우의 수(또는 존재 여부, 또는
\(S\) 이하 최대)를 구합니다. 완전 탐색은 \(2^N\). MITM은:

  1. 수를 앞 절반 \(A\)(\(N/2\)개)와 뒤 절반 \(B\)로 나눈다.
  2. \(A\)의 모든 부분집합 합을 리스트 left에, \(B\)의 모든 부분집합 합을 right
    담는다. 각 \(2^{N/2}\)개.
  3. right정렬한다.
  4. left의 각 합 \(x\)에 대해, right에서 \(S - x\)인 원소 개수를 이분 탐색으로
    센다.

각 부분집합 합이 정확히 한 번 앞·뒤로 나뉘므로 중복·누락 없이 전체를 셉니다.
전체 \(O(2^{N/2} \cdot N/2)\).

불변식: 원래 집합의 임의의 부분집합은 "앞 절반의 부분집합 \(\cup\) 뒤 절반의
부분집합"으로 유일하게 분해된다. 그래서 leftright를 짝지으면 전체를
정확히 한 번씩 만든다.


워크드 예제

\(a = [1, 3, 5, 7, 9, 11]\), 목표 \(S = 16\)인 부분집합의 개수.

  • 앞 절반 \(\{1,3,5\}\)의 부분집합 합 left: \(\{0,1,3,4,5,6,8,9\}\).
  • 뒤 절반 \(\{7,9,11\}\)의 부분집합 합 right(정렬): \(\{0,7,9,11,16,18,20,27\}\).
  • \(x \in\) left에 대해 right에서 \(16 - x\) 개수를 셈:
  • \(x=5 \Rightarrow 11\) 있음(1), \(x=9 \Rightarrow 7\) 있음(1), \(x=16-16=0\)일 때
    \(x=16\)left에 없음… 실제로 세면 3가지(\(\{5,11\},\{9,7\},\{16=?\}\) 등).

완전 탐색으로 직접 세도 정확히 3이 나옵니다. 절반씩 만들어 이분 탐색으로
결합한 결과가 완전 탐색과 일치함을 확인할 수 있습니다.


언제 쓰는가

  • \(N\)이 대략 \(30\)\(45\)로 완전 탐색은 크지만 절반은 감당되는 크기.
  • 부분집합 합/개수, 목표값에 가장 가까운 합, \(K\)개 골라 합 맞추기.
  • 4-SUM류(네 수의 합 = 목표), 암호 해독(2배 크기 키 공간 절반 탐색).
  • 결합이 정렬+이분 탐색이나 해시맵으로 되는 구조. 다음 강의에서 구현.
Lesson 2강 · 구현 — 부분집합 합 개수 선택 8m

부분집합 합 개수 (C++) — 검증된 코드

앞·뒤 절반의 부분집합 합을 각각 만들고, 뒤 절반을 정렬해 이분 탐색으로 셉니다.

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

int main() {
    vector<ll> a = {1, 3, 5, 7, 9, 11};
    ll S = 16;
    int n = a.size(), h = n / 2;

    vector<ll> left, right;
    for (int m = 0; m < (1 << h); m++) {           // 앞 절반 부분집합
        ll s = 0;
        for (int i = 0; i < h; i++) if (m >> i & 1) s += a[i];
        left.push_back(s);
    }
    for (int m = 0; m < (1 << (n - h)); m++) {      // 뒤 절반 부분집합
        ll s = 0;
        for (int i = 0; i < n - h; i++) if (m >> i & 1) s += a[h + i];
        right.push_back(s);
    }
    sort(right.begin(), right.end());

    ll cnt = 0;
    for (ll x : left) {
        ll need = S - x;
        auto lo = lower_bound(right.begin(), right.end(), need);
        auto hi = upper_bound(right.begin(), right.end(), need);
        cnt += hi - lo;                             // need와 같은 원소 수
    }
    cout << cnt << "\n";                             // 3
}

lower_bound/upper_bound로 "정확히 \(S-x\)인 원소 개수"를 셉니다. 개수가 아닌
존재 여부
만 필요하면 binary_search, \(S\) 이하 최댓값 이면 upper_bound
경계를 잡고 바로 앞 원소를 봅니다.

Python

from bisect import bisect_left, bisect_right

def count_subsets(a, S):
    n = len(a); h = n // 2
    def sums(arr):
        res = []
        for m in range(1 << len(arr)):
            s = 0
            for i in range(len(arr)):
                if m >> i & 1: s += arr[i]
            res.append(s)
        return res
    left, right = sums(a[:h]), sorted(sums(a[h:]))
    cnt = 0
    for x in left:
        need = S - x
        cnt += bisect_right(right, need) - bisect_left(right, need)
    return cnt

print(count_subsets([1, 3, 5, 7, 9, 11], 16))   # 3

변형 1 — S 이하 최대 합 (냅색류)

배낭 무게 한도 \(W\) 아래 최대 합을 찾을 때는, right를 정렬한 뒤 각 \(x \in\)
left에 대해 \(W - x\) 이하 의 가장 큰 값을 이분 탐색으로 찾습니다.

sort(right.begin(), right.end());
ll best = 0;
for (ll x : left) {
    if (x > W) continue;
    // right에서 (W - x) 이하 최대값
    auto it = upper_bound(right.begin(), right.end(), W - x);
    if (it != right.begin()) best = max(best, x + *prev(it));
}

right에서 파레토 최적만 남기면(합이 작을수록 좋음이 없으니 단순 정렬로 충분)
상수를 더 줄일 수 있습니다.


변형 2 — 해시맵 결합

개수만 필요하고 목표가 정확히 일치 하는 유형이면, right를 정렬 대신
해시맵(값→개수) 으로 만들고 각 left 원소에 대해 \(S - x\)를 조회하면
평균 \(O(2^{N/2})\)입니다. 4-SUM(네 수의 합)에서 두 쌍을 각각 \(O(N^2)\)로 만들어
해시로 맞추는 것도 같은 MITM 사고입니다.

unordered_map<ll,ll> cnt_r;
for (ll y : right) cnt_r[y]++;
ll ans = 0;
for (ll x : left) {
    auto it = cnt_r.find(S - x);
    if (it != cnt_r.end()) ans += it->second;
}

정렬+이분 탐색은 \(O(2^{N/2} \cdot N)\)로 안정적이고, 해시맵은 평균 더 빠르나
최악에 느려질 수 있습니다. 값 범위·개수에 따라 고르세요.

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

흔한 함정

  • 절반을 어떻게 나누나 — 보통 \(\lfloor N/2 \rfloor\)로 균등 분할이 최적입니다.
    한쪽이 너무 크면 \(2^{\text{큰 절반}}\)이 병목입니다.
  • 오버플로 — 부분집합 합은 원소 합의 최대치까지 커집니다. \(N=40\), 원소가
    \(10^9\)면 합이 \(4\times10^{10}\) → 반드시 long long.
  • 메모리 — 각 절반이 \(2^{20} \approx 10^6\)개면 long long 배열 두 개로 수십
    MB. \(N=45\)\(2^{22.5}\)로 메모리·시간이 빠듯하니 파레토 가지치기나 스트리밍
    결합을 고려하세요.
  • 중복 카운팅 — "부분집합" 문제인지 "순서 있는 선택"인지 명확히. 앞·뒤 분해가
    유일해야 하므로, 각 원소를 정확히 한 절반에만 배정하세요.
  • 이분 탐색 경계 — 정확히 일치 개수는 upper_bound - lower_bound, "이하
    최대"는 upper_boundprev, "이상 최소"는 lower_bound. 경계 함수를
    문제 요구에 맞게 고르세요.
  • 빈 부분집합 — 합 \(0\)(아무것도 안 고름)을 포함할지 문제 정의에 따라 처리.
    위 코드는 \(m=0\)을 포함하므로 빈 집합이 카운트됩니다.

복잡도 관점 — 왜 절반이 마법인가

완전 탐색 \(2^N\)에서 지수를 절반으로 나누면 \(2^{N/2} = \sqrt{2^N}\). 제곱근 향상
입니다. 결합에 붙는 로그(\(N\) 또는 \(\log\))는 지수 항에 비해 작아, 실질 병목은
\(2^{N/2}\)개 생성과 정렬입니다.

\(N\) 완전 탐색 \(2^N\) MITM \(2^{N/2}\)
20 \(10^6\) \(10^3\)
30 \(10^9\) \(3\times10^4\)
40 \(10^{12}\) (불가) \(10^6\) (가능)
45 \(3.5\times10^{13}\) \(6\times10^6\) (빠듯)

MITM의 스위트 스팟은 \(N \approx 30\)\(42\). 그 아래는 그냥 완전 탐색, 그 위는
다른 접근(DP, 근사)이 필요합니다.


확장 — 이산 로그와 Baby-step Giant-step

MITM은 정수론에도 나타납니다. \(a^x \equiv b \pmod{m}\)을 푸는 BSGS(baby-step
giant-step)
\(x = i \cdot \lceil\sqrt{m}\rceil - j\)로 쪼개, \(a^{-j}b\)(baby)를
해시에 담고 \(a^{i\lceil\sqrt{m}\rceil}\)(giant)로 맞춥니다. \(O(m)\)\(O(\sqrt{m})\)
줄이는 것도 "지수를 반씩 나눠 만나기"의 정신입니다.


다른 기법과의 선택

상황 접근
\(N \le 20\)\(25\) 완전 탐색 / 비트마스크 DP
\(N \approx 30\)\(42\), 부분집합 합류 중간에서 만나기
합 목표 \(S\)가 작음(\(\le 10^6\)) 부분집합 합 DP(\(O(NS)\))
\(N\) 큼 + 값 작음 비트셋 DP(별도 강의)

핵심 판단: "\(N\)이 완전 탐색엔 크지만 절반은 되는가?", 그리고 "두 절반의
결과를 정렬/해시로 짝지을 수 있는가?"
둘 다 예이면 MITM입니다.

Practice problem 네 수의 합 선택 25m
R01417

네 수의 합

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

Unrated 레이팅 미적용 지금 풀기
Practice problem 마법사의 물약 배합 선택 25m
R01416

마법사의 물약 배합

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

Unrated 레이팅 미적용 지금 풀기
04
Level 5 · Analyst

Analyst

정렬과 탐색 · Analyst 단계

0/5 완료
Lesson 역전 쌍이란: 정렬로부터의 거리 필수 8m

문제 정의

수열 \(A\)에서 \(i < j\)인데 \(A_i > A_j\)인 쌍 \((i, j)\)역전(inversion) 이라
합니다. 역전의 개수는 "수열이 오름차순 정렬에서 얼마나 먼가"의 척도이며,
정확히 버블 정렬이 수행하는 교환 횟수와 같습니다.

$$ \text{inv}(A) = \bigl|\{(i, j) : i < j,\ A_i > A_j\}\bigr| $$

복잡도의 도전

모든 쌍을 확인하면 \(O(N^2)\)입니다. \(N\)\(10^5\) 이상이면 시간 초과이므로,
\(O(N \log N)\) 알고리즘이 필요합니다. 두 가지 표준 도구가 있습니다.

  • 병합 정렬의 병합 단계에서 세기.
  • 펜윅 트리(BIT) + 좌표 압축으로 세기.

답의 크기: 64비트 필수

역전은 최대 \(\binom{N}{2} = \dfrac{N(N-1)}{2}\)개입니다. \(N = 10^5\)이면 약
\(5 \times 10^9\)로 32비트 정수를 넘습니다. 반드시 long long 을 쓰세요.
이 단원에서 가장 흔한 오답 원인입니다.

병합 정렬 아이디어

병합 정렬의 merge 단계에서, 오른쪽 절반의 원소가 왼쪽 절반의 원소보다 먼저
뽑히는 순간
, 왼쪽에 남아 있는 원소 수만큼의 역전이 한 번에 발견됩니다. 왜냐하면
왼쪽에 남은 원소들은 모두 인덱스가 더 앞이면서(원래 순서) 값이 더 크기 때문입니다.

$$ \text{inv}(A) = \text{inv}(L) + \text{inv}(R) + (\text{merge에서 센 교차 역전}) $$

이것은 분할 정복의 전형입니다 — 왼쪽 안, 오른쪽 안, 그리고 경계를 가로지르는
역전
을 merge가 책임집니다.

펜윅 트리 아이디어

수열을 훑으며 "지금 원소보다 작은/큰 값이 이미 몇 개 나왔나"를 펜윅
트리로 즉시 답합니다. 값의 범위가 크면 좌표 압축을 먼저 합니다. 구현은
다음 강에서 두 방법 모두 완성합니다.

어디서 만나는가

  • 두 줄 사이 전선의 교차 수 — 한쪽 순서로 재배열한 뒤 역전 카운팅.
  • 순열의 패리티(짝/홀) — 역전 수의 홀짝. 15-퍼즐 해결 가능성 판정.
  • "내 앞에 있는 나보다 큰 수의 개수" 류 카운팅 전반.
Lesson 구현 1: 병합 정렬로 세기 선택 8m

병합 정렬 기반 역전 카운팅 (C++)

병합 정렬을 그대로 짜되, 오른쪽 원소를 먼저 뽑을 때 왼쪽에 남은 개수
누적합니다.

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

ll merge_count(vector<int>& a, int lo, int hi, vector<int>& tmp) {
    if (hi - lo <= 1) return 0;              // 원소 0~1개 → 역전 없음
    int mid = (lo + hi) / 2;
    ll inv = merge_count(a, lo, mid, tmp)    // 왼쪽 안
           + merge_count(a, mid, hi, tmp);   // 오른쪽 안
    int i = lo, j = mid, k = lo;
    while (i < mid && j < hi) {
        if (a[i] <= a[j]) {                  // 역전 아님 (등호 포함 주의!)
            tmp[k++] = a[i++];
        } else {                             // a[i] > a[j] : 교차 역전 발생
            tmp[k++] = a[j++];
            inv += (mid - i);                // 왼쪽에 남은 원소 수만큼
        }
    }
    while (i < mid) tmp[k++] = a[i++];
    while (j < hi)  tmp[k++] = a[j++];
    for (int t = lo; t < hi; t++) a[t] = tmp[t];
    return inv;
}
int main() {
    int n; cin >> n;
    vector<int> a(n), tmp(n);
    for (auto& x : a) cin >> x;
    cout << merge_count(a, 0, n, tmp) << '\n';
}

a[i] <= a[j]의 등호가 핵심입니다. 같은 값은 역전이 아니므로 왼쪽을
먼저 뽑아야 중복으로 세지 않습니다. <로 쓰면 같은 값 쌍을 잘못 셉니다.

파이썬 구현

import sys
sys.setrecursionlimit(1 << 20)

def merge_count(a):
    if len(a) <= 1:
        return a, 0
    mid = len(a) // 2
    left, il = merge_count(a[:mid])
    right, ir = merge_count(a[mid:])
    merged, inv = [], il + ir
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:              # 역전 아님
            merged.append(left[i]); i += 1
        else:                                # 교차 역전
            merged.append(right[j]); j += 1
            inv += len(left) - i             # 왼쪽 남은 개수
    merged.extend(left[i:]); merged.extend(right[j:])
    return merged, inv

n = int(input())
a = list(map(int, input().split()))
print(merge_count(a)[1])

장점

  • 값 압축이 필요 없습니다(값을 직접 비교).
  • "차이가 \(K\) 이하인 쌍" 같은 변형에 유연하게 확장됩니다.
Lesson 구현 2: 펜윅 트리 + 좌표 압축, 그리고 함정 선택 8m

펜윅 트리(BIT) 기반 역전 카운팅

수열을 뒤에서 앞으로 훑으며, "지금 원소보다 작은 값이 이미(=오른쪽에)
몇 개 나왔나"를 펜윅 트리로 묻습니다. 그 개수가 곧 이 원소가 만드는 역전입니다.

준비: 좌표 압축

값의 범위가 크면 값을 등수(1..M)로 압축합니다. 펜윅 트리는 인덱스 \(0\)
못 쓰므로 1-based 로 매깁니다.

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

int n;
vector<int> bit;                         // 1-based 펜윅 트리
void update(int i, int v){ for(; i<=n; i+=i&-i) bit[i]+=v; }
int  query(int i){ int s=0; for(; i>0; i-=i&-i) s+=bit[i]; return s; }

int main(){
    cin >> n;
    vector<int> a(n);
    for (auto& x : a) cin >> x;
    // 좌표 압축 (1-based 등수)
    vector<int> vals(a);
    sort(vals.begin(), vals.end());
    vals.erase(unique(vals.begin(), vals.end()), vals.end());
    auto id = [&](int v){ return (int)(lower_bound(vals.begin(), vals.end(), v)
                                       - vals.begin()) + 1; };
    bit.assign(n + 1, 0);
    ll inv = 0;
    for (int i = n - 1; i >= 0; i--) {   // 뒤에서 앞으로
        int r = id(a[i]);
        inv += query(r - 1);             // 나(오른쪽)보다 작은 값의 개수
        update(r, 1);
    }
    cout << inv << '\n';
}

query(r - 1)자기보다 엄격히 작은 값만 세야 하므로 r - 1까지입니다.
같은 값은 역전이 아니므로 query(r)로 쓰면 틀립니다.

앞에서 뒤로 도는 변형

"앞에서 뒤로" 훑으며 "지금까지 나온 값 중 나보다 큰 값의 개수"를 세도
됩니다: inv += (지금까지 삽입 개수) - query(r).

두 방법 비교

병합 정렬 펜윅 트리
시간 \(O(N \log N)\) \(O(N \log N)\)
추가 지식 분할 정복 BIT + 좌표 압축
값 압축 불필요 필요(값이 클 때)
확장성 "차이 \(K\) 이하 쌍" 등 유연 온라인/부분 질의로 확장 쉬움

흔한 함정

  • 32비트 오버플로 — 답이 \(\binom{N}{2} \approx N^2/2\). long long 필수.
  • 등호 처리 — 같은 값은 역전이 아님. 병합은 a[i] <= a[j], BIT는
    query(r-1).
  • 압축 누락 — 값 범위가 크면 펜윅 배열을 잡을 수 없음. 좌표 압축 먼저.
  • 1-based 오프셋 — 펜윅 트리에 넣을 등수는 +1.
  • 재귀 깊이/스택 — Python 병합 버전은 재귀 한계를 늘리거나 반복 병합으로.

요약

역전 카운팅은 분할 정복(병합 정렬)과 자료구조(펜윅 + 좌표 압축)라는 두 큰
기법이 만나는 지점입니다. 어느 쪽이든 핵심은 "경계를 가로지르는/오른쪽에
있는 더 작은 값
"을 \(O(\log N)\)에 세는 것이며, 등호 처리와 64비트만
정확하면 됩니다.

Practice problem 수 정렬하기 2 — 퀵정렬 킬러 선택 25m
R02012

수 정렬하기 2 — 퀵정렬 킬러

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

Unrated 레이팅 미적용 지금 풀기
Practice problem 외곽 순환 도로 선택 25m
KOI00058

외곽 순환 도로

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

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

Specialist

정렬과 탐색 · Specialist 단계

0/5 완료
Lesson 여러 질의의 이분 탐색을 한꺼번에 필수 8m

병렬 이분 탐색이란

병렬 이분 탐색(parallel binary search, PBS)여러 개의 질의가 각각
"어떤 시점(파라미터) \(t\)에 조건이 처음 만족되는가?"를 이분 탐색으로 물을 때,
그 이분 탐색들을 동시에(병렬로) 수행해 전체를 효율적으로 푸는 오프라인
기법입니다.

동기: 왜 병렬로 하나

질의 하나마다 독립적으로 이분 탐색을 하면, 각 이분 탐색의 판정마다 "시점
\(t\)까지의 이벤트를 모두 적용한 상태"를 만들어야 합니다. 이 판정 비용이
\(O(N)\)이면 질의 \(Q\)개에 대해 \(O(Q \cdot N \log N)\)으로 너무 느립니다.

핵심 관찰: 서로 다른 질의라도 판정 시점(mid)이 비슷합니다. 그렇다면 모든
질의의 현재 mid를 모아, 시간축을 한 번만 훑으며(sweep) 각 시점에 도달할
때 그 시점을 mid로 가진 질의들을 한꺼번에 판정하면 됩니다. 이렇게 하면 한
"라운드"에 이벤트를 한 번만 적용하고 모든 질의를 갱신할 수 있습니다.

복잡도

이분 탐색은 각 질의당 \(O(\log N)\) 라운드면 끝납니다. 각 라운드에서 전체
이벤트(\(N\)개)를 한 번 적용하고(\(O(N \log N)\), 펜윅 트리 등 사용) 모든
질의(\(Q\)개)를 판정하면

$$ O\bigl((N + Q)\log N \cdot \log N\bigr) $$

정도가 됩니다(자료구조 비용 포함). 독립 이분 탐색 대비 \(\log N\) 라운드로
공유되어 크게 절약됩니다.

언제 쓰는가 — 신호

  • 질의마다 "몇 번째 이벤트에서 조건이 처음 성립하나"를 물음.
  • 각 질의의 판정이 단조(이벤트를 더 적용할수록 조건이 쭉 만족 방향).
  • 이벤트를 순서대로 누적 적용할 수 있고, 오프라인(모든 질의를 미리 앎)이다.

대표 유형: "간선/변을 하나씩 추가할 때, 두 정점이 처음 연결되는 시점"(유니온
파인드), "물을 부을 때 각 우물이 처음 넘치는 시점"(펜윅/구간 합), "\(K\)번째
원소가 확정되는 시점" 등.

전제 조건 정리

  1. 단조성: 시점 \(t\)가 늘수록 각 질의의 조건이 한 방향으로만 바뀐다.
  2. 오프라인: 질의를 모두 미리 읽어 함께 처리한다.
  3. 누적 가능한 이벤트: 시점 순으로 이벤트를 자료구조에 반영할 수 있다.
Lesson 구현 골격: 라운드-스위프-갱신 선택 8m

PBS 표준 골격

각 질의 \(q\)마다 탐색 구간 lo[q], hi[q]를 두고, 답이 확정될 때까지 다음
라운드를 반복합니다.

  1. 아직 구간이 안 좁혀진 질의를 mid = (lo+hi)/2 값으로 버킷에 담는다.
  2. 이벤트를 시점 \(1, 2, \dots\) 순서로 자료구조에 적용하며, 시점 \(t\)
    도달하면 mid == t인 버킷의 질의들을 판정한다.
  3. 판정 결과(성립/불성립)에 따라 각 질의의 lo/hi를 좁힌다.
  4. 모든 구간이 길이 1이 되면 종료. 각 질의의 답은 lo[q].

한 라운드에 이벤트를 한 번만 훑으므로 라운드당 \(O((N + Q)\log N)\),
전체 \(O(\log N)\) 라운드입니다.

// 개념 골격 (유니온 파인드로 "처음 연결되는 간선 번호" 찾기 예시)
// events[t] : t번째로 추가되는 간선 (1..M)
// queries[q] = {u, v} : u, v 가 처음 연결되는 시점?
int lo[Q], hi[Q];                        // 각 질의의 탐색 구간 [lo, hi]
for (int q = 0; q < Qn; q++) { lo[q] = 1; hi[q] = M + 1; } // M+1 = "영영 안 됨"

bool changed = true;
while (changed) {
    changed = false;
    vector<vector<int>> bucket(M + 2);   // mid 값 -> 질의 목록
    for (int q = 0; q < Qn; q++)
        if (lo[q] < hi[q]) {
            int mid = (lo[q] + hi[q]) / 2;
            bucket[mid].push_back(q);
            changed = true;
        }
    dsu_init();                          // 라운드마다 자료구조 초기화!
    for (int t = 1; t <= M; t++) {
        dsu_union(events[t]);            // t번째 이벤트 적용
        for (int q : bucket[t]) {        // 지금 시점 t가 mid인 질의 판정
            if (connected(queries[q].u, queries[q].v))
                hi[q] = t;               // t에 성립 → 답은 t 이하
            else
                lo[q] = t + 1;           // 아직 → 답은 t 초과
        }
    }
}
// 답: lo[q] (== hi[q]). lo[q] == M+1 이면 끝내 성립 안 함.
def parallel_binary_search(Qn, M):
    lo = [1] * Qn
    hi = [M + 1] * Qn
    changed = True
    while changed:
        changed = False
        bucket = [[] for _ in range(M + 2)]
        for q in range(Qn):
            if lo[q] < hi[q]:
                mid = (lo[q] + hi[q]) // 2
                bucket[mid].append(q)
                changed = True
        ds_init()                        # 라운드마다 초기화
        for t in range(1, M + 1):
            apply_event(t)               # t번째 이벤트 반영
            for q in bucket[t]:
                if check(q):
                    hi[q] = t
                else:
                    lo[q] = t + 1
    return lo

자료구조 선택

  • 연결성 질의 → 유니온 파인드(라운드마다 초기화; rollback DSU면 더 빠름).
  • 구간 합/개수 질의 → 펜윅 트리(각 라운드 시작 시 리셋).
  • 판정이 "누적값이 임계치를 넘는가"면 펜윅으로 부분합을 구해 비교합니다.
Lesson 심화·함정: 초기화, 판정 방향, 오프라인성 선택 8m

라운드마다 자료구조를 반드시 초기화

PBS의 1순위 버그는 자료구조 초기화 누락입니다. 각 라운드는 시간축을
\(1\)부터 다시 훑으므로, 라운드가 시작될 때 유니온 파인드/펜윅 트리를 처음
상태로 되돌려야
합니다. 매번 전체 초기화가 부담이면 되돌리기(rollback)
가능한 자료구조
(경로 압축 없는 union by rank + 스택)로 라운드당 적용분만
취소합니다.

단조성과 판정 방향

각 질의의 조건은 시점에 대해 단조여야 합니다. "이벤트를 더 적용할수록
성립 쪽으로만 이동"해야 이분 탐색이 성립합니다. 판정에서

  • 시점 \(t\)에서 성립 → 답이 \(t\) 이하 → hi = t.
  • 시점 \(t\)에서 불성립 → 답이 \(t\) 초과 → lo = t + 1.

방향을 반대로 잡으면 답이 통째로 틀립니다. "언제 처음 성립하나"(최소 시점)
골격임을 기억하세요.

끝내 성립하지 않는 질의

모든 이벤트를 적용해도 조건이 안 되는 질의가 있을 수 있습니다. hi의 초깃값을
\(M + 1\)("영영 안 됨")로 두면, 종료 후 lo[q] == M + 1인 질의를 "불가능"으로
구분할 수 있습니다.

흔한 함정

  • 초기화 누락 — 라운드마다 자료구조 리셋 필수. 최대 버그 원인.
  • 판정 방향 오류 — 최소 시점 탐색의 갱신 방향(hi=t / lo=t+1)을 정확히.
  • 버킷/시간축 정렬 — 이벤트를 시점 순으로 정확히 적용. mid 버킷을 시점에
    맞춰 판정.
  • 오버플로/자료구조 비용 — 펜윅 값이 커지면 long long. 라운드당 비용에
    자료구조의 \(\log\)이 곱해짐을 복잡도에 반영.
  • 오프라인성 위반 — 질의를 즉석에서 받아야 하면 PBS 불가. 모든 질의를
    미리 알아야 함.
  • 불필요한 재초기화 비용 — 매 라운드 전체 초기화가 무거우면 rollback으로.

다른 기법과의 관계

  • 매개 변수 탐색을 여러 질의에 대해 병렬화한 것이 PBS입니다. 하나의 답을
    이분 탐색하는 것을 \(Q\)개로 확장했다고 보면 됩니다.
  • 질의가 하나면 그냥 이분 탐색 + 판정으로 충분합니다. PBS는 질의가 많을 때
    공통 판정 비용을 라운드로 공유해 이득을 봅니다.

요약

PBS는 "여러 이분 탐색의 mid를 모아 시간축을 한 번씩만 훑는" 오프라인
기법으로, 전체를 \(O((N + Q)\log N \times \text{자료구조 비용})\)에 해결합니다.
단조성·오프라인·누적 이벤트라는 전제와, 라운드마다 초기화 + 판정 방향
지키면 개별 이분 탐색을 크게 앞지릅니다.

Practice problem 언제 이어지는가 선택 25m
R00618

언제 이어지는가

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

Unrated 레이팅 미적용 지금 풀기
Practice problem 유성우와 영지 수확 선택 25m
R00645

유성우와 영지 수확

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

Unrated 레이팅 미적용 지금 풀기