본문 바로가기

프로그래밍 공부/알고리즘

정렬에 관한 정리-2

https://pro-gramm-ing.tistory.com/488

 

정렬에 관한 정리 - 1

개인적으로 정렬은 코테에서 혹은 개발을 할때 자주 쓰인다고 생각되어서 정렬에 대해 공부를 해두고 싶었다. 그래서 이 기회에 리트코드에 있는 정렬에 대한 글을 보고 정리를 해봤다. 1.정렬

pro-gramm-ing.tistory.com

앞서 비교 기반 정렬을 살펴 보았다. 이번에는 비비교 기반 정렬을 알아보고자 한다. 직관적이거나 만들기 쉽지는 않지만 비교적 실행속도가 빠르고 효율적이다.

 

비비교 기반 정렬

1. 계산 정렬(Counting Sort)

이 정렬방식은 값을 인덱스와 매핑을 다시 시킨 후 정렬을 하는 방식이다. 계산 정렬은 다음과 같은 상황에서 사용이 가능하다. 

- 1.배열에 중복적인 요소들이 있는경우

   ex) [0,0,1,3,5] : 0은 중복된 요소이다.

- 2.정의된 값 범위 밖의 요소들을 가진 경우

  ex) [0,1,3,5] : 배열의 크기는 4이지만 크기 밖의 요소 5를 가진다.

- 3.숫자가 아니더라도 순서가 있는 문자도 가능

  ex) ['a','b','c'] : 문자로 된 배열도 정렬이 가능하다.


1번째 단계

이런 배열이 있다고 해보자, 이 배열의 이름을 A라고 하자.

index 0 1 2 3 4 5 6
value 5 4 5 5 1 1 3

그리고 이런 비어있는 배열이 있다고 해보자. 얘는 B이다. B의 크기는 A의 값들중 최대값+1 의 크기를 가진다.

index 0 1 2 3 4 5
value 0 0 0 0 0 0

2번째 단계

A[0]의  값은 5이다. 그럼 B[ A[0] ]의 값을 1 더한다. 즉,  B[5] 에 숫자를 1증가시키자.

index 0 1 2 3 4 5
value 0 0 0 0 0 1

 

A[1]의  값은 4이다. 그럼 B[ A[1] ]의 값을 1 더한다. 즉,  B[4] 에 숫자를 1증가시키자.

index 0 1 2 3 4 5
value 0 0 0 0 1 1

 

A[2]의  값은 또 5이다. 그럼 B[ A[5] ]의 값을 1 더한다. 즉,  B[5] 에 숫자를 1증가시키자.

index 0 1 2 3 4 5
value 0 0 0 0 1 2

 

이런식으로 A[마지막 index] 까지 B에 증가시키는 과정을 거치면 다음과 같은 B가 만들어진다.

index 0 1 2 3 4 5
value 0 2 0 1 1 3

 


3번째 단계

이 계산 정렬의 중요 원칙은 0부터 배열가능한 최대값(이 예시에서는 A배열의 최대값 5) 까지 정렬할수 있다고 가정하고, 배열할 요소들의 빈도를 추적하는 것이다. 위의 예시에서 정렬되어야 하는 형태는 결국 아래와 같을텐데

index 0 1 2 3 4 5 6
value 1 1 3 4 5 5 5

생각해보면, 1이 두번 나오고, 3과 4가 각각 한번 나오고 5가 세번 나오게 하면 되는것이다. 이를 위해서는 단계가 하나 더 필요한데 배열을 하나 더 만들자. 얘는 C이다. 0부터 최대값(여기선 5)을 정렬하는데 현재 A에는 0이 없다. 0인덱스에 0을 넣자.

index 0 1 2 3 4 5
value 0          

 

그리고 다음요소는 B배열에서 이전요소와 현재요소의 총합이 오도록 배열을 초기화 하자.

index 0 1 2 3 4 5
value 0 2 0 1 1 3
index 0 1 2 3 4 5
value 0 0 + 0 = 0 0 + 2 = 2  2 + 0 = 2 2 + 1 = 3 3 + 1 = 4
과정 0 B[0] +
0(최소값)
B[0] + B[1] B[1] + B[2] B[2] + B[3] B[3] + B[4]

 

이제 C를 참고해서 정렬을 해보자.

index 0 1 2 3 4 5 6
value 5 4 5 5 1 1 3

A의 첫번째 요소는 5이다. C[ A[0] ] 의 값은 4이다. 정렬을 위한 인덱스 4에 5를 넣자 

index 0 1 2 3 4 5 6
value         5    

 

그리고 C[ A[0] ] 에 1을 더해주자, 이는 중복된 값을 정렬 위해서이다. 

index 0 1 2 3 4 5
value 0 0 2 2 3 5

 

