정렬 알고리즘

정렬 알고리즘

작성자
태인태인
카테고리
📗 스터디
작성일
2024년 07월 12일
태그
Algorithm
Java
Data Structure

개요

정렬(sort)란 데이터를 정해진 기준에 따라 배치하여 의미 있는 구조로 재설정하는 것을 의미한다. 데이터를 정렬해야 하는 이유는 데이터를 쉽게 찾기 위해서이다. 대표적인 정렬의 예시는 내림차순, 오름차순 정렬을 들 수 있다. 많은 양의 데이터를 처리할 때 이진 탐색과 같은 효율적인 알고리즘을 적용하기 위해서는 데이터가 정렬되어 있어야 한다. 데이터를 정렬하는 방법에는 여러 가지가 있는데, 어떤 방법을 선택하는지에 따라 시간복잡도와 공간복잡도가 달라지기 때문에 상황에 맞는 최적의 정렬 방식을 사용해야 할 필요가 있다. 본 글에서 다룰 정렬 알고리즘의 종류는 다음 표와 같다.
종류
정의
버블(bubble) 정렬
데이터의 인접 요소끼리 비교하고, swap 연산을 수행하며 정렬
선택(selection) 정렬
대상에서 가장 크거나 작은 데이터를 찾아가 선택을 반복하면서 정렬
삽입(insertion) 정렬
대상을 선택해 정렬된 영역에서 선택 데이터의 적절한 위치를 찾아 삽입하면서 정렬
퀵(quick) 정렬
pivot 값을 선택해 해당 값을 기준으로 정렬
병합(merge) 정렬
이미 정렬된 부분 집합들을 효율적으로 병합해 전체를 정렬
힙(heap) 정렬
완전 이진 트리를 기본으로 하는 힙(Heap) 자료구조를 기반으로 정렬
기수(radix) 정렬
데이터의 자릿수를 바탕으로 비교해 데이터를 정렬
 

버블 정렬(Bubble Sort)

버블 정렬은 인접한 두 값의 크기를 비교해가며 교환(swap) 연산으로 전체 데이터를 정렬하는 방식이다. 값들의 이동이 마치 거품이 물 위로 올라오는 듯한 모습과 비슷해 버블 정렬이란 이름이 붙여졌다고 한다. 구현이 매우 간단하며, 정렬하고자 하는 배열 내에서 swap이 진행되므로 추가적인 메모리 공간을 만들어줄 필요가 없다(제자리 정렬). 다만 시간복잡도는 O(n²)으로 비교적 효율이 떨어진다. 정렬 과정을 요약하면 다음과 같다.
  1. 정렬되지 않은 영역을 루프 범위로 설정한다.
  1. 인접한 두 값을 비교한다.
  1. swap 조건에 부합하는 경우 swap을 수행한다.
  1. 루프 범위 내에서 2~3번을 반복한다.
  1. 비교 대상이 없을 때까지 1~4를 반복한다.
