문서 읽는 데 42분 · D2

D-2: 정렬 ② 선형·내장 정렬 — 비교를 버리고, 실전 내장 정렬로

목차 27
전체 20강 중 10강 · 자료구조·알고리즘
난이도 · 중급선수지식파이썬 기초

ℹ️코딩테스트의 관문 — 자료구조·알고리즘을 직접 구현하고 유형별로 풀어요. 언어 하나(파이썬·자바)를 뗀 다음에 권해요.

안녕하세요, 홍순구 튜터입니다. 지난 시간에 우리는 비교 기반 정렬 여섯 가지를 손으로 짰습니다. 버블·선택·삽입의 O(n²)를 병합·퀵·힙의 O(n log n)까지 끌어내렸죠. 그리고 마지막에 벽을 하나 만났습니다. 원소끼리 비교만 해서는 아무리 잘해도 O(n log n)이 한계라는 것, "비교의 벽"이었어요.

오늘은 그 벽을 넘습니다. 방법은 뜻밖에 단순해요. 비교를 아예 하지 않으면 됩니다. 값을 세거나 자릿수로 나누면, 원소끼리 크고 작음을 한 번도 따지지 않고 O(n)에 정렬할 수 있어요. 그게 오늘의 두 주인공, 계수 정렬과 기수 정렬입니다. 대신 공짜는 아니라서, 값이 정수이고 범위가 좁아야 한다는 조건이 붙습니다.

정렬의 원리를 다 봤으니, 오늘 후반부에서는 실전으로 넘어갑니다. 코딩테스트에서 실제로 쓰는 파이썬 내장 정렬(sorted·list.sort)을 제대로 익히고, 정렬 기준을 마음대로 바꾸는 key와 여러 기준을 한 번에 거는 튜플 정렬까지 손에 넣습니다. 마지막엔 "언제 직접 짜고 언제 내장을 쓰나"를 정리해요.

텍스트
 오늘의 여정 — 정렬 , 비교를 버리고 실전으로

    비교의 벽을 넘는다   계수 정렬(값을 센다) · 기수 정렬(자릿수로 나눈다)  O(n)
    안정성 다시 보기     같은 값의 순서를 지킨다  다중키 정렬의 토대
    실전 내장 정렬       sorted · list.sort · key · 튜플 · 직접 vs 내장 판단

💡 오늘 수업의 핵심 — "비교를 버려 O(n)에 정렬하는 계수·기수를 이해하고, 실전 내장 정렬을 key·튜플로 자유자재로 커스텀한다"

🎯 학습 목표

  • 계수·기수 정렬로 비교의 벽 O(n log n)을 넘어 O(n)에 정렬하는 원리와 그 조건(정수·좁은 범위)을 이해한다.
  • 안정 정렬이 왜 다중키 정렬의 토대인지 알고, 파이썬 내장 sorted·list.sort의 차이를 구분한다.
  • key 함수와 튜플로 정렬 기준을 자유롭게 바꾸고, 언제 직접 짜고 언제 내장을 쓸지 판단한다.

Step 1: "값을 세어 줄 세운다 — 계수 정렬"

지난 시간의 여섯 정렬은 전부 "누가 더 큰가"를 비교했습니다. 그 비교 때문에 O(n log n)이라는 벽에 막혔죠. 그럼 비교를 안 하고 어떻게 순서를 정할까요? 발상을 바꿔 봅시다. 크기를 견주는 대신, 각 값이 몇 번 나왔는지 세기만 하는 겁니다.

예를 들어 [2, 5, 2, 1, 0, 3, 2, 1]을 정렬한다고 해 봅시다. 값이 0부터 5까지니까, 칸이 여섯 개인 개수 배열을 만들어 각 값이 몇 번 나오는지 세요. 다 세고 나면, 작은 값부터 센 개수만큼 다시 펼치기만 하면 정렬이 끝납니다. 계수 정렬(counting sort)이에요.

Python
# algorithms/sorting.py
def counting_sort(arr):
    a = list(arr)
    if not a:
        return a
    lo, hi = min(a), max(a)
    k = hi - lo + 1                         # 값의 범위: 개수 배열의 칸 수
    count = [0] * k
    for x in a:
        count[x - lo] += 1                  # 최솟값을 0번 칸으로 당겨 값을 칸에 센다
    out = []
    for i in range(k):                      # 작은 값(0번 칸)부터 개수만큼 펼친다
        out.extend([i + lo] * count[i])
    return out

count[x - lo]에서 - lo가 눈에 띄죠? 최솟값을 0번 칸에 맞추려는 겁니다. 값이 음수여도(예를 들어 최솟값이 -3이면) 그만큼 당겨서 0번 칸부터 채워요. 실제로 도는 과정을 그림으로 보면 단번에 이해됩니다.

텍스트
 counting_sort([2, 5, 2, 1, 0, 3, 2, 1])  — 값을 세고, 작은 값부터 펼친다

   값:     0   1   2   3   4   5
   개수:   1   2   3   1   0   1       2가 세 번, 1이 두 번, 4는 0번
           └ 작은 값부터 개수만큼 펼치면 ┘
   결과:  [0, 1, 1, 2, 2, 2, 3, 5]

원소끼리 크고 작음을 단 한 번도 비교하지 않았어요. 그냥 세고 펼쳤을 뿐입니다. 이게 비교의 벽을 넘는 열쇠예요.

빅오를 따져 봅시다. 값을 세는 데 O(n), 칸을 훑으며 펼치는 데 O(n + k)입니다. 여기서 k는 값의 범위(최댓값 - 최솟값 + 1)예요. 전체는 O(n + k), 공간도 개수 배열 때문에 O(n + k)입니다.

그런데 바로 여기에 함정이 있습니다. k가 값의 범위라는 것. 값이 0부터 10억까지 벌어지면, 개수 배열이 10억 칸이 필요해요. n이 아무리 작아도 k가 커지면 O(n + k)가 통째로 무너집니다. 그래서 계수 정렬은 "값이 정수이고, 그 범위가 좁을 때"만 빛나요.

"1초 ≈ 1억 연산" 잣대로 감을 잡아 봅시다. n이 100만 개인데 값이 0~1,000 사이라면, O(n + k)는 약 100만 연산이라 순식간입니다. 같은 걸 비교 정렬로 하면 O(n log n)이라 약 2,000만 연산이에요. 조건만 맞으면 계수 정렬이 확실히 빠릅니다.

🎯 코테에서는 값의 범위가 문제에 정해져 있을 때 계수 정렬이 강합니다. 나이(0~120), 점수(0~100), 알파벳(26칸)처럼요. "값이 작은 정수"라는 힌트가 보이면 O(n) 정렬을 떠올려 보세요. 물론 실전 코드는 대개 sorted() 한 줄이지만, "이 입력이면 O(n)도 가능하다"는 판단이 서는 게 중요합니다.

