icon

안동민 개발노트

9장: 정렬 알고리즘

정렬 안정성과 커스텀 비교 기준 설계


정렬은 숫자 오름차순만 맞추면 끝나는 문제가 아닙니다. 실무에서는 동점일 때 어떤 순서를 유지할지까지 규칙으로 정해야 결과가 일관됩니다. 이 규칙이 빠지면 화면마다 순서가 달라져 데이터는 맞는데 결과가 이상한 상황이 생깁니다.

안정 정렬과 커스텀 비교 기준은 선택 옵션이 아니라 기본 설계입니다. 여기서는 복잡한 문법보다 비교 규칙을 우선 문장으로 고정한 뒤 코드로 옮기는 흐름을 강조합니다. 규칙 누락 없이 구현할 수 있도록 단계적으로 정리합니다.

핵심 개념: 안정 정렬과 비교 규칙

복합 정렬은 기준을 1차/2차로 분리해 문장으로 우선 고정하면 구현이 훨씬 쉬워집니다. 코드 전에 동점 처리 규칙을 우선 써 보고 시작하세요. 안정 정렬(stable sort)은 같은 키 원소의 기존 순서를 보존하는 정렬 성질입니다. 정확히는 비교 키가 같은 두 원소의 상대 순서를 정렬 전후에 유지해야 안정 정렬입니다. 점수가 같은 사용자 두 명이 있을 때 입력 순서가 결과에서도 유지되면 안정성이 보장된 것입니다.

커스텀 비교 기준은 문제 요구에 맞게 정렬 우선순위를 직접 정의한 규칙입니다. 다중 키(1차/2차/3차)를 사용해 원소 간 total order를 명시해야 결과가 모호하지 않습니다. 예를 들어 score 내림차순, 동점이면 joined_at 오름차순으로 정하면 요구사항을 코드에 그대로 옮길 수 있습니다.

동점 처리(tie-breaker)는 1차 기준이 같은 경우 순서를 결정하는 보조 키입니다. 이 기준이 없으면 실행 시점이나 환경에 따라 결과 순서가 흔들릴 수 있습니다. 점수가 같을 때 이름 오름차순을 추가하면 화면마다 순서가 달라지는 문제를 막을 수 있습니다.

비교 기준 설계 실전 장애와 연결하기

정렬 기준이 불완전하면 화면 A와 화면 B의 결과가 달라질 수 있습니다. 이 현상은 데이터 자체 오류가 아니라 비교 기준 모호성에서 자주 발생합니다. 실무 장애 분석에서는 이런 정렬 불일치가 권한/정산/리포트 해석 문제로 이어지기도 합니다.

비교 로직을 명시적으로 분리하면 요구사항 변경에도 코드를 안정적으로 수정할 수 있습니다.

비교 규칙 테스트로 빠르게 검증하기

학습 효율을 높이려면 오답 -> 디버깅 -> 교정 -> 검증 순서로 로그를 남기면서 진행하는 것이 가장 빠릅니다.

복합 키 정렬을 간단한 데이터로 추적합니다.

  • 데이터: (name, score, joined)
  • 기준: score 내림차순, 동점이면 joined 오름차순
  1. 1차 키(score)로 우선 큰 점수를 앞에 둡니다.
  2. 동점 그룹에서는 2차 키(joined)로 순서를 고정합니다.
  3. 기준을 문장으로 우선 확정하면 코드 구현과 검증이 쉬워집니다.

복합 키 정렬 규칙 설계 기준

정렬 기준을 만들 때 아래 질문을 우선 정리합니다.

  • 1차 키는 무엇인가?
  • 동점 시 우선순위는 무엇인가?
  • None, 공백, 이상값은 어디로 보낼 것인가?
  • locale/대소문자 규칙이 필요한가?

이 질문에 답이 없으면 정렬 결과가 팀마다 다르게 해석됩니다.

정렬 안정성: 안정 정렬 기반 복합 키

문제 의도: 튜플 키/복합 비교로 다중 정렬 기준을 명시적으로 구현합니다.
입력 예시: 학생 목록 (name, score, joined)
출력 예시: score 내림차순 + joined 오름차순 결과

main.cpp
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