그리고 이와같은 과정을 반복한다. A의 두번째 요소는 4이다. C[ A[4] ] 는 3이니 3번째 인덱스에 4를 배치하자.

index 0 1 2 3 4 5 6
value       4 5    

그리고 마찬가지로 C[3]의 값에 +1을 한다.

index 0 1 2 3 4 5
value 0 0 2 2 4 5

 

A[2]의 경우, C[ A[2] ] = 5, 5번째 인덱스에 정렬

index 0 1 2 3 4 5 6
value       4 5 5  

C[ A[2] ] 에 1을 또 더한다.

index 0 1 2 3 4 5
value 0 0 2 2 4 6

A[3]의 경우, C[ A[3] ] = 5, 5번째 인덱스에 정렬

index 0 1 2 3 4 5 6
value       4 5 5 5

C[ A[3] ] 에 1을 또 더한다.

index 0 1 2 3 4 5
value 0 0 2 2 4 7

A[4]의 경우 A[4]의 값은 1, C[ A[4] ] = 0, 0번째 인덱스에 정렬

index 0 1 2 3 4 5 6
value 1     4 5 5 5

C[ A[4] ] 에 1을 또 더한다.

index 0 1 2 3 4 5
value 0 1 2 2 4 7

A[5]의 경우 A[5]의 값은 1, C[ A[5] ] = 1, 1번째 인덱스에 정렬

index 0 1 2 3 4 5 6
value 1 1   4 5 5 5

C[ A[5] ] 에 1을 또 더한다.

index 0 1 2 3 4 5
value 0 2 2 2 4 7

A[6]의 경우 A[6]의 값은 3, C[ A[6] ] = 2, 2번째 인덱스에 정렬

index 0 1 2 3 4 5 6
value 1 1 3 4 5 5 5

이미 정렬은 되었지만 로직상 C[ A[6] ] 에 1을 또 더한다.

index 0 1 2 3 4 5
value 0 1 2 3 4 7

 

코드의 예시는 다음과 같다.

public class Solution {
    public void countingSort(int[] arr) {
        int K = Arrays.stream(arr).max().getAsInt();
        int[] counts = new int[K + 1];
        for (int elem : arr) {
            counts[elem] += 1;
        }
        int startingIndex = 0;
        for (int i = 0; i < K + 1; i++) {
            int count = counts[i];
            counts[i] = startingIndex;
            startingIndex += count;
        }

        int sortedArray[] = new int[arr.length];
        for (int elem : arr) {
            sortedArray[counts[elem]] = elem;
            counts[elem] += 1;
        }

        for (int i = 0; i < arr.length; i++) {
            arr[i] = sortedArray[i];
        }
    }
}

계산정렬의 시간복잡도는 O( N + K) 이다. 새로운 배열의 초기화에 N의 시간이 걸리고 최대값인 K만큼의 시간이 추가로 걸린다. 이 계산정렬의 한계는 고정된 크기의 배열에 적용할 수 있다는 것이다.  또 다른 비교 기반 정렬보다 빠를 수는 있지만, K의 범위가 N에 비해 큰 경우에는 오히려 느린 정렬보다도 더 나쁜 성능을 발휘할 수도 있어 주의가 필요하다.


2.기수정렬(Radix Sort)

계산 정렬은 제한된 범위에서 정렬을 해야된다. 또 배열의 최대값이 크다면 불필요한 메모리가 소모될 수도 있다. 이런 상황을 해결하기 위해 계산 정렬에서 확장된 정렬이 기수정렬이다. 기수정렬은 문자열및 컬렉션, 정수 컬렉션에 잘 작동된다.

기수정렬에는 몇가지 변형이 있지만 이번 글은 LSD( Least Significant Digit 즉, 최하위 숫자) 에 중점을 두고자 한다. 

다음과 같은 배열 A가 있다고 하자.

index 0 1 2 3 4 5 6 7
value 889 2448 4021 4500 6270 7324 8192 9011

이 배열을 다음과 같이 바꿔볼 수 있다. 이 바꾼 형태를 W라고 해보자

0 8 8 9
2 4 4 8
4 0 2 1
4 5 0 0
6 2 7 0
7 3 2 4
8 1 9 2
9 0 1 1

W의 최대 정수의 자리수를 구한 다음 최소단위, 즉 1의 자리에서부터 계산정렬을 하는것이다. 그럼 1의 자리에서 실행한다면 w는 다음과 같을 것이다. 

4 5 0 0
6 2 7 0
4 0 2 1
9 0 1 1
8 1 9 2
7 3 2 4
2 4 4 8
0 8 8 9

차례대로 10의 자리, 100의 자리, 1000의 자리까지 정렬을 하면 정렬이 완성되게 된다. 

