본문으로 바로가기

[Algorithm] 퀵 정렬 / 병합(합병) 정렬

category 알고리즘 문제 풀이 2021. 4. 15. 18:28

안전 정렬 : 동일한 값에 기존 순서가 유지 (버블, 삽입, 병합)

 

불안정 정렬 : 동일한 값에 기존 순서가 유지X (선택,퀵)

 

📌퀵 정렬 

퀵 정렬은 특정한 값을 기준으로 해당 값보다 큰 숫자와 작은 숫자를 서로 교환하면서 정렬을 수행합니다.
퀵 정렬은 대표적인 분할 정복 알고리즘으로 평균적으로 선택 정렬, 버블 정렬, 삽입 정렬보다 월등히 빠르다는 특징을 가지고 있습니다.

 

위 세가지 정렬이 O(N2)인것에 비해 퀵 정렬은 O(N*logN)의 속도를 가지고 있기 때문입니다.

비교의 기준이 되는 특정한 값을 피봇(Pivot)이라고 부르며 보통 가장 앞에 있는 숫자를 Pivot으로 사용합니다.

작동 원리

  1. 배열을 왼쪽에서 오른쪽으로 순회하면서 Pivot 보다 큰 수를 찾습니다.
  2. 배열을 오른쪽에서 왼쪽으로 순회하면서 Pivot보다 작은 수를 찾습니다.
  3. 1의 결과와 2의 결과를 서로 Swap합니다.

단, 2의 결과가 1의 결과보다 더 왼쪽에 있을 경우 Pivot과 2의 결과(작은 수)를 서로 Swap합니다.
이때, Pivot의 왼쪽은 Pivot보다 작고, 오른쪽은 Pivot보다 큽니다.

 

시간 복잡도

퀵 정렬은 O(N*logN)의 시간 복잡도를 가집니다. 이게 O(N2)보다 얼마나 빠른지 감이 잘 안잡히시죠?

N이 1,000,000이라면?(정확한 차이는 아닙니다. 대략적으로 이해만 해주세요.)

  • O(N2) : 1,000,000 * 1,000,000 = 1,000,000,000,000(1조)
  • O(N*logN) : 1,000,000 * 약 20(log21,000,000) = 20,000,000(2천만)

둘은 약 50,000배의 속도 차이를 보여줍니다.

이렇듯 퀵 정렬은 월등히 빠른 속도를 자랑합니다. 그러나 항상 O(N*logN)을 보장해주지는 않습니다.

1,2,3,4,5,6,7,8,9,10을 퀵 정렬로 정렬할 경우 10+9+8+..+1번 배열에 접근하므로 O(N2)의 시간 복잡도를 가집니다.
이를 삽입 정렬로 정렬할 경우 O(N)만에 정렬이 완료되게 됩니다.

 

이렇듯 ‘어떠한 방법이 가장 좋다.’ 라고 확정지을 순 없으며 정렬하고자하는 대상에 따라 적절한 방법을 선택하는 것이 매우 중요합니다.

 

예시를 통해 알아봅시다.

쉬운 이해를 위해서 다음과 같이 1부터 7까지 총 7개의 숫자가 들어있는 배열을 기준으로 설명하겠습니다.

[6, 5, 1, 4, 7, 2, 3]

 

항상 정 가운데를 기준으로 분할을 하는 병합 정렬과 달리, 퀵 정렬은 흔히 피봇(pivot)이라고 불리는 임의의 기준값을 사용합니다. pivot 값을 선택하는데는 여러가지 방법이 있지만 여기서는 간단한 설명을 위해 정 중앙에 위치한 4을 pivot으로 정하겠습니다. 그리고 다음과 같이 이 pivot 값을 기준으로 pivot보다 작은 값의 그룹과 pivot보다 큰 값의 그룹으로 나눕니다.

            p
[3, 2, 1] < 4 < [7, 5, 6]

 

위와 같이 pivot 값보다 작은 값들은 모두 왼편으로 몰고, 큰 값들은 모두 오른편으로 몰면 기준값은 정확히 정렬된 위치에 놓이게 됩니다.

또한 이런 방식으로 분할을 해놓으면 앞으로 더 이상 왼편에 있는 값들과 오른편에 있는 값들 간에는 비교를 할 필요가 없습니다.

