삶의 공유
STL 알고리즘의 진화: std::find부터 Ranges와 Views, 예제까지 완벽 정리 본문

C++로 개발을 하다 보면 컨테이너(vector, list 등) 안에 있는 특정 데이터를 찾아야 할 때가 수도 없이 많습니다. 이때 여러분은 어떻게 하시나요? 직접 for 문을 돌리시나요, 아니면 멤버 함수 find를 찾으시나요?
오늘은 C++ 표준 라이브러리(STL)가 데이터를 검색하는 철학이 어떻게 변화해왔는지, 그리고 C++20에 도입된 Ranges가 어떻게 이 과정을 혁신적으로 바꿨는지 깊이 있게 알아보겠습니다.
1. 컨테이너에 '검색' 기능을 넣는 두 가지 방법
C++ STL 설계자들은 컨테이너에서 데이터를 찾는 기능을 제공하기 위해 깊은 고민을 했습니다. 여기에는 두 가지 접근 방식이 있습니다.
방법 1: 멤버 함수로 제공하기 (s.find(3))
- 장점: s.find(3)처럼 작성하면 되니 직관적이고 사용하기 쉽습니다.
- 단점: 모든 컨테이너(vector, list, deque...)마다 똑같은 find 함수를 각각 만들어줘야 합니다. 만약 새로운 검색 기능(예: 뒤에서부터 찾기)을 추가하려면 모든 컨테이너를 다 뜯어고쳐야 하는 유지보수의 지옥이 펼쳐집니다.
방법 2: 일반 함수(템플릿)로 제공하기 (std::find(...))
- 장점: std::find라는 하나의 템플릿 함수만 잘 만들어두면, vector든 list든 상관없이 모든 컨테이너에서 재사용할 수 있습니다.
- 단점: std::find(s.begin(), s.end(), 3)처럼 인자를 많이 넣어야 해서 코드가 다소 복잡해 보입니다.
STL의 선택: C++ STL은 확장성과 재사용성을 위해 **'방법 2(일반 함수)'**를 채택했습니다. 이것이 바로 우리가 <algorithm> 헤더를 사용하는 이유입니다.
2. 알고리즘(Algorithm)과 반복자(Iterator)의 만남
STL에서 말하는 알고리즘은 '문제를 해결하는 방법'이자, 구체적으로는 검색, 정렬 등 특정 기능을 구현한 템플릿 함수를 뜻합니다.
재미있는 점은 std::find가 내부에 들어온 게 list인지 vector인지 전혀 모른다는 것입니다. 단지 **"반복자(Iterator)를 통해 다음으로 이동(++)하고, 값을 비교(*)할 수 있다"**는 사실만 믿고 동작합니다.
C++98 스타일: 반복자 쌍(Iterator Pair) 전달
전통적인 방식은 검색할 구간의 **시작(begin)**과 **끝(end)**을 명확히 알려주는 것입니다.
#include <algorithm>
#include <vector>
#include <list>
#include <print> // C++23 (없다면 iostream 사용)
int main()
{
std::list s = {1, 2, 3, 4, 5};
std::vector v = {1, 2, 3, 4, 5};
// [전통적 방식] 시작과 끝 반복자를 모두 전달
auto ret1 = std::find(s.begin(), s.end(), 3);
// 검색 실패 시: 두 번째 인자로 넣었던 'end()'를 반환함
if (ret1 == s.end()) { /* 실패 처리 */ }
// [주의] 부분 구간 검색 시 반복자 연산 차이
auto first = s.begin();
// list는 메모리가 떨어져 있어서 + 연산 불가! std::next 사용
// auto last = first + 3; // (Vector만 가능, List는 에러)
auto last = std::next(first, 3); // 3칸 뒤(값 4의 위치)로 이동
// 1, 2, 3 중에서만 검색 (last인 4는 검색 대상 아님)
auto ret2 = std::find(first, last, 3);
if (ret2 == last) { std::println("Fail"); }
else { std::println("Success: {}", *ret2); }
}
- 핵심 포인트: STL 알고리즘의 구간은 항상 [first, last) 입니다. 즉, last가 가리키는 요소는 검색 대상에 포함되지 않습니다.
3. C++20의 혁명: Ranges와 Views
C++20부터는 <ranges> 헤더가 도입되면서 코드가 훨씬 간결하고 강력해졌습니다. 이제 begin과 end를 따로 넘길 필요 없이 컨테이너 자체를 넘길 수 있습니다.
std::ranges::find와 std::views의 결합
특히 **Views(뷰)**를 활용하면 데이터를 복사하지 않고도 원하는 구간만 똑똑하게 검색할 수 있습니다. 질문주신 내용을 자세히 풀어보겠습니다.
#include <ranges>
#include <algorithm>
#include <vector>
#include <print>
int main()
{
std::vector s = {1, 2, 3, 4, 5};
// 1. 전체 검색 (C++20 스타일)
auto ret1 = std::ranges::find(s, 3);
if (ret1 == s.end()) { /* 실패 */ }
// 2. 부분 검색 (Views 활용)
// std::views::take(s, 3) -> s의 앞에서부터 '3개'만 바라보는 가상의 뷰 생성
auto iv = std::views::take(s, 3);
// [상세 설명 필요 구간]
// ranges::find는 'iv'라는 뷰를 마치 하나의 컨테이너처럼 취급합니다.
// iv는 {1, 2, 3}만 포함하므로, 여기서 3을 찾습니다.
auto ret2 = std::ranges::find(iv, 3);
// 검색 실패 조건 확인
// 중요: iv에서 검색했으므로 실패 시 반환값은 'iv.end()'입니다.
if (ret2 == iv.end()) {
std::println("fail");
} else {
std::println("success : {}", *ret2);
}
}
💡 std::views::take(s, 3) 동작 원리 상세 해설
이 코드가 어려운 이유는 "눈에 보이지 않는 가상의 뷰" 때문입니다.
- std::views::take(s, 3)는 새로운 vector를 만드는 것이 아닙니다.
- 대신, 원래 s 데이터를 가리키되 **"나는 딱 3개까지만 볼 거야"**라고 제한을 건 **특수 안경(View)**을 iv에 저장합니다. (Lazy Evaluation, 지연 평가)
- std::ranges::find에게 iv를 넘기면, 알고리즘은 1, 2, 3까지만 검사하고 멈춥니다.
- 만약 3을 못 찾았다면? s.end()가 아니라 **iv가 정해둔 끝(iv.end())**을 반환합니다.
4. 조건자(Predicate): 값 대신 '조건'으로 찾기
단순히 "3을 찾아라"가 아니라, "3의 배수를 찾아라"처럼 조건으로 검색해야 할 때는 어떻게 할까요? 이때 필요한 것이 **조건자(Predicate)**입니다.
- 조건자(Predicate): bool을 반환하는 함수 (또는 함수 객체)
- 규칙: 이름이 _if로 끝나는 알고리즘(find_if)을 사용해야 합니다.
#include <print>
#include <algorithm>
#include <list>
int main()
{
std::list s = {1, 9, 5, 7, 3, 8, 10};
// 1. 단순 값 검색
auto ret1 = std::find(s.begin(), s.end(), 3);
// 2. 조건 검색 (람다 표현식 활용 권장)
// "3의 배수"를 찾는 람다 함수를 직접 전달합니다.
auto ret2 = std::find_if(s.begin(), s.end(), [](int n) {
return n % 3 == 0;
});
std::println("{}", *ret2); // 9 출력 (가장 먼저 찾은 3의 배수)
// 3. 람다 캡처(Capture) 활용
int k = 0;
std::cin >> k; // 사용자에게 k 값을 입력받음
// "k의 배수"를 찾고 싶다. -> 외부 변수 k를 람다 내부로 가져와야 함!
// [k] : 캡처 절(Capture Clause)에 k를 명시하여 람다 안에서 사용 가능하게 함
auto ret3 = std::find_if(s.begin(), s.end(), [k](int n) {
return n % k == 0;
});
}
Why Lambda? 일반 함수(bool foo...)를 따로 만드는 것보다, **람다(Lambda)**를 사용하면 코드가 간결해지고, [k]처럼 지역 변수를 쉽게 캡처하여 로직에 활용할 수 있어 훨씬 강력합니다.
🚀 실전 예제로 배우는 STL 알고리즘
우리가 만들 프로그램의 요구사항은 다음과 같습니다.
- 입력: -1을 입력할 때까지 정수를 받아 vector에 저장합니다.
- 치환: 10보다 큰 값은 모두 0으로 바꿉니다 (replace_if).
- 계산: 모든 요소의 합을 구합니다 (accumulate).
- 정렬: 내림차순으로 정렬합니다 (sort).
- 초기화: 모든 요소를 1로 덮어씁니다 (fill).
전체 코드 먼저 보기
백문이 불여일견, 완성된 코드를 먼저 보고 하나씩 뜯어보겠습니다.
#include <print> // C++23 (없다면 <iostream> 사용)
#include <vector>
#include <algorithm> // sort, replace_if, fill
#include <numeric> // accumulate (중요! 헤더 다름)
int main()
{
// 1. 데이터 입력 (push_back 활용)
// 예제 테스트를 위해 값을 미리 넣어두겠습니다.
// 실제 입력 로직은 아래 주석을 참고하세요.
std::vector<int> v = {1, 3, 10, 30, 4, 7, 8, 20};
/* [실제 입력 로직 구현 시]
int input;
while(true) {
std::cin >> input;
if(input == -1) break;
v.push_back(input);
}
*/
std::println("Original: ");
for (auto e : v) std::print("{}, ", e);
std::println("\n");
// 2. 조건부 치환 (replace_if)
// 10 이상인 값은 0으로 변경
// 주의: 값만 바꿀 땐 replace, 조건으로 바꿀 땐 replace_if
std::replace_if(v.begin(), v.end(), [](int n){ return n >= 10; }, 0);
std::println("After Replace (>10 -> 0): ");
for (auto e : v) std::print("{}, ", e);
std::println("\n");
// 3. 합계 구하기 (accumulate)
// 주의: 이 친구만 <numeric> 헤더에 있습니다!
// 0은 초기값(sum의 시작값)입니다.
int sum = std::accumulate(v.begin(), v.end(), 0);
std::println("Sum: {}", sum);
std::println("");
// 4. 정렬 (sort)
// 기본은 오름차순, 내림차순은 비교 함수(람다) 필요
// a > b : 앞의 것이 더 크도록 정렬해라 -> 내림차순
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
std::println("After Sort (Descending): ");
for (auto e : v) std::print("{}, ", e);
std::println("\n");
// 5. 일괄 채우기 (fill)
// 모든 요소를 1로 초기화
std::fill(v.begin(), v.end(), 1);
std::println("After Fill (1): ");
for (auto e : v) std::print("{}, ", e);
std::println("");
}
🔍 핵심 알고리즘 상세 분석
1. std::replace vs std::replace_if
데이터를 수정할 때 for 문 안에 if를 넣고 계신가요? 이 두 함수를 사용하면 훨씬 직관적입니다.
- std::replace: 특정 값과 똑같은 요소만 변경합니다.
- 예: std::replace(v.begin(), v.end(), 3, 0); (3을 찾아 0으로 바꿔라)
- std::replace_if: **조건(함수, 람다)**을 만족하는 요소를 변경합니다.
- 예: std::replace_if(..., [](int n){ return n >= 10; }, 0); (10 이상이면 0으로 바꿔라)
- 강조: 세 번째 인자로 조건자(Predicate)가 들어간다는 점이 다릅니다.
2. std::accumulate (합계)
많은 분들이 컴파일 에러를 겪는 부분입니다. 대부분의 알고리즘은 <algorithm>에 있지만, 수치 계산과 관련된 함수는 <numeric> 헤더에 있습니다.
- 세 번째 인자 0은 합계의 초기값입니다. 만약 곱하기를 하고 싶거나 초기값이 100이어야 한다면 이 값을 바꾸면 됩니다.
3. std::sort (정렬)
가장 강력하고 빠른 정렬 함수입니다 (퀵 소트, 힙 소트 등을 섞은 Introsort 방식 사용).
- 오름차순 (기본): std::sort(v.begin(), v.end());
- 내림차순 (커스텀): 세 번째 인자로 비교 함수를 넣어줍니다.
- [](int a, int b) { return a > b; }: "a가 b보다 클 때 true를 반환해라" → 큰 수가 앞으로 옴 → 내림차순
4. std::fill (채우기)
컨테이너의 모든 값을 특정 값으로 리셋하고 싶을 때 사용합니다. 반복문으로 대입하는 것보다 가독성이 훨씬 좋습니다.
💡 결론: 문법보다 중요한 건 '존재를 아는 것'
C++ 표준 위원회는 여러분이 필요로 할 만한 거의 모든 기능을 만들어 두었습니다.
- 반복문(for, while)을 작성하기 전에 **"이거 STL에 있지 않을까?"**라고 의심해 보세요.
- cppreference.com의 Algorithm Library 페이지를 즐겨찾기 해두고, 심심할 때마다 제목만이라도 훑어보세요.
- 직접 구현하는 것보다 표준 알고리즘이 더 빠르고, 버그가 적고, 읽기 쉽습니다.
오늘 소개한 find, replace, accumulate, sort, fill 5가지만 자유자재로 다뤄도 여러분의 코드는 훨씬 세련되어질 것입니다.
📝 요약 (Key Takeaways)
- 일반화의 힘: STL 알고리즘은 멤버 함수가 아닌 일반 함수(std::find) 형태로 제공되어, 모든 컨테이너에 범용적으로 사용됩니다.
- Ranges (C++20): std::ranges::find를 사용하면 begin, end를 일일이 넘길 필요 없이 컨테이너를 통째로 전달할 수 있습니다.
- Views의 마법: std::views::take 등을 활용하면 데이터를 복사하지 않고도 가상의 구간을 만들어 효율적으로 검색할 수 있습니다.
- 조건자 활용: 특정 조건(배수, 크기 등)으로 검색할 때는 find_if와 **람다 표현식(캡처 기능 포함)**을 사용하는 것이 모던 C++의 정석입니다.
'Programing언어 > C++' 카테고리의 다른 글
| 반복자(Iterator)부터 C++20 Ranges까지: 메모리 구조와 동작 원리 완벽 해부 (0) | 2025.11.30 |
|---|---|
| 배열(Array) vs 벡터(Vector), 도대체 무엇을 써야 할까? (feat. std::array) (0) | 2025.11.30 |
| C++ 벡터의 capacity와 size 완전 이해하기 (0) | 2025.11.18 |
| C++ STL 핵심 파헤치기: vector, list, deque... 대체 언제, 무엇을 써야 할까? (0) | 2025.11.03 |
| C++ 상속에서의 복사 생성자와 대입 연산자 완전 정리 (0) | 2025.10.18 |
