Skip to Content
독학사독학사 4단계알고리즘08. 기수 정렬과 계수 정렬: 비교 정렬 하한을 넘어서

이번 문서의 목표: 이 문서를 다 읽으면 계수 정렬이 배열 원소를 직접 비교하지 않고도 정렬할 수 있는 이유를 누적합 계산 과정으로 설명할 수 있고, 기수 정렬이 계수 정렬을 자릿수마다 반복 적용하는 절차임을 재현할 수 있으며, 비교 기반 정렬은 왜 최선의 경우에도 O(n log n)보다 빨라질 수 없는지를 결정 트리(decision tree) 논증으로 설명할 수 있다.

왜 “비교하지 않는 정렬”이 따로 필요한가

06~08편에서 다룬 선택·삽입·버블·퀵·합병·히프 정렬은 모두 두 원소의 크기를 직접 비교해서 순서를 정한다는 공통점이 있다. “이 값이 저 값보다 큰가 작은가”라는 질문을 반복해서 던지고, 그 답에 따라 원소를 옮기는 방식이다. 그런데 이런 비교 기반 정렬(comparison-based sort)에는 이론적인 한계가 있다. 아무리 영리하게 비교 순서를 짜도, 정렬을 완료하려면 최악의 경우 원소 개수 nn에 대해 O(nlogn)O(n \log n)번의 비교가 필요하다는 사실이 수학적으로 증명되어 있다(이 편의 마지막 절에서 직접 증명한다).

그렇다면 “비교를 아예 하지 않는” 정렬을 만들면 이 하한을 피해갈 수 있지 않을까? 계수 정렬(counting sort)과 기수 정렬(radix sort)이 바로 그 답이다. 두 정렬은 원소끼리 크기를 비교하는 대신, 값 자체를 배열의 인덱스로 직접 사용해서 순서를 정한다. 독학사 4단계 시험에서는 “왜 기수 정렬이 O(n log n) 하한을 넘어설 수 있는가”를 묻는 문항이 자주 나오므로, 단순히 절차를 외우는 것을 넘어 왜 그것이 가능한지를 이해해야 한다.

쉽게 말하면: 비교 정렬은 “누가 더 큰지” 물어보며 순서를 찾아내고, 계수·기수 정렬은 값 자체를 “몇 번째 칸에 넣을지”를 계산하는 주소로 써서 비교 없이 바로 제자리를 찾는다.

계수 정렬 — 값을 세어서 위치를 계산한다

계수 정렬(counting sort)은 정렬할 값들이 00부터 kk까지의 정수 범위 안에 있다는 사실을 이용한다. 아이디어는 “각 값이 몇 개씩 있는지 세어 두면, 그 개수만으로 정렬된 배열에서 각 값이 차지할 위치를 계산할 수 있다”는 것이다. 이 편에서는 원소 개수 n=7n = 7, 값의 범위 k=8k = 8(값은 0부터 8까지 가능)인 예시 배열로 전 과정을 추적한다.

A=[4, 2, 2, 8, 3, 3, 1]A = [\,4,\ 2,\ 2,\ 8,\ 3,\ 3,\ 1\,]
  1. 값별 개수 세기(count 배열 만들기): 크기 k+1=9k+1 = 9인 배열 count를 0으로 초기화하고, AA를 한 번 훑으면서 count[값]을 하나씩 늘린다.

    값(인덱스)012345678
    count012210001
  2. 누적합 계산(prefix sum): count[i]count[i-1]을 더해 나가면, count[i]는 “값이 ii 이하인 원소가 정렬된 배열에서 차지하는 마지막 인덱스(0부터 셀 때, 배치가 끝난 뒤의 개수)“가 된다. 이 값을 이 문서에서는 cum이라 부른다.

    값(인덱스)012345678
    cum(누적합)013566667

    예를 들어 cum[3] = 5는 “값이 3 이하인 원소가 총 5개이므로, 값 3을 가진 원소들은 정렬된 배열의 (0부터 세어) 3번, 4번 인덱스에 놓인다”는 뜻이다(값 1이 1개, 값 2가 2개, 값 3이 2개이므로 값 3의 마지막 원소는 다섯 번째 자리, 즉 인덱스 4에 놓인다).

  3. 원본 배열을 오른쪽에서 왼쪽으로 훑으며 배치: 원본 배열의 뒤에서부터 값을 하나씩 확인해, 그 값의 cum 위치에서 1을 뺀 인덱스에 배치하고, 그 값의 cum을 1 감소시킨다. 뒤에서부터 처리하는 이유는 같은 값을 가진 원소들의 원래 순서를 유지하기 위해서다(이 성질이 바로 안정 정렬의 핵심이며, 다음 절 기수 정렬에서 반드시 필요하다).

    스캔 순서(뒤→앞)확인한 값배치 전 cum[값]배치 인덱스(cum[값] − 1)배치 후 cum[값]출력 배열 상태
    A[6]A[6]1100[1, _, _, _, _, _, _]
    A[5]A[5]3544[1, _, _, _, 3, _, _]
    A[4]A[4]3433[1, _, _, 3, 3, _, _]
    A[3]A[3]8766[1, _, _, 3, 3, _, 8]
    A[2]A[2]2322[1, _, 2, 3, 3, _, 8]
    A[1]A[1]2211[1, 2, 2, 3, 3, _, 8]
    A[0]A[0]4655[1, 2, 2, 3, 3, 4, 8]
  4. 완성: 출력 배열은 1, 2, 2, 3, 3, 4, 8로, 원본 배열을 오름차순으로 정렬한 결과와 정확히 같다.