0 8 8 9
2 4 4 8
4 0 2 1
4 5 0 0
6 2 7 0
7 3 2 4
8 1 9 2
9 0 1 1

 

구현 코드는 다음과 같다.

public class Solution {
    private static final int NUM_DIGITS = 10;

    public void countingSort(int[] arr, int placeVal) {
        int[] counts = new int[NUM_DIGITS];

        for (int elem : arr) {
            int current = elem / placeVal;
            counts[current % NUM_DIGITS] += 1
        }

        int startingIndex = 0;
        for (int i = 0; i < counts.length; i++) {
            int count = counts[i];
            counts[i] = startingIndex;
            startingIndex += count;
        }

        int[] sortedArray = new int[arr.length];
        for (int elem : arr) {
            int current = elem / placeVal;
            sortedArray[counts[current % NUM_DIGITS]] = elem;
         
            counts[current % NUM_DIGITS] += 1;
        }
        for (int i = 0; i < arr.length; i++) {
            arr[i] = sortedArray[i];
        }
    }

    public void radixSort(int[] arr) {
        int maxElem = Integer.MIN_VALUE;
        for (int elem : arr) {
            if (elem > maxElem) {
                maxElem = elem;
            }
        }

        int placeVal = 1;
        while (maxElem / placeVal > 0) {
            countingSort(arr, placeVal);
            placeVal *= 10;
        }
    }
}

기수정렬에는 몇가지 매개변수가 필요하다. 1. 정수 목록 내 최대 숫자 길이(W), 원래 입력정수 배열의 크기(N), 그리고 계산 정렬을 하기 위한 알파벳(K)(정수가 아닐때, 정수라면 10진수니 10을 쓰면 된다.)을 알아야 한다. 기수정렬은 안정적인 정렬이며, O( W( N + K ) ) 의 시간복잡도를 가진다. 

계산 정렬보다 훨씬 빠르지만 메모리를 더 필요로 한다. 최상위 숫자를 먼저 살펴보는 MSD 기수 정렬은 구현이 까다롭지만 LSD 기수 정렬보다 평균적으로 더 나은 성능을 낸다고 한다.


3.버킷정렬

다음과 같은 배열 A가 있다고 가정해보자

A = [22,50,32,28,41,12]

그리도 5개의 버킷(저장소) 이 있다고 가정해보자. 5개의 버킷은 10의 단위로 저장할 수 있다고 생각해보자. 그리고 그 버킷에 A의 요소 순서대로 저장을 해보자

1버킷(0~9)    
2버킷(10~19) 12  
3버킷(20~29) 22 28
4버킷 (30~39) 32  
5버킷 (40~50) 50 41

 

그리고 계산정렬이나 다른 정렬을 사용해서 버킷들 안에 있는 요소들을 정렬하면 된다.

1버킷(0~9)    
2버킷(10~19) 12  
3버킷(20~29) 22 28
4버킷 (30~39) 32  
5버킷 (40~50) 41 50

코드로 구현하면 다음과 같다.

import java.util.*;

public class Solution {
    public void bucketSort(int[] arr, int K) {
        List<List<Integer>> buckets = new ArrayList<ArrayList<Integer>>(K);
        int shift = Arrays.stream(arr).min().getAsInt();
        int maxValue = Arrays.stream(arr).max().getAsInt() - shft;

        double bucketSize = (double) maxValue / K;
        if (bucketSize < 1) {
            bucketSize = 1.0;
        }
        for (int elem : arr) {
            int index = (int) (elem - shift) / bucketSize;
            if (index == K) {
                // put the max value in the last bucket
                buckets[K - 1].add(elem);
            } else {
                buckets[index].add(elem);
            }
        }

        for (List<Integer> bucket : buckets) {
            Collections.sort(bucket);
        }

        List<Integer> sortedList = new ArrayList<Integer>();
        for (List<Integer> bucket : buckets) {
            sortedList.addAll(bucket);
        }

        for (int i = 0; i < arr.length; i++) {
            arr[i] = sortedList.get(i);
        }
    }
}

버킷 정렬의 시간복잡도는 최악의 경우 O(N²) 이다. 일반적으로 버킷에 있는 요소들을 정렬시 사용되는 알고리즘은 삽입정렬이다. 버킷이 전체 목록에 비해 너무 많은 요소를 가지지 않는 경우가 대부분이기 때문이다. 최악의 경우가 아닌 이상 평균적으로는 O(버킷에 있는 요소의 개수 + 버킷개수)의 시간복잡도를 가진다.


추가적으로 각 정렬의 시간복잡도와 공간복잡도, 안정적인 정렬여부에 대한 정리를 한 표이다. 

'프로그래밍 공부 > 알고리즘' 카테고리의 다른 글

정렬에 관한 정리 - 1  (2) 2024.01.07
선택 정렬  (0) 2020.11.03