본문 바로가기
cs

Merge Sort (병합 정렬)

알고리즘 복습하기 정렬편 5탄

3

알고리즘 포스팅 하며 복습하기 (정렬편) 📊

  1. Selection Sort (선택 정렬)
  2. Insertion Sort (삽입 정렬)
  3. Bubble Sort (버블 정렬)
  4. Quick Sort (퀵 정렬)
  5. Merge Sort (병합 정렬)
  6. Heap Sort (힙 정렬)

병합 정렬이란?

병합 정렬은 퀵 정렬과 마찬가지로 분할 정복(divide and conquer)을 이용해 데이터를 정렬하는 알고리즘입니다. 배열을 절반씩 나눠 더 이상 나눌 수 없을 때까지 쪼갠 뒤, 정렬하면서 다시 합칩니다.

지난 글에서 퀵 정렬을 다루며 분할 정복을 정리했는데, 병합 정렬은 같은 분할 정복이면서도 일을 하는 시점이 정반대입니다.

  • 퀵 정렬은 나눌 때 일합니다. pivot을 기준으로 작은 값과 큰 값을 갈라놓기 때문에, 다 나누고 나면 합치는 건 그냥 이어붙이기입니다.
  • 병합 정렬은 합칠 때 일합니다. 나눌 때는 아무 기준 없이 무조건 절반으로 자르고, 정렬은 두 배열을 합치는 과정에서 일어납니다.

병합 정렬 알고리즘

  1. 배열을 절반으로 나눈다 (Divide)
  2. 길이가 1이 될 때까지 재귀적으로 나눈다 — 요소가 하나면 그 자체로 정렬된 상태다
  3. 정렬된 두 배열을 앞에서부터 비교하며 하나로 합친다 (Conquer + Combine)

코드

합치는 과정이 핵심이라 merge 함수를 따로 두고 시작합니다.

// 각각 이미 정렬돼 있는 두 배열을, 정렬된 하나의 배열로 합치는 함수
const merge = (left, right) => {
  // 합쳐진 결과를 담을 배열
  const result = [];
 
  // 두 배열에서 각각 "어디까지 꺼냈는지"를 가리키는 인덱스
  let leftIndex = 0;
  let rightIndex = 0;
 
  // 양쪽 모두 아직 꺼낼 값이 남아 있는 동안에만 비교한다.
  // 한쪽이 바닥나면 비교할 상대가 없으므로 루프를 빠져나간다.
  while (leftIndex < left.length && rightIndex < right.length) {
    // 두 배열 모두 정렬된 상태이므로, 각 배열의 맨 앞 값이 그 배열에서 가장 작다.
    // 따라서 이 둘 중 작은 쪽이 "아직 안 꺼낸 값 전체에서 가장 작은 값"이다.
    // 뒤쪽은 볼 필요 없이 앞의 두 개만 비교하면 되는 이유다.
    //
    // 값이 같을 때 왼쪽을 먼저 담는 것이 중요하다.
    // left는 원래 배열에서 앞쪽이었던 조각이라, 같은 값끼리의 순서가 보존된다 (안정 정렬).
    if (left[leftIndex] <= right[rightIndex]) {
      result.push(left[leftIndex]);
      leftIndex++;
    } else {
      result.push(right[rightIndex]);
      rightIndex++;
    }
  }
 
  // 여기 도달했다는 건 한쪽이 바닥났다는 뜻이다.
  // 남아 있는 값들은 (1) 이미 result의 마지막 값보다 크고 (2) 자기들끼리도 정렬돼 있다.
  // 그래서 더 비교하지 않고 뒤에 그대로 이어붙이면 된다.
  //
  // 바닥난 쪽의 slice는 빈 배열이 되므로,
  // 어느 쪽이 남았는지 if로 따지지 않고 둘 다 펼쳐도 안전하다.
  return [...result, ...left.slice(leftIndex), ...right.slice(rightIndex)];
};

이제 배열을 절반으로 나누며 재귀적으로 merge를 호출합니다.

const array = [1, 4, 2, 3, 5];
 
const mergeSort = (arr) => {
  // 재귀를 멈추는 지점.
  // 요소가 하나뿐이면 비교할 상대가 없으므로 그 자체로 이미 정렬된 상태다.
  if (arr.length <= 1) {
    return arr;
  }
 
  const middle = Math.floor(arr.length / 2);
 
  // 값을 전혀 보지 않고 무조건 가운데에서 자른다 (Divide).
  // 퀵 정렬은 pivot과 비교해가며 나눴지만, 병합 정렬의 나누는 단계는 아무 판단도 하지 않는다.
  //
  // 각 절반을 다시 mergeSort에 넘기면, 같은 과정이 반복되다가
  // 길이 1까지 쪼개진 뒤 정렬된 상태로 돌아온다 (Conquer).
  const left = mergeSort(arr.slice(0, middle));
  const right = mergeSort(arr.slice(middle));
 
  // 이 시점에서 left와 right는 각각 정렬이 끝난 배열이다.
  // 그래서 위에서 만든 merge로 합치기만 하면 된다 (Combine).
  // 실제 정렬은 전부 이 합치는 단계에서 일어난다.
  return merge(left, right);
};
 
console.log(mergeSort(array)); // [1, 2, 3, 4, 5]

퀵 정렬과 무엇이 다른가

퀵 정렬병합 정렬
나누는 기준pivot 값무조건 절반
정렬하는 시점나눌 때합칠 때
최악 시간 복잡도O(n^2)O(n log n)
추가 메모리O(log n) (재귀 스택)O(n)
안정 정렬XO

퀵 정렬은 pivot을 잘못 고르면 분할이 한쪽으로 치우쳐 최악 O(n^2)까지 떨어졌습니다. 병합 정렬은 값과 무관하게 항상 절반으로 자르기 때문에 그런 경우가 없습니다. 대신 합칠 때마다 새 배열을 만들어 쓰므로 메모리를 더 씁니다. 앞선 정렬들이 배열 안에서 자리를 바꾸며(in-place) 동작한 것과 다른 지점입니다.

안정 정렬 (Stable Sort)

값이 같은 요소들의 원래 순서가 정렬 후에도 유지되면 안정 정렬이라고 합니다.

merge 함수에서 두 값이 같을 때 왼쪽 배열의 값을 먼저 담았는데, 왼쪽이 원래 배열에서 앞에 있던 쪽이므로 같은 값끼리의 순서가 그대로 보존됩니다. 이 비교를 <=가 아니라 <로 바꾸면 오른쪽이 먼저 담기면서 안정성이 깨집니다. 한 글자 차이입니다.

정렬 결과만 보면 차이가 없어 보이지만, 이미 다른 기준으로 정렬해둔 데이터를 다시 정렬할 때 드러납니다. 예를 들어 이름순으로 정렬한 목록을 점수순으로 다시 정렬하면, 안정 정렬은 점수가 같은 사람들 사이에서 이름순을 유지하지만 불안정 정렬은 그 순서를 보장하지 않습니다.

시간 복잡도

  • 절반씩 나누므로 재귀의 깊이는 log n입니다.
  • 각 깊이마다 전체 요소 n개를 한 번씩 훑으며 합칩니다.
  • 따라서 두 값을 곱한 **O(n log n)**이며, 나누는 기준이 값에 좌우되지 않으므로 최선·평균·최악이 모두 같습니다.
  • Big O 표기법은 최악의 경우를 기준으로 표기