1회차 루프를 수행한 후에는 가장 큰 원소가 맨 뒤로 이동되어 있을 것이다. 따라서 2회차 루프에서는 마지막 원소를 루프 범위에서 제외하면 된다. 만약 루프 전체 영역에서 swap이 한 번도 이루어지지 않았다면, 해당 영역 뒤의 데이터가 모두 정렬된 것이므로 프로세스를 종료해도 된다. 코드로 구현하면 다음과 같다.
void bubbleSort(int[] arr) { int temp = 0; for (int i = 0; i < arr.length; i++) { // 루프 범위에서 제외할 원소 개수 for (int j = 1; j < arr.length - i; j++) { if (arr[j - 1] > arr[j]) { // swap 연산 temp = arr[j - 1]; arr[j - 1] = arr[j]; arr[j] = temp; } } } System.out.println(Arrays.toString(arr)); }
 

선택 정렬(Selection Sort)

선택 정렬은 버블 정렬과 비슷한 측면이 있다. 각 루프마다 원소를 넣을 위치는 이미 정해져 있고, 어떤 원소를 넣을지를 선택하는 방식이다. 최솟값 또는 최댓값을 찾아 해당 원소를 정해진 위치와 swap한다. 버블 정렬과 마찬가지로 정렬하고자 하는 배열 안에서 교환하는 방식이므로, 추가적인 메모리 공간이 필요 없다. 다만, 시간 복잡도는 O(n²)로 효율적이지 않은 편이다.
  1. 주어진 배열에서 최솟값을 찾는다.
  1. 배열 맨 앞의 요소와 1번의 최솟값을 swap 한다.
  1. 맨 앞 위치를 뺀 나머지 영역을 1~2번을 반복하며 교체한다.
이를 코드로 구현하면 다음과 같다.
void selectionSort(int[] arr) { int minIndex, temp; // 최솟값 찾기 for (int i = 0; i < arr.length - 1; i++) { minIndex = i; for (int j = i + 1; j < arr.length; j++) { if (arr[j] < arr[minIndex]) { minIndex = j; } } // swap 연산 수행(최솟값 요소와 맨 앞 요소) temp = arr[minIndex]; arr[minIndex] = arr[i]; arr[i] = temp; } System.out.println(Arrays.toString(arr)); }
 

삽입 정렬(Insertion Sort)

삽입 정렬은 이미 정렬된 데이터 범위에 정렬되지 않은 데이터를 적절한 위치에 삽입하는 방식이다. 2번째 원소부터 시작하여 그 앞 원소들과의 비교를 통해 삽입 위치를 지정한 후, 해당 위치로 이동할 때까지 shift 연산을 수행한다. 대부분의 원소가 이미 정렬되어 있는 상태라면 효율적인 정렬 알고리즘이 될 수 있다. 실제로 최선의 경우 O(n)의 시간복잡도를 갖는다. 그러나 평균과 최악의 시간복잡도는 O(n²)으로 여전히 비효율적이다. 버블, 선택 정렬과 마찬가지로 추가적인 메모리 공간이 필요하지 않은 제자리 정렬이다.
  1. 2번째 index의 값을 temp에 저장한다.
  1. temp와 temp 앞에 있는 요소들과 비교하며 shift 연산을 수행한다.
  1. 선택할 데이터가 더 이상 없을 때까지, 1번으로 돌아가 다음 index값을 temp에 저장하고 반복한다.
이를 코드로 구현해보면 아래와 같다.
void insertionSort(int[] arr) { for (int i = 1; i < arr.length; i++) { // 삽입할 위치 지정 int temp = arr[i]; int prev = i - 1; while ((prev >= 0) && (arr[prev] > temp)) { // shift 연산 arr[prev + 1] = arr[prev]; prev--; } arr[prev + 1] = temp; } System.out.println(Arrays.toString(arr)); }
 

퀵 정렬(Quick Sort)

퀵 정렬은 기준값(pivot)을 선정해 해당 값보다 작은 데이터와 큰 데이터로 분류하는 것을 반복하는 방식으로 정렬하는 알고리즘이다. 퀵 정렬은 분할 정복법*을 통해 주어진 배열을 정렬한다. 불필요한 원소의 이동을 최소화하며 먼 거리의 원소를 교환할 수 있고, 한 번 선정된 pivot은 이후 연산에서 제외되기 때문에 평균 시간 복잡도가 O(nlog₂n)으로 효율적이다.
* 분할 정복(divide and conquer)법 : 문제를 작은 2개의 문제로 분리하고 각각을 해결한 다음, 결과를 모아서 원래의 문제를 해결하는 전략
  1. 배열에서 기준값(pivot)을 선정한다.
  1. 피벗 앞에는 피벗보다 값이 작은 모든 원소들이 오고, 피벗 뒤에는 피벗보다 값이 큰 모든 원소들이 오도록 배열을 두 개로 나눈다(분할). 분할을 마친 후, 피벗은 더 이상 이동하지 않는다.
  1. 분할된 두 개의 작은 배열에 대해 재귀적으로 이 과정을 반복한다.
피벗을 중심으로 데이터를 2개로 집합으로 나누면서 정렬하는 것이 퀵 정렬의 핵심이라고 할 수 있다.
<가장 오른쪽 값을 피벗으로 설정했을 때 퀵 정렬 수행 과정>
출처 : 김종관,  「Do it! 알고리즘 코딩 테스트: 자바 편」, 이지스퍼블리싱, 2022, 119쪽
<가장 오른쪽 값을 피벗으로 설정했을 때 퀵 정렬 수행 과정> 출처 : 김종관, 「Do it! 알고리즘 코딩 테스트: 자바 편」, 이지스퍼블리싱, 2022, 119쪽
 
퀵 정렬을 구현할 때는 정복(Conquer) 부분과 분할(Divide) 단계로 나누어 생각해야 한다.
먼저 정복 부분이다. 부분 배열을 정렬하는 재귀 함수이다.
public void quickSort(int[] array, int left, int right) { if (left >= right) return; int pivot = partition(); // 분할 // 피벗 제외한 2개의 부분 배열을 대상으로 재귀 호출 quickSort(array, left, pivot - 1); // 피벗 왼쪽 부분 quickSort(array, pivot + 1, right); // 피벗 오른쪽 부분 }
다음은 분할 부분이다. 파라미터로 전달받은 피벗을 기준으로 2개의 부분 배열로 분할하는 역할을 수행한다. 여기서는 가장 왼쪽값을 피벗으로 설정했지만, 피벗 설정 기준은 상황에 따라 다양할 수 있다(예를 들면 가장 오른쪽 값).
public int partition(int[] array, int left, int right) { int pivot = array[left]; // 가장 왼쪽값을 피벗으로 설정 int i = left, j = right; while (i < j) { // 오른쪽 끝에서부터 피벗보다 작거나 같은 값을 찾을 때까지 j를 왼쪽으로 이동 while (pivot < array[j]) { j--; } // 왼쪽 끝에서부터 피벗보다 큰 값을 찾을 때까지 i를 오른쪽으로 이동 while (i < j && pivot >= array[i]) { i++; } swap(array, i, j); // 찾은 두 값을 교환 } // 피벗을 자신의 위치로 이동 array[left] = array[i]; array[i] = pivot; return i; // 피벗의 최종 위치를 반환 } private static void swap(int[] a, int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; }
 
퀵 정렬의 시간 복잡도는 피벗 값을 어떻게 정하는 지에 따라 달라진다. 예를 들어 가장 왼쪽에 있는 값을 피벗으로 선정했는데, 그 값이 가장 큰 수이고 뒤의 모든 값이 내림차순으로 이미 정리되어 있는 경우를 생각해보자. 이때는 피벗의 최종 위치를 찾은 시점에서 모든 값과 비교하며 전부 swap 되었을 것이므로 시간 복잡도가 O(n²)으로 퀵 정렬을 사용하는 의미가 퇴색된다.
즉, 배열이 이미 오름차순 정렬되어있거나 내림차순 정렬된 경우, 퀵 정렬을 수행하면 O(n²)의 시간복잡도를 가진다. 이 경우에는 배열 가장 앞에 있는 값과 중간값을 swap 해주면, 확률적으로 시간복잡도 O(nlog₂n)로 개선할 수 있다.

병합 정렬(Merge Sort)

퀵 정렬과 마찬가지로 분할 정복법을 사용해 데이터를 분할하고, 분할한 집합을 정렬하며 합치는 방식의 정렬 알고리즘이다. 평균 시간 복잡도는 O(nlogn)으로 효율적이다.
아래 사진을 통해 병합 정렬을 이해해보자.
출처 : 김종관,  「Do it! 알고리즘 코딩 테스트: 자바 편」, 이지스퍼블리싱, 2022, 126쪽
출처 : 김종관, 「Do it! 알고리즘 코딩 테스트: 자바 편」, 이지스퍼블리싱, 2022, 126쪽
처음에는 총 8개의 그룹으로 배열을 나눈다. 이후 2개씩 그룹을 병합하며 각 그룹에 대해 오름차순 정렬한다. 그 결과가 위 그림에서 세 번째 배열이다. 이제 다시 2개씩 그룹을 병합하여 오름차순 정렬하는 과정을 반복한다.
public void mergeSort(int[] array, int left, int right) { if (left < right) { int mid = (left + right) / 2; mergeSort(array, left, mid); //중간값 포함 왼쪽 영역 mergeSort(array, mid + 1, right); // 중간값 오른쪽 영역 merge(array, left, mid, right); // 그룹 합치기 } } public static void merge(int[] array, int left, int mid, int right) { int[] L = Arrays.copyOfRange(array, left, mid + 1); int[] R = Arrays.copyOfRange(array, mid + 1, right + 1); int i = 0, j = 0, k = left; int ll = L.length, rl = R.length; // 두 배열을 병합하는 동안 요소를 비교하여 결과 배열에 추가 while (i < ll && j < rl) { if (L[i] <= R[j]) { array[k] = L[i++]; } else { array[k] = R[j++]; } k++; } // 왼쪽 배열에 남아있는 모든 요소를 결과 배열에 추가 while (i < ll) { array[k++] = L[i++]; } // 오른쪽 배열에 남아있는 모든 요소를 결과 배열에 추가 while (j < rl) { array[k++] = R[j++]; } }
 

힙 정렬(Heap Sort)

완전 이진 트리*를 기본으로 하는 힙(Heap) 자료구조를 기반으로한 정렬 방식이다.
*완전 이진 트리 : 삽입할 때 왼쪽부터 차례대로 추가하는 이진 트리. 부모 노드 밑에 자식 노드가 최대 2개까지 존재 가능. 마지막 레벨을 제외한 모든 레벨에 노드가 완전히 채워져 있는 트리 구조.
먼저 힙에 대해 간단히 살펴보자.

힙(Heap)

힙은 완전 이진 트리의 일종으로, 부모 노드와 자식 노드 간에 특정한 조건을 만족하는 자료 구조를 말한다.
힙에는 최대 힙(Max-Heap)과 최소 힙(Min-Heap)이 있다. 최대 힙은 모든 부모 노드가 그 자식 노드보다 크거나 같은 값을 갖는다. 이에 반해 최소 힙은 모든 부모 노드가 자식 노드보다 작거나 같다. 아래 그림에 있는 트리 구조가 최대 힙의 예시이다.
힙과 트리 구조에 대한 소개는 향후 트리 구조 관련 글에서 다시 자세히 설명하도록 하고, 본 글에서는 힙 정렬의 발상에 대해서만 간단히 살펴보자.
  1. 최대 힙을 구성한다.
  1. 현재 힙 루트(가장 위 노드)는 가장 큰 값이 존재한다. 루트의 값을 마지막 요소와 바꾸고, 힙의 사이즈를 하나 줄인다(마지막 요소 제거). 이 때, 큰 수부터 제거되므로 이를 저장해두면 정렬이 가능하다.
  1. 힙의 사이즈가 1보다 크면 위 과정을 반복한다.
예시를 살펴보자.
notion image
public void heapSort(int[] array) { int n = array.length; // 최대 힙 초기화 for (int i = n / 2 - 1; i >= 0; i--) { heapify(array, n, i); // 일반 배열을 최대 힙으로 구성 } // extract 연산 for (int i = n - 1; i > 0; i--) { swap(array, 0, i); // 루트와 마지막 요소 교환 heapify(array, i, 0); // 요소 하나 제거 후 다시 최대 힙 구성 } }
heapify는 힙을 재구성하는 함수로, 여기서는 주어진 힙을 다시 최대 힙으로 구성하는 역할을 한다. 다시 최대 힙을 구성할 때까지 부모 노드와 자식 노드를 swap하며 재귀 진행한다.
public void heapify(int array[], int n, int i) { int p = i; int l = i*2 + 1; int r = i*2 + 2; //왼쪽 자식노드 if (l < n && array[p] < array[l]) { p = l; } //오른쪽 자식노드 if (r < n && array[p] < array[r]) { p = r; } //부모노드 < 자식노드 if(i != p) { swap(array, p, i); heapify(array, n, p); } }
 

기수 정렬(Radix Sort)

기수 정렬은 값을 비교하지 않는다는 점에서 특이한 정렬 알고리즘이다. 기수 정렬은 값을 놓고 비교할 자릿수를 정한 다음 해당 자릿수만 비교한다. 자릿수의 값 별로 정렬 하기 때문에, 값의 최대 size는 9이다. 데이터 자릿수를 k라 할때, 기수 정렬의 시간 복잡도는 O(kn)이다.
아래 예시를 보며 기수 정렬을 이해해보자.
출처 : 김종관,  「Do it! 알고리즘 코딩 테스트: 자바 편」, 이지스퍼블리싱, 2022, 138쪽
출처 : 김종관, 「Do it! 알고리즘 코딩 테스트: 자바 편」, 이지스퍼블리싱, 2022, 138쪽
먼저 주어진 배열을 일의 자릿수 기준으로 큐(Queue)에 삽입한다. 그런 다음 각 큐의 0번째 값부터 9번째 값까지 pop(맨 앞 원소 삭제)을 해준다. 그럼 [80, 03, 23, 24, 16, 77, 18, 88]의 배열이 만들어진다. 마찬가지로, 십의 자릿수를 기준으로 하여 위 과정을 진행한다. 이런 식으로 마지막 자릿수를 기준으로 정렬할 때까지 반복하면 된다. 여기서는 값들이 두 자릿수이니 십의 자릿수 기준으로 진행하면 최종 정렬 데이터를 얻을 수 있다.
이 과정을 코드로 구현하면 다음과 같다. 코드로 구현할 때는, 큐 자료구조 대신 count라는 배열을 이용해 n자릿수 별 데이터를 저장했다.
public static void radixSort(int[] array) { // 최대 자릿수를 구하기 위한 최대값 찾기 int max = getMax(array); // 자릿수별로 정렬 for (int exp = 1; max / exp > 0; exp *= 10) { countingSortByDigit(array, exp); } } private static int getMax(int[] array) { int max = array[0]; for (int num : array) { if (num > max) { max = num; } } return max; } private static void countingSortByDigit(int[] array, int exp) { int n = array.length; int[] output = new int[n]; int[] count = new int[10]; // 자릿수는 0부터 9까지 // 해당 자릿수의 개수 세기 for (int i = 0; i < n; i++) { int digit = (array[i] / exp) % 10; count[digit]++; } // 위치 계산 (누적합 구하기) for (int i = 1; i < 10; i++) { count[i] += count[i - 1]; } // 출력 배열에 정렬된 값 배치 (계수 정렬(counting sort) 과정) for (int i = n - 1; i >= 0; i--) { int digit = (array[i] / exp) % 10; output[count[digit] - 1] = array[i]; count[digit]--; } // 정렬된 배열을 원래 배열에 복사 for (int i = 0; i < n; i++) { array[i] = output[i]; } }

댓글

guest