본문 바로가기
이직&취업/알고리즘

병합 정렬(Merge Sort) 완벽 분석: 개념부터 실무 적용까지

by journeylabs 2025. 4. 5.
728x90
반응형

1. 병합 정렬(Merge Sort)이란? 주요 개념과 특징

병합 정렬은 분할 정복(Divide and Conquer) 전략을 사용하는 비교 기반 정렬 알고리즘입니다. 배열을 더 작은 하위 배열로 나누고(Divide), 각각을 정렬한 뒤(Conquer), 다시 합치는(Merge) 과정을 통해 정렬을 완성합니다.

 

주요 개념

  • 분할 정복: 문제를 더 작은 하위 문제로 나눕니다. 배열을 반으로 계속 분할하여 크기가 1인 하위 배열을 만듭니다.
  • 비교 기반: 요소 간 비교로 순서를 결정합니다.
  • 안정 정렬(Stable Sorting): 동일한 값의 상대적 순서가 유지됩니다.
  • 시간 복잡도: 최악, 평균, 최선 모두 O(n log n).
  • 공간 복잡도: O(n)으로 추가 메모리 필요합니다.

특징

  • 안정 정렬(Stable Sort): 동일한 값을 가진 요소들의 상대적인 순서가 유지됩니다.
  • 시간 복잡도:
    • 최선: O(n log n)
    • 평균: O(n log n)
    • 최악: O(n log n) - 데이터 분포에 영향을 받지 않고 항상 동일한 성능을 보장합니다.
  • 공간 복잡도: O(n) - 추가적인 메모리 공간이 필요합니다 (병합 과정에서 임시 배열 사용).
  • 외부 정렬(External Sort)에 적합: 메모리에 올릴 수 없는 대용량 데이터를 정렬하는 데 효과적입니다.

2. 병합 정렬 예시와 코드 설명

예시: 배열 [38, 27, 43, 3, 9, 82, 10] 정렬

병합 정렬은 배열을 반으로 나누고, 정렬한 뒤 합칩니다.

 

단계별 진행

  1. 분할: [38, 27, 43, 3, 9, 82, 10] → [38, 27, 43, 3]와 [9, 82, 10].
  2. 재귀적 분할: [38, 27, 43, 3] → [38, 27]와 [43, 3], [9, 82, 10] → [9]와 [82, 10].
  3. 정복: [38, 27] → [27, 38], [43, 3] → [3, 43], [82, 10] → [10, 82].
  4. 병합: [27, 38]와 [3, 43] → [3, 27, 38, 43], [9]와 [10, 82] → [9, 10, 82].
  5. 최종 병합: [3, 27, 38, 43]와 [9, 10, 82] → [3, 9, 10, 27, 38, 43, 82].
public class MergeSort {
    public static void mergeSort(int[] arr, int left, int right) {
        if (left < right) {
            int mid = (left + right) / 2;
            mergeSort(arr, left, mid);      // 왼쪽 분할
            mergeSort(arr, mid + 1, right); // 오른쪽 분할
            merge(arr, left, mid, right);   // 병합
        }
    }

    public static void merge(int[] arr, int left, int mid, int right) {
        int[] temp = new int[right - left + 1];
        int i = left, j = mid + 1, k = 0;

        // 두 하위 배열 비교 및 병합
        while (i <= mid && j <= right) {
            if (arr[i] <= arr[j]) {
                temp[k++] = arr[i++];
            } else {
                temp[k++] = arr[j++];
            }
        }

        // 남은 요소 처리
        while (i <= mid) temp[k++] = arr[i++];
        while (j <= right) temp[k++] = arr[j++];

        // 원래 배열에 복사
        for (i = 0; i < temp.length; i++) {
            arr[left + i] = temp[i];
        }
    }

    public static void main(String[] args) {
        int[] arr = {38, 27, 43, 3, 9, 82, 10};
        mergeSort(arr, 0, arr.length - 1);
        System.out.println("정렬된 배열: " + Arrays.toString(arr));
    }
}
  • mergeSort: 배열을 재귀적으로 분할.
  • merge: 두 하위 배열을 비교하며 병합.
  • temp: 병합 결과를 임시 저장.

3. 병합 정렬 관련 질문 & 풀이

질문 1: "역순 쌍(Inversion) 개수 세기"

문제: 배열에서 역순 쌍(앞 요소가 뒤 요소보다 큰 경우)의 개수를 구하라.
입력: [2, 4, 1, 3, 5]
출력: 3 (쌍: (2, 1), (4, 1), (4, 3))

public class InversionCount {
    static long inversionCount = 0; // 역순 쌍 개수를 저장하는 전역 변수

