본문으로 건너뛰기

안동민 개발노트

본문 시작

알고리즘

반복자 범위에 정렬·검색·복사·변환 알고리즘을 적용하고 조건자와 사용자 정의 타입을 연결하는 방법을 익힙니다.

컨테이너는 데이터를 저장하고, 반복자는 그 데이터에 접근하는 통로 역할을 합니다.

이번 절에서는 STL의 마지막 주요 구성 요소인 알고리즘(Algorithms)을 다룹니다.

알고리즘은 컨테이너 이름이 아니라 각 함수가 요구하는 반복자 범위와 연산 계약에 대해 일반화된 함수 템플릿입니다.

정렬, 검색, 복사, 변환 같은 표준화된 동작과 복잡도 보장을 재사용할 수 있지만, 호출 전에는 반복자 범주와 요소 연산, 전제 조건을 확인해야 합니다.

컨테이너나 배열에서 반열린 반복자 범위를 만들고 알고리즘의 요구 조건을 만족시켜 호출한 뒤 반환값과 범위 효과를 확인하는 흐름
반복자 범위를 사용한 표준 알고리즘 호출 흐름 컨테이너나 배열이 요소를 소유하고, 시작을 포함하고 끝을 제외하는 반복자 범위를 제공한다. 알고리즘은 반복자 범주, 요소 연산, 비교 함수나 조건자의 요구를 확인한 뒤 호출하며, 호출자는 반복자, 불리언, 새 논리 끝, 값 같은 반환 계약과 범위의 변경 효과를 처리한다. 컨테이너 · 배열 요소를 소유하고 반복자를 제공 [first, last) first는 포함 · last는 제외하며 역참조하지 않음 알고리즘 호출 반복자 범주 · 요소 연산 · 비교 함수/조건자 전제 std::find · std::sort · std::remove · … 반환값 · 범위 효과 iterator · bool · 새 논리 끝 · 값
[first, last) 범위와 요구 조건이 알고리즘을 일반화합니다.
  1. 요소 소유

    컨테이너나 배열이 요소를 보관하고 반복자를 제공합니다.

  2. 반열린 범위 구성

    first는 포함하고 last는 제외합니다. last는 경계이므로 역참조하지 않습니다.

  3. 호출 계약 확인

    반복자 범주, 요소 연산, 비교 함수나 조건자가 알고리즘의 요구를 만족하는지 확인합니다.

  4. 결과 처리

    반환된 반복자·불리언·새 논리 끝·값의 의미와 범위 변경 여부에 맞춰 후속 작업을 합니다.

같은 반복자 계약을 만족하면 서로 다른 컨테이너에도 같은 알고리즘을 재사용할 수 있지만, 모든 알고리즘이 모든 반복자에 적용되는 것은 아닙니다.

알고리즘(Algorithms)이란 무엇인가?

알고리즘은 컨테이너의 요소들을 대상으로 특정 작업을 수행하는 함수 템플릿입니다.

많은 표준 알고리즘은 <algorithm>에, std::accumulate 같은 수치 연산은 <numeric>에 선언됩니다. <functional>은 알고리즘 정의 헤더가 아니라 std::greater 같은 함수 객체를 제공하며, 이 이름들은 대부분 std:: 네임스페이스 안에 있습니다.

알고리즘의 주요 특징
  • 제네릭 프로그래밍: 컨테이너 이름이 아니라 반복자 쌍을 받습니다. 각 알고리즘의 요구 조건을 만족한다면 표준 컨테이너뿐 아니라 std::begin/std::end로 얻은 C-스타일 배열 범위에도 적용할 수 있습니다.
  • 명시적인 계약: 표준이 동작과 복잡도 요구를 정의합니다. 실제 적용 가능성과 비용은 반복자 범주, 요소 타입, 호출 방식에 따라 달라집니다.
  • 재사용성: 한 번 구현된 알고리즘은 다양한 컨테이너와 데이터 타입에 대해 재사용될 수 있습니다.
  • 선형 탐색, 정렬, 복사, 변환, 조건부 실행 등 다양한 기능 제공.

