본문으로 건너뛰기

내장 정렬이 보장하는 것 - 이름과 비용: 자료구조 ep.03

정렬은 이미 만들어져 있습니다. 그래서 무엇이 보장되는지 확인하지 않고 씁니다.


숫자 배열을 정렬하면 숫자 순이 아닙니다

자바스크립트의 기본 정렬부터 확인하고 시작합니다.

[10, 9, 80, 7].sort();
// 결과: [10, 7, 80, 9]

Array.prototype.sort()는 비교 함수를 주지 않으면 원소를 문자열로 변환한 뒤 UTF-16 코드 단위 순서로 비교합니다. "10"이 "9"보다 앞서므로 10, 7, 80, 9 순서가 나옵니다.

숫자 정렬에는 비교 함수가 필요합니다.

[10, 9, 80, 7].sort((a, b) => a - b);
// 결과: [7, 9, 10, 80]

비교 함수는 음수를 반환하면 a를 앞에, 양수를 반환하면 b를 앞에 둡니다. 0이면 순서를 유지합니다. 두 원소가 같다고 판정되어 0이 반환됐을 때 원래 순서가 그대로 유지되는지가 안정 정렬의 문제이고, 다음 절에서 다룹니다.


안정 정렬은 순서를 잃지 않는 성질입니다

안정 정렬(stable sort) 은 비교했을 때 같다고 판정된 원소들의 원래 순서를 그대로 유지하는 정렬입니다.

말로만 보면 사소해 보입니다. 다중 기준 정렬에 들어가면 성격이 달라집니다.

주문 목록을 지역별로 묶되 각 지역 안에서는 최신순으로 보여줘야 한다고 해봅시다. 비교 함수 하나에 두 조건을 다 넣을 수도 있지만, 안정 정렬이 보장되면 두 번 나눠 정렬해도 됩니다.

// 안쪽 기준을 먼저, 바깥 기준을 나중에
orders.sort((a, b) => b.createdAt - a.createdAt);        // 최신순
orders.sort((a, b) => a.region.localeCompare(b.region)); // 지역순

두 번째 정렬에서 같은 지역끼리는 비교 결과가 0입니다. 안정 정렬이면 이들의 상대 순서, 즉 첫 번째 정렬로 맞춰둔 최신순이 그대로 남습니다.

안정성이 보장되지 않으면 이렇게 두 번 나눠 정렬하는 방법은 실패합니다. 지역은 맞게 묶이지만 안쪽 순서가 뒤섞입니다. 재현이 어렵고 원인을 찾기도 까다로운 버그가 됩니다.

ECMAScript 2019부터 Array.prototype.sort()는 안정 정렬로 규정되었습니다. 그 이전 사양에서는 안정성이 요구되지 않았고, 엔진마다 배열 길이에 따라 다른 알고리즘을 쓰는 경우가 있었습니다. 오래된 환경을 지원해야 한다면 이 부분을 확인해야 합니다.


비교 함수는 한 줄로도 됩니다

두 번 정렬하는 대신 비교 함수에 조건을 이어 붙일 수도 있습니다. 앞 기준이 같을 때만 다음 기준으로 넘어가는 구조입니다.

orders.sort((a, b) => {
  const byRegion = a.region.localeCompare(b.region);
  if (byRegion !== 0) return byRegion; // 지역이 다르면 여기서 결정
  return b.createdAt - a.createdAt;    // 같으면 최신순
});

문자열 비교에 localeCompare를 쓴 이유는, 한글이나 악센트가 붙은 문자를 < 연산자로 비교하면 유니코드 기준 순서가 나와서 사람이 기대하는 사전 순과 어긋나기 때문입니다. 사용자에게 보이는 목록이라면 로케일을 반영하는 비교가 맞습니다.


세 가지 정렬의 성격

직접 구현할 일은 드물지만, 내장 정렬이 어떤 선택을 했는지 읽으려면 원리는 필요합니다.

세 가지 정렬 알고리즘의 동작 방식을 비교한 다이어그램. 위쪽은 퀵 정렬로, 배열이 피벗을 기준으로 왼쪽 작은 그룹과 오른쪽 큰 그룹으로 갈라지는 트리 형태가 3단계까지 그려짐. 피벗 원소는 진한 색으로 강조됨. 가운데는 병합 정렬로, 배열이 절반씩 아래로 쪼개진 뒤 다시 위로 합쳐지는 V자 형태가 그려지고, 합치는 단계마다 두 정렬된 조각이 하나로 병합되는 화살표가 표시됨. 아래쪽은 힙 정렬로, 완전 이진 트리 형태의 힙에서 루트 원소를 꺼내 결과 배열 끝에 넣고 힙을 다시 정리하는 과정이 3단계로 표시됨. 오른쪽 여백에는 퀵 정렬이 평균 O(N log N), 최악 O(N²)(피벗이 계속 치우칠 때), 추가 공간 O(log N), 불안정, 제자리에서 처리해 상수가 작음으로, 병합 정렬이 평균과 최악 모두 O(N log N)(입력과 무관하게 일정), 추가 공간 O(N), 안정, 합칠 배열이 따로 필요해 메모리를 씀으로, 힙 정렬이 평균과 최악 모두 O(N log N)(보장됨), 추가 공간 O(1), 불안정, 캐시 지역성이 나빠 실측은 느린 편으로 적혀 있음. 다이어그램 제목은 「셋 다 O(N log N)이지만 지불하는 것이 다릅니다」, 부제는 「같은 배열 5 3 8 1 9를 세 방식으로 정렬합니다」임. 퀵 정렬 머리글은 「기준 하나를 정하고 좌우로 가릅니다」이고 단계 라벨은 「1단계 · 피벗 5」, 「2단계 · 작은 쪽과 큰 쪽으로 나뉩니다」, 「3단계 · 각 조각을 다시 가릅니다」임. 병합 정렬 머리글은 「끝까지 쪼갠 뒤 합치면서 정렬합니다」이고 「절반씩 쪼갭니다」, 「두 조각을 하나로 합칩니다」라는 단계 라벨이 있음. 힙 정렬 머리글은 「최댓값을 꺼내 뒤에서부터 채웁니다」이고 최대 힙에서 루트를 꺼내 결과 끝에 놓고 힙을 다시 정리하는 흐름과 「결과 배열 · 뒤에서부터 확정됩니다」, 「색칠된 칸이 이미 확정된 자리입니다」라는 라벨이 있음.

