인접한 두 개의 데이터를 비교해가면서 정렬을 진행하는 방식
정렬의 우선순위가 가장 낮은, 제일 큰 값을 맨 뒤로 보내기
void BubbleSort(int arr[], int n)
{
int i, j;
int temp;
for(i = 0; i < n-1; i++)
{
for(j = 0; j < n-1-i; j++)
{
if(arr[j] > arr[j+1])
{
//데이터의 교환
temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}
버블 정렬에서 데이터의 수가 n개일 때
(n-1) + (n-2) + … + 2 + 1
즉, 최선의 경우와 최악의 경우 모두 O(n^2)
데이터가 이미 정렬된 상태라면 데이터의 이동이 한 번도 일어나지 않지만, 데이터가 역순으로 정렬된 상태라면 비교의 횟수와 이동의 횟수가 일치한다
즉, 최악의 경우 O(n^2)이다.
정렬 순서에 맞게 하나씩 선택해서 옮기는 정렬
선택 정렬은 정렬 결과를 담을 별도의 메모리 공간이 필요하다
이는 개선된 알고리즘으로 해결할 수 있다
정렬순서상 가장 앞서는 것을 선택해 가장 왼쪽으로 이동시키고, 원래 그 자리에 있던 데이터는 빈 자리에 가져다 놓는다
void SelSort(int arr[], int n)
{
int i, j;
int maxIdx;
int temp;
for(i = 0; i < n-1; i++)
{
maxIdx = i;
for(j = i+1; j < n; j++)
{
if(arr[j] < arr[maxIdx])
maxIdx = j;
}
temp = arr[i];
arr[i] = arr[maxIdx];
arr[maxIdx] = temp;
}
}
비교의 횟수
(n-1) + (n-2) + . . . + 2 + 1
즉, 최선의 경우와 최악의 경우 모두 O(n^2)
연산의 횟수
선택 정렬의 경우 데이터 연산 코드가 바깥쪽 for문에 있기 때문에 (n-1)회의 교환이 이루어지고, 코드 당 데이터가 3회 이동하므로 3(n-1)이다
즉, 최선의 경우와 최악의 경우 모두 O(n)
최악의 경우 버블정렬과 비교했을 때 더 좋은 성능을 기대할 수 있지만, 버블 정렬은 최선의 경우에 단 한번도 데이터 이동이 없는 점과 실제로 데이터들이 늘 최악의 경우로 배치되어 있지 않다는 점을 감안하면 둘의 비교는 무의미하다
- 정렬이 완료된 영역의 다음에 위치한 데이터가 그 다음 정렬대상이다
- 삽입할 위치를 발견하고 데이터를 한 칸씩 뒤로 밀수도 있지만, 데이터를 한 칸씩 뒤로 밀면서 삽입할 위치를 찾을 수도 있다
void InserSort(int arr[], int n)
{
int i, j;
int insData;
for(i = 1 ; i < n; i++)
{
insData = arr[i]; //정렬 대상을 insData에 저장
for(j = i - 1; j >= 0; j--)
{
if(arr[j] > insData)
arr[j+1] = arr[j]; //비교대상 한 칸 뒤로 밀기
else
break; //삽입위치 찾았으니 탈출!
}
arr[j+1] = insData; //찾은 위치에 정렬대상 삽입!
}
}
1 + 2 + 3 + . . . + (n-2) + (n-1) 만큼 계산되므로
최선의 경우, 최악의 경우 모두 O(n^2)
#include "UsefulHeap.h"
int PriComp(int n1, int n2)
{
return n2-n1; //오름차순 정렬을 위한 문장
}
void HeapSort(int arr[], int n, PriorityComp pc)
{
Heap heap;
int i;
HeapInit(&heap, pc);
//정렬 대상을 가지고 힙을 구성한다
for(i = 0; i < n; i++)
arr[i] = HDelete(&heap);
}
내림차순 정렬을 진행할 때
: PriComp함수 변경
int PriComp(int n1, int n2)
{
return n1-n2;
}
n^2과 nlog2(n)의 차이
분할 정복(divide and conquer)이라는 알고리즘 디자인 기법에 근거하여 만들어진 정렬 방법
8개의 데이터를 동시에 정렬하는 것보다, 이를 둘로 나눠서 4개의 데이터를 정렬하는 것이 더 쉽고, 또 이들을 각각을 다시 한 번 둘로 나눠서 2개의 데이터를 정렬하는 것이 더 쉽다
void MergeSort(int arr[], int left, int right)
{
int mid;
if(left < right) //left가 작다는 것은 더 나눌 수 있다는 뜻!
{
//중간지점을 계산한다
mid = (left + right) / 2;
//둘로 나눠서 각각을 정렬한다
MergeSort(arr, left, mid); //left~mid에 위치한 데이터 정렬!
MergeSort(arr, mid+1, right); //mid+1~right에 위치한 데이터 정렬!
//정렬된 두 배열을 병합한다
MergeTwoArea(arr, left, mid, right);
}
}
void MergeTwoArea(int arr[], int left, int mid, int right)
{
int fIdx = left;
int rIdx = mid+1;
int i;
int *sortArr = (int*)malloc(sizeof(int)*(right+1));
int sIdx = left;
while(fIdx <= mid && rIdx <= right)
{
if(arr[fIdx] <= arr[rIdx])
sortArr[sIdx] = arr[fIdx++];
else
sortArr[sIdx] = arr[rIdx++];
sIdx++;
}
if(fIdx > mid)
{
for(i = rIdx; i <= right; i++, sIdx++)
sortArr[sIdx] = arr[i];
}
else
{
for(i = fIdx; i<=mid; i++, sIdx++)
sortArr[sIdx] = arr[i];
}
for(i = left; i <= right; i++)
arr[i] = sortArr[i];
free(sortArr);
}
비교연산
정렬의 대상인 데이터의 수가 n개 일 때, 각 병합의 단계마다 최대 n번의 비교연산이 진행된다
즉, 최대 비교연산의 횟수는 O(nlog2(n))
이동연산
- 임시 배열에 데이터를 병합하는 과정에서 데이터 이동
임시 배열에 저장된 데이터 전부를 원위치로 옮기는 과정에서 한 번
즉, 최악, 평균, 최선의 경우에 상관없이 2nlog2(n)이므로 O(nlog2(n))
병합 정렬은 임시 메모리가 필요하다는 단점이 있지만 연결 리스트일 경우 단점이 되지 않는다
low와 high의 이동 기준
left > right일 시 정렬 종료
이후, 병합 정렬처럼 재귀로 진행한다
void Swap(int arr[], int idx1, int idx2)
{
int temp = arr[idx1];
arr[idx1] = arr[idx2];
arr[idx2] = temp;
}
int Partition(int arr[], int left, int right)
{
int pivot = arr[left];
int low = left + 1;
int high = right;
while(low <= high)
{
while(pivot > arr[low])
low++;
while(pivot < arr[high])
high--;
if(low <= high)
Swap(arr, low, high);
}
Swap(arr, left, high);
return high;
}
void QuickSort(int arr[], int left, int right)
{
if(left <= right)
{
int pivot = Partition(arr, left, right);
QuickSort(arr, left, pivot - 1);
QuickSort(arr, pivot + 1, right);
}
}
같은 수가 중복이 되는 배열을 정렬할 경우 코드의 버그로 Partition함수를 빠져 나올 수 없다
만일, 3이 세 개인 배열이라면
pivot == arr[low] == arr[high]
가 항상 참이 된다.
따라서, Partition함수를
while(pivot >= arr[low] && low <= right)
low++;
while(pivot <= arr[high] && high >= (left + 1))
high--;
로 변경한다.
같은 수를 비교해도 low와 high를 증감하는 조건으로 변경한다. 이 때 low와 high가 배열의 정렬 범위를 넘어서는 일이 발생할 수 있으므로 경계검사를 위한 조건을 추가한다.
피벗이 중간에 해당하는 값일 경우, 정렬대상은 균등하게 나뉜다
한 쪽으로 쏠릴 경우
정렬대상에서 세 개의 데이터를 추출한다. 그리고 그 중에서 중간값에 해당하는 것을 피벗으로 선택한다
n번 비교를 한다. 분할을 하더라도 마찬가지이다.
Ex) 31개의 데이터를 정렬
- 31개의 데이터는 15개씩 둘로 나뉘어 총 2조각이 된다
- 이어서 각각 7개씩 둘로 나뉘어 총 4조각이 된다
- 이어서 각각 3개씩 둘로 나뉘어 총 8조각이 된다
- 이어서 각각 1개씩 둘로 나뉘어 총 16조각이 된다
즉, 평균적으로O(nlog2(n))이다
최악의 경우, 피벗이 가장 작은 값으로 결정되는 상황이므로 O(n^2)이다.
하지만 퀵 정렬은 평균적으로 제일 빠른 정렬이고, 다른 정렬과 비교했을 때 이동횟수가 상대적으로 적고 별도의 메모리를 요구하지 않는다.
기수 정렬은 정렬 순서상 앞서고 뒤섬의 판단을 위한 비교연산을 하지 않는다
정렬 알고리즘의 시간 복잡도의 한계는 O(nlog2(n))인데, 이를 뛰어넘을 수 있는 유일한 알고리즘이다
하지만, 데이터의 길이가 다를 경우 정렬이 가능하긴 하지만, 효율성이 떨어져 제한적이다
10진수 정렬
버킷 0~9까지에 해당하는 버킷으로 값을 이동시킨 후, 모든 데이터의 이동이 끝나면 0~9 순서대로 버킷에 있는 데이터를 꺼낸다
기수 정렬은 데이터를 구성하는 기본 요소, 즉 기수를 이용하는 정렬이다
LSD기수 정렬 : Least Significant Digit의 약자로 덜 중요한 자릿수부터 정렬을 해 나간다
5진수 정렬 과정
작은 자릿수에서 시작해서 큰 자릿수까지 모두 비교를 해야 값의 대소를 판단할 수 있다. 비교 중간에 대소를 판단하는 것은 불가능하다.
LSD의 정렬과정
MSD기수 정렬 : Most Significant Digit의 약자로 중요한 자릿수부터 정렬을 해 나간다
MSD와 LSD의 빅-오는 같다
#define BUCKET_NUM 10
void RadixSort(int arr[], int num, int maxLen)
{
// 매개변수 maxLen에는 정렬대상 중 가장 긴 데이터의 길이 정보가 전달
Queue buckets[BUCKET_NUM];
int bi;
int pos;
int di;
int divfac = 1;
int radix;
// 총 10개의 버킷 초기화
for(bi = 0; bi < BUCKET_NUM; bi++)
QueueInit(&buckets[bi]);
// 가장 긴 데이터의 길이만큼 반복
for(pos = 0; pos < maxLen; pos++)
{
//정렬대상의 수만큼 반복
for(di = 0; di < num; di++)
{
//N번째 자리의 숫자 추출
radix = (arr[di] / divfac) % 10;
//추출한 숫자를 근거로 버킷에 데이터 저장
Enqueue(&buckets[radix], arr[di]);
}
//버킷 수만큼 반복
for(bi = 0, di = 0; bi < BUCKET_NUM; bi++)
{
//버킷에 저장된 것 순서대로 다 꺼내서 다시 arr에 저장
while(!QIsEmpty(&buckets[bi]))
arr[di++] = Dequeue(&buckets[bi]);
}
// N번째 자리의 숫자 추출을 위한 피제수의 증가
divfac *= 10;
}
}
비교연산이 핵심이 아니고, 데이터의 삽입과 추출이 핵심이다
데이터의 삽입과 추출 : maxLen X num
정렬대상의 수가 n이고, 모든 정렬대상의 길이가 l이라 할 때, 시간복잡도는
O(ln)
이고, 빅-오 표기법에 의해서 O(n)으로 본다.
즉, 기수 정렬은 O(nlog2(n))인 퀵 정렬보다 뛰어난 O(n)의 성능을 보이지만 적용 가능한 대상이 한정적인 정렬이다.