본문 바로가기

std::list가 sort를 멤버 함수로 제공하는 이유

@iamrain2025. 12. 3. 12:13

1. std::sort

std::sort와 반복자 요구사항

std::sort는 C++ 표준에 따라 평균적으로 O(N log N)의 시간 복잡도를 보장해야 합니다. 이를 위해 대부분의 STL 구현체는 인트로소트(Introsort)라는 하이브리드 정렬 알고리즘을 사용합니다. 인트로소트는 퀵소트(Quicksort)를 기반으로 하되, 재귀 깊이가 깊어지면 힙소트(Heapsort)로 전환하고, 데이터 크기가 작아지면 삽입정렬(Insertion Sort)을 사용하는 방식으로 최악의 경우에도 O(N log N)을 보장합니다.

이러한 알고리즘, 특히 퀵소트의 핵심은 임의 접근(Random Access)을 통해 효율적으로 동작한다는 점입니다. 퀵소트의 파티션(partition) 과정에서는 피벗(pivot)을 기준으로 컨테이너의 양 끝에서 중앙으로 원소들을 비교하고 교환해야 합니다. 이 과정에서 특정 원소로 한 번에 "점프"하는 연산이 필수적입니다.

따라서 std::sort임의 접근 반복자(Random Access Iterator)를 요구합니다. 이 반복자는 다음과 같은 연산을 O(1) 시간 복잡도로 지원해야 합니다.

  • it + n: n칸 뒤로 이동
  • it - n: n칸 앞으로 이동
  • it[n]: n번째 원소 접근
  • it1 - it2: 두 반복자 사이의 거리 계산

std::vector, std::deque, std::array가 이러한 임의 접근 반복자를 제공합니다.

std::list의 내부 구조와 반복자

std::list이중 연결 리스트(Doubly-linked list)로 구현됩니다. 각 노드(node)는 데이터 값과 함께, 이전 노드와 다음 노드를 가리키는 두 개의 포인터(prev, next)를 가지고 있습니다. 원소들은 메모리상에 흩어져 있으며, 오직 포인터를 통해서만 순차적으로 연결됩니다.

이러한 구조적 특징 때문에 std::list의 반복자는 양방향 반복자(Bidirectional Iterator)입니다. 이 반복자는 다음과 같은 연산만을 지원합니다.

  • ++it: 다음 노드로 이동
  • --it: 이전 노드로 이동

list[10]과 같이 특정 인덱스로 한 번에 점프하는 것은 불가능하며, 해당 위치까지 처음부터 노드를 하나씩 따라가야 하므로 O(n)의 시간이 걸립니다. 즉, 임의 접근을 지원하지 않습니다.

std::sortstd::list에 사용될 수 없는가?

결론적으로 std::sort가 요구하는 임의 접근 반복자를 std::list가 제공하는 양방향 반복자가 만족시키지 못하기 때문입니다. 컴파일러는 std::sort(list.begin(), list.end())와 같은 코드를 만나면, list.begin()이 임의 접근 반복자가 아니므로 타입 요구사항 불일치로 컴파일 오류를 발생시킵니다.

만약 억지로 양방향 반복자를 이용해 std::sort를 구현한다 해도, it + n과 같은 연산이 O(n)이 되므로 전체 시간 복잡도는 O(N² log N) 이상으로 급격히 저하되어 알고리즘의 의미가 없어집니다.

std::list::sort의 구현 방식: 병합 정렬

std::list는 이러한 한계를 극복하기 위해 멤버 함수로 sort()를 제공합니다. 이 함수는 연결 리스트 구조에 매우 효율적인 병합 정렬(Merge Sort) 알고리즘을 기반으로 구현됩니다.

병합 정렬의 동작 방식은 다음과 같습니다.

  1. 분할(Divide): 리스트를 더 이상 나눌 수 없을 때까지(원소가 0개 또는 1개) 절반으로 계속 분할합니다.
  2. 정복(Conquer): 분할된 작은 리스트들을 정렬하며 다시 병합(merge)합니다.

연결 리스트에서 병합 정렬은 매우 강력한 장점을 가집니다. 원소(데이터) 자체를 복사하거나 교환(swap)할 필요 없이, 각 노드가 가리키는 포인터만 재연결하면 되기 때문입니다. 이는 데이터의 크기가 클 경우 엄청난 성능 이점을 가져옵니다. 이 방식은 안정 정렬(stable sort)이며, 항상 O(N log N)의 시간 복잡도를 보장합니다.

비교: std::sort vs. std::list::sort

항목 std::sort (범용 알고리즘) std::list::sort (멤버 함수)
핵심 알고리즘 인트로소트 (퀵소트, 힙소트, 삽입정렬) 병합 정렬
요구 반복자 임의 접근 반복자 양방향 반복자 (내부 구현)
시간 복잡도 평균 O(N log N), 최악 O(N log N) 항상 O(N log N)
메모리 접근 연속 메모리에서 캐시 효율성 높음 (vector) 비연속 메모리, 포인터 순회
원소 이동 원소들의 교환(swap)이 빈번히 발생 원소 이동 없이 포인터만 재연결
사용법 std::sort(vec.begin(), vec.end()); my_list.sort();
적합한 컨테이너 std::vector, std::deque, std::array std::list