💡 한 줄 정리

계수 정렬은 값을 세어 O(n + k)에 정렬하며, 비교를 한 번도 하지 않는 대신 값이 정수이고 범위(k)가 좁아야 한다.

🙋 학생 질문 — "음수나 실수도 계수 정렬할 수 있나요?"

음수는 됩니다. 코드의 x - lo가 그 처리예요. 최솟값을 0번 칸으로 당기니, [-2, 0, -5] 같은 입력도 -5를 0번 칸에 맞춰 정상적으로 셉니다.

실수는 안 돼요. 3.14가 몇 번 칸인지 정할 수가 없으니까요. 개수 배열은 "정수 칸"이라, 실수처럼 값 사이가 촘촘하게 이어지는 데이터엔 못 씁니다.

문자열은 한 글자씩이면 아스키 코드로 바꿔(B-3에서 본 ord) 셀 수 있어요. 다만 유니코드 전체를 범위로 잡으면 k가 폭발하니, "소문자 26칸"처럼 범위를 좁게 제한할 때만 실용적입니다.


Step 2: "자릿수로 나눠 O(n) — 기수 정렬"

계수 정렬의 약점을 다시 봅시다. 값의 범위 k가 크면 무너진다고 했죠. 그럼 [170, 45, 802, 24, 2]처럼 값이 크게 벌어지면 계수 정렬을 포기해야 할까요? 방법이 있습니다. 값을 통째로 세는 대신, 자릿수 하나씩 나눠서 정렬하는 거예요. 기수 정렬(radix sort)입니다.

핵심 아이디어는 이렇습니다. 먼저 1의 자릿수만 보고 정렬하고, 그다음 10의 자릿수로 정렬하고, 또 100의 자릿수로 정렬해요. 낮은 자릿수부터 올라갑니다(LSD, 최하위 자리부터). 각 자릿수의 값은 늘 0~9라서, 자릿수 하나를 정렬하는 데는 칸이 열 개인 계수 정렬이면 충분해요.

Python
# algorithms/sorting.py
def radix_sort(arr):
    a = list(arr)
    if not a:
        return a
    if min(a) < 0:                          # 음수는 자릿값 개념이 흔들려 이 구현 밖이다
        raise ValueError("radix_sort는 음이 아닌 정수만 지원한다")
    max_val = max(a)
    exp = 1                                 # 지금 보는 자릿수의 자릿값(1·10·100…)
    while max_val // exp > 0:               # 가장 긴 수의 자릿수만큼 반복
        a = _counting_sort_by_digit(a, exp)
        exp *= 10
    return a

