Post

2026-07-29 TIL (107일차)

2026-07-29 TIL (107일차)

알고리즘 강의 총정리 — 복잡도부터 A*까지

강의 21개 주제를 기초 → 심화 순으로 재배열해 핵심만 압축했습니다. 각 주제는 개념 + 면접 포인트 + 게임 적용 관점으로 정리했습니다.


1. “1초에 1억 번” — 시간 복잡도

입력 크기 $n$이 커질 때 연산 횟수의 증가 추세를 나타내는 표기이며, 상수와 낮은 차수 항은 무시합니다($3n^2 + 5n + 10 \rightarrow O(n^2)$). Big-O(상한) / Θ(정확한 차수) / Ω(하한) 중 최악을 보장하는 Big-O를 주로 씁니다.

\[O(1) < O(\log n) < O(n) < O(n \log n) < O(n^2) < O(n^3) < O(2^n) < O(n!)\]

채점 환경에서 1초 ≈ 1억(10^8)회 단순 연산이 경험적 기준입니다. 이 표로 $n$을 보고 알고리즘을 역산합니다.

n의 크기허용 복잡도접근법
n ≤ 10$O(n!)$순열 전탐색
n ≤ 20~25$O(2^n)$비트마스크, 부분집합
n ≤ 100$O(n^3)$플로이드-워셜, 3중 루프
n ≤ 2,000$O(n^2)$이중 루프 DP
n ≤ 100,000$O(n \log n)$정렬, 우선순위 큐, 이분 탐색
n ≤ 1,000,000$O(n)$투 포인터, 슬라이딩 윈도우, 누적합
n ≥ 10^9$O(\log n)$이분 탐색, 수학적 접근
  • 공간 복잡도: 게임에서는 메모리가 프레임 성능과 직결됩니다. 재귀는 콜 스택을 소비해 깊이가 크면 스택 오버플로우가 나고, DP 테이블은 $O(n^2)$ 메모리를 잡아먹습니다.
  • 분할 상환(Amortized): vector::push_back은 재할당 순간만 $O(n)$이고 평균은 $O(1)$입니다. 다만 이 간헐적 $O(n)$이 게임에서는 프레임 스파이크로 나타나므로 reserve()로 최악을 제거해야 합니다.

2. 컨테이너 선택과 문자열

요구 사항선택
인덱스 접근 + 순차 순회vector
LIFO (DFS, 괄호, Undo)stack
FIFO (BFS, 큐잉)queue
양끝 삽입/삭제 (0-1 BFS, 윈도우 최대값)deque
존재 여부 / 카운팅unordered_set / unordered_map
정렬 유지 + 범위 질의map / set
최소·최대 반복 추출priority_queue

문자열 주의점: std::string은 짧으면 SSO로 스택, 길어지면 힙 할당이 발생합니다. 루프에서 +는 임시 객체를 만들므로 +=·ostringstream을 쓰고, 인자는 const std::string& 또는 std::string_view로 받습니다. substr은 새 문자열을 만들지만 string_view는 복사 없이 $O(1)$입니다.

패턴 매칭: 단순 비교 $O(nm)$ → KMP는 실패 함수로 “이미 비교한 접두사 정보를 버리지 않아” $O(n+m)$ → 라빈-카프는 롤링 해시 → 트라이 / 아호-코라식은 다중 패턴, 즉 채팅 금칙어 필터의 실제 구현입니다.


3. 정렬 — std::sort의 정체

알고리즘평균최악공간안정성특징
삽입$O(n^2)$$O(n^2)$$O(1)$거의 정렬된 데이터에 $O(n)$
병합$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)$제자리 + 최악 보장
카운팅 / 기수$O(n+k)$동일$O(k)$값 범위가 작을 때
  • 퀵소트가 최악 $O(n^2)$인데도 표준인 이유: 제자리 정렬이라 추가 할당이 없고 캐시 지역성이 뛰어나며, 피벗을 랜덤·중앙값-of-3로 고르면 최악 확률이 무시할 수준입니다. (최악은 이미 정렬된 배열에서 첫 원소를 피벗으로 고를 때)
  • std::sort = Introsort: ① 퀵소트로 시작 → ② 재귀 깊이가 $2\log n$을 넘으면 힙소트로 전환(최악 보장) → ③ 구간이 16개 이하로 짧아지면 삽입 정렬로 마무리. 세 알고리즘의 장점만 조합한 하이브리드입니다.
  • 안정성: 같은 키의 원래 순서 보존. 다중 기준 정렬을 순차적으로 할 수 있게 해줍니다. std::sort는 불안정, std::stable_sort는 안정. 리더보드 동점 처리, 같은 depth UI의 드로우 순서에 필요합니다.