이 과정 전체에서 원소끼리 크기를 비교하는 연산은 단 한 번도 없었다. 값을 count 배열의 인덱스로 직접 사용해서 개수를 세고, 그 개수의 누적합으로 최종 위치를 바로 계산했을 뿐이다.

계수 정렬의 복잡도

계수 정렬은 세 단계 각각이 걸리는 시간을 더하면 전체 시간이 나온다.

T(n,k)=O(n)count 세기+O(k)누적합 계산+O(n)배치=O(n+k)T(n, k) = \underbrace{O(n)}_{\text{count 세기}} + \underbrace{O(k)}_{\text{누적합 계산}} + \underbrace{O(n)}_{\text{배치}} = O(n + k)
  • nn: 정렬할 원소의 개수
  • kk: 값이 가질 수 있는 범위(0부터 kk까지이므로 k+1k+1가지 값)

이 복잡도는 최선·평균·최악이 모두 동일하게 O(n+k)O(n+k) 다. 값을 비교하지 않으므로 입력의 배열 순서(이미 정렬되어 있는지, 역순인지)가 시간에 전혀 영향을 주지 않기 때문이다. 다만 이 성능은 kknn에 비해 지나치게 크지 않을 때만 의미가 있다. 예를 들어 n=100n = 100인데 값의 범위가 k=1,000,000k = 1{,}000{,}000이라면, count 배열을 만들고 훑는 데만 100만 번 가까운 작업이 필요해 오히려 O(nlogn)O(n \log n) 비교 정렬보다 느려진다.

자주 틀리는 점: “계수 정렬은 항상 O(n)이다”라고 단순화하면 안 된다. 정확한 복잡도는 O(n+k)O(n+k)이며, kknn보다 훨씬 크면 실제로는 매우 느려질 수 있다. 계수 정렬은 값의 범위가 원소 개수와 비슷한 수준으로 제한되어 있을 때만 실용적이다.

기수 정렬 — 계수 정렬을 자릿수마다 반복한다

기수 정렬(radix sort)은 값의 범위 kk가 너무 커서 계수 정렬을 통째로 적용하기 어려운 경우(예: 세 자리 이상의 정수)에, 값을 자릿수(digit) 단위로 쪼개서 각 자릿수에 대해 계수 정렬을 반복 적용하는 방식이다. 자릿수 하나의 범위는 10진수 기준 0~9뿐이므로, 매 패스마다 계수 정렬의 kk가 항상 9로 작게 고정된다는 것이 핵심 아이디어다.

이 편에서는 최하위 자릿수부터(Least Significant Digit, LSD) 처리하는 방식으로, 세 자리 정수 7개를 정렬하는 전 과정을 추적한다.

