정렬 알고리즘: 퀵·병합·기수 정렬부터 Timsort까지
기본 정렬 알고리즘의 동작과 복잡도, 퀵 정렬과 병합 정렬의 비교, 안정 정렬, 외부 정렬, Timsort를 정리한다.
시리즈 · 자료구조 · 알고리즘12 / 13
- 시간 복잡도와 공간 복잡도
- 재귀 함수와 꼬리 재귀 최적화
- 연결 리스트(Linked List)
- 스택과 큐: 원형 큐 구현과 덱
- 해시 테이블: 해시 함수와 충돌 처리
- 트리와 이진 탐색 트리
- 힙(Heap)과 우선순위 큐
- 균형 이진 탐색 트리: AVL, Red-Black, B-Tree
- 그래프: 인접 행렬과 인접 리스트
- 최단 경로 알고리즘: BFS, 다익스트라, 벨만-포드, 플로이드-워셜, A*
- 최소 신장 트리(MST): Kruskal, Prim, Union-Find
- 정렬 알고리즘: 퀵·병합·기수 정렬부터 Timsort까지
- Thread-Safe 자료구조: Python GIL과 Java ConcurrentHashMap
한눈에 보기
| 정렬 | 최선 | 평균 | 최악 | 추가 메모리 | 안정 정렬 |
|---|---|---|---|---|---|
| 선택 정렬 | O(N²) | O(N²) | O(N²) | O(1) | X |
| 삽입 정렬 | O(N) | O(N²) | O(N²) | O(1) | O |
| 버블 정렬 | O(N) | O(N²) | O(N²) | O(1) | O |
| 병합 정렬 | O(N log N) | O(N log N) | O(N log N) | O(N) | O |
| 퀵 정렬 | O(N log N) | O(N log N) | O(N²) | O(log N) | X |
| 힙 정렬 | O(N log N) | O(N log N) | O(N log N) | O(1) | X |
기본 정렬
선택 정렬 (Selection Sort)
남은 원소 중 가장 작은 값을 찾아 0번 인덱스부터 차례대로 채워 넣는 방식이다. 입력 상태와 관계없이 항상 O(N²)이다.
삽입 정렬 (Insertion Sort)
원소를 하나씩 꺼내, 앞쪽의 이미 정렬된 부분에서 자기보다 작은 값이 나올 때까지 이동시켜 제자리에 끼워 넣는다.
- 최악은 O(N²)이지만 이미 정렬되어 있는 경우에는 O(N) 이다.
버블 정렬 (Bubble Sort)
인접한 두 원소를 비교해 기준에 맞지 않으면 자리를 바꾸며, 가장 큰 값을 뒤로 보내는 과정을 반복한다. 시간 복잡도는 O(N²)이다.
세 정렬 모두 배열 안에서 자리를 바꾸는 제자리 정렬이므로 추가 메모리는 O(1)이다.
병합 정렬 (Merge Sort)
분할 정복을 이용한 정렬이다.
- 배열을 크기가 0 또는 1이 될 때까지 절반으로 계속 쪼갠다.
- 쪼갠 배열을 다시 합치면서 정렬한다.
정렬된 두 배열 A, B를 결과 배열 C로 합칠 때는 각 배열의 맨 앞 원소 A[i]와 B[j]를 비교한다. A[i]가 B[j]보다 작거나 같으면 A[i]를 C에 넣고 i를 증가시키고, 아니면 B[j]를 넣고 j를 증가시킨다.
- 시간 복잡도: O(N log N)
- 공간 복잡도: O(N). 합칠 때 별도의 배열이 필요하다.
퀵 정렬 (Quick Sort)
역시 분할 정복을 이용한다. 기준값(pivot)을 하나 정해 pivot보다 작은 값은 왼쪽, 큰 값은 오른쪽으로 나눈 뒤 양쪽을 재귀적으로 정렬한다.
- 배열의 값 하나를 pivot으로 선택한다.
- 가장 왼쪽 인덱스를
left, 가장 오른쪽 인덱스를right에 저장한다. right부터 비교한다. 값이 pivot보다 크면right를 하나 감소시키며 반복하고, pivot보다 작은 값을 찾으면 멈춘다.left를 비교한다. 값이 pivot보다 작으면left를 하나 증가시키며 반복하고, pivot보다 큰 값을 찾으면 멈춘다.- 두 위치의 값을 교환한다.
left와right가 만날 때까지 3~5를 반복한다. - 만난 위치에 pivot을 두고, pivot의 왼쪽과 오른쪽 부분 배열에 같은 과정을 반복한다.
- 시간 복잡도: 평균 O(N log N), 최악 O(N²)
퀵 정렬 개선 방법
pivot으로 매번 최솟값이나 최댓값이 선택되면 배열이 한쪽으로만 나뉘어 O(N²)이 된다. 예를 들어 맨 앞이나 맨 뒤 원소를 pivot으로 쓰는데 배열이 이미 정렬되어 있는 경우이다.
따라서 pivot을 잘 선택해야 한다. median-of-three는 세 개의 원소를 뽑아 그 중앙값을 pivot으로 삼는 방법이다.
Quick Sort vs Merge Sort
| Quick Sort | Merge Sort | |
|---|---|---|
| 안정성 | Unstable | Stable |
| 메모리 | 적게 쓴다 | 많이 쓴다 |
| 최악의 경우 | O(N²) | O(N log N) |
| 특징 | 참조 지역성이 좋다 | 연결 리스트 정렬에 효율적이다 |
최악의 시간 복잡도는 병합 정렬이 더 낫지만, 일반적으로는 퀵 정렬이 더 빠르다. 퀵 정렬은 배열 안에서 인접한 데이터에 연속적으로 접근하므로 참조 지역성이 좋아 캐시를 잘 활용하기 때문이다.
안정 정렬 (Stable Sort)
정렬했을 때 값이 같은 원소들의 기존 순서가 변하지 않으면 안정 정렬이다.
- 안정 정렬: 삽입 정렬, 병합 정렬, 버블 정렬, 계수 정렬(Counting Sort)
- 불안정 정렬: 선택 정렬, 퀵 정렬, 힙 정렬
기수 정렬 (Radix Sort)
낮은 자릿수부터 자릿수별로 분류해 가며 정렬하는 방식이다. 원소끼리 비교 연산을 하지 않는다.
- 1의 자리를 기준으로 새로운 테이블에 분류한다. 예를 들어 50은 테이블의 0번 칸에 들어간다.
- 1의 자리 기준으로 정렬된 결과를 다시 10의 자리 기준으로 분류한다. 50은 5번 칸에 들어간다.
- 가장 높은 자릿수까지 반복한다.
- 시간 복잡도: 자릿수를 d라고 하면 O(dN)이다. 자릿수가 작게 제한되어 있으면 사실상 O(N)이다.
- 정렬 속도가 빠르지만, 데이터 전체 크기에 더해 기수 테이블만큼의 메모리가 추가로 필요하다.
Internal Sort vs External Sort
- Internal Sort: 메인 메모리 안에서 일어나는 정렬
- External Sort: 데이터가 메모리에 다 올라가지 않아 보조 기억 장치(디스크)를 사용하는 정렬
주기억 장치와 보조 기억 장치의 접근 속도는 극단적으로 차이가 난다. 그래서 외부 정렬은 보조 기억 장치에 접근하는 횟수를 최소화하는 것이 최우선이다.
과정
- 정렬할 파일을 여러 개의 run으로 분할한다. run은 메모리에 적재할 수 있는 크기의 단위이다.
- 각 run을 메모리에서 내부 정렬한 뒤 파일에 저장한다.
- 저장된 run들을 병합해 다시 파일에 저장한다.
- run의 수가 1이 될 때까지 병합을 반복한다.
언어의 기본 정렬
Python: Timsort
삽입 정렬과 병합 정렬을 결합한 정렬 알고리즘이다. 삽입 정렬은 인접한 메모리끼리 비교를 반복하기 때문에 참조 지역성이 아주 좋고, 작은 배열에서는 빠르다는 점을 이용한다.
- 배열을 2^x 크기의 작은 덩어리(run)로 나누고, 각 덩어리를 삽입 정렬로 정렬한다.
- 이때 덩어리의 다음 원소도 계속 증가하거나 감소하는 흐름을 이어 간다면, 덩어리를 최대한 크게 만들기 위해 그 원소까지 덩어리에 포함한다. 이미 정렬된 구간을 그대로 활용하는 것이다.
- 이런 식으로 배열 전체를 덩어리들로 만든다.
- 덩어리들을 병합해야 하는데, 크기가 제각각이라 아무렇게나 병합하면 비효율적이다.
- 그래서 덩어리를 스택에 push하면서, 스택 위쪽 덩어리들의 크기가 정해진 조건을 만족하지 않으면 인접한 두 덩어리를 병합한다. 비슷한 크기의 덩어리끼리 병합되도록 유지하기 위한 규칙이다.
- 마지막에 남은 덩어리들을 모두 병합한다.
자세한 내용은 Naver D2의 Tim sort 글에 잘 정리되어 있다.
Java
- Primitive 타입 배열: 듀얼 피벗 퀵 정렬(Dual-Pivot Quicksort)
- 객체 배열과 컬렉션: Timsort
더 생각해 볼 질문
- Java에서 primitive 배열과 객체(컬렉션)의 정렬 방식이 다른 이유
- 듀얼 피벗 퀵 정렬의 동작 방식