커스텀 비교자 함정: 비교자는 엄격한 약한 순서를 만족해야 하며, 같을 때 false를 반환해야 합니다.

1
2
std::sort(v.begin(), v.end(), [](int a, int b){ return a <= b; });  // ❌ UB, 실제로 크래시
std::sort(v.begin(), v.end(), [](int a, int b){ return a <  b; });  // ✅

4. 재귀와 스택 프레임

  • 필수 요소: ① 종료 조건(base case) ② 더 작은 문제로의 자기 호출
  • 한계: 호출마다 프레임(매개변수·지역변수·반환 주소·이전 프레임 포인터)이 쌓입니다. 기본 스택이 약 1MB, 프레임이 100byte면 대략 1만 depth 근처에서 위험해집니다. 이때는 std::stack에 상태를 담아 반복문으로 전환합니다.
  • 꼬리 재귀: 컴파일러가 반복문으로 최적화할 수 있지만 C++ 표준이 보장하지 않으므로 의존하면 안 됩니다.
  • 연결 고리: 콜 스택은 결국 스택 자료구조 그 자체여서, 재귀 DFS와 명시적 스택 DFS가 같은 순서를 만듭니다.

5. 이진 탐색 — 핵심은 매개변수 탐색

전제는 정렬이며 $O(\log n)$입니다. 단, 정렬 비용이 $O(n \log n)$이라 한 번만 찾을 거면 선형 탐색이 낫고, 여러 번 검색할 때 이득입니다.

1
2
int mid = (lo + hi) / 2;        // ❌ 오버플로우 위험
int mid = lo + (hi - lo) / 2;   // ✅
  • lower_bound(x) = x 이상인 첫 위치, upper_bound(x) = x 초과인 첫 위치. 차를 구하면 x의 개수입니다. (랜덤 액세스 이터레이터일 때만 $O(\log n)$ — std::list에 쓰면 $O(n)$)
  • 경계 조건에서 무한 루프가 잦으므로 반열린 구간 [lo, hi) 한 가지 형태로 통일하는 게 안전합니다.
  • 매개변수 탐색: 어떤 값 $x$에 대해 조건이 단조(monotonic) 하게 참/거짓으로 갈리면, 답 자체를 이분 탐색해 $O(\log(\text{범위}))$에 찾습니다. (나무 자르기, 랜선 자르기)

게임 적용: 조준 발사 각도 계산, 카메라가 벽에 닿지 않는 최대 거리, 밸런싱 파라미터 역산(목표 클리어 시간이 나오는 몬스터 체력 찾기).


6. 완전 탐색과 비트마스크

완전 탐색이 정당한 경우는 ① n이 작을 때 ② 정확성이 우선일 때 ③ 다른 알고리즘의 정답 검증 기준이 필요할 때입니다. 면접에서는 “먼저 완전 탐색으로 정답을 보장하고, 그다음 최적화 지점을 찾는다”는 사고 순서를 보여주는 게 중요합니다.

기법: 반복문 중첩(고정 개수) / 재귀·DFS(가변 깊이) / 비트마스크(n ≤ 20) / std::next_permutation(정렬 상태에서 시작) / BFS(최소 횟수).

연산코드
포함 확인(mask >> i) & 1
추가mask \|= (1 << i)
제거mask &= ~(1 << i)
토글mask ^= (1 << i)
전체 순회for (int s = 0; s < (1 << n); ++s)

게임 적용: 언리얼의 콜리전 채널이 정확히 비트마스크입니다. 오브젝트 타입별 비트를 세워 & 한 번으로 “충돌해야 하는가”를 판정하므로 수천 쌍을 검사해도 저렴합니다.


7. 백트래킹 — 가지치기

DFS 기반 완전 탐색에 가지치기(Pruning) 를 더한 것입니다. 현재 경로가 더 진행해도 답이 될 수 없으면 즉시 되돌아갑니다. 최악 복잡도는 완전 탐색과 같지만 실측 성능은 훨씬 좋습니다.

1
2
3
4
5
6
7
8
9
void backtrack(State& s, int depth) {
    if (isSolution(s)) { record(s); return; }
    for (auto& choice : candidates(s)) {
        if (!isPromising(s, choice)) continue;   // ← 가지치기
        apply(s, choice);
        backtrack(s, depth + 1);
        undo(s, choice);                         // ← 되돌리기 (최다 실수)
    }
}

