정렬 알고리즘은 데이터를 특정한 기준에 따라 정렬하는 알고리즘으로 정렬 알고리즘은 데이터 처리에서 매우 중요한 역할을 한다.
이번 글에서는 가장 많이 사용되고 또 헷갈릴 수 있는 정렬 알고리즘들에 대해 정리해보며 JavaScript 구현 코드까지 같이 작성해봤다.
function selectionSort(array) {
for (let i = 0; i < array.length - 1; i++) {
let minIndex = i;
for (let j = i + 1; j < array.length; j++) {
if (array[j] < array[minIndex]) {
minIndex = j;
}
}
if (minIndex !== i) {
[array[i], array[minIndex]] = [array[minIndex], array[i]];
}
}
return array;
}
arr = [4, 5, 3, 1, 2, 8, 7, 6];
arr = selectionSort(arr);
console.log(arr); // [1, 2, 3, 4, 5, 6, 7, 8]
function insertionSort(array) {
for (let i = 1; i < array.length; i++) {
for (let j = i; j > 0; j--) {
if (array[j] < array[j - 1]) {
[array[j], array[j - 1]] = [array[j - 1], array[j]];
} else {
break;
}
}
}
return array;
}
arr = [4, 5, 3, 1, 2, 8, 7, 6];
arr = insertionSort(arr);
console.log(arr); // [1, 2, 3, 4, 5, 6, 7, 8]
피벗의 값은 임의의 값으로 해도 상관은 없다.
보통은 리스트에서 첫 번째 데이터를 피벗으로 정한다.(호어 분할, Hoare Partition)
function quickSort(arr) {
if (arr.length <= 1) return arr;
const pivot = arr[0]; //피벗을 리스트의 첫 번째 데이터로 정한다.
const left = [];
const right = [];
for (let i = 1; i < arr.length; i++) {
if (arr[i] < pivot) {
left.push(arr[i]);
} else {
right.push(arr[i]);
}
}
const sortedLeft = quickSort(left);
const sortedRight = quickSort(right);
return [...sortedLeft, pivot, ...sortedRight];
}
const arr = [4, 5, 3, 1, 2, 8, 7, 6];
const sortedArr = quickSort(arr);
console.log(sortedArr); // [1, 2, 3, 4, 5, 6, 7, 8]
즉 계수 정렬은 데이터의 크기가 한정되어 있고, 데이터의 크기가 많이 중복되어 있을수록 유리하며 항상 사용할 수는 없기 때문에 데이터의 특성을 파악할 수 없다면 퀵정렬을 이용하는 것이 편리하다.
[4, 5, 1, 3, 2, 0]라는 배열을 계수 정렬한다고 가정했을때 [0, 0, 0, 0, 0, 0]으로 초기화된 새로운 배열을 만든다.
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 |
배열을 순회하며 각 숫자가 몇 번 나왔는지 계수 배열에 기록한다.
[4, 5, 1, 3, 2, 0]
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
0개 | 0개 | 0개 | 0개 | 1개 | 0개 |
[4, 5, 1, 3, 2, 0]
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
0개 | 0개 | 0개 | 0개 | 1개 | 1개 |
[4, 5, 1, 3, 2, 0]
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
0개 | 1개 | 0개 | 0개 | 1개 | 1개 |
[4, 5, 1, 3, 2, 0]
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
0개 | 1개 | 0개 | 1개 | 1개 | 1개 |
[4, 5, 1, 3, 2, 0]
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
0개 | 1개 | 1개 | 1개 | 1개 | 1개 |
[4, 5, 1, 3, 2, 0]
0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
1개 | 1개 | 1개 | 1개 | 1개 | 1개 |
배열을 순회하며 각 숫자가 몇 개씩 등장했는지 확인하여 새로운 배열(정렬된 배열)을 생성한다.
따라서, 계수 정렬의 결과는 [0, 1, 2, 3, 4, 5]이다.
function countingSort(arr) {
const count = new Array(Math.max(...arr) + 1).fill(0); //최댓값 + 1의 크기만큼 배열 생성
const result = [];
for (let i = 0; i < arr.length; i++) {
count[arr[i]]++;
}
for (let i = 0; i < count.length; i++) {
for (let j = 0; j < count[i]; j++) {
result.push(i);
}
}
return result;
}
// 모든 원소의 값이 0보다 크거나 같다고 가정한다.
const arr = [4, 5, 1, 3, 2, 0];
const sortedArr = countingSort(arr);
console.log(sortedArr); // [0, 1, 2, 3, 4, 5]
힙 정렬은 완전 이진 트리를 기반으로 하는 정렬 방법이다. 최대 힙/최소 힙을 구성해 정렬을 수행한다.
완전 이진 트리는 트리의 노드가 왼쪽에서 오른쪽으로 채워지는 이진 트리로, 마지막 레벨을 제외한 모든 레벨이 꽉 찬 상태인 이진 트리를 말한다. 왼쪽부터 순서대로 노드가 채워지며 이러한 특징은 배열로 구현할 때 인덱스 계산이 용이하다는 장점이 있다.
class MinHeap {
constructor() {
this.heap = [ null ]; // 첫 번째 원소는 구현의 편의상 사용하지 않는다.
}
size = () => {
return this.heap.length - 1;
};
swap = (a, b) => {
[this.heap[a], this.heap[b]] = [this.heap[b], this.heap[a]];
};
push = (value) => {
this.heap.push(value);
let curIdx = this.heap.length - 1; // 새로 삽입한 노드의 index
let parentIdx = Math.floor(curIdx / 2); // 자식 노드의 부모 노드 인덱스를 구함(자식의 인덱스 / 2)
while (curIdx > 1 && this.heap[parentIdx] > this.heap[curIdx]) { // 부모 노드의 값이 자식 노드의 값보다 큰 경우 반복
this.swap(curIdx, parentIdx); //자식노드와 부모노드의 값을 바꿈
curIdx = parentIdx; // 부모 노드의 인덱스를 자식 노드 인덱스로 대입
parentIdx = Math.floor(curIdx / 2); // 새로운 자식 노드의 부모 노드 인덱스를 구함
}
};
pop = () => {
if (this.heap.length === 2) return this.heap.pop();
const result = this.heap[1]; // 배열 첫 원소(idx 0)은 비워두므로 root는 heap[1]에 위치
this.heap[1] = this.heap.pop();
let curIdx = 1;
let leftIdx = curIdx * 2; //왼쪽 자식의 인덱스 = 부모 인덱스 * 2
let rightIdx = curIdx * 2 + 1; // 오른쪽 자식의 인덱스 = (부모 인덱스 * 2) + 1
if (!this.heap[leftIdx]) return result; // 왼쪽 자식이 없음은 오른쪽 자식도 없다는 것이므로 바로 반환
if (!this.heap[rightIdx]) {
if (this.heap[leftIdx] < this.heap[curIdx]) { // 오른족 자식은 없지만 왼쪽 자식이 있는 경우
this.swap(leftIdx, curIdx);
}
return result;
}
while (this.heap[leftIdx] < this.heap[curIdx] || this.heap[rightIdx] < this.heap[curIdx]) {
const minIdx = this.heap[leftIdx] > this.heap[rightIdx] ? rightIdx : leftIdx;
this.swap(minIdx, curIdx);
curIdx = minIdx;
leftIdx = curIdx * 2;
rightIdx = curIdx * 2 + 1;
}
return result;
};
}
병합 정렬 알고리즘은 분할 정복(divide and conquer) 방식을 이용하여 데이터를 정렬하는 알고리즘으로, 리스트를 두 개의 균등한 크기로 분할하고, 각각 정렬을 수행한 후, 분할된 리스트를 병합하여 전체 리스트를 정렬한다.
[6, 5, 3, 1, 8, 7, 2, 4]
가 있다고 가정하자.[6, 5, 3, 1] [8, 7, 2, 4]
[6, 5] [3, 1] [8, 7] [2, 4]
[6] [5] [3] [1] [8] [7] [2] [4]
[5, 6] [1, 3] [7, 8] [2, 4]
[1, 3, 5, 6] [2, 4, 7, 8]
[1, 2, 3, 4, 5, 6, 7, 8]
병합 정렬의 시간 복잡도는 O(nlogn)이다.
이는 리스트를 분할하여 정렬하는 데 logn의 시간이 걸리고, 분할된 리스트를 병합하는 데 n의 시간이 걸리기 때문이다. 이러한 특성 때문에 대부분의 경우 퀵 정렬보다 느리지만 입력값이 큰 경우에는 퀵 정렬보다 유리할 수 있다.