struct Student {
    string name;
    int score;
    int joined;
};

bool cmpStudent(const Student& a, const Student& b) {
    if (a.score != b.score) {
        return a.score > b.score;
    }
    return a.joined < b.joined;
}

int main() {
    vector<Student> students = {
        {"A", 90, 3},
        {"B", 90, 1},
        {"C", 85, 2},
        {"D", 90, 2},
    };

    stable_sort(students.begin(), students.end(), cmpStudent);

    for (const auto& s : students) {
        cout << s.name << " " << s.score << " " << s.joined << "\n";
    }
    return 0;
}
Main.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class Main {
    static class Student {
        String name;
        int score;
        int joined;
        Student(String name, int score, int joined) {
            this.name = name;
            this.score = score;
            this.joined = joined;
        }
    }

    public static void main(String[] args) {
        List<Student> students = new ArrayList<>(List.of(
            new Student("A", 90, 3),
            new Student("B", 90, 1),
            new Student("C", 85, 2),
            new Student("D", 90, 2)
        ));

        students.sort(
            new Comparator<Student>() {
                @Override
                public int compare(Student a, Student b) {
                    if (a.score != b.score) {
                        return Integer.compare(b.score, a.score);
                    }
                    return Integer.compare(a.joined, b.joined);
                }
            }
        );

        for (Student s : students) {
            System.out.println(s.name + " " + s.score + " " + s.joined);
        }
    }
}
main.py
students = [
    {"name": "A", "score": 90, "joined": 3},
    {"name": "B", "score": 90, "joined": 1},
    {"name": "C", "score": 85, "joined": 2},
    {"name": "D", "score": 90, "joined": 2},
]

# 점수 내림차순, 동점이면 가입순 오름차순
def student_key(row):
    return (-row["score"], row["joined"])

result = students[:]
result.sort(key=student_key)
for row in result:
    print(row["name"], row["score"], row["joined"])

# 출력
# B 90 1
# D 90 2
# A 90 3
# C 85 2
main.rs
#[derive(Debug, Clone)]
struct Student {
    name: &'static str,
    score: i32,
    joined: i32,
}

fn cmp_student(a: &Student, b: &Student) -> std::cmp::Ordering {
    if a.score != b.score {
        return b.score.cmp(&a.score);
    }
    a.joined.cmp(&b.joined)
}

fn main() {
    let mut students = vec![
        Student { name: "A", score: 90, joined: 3 },
        Student { name: "B", score: 90, joined: 1 },
        Student { name: "C", score: 85, joined: 2 },
        Student { name: "D", score: 90, joined: 2 },
    ];

    students.sort_by(cmp_student);

    for s in students {
        println!("{} {} {}", s.name, s.score, s.joined);
    }
}

복합 키 정렬 결과 정렬 기준 충돌 체크

튜플 키로 우선순위를 한 번에 표현하면 정렬 의도가 코드에 직접 드러납니다. 내림차순은 음수 변환으로 표현했고, 동점 처리 규칙은 두 번째 키로 고정했습니다.

  • 평균 시간복잡도: O(N log N)
  • 최악 시간복잡도: O(N log N)
  • 공간복잡도: 구현체(Timsort) 기준 보조 메모리 사용

정렬 안정성: 다단계 안정 정렬

문제 의도: 안정 정렬을 여러 번 적용해 다중 기준 정렬을 구현합니다.
입력 예시: 주문 목록 (tier, amount, id)
출력 예시: 정의한 우선순위 기준대로 정렬된 목록

main.cpp
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

struct Order {
    int id;
    string tier;
    int amount;
};

bool cmpAmountDesc(const Order& a, const Order& b) {
    return a.amount > b.amount;
}

bool cmpTierAsc(const Order& a, const Order& b) {
    return a.tier < b.tier;
}