가지치기 예: N-Queen(같은 열·대각선), 부분집합 합(목표 초과 시 중단), 스도쿠(후보 0개 칸 발생), 미로(방문 표시 후 해제).

게임 적용: AI가 몇 수 앞을 보는 미니맥스 + 알파베타 가지치기가 백트래킹의 직계 응용이고, 제약을 만족하는 배치를 찾는 절차적 레벨 생성(WFC류)에도 쓰입니다.


8. 그리디 vs DP — 구분이 핵심

그리디 성립 조건: ① 탐욕적 선택 속성 ② 최적 부분 구조. 증명 없이 쓰면 틀립니다. 동전 {1,4,5}로 8원은 그리디 5+1+1+1=4개지만 최적은 4+4=2개이고, {1,5,10,50}처럼 배수 관계면 성립합니다.

구분GreedyDP
선택 방식현재 최선을 즉시 확정, 되돌아보지 않음모든 경우를 검토 후 최선
속도빠름 (보통 정렬 $O(n \log n)$)느림 (상태 수 × 전이)
정답 보장조건 만족 시에만항상

판별 실전 팁: “지금의 선택이 나중의 선택 가능성을 제약하는가?”제약하면 DP, 독립적이면 그리디. 배낭 문제에서 분할 가능(Fractional)이면 그리디, 0/1이면 DP인 이유가 정확히 이것입니다.

대표 문제: 활동 선택(회의실 배정 — 종료 시각 기준 정렬), 거스름돈, 최소 회의실 수(우선순위 큐), 허프만 코딩, 크루스칼·프림 MST.

게임 적용: 타겟 선정(“가장 가까운 적”), 인벤토리 자동 정리, 스킬 로테이션. 다만 그리디 AI는 예측 가능해서 재미가 없다는 점도 고려해야 합니다.


9. DP — 4단계 접근과 유형표

적용 조건: ① 최적 부분 구조 ② 중복되는 부분 문제. ②가 없으면 DP가 아니라 그냥 분할 정복입니다(병합 정렬은 부분 문제가 겹치지 않으므로 DP가 아님).

구분Top-down (메모이제이션)Bottom-up (타뷸레이션)
구현재귀 + 캐시반복문 + 테이블
계산 범위필요한 상태만모든 상태
오버헤드호출 비용, 스택 위험없음, 더 빠름
메모리 최적화어려움슬라이딩 윈도우로 차원 축소 가능

접근 4단계:상태 정의(가장 중요, 대부분 여기서 막힘) → ② 점화식 도출 → ③ 초기 조건 → ④ 순회 순서. dp[i][w] = i번째 물건까지 고려했을 때 무게 w 이하 최대 가치처럼 정의를 문장으로 소리 내어 말하는 것이 포인트입니다.

유형상태 정의복잡도
0/1 배낭dp[i][w] = i개까지 고려, 무게 w 이하 최대 가치$O(nW)$
LISdp[i] = i로 끝나는 최장 증가 부분수열 길이$O(n^2)$ → 이분탐색 $O(n \log n)$
LCSdp[i][j] = A의 i까지, B의 j까지 최장 공통 부분수열$O(nm)$
편집 거리dp[i][j] = A[..i] → B[..j] 최소 편집 횟수$O(nm)$
동전 교환dp[amount] = 금액을 만드는 최소 동전 수$O(n \cdot amount)$
구간 DPdp[i][j] = 구간 [i,j]의 최적값$O(n^3)$
비트마스크 DPdp[mask][i] = 방문 집합 mask, 현재 i (TSP)$O(2^n n^2)$
트리 DPdp[node][state] = 서브트리 최적값$O(n)$
  • 0/1 배낭 1차원 압축: dp[i][w]dp[i-1][*]만 참조하므로 압축 가능하되, 무게를 역순(W → w)으로 순회해야 합니다. 정순으로 하면 같은 물건을 여러 번 담아 무한 배낭이 됩니다. (역으로 무한 배낭은 정순)
  • 피보나치 3단계: 단순 재귀 $O(2^n)$ → 메모이제이션 $O(n)$ → 직전 두 값만 유지해 공간 $O(1)$ (행렬 거듭제곱은 시간 $O(\log n)$)

게임 적용: 무게 제한 하 최적 아이템 조합, 제한된 스킬 포인트 배분, 예산 내 최대 효율 장비 조합.


