October 10th 2018
목차
정렬 알고리즘을 사용하면 아래와 같은 여러 작업을 할 때 도움이 될 수 있습니다.
정렬 알고리즘을 학습하면 어떠한 애플리케이션을 만드는데 보장된 성능이 필요할 때, 그에 맞는 알고리즘을 선택해 사용할 수 있을 것입니다. 또는 대용량의 데이터를 다루거나 소규모의 데이터를 다루는 작업을 할 때 상황에 맞는 특정 알고리즘을 사용할 수 있을 것입니다.
버블정렬은 데이터의 인접한 두 요소를 비교하여, 정렬 기준에 맞지 않으면 두 요소의 위치를 서로 변경하는 정렬방법입니다. 비교적 간단한 정렬 알고리즘이지만 그에 따라 시간복잡도가 O(n2)이기에 코드 성능이 떨어져 실용적이지 못 합니다.
버블정렬의 숫자 정렬 방식은 아래와 같습니다.
Pseudocode로 작성하고, 옆에 시간복잡도를 표기했습니다.
for k, loop through 1 to n-1 (n-1)
for i loop through 0 to n-2 (n-1)
if arr[ i ] is greater than arr[ i+1 ](constant)
swap arr[ i ] with arr [ i+1 ](constant)
시간복잡도
만약 정렬이 이미 된 상태라면, best case로 O(n)의 시간복잡도가지 나올수도 있기는 합니다.
단점 성능이 좋지 않습니다. 특히 데이터의 양이 크면 더더욱 좋지 않습니다. 따라서, 거의 학습용으로 사용하는 정렬 알고리즘이라고 간주할 수 있습니다.
장점 Bubble-sort는 "in-place"로, 공간복잡도가 좋습니다. 쓰고 이해하기 쉽습니다.
아래는 제가 작성한 버블정렬 알고리즘을 애니메이션을 추가해 시각화 해 보았습니다.
개념이해를 했으니 이제 코드로 표현해 보도록 하겠습니다. 매 loop마다 배열의 인접한 두 요소를 비교하기 때문에 첫번째 for-loop는 '배열의 길이 - 1회'를 실행합니다. nested for-loop에서 끝의 두 요소를 비교하면 마지막 index에서 loop를 또 실행할 필요가 없습니다. 때문에, 마찬가지로 '배열의 길이-1'회를 실행합니다.
그리고 한번의 loop가 끝나면 배열 맨 뒤 쪽에 가장 큰 수가 쌓이게됩니다. 따라서, 중복으로 확인할 필요가 없기에 'wall-k'로 loop범위를 1씩 줄였습니다.
function bubbleSort(arr) {
const wall = arr.length - 1;
for (let k = 0; k < wall; k++) {
for (let i = 0; i < wall - k; i++) {
if (arr[i] > arr[i + 1]) {
let small = arr[i + 1];
let big = arr[i];
arr[i] = small;
arr[i + 1] = big;
}
}
}
return arr;
}
아래는 동일한 버블정렬이지만 while-loop를 사용한 코드입니다.
function bubbleSort(arr) {
let wall = arr.length - 1;
while (wall > 0) {
for (let i = 0; i < wall; i++) {
if (arr[i] > arr[i + 1]) {
arr = swap(arr, i, i + 1);
}
}
wall--;
}
return arr;
}
function swap(arr, i1, i2) {
// Bitwise swap
// Note: only works with integer elements
arr[i1] = arr[i1] ^ arr[i2];
arr[i2] = arr[i1] ^ arr[i2];
arr[i1] = arr[i1] ^ arr[i2];
return arr;
}
안정정렬은 정렬을 하며, 동일한 요소일 때 기존 배열의 순서를 유지하며 그렇지 않은 경우 불안정 정렬이라 합니다. 만약 정렬할 리스트가 일부분 정렬되어 있다면 adaptability에 대해 고려해 볼 수 있습니다. 정렬 알고리즘이 특정 리스트를 바탕으로 더 효율적으로 작성할 수 있다면 adaptive하다고 할 수 있어요.
선택정렬은 배열에서 가장 작은 수를 찾은 후, 새로운 배열에 push하는 정렬방법입니다. 만약 공간복잡도를 고려한다면, 선택정렬은 새로운 배열을 생성하기 때문에 좋지는 않습니다.
function selectionSort(arr) {
let newArr = [];
let minVal;
let wall = arr.length;
while (wall > 0) {
minVal = arr.reduce((acc, cur) => {
minVal = acc > cur ? cur : acc;
return minVal;
}, arr[0]);
newArr.push(minVal);
arr.splice(arr.indexOf(minVal), 1);
wall--;
}
return newArr;
}
using for loop instead of while loop
function selectionSort(arr) {
let newArr = [];
let minVal;
let wall = arr.length;
for (let k = 0; k < wall; k++) {
arr.reduce((acc, cur) => {
acc > cur ? (minVal = cur) : (minVal = acc);
return minVal;
}, arr[0]);
newArr.push(minVal);
arr.splice(arr.indexOf(minVal), 1);
}
return newArr;
}
선택정렬 (in place )
이번에는 위의 방법과 다르게 새로운 배열을 생성하지 않고 기존의 배열을 그대로 사용합니다. 굳이 새로운 공간을 차지하지않고 동일한 결과를 얻을 수 있습니다.
function selectionSort(arr) {
arr.forEach((cur, idx) => {
let minVal = cur;
// loop를 돌며 두 요소만 비교해서 최소값을 찾을 수 없음. 따라서 기준점을 설정함.
for (let i = idx; i < arr.length; i++) {
minVal = minVal > arr[i] ? arr[i] : minVal;
}
arr.splice(arr.indexOf(minVal), 1);
arr.unshift(minVal);
});
return arr.reverse();
}
// 밑의 코드는 내부 함수를 만들어 recursion을 사용해 보았습니다.
function recursiveSelectionSort(array) {
let end = array.length;
innerRecursion(array);
function innerRecursion(arr) {
let minVal = arr[0];
for (let i = 0; i < end; i++) {
minVal = minVal > arr[i] ? arr[i] : minVal;
}
arr.push(minVal);
arr.splice(arr.indexOf(minVal), 1);
if (end === 1) {
return;
} else {
end--;
innerRecursion(arr);
}
}
return array;
}
Recursion으로도 구현을 해 보았습니다. 정렬알고리즘 공부하며 재귀 연습을 더 하려 했는데 loop를 쓴 것보다 더 복잡해 보입니다. 더 연습이 필요한것 같습니다.
삽입정렬은 선택정렬과 유사합니다. 차이점은 삽입정렬은 배열의 처음부터 순차적으로 요소를 선택해 새로운 배열에 push 합니다. 새로운 배열에 새로운 요소가 추가될때마다, 해당 요소의 위치를 알맞게 설정해줍니다.
삽입정렬의 시간복잡도 버블정렬과 마찬가지로 O(n2)이고, best-case로 O(n)까지 될 수 있습니다.
삽입정렬( in place )
삽입정렬( in place )은 배열의 첫 요소를 선택하고, 이것을 정렬된 리스트로 간주합니다. 순차적으로 다음 요소를 선택해 앞서 정렬된 리스트에 요소를 추가합니다. 요소를 추가할 때마다 알맞은 자리에 위치를 설정해줍니다.
function insertSort(arr) {
let flag = 0;
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
break;
}
if (arr[i] < arr[i + 1]) {
flag = i + 1;
}
}
for (let i = flag + 1; i <= arr.length - 1; i++) {
let temp = arr.splice(i, 1)[0];
let count = flag;
while (count >= 0) {
if (arr[count] < temp) {
arr.splice(count + 1, 0, temp);
flag++;
break;
}
if (count === 0 && temp < arr[count]) {
arr.unshift(temp);
}
count--;
}
}
return arr;
}
또는 for loop를 사용하면,
function insertionSort(arr) {
let flag = 0;
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
break;
}
if (arr[i] < arr[i + 1]) {
flag = i + 1;
}
}
for (let i = flag + 1; i <= arr.length - 1; i++) {
let temp = arr.splice(i, 1)[0];
for (let j = i - 1; j >= 0; j--) {
if (arr[j] < temp) {
arr.splice(j + 1, 0, temp);
break;
}
if (j === 0 && temp < arr[j]) {
arr.unshift(temp);
}
}
}
return arr;
}
저는 배열을 argument로 전달 받은 후 loop를 한 번 진행했습니다. 그리고, 어느 지점까지 배열이 순차적으로 정리되어 있는지 확인을 한 번 거친 후, 정리간 안 된 인덱스부터 Insertion Sort를 진행했습니다. 이 부분은 생략을 하고 배열의 처음부터 차례로 정렬을 진행해도 됩니다.
위 insertionSort 함수가 compare function을 argument로 받을 수 있도록 코드를 수정할 수 있습니다. 만약 compare function이 undefined일 경우를 대비해 default로 Array.sort compareFunction을 지정하도록 하겠습니다.
이 compare function을 비교하는 두 요소의 값이 같을 때 우선 순위를 정하도록 사용하여, 안정정렬이 될 수 있도록 사용할 수 있습니다. 아래의 코드는 Array.sort 메소드의 compareFunction을 참고한 default function을 별도로 작성한 것입니다.
function defaultFunction(a, b) {
if (a < b) {
return -1;
} else if (a > b) {
return 1;
}
return 0;
}
이제 compare function을 위에서 작성한 insertFunction 함수에 추가해 보도록 하겠습니다.
function insertionSort(arr, comparator) {
let flag = 0;
comparator = comparator || defaultFunction;
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
break;
}
if (arr[i] < arr[i + 1]) {
flag = i + 1;
}
}
for (let i = flag + 1; i <= arr.length - 1; i++) {
let temp = arr.splice(i, 1)[0];
for (let j = i - 1; j >= 0; j--) {
if (comparator(arr[j], temp) < 0) {
arr.splice(j + 1, 0, temp);
break;
}
if (j === 0 && temp < arr[j]) {
arr.unshift(temp);
}
}
}
return arr;
}
위와 같이 argument로 배열과 compareFunction을 전달 받아 insertion sort로 졍렬을 진행하는 알고리즘을 작성했습니다. 배열에서 loop를 돌며 splice 메서드를 사용했는데, 사실 iteration 중 splice를 사용하는 것은 index가 변형되기 때문에 오류를 발생시킬 수 있습니다. 이번에는 splice를 사용하지 않고 insertion sort를 작성해 보았습니다.
function insertionSort(arr, comparator) {
comparator = comparator || defaultFunction;
// start at index 1 as sublist of arr[0] is already sorted;
for (let idx = 1; idx < arr.length; i++) {
let value = arr[idx];
let compareIdx = idx - 1;
while (compareIdx >= 0 && comparator(arr[compareIdx], value) > 0) {
arr = swap(arr, compareIdx, idx);
idx = compareIdx;
compareIdx--;
}
}
return arr;
}
function swap(array, i1, i2) {
let temp = array[i1];
array[i1] = array[i2];
array[i2] = temp;
return array;
}
합병정렬은 분할정복(Divide and conquer) 방식의 알고리즘입니다. 말그대로 배열을 둘로 쪼갠 후 두 배열의 각 요소를 비교해가며, 하나의 새로운 배열을 만들어가는 방식입니다. merge-sort는 위에서 살펴본 bubble-sort, selection-sort, insertion-sort 보다 좀 더 빠른 시간복잡도를 가집니다. 이는 중복된 loop를 사용하지 않기 때문입니다. 따라서, 우리가 정렬을 할 때 많이 사용하게 될 정렬 방식입니다. Array.prototype.sort도 대부분 merge sort를 사용한다고 하고, firefox의 자바스크립트 엔진은 데이터 타입에 따라 quick sort를 사용하는 경우도 있다고 합니다.
Merge-sort는 recursion을 사용합니다. Argument로 전달 받은 list를 둘로 쪼갠 후, 둘로 나뉜 list에 다시 recursive call을 적용합니다. Recursion의 base case는 list에 한 개의 요소가 남아있을 때입니다. Recursion call이 base case에 도달하면, 쪼개진 리스트들을 낮은 숫자가 앞쪽에 위치하도록 다시 합치며 최종적으로 하나의 리스트를 도출합니다.
merge-sort는 유일하게 시간복잡도가 O(n log n)으로 안정화된 알고리즘입니다. 이것은 이상하게 느껴질 수도 있습니다. 위에서 살펴본 정렬방식은 각각의 요소를 한번씩 비교했고, 시간복잡도가 exponential이었습니다. Merge-sort는 쪼개진 리스트에서 비교를 합니다.
Merge-sort는 recursion을 진행할수록 새로운 리스트를 생성하기 때문에, 공간복잡도는 위의 다른 정렬방식보다 좋지 못 한 O(n)입니다. 나쁘지는 않지만 인지할 필요는 있습니다. Merge-sort는 stable 합니다. 그래서 두 값이 같을때 기존의 순서를 유지합니다.
코드로 바로 작성하기 전 쪼개진 리스트를 다시 합치는 과정의 pseudocode를 먼저 작성해 보았습니다.
merge(L, R)
Lpointer = 0;
Rpointer = 0;
newArr = [];
Loop until L.length === Lpointer and R.length === Rpointer
if L[Lpointer] is less than R[Rpointer]
push L[Lpointer] to a new array
increment Lpointer
else
push R[Rpointer] to a new array
increment Rpointer
그리고 아래는 merge-sort의 전체 과정의 pseudocode 입니다. 개인적으로 이렇게 알고리즘을 어떻게 짤지 큰 부분을 사전에 생각하고 코드를 쓰면, 코드가 좀 더 수월하게 쓰여지는것 같습니다. 하지만 어떤식으로 코딩할지 생각해 내는 것이 또한 가장 어려운 것 같습니다.
mergeSort(list)
아래는 위에서 정리한 개념을 바탕으로 merge-sort를 작성했습니다. 코드를 한 번에 쭉 쓰고 테스트를 몇 개 했는데 문제가 없었습니다. 제가 이렇게 한 번에 깨끗한 코드를 쓴적이 거의 없는데 기분이 사뭇 좋네요...;;
function recursiveMergeSort(arr) {
if (arr.length < 2) {
return arr;
}
let mid = Math.floor(arr.length / 2);
let leftArr = recursiveMergeSort(arr.slice(0, mid));
let rightArr = recursiveMergeSort(arr.slice(mid));
return merge(leftArr, rightArr);
}
function merge(left, right) {
const sortedArr = [];
while (left.length !== 0 && right.length !== 0) {
if (left[0] <= right[0]) {
sortedArr.push(left.shift());
} else {
sortedArr.push(right.shift());
}
}
return sortedArr.concat(left, right);
}
merger 함수를 argument 배열을 건드리지 않고 진행하고 싶으면 아래와 같은 방식으로 할 수도 있지만 가독성이 떨어져서 개인적으로 별로인 것 같습니다.
function merge(left, right) {
let result = [];
let leftIdx = 0;
let rightIdx = 0;
while (result.length < left.length + right.length) {
if (leftIdx === left.length) {
result.concat(right.slice(rightIdx));
}
if (rightIdx === right.length) {
result.concat(left.slice(leftIdx));
} else if (left[leftIdx] <= right[rightIdx]) {
result.push(left[leftIdx++]);
} else {
result.push(right[rightIdx++]);
}
}
return result;
}
위에서 recursion을 사용했고, 아래는 반복문을 사용해 merge-sort를 구현해 보았습니다.
function mergeSort(arr) {
let splitArr = arr.map(el => [el]);
while (splitArr.length > 1) {
let result = [];
for (let i = 0; i < splitArr.length; i += 2) {
if (splitArr[i + 1]) {
result.push(merge(splitArr[i], splitArr[i + 1]));
} else {
result.push(splitArr[i]);
}
}
splitArr = result;
}
return splitArr[0];
}
Quick-sort는 또다른 분할정복(divide and conquer) 방식의 알고리즘입니다. Quick-Sort의 기본적인 개념은 배열 내 중심축(pivot)이 될 요소를 정하고, pivot보다 작으면 왼쪽 배열에, pivot보다 크면 오른쪽 배열로 분류 후 각각의 배열에 recusrive call을 적용하는 것입니다. 최종적으로는 왼쪽과, pivot, 오른쪽 배열을 하나로 합친 정렬된 배열을 도출합니다.
퀵정렬의 시간복잡도는 일반적으로 O(nlogn)입니다. 하지만, 경우에 따라 정렬된 배열에 quick-sort를 적용하면 좋지 못 한 성능을 보입니다.
function quickSort(arr) {
if (arr.length <= 1) {
return arr;
}
const pivot = [arr.pop()];
const left = quickSort(arr.filter(el => el <= pivot[0]));
const right = quickSort(arr.filter(el => el > pivot[0]));
return left.concat(pivot, right);
}
위아 같이 quick-sort를 작성했습니다. Built-in method 이긴 하지만 filter 메소드를 두 번 사용했습니다. 그리고 argument로 전달 받은 원본 배열도 매 execution context마다 수정을 했습니다. 이부분을 피하고 싶으면 아래와 같이 작성할 수도 있습니다.
function quickSort(arr) {
if (arr.length <= 1) {
return arr;
}
const pivot = arr.slice(-1);
const left = [];
const right = [];
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] <= pivot) {
left.push(arr[i]);
} else {
right.push(arr[i]);
}
}
return quickSort(left).concat(pivot, quickSort(right));
}
Quick-Sort in place
별도의 메모리 공간을 사용하지 않으려면, quick-sort in place로 구현해야하며, 대신 로직이 조금 더 길어집니다. Merge-Sort가 배열을 쪼갠 후 병합하는 과정에서 정렬이 이루어 졌다면, quick-sort는 그 반대입니다. Pivot을 선정하고 배열을 둘로 나누는 partition과정에서 모든 작업이 이루어집니다. 둘로 나뉜 배열에 각각 recursion call 을 적용합니다. 저는 Pivot을 배열의 맨 마지막 요소로 정하고 아래와 같이 먼저 pseudocode를 작성했습니다.
- partition(arr, first, last)
- choose last element as pivot
- keep track of pivotLoc (초기값 = 0)
- for loop from beginning
- if current arr[pivotLoc] <= pivot
- increment pivotLoc
- else
- swap arr[pivotLoc] to pivot
- swap pivot to arr[last]
위의 pseudocode를 바탕으로 quick sort 함수를 작성하면 아래와 같습니다. quick sort에서 아래 함수 자체는 정렬에 관여하는 로직이 있지는 않아요. 정력 자체는 partition을 나누는 과정에서 진행됩니다.
function quickSort(arr, low, high) {
if (low < high) {
let pivotNum = partition(arr, low, high);
quickSort(arr, low, pivotNum - 1);
quickSort(arr, pivotNum + 1, high);
}
if (high - low === arr.length - 1) {
return arr;
}
}
아래 partition 함수를 통해 pivot을 정하고, pivot을 기준으로 왼쪽, 오른쪽 요소를 정렬합니다.
function partition(arr, pivotLoc, pivot) {
while (pivotLoc !== pivot) {
if (arr[pivotLoc] > arr[pivot]) {
swap(arr, pivotLoc, pivot);
swap(arr, pivotLoc, --pivot);
} else {
pivotLoc++;
}
}
}
partition 함수를 for loop으로도 구현할 수 있는데 아래의 코드에 대해, 좀 더 세세히 알고 싶은신 분은 Lumoto partiton scheme을 검색해 보시길 바랍니다.
function partition(arr, low, high) {
let pivot = arr[high];
let pivotLoc = low;
for (let i = low; i < high; i++) {
if (arr[i] <= pivot) {
swap(arr, pivotLoc, i);
pivotLoc++;
}
}
swap(arr, pivotLoc, high);
return pivotLoc;
}
위와 같이 배열 내에서 pivot을 선정하고, 그것을 기준으로 작은 수는 pivot 앞쪽에 큰 수는 pivot 뒤쪽에 위치시키는 함수를 작성했습니다.