알고리즘의 일반적인 사용법

대부분의 전통적인 STL 알고리즘은 하나 이상의 반복자 쌍을 매개변수로 받아 범위(Range)를 지정합니다.

전체 컨테이너를 처리할 때는 보통 begin()end()로 시작 포함·끝 제외의 반열린 범위 [first, last)를 만듭니다.

end()마지막 요소 다음 위치를 나타내는 경계이므로 역참조하면 안 됩니다.

알고리즘 형식
std::알고리즘이름(first_iterator, last_iterator, ... 추가_매개변수 ...);
  • first_iterator: 범위의 시작을 가리키는 반복자입니다.
  • last_iterator: 범위의 끝(마지막 요소 다음)을 가리키는 반복자입니다.

주요 알고리즘 범주와 예시

알고리즘은 타입 추론, 호출 계약, 재사용 경계를 기준으로 확인합니다.

읽기와 검색, 순서와 탐색, 복사와 변환, 논리적 제거, 누적 목적별 대표 표준 알고리즘과 핵심 호출 계약 비교
기능 이름만 고르지 말고 입력 범위의 요구 조건과 반환·후처리 계약까지 함께 확인한다.
목적 대표 알고리즘 핵심 계약
읽기 · 검색 find, count_if 값 비교나 조건자를 만족해야 한다. find는 없으면 last를 반환한다.
순서 · 이진 탐색 sort, binary_search sort는 임의 접근 반복자가 필요하다. 이진 탐색 범위는 comp(e, value)!comp(value, e)에 대해 각각 분할되어야 하고, 고전 오버로드의 양방향 비교는 모순되면 안 된다. 결과는 bool이다.
복사 · 변환 copy, transform 출력 반복자가 기록 가능한 유효 범위를 가리키도록 목적지 크기나 삽입 반복자를 준비한다.
논리적 제거 remove, remove_if 새 논리 끝을 반환할 뿐 컨테이너 크기는 줄지 않는다. 필요하면 이어서 erase한다.
누적 accumulate <numeric>에 정의되며 초기값의 타입이 누산기와 반환 타입을 결정한다.
읽기 · 검색
대표 find, count_if
값 비교나 조건자를 만족해야 하며, find는 없으면 last를 반환합니다.
순서 · 이진 탐색
대표 sort, binary_search
sort에는 임의 접근 반복자가 필요합니다. 이진 탐색 범위는 comp(e, value)!comp(value, e)에 대해 각각 분할되어야 하고, 고전 오버로드의 양방향 비교는 모순되면 안 됩니다. 결과는 bool입니다.
복사 · 변환
대표 copy, transform
출력 반복자가 기록 가능한 유효 범위를 가리키도록 목적지 크기나 삽입 반복자를 준비합니다.
논리적 제거
대표 remove, remove_if
새 논리 끝을 반환할 뿐 크기는 줄지 않으므로 필요하면 이어서 erase합니다.
누적
대표 accumulate
<numeric>에 정의되며 초기값의 타입이 누산기와 반환 타입을 결정합니다.
범주 이름은 출발점일 뿐입니다. 같은 범주 안에서도 필요한 반복자와 반환 형식이 다르므로 개별 알고리즘의 계약을 확인합니다.

STL 알고리즘은 기능에 따라 여러 범주로 나눌 수 있습니다.

여기서는 자주 사용되는 몇 가지 알고리즘을 소개합니다.