10. 투 포인터 · 슬라이딩 윈도우 · 누적합

  • 투 포인터: $O(n^2)$ 이중 루프를 $O(n)$으로 줄입니다. ① 양끝에서 마주 오기(정렬 배열의 두 수 합, 팰린드롬) ② 같은 방향(구간 합, 중복 제거, 플로이드 토끼-거북이 사이클 탐지 — $O(1)$ 공간). 설명의 핵심은 “왜 답을 놓치지 않는가”이며, 근거는 정렬 + 단조성입니다.
  • 슬라이딩 윈도우: 투 포인터의 특수 형태로, 두 포인터가 같은 방향으로 움직이며 구간 상태를 유지합니다. 전체를 재계산하지 않고 sum += a[i] - a[i-k]로 $O(1)$ 갱신 → 전체 $O(n)$. 윈도우 내 최대값은 단조 덱으로 $O(1)$ 유지(각 원소가 한 번 들어가고 한 번 나가므로 총 $O(n)$).
  • 누적합: sum(l..r) = prefix[r+1] - prefix[l], 전처리 $O(n)$ + 질의 $O(1)$. 전제는 배열이 변하지 않는 것입니다. 2차원은 포함-배제로, 반대로 차분 배열(Imos법) 은 구간 갱신을 $O(1)$로 처리합니다.
방법구간 질의값 갱신난이도
누적합$O(1)$$O(n)$
펜윅 트리(BIT)$O(\log n)$$O(\log n)$
세그먼트 트리$O(\log n)$$O(\log n)$상 (범용성 최고)

질의만 많으면 누적합, 갱신도 많으면 세그먼트 트리 — 이 트레이드오프가 핵심입니다. 게임 적용: 영향력 맵(Influence Map) + 2D 누적합으로 “가장 안전한 지점” 탐색, 프레임 타임 이동평균(프로파일러), 콤보 입력 윈도우 판정.


11. DFS & BFS

그래프 문제를 만나면 ① 방향성 ② 가중치 ③ 음수 가중치 ④ 정점·간선 규모 ⑤ 구하는 것(도달 가능성/최단 거리/모든 쌍/사이클/연결 요소) 5가지를 먼저 체크합니다.

구분DFSBFS
구현스택 / 재귀
최단 경로보장 안 함가중치 없을 때 보장
메모리$O(깊이)$$O(너비)$ — 넓으면 폭증
용도모든 경로, 사이클 탐지, 위상 정렬, 연결 요소최단 거리·최소 횟수, 레벨 처리, 플러드 필
  • BFS가 최단을 보장하는 이유: 거리 1인 정점 전부 → 거리 2인 정점 전부 순으로 방문하므로, 처음 도달한 시점이 곧 최단 거리입니다. 단 모든 간선 가중치가 동일해야 성립합니다.
  • 실전 버그: visited큐에 넣는 시점에 체크해야 합니다. 꺼낼 때 체크하면 같은 정점이 큐에 여러 번 들어가 시간 초과의 흔한 원인이 됩니다.
  • 0-1 BFS: 가중치가 0/1뿐이면 덱을 써서 0은 push_front, 1은 push_back → 거리 오름차순이 유지되어 $O(V+E)$로 다익스트라와 같은 결과를 냅니다.

게임 적용: BFS는 SRPG 이동 범위 표시, 플러드 필(물 퍼짐·영역 채우기), 폭발 전파. DFS는 절차적 미로 생성, 방 연결성 검증, 에셋 순환 참조 탐지, 스킬트리 선행 조건. 다중 시작점 BFS로 여러 적으로부터의 거리를 한 번에 계산해 위험도 맵을 만듭니다.


12. 다익스트라