exp가 지금 보는 자릿수를 가리킵니다. 1이면 1의 자릿수, 10이면 10의 자릿수예요. (x // exp) % 10으로 그 자릿수의 값(0~9)을 뽑아내죠. 가장 큰 수의 자릿수만큼 반복하면 끝입니다. 각 단계에서 자릿수 하나로 정렬하는 부품이 아래의 _counting_sort_by_digit이에요.

Python
def _counting_sort_by_digit(a, exp):
    n = len(a)
    out = [0] * n
    count = [0] * 10                        # 자릿값은 0~9 열 칸
    for x in a:
        count[(x // exp) % 10] += 1         # 이 자릿수의 값이 몇 번 나오나
    for d in range(1, 10):
        count[d] += count[d - 1]            # 누적합 → 각 자릿값이 끝나는 위치
    for x in reversed(a):                   # 뒤에서부터 놓아 안정성을 지킨다
        d = (x // exp) % 10
        count[d] -= 1
        out[count[d]] = x
    return out

여기서 결정적인 줄이 for x in reversed(a), 입력을 뒤에서부터 훑는 부분입니다. 왜 굳이 뒤에서부터일까요? 같은 자릿값을 가진 원소들의 원래 순서를 지키기 위해서예요. 앞 단계에서 이미 맞춰 둔 순서가 흐트러지지 않아야 다음 자릿수 정렬이 그 위에 쌓입니다. 이걸 안정 정렬이라고 부르는데, 다음 Step에서 제대로 짚겠습니다.

[170, 45, 75, 90, 802, 24, 2, 66]이 자릿수를 하나씩 올라가며 정렬되는 과정을 따라가 봅시다.

텍스트
 radix_sort([170, 45, 75, 90, 802, 24, 2, 66])  — 낮은 자릿수부터 안정 정렬 반복

   1의 자리로:   170, 90, 802, 2, 24, 45, 75, 66
   10의 자리로:  802, 2, 24, 45, 66, 170, 75, 90
   100의 자리로:  2, 24, 45, 66, 75, 90, 170, 802    완성

1의 자릿수로 정렬한 결과가 10의 자릿수 정렬의 입력이 되고, 그게 다시 100의 자릿수 정렬로 이어집니다. 마지막 자릿수까지 끝나면 전체가 정렬돼요. 신기하죠? 큰 수를 통째로 비교하지 않고, 자릿수 하나씩만 봤을 뿐인데요.

빅오는 O(d · (n + k))입니다. d는 가장 긴 수의 자릿수, k는 10(자릿값의 가짓수)이에요. d가 상수처럼 작으면 사실상 O(n)에 가깝습니다. 공간은 O(n)이고요.

🎯 코테에서는 기수 정렬을 직접 짜라는 문제는 드뭅니다. 대부분 sorted()로 충분해요. 하지만 "값의 범위는 아주 큰데 자릿수는 적은 정수를 대량으로 정렬"하는 특수 상황에서 O(n)의 가능성을 아는 것, 그리고 면접에서 "비교 없이 O(n) 정렬이 어떻게 가능한가"를 설명하는 것에서 이 원리가 힘을 발휘합니다.

💡 한 줄 정리

기수 정렬은 낮은 자릿수부터 안정 계수 정렬을 자릿수만큼 반복해 O(d · (n + k))에 정렬하며, 각 단계의 안정성이 앞 자리 순서를 지켜 준다.

🙋 학생 질문 — "왜 높은 자리가 아니라 낮은 자리부터 정렬하나요?"

낮은 자릿수부터 하면(LSD), 안정 정렬 덕분에 앞 단계에서 맞춘 순서가 그대로 유지된 채 다음 자릿수가 쌓입니다. 그래서 마지막 자릿수까지 한 방향으로 죽 올라가면 자동으로 완성돼요. 구현이 단순하고 반복문 하나로 끝나죠.

높은 자릿수부터(MSD)도 가능은 합니다. 다만 같은 자릿값끼리 그룹을 나눠 각 그룹을 재귀로 다시 정렬해야 해서, 버킷을 쪼개는 로직이 복잡해져요. 입문 단계에서는 코드가 간결한 LSD가 훨씬 명확합니다.


Step 3: "안정성, 다시 보기 — 순서가 유지돼야 하는 이유"

방금 기수 정렬이 "각 단계가 안정적이어야 앞 자리 순서가 유지된다"에 기대고 있었죠. 이 안정성(stable), 지난 시간에 정렬을 보는 세 잣대 중 하나로 만났습니다. 오늘은 그걸 실전 관점에서 한 번 더 파고들어요. 왜냐하면 안정성이 곧 뒤에서 배울 다중키 정렬의 토대이기 때문입니다.

안정 정렬이란, 값이 같은 원소들의 원래 순서가 정렬 뒤에도 지켜지는 정렬입니다. 이게 왜 실전에서 중요할까요? 핵심은 안정 정렬이면 "기준을 여러 번 이어서 걸 수 있다"는 데 있어요. 먼저 2차 기준으로 정렬해 두고, 그다음 1차 기준으로 안정 정렬하면, 1차가 같을 때 2차 순서가 그대로 남습니다.

텍스트
 안정 정렬이면 '이어서 정렬'이 된다 — 먼저 이름순, 그다음 점수순(안정)

    이름순 정렬:  (가영,90) (나래,80) (다온,90) (라온,80)
    점수순 안정:  (나래,80) (라온,80) (가영,90) (다온,90)
                   └ 80 동점: 나래  라온 (의 이름순이 그대로 유지)

②에서 점수 80이 같은 나래와 라온을 보세요. ①에서 이름순으로 나래가 라온보다 앞이었는데, 점수순으로 다시 정렬한 뒤에도 그 순서가 지켜졌습니다. 안정 정렬이니까요. 만약 불안정 정렬이었다면 이 순서가 뒤집힐 수 있어, 두 단계로 나눠 푸는 방법 자체가 통하지 않아요.

다행히 파이썬 내장 정렬은 안정 정렬입니다. 그래서 "점수순으로 정렬하되 같은 점수는 먼저 등록한 사람이 위로" 같은 요구를 공짜로 만족시켜요. 이 성질이 다음 Step들에서 key와 튜플로 여러 기준을 거는 밑바탕이 됩니다.

🎯 코테에서는 "정렬한 뒤 동점은 이렇게 처리하라"는 조건이 붙는 문제가 정말 많습니다. 나이순으로 정렬하되 같은 나이는 이름순, 점수순이되 동점은 먼저 낸 사람 우선 같은 식이죠. 안정성을 알면 이런 문제를 "2차 기준 먼저, 1차 기준 나중"의 다단계로 풀 수 있고, 다음 Step에서 볼 튜플 key로는 한 줄에 끝냅니다.

💡 한 줄 정리

안정 정렬은 같은 값의 원래 순서를 지켜, 기준을 여러 번 이어서 거는 다중키 정렬의 토대가 되며, 파이썬 sorted는 안정 정렬이다.

🙋 학생 질문 — "안정 정렬로 다단계를 돌리지 않고, 한 번에 여러 기준을 걸 순 없나요?"

있습니다. 그게 바로 Step 6에서 배울 튜플 key예요. key=lambda p: (점수, 이름)처럼 기준을 튜플로 묶으면 한 번의 정렬로 다중키가 끝납니다.

그럼 다단계 안정 정렬은 언제 쓸까요? 원리를 이해하는 데 좋고, 튜플로 표현하기 까다로운 복잡한 혼합 정렬에서 유용해요. 예를 들어 "어떤 기준은 오름차순, 어떤 기준은 내림차순인데 그 기준이 숫자가 아니라 문자열"이면 튜플 하나로 표현하기 어려운데, 그럴 때 안정 정렬을 나눠 거는 방법이 통합니다. 대부분의 경우는 튜플이 더 간결해요.


Step 4: "실전 정렬 — sorted vs list.sort"

여기까지 정렬의 원리를 손으로 다 봤습니다. 이제 실전입니다. 코딩테스트에서 정렬은 직접 짜는 게 아니라 파이썬 내장 정렬 한 줄로 끝내요. 그런데 이 내장 정렬이 두 가지 형태로 있습니다. sorted()list.sort(), 이 둘의 차이를 정확히 알아야 실수를 안 합니다.

Python
xs = [5, 2, 9, 1]

ys = sorted(xs)      # 새 리스트를 만들어 반환한다 (원본은 그대로)
# ys → [1, 2, 5, 9]
# xs → [5, 2, 9, 1]  (건드리지 않음)

xs.sort()            # 원본을 그 자리에서 바꾼다 (반환값은 None)
# xs → [1, 2, 5, 9]

sorted(xs)는 정렬된 새 리스트를 돌려주고 원본 xs는 그대로 둡니다. 반대로 xs.sort()는 원본 xs를 직접 정렬하고, 반환값은 None이에요. 이 반환값 차이에서 초보자가 자주 걸리는 함정이 하나 있습니다.

Python
xs = [5, 2, 9, 1]
xs = xs.sort()       # ⚠️ 함정! sort()는 None을 반환한다
# xs → None  (정렬 결과가 아니라 None이 담긴다)

list.sort()의 결과를 다시 변수에 담으면 None이 들어가 버려요. 원본을 정렬하고 싶으면 xs.sort()만 쓰고, 정렬된 결과를 받고 싶으면 sorted(xs)를 써야 합니다. 내림차순이 필요하면 둘 다 reverse=True를 붙이면 돼요.

Python
sorted(xs, reverse=True)   # 큰 값부터 → [9, 5, 2, 1]

이 내장 정렬의 이름은 Timsort입니다. 지난 시간에 짠 삽입 정렬과 병합 정렬을 영리하게 섞은 하이브리드예요. 작은 조각이나 거의 정렬된 부분은 삽입 정렬로 빠르게 처리하고, 큰 흐름은 병합 정렬로 합칩니다. 그래서 안정 정렬이면서 최악에도 O(n log n)을 보장하고, C로 구현돼 우리가 짠 파이썬 코드보다 훨씬 빠르고 버그도 없어요. 지난 시간에 짠 여섯 정렬은 원리를 보려는 것이었고, 실전은 이 한 줄입니다.

💡 한 줄 정리

sorted()는 새 정렬 리스트를 돌려주고 list.sort()는 원본을 제자리에서 정렬하며(반환값 None), 둘 다 안정 정렬 Timsort로 최악에도 O(n log n)을 보장한다.

🙋 학생 질문 — "sorted랑 list.sort 중 뭘 쓰는 게 좋나요?"

원본을 남겨 둬야 하면 sorted(), 원본을 남길 필요가 없으면 list.sort()를 씁니다. list.sort()는 새 리스트를 만들지 않아 메모리를 아주 조금 아껴요.

실전 코딩테스트에서는 대부분 sorted()를 씁니다. 원본이 필요 없더라도 sorted()가 어떤 반복 가능 객체(리스트뿐 아니라 튜플·집합 등)든 받아서 리스트로 돌려주니 더 두루 쓰기 좋거든요. list.sort()는 리스트에만 있는 메서드예요. 헷갈리면 sorted()를 기본으로 삼으세요.


Step 5: "정렬 기준을 바꾼다 — key 함수"

지금까지는 값 자체의 크기로 정렬했습니다. 그런데 "숫자 크기 말고 다른 기준으로 줄 세우고 싶다"면 어떻게 할까요? 문자열을 길이순으로, 숫자를 절댓값순으로 정렬하는 것처럼요. 이럴 때 key 파라미터를 씁니다. 각 원소에서 "정렬에 쓸 기준값"을 뽑아내는 함수예요.

Python
words = ["ccc", "a", "bb"]
sorted(words, key=len)           # 길이순 → ['a', 'bb', 'ccc']

nums = [-5, 2, -1, 3]
sorted(nums, key=abs)            # 절댓값순 → [-1, 2, 3, -5]

sorted(nums, key=lambda x: -x)   # 내림차순(람다) → [3, 2, -1, -5]

key=len은 각 문자열을 길이로 바꿔 그 길이로 정렬합니다. key=abs는 각 숫자를 절댓값으로 바꿔 비교하고요. 복잡한 기준이면 lambda로 그 자리에서 함수를 만들어 넘기면 됩니다. key=lambda x: -x는 각 값에 음수를 붙여 큰 값이 작아 보이게 만드니, 결과가 내림차순이 돼요.

여기서 딱 하나만 정확히 기억하세요. key는 "비교 함수"가 아니라 "변환 함수"입니다. 두 원소를 받아 누가 큰지 답하는 게 아니라, 원소 하나를 받아 그 원소의 기준값 하나를 돌려줘요. 파이썬이 각 원소에 key를 한 번씩 적용해 기준값을 뽑고, 그 기준값들로 정렬합니다. 원소 자체는 바뀌지 않고, 뽑은 기준값은 비교에만 쓰여요.

🎯 코테에서는 커스텀 정렬이 key 한 줄로 끝납니다. "문자열을 길이순으로", "좌표를 y값 기준으로", "이름의 마지막 글자순으로" 같은 요구가 나오면 key=lambda를 떠올리세요. 정렬 로직을 직접 짜지 말고, 기준값만 뽑아 주면 나머지는 Timsort가 다 합니다.

💡 한 줄 정리

key는 각 원소에서 정렬 기준값을 뽑는 변환 함수로, len·abs·lambda로 원하는 기준을 한 줄에 건다.

🙋 학생 질문 — "다른 언어에서 본 비교 함수(a, b를 받아 -1/0/1)와는 다른 건가요?"

다릅니다. 자바나 C++의 비교자(comparator)는 두 원소 a, b를 받아 "누가 앞이냐"를 답하죠. 파이썬의 key는 원소 하나를 받아 기준값 하나를 돌려줘요.

이 방식이 더 빠릅니다. 비교 함수는 정렬하는 내내 원소 쌍마다 호출돼 O(n log n)번 불리지만, key는 각 원소당 딱 한 번씩만 불려 O(n)번이면 끝나거든요. 예전 파이썬에는 비교 함수 방식(cmp)도 있었지만 지금은 사라졌어요. 꼭 비교 함수 스타일이 필요하면 functools.cmp_to_key로 감쌀 수 있지만, 실전에서 그럴 일은 드뭅니다.


Step 6: "여러 기준을 한 번에 — 튜플 다중키 정렬"

기준이 하나로 부족할 때가 있습니다. "나이순으로 정렬하되 같은 나이는 이름순"처럼 기준이 여러 개인 경우죠. Step 3에서 안정 정렬로 다단계를 돌리는 방법을 봤는데, 더 간결한 길이 있습니다. key가 튜플을 돌려주게 하면 돼요.

Python
people = [("minji", 30), ("jaehoon", 25), ("seungwoo", 30), ("aeri", 25)]
sorted(people, key=lambda p: (p[1], p[0]))
# → [('aeri', 25), ('jaehoon', 25), ('minji', 30), ('seungwoo', 30)]
#    나이(p[1]) 오름차순, 나이가 같으면 이름(p[0]) 오름차순

key(나이, 이름) 튜플을 돌려줍니다. 파이썬은 튜플을 비교할 때 앞 원소부터 차례로 견줘요. 먼저 나이로 비교하고, 나이가 같으면 그때 이름으로 비교합니다. 그래서 25살인 aeri와 jaehoon이 이름순(aeri 먼저)으로 정렬됐어요. 튜플 하나로 다중키 정렬이 끝난 겁니다.

그런데 "점수는 내림차순인데 이름은 오름차순"처럼 방향이 섞이면 어떻게 할까요? 숫자라면 음수를 붙이는 트릭을 씁니다.

Python
scores = [("kim", 90), ("lee", 90), ("park", 80)]
sorted(scores, key=lambda p: (-p[1], p[0]))
# → [('kim', 90), ('lee', 90), ('park', 80)]
#    점수(-p[1]) 내림차순, 점수가 같으면 이름(p[0]) 오름차순

-p[1]로 점수에 음수를 붙이면 큰 점수가 작아 보여 내림차순이 되고, 이름 p[0]은 그대로 두어 오름차순입니다. 90점인 kim과 lee가 점수 동점이라 이름순(kim 먼저)으로 정렬됐죠. 하나의 튜플 안에서 오름과 내림을 섞은 거예요.

다만 이 음수 트릭은 숫자에만 통합니다. 문자열은 음수를 붙일 수 없어요. 문자열 기준을 내림차순으로 섞어야 한다면 Step 3에서 본 안정 정렬 다단계로 돌아가거나, reverse=True로 전체 방향을 뒤집는 방법을 씁니다.

🎯 코테에서는 "여러 조건으로 정렬하라"가 튜플 key의 단골 무대입니다. 나이순·이름순 정렬, 좌표를 x 먼저 y 나중으로 정렬, 빈도순 정렬 같은 문제가 key=lambda x: (기준1, 기준2) 한 줄로 끝나요. 방향이 섞이면 숫자엔 음수 트릭, 문자엔 다단계를 떠올리면 됩니다.

💡 한 줄 정리

key가 튜플을 돌려주면 앞 기준부터 차례로 비교해 다중키 정렬이 되고, 숫자는 음수를 붙여 오름차순과 내림차순을 한 튜플에 섞는다.

🙋 학생 질문 — "튜플에 기준을 세 개, 네 개 넣어도 되나요?"

됩니다. (기준1, 기준2, 기준3, ...)처럼 얼마든지 이어 붙일 수 있어요. 파이썬은 앞에서부터 차례로 비교하다가 앞 기준이 같을 때만 다음 기준으로 넘어가니, 우선순위대로 나열하면 그대로 다중키 정렬이 됩니다.

예를 들어 "점수 내림차순 → 나이 오름차순 → 이름 오름차순"이면 key=lambda p: (-p.score, p.age, p.name)처럼 세 개를 묶으면 돼요. 다만 튜플이 너무 길고 방향이 복잡하게 섞이면 읽기 어려워지니, 그럴 땐 안정 정렬 다단계로 나누는 편이 오히려 명확할 때도 있습니다.


Step 7: "직접 짤까, 내장을 쓸까 — 정렬 선택 정리"

두 시간에 걸쳐 정렬을 여덟 가지나 봤습니다(비교 정렬 여섯 + 선형 정렬 둘). 그리고 실전 내장 정렬과 key·튜플까지 익혔죠. 이제 정리할 질문 하나가 남았어요. "그래서 실제 문제에선 뭘 써야 하나?"입니다. 이 과목이 내내 강조하는 두 트랙, 직접 구현과 내장 활용의 결론을 내 봅시다.

결론부터 말하면, 실전에서는 거의 항상 내장 정렬을 씁니다. 계수·기수를 직접 짜는 건 예외적인 경우예요. 상황별로 무엇을 고를지 표로 정리했습니다.

상황 선택
일반적인 정렬 (실전 대부분) 🌟 sorted() / list.sort() — 기본값
커스텀 기준·다중키 sorted(key=...) + 튜플
값이 정수이고 범위 좁음 (예 0~1,000) 계수 정렬 O(n) 고려
값 범위는 크지만 자릿수가 적은 대량 정수 기수 정렬 (드묾)
면접에서 "원리를 설명하라" 직접 구현으로 설명

핵심은 "원리는 알되, 도구는 내장을 쓴다"입니다. 계수·기수 정렬을 손으로 짜 본 이유는 두 가지예요. 첫째, "비교 없이 O(n)이 가능하다"는 판단이 서야 입력을 보고 최적의 접근을 고를 수 있어요. 둘째, 면접에서 "정렬의 하한이 왜 O(n log n)이고 그걸 어떻게 넘느냐"를 물으면 원리를 아는 사람만 답할 수 있습니다. 하지만 실제 코드는 검증되고 빠른 sorted() 한 줄이 정답이에요.

지금까지 배운 정렬 전체의 빅오를 한 장으로 정리하면 이렇습니다.

텍스트
 정렬 빅오 한눈에 — 비교 정렬(D-1)의 벽과 선형 정렬(D-2)

   비교 정렬 (병합·퀵·힙)   O(n log n)       비교의 하한, 실전 sorted가 여기
   계수 정렬                O(n + k)         값이 정수·범위 좁을 때
   기수 정렬                O(d · (n + k))   자릿수가 적을 때

정렬은 그 자체로 끝이 아니라, 다음 알고리즘의 발판이라고 지난 시간에 말씀드렸죠. 정렬된 배열이 있으면 무엇을 할 수 있을까요? 다음 시간(D-3)에 그 첫 번째 무기를 꺼냅니다. 절반씩 좁혀 O(log n)에 찾는 이진 탐색이에요. 뒤섞인 배열에서 값을 찾으면 O(n)이지만, 정렬해 두면 O(log n)에 찾습니다. "일단 정렬해 놓으면 풀린다"는 코테의 격언이 왜 나왔는지, 다음 시간에 몸으로 느끼게 될 거예요.

💡 한 줄 정리

실전 정렬은 sorted() 한 줄이 기본이고, key·튜플로 커스텀하며, 값이 정수이고 범위가 좁을 때만 계수·기수를 직접 고려한다.

🙋 학생 질문 — "그럼 계수·기수 정렬은 코딩테스트에서 거의 안 쓰나요?"

직접 구현해서 제출할 일은 드뭅니다. 값의 범위가 딱 맞는 특수한 문제가 아니면 sorted()가 더 빠르고 안전하니까요.

그렇다고 배운 게 헛되진 않아요. "비교 없이 O(n) 정렬이 어떻게 가능한가"는 면접의 단골 질문이고, 값의 범위가 명확한 문제에서 "여기선 O(n)도 되겠다"는 판단의 재료가 됩니다. 원리는 손에 넣되 실전 도구는 sorted(), 이게 이 과목이 두 트랙을 함께 가르치는 이유예요.


마무리

오늘은 지난 시간에 만난 "비교의 벽"을 실제로 넘었습니다. 비교를 버리고 값을 세거나 자릿수로 나눠 O(n)에 정렬하는 계수·기수를 손으로 짰고, 실전으로 넘어와 내장 정렬을 key·튜플로 자유자재로 다루는 데까지 왔어요.

오늘 배운 핵심 세 가지

  • 💡 하나 — 비교를 버리면 O(n)이 가능하다. 계수 정렬은 값을 세고, 기수 정렬은 자릿수로 나눈다. 대신 값이 정수이고 범위가 좁아야 한다는 조건이 붙는다.
  • 💡 둘 — 안정성은 다중키 정렬의 토대다. 같은 값의 원래 순서를 지키는 안정 정렬이라야 기준을 여러 번 이어서 걸 수 있고, 파이썬 sorted는 안정 정렬이다.
  • 💡 셋 — 실전은 내장 정렬이다. sorted·list.sortkey와 튜플을 얹어 커스텀하고, 계수·기수는 원리를 알되 특수 상황에서만 직접 짠다.

다음 시간 예고

오늘 "정렬은 다음 알고리즘의 발판"이라는 말을 다시 했습니다. 다음 시간(D-3)엔 그 발판 위에 첫 무기를 올립니다. 정렬된 배열을 절반씩 좁혀 O(log n)에 찾는 이진 탐색이에요. 나아가 "답 자체를 이분한다"는 매개변수 탐색까지 나아가, 최적화 문제를 결정 문제로 바꾸는 발상을 배웁니다. 오늘 "정렬해 두면 뭐가 좋은가"의 답을 다음 시간에 확실히 보게 될 거예요.


과제

오늘 배운 계수 정렬의 발상과 내장 정렬의 key·튜플을 다지는 문제들입니다. 코드베이스 algorithms/sorting.py의 함수를 쓰거나, 내장 sorted()를 직접 활용해 풀어 보세요.

[기초] 가장 많이 나온 값 찾기

0 이상의 정수 리스트 nums가 주어질 때, 가장 많이 등장한 값을 반환하는 most_frequent(nums)를 작성하세요. 만약 가장 많이 나온 값이 여럿이면(빈도가 같으면) 그중 가장 작은 값을 반환합니다. 예를 들어 most_frequent([1, 3, 1, 3, 1])은 세 번 나온 1을 반환하고, most_frequent([4, 4, 1, 1])은 빈도가 같으므로 더 작은 1을 반환합니다.

  • 계수 정렬처럼 개수 배열에 값을 세기만 하면 비교 없이 최빈값이 보입니다. 정렬조차 필요 없어요.
  • 시간·공간 복잡도를 빅오로 적고, 왜 그런지 한 줄로 설명하세요.

[응용] 나이순, 같으면 이름순 정렬

(이름, 나이) 튜플의 리스트 people이 주어질 때, 나이 오름차순으로 정렬하되 나이가 같으면 이름 오름차순으로 정렬한 리스트를 반환하는 sort_people(people)을 작성하세요. 예를 들어 [("minji", 30), ("jaehoon", 25), ("seungwoo", 30), ("aeri", 25)][("aeri", 25), ("jaehoon", 25), ("minji", 30), ("seungwoo", 30)]이 됩니다.

  • 튜플 key 하나로 다중키 정렬이 끝납니다. 어떤 기준을 앞에 둘지 생각해 보세요.
  • 나이와 이름이 모두 같은 두 사람이 있다면, 원래 순서가 지켜지는지도 확인해 보세요(안정 정렬).

[심화] 빈도순 정렬

정수 리스트 nums를 등장 빈도가 높은 값부터 정렬하되, 빈도가 같으면 값이 작은 순으로 정렬한 리스트를 반환하는 sort_by_frequency(nums)를 작성하세요. 각 값은 등장한 횟수만큼 결과에 나와야 합니다. 예를 들어 [2, 3, 1, 3, 2]는 2와 3이 각 두 번, 1이 한 번이므로 [2, 2, 3, 3, 1]이 됩니다.

  • 먼저 각 값의 빈도를 세고(Counter가 편합니다), 튜플 key로 "빈도 내림차순, 값 오름차순"을 한 번에 거세요.
  • 빈도를 내림차순으로 걸려면 숫자에 어떤 트릭을 쓰면 될지 Step 6을 떠올려 보세요.

생각해볼 주제

1. 계수 정렬이 O(n)인데 왜 항상 쓰지 않을까?

계수 정렬은 비교 정렬의 벽 O(n log n)을 넘어 O(n)에 정렬합니다. 빅오만 보면 언제나 계수 정렬이 이겨야 할 것 같은데, 실전 코드는 대부분 O(n log n)인 sorted()를 씁니다. 무엇이 계수 정렬의 발목을 잡는지, 어떤 조건이 갖춰져야 계수 정렬이 실제로 유리한지 정리해 보세요. "빅오가 작다고 항상 빠른 건 아니다"라는 감각이 목표입니다.

2. 파이썬 정렬은 왜 안정 정렬을 기본으로 골랐을까?

파이썬의 sortedlist.sort는 안정 정렬(Timsort)입니다. 반면 어떤 언어의 기본 정렬은 불안정한 퀵 계열이에요. 안정 정렬을 기본으로 삼으면 무엇을 얻고 무엇을 잃을까요? 안정성이 실전에서 어떤 문제를 공짜로 풀어 주는지, 그리고 그 대가는 없는지 생각해 보세요.

3. 튜플 key 한 방과 안정 정렬 다단계, 언제 무엇을 쓸까?

다중키 정렬을 두 가지 방법으로 배웠습니다. 하나는 key에 튜플을 넘겨 한 번에 거는 것, 다른 하나는 2차 기준부터 순서대로 안정 정렬을 여러 번 돌리는 것이에요. 두 방법이 각각 어떤 상황에서 더 낫고, 특히 "오름차순과 내림차순이 섞인 다중키"에서 어떤 함정이 있는지 따져 보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 가장 많이 나온 값 찾기

채점 포인트

항목 확인
계수 발상 값을 개수 배열에 세기만 하면 비교 없이 최빈값이 보인다
동점 처리 작은 값부터 훑어 > 로만 갱신 → 빈도 같으면 작은 값이 남는다
빅오 세기 O(n) + 칸 훑기 O(k) → 전체 O(n + k)·공간 O(k)
실전 감각 정렬조차 필요 없다 — 세는 것만으로 끝난다

풀이 예시

"가장 많이 나온 값"은 각 값이 몇 번 나왔는지만 알면 됩니다. 크기를 비교할 필요가 없어요. 계수 정렬의 앞부분, 즉 개수 배열에 세는 단계만 그대로 빌려 오면 됩니다. 정렬조차 하지 않아요.

Python

def most_frequent(nums):
    if not nums:
        return None
    hi = max(nums)
    count = [0] * (hi + 1)                   # 0..hi 개수 배열
    for x in nums:
        count[x] += 1
    best_val, best_cnt = 0, -1
    for v in range(hi + 1):                  # 작은 값부터 → 동점이면 작은 값이 이긴다
        if count[v] > best_cnt:
            best_val, best_cnt = v, count[v]
    return best_val

핵심은 두 번째 반복문의 방향과 조건입니다. 값을 0부터 작은 순으로 훑으면서, 지금까지의 최대 빈도보다 "더 클 때만"(>) 갱신해요. 빈도가 같으면 갱신하지 않으니, 먼저 만난 더 작은 값이 그대로 남습니다. 동점일 때 작은 값을 고르는 규칙이 이 한 줄에 담겨 있어요. most_frequent([4, 4, 1, 1])을 따라가 봅시다.

텍스트
 개수 배열로 최빈값을 찾는다 (동점이면 작은 값)

   입력:   [4, 4, 1, 1]
   개수:   값 00  12  20  30  42
   훑기:   v=1 에서 best=1(빈도2), v=4 는 빈도 2로 '같아서' 갱신 안 함
                                   └ 답: 1 (동점이라 작은 값)

빈도가 2로 같은 1과 4 중, 먼저 만난 1이 답으로 남습니다. 빅오는 값을 세는 데 O(n), 개수 배열을 훑는 데 O(k)라 전체 O(n + k)예요. 여기서 k는 값의 최댓값입니다.

💡 튜터의 한마디: 값의 범위가 넓거나 음수가 섞이면 개수 배열 대신 collections.Counter를 쓰는 게 더 깔끔해요. Counter(nums).most_common(1)로 최빈값을 바로 뽑을 수 있습니다. 다만 "동점이면 작은 값" 같은 규칙이 붙으면 Counter의 결과를 key로 다시 정렬해야 하니, 개수 배열로 직접 훑는 이 방식이 규칙을 담기엔 오히려 명확할 때가 있어요.


🎯 [과제 2 예시답안] 나이순, 같으면 이름순 정렬

채점 포인트

항목 확인
튜플 key (나이, 이름) 튜플 하나로 다중키를 건다
기준 순서 1차 기준(나이)을 튜플 앞에, 2차 기준(이름)을 뒤에
안정성 나이·이름이 모두 같으면 원래 순서 유지 (sorted는 안정)
빅오 정렬 O(n log n)·공간 O(n)

풀이 예시

기준이 둘입니다. 1순위는 나이, 2순위는 이름이에요. 이럴 때 key가 튜플을 돌려주게 하면 한 번의 정렬로 끝납니다. 파이썬은 튜플을 앞에서부터 비교하니, 우선순위대로 튜플에 나열하면 그대로 다중키가 돼요.

Python
# algorithms/exercises_d2.py
def sort_people(people):
    return sorted(people, key=lambda p: (p[1], p[0]))

p(이름, 나이) 튜플이라 나이는 p[1], 이름은 p[0]이에요. key(p[1], p[0])(나이, 이름)을 돌려주니, 나이로 먼저 비교하고 나이가 같을 때만 이름으로 비교합니다.

텍스트
 (나이, 이름) 튜플로 다중키 정렬

   입력:  (minji,30) (jaehoon,25) (seungwoo,30) (aeri,25)
   key   (30,minji) (25,jaehoon) (30,seungwoo) (25,aeri)
                       튜플 앞(나이)부터 비교 
   결과:  (aeri,25) (jaehoon,25) (minji,30) (seungwoo,30)
                    └ 25 동점: aeri  jaehoon (이름순)

25살인 aeri와 jaehoon이 나이 동점이라 이름순으로 갈렸습니다. 빅오는 정렬이라 O(n log n)이에요. 참고로 sorted가 안정 정렬이라, 나이와 이름이 모두 같은 두 사람이 있어도 원래 순서가 지켜집니다.

💡 튜터의 한마디: "여러 기준으로 정렬"은 코테의 아주 흔한 요구예요. 기준이 셋, 넷이어도 (기준1, 기준2, 기준3)처럼 튜플을 늘리면 됩니다. 우선순위가 높은 기준을 앞에 두는 것만 기억하세요. 실무에서도 정렬 기준이 여러 개인 화면(최신순 → 인기순 → 이름순)이 흔한데, 똑같이 튜플 key 한 줄로 풀립니다.


🎯 [과제 3 예시답안] 빈도순 정렬

채점 포인트

항목 확인
빈도 세기 Counter로 각 값의 등장 횟수를 구한다
다중키 key=(-빈도, 값) — 빈도 내림차순 + 값 오름차순
음수 트릭 빈도를 내림차순으로 걸려고 -freq[v] 사용
빅오 빈도 세기 O(n) + 정렬 O(n log n) → O(n log n)

풀이 예시

두 단계로 나뉩니다. 먼저 각 값이 몇 번 나왔는지 세고, 그다음 그 빈도를 기준으로 정렬해요. 빈도는 Counter로 한 줄에 구하고, 정렬은 튜플 key로 "빈도 내림차순, 값 오름차순"을 한 번에 겁니다.

Python
# algorithms/exercises_d2.py
from collections import Counter


def sort_by_frequency(nums):
    freq = Counter(nums)
    return sorted(nums, key=lambda v: (-freq[v], v))

key의 튜플 (-freq[v], v)가 전부입니다. 빈도 freq[v]에 음수를 붙여 큰 빈도가 작아 보이게 만들면 빈도 내림차순이 되고, 값 v는 그대로 두어 오름차순이에요. 정렬 대상은 원본 nums라, 각 원소가 빈도만큼 자연스럽게 몰려 나옵니다. [2, 3, 1, 3, 2]를 따라가 봅시다.

텍스트
 빈도 내림차순, 같은 빈도면 값 오름차순

   입력:   [2, 3, 1, 3, 2]
   빈도:   22회  32회  11회
   key    2:(-2,2)  3:(-2,3)  1:(-1,1)
           빈도 큰 2·3 먼저(값 오름차순)  그다음 1
   결과:  [2, 2, 3, 3, 1]

빈도가 2로 같은 2와 3은 값이 작은 2가 먼저 나오고, 빈도가 1인 1이 맨 뒤로 갑니다. 빅오는 빈도 세기 O(n)에 정렬 O(n log n)을 더해 전체 O(n log n)이에요.

💡 튜터의 한마디: "빈도가 같으면 값이 큰 순"으로 규칙이 바뀌면 어떻게 할까요? 값도 음수로 뒤집어 (-freq[v], -v)로 하면 됩니다. 이렇게 음수 트릭으로 각 기준의 방향을 따로 정하는 감각이 다중키 정렬의 핵심이에요. LeetCode의 "Sort Array by Increasing Frequency" 계열 문제가 정확히 이 패턴이라, 한 번 익혀 두면 두루 씁니다.


🤔 [생각해볼 주제 1] 계수 정렬이 O(n)인데 왜 항상 쓰지 않을까

문제 상황 요약

계수 정렬은 비교 정렬의 벽 O(n log n)을 넘어 O(n)에 정렬합니다. 빅오만 보면 늘 이겨야 할 것 같은데, 실전 코드는 대부분 sorted()를 써요. 무엇이 계수 정렬의 발목을 잡을까요?

튜터의 가이드 및 해설

빅오의 숨은 항 k가 범인입니다. 계수 정렬은 O(n)이 아니라 정확히는 O(n + k)이고, 여기서 k는 값의 범위예요. 이 k가 조용히 커지면 O(n)이라는 매력이 통째로 사라집니다.

세 가지 조건을 봐야 해요. 첫째, 값이 정수여야 합니다. 실수는 개수 배열의 칸을 나눌 수 없어요. 둘째, 값의 범위가 좁아야 합니다. n이 100개인데 값이 0~10억이면, 개수 배열만 10억 칸이라 시간도 공간도 폭발해요. 셋째, 공간을 그만큼 쓸 수 있어야 합니다. O(n + k)는 시간뿐 아니라 공간도 그만큼이라, 범위가 넓으면 메모리가 먼저 터져요.

그래서 계수 정렬은 "값이 정수이고, 범위가 데이터 개수와 비슷하거나 더 작을 때"만 유리합니다. 나이·점수·알파벳처럼 범위가 좁게 정해진 경우죠. 그 밖에는 범위에 상관없이 O(n log n)을 보장하는 sorted()가 안전하고, 실측 속도도 C 구현이라 빠릅니다.

🎯 코테·면접에선 이렇게 나온다

"O(n) 정렬이 있는데 왜 안 쓰냐"는 트레이드오프를 보는 질문이에요. "계수 정렬은 O(n + k)라 값의 범위 k가 크면 무너지고, 정수·좁은 범위라는 조건이 붙는다"고 답하면 됩니다. "빅오가 작다고 항상 빠른 건 아니다, 상수와 숨은 항을 봐야 한다"는 시각을 보여 주면 깊이가 드러나요.

💡 실무에선

실무에서 순수 계수·기수 정렬을 직접 쓰는 일은 드뭅니다. 대신 그 발상은 자주 빌려요. "값의 범위가 작은 데이터를 O(n)에 처리한다"는 아이디어가 버킷·해시 기반 집계나 히스토그램에 그대로 녹아 있습니다. 정렬 그 자체보다, "세어서 처리한다"는 발상이 살아남는 셈이에요.


🤔 [생각해볼 주제 2] 파이썬 정렬은 왜 안정 정렬을 기본으로 골랐을까

문제 상황 요약

파이썬의 sorted·list.sort는 안정 정렬(Timsort)입니다. 반면 어떤 언어의 기본 정렬은 불안정한 퀵 계열이에요. 안정 정렬을 기본으로 삼으면 무엇을 얻고 무엇을 잃을까요?

튜터의 가이드 및 해설

안정 정렬을 기본으로 두면 다중키 정렬이 공짜가 됩니다. "먼저 2차 기준으로 정렬하고 다시 1차 기준으로 정렬"하면, 1차가 같을 때 2차 순서가 그대로 유지돼요. 오늘 Step 3에서 본 그 성질입니다. 안정 정렬이 아니면 이 방법 자체가 통하지 않아, 사용자가 다중키를 직접 구현해야 해요.

얻는 게 있으면 대가도 있습니다. 안정성을 지키려면 대개 병합 정렬 계열이 필요한데, 병합은 임시 배열을 O(n)만큼 써요. 불안정한 퀵 계열의 제자리 정렬(추가 공간 O(log n))보다 메모리를 더 씁니다. 그래서 "안정성은 필요 없고 메모리·속도가 중요하다"는 판단이면 불안정 정렬을 기본으로 두기도 해요.

파이썬은 "예측 가능하고 다루기 쉬운 정렬"을 우선해 안정성을 택했습니다. 다중키를 자연스럽게 지원하고, 같은 데이터를 정렬하면 늘 같은 결과가 나오는 예측 가능성을 준 거예요. 게다가 Timsort는 거의 정렬된 실제 데이터에서 O(n)에 가깝게 빨라, 안정성의 대가를 상당 부분 상쇄합니다.

🎯 코테·면접에선 이렇게 나온다

"파이썬 정렬은 안정적인가"는 종종 나오는 확인 질문이에요. "그렇다, Timsort이고 안정 정렬이라 다중키를 이어서 걸 수 있다"고 답하면 됩니다. 여기에 "안정성의 대가로 O(n) 추가 공간을 쓰지만, 거의 정렬된 입력에서 빨라 실전에선 이득이 크다"까지 이으면 트레이드오프를 이해한 티가 나요.

💡 실무에선

실무에서는 이 안정성을 은근히 자주 활용합니다. 표를 여러 열로 정렬하는 UI를 떠올려 보세요. 이름으로 정렬한 뒤 날짜로 다시 정렬하면, 날짜가 같은 행들이 이름순으로 남아야 자연스럽죠. 안정 정렬이라 이게 그냥 됩니다. "정렬을 여러 번 이어 걸어도 앞 순서가 유지된다"는 보장이 실무 코드를 단순하게 만들어요.


🤔 [생각해볼 주제 3] 튜플 key 한 방과 안정 정렬 다단계, 언제 무엇을 쓸까

문제 상황 요약

다중키 정렬을 두 방법으로 배웠습니다. key에 튜플을 넘겨 한 번에 거는 것과, 2차 기준부터 순서대로 안정 정렬을 여러 번 돌리는 것이에요. 각각 언제 더 나을까요?

튜터의 가이드 및 해설

대부분은 튜플 key가 낫습니다. 한 번의 정렬로 끝나 O(n log n)이고, 코드도 key=lambda x: (기준1, 기준2) 한 줄로 짧아요. 기준이 둘이든 셋이든 튜플에 이어 붙이면 됩니다. 그래서 기본은 튜플이에요.

다단계 안정 정렬이 빛나는 건 "방향이 섞였는데 튜플로 표현하기 까다로운" 경우입니다. 특히 문자열 기준을 내림차순으로 걸어야 할 때예요. 숫자는 -x로 뒤집으면 되지만, 문자열엔 음수를 붙일 수 없어요. 이럴 때 "먼저 오름차순 기준으로 정렬하고, 그다음 내림차순 기준을 reverse=True로 안정 정렬"하는 다단계가 통합니다. 정렬을 두 번 하니 O(n log n)이 두 배지만, 여전히 같은 빅오예요.

그리고 다단계엔 함정이 하나 있습니다. 정렬 순서가 우선순위와 반대라는 것. 1순위 기준을 마지막에 정렬해야 해요. 안정 정렬은 나중 정렬이 우선하되 앞 순서를 유지하니까요. 이 순서를 헷갈리면 결과가 뒤집힙니다. 튜플 key는 이런 순서 고민이 없어 더 안전하죠.

🎯 코테·면접에선 이렇게 나온다

"오름차순과 내림차순이 섞인 다중키를 어떻게 정렬하냐"가 실전 단골이에요. "숫자 기준은 음수 트릭으로 한 튜플에 섞고, 문자열 기준이 내림차순이면 안정 정렬 다단계로 나눈다"고 답하면 됩니다. 다단계를 말할 땐 "1순위 기준을 마지막에 정렬한다"는 순서까지 짚으면 정확히 이해했다는 인상을 줘요.

💡 실무에선

실무에서는 튜플 key를 압도적으로 많이 씁니다. 읽기 쉽고 한 번에 끝나니까요. 다단계는 정렬 기준이 런타임에 동적으로 바뀌거나(사용자가 정렬 열을 순서대로 클릭), 튜플로 표현이 곤란한 복잡한 혼합에서만 꺼내요. "기본은 튜플, 예외 상황에 다단계"라는 감각이면 충분합니다.

전체 목록 자료구조·알고리즘