비수정 시퀀스 연산 (Non-modifying Sequence Operations) 읽기와 탐색이 중심인 연산입니다. 다만 std::for_each에 전달한 함수가 변경 가능한 참조를 받는다면 요소를 바꿀 수 있으므로, 호출 가능 객체의 부작용도 함께 확인해야 합니다.

  • std::for_each: 범위 내의 모든 요소에 대해 지정된 함수를 적용합니다.
  • std::find: 특정 값을 검색하여 해당 값을 가리키는 반복자를 반환합니다. 찾지 못하면 end()를 반환합니다.
  • std::count: 범위 내에서 특정 값과 일치하는 요소의 개수를 셉니다.
  • std::count_if: 특정 조건(프레디케이트)을 만족하는 요소의 개수를 셉니다.
    #include <vector>
    #include <algorithm> // for_each
    #include <iostream>
    #include <iterator>  // distance
    void print(int n) {
        std::cout << n << " ";
    }
    int main() {
        std::vector<int> v = {10, 20, 30, 40, 50};
        std::cout << "std::for_each: ";
    
        // std::for_each 사용 예시
        std::for_each(v.begin(), v.end(), print); // 각 요소에 대해 print 함수 호출
        std::cout << std::endl; // 출력: 10 20 30 40 50
    
        // 람다 함수 사용 (C++11 이상)
        std::cout << "std::for_each with lambda: ";
        std::for_each(v.begin(), v.end(), [](int n){
            std::cout << n * 2 << " "; // 각 요소를 두 배로 만들어서 출력
        });
        std::cout << std::endl; // 출력: 20 40 60 80 100
    
        // std::find 사용 예시
        auto it_find = std::find(v.begin(), v.end(), 30);
        if (it_find != v.end()) {
            std::cout << "Found 30 at position: " << std::distance(v.begin(), it_find) << std::endl;
        } else {
            std::cout << "30 not found.\n";
        } // 출력: Found 30 at position: 2
    
        // std::count 사용 예시
        std::vector<int> v2 = {1, 2, 2, 3, 2, 4};
        int count_2 = std::count(v2.begin(), v2.end(), 2);
        std::cout << "Count of 2s: " << count_2 << std::endl; // 출력: 3
    
        // std::count_if 사용 예시
        int count_even = std::count_if(v2.begin(), v2.end(), [](int n){ return n % 2 == 0; });
        std::cout << "Count of even numbers: " << count_even << std::endl; // 출력: 4
    }