시작점 거리 0·나머지 ∞로 초기화 → 미확정 정점 중 거리 최소를 선택(우선순위 큐) → 확정 후 인접 정점을 완화(Relaxation) → 반복. 우선순위 큐로 $O(E \log V)$이며, 밀집 그래프에서는 인접 행렬 $O(V^2)$가 오히려 유리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;  // {거리, 정점}
dist[start] = 0;  pq.push({0, start});
while (!pq.empty()) {
    auto [d, u] = pq.top(); pq.pop();
    if (d > dist[u]) continue;          // ★ 오래된(stale) 항목 스킵
    for (auto [v, w] : adj[u]) {
        if (dist[u] + w < dist[v]) {
            dist[v] = dist[u] + w;
            pq.push({dist[v], v});
        }
    }
}
  • pair는 반드시 {거리, 정점} 순서로 넣어야 거리 기준 정렬이 됩니다.
  • STL 우선순위 큐는 decrease-key를 지원하지 않아 중복 push가 생깁니다. if (d > dist[u]) continue; 한 줄이 이를 걸러내며, 없으면 성능이 크게 나빠집니다.
  • 음수 가중치에서 실패하는 이유: “한 번 확정한 거리는 더 짧아지지 않는다”는 그리디 가정이 깨지기 때문입니다. → 대안은 벨만-포드($O(VE)$, 음수 사이클 탐지 가능), SPFA, 플로이드-워셜($O(V^3)$, 경유지 k가 가장 바깥 루프).
  • 경로 복원: 갱신 시 parent[v] = u를 기록해 도착점에서 거꾸로 따라간 뒤 뒤집습니다. 게임에서는 이게 AI 이동 경로 포인트 배열이 됩니다.

13. A* 와 NavMesh (게임 최중요)

\[f(n) = g(n) + h(n)\]

g(n)은 시작점부터의 실제 누적 비용, h(n)은 목표까지의 예상 비용(휴리스틱)입니다. f(n)이 최소인 노드를 먼저 확장하며, h(n)=0이면 정확히 다익스트라가 됩니다.

  • 허용성(Admissible): h(n) ≤ 실제 최단 비용. 절대 과대평가하지 않아야 최적해가 보장됩니다. 과대평가하면 빠르지만 최단이 아닐 수 있습니다.
  • 일관성(Consistent): h(n) ≤ cost(n,n') + h(n'). 만족하면 닫힌 목록 노드를 재방문할 필요가 없어 구현이 단순해집니다.
  • 휴리스틱: 맨해튼(4방향), 유클리드(자유 방향), 옥타일(8방향). h에 1.001배를 곱하는 tie-breaking으로 동일 f 후보를 줄여 탐색을 가속할 수 있습니다.
  • Open Listf 최소를 빨리 꺼내야 하므로 우선순위 큐, Closed List는 조회가 빨라야 하므로 해시셋/불리언 배열로 구현합니다.
상황선택
가중치 없음 + 목표 1개BFS
가중치 있음 + 모든 정점까지 거리다익스트라
가중치 있음 + 목표가 특정 1곳A*
음수 가중치 존재벨만-포드
모든 쌍 최단 거리, V가 작음플로이드-워셜

NavMesh와의 관계: NavMesh는 이동 가능 영역을 볼록 폴리곤으로 미리 분할한 그래프입니다(폴리곤=정점, 인접=간선). ① A*로 폴리곤 시퀀스(코리도) 탐색 → ② Funnel / String Pulling으로 경로 평활화. 넓은 평지를 폴리곤 하나로 표현하므로 타일 그리드보다 노드 수가 훨씬 적고 임의 방향 이동이 자연스럽습니다. A*는 전역 경로만 담당하고 실시간 유닛 충돌은 RVO/Detour Crowd가 처리하는 전역/지역 2계층 구조입니다.

AI 100마리로 프레임이 떨어질 때: ① 경로 요청 Time-slicing ② 워커 스레드 비동기 처리 ③ 리더 경로 공유 + 오프셋(플로킹) ④ 계층적 탐색(HPA*) ⑤ 거리·화면 여부에 따른 갱신 빈도 LOD ⑥ 목표가 하나이고 유닛이 매우 많으면(RTS) Flow Field — 목표에서 역방향 BFS로 방향 벡터 필드를 한 번 만들어 전부 공유, 유닛당 비용 $O(1)$.


14. 유형 판별 치트시트

문제의 신호의심할 알고리즘
“최소 횟수”, 가중치 없음BFS
“최단 거리”, 가중치 있음다익스트라 / A*
“모든 경우”, “가능한 조합 전부”완전 탐색 / 백트래킹
“최대/최소값” + 부분 문제 반복DP
“정렬하면 뭔가 보인다”그리디 / 투 포인터
“연속된 구간”슬라이딩 윈도우 / 누적합
“K번째”, “조건 만족 최소값”, 범위가 큼이분 탐색(매개변수 탐색)
“연결되어 있는가”, “그룹 개수”DFS / BFS / Union-Find
“선행 조건”, “순서”위상 정렬
“가장 큰/작은 것 반복 추출”우선순위 큐(힙)
n ≤ 20, 부분집합비트마스크
This post is licensed under CC BY 4.0 by the author.