병합 정렬은 배열을 쪼갠 뒤, 다시 병합시키면서 차근차근 정렬해나가는 방식을 사용한다. 이 때 배열을 쪼개는 로직은 퀵 소트와 비슷하다.
이는 퀵 소트와 마찬가지로 분할 정복 기법을 통해 구현하게 된다. 병합 정렬 알고리즘도 속도가 매우 빠르기 때문에 퀵 소트와 함께 많이 언급되는 정렬 방식이다. 퀵 소트와 다른 점이라면 병합 정렬은 안정 정렬에 속한다는 점 정도. (즉, 정렬 후에도 같은 값을 가진 원소간의 순서가 바뀌지 않음을 보장한다.)
이를 코드로 옮겨보면, 아래와 같은 모양이다. (코틀린 기준)
fun mergeSort(data: MutableList<Int>, start: Int, end: Int) {
if (start >= end) return
val mid = (start + end) / 2 // 반으로 쪼갬
mergeSort(data, start, mid)
mergeSort(data, mid + 1, end)
merge(data, start, mid, end) // 분할된 두 리스트을 하나로 정렬하며 합침
}
fun merge(data: MutableList<Int>, start: Int, mid: Int, end: Int) {
val sortedList = mutableListOf<Int>() // 정렬된 새로운 리스트
var indexA = start // 왼쪽 배열 인덱스
var indexB = mid + 1 // 오른쪽 배열 인덱스
while (indexA <= mid && indexB <= end) { // 두 리스트 중 하나라도 모두 소진되면 종료
// 둘 중 최솟값을 새로운 리스트에 담아주는 작업
if (data[indexA] <= data[indexB]) {
sortedList.add(data[indexA])
indexA++
} else {
sortedList.add(data[indexB])
indexB++
}
}
if (indexA > mid) { // 오른쪽 배열 원소가 아직 남았다면
for (i in indexB..end) {
sortedList.add(data[i])
}
}
if (indexB > end) { // 왼쪽 배열 원소가 아직 남았다면
for (i in indexA..mid) {
sortedList.add(data[i])
}
}
for (x in sortedList.indices) { // 정렬된 부분 대입
data[start + x] = sortedList[x]
}
}
fun main() {
val data = mutableListOf(9, 8, 7, 6, 5, 4, 3, 2, 1)
mergeSort(data, 0, data.size - 1)
println(data)
}
퀵 소트와의 차이점은 이렇다. 퀵 소트는 피벗을 통해 정렬 (Partition) 을 한 뒤 배열을 쪼갠다면, 병합 정렬은 배열을 쪼갤 수 있을 만큼 끝까지 쪼갠 뒤에, 이를 합쳐가며 물 흐르듯 정렬 (Merge) 하는 느낌이다.
데이터의 분포와 전혀 상관없이 항상 1/2 로 쪼개기 때문에 호출 깊이는 O(logn) 이 되고, 각 병합 단계에 있어 원소 비교 연산 (N) 이 필요하기 때문에 이를 곱하여 총 O(nlogn) 이 소요된다. 이미 정렬되어 있는 두 영역에 대해 병합을 하기 때문에, 순차적으로 비교하여 정렬할 수 있다는 장점이 있다.
정렬하여 병합하는 과정에서 새로운 리스트를 만드는 모습을 확인했다. 이 때 O(nlogn) 만큼의 공간 복잡도를 차지한다.
병합 정렬은 순차적인 비교를 통해 정렬하기 때문에, 순차 접근에 용이한 LinkedList 의 정렬이 필요할 때 매우 효율적으로 동작하게 된다. 퀵 소트는 순차 접근이 아닌 임의 접근을 통해 정렬을 하기 때문에 오버헤드가 발생하여 성능이 좋지 않다.