알고리즘에서 복잡도는 시간복잡도와 공간복잡도로 나뉘어진다.
시간복잡도란 '문제를 해결하는 데 걸리는 시간과 입력의 함수 관계'를 말하며, 특정 알고리즘의 로직이 입력의 크기에 따라 얼마나 오랜 시간 소요되는지를 의미한다. 빅오 표기법이 주로 사용되며, 이러한 시간복잡도 표기를 통해 알고리즘이 수행되기 위해 필요한 연산의 횟수를 가늠하고, 성능의 지표로 활용할 수 있다.
공간복잡도는 특정한 크기의 입력에 대해 알고리즘이 얼마나 많은 메모리를 차지하는지를 의미하며, 시간복잡도와 동일하게 빅오 표기법으로 주로 표기한다.
빅오 표기법이란
간단히 말하면 가장 빠르게 증가하는 항만을 고려하는 표기법이다.
예를 들어 N개의 입력 데이터가 존재하고, 어떤 알고리즘의 연산 횟수가 이라고 할 때, N이 증가함에 따라 의 영향력은 급격히 증가하지만 나머지 항의 영향은 감소하기 때문에, 이를 간소화하여 으로 표기한다.
그러나 N의 크기가 작다면 상수항의 영향력이 더 크기 때문에 표기법이 항상 절대적인 것은 아니며, 생략되는 항이 있기 때문에 빅오 표기법이 같은 알고리즘이라도 내부 로직 등에 따라 실제 수행되는 연산 횟수와 속도는 다를 수 있다.다음은 주로 등장하는 시간 복잡도의 표이다.
빅오 표기법 명칭 상수 시간(Constant time) 로그 시간(Log time) 선형 시간 로그 선형 시간 이차 시간 지수 시간
정렬은 순서의 유지 여부에 따라 두 가지로 나눌 수 있는데, 같은 값을 가지는 요소가 초기의 순서를 유지한다면 안정 정렬, 그렇지 않다면 불안정 정렬로 구분할 수 있다.
불안정 정렬에는 대표적으로 퀵 정렬, 힙 정렬 등이 있으며, 안정 정렬에는 버블 정렬, 삽입 정렬, 병합 정렬이 속한다. 후술할 병합 정렬과 삽입 정렬이 결합된 팀 정렬 또한 안정 정렬에 속한다.
실행되기 전의 프로그램은 보조 기억장치(SSD, HDD)에 저장되어 있다가 실행되면 프로세스가 되어 주 기억장치(RAM)에 올라오게 된다. 이 때 프로세스는 Text Section, Data Section 등 여러 부분으로 나뉘어 각각 실행 코드, 전역 변수 등의 정보를 담게 된다.
CPU는 프로세스를 처리하며 프로세스 내부의 정보가 필요하면 주 기억장치에 접근하게 되는데, 일반적으로 CPU의 처리 속도가 RAM보다 현저히 빠르기 때문에 매번 기억장치로부터 정보를 읽어온다면 성능 저하가 발생하게 된다.
이를 해결하기 위해 CPU 내부 또는 외부에 용량은 적지만 속도가 빠른 저장공간을 마련하여 특정 관리 규칙에 따라 자주 참고하게 될지도 모르는 정보들을 CPU 근처에 보관하게 된다. 이러한 저장 공간을 캐시(Cache) 라고 하며, CPU는 어떤 정보를 필요로 할 때 우선적으로 캐시 메모리를 탐색하고 없다면 주 기억장치에서 가져오게 된다.
어떤 알고리즘을 수행하는 과정에서 이전에 참조한 값 또는 연관된 값에 반복적으로 접근하여 해당 정보를 주 기억장치가 아닌 캐시 메모리에서 주로 읽어온다면 이를 참조 지역성이 좋다고 표현하며, 보다 빠른 처리 속도를 보이게 된다.
병합정렬 또는 합병정렬은 분할 정복과 비교를 통한 정렬 알고리즘이다.
JavaScript (오름차순 정렬)
function mergeSort(array, start = 0, end = array.length - 1) {
if (start >= end) return;
const middle = divide(array, start, end);
merge(array, start, end, middle);
}
function divide(array, start, end) {
const middle = Math.floor((start + end) / 2);
mergeSort(array, start, middle);
mergeSort(array, middle + 1, end);
return middle;
}
function merge(array, start, end, middle) {
const temp = [];
let left = start;
let right = middle + 1;
// conquer & combine
while (left <= middle && right <= end) {
if (array[left] > array[right]) temp.push(array[right++]);
else temp.push(array[left++]);
}
while (left <= middle) {
temp.push(array[left++]);
}
while (right <= end) {
temp.push(array[right++]);
}
// copy
for (let i = 0; i < temp.length; i++) {
array[start + i] = temp[i];
}
}
퀵 정렬은 병합 정렬과 마찬가지로 분할 정복과 비교를 통한 정렬 알고리즘이다.
JavaScript (오름차순 정렬, pivot값 중앙 선정)
function quickSort(array, start = 0, end = array.length - 1) {
if (start >= end) return;
const mid = Math.floor((start + end) / 2);
const pivot = array[mid];
let left = start + 1;
let right = end;
swap(array, start, mid);
while (left <= right) {
while (left <= end && array[left] <= pivot) {
left++;
}
while (right > start && array[right] >= pivot) {
right--;
}
if (left > right) {
swap(array, start, right);
break;
} else {
swap(array, left, right);
}
}
quickSort(array, start, right - 1);
quickSort(array, right + 1, end);
}
function swap(array, aIdx, bIdx) {
const temp = array[aIdx];
array[aIdx] = array[bIdx];
array[bIdx] = temp;
}
힙 정렬은 주어진 배열을 힙 자료구조의 특징을 만족하도록 하는 Heapify 과정을 반복하여 정렬하는 방법이다.
1 - 1. i 번째 값을 (i * 2 + 1) 번째 값과 비교하여 더 작다면/크다면 교환한다.
1 - 2. i 번째 값을 (i * 2 + 2) 번째 값과 비교하여 더 작다면/크다면 교환한다.
2. 배열의 모든 원소에 대하여 수행한다.
JavaScript (최소 힙)
function heapSort(array) {
// 초기 최대 힙 구현.
for (let i = array.length - 1; i >= 0; i--) {
heapify(array, array.length - 1, i);
}
// 최대의 크기를 가진 루트 노드를 리프 노드로 옮긴 뒤, 갱신된 위치를 제외하고 다시 최대 힙 구현.
for (let i = array.length - 1; i >= 1; i--) {
swap(array, 0, i);
heapify(array, i - 1);
}
}
function heapify(array, length, idx = 0) {
while (true) {
const leftChild = idx * 2 + 1;
const rightChild = idx * 2 + 2;
if (length >= rightChild) {
// 자식 노드가 두 개일 경우 더 큰 노드와 비교 (최대 힙)
const maxChild = (array[leftChild] < array[rightChild]) ? rightChild : leftChild;
if (array[idx] < array[maxChild]) {
swap(array, idx, maxChild);
idx = maxChild;
} else {
break;
}
} else if (length === leftChild) {
// 자식 노드가 하나인 경우 해당 자식과 비교.
if (array[idx] < array[leftChild]) {
swap(array, idx, leftChild);
} else {
break;
}
} else {
// 리프 노드라면 정렬 종료.
break;
}
}
}
function swap(array, aIdx, bIdx) {
const temp = array[aIdx];
array[aIdx] = array[bIdx];
array[bIdx] = temp;
}
팀 정렬은 병합 정렬과 삽입 정렬이 조합된 하이브리드 정렬 알고리즘이다.
삽입 정렬은 기본적으로 의 시간 복잡도를 가지는 정렬 알고리즘이지만, 인접 메모리와의 반복 비교를 통한 정렬 방식으로 참조 지역성을 매우 잘 만족한다. 때문에 시간 복잡도 표기에서 생략되어 있는 상수 를 고려하여 다른 의 알고리즘(Quick sort)와 비교해보면, 의 값이 작을 경우 상수 가 더 작은 삽입 정렬의 경우가 유리하다는 아이디어에 기반한다.
Run
하나의 Run의 크기가 커질 수록 그 길이만큼의 병합 과정이 생략되기 때문에 더 높은 성능을 기대할 수 있다. 이를 위해 초기 두 개의 원소를 비교하여 오름차순 또는 내림차순을 결정한 뒤, 크기가 될 때까지 삽입정렬을 수행한다. 그 다음, 이후의 원소가 계속해서 정렬 기준을 만족할 경우 Run에 포함시킨다. 이후 내림차순으로 정렬된 Run은 뒤집어 오름차순으로 변환한다.
Binary Insertion sort
Run을 생성할 때 앞의 원소들은 이미 정렬되어 있다는 전제를 기반으로 이분탐색을 통해 원소를 삽입할 위치를 찾는다. 의 비교 대신 의 비교를 통해 시간 절약을 기대할 수 있다.
Merge
앞서 minRun까지의 삽입정렬 후 추가적인 병합을 통해 만들어진 각각의 Run들은 크기가 제각각으로 효율적인 병합 정렬을 위해 최대한 비슷한 크기의 덩어리끼리 병합되도록 조정한다.
병합되어야 할 Run들의 스택 상위 3개의 A, B, C Run이 있다고 할 때, 다음과 같은 조건을 확인한 뒤, 만족하지 않을 경우 중간 Run인 B를 A와 C 중 더 작은 Run과 병합한다.
또한 병합되는 배열의 크기인 만큼의 추가 메모리를 필요로하는 병합 정렬의 특징을 해결하기 위해 두 Run 중 작은 Run을 복사한 뒤, 각 Run의 시작부분부터 병합정렬을 수행한다. 이 경우, 최대 의 추가 메모리로도 병합을 진행할 수 있다.
Galloping
병합을 진행하는 과정에서 MIN_GALLOP번 연속으로 한 Run에서만 병합이 이뤄질 경우, $2^k$만큼 뛰어 넘으며 대소비교를 진행 후, 이분 탐색을 통해 삽입 위치를 결정한다. 이후 하나의 Run에서 연속적으로 병합되지 않으면 다시 하나씩 비교하며, 실 사용에서는 Galloping이 반복될 경우 MIN_GALLOP이 감소하고 반대의 경우 증가하는 등의 보다 효율적으로 동작하게 된다.
자바스크립트의 Array.prototype.sort()
메소드는 각 엔진이 어떻게 구현했는지에 따라 사용되는 정렬 알고리즘이 다르다. 그러나 ECMA2019
에서 안정 정렬을 보장하게 되어, 인터넷 익스플로러를 제외한 모든 브라우저에서 Array.prototype.sort()
는 안정 정렬로 동작하게 되었다.
크롬 브라우저의 V8 엔진은 Tim sort를 사용하고 있으며, 파이어폭스 브라우저의 SpiderMonkey 엔진은 Merge sort를 사용하고 있다.