수정 시퀀스 연산 (Modifying Sequence Operations) 컨테이너의 내용을 변경하는 작업을 수행합니다.

  • std::sort: 컨테이너의 요소들을 정렬합니다. 임의 접근 반복자를 지원하는 컨테이너에만 적용됩니다. (예: vector, deque, 배열)

    std::sort 사용 예시
    #include <vector>
    #include <algorithm> // sort
    #include <functional> // greater
    #include <iostream>
    #include <list> // list 예시
    int main() {
        std::vector<int> v = {5, 2, 8, 1, 9};
        std::sort(v.begin(), v.end()); // 오름차순 정렬
        std::cout << "std::sort (vector): ";
        for (int n : v) std::cout << n << " "; // 출력: 1 2 5 8 9
        std::cout << std::endl;
        // 같은 비교식을 정렬과 이진 탐색에 사용
        const auto descending = std::greater<int>{};
        std::sort(v.begin(), v.end(), descending);
        std::cout << "std::sort (desc): ";
        for (int n : v) std::cout << n << " "; // 출력: 9 8 5 2 1
        std::cout << std::endl;
        bool has_8 = std::binary_search(v.begin(), v.end(), 8, descending);
        std::cout << "contains 8: " << has_8 << std::endl; // 출력: 1
        // std::list는 sort() 멤버 함수를 제공 (양방향 반복자는 std::sort 사용 불가)
        std::list<int> l = {5, 2, 8, 1, 9};
        l.sort(); // list의 멤버 함수 sort 호출
        std::cout << "std::list::sort(): ";
        for (int n : l) std::cout << n << " "; // 출력: 1 2 5 8 9
        std::cout << std::endl;
    }

    std::sort에 전달하는 비교 함수는 엄격 약순서(strict weak ordering)를 만들어야 합니다. std::binary_search는 각 요소 e에 대해 comp(e, value)!comp(value, e) 두 표현으로 범위가 각각 분할되어 있어야 합니다. 고전 std::binary_search 비교 함수 오버로드에서는 두 방향의 비교가 모두 가능해야 하고, comp(e, value)가 참인데 comp(value, e)도 참인 모순을 만들면 안 됩니다. 보통 같은 비교식으로 먼저 정렬해 이 전제를 보장합니다. 반환값은 존재 여부를 나타내는 bool뿐이므로 위치가 필요하면 같은 순서 관계로 std::lower_bound를 사용합니다.

  • std::copy: 한 범위의 요소를 다른 위치로 복사합니다.

    std::copy 사용 예시
    #include <vector>
    #include <algorithm> // copy
    #include <iostream>
    int main() {
        std::vector<int> source = {1, 2, 3, 4, 5};
        std::vector<int> destination(source.size()); // 복사할 크기만큼 미리 할당
        std::copy(source.begin(), source.end(), destination.begin());
        std::cout << "std::copy: ";
        for (int n : destination) std::cout << n << " "; // 출력: 1 2 3 4 5
        std::cout << std::endl;
    }
  • std::transform: 한 범위의 요소를 변환하여 다른 위치(또는 동일한 위치)에 저장합니다.

    std::transform 사용 예시
    #include <vector>
    #include <algorithm> // transform
    #include <iostream>
    int main() {
        std::vector<int> original = {1, 2, 3, 4, 5};
        std::vector<int> squared(original.size());
        std::transform(original.begin(), original.end(), squared.begin(), [](int n){
            return n * n;
        });
        std::cout << "std::transform (squared): ";
        for (int n : squared) std::cout << n << " "; // 출력: 1 4 9 16 25
        std::cout << std::endl;
    }
  • std::remove: 일치하지 않는 요소를 범위 앞쪽으로 이동하고 새 논리 끝을 반환합니다. 컨테이너 크기는 바뀌지 않으며, 새 논리 끝부터 기존 end()까지의 값은 유효하지만 지정되지 않은 상태입니다. 실제 크기를 줄이려면 erase와 함께 사용합니다.

    std::remove 사용 예시
    #include <vector>
    #include <algorithm> // remove
    #include <iostream>
    int main() {
        std::vector<int> v = {1, 2, 3, 2, 4, 2, 5};
    
        auto new_end = std::remove(v.begin(), v.end(), 2);
        // [v.begin(), new_end)는 {1, 3, 4, 5}
        // [new_end, v.end())의 값은 유효하지만 지정되지 않음
    
        v.erase(new_end, v.end()); // 컨테이너의 실제 크기를 줄임
        std::cout << "std::remove + erase: ";
        for (int n : v) std::cout << n << " "; // 출력: 1 3 4 5
        std::cout << std::endl;
    }
정렬 관련 알고리즘 (Sorting Related Operations)
  • std::min_element, std::max_element: 범위 내에서 최소/최대 요소를 가리키는 반복자를 반환합니다.

    std::min_element, std::max_element 사용 예시
    #include <vector>
    #include <algorithm> // min_element, max_element
    #include <iostream>
    int main() {
        std::vector<int> v = {10, 20, 5, 30, 15};
        auto min_it = std::min_element(v.begin(), v.end());
        auto max_it = std::max_element(v.begin(), v.end());
        if (min_it != v.end() && max_it != v.end()) {
            std::cout << "Min element: " << *min_it << std::endl; // 출력: 5
            std::cout << "Max element: " << *max_it << std::endl; // 출력: 30
        }
    }

    빈 범위에서 std::min_elementstd::max_elementlast를 반환하므로, 반환 반복자를 역참조하기 전에 경계와 비교해야 합니다.