int main() {
    vector<Order> orders = {
        {1, "gold", 120},
        {2, "silver", 120},
        {3, "gold", 80},
        {4, "silver", 80},
    };

    stable_sort(orders.begin(), orders.end(), cmpAmountDesc);
    stable_sort(orders.begin(), orders.end(), cmpTierAsc);

    for (const auto& o : orders) {
        cout << o.id << " " << o.tier << " " << o.amount << "\n";
    }
    return 0;
}
Main.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class Main {
    static class Order {
        int id, amount;
        String tier;
        Order(int id, String tier, int amount) {
            this.id = id;
            this.tier = tier;
            this.amount = amount;
        }
    }

    public static void main(String[] args) {
        List<Order> orders = new ArrayList<>(List.of(
            new Order(1, "gold", 120),
            new Order(2, "silver", 120),
            new Order(3, "gold", 80),
            new Order(4, "silver", 80)
        ));

        orders.sort(
            new Comparator<Order>() {
                @Override
                public int compare(Order a, Order b) {
                    return Integer.compare(b.amount, a.amount);
                }
            }
        );
        orders.sort(
            new Comparator<Order>() {
                @Override
                public int compare(Order a, Order b) {
                    return a.tier.compareTo(b.tier);
                }
            }
        );

        for (Order o : orders) {
            System.out.println(o.id + " " + o.tier + " " + o.amount);
        }
    }
}
main.py
orders = [
    {"id": 1, "tier": "gold", "amount": 120},
    {"id": 2, "tier": "silver", "amount": 120},
    {"id": 3, "tier": "gold", "amount": 80},
    {"id": 4, "tier": "silver", "amount": 80},
]

# 2차 기준부터 우선 정렬하고, 마지막에 1차 기준 정렬
def amount_key(row):
    return row["amount"]

def tier_key(row):
    return row["tier"]

by_amount = orders[:]
by_amount.sort(key=amount_key, reverse=True)
by_tier_then_amount = by_amount[:]
by_tier_then_amount.sort(key=tier_key)

for row in by_tier_then_amount:
    print(row["id"], row["tier"], row["amount"])

# 출력
# 1 gold 120
# 3 gold 80
# 2 silver 120
# 4 silver 80
main.rs
#[derive(Clone)]
struct Order {
    id: i32,
    tier: &'static str,
    amount: i32,
}

fn cmp_amount_desc(a: &Order, b: &Order) -> std::cmp::Ordering {
    b.amount.cmp(&a.amount)
}

fn cmp_tier_asc(a: &Order, b: &Order) -> std::cmp::Ordering {
    a.tier.cmp(b.tier)
}

fn main() {
    let mut orders = vec![
        Order { id: 1, tier: "gold", amount: 120 },
        Order { id: 2, tier: "silver", amount: 120 },
        Order { id: 3, tier: "gold", amount: 80 },
        Order { id: 4, tier: "silver", amount: 80 },
    ];

    orders.sort_by(cmp_amount_desc);
    orders.sort_by(cmp_tier_asc);

    for o in orders {
        println!("{} {} {}", o.id, o.tier, o.amount);
    }
}

다단계 정렬 순서 분할/병합 과정 추적

안정 정렬에서는 이전 정렬 결과가 보존되므로 다단계 정렬 전략이 예측 가능하게 동작합니다. 복합 튜플 키와 다단계 정렬 중 무엇을 쓰든, 팀이 읽기 쉬운 형태를 선택하는 것이 유지보수에 유리합니다.

  • 평균 시간복잡도: 안정 정렬 2회 적용 시 O(N log N) + O(N log N)
  • 공간복잡도: 구현체 의존(보조 메모리 사용 가능)

정렬 안정성: cmp_to_key 사용 시 유의

문제 의도: 비교 함수 기반 정렬에서 일관성(추이성/동치성) 요구를 점검합니다.
입력 예시: 사용자 정의 비교 함수와 데이터 목록
출력 예시: 규칙을 만족하는 정렬 결과

main.cpp
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

bool numericLess(const string& a, const string& b) {
    return stoi(a) < stoi(b);
}

int main() {
    vector<string> items = {"10", "2", "30", "4"};
    sort(items.begin(), items.end(), numericLess);
    for (const auto& x : items) cout << x << " ";
    cout << "\n";
    return 0;
}
Main.java
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<String> items = new ArrayList<>(List.of("10", "2", "30", "4"));
        items.sort(
            new java.util.Comparator<String>() {
                @Override
                public int compare(String a, String b) {
                    return Integer.compare(Integer.parseInt(a), Integer.parseInt(b));
                }
            }
        );
        System.out.println(items);
    }
}
main.py
from functools import cmp_to_key