    public static void mergeSort(int[] arr, int left, int right) {
        if (left < right) { // 분할 조건: 왼쪽 인덱스가 오른쪽보다 작을 때
            int mid = (left + right) / 2; // 중간 지점 계산
            mergeSort(arr, left, mid);    // 왼쪽 하위 배열 정렬
            mergeSort(arr, mid + 1, right); // 오른쪽 하위 배열 정렬
            merge(arr, left, mid, right); // 두 하위 배열 병합
        }
    }

    public static void merge(int[] arr, int left, int mid, int right) {
        int[] temp = new int[right - left + 1]; // 병합 결과를 저장할 임시 배열
        int i = left;   // 왼쪽 하위 배열의 시작 인덱스
        int j = mid + 1; // 오른쪽 하위 배열의 시작 인덱스
        int k = 0;      // 임시 배열의 인덱스

        // 두 하위 배열을 비교하며 병합
        while (i <= mid && j <= right) {
            if (arr[i] <= arr[j]) { // 왼쪽 요소가 작거나 같으면
                temp[k++] = arr[i++]; // 왼쪽 요소를 임시 배열에 추가
            } else { // 오른쪽 요소가 더 작으면
                temp[k++] = arr[j++]; // 오른쪽 요소를 임시 배열에 추가
                inversionCount += (mid - i + 1); // 왼쪽 하위 배열의 남은 요소 수만큼 역순 쌍 추가
            }
        }

        // 왼쪽 하위 배열에 남은 요소 처리
        while (i <= mid) temp[k++] = arr[i++];
        // 오른쪽 하위 배열에 남은 요소 처리
        while (j <= right) temp[k++] = arr[j++];

        // 임시 배열을 원래 배열에 복사
        for (i = 0; i < temp.length; i++) {
            arr[left + i] = temp[i];
        }
    }

    public static void main(String[] args) {
        int[] arr = {2, 4, 1, 3, 5};
        mergeSort(arr, 0, arr.length - 1);
        System.out.println("역순 쌍 개수: " + inversionCount); // 출력: 3
    }
}

 

📌 문제 이해:

  • 역순 쌍(Inversion)은 배열에서 arr[i] > arr[j]이면서 i < j인 경우를 의미합니다.
  • 예: [2, 4, 1, 3, 5]에서 (2, 1), (4, 1), (4, 3)이 역순 쌍.

📌 알고리즘 동작:

  • 병합 정렬의 병합 과정에서 역순 쌍을 카운트합니다.
  • 두 하위 배열을 병합할 때, 오른쪽 하위 배열의 요소가 왼쪽 하위 배열의 요소보다 작으면, 왼쪽 하위 배열의 남은 요소들이 모두 역순 쌍을 형성합니다.

📌 단계별 분석:

  • 분할: [2, 4, 1, 3, 5] → [2, 4]와 [1, 3, 5].
  • 재귀적 분할: [2, 4] → [2]와 [4], [1, 3, 5] → [1]와 [3, 5].
  • 병합:
    • [2]와 [4] → [2, 4] (역순 쌍 없음).
    • [3]와 [5] → [3, 5] (역순 쌍 없음).
    • [1]와 [3, 5] → [1, 3, 5] (역순 쌍 없음).
    • [2, 4]와 [1, 3, 5] 병합:
      • 2 > 1: inversionCount += 2 (2와 4가 1보다 큼).
      • 4 > 3: inversionCount += 1 (4가 3보다 큼).
      • 결과: [1, 2, 3, 4, 5], 총 3개.

📌 핵심 포인트:

  • inversionCount += (mid - i + 1): 오른쪽 요소가 선택될 때, 왼쪽 하위 배열의 남은 요소 수만큼 역순 쌍이 발생.
  • 시간 복잡도: O(n log n), 병합 정렬의 기본 성능 유지.

질문 2: "K번째로 작은 수 찾기"

문제: 병합 정렬을 활용해 배열에서 K번째로 작은 수를 구하라.
입력: [4, 2, 7, 1, 5], K=3
출력: 4

public class KthSmallest {
    public static int findKthSmallest(int[] arr, int k) {
        mergeSort(arr, 0, arr.length - 1); // 배열 전체를 정렬
        return arr[k - 1]; // K번째 요소 반환 (0-based index)
    }

    public static void mergeSort(int[] arr, int left, int right) {
        if (left < right) { // 분할 조건
            int mid = (left + right) / 2; // 중간 지점
            mergeSort(arr, left, mid);    // 왼쪽 정렬
            mergeSort(arr, mid + 1, right); // 오른쪽 정렬
            merge(arr, left, mid, right); // 병합
        }
    }

