Sorting Algorithms in C++
In-depth Analysis of Sorting Techniques with Examples, Pros, Cons, and Time Complexity
1. 버블 정렬 (Bubble Sort)
버블 정렬은 인접한 두 원소를 비교해 교환하며 정렬하는 알고리즘이다. 가장 큰 원소가 반복적으로 뒤로 이동하는 특성이 있다.
- 시간 복잡도: O(n^2) (평균, 최악)
- 장점: 구현이 간단하고, 정렬된 경우 비교 횟수가 줄어든다.
-
단점: 데이터가 많을 경우 비효율적이다.
-
예시: 5, 2, 9, 1을 버블 정렬로 정렬할 때, 첫 번째 패스에서 5와 2, 9와 1이 교환된다.
- C++ 코드 예시:
void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) for (int j = 0; j < n - i - 1; j++) if (arr[j] > arr[j + 1]) swap(arr[j], arr[j + 1]); }
2. 선택 정렬 (Selection Sort)
선택 정렬은 리스트에서 가장 작은 값을 찾아 맨 앞에 있는 값과 교환하는 작업을 반복해 정렬한다.
- 시간 복잡도: O(n^2) (평균, 최악)
- 장점: 코드가 단순하고 메모리 사용이 적다.
-
단점: 성능이 느려 큰 데이터에서는 비효율적이다.
-
예시: 4, 2, 9, 1을 선택 정렬할 때, 첫 번째 반복에서 1이 가장 작기 때문에 맨 앞으로 이동한다.
- C++ 코드 예시:
void selectionSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int minIdx = i; for (int j = i + 1; j < n; j++) if (arr[j] < arr[minIdx]) minIdx = j; swap(arr[i], arr[minIdx]); } }
3. 삽입 정렬 (Insertion Sort)
삽입 정렬은 정렬된 부분에 다음 원소를 삽입하며 정렬을 진행한다.
- 시간 복잡도: O(n^2) (평균, 최악)
- 장점: 대부분 정렬된 배열에 빠르게 작동한다.
-
단점: 성능이 느려 큰 데이터에 비효율적이다.
-
예시: 5, 2, 9, 1을 삽입 정렬할 때, 첫 번째 원소 5는 이미 정렬된 상태로 두고, 2를 삽입하여 재배열한다.
- C++ 코드 예시:
void insertionSort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }
4. 병합 정렬 (Merge Sort)
병합 정렬은 배열을 반으로 나누어 재귀적으로 정렬한 뒤 병합하는 방식이다.
- 시간 복잡도: O(n log n) (최악)
- 장점: 데이터 크기에 관계없이 안정적이다.
- 단점: 추가 메모리를 사용해야 한다.
병합 정렬(Merge Sort)은 분할 정복 기법을 이용하여 정렬을 수행하는 알고리즘이다. 이 알고리즘은 다음과 같은 단계를 거쳐 진행된다.
-
분할(Divide): 주어진 배열을 반으로 나누어 두 개의 하위 배열을 만든다. 이 과정을 재귀적으로 수행하여 하위 배열이 더 이상 나눌 수 없을 때까지 계속 진행한다.
-
정복(Conquer): 각 하위 배열이 정렬된 상태에서 두 개의 하위 배열을 병합하여 하나의 정렬된 배열로 만든다.
-
결합(Combine): 두 개의 정렬된 하위 배열을 하나의 정렬된 배열로 합친다.
예시
주어진 배열이 [38, 27, 43, 3, 9, 82, 10]일 때, 병합 정렬의 흐름은 다음과 같다.
- 분할 단계:
- [38, 27, 43, 3, 9, 82, 10] → [38, 27, 43]와 [3, 9, 82, 10]으로 나눈다.
- [38, 27, 43] → [38]와 [27, 43]으로 나눈다.
- [27, 43] → [27]와 [43]으로 나눈다.
- [3, 9, 82, 10] → [3, 9]와 [82, 10]으로 나눈다.
- [3, 9] → [3]와 [9]으로 나눈다.
- [82, 10] → [82]와 [10]으로 나눈다.
- 정복 및 결합 단계:
- [27]과 [43]을 병합하여 [27, 43]을 만든다.
- [38]와 [27, 43]을 병합하여 [27, 38, 43]을 만든다.
- [3]과 [9]을 병합하여 [3, 9]을 만든다.
- [82]와 [10]을 병합하여 [10, 82]을 만든다.
- [3, 9]과 [10, 82]을 병합하여 [3, 9, 10, 82]을 만든다.
- 마지막으로, [27, 38, 43]와 [3, 9, 10, 82]을 병합하여 [3, 9, 10, 27, 38, 43, 82]을 만든다.
-
C++ 코드 예시:
void merge(int arr[], int left, int mid, int right) { int n1 = mid - left + 1, n2 = right - mid; int L[n1], R[n2]; for (int i = 0; i < n1; i++) L[i] = arr[left + i]; for (int j = 0; j < n2; j++) R[j] = arr[mid + 1 + j]; int i = 0, j = 0, k = left; while (i < n1 && j < n2) arr[k++] = (L[i] <= R[j]) ? L[i++] : R[j++]; while (i < n1) arr[k++] = L[i++]; while (j < n2) arr[k++] = R[j++]; } void mergeSort(int arr[], int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); }
5. 퀵 정렬 (Quick Sort)
퀵 정렬은 피벗을 기준으로 작은 값과 큰 값으로 나누어 재귀적으로 정렬하는 방식이다.
- 시간 복잡도: O(n log n) (평균), O(n^2) (최악)
- 장점: 평균적으로 빠르며 추가 메모리가 필요하지 않다.
- 단점: 최악의 경우 비효율적이다.
퀵 정렬(Quick Sort)은 분할 정복 기법을 사용하는 효율적인 정렬 알고리즘이다. 이 알고리즘은 다음과 같은 단계를 거쳐 진행된다.
-
피벗 선택(Pivot Selection): 주어진 배열에서 피벗이라고 불리는 요소 하나를 선택한다. 피벗은 배열의 요소 중 하나로, 정렬 과정에서 기준점 역할을 한다.
-
분할(Partitioning): 배열의 요소들을 피벗을 기준으로 두 개의 하위 배열로 나눈다. 피벗보다 작은 요소들은 왼쪽 배열로, 큰 요소들은 오른쪽 배열로 이동한다.
-
재귀 호출(Recursive Calls): 왼쪽과 오른쪽의 하위 배열에 대해 재귀적으로 퀵 정렬을 수행한다.
-
결합(Combining): 모든 재귀 호출이 완료되면, 정렬된 배열이 생성된다.
예시
주어진 배열이 [38, 27, 43, 3, 9, 82, 10]일 때, 퀵 정렬의 흐름은 다음과 같다.
- 피벗 선택:
- 배열의 첫 번째 요소인 38을 피벗으로 선택한다.
- 분할:
- 38보다 작은 요소는 [27, 3, 9, 10]이고, 큰 요소는 [43, 82]이다.
- 분할된 결과는 [27, 3, 9, 10] (피벗보다 작은 부분) + [38] (피벗) + [43, 82] (피벗보다 큰 부분)이다.
- 재귀 호출:
- 왼쪽 배열 [27, 3, 9, 10]에 대해 퀵 정렬을 수행한다. 여기서 27을 피벗으로 선택하고, 다시 분할한다.
- 27보다 작은 요소는 [3, 9, 10], 큰 요소는 없다.
- 분할된 결과는 [3, 9, 10] + [27]이다.
- [3, 9, 10]에 대해 퀵 정렬을 수행하면, 3을 피벗으로 선택하고 다시 분할한다.
- 3보다 작은 요소는 없고, 큰 요소는 [9, 10]이다.
- 결과는 [3] + [9, 10]이다.
- [9, 10]에 대해 퀵 정렬을 수행하면, 9을 피벗으로 선택하고 결과는 [9] + [10]이 된다.
- 최종적으로 왼쪽 배열은 [3, 9, 10, 27]이 된다.
- 왼쪽 배열 [27, 3, 9, 10]에 대해 퀵 정렬을 수행한다. 여기서 27을 피벗으로 선택하고, 다시 분할한다.
- 오른쪽 배열 정렬:
- 오른쪽 배열 [43, 82]는 피벗 43으로 분할되어 [43] + [82]로 결과가 정렬된다.
모든 재귀 호출이 완료된 후, 최종적으로 정렬된 배열은 [3, 9, 10, 27, 38, 43, 82]이다.
- C++ 코드 예시:
int partition(int arr[], int low, int high) { int pivot = arr[high]; int i = (low - 1); for (int j = low; j <= high - 1; j++) { if (arr[j] <= pivot) swap(arr[++i], arr[j]); } swap(arr[i + 1], arr[high]); return (i + 1); } void quickSort(int arr[], int low, int high) { if (low < high) { int pi = partition(arr, low, high); quickSort(arr, low, pi - 1); quickSort(arr, pi + 1, high); } }
6. 힙 정렬 (Heap Sort)
힙 정렬은 최대 힙이나 최소 힙을 구성한 뒤, 루트 값을 제거하면서 정렬한다.
- 시간 복잡도: O(n log n) (평균, 최악)
- 장점: 추가 메모리가 필요하지 않다.
- 단점: 힙 구성에 시간이 걸린다.
힙 정렬(Heap Sort)은 완전 이진 트리 형태인 힙(Heap) 자료구조를 이용하여 정렬을 수행하는 알고리즘이다. 이 알고리즘은 다음과 같은 단계를 거쳐 진행된다.
-
힙 구성(Build Heap): 주어진 배열을 힙 구조로 변환한다. 일반적으로 최대 힙(Max Heap)을 사용하며, 최대 힙은 부모 노드가 자식 노드보다 크거나 같은 구조를 가진다.
-
최대 원소 추출(Remove Max): 배열의 첫 번째 원소(최대 원소)를 추출하고 배열의 마지막 원소와 교환한 후, 나머지 원소에 대해 힙 구조를 유지한다.
-
재구성(Heapify): 교환 후 남은 원소에 대해 힙을 재구성하여 다시 최대 힙 구조를 유지하도록 한다.
-
반복: 이 과정을 남아 있는 원소가 없을 때까지 반복하여 정렬된 배열을 얻는다.
예시
주어진 배열이 [38, 27, 43, 3, 9, 82, 10]일 때, 힙 정렬의 흐름은 다음과 같다.
- 힙 구성:
- 주어진 배열을 최대 힙으로 변환한다.
- 결과적인 최대 힙은 다음과 같다:
82 / \ 38 43 / \ / \ 3 9 10 27
- 이때, 배열 표현은 [82, 38, 43, 3, 9, 10, 27]이다.
- 최대 원소 추출:
- 최대 원소인 82를 배열의 마지막 원소와 교환한다.
- 교환 후 배열은 [27, 38, 43, 3, 9, 10, 82]가 된다.
- 재구성(Heapify):
- 남아 있는 원소들에 대해 최대 힙을 재구성한다.
- 결과적으로 [43, 38, 27, 3, 9, 10] 형태의 최대 힙이 된다.
- 반복:
- 최대 원소 43을 추출하여 [10, 38, 27, 3, 9, 43, 82]로 만들고, 다시 힙 재구성하여 [38, 10, 27, 3, 9]이 된다.
- 계속해서 38, 27, 10, 9, 3을 차례로 추출하여 정렬한다.
모든 과정을 마친 후, 정렬된 배열은 [3, 9, 10, 27, 38, 43, 82]가 된다.
- C++ 코드 예시:
void heapify(int arr[], int n, int i) { int largest = i; int left = 2 * i + 1; int right = 2 * i + 2; if (left < n && arr[left] > arr[largest]) largest = left; if (right < n && arr[right] > arr[largest]) largest = right; if (largest != i) { swap(arr[i], arr[largest]); heapify(arr, n, largest); } } void heapSort(int arr[], int n) { for (int i = n / 2 - 1; i >= 0; i--) heapify(arr, n, i); for (int i = n - 1; i > 0; i--) { swap(arr[0], arr[i]); heapify(arr, i, 0); } }
7. 기수 정렬 (Radix Sort)
기수 정렬은 자릿수를 기준으로 정렬하여 큰 숫자도 처리할 수 있는 알고리즘이다.
- 시간 복잡도: O(d*(n + k)) (d는 자릿수, k는 자릿수 범위)
- 장점: 특정 데이터 집합에서 빠르다.
- 단점: 자릿수 처리에 제약이 있다.
기수 정렬(Radix Sort)은 숫자를 자릿수 단위로 분리하여 정렬하는 알고리즘이다. 주로 정수나 문자열과 같은 고정된 길이의 데이터를 정렬하는 데 사용된다. 기수 정렬은 안정적인 정렬 알고리즘으로, 다음과 같은 단계를 거쳐 진행된다.
-
최대 자릿수 계산: 주어진 배열에서 가장 큰 숫자를 찾아 그 숫자의 자릿수를 계산한다.
-
자릿수별 정렬: 각 자릿수(1의 자리, 10의 자리 등)에 대해 개별적으로 정렬을 수행한다. 일반적으로 가장 낮은 자릿수부터 시작하여 높은 자릿수로 진행한다. 이때 계수 정렬(Counting Sort)을 사용하여 각 자릿수를 정렬한다.
-
반복: 모든 자릿수에 대해 정렬을 완료할 때까지 반복한다.
예시
주어진 배열이 [170, 45, 75, 90, 802, 24, 2]일 때, 기수 정렬의 흐름은 다음과 같다.
- 최대 자릿수 계산:
- 주어진 배열에서 최대값은 802이며, 이 숫자는 3자리이다. 따라서 1의 자리, 10의 자리, 100의 자리 순으로 정렬한다.
- 자릿수별 정렬:
- 1의 자리 정렬:
- 배열을 1의 자리 기준으로 정렬하면 [170, 90, 802, 24, 45, 75, 2]가 된다.
- 10의 자리 정렬:
- 배열을 10의 자리 기준으로 정렬하면 [170, 802, 24, 45, 2, 75, 90]가 된다.
- 100의 자리 정렬:
- 배열을 100의 자리 기준으로 정렬하면 [2, 24, 45, 75, 90, 170, 802]가 된다.
- 1의 자리 정렬:
- 최종 결과: 모든 자릿수 정렬이 완료된 후, 최종적으로 정렬된 배열은 [2, 24, 45, 75, 90, 170, 802]가 된다.
-
C++ 코드 예시:
void countingSort(int arr[], int n, int exp) { int output[n], count[10] = {0}; for (int i = 0; i < n; i++) count[(arr[i] / exp) % 10]++; for (int i = 1; i < 10; i++) count[i] += count[i - 1]; for (int i = n - 1; i >= 0; i--) { output[count[(arr[i] / exp) % 10] - 1] = arr[i]; count[(arr[i] / exp) % 10]--; } for (int i = 0; i < n; i++) arr[i] = output[i]; } void radixSort(int arr[], int n) { int max = *max_element(arr, arr + n); for (int exp = 1; max / exp > 0; exp *= 10) countingSort(arr, n, exp); }
이와 같은 정렬 알고리즘들은 각각 장단점과 다양한 시간 복잡도를 가지고 있다. 데이터의 성질과 크기에 따라 적합한 알고리즘을 선택하는 것이 중요하다.