따라서 반대편은 전혀 신경쓰지 않고 왼편이든 오른편이든 같은편 내의 값들 끼리만 비교 후 정렬을 할 수 있게 됩니다.

 

먼저 왼편을 동일한 방식으로 정렬해보도록 하겠습니다. 왼편의 정 가운데에 위치한 pivot 값인 2 보다 작은 값인 1인 왼쪽에 큰 값인 3은 오른쪽에 위치시켰습니다. 이제 양쪽 모두 값이 하나씩 밖에 없기 때문에 이로써 왼편의 정렬 작업은 완료되었습니다.

      p
[1] < 2 < [3]

 

오른편도 동일한 방식으로 정렬해보겠습니다. 오른편의 pivot 값인 5 보다 작은 값은 없으므로 7과 6을 모두 오른편에 위치시켰습니다.

     p
[] < 5 < [7, 6]

 

오른편의 오른편(?)에는 값이 2개가 있기 때문에 추가 정렬이 필요합니다. 왼편에는 값이 없지만 오른편에는 여전히 두 개의 값이 있기 때문에, 동일한 방식의 정렬을 적용하겠습니다.

      p
[6] < 7 < []

 

마지막으로 지금까지 좌우로 분할했던 값들을 모두 합치보면 다음과 같이 정렬된 배열을 얻을 수 있습니다.

[1, 2, 3, 4, 5, 6, 7]

 

지금까지 살펴본 것과 같이 퀵 정렬은 배열을 pivot 값 기준으로 더 작은 값과 큰 값으로 반복적으로 분할하여 정렬해나가는 방식을 취하고 있습니다.

 

구현

먼저 리스트의 정 가운데 있는 값을 pivot 값으로 선택하고, pivot 값보다 작은 값, 동일한 값 그리고 큰 값을 담아둘 3개의 리스트를 생성합니다.

 

그리고 반복문을 통해 각 값을 pivot과 비교 후에 해당하는 리스트에 추가시킵니다.

그 다음 작은 값과 큰 값을 담고 있는 배열을 대상으로 퀵 정렬 함수를 재귀적으로 호출합니다.

 

마지막으로 재귀 호출의 결과를 다시 크기 순으로 합치면 정렬된 리스트를 얻을 수 있습니다.

import java.util.Arrays;

public class QuickSort {

    public static void quicksort(int[] array, int left, int right) {
        if (left >= right) return; // 정렬 하고자 하는 집단의 원소가 1개인 경우

        int pi = partition(array, left, right);

        quicksort(array, left, pi - 1);
        quicksort(array, pi, right);
    }

    public static int partition(int[] array, int left, int right) {
        int pivot = array[(left + right) / 2];

        while (left <= right) {                         // 엇갈릴 때까지 반복
            // 오름차순 조건, 내림차순은 반대로 하면됩니다.
            while (array[left] < pivot) left++;         // 키 값보다 큰 값을 만날 때까지 오른쪽으로 이동
            while (array[right] > pivot) right--;       // 키 값보다 작은 값을 만날 때까지 왼쪽으로 이동
            if(left <= right) {                         // 현재 엇갈린 상태면 키 값과 교체
                swap(array, left, right);
                left++;
                right--;
            }
        }
        return left;
    }

    public static void swap(int[] array, int a, int b) {
        int temp = array[b];
        array[b] = array[a];
        array[a] = temp;
    }

    public static void main(String[] args) {
        int[] array = { 80, 70, 60, 50, 40, 30, 20 };
        quicksort(array, 0, array.length - 1);

        System.out.println(Arrays.toString(array));
    }
}

 

📌병합 정렬 

큰 문제를 작은 문제 단위로 쪼개면서 해결해나가는 방식 시간복잡도는 O(n log n)

빠른 정렬로 분류되며, 퀵소트와 함께 많이 언급되는 정렬 방식이다.

퀵소트와는 반대로 안정 정렬에 속함

 

  • 다른 정렬 알고리즘과 달리 인접한 값들 간에 상호 자리 교대(swap)이 일어나지 않습니다.

 

 

예시를 통해 알아봅시다.

예를 들어, 다음과 같이 1부터 8까지 총 8개의 숫자가 들어있는 배열에 있다고 가정해보겠습니다.

[6, 5, 3, 1, 8, 7, 2, 4]

 

먼저 하나의 배열을 두 개로 쪼갭니다.