    public static void merge(int[] arr, int left, int mid, int right) {
        int[] temp = new int[right - left + 1]; // 임시 배열
        int i = left;   // 왼쪽 시작
        int j = mid + 1; // 오른쪽 시작
        int k = 0;      // 임시 배열 인덱스

        // 두 하위 배열 병합
        while (i <= mid && j <= right) {
            if (arr[i] <= arr[j]) { // 왼쪽 요소가 작거나 같으면
                temp[k++] = arr[i++];
            } else { // 오른쪽 요소가 작으면
                temp[k++] = arr[j++];
            }
        }

        // 남은 요소 처리
        while (i <= mid) temp[k++] = arr[i++];   // 왼쪽 남은 요소
        while (j <= right) temp[k++] = arr[j++]; // 오른쪽 남은 요소

        // 원래 배열에 복사
        for (i = 0; i < temp.length; i++) {
            arr[left + i] = temp[i];
        }
    }

    public static void main(String[] args) {
        int[] arr = {4, 2, 7, 1, 5};
        int k = 3;
        int result = findKthSmallest(arr, k);
        System.out.println("K번째 작은 수: " + result); // 출력: 4
    }
}

 

📌 문제 이해:

  • K번째로 작은 수를 찾으려면 배열을 정렬한 뒤 K번째 요소를 반환하면 됩니다.
  • 예: [4, 2, 7, 1, 5] → [1, 2, 4, 5, 7], K=3 → 4.

📌 알고리즘 동작:

  • 병합 정렬로 배열을 완전히 정렬(O(n log n)).
  • 정렬된 배열에서 인덱스 k-1에 접근(O(1)).

📌 단계별 분석:

  • 분할: [4, 2, 7, 1, 5] → [4, 2]와 [7, 1, 5].
  • 재귀적 분할: [4, 2] → [4]와 [2], [7, 1, 5] → [7]와 [1, 5].
  • 병합:
    • [4]와 [2] → [2, 4].
    • [1]와 [5] → [1, 5].
    • [7]와 [1, 5] → [1, 5, 7].
    • [2, 4]와 [1, 5, 7] → [1, 2, 4, 5, 7].
  • 결과: K=3이므로 arr[2] = 4 반환.

📌 핵심 포인트:

  • 병합 정렬의 안정성과 O(n log n) 성능을 활용.
  • 단순히 정렬 후 인덱스 접근이므로 추가 최적화는 불필요.
  • 더 효율적인 방법(예: 퀵 셀렉트, O(n))도 가능하지만, 병합 정렬로도 충분히 해결 가능. 

질문 3: 병합 정렬의 장점과 단점을 설명하고, 다른 정렬 알고리즘 (퀵 정렬, 힙 정렬)과 비교했을 때 어떤 특징을 가지나요?

답변:

📌 장점:

  • 시간 복잡도가 O(n log n)으로, 데이터 분포에 영향을 받지 않고 항상 동일한 성능을 보장합니다.
  • 안정 정렬 알고리즘입니다.
  • 외부 정렬에 적합합니다.

📌 단점:

  • 추가적인 메모리 공간이 필요합니다 (O(n)).
  • 퀵 정렬보다 성능이 약간 떨어질 수 있습니다 (상수 시간 요소).

📌 다른 정렬 알고리즘과의 비교:

  • 퀵 정렬: 평균적으로 O(n log n)의 시간 복잡도를 가지지만, 최악의 경우 O(n^2)이 될 수 있습니다. 제자리 정렬이 가능하지만, 불안정 정렬입니다.
  • 힙 정렬: O(n log n)의 시간 복잡도를 가지며, 제자리 정렬이 가능합니다. 불안정 정렬입니다.

📌 특징:

병합 정렬은 퀵 정렬이나 힙 정렬과 달리 항상 O(n log n)의 시간 복잡도를 보장하며, 안정 정렬이라는 장점을 가집니다. 하지만 추가적인 메모리 공간이 필요하다는 단점이 있습니다.

일반적으로 병합 정렬은 퀵 정렬보다 약간 느리지만, 최악의 경우 성능 저하가 없습니다.


4. 실무 코딩에서 병합 정렬 적용 예시

 병합 정렬은 안정 정렬이 필요하거나, 데이터 양이 많아 외부 정렬이 필요한 경우에 유용하게 사용될 수 있습니다.

  • 대용량 데이터 정렬: 데이터베이스 시스템에서 대용량 데이터를 정렬할 때 병합 정렬을 사용할 수 있습니다.
  • 안정 정렬: 객체 목록을 특정 속성을 기준으로 정렬할 때, 동일한 속성 값을 가진 객체들의 원래 순서를 유지해야 하는 경우 병합 정렬을 사용할 수 있습니다.
  • Spring Framework에서 LinkedHashMap은 삽입 순서를 유지하는 HashMap의 구현체입니다. LinkedHashMap의 내부 구현에서 요소를 정렬해야 하는 경우, 삽입 순서를 유지하면서 정렬하기 위해 병합 정렬과 유사한 방식을 사용할 수 있습니다. 

