[2026] C++ 알고리즘 최적화 | 시간복잡도·공간복잡도·트레이드오프 [#54-10]
이 글의 핵심
C++ 알고리즘 최적화: Big-O 분석, 공간-시간 트레이드오프, 실전 최적화 기법. 문제 시나리오, 완전한 예제, 흔한 에러, 성능 팁, 프로덕션 패턴. 100만 건의 로그를 처리할 때 O(n²) 알고리즘을 쓰면 시간 초과가 발생합니다. 반대로 O(n)으로 줄이려다 메모리를 과다 사용해 OOM(Out of Memory)에 빠지기도 합니다. 알고리즘 최적화는 시간복잡도·공간복잡도·트레이드오프를 이해하고,.
들어가며: 알고리즘 최적화가 왜 필요한가
”데이터가 늘어나니 응답이 10초 넘게 걸려요”
100만 건의 로그를 처리할 때 O(n²) 알고리즘을 쓰면 시간 초과가 발생합니다. 반대로 O(n)으로 줄이려다 메모리를 과다 사용해 OOM(Out of Memory)에 빠지기도 합니다. 알고리즘 최적화는 시간복잡도·공간복잡도·트레이드오프를 이해하고, 문제 제약에 맞는 선택을 하는 것입니다. 이 글에서는 Big-O 분석부터 공간-시간 트레이드오프, 메모이제이션, 프로덕션 패턴까지 실전 활용법을 다룹니다. 요구 환경: C++17 이상 이 글을 읽으면:
- Big-O 표기법으로 알고리즘을 분석할 수 있습니다.
- 공간-시간 트레이드오프를 이해하고 적용할 수 있습니다.
- 실전 알고리즘 최적화 예제를 구현할 수 있습니다.
- 흔한 에러를 피하고 프로덕션 패턴을 적용할 수 있습니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 문제 시나리오
- 기본 개념: Big-O와 복잡도
- 핵심 구현: 최적화 패턴
- 고급 기법: 공간-시간 트레이드오프
- 완전한 알고리즘 최적화 예제
- 자주 발생하는 에러와 해결법
- 성능 최적화 팁
- 프로덕션 패턴
1. 문제 시나리오
시나리오 1: 대량 데이터 정렬 시 타임아웃
상황: 100만 건의 사용자 ID를 정렬해야 합니다. 버블 정렬을 사용하면 O(n²)이 되어 10초 이상 걸립니다. 실시간 API 응답 요구사항(200ms 이내)을 충족하지 못합니다.
해결: std::sort(인트로소트, O(n log n))로 교체하면 수 밀리초 이내에 완료됩니다. 정렬 알고리즘 선택이 최적화의 첫 단계입니다.
시나리오 2: 정렬된 배열에서 반복 검색
상황: 이미 정렬된 100만 개 ID 목록에서 10만 개의 키를 검색해야 합니다. std::find로 선형 검색하면 O(n × m) = 1조 번 연산으로 수 초가 걸립니다.
해결: std::binary_search 또는 std::lower_bound로 O(log n) 검색하면 10만 × 20 ≈ 200만 번 연산으로 수십 밀리초에 완료됩니다. 데이터 구조와 알고리즘 조합이 핵심입니다.
시나리오 3: 피보나치 재귀 호출 폭발
상황: fib(n)을 재귀적으로 구현하면 fib(40) 호출에 수 초가 걸립니다. 같은 하위 문제를 반복 계산하기 때문입니다.
해결: 메모이제이션(캐시)으로 O(2^n) → O(n)으로 개선합니다. 중복 계산 제거가 최적화의 핵심입니다.
시나리오 4: 메모리 부족 (OOM)
상황: 10GB 데이터를 처리하는데 O(n²) 공간을 사용하는 2차원 DP 테이블을 만들면 OOM이 발생합니다. 해결: 슬라이딩 윈도우 또는 상태 압축으로 O(n²) → O(n) 공간으로 줄입니다. 공간-시간 트레이드오프를 이해해야 합니다.
시나리오 5: 중복 제거 후 성능 저하
상황: std::vector에서 중복을 제거할 때 매번 std::find로 검색하면 O(n²)입니다. 100만 건이면 수 시간이 걸립니다.
해결: std::sort + std::unique + erase로 O(n log n)에 처리하거나, std::unordered_set으로 O(n)에 처리합니다.
알고리즘 최적화 의사결정 흐름
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
flowchart TB
subgraph input[입력]
N[데이터 크기 n]
Q[쿼리 횟수]
M[메모리 제한]
end
subgraph decision[의사결정]
D1{n이 작음?\n< 10^4}
D2{정렬 가능?}
D3{중복 계산?}
end
subgraph result[선택]
R1[브루트포스 OK]
R2[정렬 + 이진검색]
R3[메모이제이션]
R4[공간 압축]
end
N --> D1
D1 -->|Yes| R1
D1 -->|No| D2
D2 -->|Yes| R2
D2 -->|No| D3
D3 -->|Yes| R3
D3 -->|No| R4
2. 기본 개념: Big-O와 복잡도
Big-O 표기법이란
Big-O는 입력 크기 n이 커질 때 연산 횟수나 메모리 사용량이 어떻게 증가하는지 나타냅니다. 상수 배와 낮은 차수는 무시하고 최악의 증가 추세를 표현합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// O(1): 상수 시간 - n과 무관
int get_first(const std::vector<int>& vec) {
return vec.empty() ? 0 : vec[0];
}
// O(n): 선형 - n에 비례
int sum(const std::vector<int>& vec) {
int s = 0;
for (int x : vec) s += x;
return s;
}
// O(n²): 제곱 - n²에 비례 (이중 루프)
void bubble_sort(std::vector<int>& vec) {
for (size_t i = 0; i < vec.size(); ++i)
for (size_t j = 0; j < vec.size() - 1 - i; ++j)
if (vec[j] > vec[j + 1])
std::swap(vec[j], vec[j + 1]);
}
// O(log n): 로그 - 이진 검색
bool binary_search(const std::vector<int>& vec, int target) {
size_t lo = 0, hi = vec.size();
while (lo < hi) {
size_t mid = lo + (hi - lo) / 2;
if (vec[mid] < target) lo = mid + 1;
else if (vec[mid] > target) hi = mid;
else return true;
}
return false;
}
시간복잡도 비교표
| 복잡도 | n=1,000 | n=1,000,000 | 대표 예시 |
|---|---|---|---|
| O(1) | 1 | 1 | 배열 인덱싱, 해시 조회 |
| O(log n) | 10 | 20 | 이진 검색, 균형 트리 |
| O(n) | 1,000 | 1,000,000 | 선형 스캔, 단일 루프 |
| O(n log n) | 10,000 | 20,000,000 | 병합/퀵 정렬 |
| O(n²) | 1,000,000 | 1조 | 버블 정렬, 이중 루프 |
| O(2^n) | - | - | 재귀 피보나치(비실용) |
공간복잡도
공간복잡도는 추가 메모리 사용량을 나타냅니다. 입력 자체의 크기는 제외하고, 알고리즘이 사용하는 보조 공간을 봅니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// O(1) 공간: 추가 메모리 없음
int max_element(const std::vector<int>& vec) {
int m = vec[0];
for (size_t i = 1; i < vec.size(); ++i)
if (vec[i] > m) m = vec[i];
return m;
}
// O(n) 공간: 결과 벡터
std::vector<int> doubled(const std::vector<int>& vec) {
std::vector<int> result(vec.size());
for (size_t i = 0; i < vec.size(); ++i)
result[i] = vec[i] * 2;
return result;
}
// O(n²) 공간: 2차원 DP 테이블
// dp[i][j] = 길이 i, j인 두 수열의 LCS
std::vector<std::vector<int>> lcs_table(size_t n, size_t m) {
return std::vector<std::vector<int>>(n + 1,
std::vector<int>(m + 1, 0));
}
Big-O 분석 다이어그램
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
flowchart LR
subgraph time[시간복잡도]
T1[O(1)]
T2[O(log n)]
T3[O(n)]
T4[O(n log n)]
T5[O(n²)]
end
subgraph space[공간복잡도]
S1[O(1)]
S2[O(n)]
S3[O(n²)]
end
T1 --> S1
T3 --> S2
T5 --> S3
일상 비유로 이해하기: 시간 복잡도는 책에서 원하는 페이지를 찾는 방법으로 비유할 수 있습니다. O(1)은 목차를 보고 바로 찾기, O(log n)은 이진 탐색처럼 중간을 펼쳐 앞뒤 판단하기, O(n)은 첫 페이지부터 하나씩 넘기기, O(n²)은 모든 페이지 조합을 확인하기입니다.
3. 핵심 구현: 최적화 패턴
패턴 1: 불필요한 반복 제거
같은 데이터를 여러 번 순회하지 말고, 한 번의 루프에서 필요한 연산을 모두 수행합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 비효율: 두 번 순회
int sum_and_count_evens_bad(const std::vector<int>& vec) {
int sum = 0;
for (int x : vec) sum += x;
int count = 0;
for (int x : vec)
if (x % 2 == 0) ++count;
return sum + count; // 의미 없는 반환, 예시용
}
// ✅ 효율: 한 번 순회
std::pair<int, int> sum_and_count_evens(const std::vector<int>& vec) {
int sum = 0, count = 0;
for (int x : vec) {
sum += x;
if (x % 2 == 0) ++count;
}
return {sum, count};
}
패턴 2: 조기 종료 (Early Exit)
조건을 만족하면 즉시 반환하여 불필요한 연산을 건너뜁니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 비효율: 끝까지 순회
bool contains_negative_bad(const std::vector<int>& vec) {
bool found = false;
for (int x : vec)
if (x < 0) found = true;
return found;
}
// ✅ 효율: 첫 음수 발견 시 즉시 반환
bool contains_negative(const std::vector<int>& vec) {
for (int x : vec)
if (x < 0) return true;
return false;
}
패턴 3: 인덱스 vs 반복자
랜덤 접근이 필요하면 vector + 인덱스가 캐시 친화적입니다. 순차 접근만 필요하면 반복자가 깔끔합니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 인덱스: 이진 검색, DP 등
size_t binary_search_idx(const std::vector<int>& vec, int target) {
size_t lo = 0, hi = vec.size();
while (lo < hi) {
size_t mid = lo + (hi - lo) / 2;
if (vec[mid] < target) lo = mid + 1;
else hi = mid;
}
return lo;
}
// 반복자: STL 알고리즘과 호환
auto it = std::lower_bound(vec.begin(), vec.end(), target);
패턴 4: 데이터 구조 선택
| 연산 | vector | set/map | unordered_set/map |
|---|---|---|---|
| 검색 | O(n) | O(log n) | O(1) 평균 |
| 삽입 | O(1) 끝 | O(log n) | O(1) 평균 |
| 정렬 유지 | 수동 | 자동 | 없음 |
| 아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요. |
// 존재 여부만 빠르게 확인 -> unordered_set
#include <unordered_set>
std::unordered_set<int> ids(data.begin(), data.end());
if (ids.count(target)) { /* ....*/ }
// 정렬된 순서 유지 + 검색 -> set
#include <set>
std::set<int> sorted_ids(data.begin(), data.end());
auto it = sorted_ids.lower_bound(target);
4. 고급 기법: 공간-시간 트레이드오프
트레이드오프 개념
공간을 더 쓰면 시간을 줄일 수 있고, 시간을 더 쓰면 공간을 줄일 수 있습니다. 메모이제이션, 해시 테이블, 사전 계산이 대표적입니다. 아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
flowchart TB
subgraph trade[트레이드오프]
A[메모리 ↑] --> B[시간 ↓]
C[메모리 ↓] --> D[시간 ↑]
end
E[메모이제이션] --> A
F[해시 테이블] --> A
G[슬라이딩 윈도우] --> C
H[재계산] --> C
메모이제이션: 피보나치 최적화
재귀 피보나치는 O(2^n)입니다. 메모이제이션으로 O(n) 시간, O(n) 공간으로 개선합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#include <vector>
#include <optional>
// ❌ 비효율: O(2^n)
long long fib_naive(int n) {
if (n <= 1) return n;
return fib_naive(n - 1) + fib_naive(n - 2);
}
// ✅ 메모이제이션: O(n) 시간, O(n) 공간
long long fib_memo(int n) {
if (n <= 1) return n;
std::vector<std::optional<long long>> cache(n + 1);
cache[0] = 0;
cache[1] = 1;
std::function<long long(int)> fib = [&](int k) -> long long {
if (cache[k]) return *cache[k];
cache[k] = fib(k - 1) + fib(k - 2);
return *cache[k];
};
return fib(n);
}
// ✅ 반복 + 슬라이딩: O(n) 시간, O(1) 공간
long long fib_iter(int n) {
if (n <= 1) return n;
long long a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
long long next = a + b;
a = b;
b = next;
}
return b;
}
슬라이딩 윈도우: 공간 압축
2차원 DP를 1차원으로 압축하여 O(n²) → O(n) 공간으로 줄입니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 0/1 배낭 문제: O(n * W) 공간 -> O(W) 공간
int knapsack_1d(const std::vector<int>& weights,
const std::vector<int>& values, int W) {
std::vector<int> dp(W + 1, 0);
for (size_t i = 0; i < weights.size(); ++i) {
// 역순 순회: 이전 행만 참조
for (int w = W; w >= weights[i]; --w) {
dp[w] = std::max(dp[w],
dp[w - weights[i]] + values[i]);
}
}
return dp[W];
}
해시 테이블로 검색 가속
정렬 없이 O(1) 평균 검색이 필요할 때 std::unordered_map을 사용합니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#include <unordered_map>
#include <vector>
// 두 수의 합: O(n²) -> O(n)
std::pair<int, int> two_sum(const std::vector<int>& vec, int target) {
std::unordered_map<int, size_t> seen;
for (size_t i = 0; i < vec.size(); ++i) {
int complement = target - vec[i];
if (auto it = seen.find(complement); it != seen.end())
return {static_cast<int>(it->second), static_cast<int>(i)};
seen[vec[i]] = i;
}
return {-1, -1};
}
5. 완전한 알고리즘 최적화 예제
예제 1: 최대 부분 배열 합 (Kadane 알고리즘)
문제: 연속된 부분 배열 중 합이 최대인 값을 구합니다. 브루트포스는 O(n²) 또는 O(n³)입니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// g++ -std=c++17 -o kadane kadane.cpp && ./kadane
#include <vector>
#include <algorithm>
#include <climits>
#include <iostream>
// ❌ O(n²): 모든 구간 탐색
int max_subarray_naive(const std::vector<int>& vec) {
int best = INT_MIN;
for (size_t i = 0; i < vec.size(); ++i) {
int sum = 0;
for (size_t j = i; j < vec.size(); ++j) {
sum += vec[j];
best = std::max(best, sum);
}
}
return best;
}
// ✅ O(n): Kadane 알고리즘
int max_subarray_kadane(const std::vector<int>& vec) {
if (vec.empty()) return 0;
int current = vec[0];
int best = vec[0];
for (size_t i = 1; i < vec.size(); ++i) {
current = std::max(vec[i], current + vec[i]);
best = std::max(best, current);
}
return best;
}
int main() {
std::vector<int> vec = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
std::cout << "Naive: " << max_subarray_naive(vec) << "\n"; // 6
std::cout << "Kadane: " << max_subarray_kadane(vec) << "\n"; // 6
return 0;
}
실행 결과:
Naive: 6
Kadane: 6
예제 2: 두 수의 합 → 세 수의 합 (정렬 + 투 포인터)
문제: 배열에서 합이 target이 되는 두/세 원소를 찾습니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#include <algorithm>
#include <vector>
#include <iostream>
// 두 수의 합: 정렬 O(n log n) + 투 포인터 O(n) = O(n log n)
std::pair<int, int> two_sum_sorted(std::vector<int> vec, int target) {
std::sort(vec.begin(), vec.end());
size_t lo = 0, hi = vec.size() - 1;
while (lo < hi) {
int sum = vec[lo] + vec[hi];
if (sum == target) return {vec[lo], vec[hi]};
if (sum < target) ++lo;
else --hi;
}
return {-1, -1};
}
// 세 수의 합: O(n²)
std::vector<int> three_sum(std::vector<int> vec, int target) {
std::sort(vec.begin(), vec.end());
for (size_t i = 0; i + 2 < vec.size(); ++i) {
size_t lo = i + 1, hi = vec.size() - 1;
int remain = target - vec[i];
while (lo < hi) {
int sum = vec[lo] + vec[hi];
if (sum == remain)
return {vec[i], vec[lo], vec[hi]};
if (sum < remain) ++lo;
else --hi;
}
}
return {};
}
예제 3: LCS (최장 공통 부분 수열) 최적화
문제: 두 수열의 최장 공통 부분 수열 길이. 2차원 DP는 O(nm) 공간입니다. 현재 행과 이전 행만 쓰면 O(min(n,m)) 공간으로 압축합니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#include <vector>
#include <algorithm>
#include <string>
// O(n*m) 시간, O(n*m) 공간
int lcs_full(const std::string& a, const std::string& b) {
size_t n = a.size(), m = b.size();
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1, 0));
for (size_t i = 1; i <= n; ++i) {
for (size_t j = 1; j <= m; ++j) {
if (a[i-1] == b[j-1])
dp[i][j] = dp[i-1][j-1] + 1;
else
dp[i][j] = std::max(dp[i-1][j], dp[i][j-1]);
}
}
return dp[n][m];
}
// O(n*m) 시간, O(min(n,m)) 공간 - 슬라이딩
int lcs_compressed(const std::string& a, const std::string& b) {
if (a.size() < b.size()) return lcs_compressed(b, a);
size_t n = a.size(), m = b.size();
std::vector<int> prev(m + 1, 0), curr(m + 1, 0);
for (size_t i = 1; i <= n; ++i) {
for (size_t j = 1; j <= m; ++j) {
if (a[i-1] == b[j-1])
curr[j] = prev[j-1] + 1;
else
curr[j] = std::max(prev[j], curr[j-1]);
}
std::swap(prev, curr);
}
return prev[m];
}
예제 4: 로그 분석 파이프라인 (STL 알고리즘 조합)
대량 로그를 필터링·정렬·집계하는 실전 예제입니다. 다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#include <algorithm>
#include <numeric>
#include <vector>
#include <string>
#include <iostream>
struct LogEntry {
std::string timestamp;
int level;
std::string message;
};
void analyze_logs(std::vector<LogEntry>& logs) {
// 1. 타임스탬프 정렬 O(n log n)
std::sort(logs.begin(), logs.end(),
{
return a.timestamp < b.timestamp;
});
// 2. ERROR 개수 O(n)
int errors = std::count_if(logs.begin(), logs.end(),
{ return e.level >= 3; });
// 3. WARN 이상만 복사 O(n)
std::vector<LogEntry> critical;
critical.reserve(logs.size());
std::copy_if(logs.begin(), logs.end(), std::back_inserter(critical),
{ return e.level >= 2; });
// 4. 평균 레벨 O(n)
int sum = std::accumulate(logs.begin(), logs.end(), 0,
{ return a + e.level; });
double avg = static_cast<double>(sum) / logs.size();
std::cout << "Errors: " << errors << ", Avg level: " << avg << "\n";
}
예제 5: 상위 K개 요소 (partial_sort vs nth_element)
전체 정렬 없이 상위 K개만 필요할 때 nth_element가 더 빠릅니다.
다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
#include <algorithm>
#include <vector>
#include <iostream>
// 상위 5개 (정렬된 순서 필요)
void top5_sorted(std::vector<int>& vec) {
std::partial_sort(vec.begin(), vec.begin() + 5, vec.end(),
std::greater<int>());
}
// 상위 5개 (순서 무관, 더 빠름)
void top5_any_order(std::vector<int>& vec) {
if (vec.size() < 5) return;
std::nth_element(vec.begin(), vec.begin() + 4, vec.end(),
std::greater<int>());
// vec[0..4]에 상위 5개 (순서 보장 안 됨)
}
6. 자주 발생하는 에러와 해결법
에러 1: 정렬되지 않은 범위에 binary_search 사용
증상: 검색 결과가 잘못되거나 예측 불가능합니다.
원인: binary_search, lower_bound, upper_bound는 정렬된 범위에서만 올바르게 동작합니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 사용
std::vector<int> vec = {5, 2, 8, 1, 9};
bool found = std::binary_search(vec.begin(), vec.end(), 5); // UB!
// ✅ 올바른 사용
std::sort(vec.begin(), vec.end());
bool found = std::binary_search(vec.begin(), vec.end(), 5);
에러 2: accumulate 초기값 타입 불일치
증상: 부동소수점 합계가 잘못되거나 정밀도가 떨어집니다.
원인: 0은 int이므로 double 벡터를 합할 때 매 단계 int로 변환됩니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 사용
std::vector<double> vec = {0.1, 0.2, 0.3};
double sum = std::accumulate(vec.begin(), vec.end(), 0); // int 0
// ✅ 올바른 사용
double sum = std::accumulate(vec.begin(), vec.end(), 0.0);
에러 3: 비교자 strict weak ordering 위반
증상: std::sort 호출 시 크래시 또는 무한 루프.
원인: a <= b를 사용하면 a == b일 때 true가 되어 a < a가 true가 될 수 있습니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 잘못된 비교자
std::sort(vec.begin(), vec.end(),
{ return a <= b; }); // 위반
// ✅ 올바른 비교자
std::sort(vec.begin(), vec.end(),
{ return a < b; });
에러 4: 반복자 무효화 후 사용
증상: Segmentation fault 또는 예측 불가능한 동작.
원인: push_back 등으로 재할당이 일어나면 기존 반복자가 무효화됩니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 위험한 패턴
auto it = vec.begin();
vec.push_back(100); // 재할당 가능
*it; // UB
// ✅ 인덱스 사용 또는 반복자 사용 직후에만
size_t idx = std::distance(vec.begin(), it);
vec.push_back(100);
에러 5: 빈 범위에서 나눗셈
증상: 0으로 나누기 또는 잘못된 평균.
원인: vec.empty()일 때 accumulate 결과를 size()로 나누면 0으로 나눕니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ❌ 위험
double avg = std::accumulate(vec.begin(), vec.end(), 0.0) / vec.size();
// ✅ 안전
double avg = vec.empty()
? 0.0
: std::accumulate(vec.begin(), vec.end(), 0.0) / vec.size();
에러 6: 메모이제이션에서 무한 재귀
증상: 스택 오버플로우. 원인: 기저 조건을 빠뜨리거나, 캐시에 저장 전에 재귀 호출합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// ❌ 잘못된 메모이제이션
long long fib_bad(std::vector<long long>& cache, int n) {
cache[n] = fib_bad(cache, n-1) + fib_bad(cache, n-2); // 기저 없음
return cache[n];
}
// ✅ 올바른 메모이제이션
long long fib_ok(std::vector<long long>& cache, int n) {
if (n <= 1) return n;
if (cache[n] != -1) return cache[n];
cache[n] = fib_ok(cache, n-1) + fib_ok(cache, n-2);
return cache[n];
}
7. 성능 최적화 팁
팁 1: 정렬이 필요할 때만 정렬
한 번만 검색할 거면 std::find(O(n))가 정렬(O(n log n)) + binary_search보다 나을 수 있습니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 한 번만 검색 -> find
auto it = std::find(vec.begin(), vec.end(), target);
// 여러 번 검색 -> 정렬 후 binary_search
std::sort(vec.begin(), vec.end());
for (int key : keys) {
if (std::binary_search(vec.begin(), vec.end(), key)) { /* ....*/ }
}
팁 2: reserve로 재할당 최소화
back_inserter 사용 시 결과 크기를 대략 알 수 있으면 reserve로 재할당을 줄입니다.
다음은 간단한 cpp 코드 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
std::vector<int> result;
result.reserve(vec.size());
std::copy_if(vec.begin(), vec.end(), std::back_inserter(result),
{ return x % 2 == 0; });
팁 3: partial_sort vs nth_element
상위 K개만 필요할 때:
- 순서 필요:
partial_sortO(n log k) - 순서 무관:
nth_elementO(n) 평균, 더 빠름 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 상위 10개, 정렬된 순서
std::partial_sort(vec.begin(), vec.begin() + 10, vec.end(), std::greater<int>());
// 상위 10개, 순서 무관
std::nth_element(vec.begin(), vec.begin() + 9, vec.end(), std::greater<int>());
팁 4: 람다 vs std::function
람다는 컴파일러가 인라인하기 쉽습니다. std::function은 간접 호출 비용이 있습니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 람다 (인라인 가능)
std::sort(vec.begin(), vec.end(), { return a < b; });
// ❌ std::function (간접 호출)
std::function<bool(int,int)> cmp = { return a < b; };
std::sort(vec.begin(), vec.end(), cmp);
팁 5: std::reduce로 병렬 집계 (C++17)
대용량 데이터 집계 시 std::reduce + std::execution::par로 병렬화합니다.
#include <numeric>
#include <execution>
int sum = std::reduce(std::execution::par, vec.begin(), vec.end(), 0);
팁 6: 캐시 친화적 접근
연속 메모리 접근이 캐시에 유리합니다. vector가 list보다 대부분 빠릅니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// ✅ 연속 접근 (캐시 친화적)
for (size_t i = 0; i < vec.size(); ++i)
process(vec[i]);
// ❌ 랜덤 포인터 따라가기 (캐시 미스)
for (auto* p = head; p; p = p->next)
process(p->value);
성능 비교 요약
| 연산 | 비효율 | 효율 | 개선 |
|---|---|---|---|
| 부분 배열 합 | O(n²) | O(n) Kadane | n배 |
| 두 수의 합 | O(n²) | O(n) 해시 | n배 |
| 피보나치 | O(2^n) | O(n) 메모이제이션 | 지수적 |
| LCS 공간 | O(nm) | O(min(n,m)) | m배 |
| 상위 K개 | O(n log n) sort | O(n) nth_element | log n배 |
8. 프로덕션 패턴
패턴 1: erase-remove idiom
조건에 맞는 원소를 제거할 때 remove_if + erase 조합을 사용합니다.
다음은 간단한 cpp 코드 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 짝수 제거
vec.erase(std::remove_if(vec.begin(), vec.end(),
{ return x % 2 == 0; }),
vec.end());
패턴 2: 정렬 + unique로 중복 제거
std::sort(vec.begin(), vec.end());
vec.erase(std::unique(vec.begin(), vec.end()), vec.end());
패턴 3: 안전한 이진 검색 래퍼
정렬 여부를 확인한 뒤 binary_search를 호출하는 래퍼입니다.
아래 코드는 cpp를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
#include <algorithm>
template <typename Container, typename T>
bool safe_binary_search(const Container& c, const T& value) {
if (!std::is_sorted(c.begin(), c.end()))
return false;
return std::binary_search(c.begin(), c.end(), value);
}
패턴 4: 알고리즘 선택 가이드
다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
flowchart TB
subgraph need[요구사항]
N1[전체 정렬]
N2[상위 K개]
N3[존재 여부]
N4[빠른 검색]
end
subgraph choice[선택]
C1[sort]
C2[partial_sort / nth_element]
C3[binary_search]
C4[unordered_set]
end
N1 --> C1
N2 --> C2
N3 --> C3
N4 --> C4
패턴 5: 프로파일링 우선
최적화 전에 병목 지점을 측정합니다. 추측이 아닌 데이터로 결정합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
// 프로파일링 예시 (의사 코드)
// 1. 벤치마크로 현재 성능 측정
// 2. 병목 구간 식별 (정렬? 검색? I/O?)
// 3. 해당 구간만 최적화
// 4. 다시 측정하여 개선 확인
패턴 6: 구현 체크리스트
프로덕션에 알고리즘 최적화를 적용할 때 확인할 항목입니다.
-
binary_search계열 사용 전 범위가 정렬되어 있는지 확인 -
accumulate초기값 타입이 집계 결과와 일치하는지 확인 (double이면0.0) - 커스텀 비교자가 strict weak ordering을 만족하는지 확인
-
remove/remove_if후 반드시erase로 논리적 끝 구간 제거 - 대용량 데이터 시
reserve로 재할당 최소화 - 메모리 제한이 있으면 공간 압축(슬라이딩 윈도우) 검토
- 최적화 전 프로파일링으로 병목 확인
패턴 7: 알고리즘 선택 표
| 요구사항 | 추천 | 복잡도 |
|---|---|---|
| 전체 정렬 | sort | O(n log n) |
| 같은 값 순서 유지 | stable_sort | O(n log n) |
| 상위 K개 (정렬) | partial_sort | O(n log k) |
| 상위 K개 (순서 무관) | nth_element | O(n) 평균 |
| 존재 여부 (정렬됨) | binary_search | O(log n) |
| 빠른 존재 여부 | unordered_set | O(1) 평균 |
| 부분 배열 최대합 | Kadane | O(n) |
| 두 수의 합 | 해시 / 투 포인터 | O(n) |
| 중복 제거 | sort + unique | O(n log n) |
정리
| 항목 | 설명 |
|---|---|
| Big-O | 시간·공간 복잡도로 알고리즘 분석 |
| 트레이드오프 | 메모이제이션, 슬라이딩 윈도우, 해시 |
| 최적화 패턴 | 조기 종료, 반복 제거, 데이터 구조 선택 |
| 에러 | 정렬 없이 binary_search, 초기값 타입, 비교자 위반 |
| 성능 | reserve, nth_element, 람다, reduce 병렬 |
| 프로덕션 | erase-remove, sort+unique, 프로파일링 우선 |
| 핵심 원칙: |
- Big-O로 알고리즘을 분석하고 병목을 파악한다.
- 공간-시간 트레이드오프를 이해하고 제약에 맞게 선택한다.
- 정렬된 범위에서만 binary_search 계열을 사용한다.
- accumulate 초기값 타입을 결과와 일치시킨다.
- 최적화 전 프로파일링으로 병목을 확인한다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 성능 크리티컬한 시스템, 대용량 데이터 처리, 실시간 제약 등에 활용합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.