숫자 관련 알고리즘 (Numeric Operations) - <numeric> 헤더
  • std::accumulate: 범위 내의 요소들을 합산합니다.

    std::accumulate 사용 예시
    #include <vector>
    #include <numeric>   // accumulate
    #include <iostream>
    #include <string>
    int main() {
        std::vector<int> v = {1, 2, 3, 4, 5};
        int sum = std::accumulate(v.begin(), v.end(), 0); // 초기값 0
        std::cout << "Sum: " << sum << std::endl; // 출력: 15
        // 문자열 연결도 가능
        std::vector<std::string> words = {"Hello", " ", "World", "!"};
        std::string concat = std::accumulate(words.begin(), words.end(), std::string(""));
        std::cout << "Concatenated string: " << concat << std::endl; // 출력: Hello World!
    }

    std::accumulate의 누산기와 반환 타입은 초기값의 타입으로 정해집니다. 예를 들어 double 요소를 실수로 누적하려면 정수 0 대신 0.0 같은 초기값을 사용합니다.


사용자 정의 타입과 알고리즘

STL 알고리즘은 템플릿으로 구현되어 있기 때문에, 사용자 정의 타입에도 적용할 수 있습니다.

단, 해당 타입과 호출 방식이 알고리즘이 요구하는 연산을 지원해야 합니다. 비교 함수를 생략한 정렬은 기본 순서 연산을 사용하지만, 비교 함수 오버로드를 사용하면 타입 자체에 <를 추가하지 않아도 됩니다. std::find는 값의 동등 비교가 가능해야 합니다.

정렬 비교 함수는 어떤 두 원소를 비교해도 일관된 엄격 약순서를 만들어야 하며, 정렬된 범위에 std::binary_search 같은 순서 기반 알고리즘을 적용할 때도 요소와 검색값을 양방향으로 비교할 수 있는 같은 순서 관계를 사용해야 합니다.

사용자 정의 타입과 알고리즘
#include <iostream>
#include <vector>
#include <algorithm> // sort
#include <string>

class Person {
public:
    std::string name;
    int age;

    Person(std::string n, int a) : name(n), age(a) {}

    // << 연산자 오버로딩 (출력용)
    friend std::ostream& operator<<(std::ostream& os, const Person& p) {
        os << "[" << p.name << ", " << p.age << "]";
        return os;
    }
};

// 1. 나이 기준 오름차순 비교 함수 (전역 함수 또는 람다)
bool comparePersonsByAge(const Person& p1, const Person& p2) {
    return p1.age < p2.age;
}

// 2. 이름 기준 오름차순 비교를 위한 함수 객체 (Functor)
struct ComparePersonsByName {
    bool operator()(const Person& p1, const Person& p2) const {
        return p1.name < p2.name;
    }
};

int main() {
    std::vector<Person> people = {
        {"Alice", 30},
        {"Charlie", 25},
        {"Bob", 35}
    };

    std::cout << "Original order:\n";
    for (const auto& p : people) {
        std::cout << p << " ";
    }
    std::cout << std::endl;

    // 나이 기준 정렬 (비교 함수 사용)
    std::sort(people.begin(), people.end(), comparePersonsByAge);
    std::cout << "Sorted by age:\n";
    for (const auto& p : people) {
        std::cout << p << " ";
    }
    std::cout << std::endl; // 출력: [Charlie, 25] [Alice, 30] [Bob, 35]

    // 이름 기준 정렬 (함수 객체 사용)
    std::sort(people.begin(), people.end(), ComparePersonsByName());
    std::cout << "Sorted by name:\n";
    for (const auto& p : people) {
        std::cout << p << " ";
    }
    std::cout << std::endl; // 출력: [Alice, 30] [Bob, 35] [Charlie, 25]

    return 0;
}

알고리즘 사용 시 고려사항

알고리즘을 고를 때는 기능 이름뿐 아니라 반복자 요구 조건, 실제 컨테이너 크기 변화 여부, 비교/대입 가능성을 함께 확인해야 합니다.