A=[329, 457, 657, 839, 436, 720, 355]A = [\,329,\ 457,\ 657,\ 839,\ 436,\ 720,\ 355\,]
  1. 1의 자리 기준으로 계수 정렬: 각 값의 1의 자리는 3299329 \to 9, 4577457 \to 7, 6577657 \to 7, 8399839 \to 9, 4366436 \to 6, 7200720 \to 0, 3555355 \to 5다. 이 자릿수 값을 키로 삼아 위에서 익힌 계수 정렬(누적합 방식)을 그대로 적용하면, 같은 자릿수 값을 가진 원소는 원래 순서를 그대로 유지한 채(안정 정렬이므로) 자릿수 오름차순으로 재배치된다.

    자릿수(0–9)0567799
    해당 값720355436457657329839

    → 1의 자리 정렬 후 배열: 720, 355, 436, 457, 657, 329, 839

  2. 10의 자리 기준으로 계수 정렬: 위 결과 배열에 대해 이번엔 10의 자리(7202720 \to 2, 3555355 \to 5, 4363436 \to 3, 4575457 \to 5, 6575657 \to 5, 3292329 \to 2, 8393839 \to 3)로 다시 계수 정렬을 적용한다. 반드시 1단계의 결과 순서를 입력으로 사용해야 하며, 같은 10의 자리 값을 가진 원소끼리는 1단계에서 정해진 순서를 그대로 지킨다.

    10의 자리(0–9)2233555
    해당 값(1단계 순서 유지)720329436839355457657

    → 10의 자리 정렬 후 배열: 720, 329, 436, 839, 355, 457, 657

  3. 100의 자리 기준으로 계수 정렬: 마지막으로 100의 자리(7207720 \to 7, 3293329 \to 3, 4364436 \to 4, 8398839 \to 8, 3553355 \to 3, 4574457 \to 4, 6576657 \to 6)로 계수 정렬을 적용한다.

    100의 자리(0–9)3344678
    해당 값(2단계 순서 유지)329355436457657720839

    → 100의 자리 정렬 후 배열: 329, 355, 436, 457, 657, 720, 839

세 번의 자릿수 패스를 거치자 329, 355, 436, 457, 657, 720, 839로 완전히 정렬되었다. 왜 최하위 자릿수부터 처리해야 하는가가 이 알고리즘의 핵심 함정이다. 만약 반대로 최상위 자릿수(100의 자리)부터 처리한다면, 100의 자리가 같은 원소들(예: 329355, 둘 다 100의 자리가 3) 사이의 순서를 그 뒤의 10의 자리·1의 자리로 다시 정해 줘야 하는데, 이를 올바르게 처리하려면 훨씬 복잡한 재귀적 절차가 필요하다. LSD 방식은 “이미 정렬된 하위 자릿수 순서 위에 상위 자릿수 정렬을 안정적으로 덧씌운다”는 전략이라, 매 패스가 반드시 안정 정렬이기만 하면 최종 결과가 자동으로 올바른 전체 순서가 된다는 점이 이 방식의 우아함이다.

자주 틀리는 점: 기수 정렬의 각 자릿수 패스에 안정적이지 않은 정렬(예: 퀵 정렬)을 쓰면 안 된다. 값이 같은 자릿수를 가진 원소들의 상대적 순서가 패스마다 뒤바뀌면, 이전 패스에서 애써 맞춰 둔 하위 자릿수 순서가 깨져 최종 결과가 틀린 정렬이 되어버린다.

기수 정렬의 복잡도

자릿수(패스) 개수를 dd, 원소 개수를 nn, 자릿수 하나가 가질 수 있는 값의 개수(10진수라면 k=10k=10)라 하면, 각 패스는 계수 정렬 O(n+k)O(n+k)가 걸리고 이를 dd번 반복하므로 다음과 같다.

T(n,d,k)=O(d×(n+k))T(n, d, k) = O(d \times (n + k))

위 예시에서는 n=7n=7, d=3d=3(자릿수 3개), k=10k=10이므로 O(3×(7+10))=O(51)O(3 \times (7+10)) = O(51) 수준의 작업이 필요했다. 만약 ddkk를 상수로 볼 수 있는 상황(예: “32비트 정수만 다룬다”처럼 자릿수 범위가 고정된 경우)이라면, 기수 정렬의 시간 복잡도는 사실상 O(n)O(n)에 가까워진다. 이는 다음 절에서 증명할 비교 정렬의 이론적 하한 Ω(nlogn)\Omega(n \log n)보다 빠른 것으로, “비교하지 않는 정렬만이 도달할 수 있는 영역”이다.

비교 기반 정렬은 왜 O(n log n)보다 빨라질 수 없는가

이제 이 편의 핵심 질문에 답한다. 어떤 비교 기반 정렬 알고리즘도 최악의 경우 Ω(nlogn)\Omega(n \log n)번의 비교 없이는 정렬을 끝낼 수 없다는 사실을 증명한다.

정렬을 결정 트리로 표현하기

