코딩테스트나 사이드 프로젝트와 같이 프로그래밍을 하다 보면, 데이터를 효율적으로 정리하고 처리하는 것이 중요함을 점점 뼈저리게 느끼게 된다.
특히 알고리즘을 공부하면서 프로그래머스나 코드트리에 답변을 저장 후 제출하면 시간 초과와 같은 문제를 종종 마주하게 된다. 인터프리터 언어탓 이러한 문제를 마주할 때마다 시간 복잡도에 대한 기본적인 이해가 부족함을 깨닫게 된다.
또한 어떤 상황에서 어떤 정렬 방법이 유리하거나 불리한지 파악하는 것이 실전에서도 중요하다. 이번 글에서는 JavaScript
를 사용하여 공통적으로 O(N²)
의 시간 복잡도를 가지는 거품 정렬(Bubble Sort), 선택 정렬(Selection Sort), 삽입 정렬(Insertion Sort)을 직접 구현하고, 다양한 테스트 케이스를 통해 실험을 진행해 보았다. 이를 통해 각 알고리즘의 수행 시간이 왜 다르게 나오며, 특정 상황에서 어떤 알고리즘이 더 유리한지에 대해 탐구해 보겠다.
거품 정렬은 기본적인 아이디어부터가 매우 간단하다.
첫번째 값과 두번째 값을 비교하고, 이어서 두번째 값과 세번째 값을 비교하고,
세번째 값과 네번째 값을 비교하고 … n-1번째와 n번째 값을 비교하며 이 과정에서
순서가 맞지 않은 값은 서로 교환해준다.
이런 절차를 정렬이 될 때까지 반복한다.
여기에선 37을 제외하고 동일한 교환 과정을 반복하면 된다.
function bubbleSort(arr) {
let n = arr.length;
let swapped;
do {
swapped = false;
for (let i = 0; i < n - 1; i++) {
if (arr[i] > arr[i + 1]) {
let temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
swapped = true;
}
}
n--;
} while (swapped);
return arr;
}
하지만 교환 여부를 확인하기 위해 전체 값을 한 바퀴 돌아야하므로 상당히 비효율적이고 성능도 다른 알고리즘에 비해 떨어진다.
최악의 경우, 각 원소가 정렬될 최종 위치에 도달하기 전까지 반복적으로 비교 및 교환이 일어나야 하므로, 시간 복잡도는 N(N-1)/2
비교와 교환으로 O(N²)
이 된다.
가장 일상적인 방법이라 평가받는 알고리즘이다.
우선 선택 정렬에서는 전체 값 중에서 가장 작은 값을 찾아야 한다.
그 후 해당 값을 가장 맨 앞에 배치한다. (arr[0]
) 기존에 맨앞에 있던 숫자는 최솟값이 있었던 자리로 옮겨준다.
다음 차수에서는 첫번째 값을 제외하고 가장 작은 값을 찾아 두번째에 배치한다. (arr[1]
)
두번째, 세번째, … n-1번째 값을 제외하고 가장 작은 값을 찾아 정해진 위치에 배치하는 것을 반복한다.
function selectionSort(arr) {
let n = arr.length;
for (let i = 0; i < n-1; i++) {
let min = i;
for (let j = i+1; j < n; j++) {
if (arr[j] < arr[min]) {
min = j
}
}
let tmp = arr[i]
arr[i] = arr[min]
arr[min] = tmp
}
return arr;
}
선택 정렬의 경우 반드시 n-1
, n-2
, … ,2
,1
번의 비교 연산을 수행해야 한다. 즉, n * (n-1)/2
번의 연산을 필요로 한다. 이는 중간에 정렬된 상태라면 종료될 수 있는 거품 정렬과는 구분된다.
그러므로 시간복잡도는 O(N²)
이 된다.
삽입 정렬은 앞에서부터 순차적으로 보면서 상단에 있는 모든 원소가 정렬이 되어있다는 가정 하에 현재 원소의 위치를 삽입하는 정렬이다.
즉, 원소가 1개인 경우부터, 2번째 원소, 3번째 원소, ... n번째 원소까지 진행하며 앞의 원소들을 전부 오름차순을 유지하며 진행하는 방법이다.
예를 들어, 다음 사진과 같이 3번째 원소까지 정렬이 진행되었을 경우([1,11,14]
), 4번째 원소를 진행해본다고 가정을 해보자. 이때 삽입 정렬은, 앞 원소들은 (1~3번째까지) 정렬이 되어있는 상황이고, 이제 숫자 7을 적절한 위치에 넣어서 4개의 원소가 모두 오름차순 정렬이 되도록 만들려고 한다.
가장 먼저 현재 위치에 있던 7을 앞에 있는 원소 (여기서는 14) 와 비교하여 현재 위치의 7이 더 작다면 두 원소의 위치를 바꿔준다.
swipe가 일어난 후에도 앞에 원소(여기서는 11)과 비교 했을때 앞에 있는 원소가 더 크다면 두 원소의 위치를 또다시 바꿔준다.
앞에 있는 원소가 더 커지지 않는 경우에 도달한다면, 멈춘다.
function insertionSort(arr) {
let n = arr.length;
for (let i = 1; i < n; i++) {
let key = arr[i];
j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j+1] = arr[j]
arr[j] = key;
j--;
}
arr[j+1] = key;
}
return arr;
}
삽입 정렬의 경우, n개의 원소에 대해 값 삽입을 수행할때 2번째 원소의 경우엔 최대 1개의 원소를 이동, 3번째 원소의 경우엔 최대 2개의 원소를 이동 … n번째 원소까지 삽입하는 경우엔 최대 n-1
개의 원소가 이동해야 하므로 결과적으로 최대 n(n-1)/2
개의 원소가 이동해야 한다.
따라서 이 역시 버블, 선택과 동일하게 O(N²)
의 시간 복잡도가 나온다.
앞서 살펴본 거품, 선택, 삽입 정렬 알고리즘 시간복잡도는 O(N^2)라는 공통점이 있지만, 동작되는 원리를 살펴보면 실제 소요되는 시간은 많이 다르다는 것을 예상할 수 있다.
따라서 해당 실험은 거품 정렬(Bubble Sort), 선택 정렬(Selection Sort), 그리고 삽입 정렬(Insertion Sort)의 성능을 비교하여 각 알고리즘의 시간 복잡도 O(N^2) 가 실제로 다양한 데이터 배열 상태에서 어떻게 다르게 작동하는지를 평가하기 위함이다.
console.time
과 console.timeEnd
를 사용하여 각 정렬 알고리즘이 배열을 정렬하는 데 소요된 시간을 밀리초 단위로 측정했다.console.time(label)
: 특정 코드 실행 시간 측정을 시작한다. label
은 해당 타이머의 이름을 지정한다.console.timeEnd(label)
: console.time
으로 시작된 타이머를 멈추고, 시작점부터 종료점까지의 시간을 콘솔에 출력한다. 출력된 시간은 label
과 함께 나타나므로, 여러 타이머를 구분할 수 있다.실험에 사용된 입력 데이터는 다음과 같다:
[1, 2, 3, 4, 5, 6, 7]
[7, 6, 5, 4, 3, 2, 1]
[1, 1, 1, 1, 1, 1, 1, 1]
[23, 15, 38, 94, 62, 123, 243, 234, 112]
각 정렬 알고리즘에 대해 위의 네 가지 유형의 배열을 입력으로 제공하고, 각각에 대해 정렬을 수행할 때의 시작 시간과 종료 시간을 측정하여 정렬 소요 시간을 기록하다. 이를 통해 각 정렬 방식의 효율성을 평가하고, 특정 상황에서의 장단점을 분석하였다.
1.56ms
. 배열의 무작위 정렬 상태에서는 많은 비교와 교환 작업이 필요하므로 시간이 많이 소요된다.0.294ms
. 배열이 이미 정렬되어 있으면, 거의 어떠한 교환도 일어나지 않으므로 처리 시간이 크게 줄어든다.0.431ms
. 역순 배열에서는 최악의 경우가 발생하여 많은 교환이 필요하지만, 여전히 빠른 처리가 가능하다.0.155ms
. 모든 요소가 동일하면 교환이 필요 없으므로 가장 빠르다.정렬된 배열에서 거품 정렬은 다른 배열에 비해 매우 빠르게 작동한다. 이는 배열을 한 번 스캔하는 동안 어떤 교환도 일어나지 않기 때문이다. 거품 정렬 알고리즘은 교환이 한 번도 일어나지 않으면 정렬이 완료되었다고 판단하고 조기 종료할 수 있기 때문에, 이미 정렬된 배열에서는 최적의 경우 O(N)
의 시간 복잡도를 가진다.
요소가 전부 같은 배열 역시 정렬된 배열과 유사한 이유로 빠르게 처리된다. 모든 원소가 같기 때문에 교환이 필요 없다. 따라서 거품 정렬은 첫 번째 스캔에서 아무런 교환이 이루어지지 않아 바로 정렬이 완료된 것으로 인식하고 처리를 종료한다.
선택 정렬은 매 단계에서 전체 배열을 순회하여 현재 위치에 들어갈 최소 값을 찾고, 그 값을 현재 위치로 이동시키는 방식으로 작동한다. 이러한 특징 때문에, 배열의 초기 상태와는 상대적으로 독립적인 성능을 보였다.
0.312ms
. 배열 상태와 무관하게 일정한 비교 횟수가 필요.0.202ms
. 최소 검색이 간단하므로 약간 더 빠르다.0.506ms
. 각 단계에서 최소값을 찾기 위한 비교가 상대적으로 많이 필요.0.058ms
. 모든 값이 같기 때문에 비교는 역시나 빠르고 간단하다.2N(N−1)
로, 입력 배열의 상태에 상관없이 일정하다.N−1
번으로 고정되어 있다. 다시말해, 이는 배열의 상태에 관계없이 일정한 수의 교환이 이루어진다는 것을 의미한다.삽입 정렬은 각 반복에서 원소를 적절한 위치에 삽입하는 방식으로 정렬을 진행한다. 실험 결과는 다음과 같다:
0.125ms
. 중간 수준의 비교와 교환 작업이 필요하다.0.227ms
. 원소가 이미 적절한 위치에 있어 빠른 처리가 가능하다.0.062ms
. 각 원소가 최소값이 되어 삽입 위치를 찾는 것이 간단하다.0.173ms
. 비교가 적고 간단하여 빠른 정렬이 가능하다.N(N-1)/2
이다. 이는 배열이 역순으로 정렬되어 있을 때 발생한다.O(N)
으로 최적화된다.통상적으로 거품 정렬이 셋 중에서는 가장 느리지만 정렬된 배열의 경우, sorted의 값이 계속 true
이기 때문에(swipe를 할 필요 x) 시간이 엄청 빨라진다.
선택 정렬의 경우에는 배열의 상태와 상관 없이, 1번째로 작은 원소를 찾고, 2번째로 작은 원소를 찾고, 3번째로 작은 원소를 찾고, … 하는 과정을 거치기 때문에 배열이 중구난방이던 정렬된 상태던 동일한, 일관적인 시간을 보여준다.
삽입 정렬의 경우에는 셋 중에서는 가장 빠르나, 값이 역순으로 정렬되어 있는 경우 성능이 많이 떨어진다는 단점이 있다는 것을 알 수 있었다.
그러나 앞 배열에 값을 삽입하는 삽입 정렬의 특성상 이미 정렬된 배열에 추가적으로 값을 몇개 추가하여 정렬하는 경우에는 좋은 성능을 보여준다.
단순히 원리와 시간복잡도만 달달 외우는 것보다 직접 코드를 구현해보고 왜 그런지 테스트 케이스를 넣어보며 실험을 해보니 더 좋았다.
function bubbleSort(arr) {
let n = arr.length;
let swapped;
do {
swapped = false;
for (let i = 0; i < n - 1; i++) {
if (arr[i] > arr[i + 1]) {
let temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
swapped = true;
}
}
n--;
} while (swapped);
return arr;
}
function selectionSort(arr) {
let n = arr.length;
for (let i = 0; i < n - 1; i++) {
let min = i;
for (let j = i + 1; j < n; j++) {
if (arr[j] < arr[min]) {
min = j;
}
}
let tmp = arr[i];
arr[i] = arr[min];
arr[min] = tmp;
}
return arr;
}
function insertionSort(arr) {
let n = arr.length;
for (let i = 1; i < n; i++) {
let key = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
arr[j] = key;
j--;
}
arr[j + 1] = key;
}
return arr;
}
function testSorting(sortFunction, arr, description) {
console.time(`Sorting time for ${description}`);
console.log(description, ":", sortFunction([...arr])); // 배열을 복사하여 원본 데이터 유지
console.timeEnd(`Sorting time for ${description}`);
}
const arr = [23, 15, 38, 94, 62, 123, 243, 234, 112];
const arr1 = [1, 2, 3, 4, 5, 6, 7];
const arr2 = [7, 6, 5, 4, 3, 2, 1];
const arr3 = [1, 1, 1, 1, 1, 1, 1, 1];
console.log("### Bubble Sort Results ###");
testSorting(bubbleSort, arr, "랜덤 배열");
console.log();
testSorting(bubbleSort, arr1, "이미 정렬된 배열");
console.log();
testSorting(bubbleSort, arr2, "역순으로 정렬된 배열");
console.log();
testSorting(bubbleSort, arr3, "요소가 전부 똑같은 배열");
console.log();
console.log("\n### Selection Sort Results ###");
testSorting(selectionSort, arr, "랜덤 배열");
console.log();
testSorting(selectionSort, arr1, "이미 정렬된 배열");
console.log();
testSorting(selectionSort, arr2, "역순으로 정렬된 배열");
console.log();
testSorting(selectionSort, arr3, "요소가 전부 똑같은 배열");
console.log();
console.log("\n### Insertion Sort Results ###");
testSorting(insertionSort, arr, "랜덤 배열");
console.log();
testSorting(insertionSort, arr1, "이미 정렬된 배열");
console.log();
testSorting(insertionSort, arr2, "역순으로 정렬된 배열");
console.log();
testSorting(insertionSort, arr3, "요소가 전부 똑같은 배열");
console.log();