예시: 고객 데이터 정렬

Spring Boot에서 고객 목록을 이름순으로 정렬:

@Service
public class CustomerService {
    @Autowired
    private CustomerRepository customerRepository;

    public List<Customer> getSortedCustomers() {
        List<Customer> customers = customerRepository.findAll(); // DB에서 고객 목록 조회
        mergeSort(customers, 0, customers.size() - 1); // 병합 정렬 호출
        return customers;
    }

    private void mergeSort(List<Customer> list, int left, int right) {
        if (left < right) { // 분할 조건
            int mid = (left + right) / 2; // 중간 지점
            mergeSort(list, left, mid);   // 왼쪽 정렬
            mergeSort(list, mid + 1, right); // 오른쪽 정렬
            merge(list, left, mid, right); // 병합
        }
    }

    private void merge(List<Customer> list, int left, int mid, int right) {
        List<Customer> temp = new ArrayList<>(); // 병합 결과를 저장할 임시 리스트
        int i = left;   // 왼쪽 시작
        int j = mid + 1; // 오른쪽 시작

        // 두 하위 리스트를 비교하며 병합
        while (i <= mid && j <= right) {
            if (list.get(i).getName().compareTo(list.get(j).getName()) <= 0) {
                temp.add(list.get(i++)); // 왼쪽 고객 추가
            } else {
                temp.add(list.get(j++)); // 오른쪽 고객 추가
            }
        }

        // 남은 요소 처리
        while (i <= mid) temp.add(list.get(i++));   // 왼쪽 남은 고객
        while (j <= right) temp.add(list.get(j++)); // 오른쪽 남은 고객

        // 원래 리스트에 복사
        for (int k = 0; k < temp.size(); k++) {
            list.set(left + k, temp.get(k));
        }
    }
}

 

📌 문제 이해:

  • 고객 데이터를 이름순으로 정렬해야 함.
  • Customer 객체는 getName() 메서드로 이름에 접근 가능.

📌 알고리즘 동작:

  • Spring의 @Service에서 DB 데이터를 가져와 병합 정렬로 처리.
  • List<Customer>를 대상으로 이름 기준 오름차순 정렬.

📌 단계별 분석 (예: [Bob, Alice, Charlie]):

  • 분할: [Bob, Alice, Charlie] → [Bob]와 [Alice, Charlie].
  • 재귀적 분할: [Alice, Charlie] → [Alice]와 [Charlie].
  • 병합:
    • [Alice]와 [Charlie] → [Alice, Charlie].
    • [Bob]와 [Alice, Charlie] → [Alice, Bob, Charlie].
  • 결과: 이름순으로 [Alice, Bob, Charlie].

📌 핵심 포인트:

  • 안정성: 동일 이름의 고객 순서가 유지됨(예: "Bob" 두 명).
  • 성능: O(n log n)으로 대규모 데이터(수백~수천 개) 처리에 적합.
  • 실무적 고려:
    • 소규모 데이터라면 Collections.sort()(팀소트, O(n log n))가 더 간단.
    • 병합 정렬은 외부 정렬(디스크 기반 대규모 데이터)로 확장 가능.

📌 Spring과의 연계:

  • customerRepository.findAll()로 JPA를 통해 DB에서 데이터를 가져옴.
  • 정렬된 결과를 컨트롤러로 반환해 뷰에 표시 가능.

5. 병합 정렬의 주의사항

🔹 장점

일관된 성능: O(n log n)으로 입력 상태와 무관.

안정성: 동일 값의 순서 유지.

대규모 데이터: 외부 정렬로 확장 가능.

 

🔹 단점

추가 메모리: O(n) 공간 필요.

재귀 오버헤드: 소규모 데이터에서는 비효율적.

복잡성: 구현이 다른 간단한 정렬보다 복잡.


6. 결론

병합 정렬은 안정 정렬과 효율적인 시간 복잡도를 제공하는 강력한 정렬 알고리즘입니다. 코딩 테스트에서는 핵심 개념과 구현 방법을 묻는 문제에 자주 등장하며, 실제 개발에서는 대용량 데이터 정렬이나 안정 정렬이 필요한 경우에 유용하게 활용될 수 있습니다. 병합 정렬의 장단점을 숙지하고, 적절한 상황에 맞게 활용하여 효율적인 코드를 작성해 보세요!

 

✨ 병합 정렬, 이제 당신의 코딩 실력을 한 단계 업그레이드해 줄 것입니다! ✨

728x90
반응형