정렬평균최악추가 공간안정성
퀵 정렬O(N log N)O(N²)O(log N)불안정
병합 정렬O(N log N)O(N log N)O(N)안정
힙 정렬O(N log N)O(N log N)O(1)불안정

퀵 정렬(quick sort) 은 기준값 하나를 골라 그보다 작은 값과 큰 값으로 배열을 갈라놓고, 양쪽에 같은 일을 반복합니다. 평균적으로 가장 빠르지만 기준값을 계속 한쪽 끝으로 고르면 분할이 되지 않아 O(N²)로 떨어집니다. 이미 정렬된 입력에서 첫 원소를 기준으로 삼으면 이런 일이 일어납니다.

병합 정렬(merge sort) 은 배열을 절반씩 쪼갠 뒤 정렬된 조각들을 합칩니다. 입력 형태와 무관하게 O(N log N)을 유지하고 안정적입니다. 대신 합칠 공간이 따로 필요합니다.

힙 정렬(heap sort) 은 힙을 만들어 최댓값을 하나씩 빼냅니다. 추가 공간을 거의 쓰지 않으면서 최악에도 O(N log N)입니다. 다만 메모리 접근 위치가 여기저기 흩어져 실측 속도는 퀵 정렬보다 느린 편입니다. 힙 자체는 ep.04 트리의 모양이 성능을 만든다에서 다룹니다.

실제 라이브러리는 하나만 쓰지 않습니다. V8은 배열 정렬에 팀소트(Timsort) 를 씁니다. 병합 정렬과 삽입 정렬을 결합한 방식으로, 입력에 이미 정렬된 구간이 있으면 그것을 찾아내 활용합니다. 안정성도 여기서 나옵니다.


O(N log N)보다 빠를 수 있는가

원소를 서로 비교해서 순서를 정하는 방식이라면 O(N log N)보다 빠를 수 없습니다. 증명은 경우의 수로 합니다. N개 원소의 가능한 순서는 N! 가지이고, 비교 한 번은 경우를 절반으로 줄이므로 최소 log₂(N!)번의 비교가 필요합니다. N이 10이면 10!은 3,628,800가지이고 log₂ 값은 약 22이므로 비교가 최소 22번 필요합니다. log₂(N!)을 정리하면 O(N log N)이 됩니다.

이 하한을 넘으려면 비교를 하지 않아야 합니다.

계수 정렬(counting sort) 이 그 방법입니다. 값의 범위가 0부터 K(코드의 max)까지로 좁을 때 쓰며, 세 단계로 진행합니다.

  1. 크기가 K + 1인 카운트 배열을 만듭니다
  2. 입력을 한 번 훑으면서 각 값을 인덱스로 삼아 몇 번 나왔는지 셉니다
  3. 카운트 배열을 0부터 K까지 차례로 읽으며 값 i를 count[i]번 출력합니다

입력이 3, 1, 4, 1, 5이고 K가 5면 2단계가 끝났을 때 카운트는 인덱스 0부터 [0, 2, 0, 1, 1, 1]이고, 3단계에서 이것을 읽어 내려가면 1, 1, 3, 4, 5가 나옵니다. 원소끼리 비교하는 단계가 없으니 하한에 걸리지 않습니다. 비용은 입력을 훑는 O(N)과 카운트 배열을 읽는 O(K)를 합친 O(N + K)입니다.

function countingSort(arr, max) {
  const count = new Array(max + 1).fill(0);
  for (const v of arr) count[v]++; // 값별 개수 세기

  const result = [];
  for (let v = 0; v <= max; v++) {
    for (let i = 0; i < count[v]; i++) result.push(v);
  }
  return result;
}

조건이 붙습니다. 값이 정수여야 하고 범위가 좁아야 합니다. 나이나 점수처럼 범위가 백 단위인 데이터에는 잘 맞고, 값이 수십억까지 벌어지면 카운트 배열이 메모리를 지나치게 차지합니다.


참고 자료


다음 편 예고

ep.04 - 트리의 모양이 성능을 만든다

같은 값을 같은 자료구조에 넣어도 넣는 순서에 따라 성능이 O(log N)과 O(N) 사이에서 갈립니다. 균형이라는 개념이 왜 필요한지, 그리고 자바스크립트에 없는 우선순위 큐를 어떻게 만드는지 봅니다.