items = ["10", "2", "30", "4"]

def numeric_cmp(a, b):
    ai, bi = int(a), int(b)
    if ai < bi:
        return -1
    if ai > bi:
        return 1
    return 0

result = items[:]
result.sort(key=cmp_to_key(numeric_cmp))
print(result)
# 출력: ['2', '4', '10', '30']
main.rs
fn main() {
    let mut items = vec!["10", "2", "30", "4"];
    items.sort_by_key(|s| s.parse::<i32>().unwrap_or(0));
    println!("{:?}", items);
}

비교 함수 안정성 정렬 결과 후속 검토

가능하면 key 함수를 우선 사용하고, cmp_to_key는 도메인 비교가 정말 필요한 경우에만 씁니다. 비교 함수는 추이성과 반사성을 깨지 않도록 주의해야 하며, 규칙이 모순되면 정렬 결과가 불안정해질 수 있습니다.

  • 평균 시간복잡도: O(N log N)
  • 공간복잡도: 구현체 의존(비교 호출 오버헤드 포함)

키 함수/비교 함수의 구현 비용 균형점

정렬 기준 설계의 현실적인 균형점입니다.

  • 단순 키 함수: 빠르고 안전하지만 표현력이 제한될 수 있음
  • 복합 튜플 키: 명시적이고 유지보수 용이, 키 생성 비용 증가
  • cmp_to_key: 유연하지만 오류 가능성 높음
  • 다단계 정렬: 읽기 쉬움, 정렬 횟수 증가

정확성과 가독성을 우선 확보한 뒤 성능 최적화를 검토해야 합니다.

비교 함수 오답 패턴과 교정 방법

정렬 기준에서 자주 놓치는 반례입니다.

  • 숫자 문자열을 문자열 비교로 처리해 "10" < "2" 오류 발생
  • None 값이 섞여 비교 예외가 발생
  • locale 미설정으로 한글/대소문자 순서가 의도와 다름
  • 동점 규칙 누락으로 실행 환경마다 결과가 달라짐

테스트 데이터에는 동점 다량, 결측치, 다국어 문자열을 포함해야 합니다.

정렬 입력 분포와 복잡도 판단

같은 정답이라도 입력 분포에 따라 병목 지점이 달라지므로, 적용 관점을 시간복잡도 표 하나로 끝내지 말고 분포 가정까지 명시해야 합니다.

항목평균 시간최악 시간공간
key 기반 정렬O(N log N)O(N log N)구현체 의존
다단계 안정 정렬(2회)O(N log N) + O(N log N)동일추가 정렬 비용 발생
cmp_to_key 정렬O(N log N)O(N log N)비교 호출 오버헤드 증가

비교 함수 호출 비용이 큰 경우에는 키 전처리(Schwartzian transform) 전략을 고려할 수 있습니다.

비교 규칙 실패사례 체크 포인트

  • 정렬 규칙은 코드와 문서 둘 다에 남겨야 운영 이슈를 줄일 수 있습니다.
  • 화면별로 다른 정렬 규칙이 필요하면 함수 이름에 목적을 드러내야 합니다.
  • 정렬 결과를 캐시할 때는 기준 변경 시 캐시 무효화 정책을 함께 설계해야 합니다.
  • 감사 로그가 필요한 도메인에서는 동점 처리 규칙을 변경 이력으로 관리하는 편이 안전합니다.

제출 전 비교 기준 체크리스트

  • 1차/2차/3차 정렬 키를 문장과 코드에 동시에 명시했는가
  • 동점 처리 규칙을 테스트 케이스로 고정했는가
  • cmp_to_key 비교 함수의 일관성 조건을 검증했는가
  • None/공백/이상값 처리 정책을 정렬 기준에 포함했는가

정렬 안정성 설계 정리

정렬 품질은 성능보다 기준의 일관성에서 결정됩니다. 안정성과 커스텀 비교 기준을 명확하게 설계하면 데이터 해석 오류와 운영 불일치 문제를 크게 줄일 수 있습니다.

목차