[6, 5, 3, 1] [8, 7, 2, 4]

그래고 다시 두 개의 배열을 네 개로 쪼갭니다.

[6, 5] [3, 1] [8, 7] [2, 4]

 

마지막으로 디시 네 개의 배열을 여덜 개로 쪼갭니다.

[6] [5] [3] [1] [8] [7] [2] [4]

이제 더 이상 쪼갤 수가 없으니 두 개씩 합치기를 시작하겠습니다. 합칠 때는 작은 숫자가 앞에 큰 수자를 뒤에 위치시킵니다.

 

[5, 6] [1, 3] [7, 8] [2, 4]

이제 4개의 배열을 2개로 합칩니다. 각 배열 내에서 가장 작은 값 2개를 비교해서 더 작은 값을 먼저 선택하면 자연스럽게 크기 순으로 선택이 됩니다.

[1, 3, 5, 6] [2, 4, 7, 8]

최종적으로 2개의 배열도 마찬가지로 크기 순으로 선택하가면서 하나로 합치면 정렬된 배열을 얻을 수 있습니다.

[1, 2, 3, 4, 5, 6, 7, 8]

구현

재귀를 이용해서 병합 정렬을 구현할 수 있습니다. 먼저 배열을 더 이상 나눌 수 없을 때 까지 (원소가 하나만 남을 때까지) 최대한 분할 후에, 다시 병합하면서 점점 큰 배열을 만들어 나가면 됩니다. 따라서 이 재귀 알고리즘의 기저 조건은 입력 배열의 크기가 2보다 작을 때이며, 이 조건에 해당할 때는 배열을 그대로 반환하면 됩니다.

 

import java.util.Arrays;

public class MergeSort {
	
    
    public static void mergesort(int[] arr, int i, int length) {
        sort(arr, 0, arr.length);
    }
	
    // mergeSort
    private static void sort(int[] arr, int low, int high) {
        if(high - low < 2) return;

        int mid = (low+high) / 2;
        sort(arr, 0, mid);
        sort(arr, mid, high);
        merge(arr, low, mid, high);
    }
	
    
    // merge
    private static void merge(int[] arr, int low, int mid, int high) {
        int[] temp = new int[high - low];
        int t = 0, l = low, h = mid;

        while (l < mid && h < high) {
            if(arr[l] < arr[h]) {
                temp[t++] = arr[l++];
            } else {
                temp[t++] = arr[h++];
            }
        }

        while (l < mid) {
            temp[t++] = arr[l++];
        }

        while (h < high) {
            temp[t++] = arr[h++];
        }

        for (int i = low; i < high; i++) {
            arr[i] = temp[i - low];
        }
    }

    public static void main(String[] args) {
        int[] array = { 80, 70, 60, 50, 40, 30, 20 };
        mergesort(array, 0, array.length);

        System.out.println(Arrays.toString(array));
    }
}

차이점

퀵정렬 : 우선 피벗을 통해 정렬(partition) → 영역을 쪼갬(quickSort)

합병정렬 : 영역을 쪼갤 수 있을 만큼 쪼갬(mergeSort) → 정렬(merge)

 

정리하기

이미 합병의 대상이 되는 두 영역이 각 영역에 대해서 정렬이 되어있기 때문에 단순히 두 배열을 순차적으로 비교하면서 정렬할 수가 있다.

 

★★★합병정렬은 순차적인 비교로 정렬을 진행하므로, LinkedList의 정렬이 필요할 때 사용하면 효율적이다.★★★

LinkedList를 퀵정렬을 사용해 정렬하면?

성능이 좋지 않음

퀵정렬은, 순차 접근이 아닌 임의 접근이기 때문

LinkedList는 삽입, 삭제 연산에서 유용하지만 접근 연산에서는 비효율적

따라서 임의로 접근하는 퀵소트를 활용하면 오버헤드 발생이 증가하게 됨

배열은 인덱스를 이용해서 접근이 가능하지만, LinkedList는 Head부터 탐색해야 함

배열[O(1)] vs LinkedList[O(n)]

 

 

참고사이트 : blog.jupiterflow.com/2020/07/28/algorithm/study/04_quick_sort/

참고사이트 : www.daleseo.com/sort-quick/

참고사이트 : github.com/gyoogle/tech-interview-for-developer/blob/master/Algorithm/MergeSort.md