원소가 nn개인 배열을 정렬한다는 것은, n!n!가지의 가능한 순서(순열, permutation) 중 정확히 하나를 찾아내는 일이다. 비교 기반 정렬 알고리즘이 “두 원소를 비교한다”는 행동을 한 번 할 때마다, 그 결과는 “크다” 또는 “작다”(또는 “같다”) 둘 중 하나이므로, 알고리즘 전체의 동작을 이진 트리(binary tree) 하나로 그릴 수 있다. 트리의 각 내부 노드는 “이 두 원소 중 어느 것이 큰가”라는 비교 하나를 나타내고, 각 리프(leaf) 노드는 그 비교들의 결과로 확정된 하나의 최종 순서를 나타낸다. 이런 트리를 결정 트리(decision tree)라 부른다.

원소가 서로 다른 값으로만 이루어져 있다면, 가능한 최종 순서는 정확히 n!n!가지이므로, 이 결정 트리는 반드시 최소 n!n!개의 리프를 가져야 한다. 어떤 순열도 결과로 나올 수 있어야 하는데, 만약 리프가 n!n!개보다 적다면 어떤 두 순열이 같은 리프로 표현되어 알고리즘이 그 둘을 구분하지 못한다는 뜻이 되어 모순이다.

트리 높이와 최악 비교 횟수의 관계

이진 트리에서 높이(루트에서 가장 먼 리프까지의 비교 횟수)가 hh면, 리프의 개수는 최대 2h2^h개다(각 내부 노드가 자식을 최대 2개만 가지므로). 이 트리가 n!n!개 이상의 리프를 가지려면 다음이 성립해야 한다.

2hn!2^h \geq n!

양변에 로그를 취하면 다음과 같다.

hlog2(n!)h \geq \log_2(n!)
  • hh: 결정 트리의 높이. 즉 이 알고리즘이 최악의 경우 수행해야 하는 비교 횟수
  • n!n!: 원소 nn개로 만들 수 있는 전체 순열의 개수
  • log2(n!)\log_2(n!): n!n!을 밑이 2인 로그로 취한 값

log2(n!)가 왜 n log n 수준인가

log2(n!)\log_2(n!)을 직접 계산하기 쉽게 풀어보면, n!=n×(n1)××2×1n! = n \times (n-1) \times \cdots \times 2 \times 1이므로 다음과 같다.

log2(n!)=log2n+log2(n1)++log21\log_2(n!) = \log_2 n + \log_2(n-1) + \cdots + \log_2 1

이 합의 뒤쪽 절반(즉 n/2n/2번째 항부터 nn번째 항까지, 항이 n/2n/2개)은 모두 log2(n/2)\log_2(n/2) 이상이므로, 전체 합은 최소한 다음 크기 이상이다.

log2(n!)n2×log2n2\log_2(n!) \geq \frac{n}{2} \times \log_2\frac{n}{2}

우변을 정리하면 n2log2nn2\frac{n}{2}\log_2 n - \frac{n}{2}가 되어, nn이 커질수록 이 값은 nlog2nn \log_2 n에 비례해서 커진다. 따라서 다음 결론에 도달한다.

hlog2(n!)=Ω(nlogn)h \geq \log_2(n!) = \Omega(n \log n)

어떤 비교 기반 정렬 알고리즘도, 아무리 영리하게 설계해도, 최악의 경우 Ω(nlogn)\Omega(n \log n)번의 비교를 피할 수 없다. 07~08편에서 본 합병 정렬·히프 정렬이 최선·평균·최악 모두 O(nlogn)O(n \log n)을 달성한 것은 우연이 아니라, 이 이론적 하한에 정확히 도달한 최적의 비교 정렬이라는 뜻이다.

왜 계수·기수 정렬은 이 하한을 벗어날 수 있는가

이 결정 트리 논증은 “두 원소를 비교해서 순서를 정한다”는 방식에만 적용된다. 계수 정렬과 기수 정렬은 원소끼리 비교하는 연산 자체가 없고, 값을 배열의 인덱스로 직접 변환해 위치를 계산한다. 결정 트리 모델 자체가 이 알고리즘들의 동작을 표현하지 못하므로, Ω(nlogn)\Omega(n \log n) 하한이 애초에 적용되지 않는다. 대신 이 알고리즘들은 값의 범위 kk에 의존하는 O(n+k)O(n+k) 또는 O(d(n+k))O(d(n+k))라는, 하한과는 다른 차원의 대가를 치른다.