계층 구조 관점에서의 동작

std::list::sort()가 호출될 때 시스템에서 일어나는 일을 계층적으로 살펴보면 다음과 같습니다.

  1. 사용자 코드 계층: 프로그래머가 my_list.sort(); 또는 my_list.sort(compare_func); 코드를 작성하고 실행합니다.
  2. C++ 표준 라이브러리 계층: std::list 클래스에 정의된 sort 멤버 함수가 호출됩니다. 이 함수는 템플릿으로 구현되어 있으며, 전달된 비교 함수(없을 경우 operator<)를 사용합니다.
  3. list::sort 구현 계층:
    • 라이브러리 내부에서 병합 정렬 로직이 실행됩니다.
    • 리스트를 작은 단위로 분할하고 병합하는 과정에서, Player와 같은 사용자 정의 객체 자체가 복사되거나 이동하지 않습니다.
    • 대신, 각 Player 객체를 담고 있는 리스트 노드의 nextprev 포인터 값만 변경하여 논리적인 순서를 재배치합니다.
  4. 메모리/커널 계층:
    • 모든 정렬 연산은 사용자 공간(user space)의 힙(heap) 메모리에서 이루어집니다. std::list의 각 노드는 new 연산자를 통해 동적으로 할당된 메모리 공간에 존재합니다.
    • 정렬 과정에서 커널의 시스템 콜(system call)은 직접적으로 호출되지 않습니다. CPU는 메모리에 있는 포인터 변수들의 값을 읽고 쓰는 기계어 명령을 실행할 뿐입니다.

요약

 std::listsort를 멤버 함수로 따로 제공하는 이유는 std::list의 내부 구조인 이중 연결 리스트와 밀접한 관련이 있습니다. 범용 정렬 함수인 std::sort는 퀵소트 기반의 효율적인 알고리즘을 사용하기 때문에, 원소들 간의 빠른 임의 접근, 즉 O(1) 시간의 점프가 가능한 임의 접근 반복자를 필요로 합니다. 하지만 std::list는 노드들이 포인터로 연결된 구조라 처음부터 순차적으로만 접근할 수 있는 양방향 반복자를 제공하므로 std::sort의 요구조건을 만족시킬 수 없어 컴파일 오류가 발생합니다.
이를 해결하기 위해 std::list는 자체적으로 sort 멤버 함수를 제공하며, 이는 연결 리스트 구조에 최적화된 병합 정렬로 구현되어 있습니다. 병합 정렬은 원소의 실제 이동 없이 포인터 연결만 바꿔주는 방식으로 동작하므로, 데이터가 크더라도 매우 효율적이며 항상 O(N log N)의 성능을 보장합니다.


2. 실습 코드

#include <iostream>
#include <list>
#include <string>
#include <algorithm>

// 플레이어 정보를 담는 구조체
struct Player {
    std::string name;
    int score;
    int level;

    // Player 객체를 출력하기 위한 편의 함수
    void print() const {
        std::cout << "Name: " << name << ", Score: " << score << ", Level: " << level << std::endl;
    }
};

// 플레이어들을 비교하기 위한 함수 객체 (Functor)
// 점수를 기준으로 내림차순 정렬
struct ComparePlayerScore {
    bool operator()(const Player& a, const Player& b) const {
        // 점수가 같으면 레벨이 높은 순으로 정렬
        if (a.score == b.score) {
            return a.level > b.level;
        }
        return a.score > b.score;
    }
};

// 리스트의 모든 플레이어 정보를 출력하는 함수
void print_players(const std::list<Player>& players) {
    for (const auto& player : players) {
        player.print();
    }
    std::cout << "------------------------" << std::endl;
}

int main() {
    // 게임에 참여한 플레이어 리스트 생성
    std::list<Player> player_list;
    player_list.push_back({"Warrior", 1500, 50});
    player_list.push_back({"Mage", 1800, 55});
    player_list.push_back({"Archer", 1800, 52});
    player_list.push_back({"Healer", 1200, 48});
    player_list.push_back({"Rogue", 1650, 51});

    std::cout << "[정렬 전 플레이어 리스트]" << std::endl;
    print_players(player_list);

    // std::list의 멤버 함수 sort를 사용하여 정렬
    // 점수(score)를 기준으로 내림차순 정렬. 점수가 같으면 레벨 순.
    player_list.sort(ComparePlayerScore());

    std::cout << "[점수 기준 내림차순 정렬 후 플레이어 리스트]" << std::endl;
    print_players(player_list);

    std::cout << "[레벨 기준 내림차순 정렬 후 플레이어 리스트]" << std::endl;
    print_players(player_list);

    return 0;
}
iamrain
@iamrain :: Annals of Unreal

iamrain 님의 블로그 입니다.

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차