표준 알고리즘 호출 전에 헤더, 범위와 반복자, 비교 함수와 정렬 전제, 반환값, 범위 변경, 비용을 확인하는 점검표
호출식 앞뒤의 계약을 점검하면 컴파일 오류뿐 아니라 정의되지 않은 동작과 잘못된 후처리를 줄일 수 있다.
검토 항목 호출 전 질문 반환 · 후속 처리
직접 헤더 사용 이름을 선언하는 헤더를 직접 포함했는가? <algorithm>, <numeric>, <functional> 등을 기능에 맞게 선택한다.
범위 · 반복자 [first, last)가 유효하고 필요한 반복자 범주를 만족하는가? sort는 임의 접근, min_element는 전방 반복자를 요구한다.
비교 함수 정렬 비교자가 일관된 엄격 약순서를 만드는가? 정렬 뒤 순서 기반 검색에도 같은 순서 관계를 사용한다.
검색 전제 comp(e, value)!comp(value, e)에 대해 각각 분할되어 있는가? 고전 오버로드의 양방향 비교는 모순되면 안 된다. 반환은 bool이며 위치는 lower_bound로 찾는다.
경계 · 논리 끝 반환 반복자를 역참조하기 전에 last와 비교했는가? 빈 범위의 min_elementlast, remove는 새 논리 끝을 반환한다.
비용 · 효과 표준 복잡도와 요소의 복사·이동 비용이 요구에 맞는가? 실제 컨테이너 크기 변화와 필요한 erase, 목적지 용량, 측정 결과를 확인한다.
직접 헤더
사용 이름을 선언하는 <algorithm>, <numeric>, <functional> 등을 직접 포함합니다.
범위 · 반복자
[first, last)가 유효한지, sort의 임의 접근이나 min_element의 전방 반복자 요구를 만족하는지 확인합니다.
비교 함수
정렬 비교자가 일관된 엄격 약순서를 만들고, 이어지는 순서 기반 검색도 같은 관계를 사용하는지 확인합니다.
검색 전제
binary_search 범위는 comp(e, value)!comp(value, e)에 대해 각각 분할되어야 하고, 고전 오버로드의 양방향 비교는 모순되면 안 됩니다. 결과는 위치가 아닌 bool입니다.
경계 · 논리 끝
빈 범위의 min_elementlast, remove는 새 논리 끝을 반환하므로 의미에 맞게 검사하고 후처리합니다.
비용 · 효과
표준 복잡도와 복사·이동 비용, 목적지 용량, 실제 크기 변화와 필요한 erase를 확인하고 필요하면 측정합니다.
반복자 반환은 곧바로 역참조할 값이라는 뜻이 아닙니다. 각 알고리즘이 정의한 실패 경계와 논리 끝을 먼저 해석합니다.
  • 헤더 파일 포함: 사용하는 이름을 선언하는 헤더를 직접 포함합니다. 많은 알고리즘은 <algorithm>, 누적은 <numeric>, std::greater 같은 함수 객체는 <functional>에 있습니다.
  • 범위와 반복자 확인: [first, last)가 유효한 범위인지, 알고리즘이 요구하는 반복자 범주를 컨테이너가 제공하는지 확인합니다. 예를 들어 std::sort에는 임의 접근 반복자가 필요합니다.
  • 비교와 전제 조건 확인: 정렬 비교자는 엄격 약순서를 만들어야 합니다. std::binary_search 범위는 comp(e, value)!comp(value, e)에 대해 각각 분할되어 있어야 하며, 고전 비교 함수 오버로드는 두 방향의 비교 결과가 모순되지 않아야 합니다.
  • 반환값과 후처리 확인: std::min_elementlast, std::binary_searchbool, std::remove의 새 논리 끝처럼 반환 계약이 서로 다릅니다. 역참조나 erase 전에 그 의미를 확인합니다.
  • 복사·이동과 비용 확인: std::copystd::move는 요소 타입의 복사·이동 의미론을 따릅니다. 표준의 복잡도 보장을 먼저 읽고, 실제 데이터와 구현에서 필요한 경우 측정합니다.

STL 알고리즘은 이름만 외우기보다 begin/end 범위, 비교 함수, 반환값, 후처리 방식을 함께 읽을 때 컨테이너와 자연스럽게 연결됩니다.