구분비교 기반 정렬(퀵·합병·히프 등)비 비교 기반 정렬(계수·기수)
순서를 정하는 방법두 원소를 직접 비교값을 인덱스로 변환해 위치 계산
이론적 하한최악 Ω(nlogn)\Omega(n \log n) 비교 필요하한 자체가 적용되지 않음
대가를 치르는 대상없음(비교 횟수 자체가 비용)값의 범위 kk(공간·시간이 kk에 비례)
적용 가능한 데이터크기 비교가 가능한 모든 데이터(실수·문자열 등)정수 또는 정수로 변환 가능한 고정 자릿수 키

자주 틀리는 점: “기수 정렬이 항상 더 빠르므로 비교 정렬은 쓸모없다”고 단정하면 안 된다. 기수 정렬은 값이 정수(또는 고정 자릿수 키)로 제한될 때만 적용할 수 있고, 임의의 부동소수점·문자열·객체를 사용자 정의 기준으로 정렬해야 하는 일반적인 상황에서는 여전히 비교 기반 정렬이 필요하다. 또한 kkddnn에 비해 매우 크면 기수 정렬이 오히려 더 느려질 수 있다.

자주 틀리는 점

  • 계수 정렬의 복잡도를 무조건 O(n)O(n)으로 외우는 실수: 정확한 복잡도는 O(n+k)O(n+k)이며, kk가 크면 실용적이지 않다.
  • 기수 정렬을 최상위 자릿수부터 처리해도 된다고 착각하는 실수: LSD 방식이 아니라 최상위 자릿수(Most Significant Digit, MSD)부터 처리하려면, 같은 자릿수를 가진 그룹을 재귀적으로 다시 나누는 훨씬 복잡한 절차가 필요하다. 독학사 시험에서 “기수 정렬”이라고만 하면 보통 더 간단한 LSD 방식을 가리킨다.
  • 기수 정렬의 자릿수 패스에 안정적이지 않은 정렬을 써도 된다고 착각하는 실수: 각 패스가 안정 정렬이 아니면 이전 패스에서 맞춘 순서가 깨져 최종 결과가 틀린다.
  • 결정 트리의 리프 개수를 nn개로 착각하는 실수: 리프 개수는 원소 개수 nn이 아니라, 가능한 전체 순열의 개수인 n!n!이다.
  • Ω(nlogn)\Omega(n \log n) 하한이 모든 정렬에 적용된다고 착각하는 실수: 이 하한은 비교 기반 정렬에만 적용되며, 계수·기수 정렬처럼 비교를 하지 않는 알고리즘에는 적용되지 않는다.

핵심 정리

  • 계수 정렬은 값별 개수를 센 뒤 누적합으로 최종 위치를 계산해 O(n+k)O(n+k)에 정렬하며, 원소끼리 비교하는 연산이 전혀 없다.
  • 기수 정렬은 값의 범위가 너무 커서 계수 정렬을 통째로 쓰기 어려울 때, 자릿수 단위로 쪼개 최하위 자릿수부터 안정적인 계수 정렬을 반복 적용해 O(d(n+k))O(d(n+k))에 정렬한다.
  • 비교 기반 정렬은 결정 트리 논증에 따라 최악의 경우 Ω(nlogn)\Omega(n \log n)번의 비교를 피할 수 없으며, 합병·히프 정렬은 이 하한에 정확히 도달한 최적 정렬이다.
  • 계수·기수 정렬은 비교를 하지 않기 때문에 이 하한의 적용 대상이 아니지만, 대신 값의 범위 kk(또는 자릿수 dd)에 비례하는 비용을 치르므로 정수 등 범위가 제한된 키에만 실용적이다.

마무리 복습

문제 14지선다
계수 정렬(counting sort)의 시간 복잡도에 대한 설명으로 옳은 것은?
문제 24지선다
기수 정렬(radix sort)에서 최하위 자릿수(LSD)부터 처리해야 하는 이유로 가장 적절한 것은?
문제 34지선다
비교 기반 정렬의 결정 트리(decision tree) 논증에서, 원소가 서로 다른 n개일 때 결정 트리가 반드시 가져야 하는 최소 리프 개수는?
문제 44지선다
결정 트리의 높이 h가 만족해야 하는 부등식 2^h ≥ n! 로부터 h ≥ log2(n!)이 O(n log n) 수준이 되는 근거로 옳은 것은?
문제 54지선다
계수·기수 정렬이 비교 기반 정렬의 O(n log n) 하한을 벗어날 수 있는 근본적인 이유는?
문제 64지선다
정수 세 자리 값들을 기수 정렬(LSD)로 정렬하는 도중, 10의 자리를 기준으로 계수 정렬을 수행할 때 반드시 지켜야 할 것은?

참고 자료

Last updated on