본문 바로가기
Development/Knowledge

정렬 알고리즘

by Mobics 2025. 10. 27.

목차


    C# 언어로 백준 문제를 풀다가 연습한 정렬 알고리즘을 한 게시글로 정리했습니다.

    혹시나 틀린 정보가 있다면 알려주시기 바랍니다 :)

     

    시간 복잡도가 O(n²)

    └ 버블 정렬(Bubble Sort)

    : 인접한 두 원소를 비교하여 정렬하는 간단한 알고리즘

    --> 1번째 원소와 2번째 원소를 비교하여 정렬하는 식으로 n-1번째와 n번째를 정렬한 뒤 다시 처음으로 돌아가 이번에는 n-2번째와 n-1번째까지 정렬하는 방식으로 반복하여 정렬한다.

     

    - 코드

    public void BubbleSort(int[] arr)
    {
        int n = arr.Length;
        for (int i = 0; i < n - 1; i++)
        {
            for (int j = 1; j < n - i; j++)
            {
                if (arr[j - 1] > arr[j])
                {
                    (arr[j - 1], arr[j]) = (arr[j], arr[j - 1]);
                }
            }
        }
    }

     

    ※ 이해를 위한 영상

    https://youtu.be/Iv3vgjM8Pv4?si=3DXzXIAGLBq0k1fV

     

    └ 선택 정렬(Selection Sort)

    : 1번째 원소부터 끝까지 훑어서 가장 작은 게 1번째 원소, 그 다음엔 2번째 원소부터 끝까지 훑어서 가장 작은게 2번째 원소와 같은 방식으로 (n - 1)번 반복하여 정렬한다.

    • 어떻게 정렬이 되어 있든 일관성 있게 n(n - 1) / 2 에 비례하는 시간이 걸린다.
    • 버블 정렬에 비해 두 배 정도 빠르다.

    - 코드

    for (int i = 0; i < arr.Length - 1; i++)
    {
        int minIndex = i;
        for (int j = i + 1; j < arr.Length; j++)
        {
            if (arr[j] < arr[minIndex])
                minIndex = j;
        }
        (arr[i], arr[minIndex]) = (arr[minIndex], arr[i]);
    }

     

    ※ 이해를 위한 영상

    출처 : 나무위키 - 정렬 알고리즘

     

    └ 삽입 정렬(Insertion Sort)

    : k번째 원소를 1부터 k-1까지와 비교해 적절한 위치에 끼워넣고 그 뒤의 자료를 한 칸씩 뒤로 밀어내는 방식으로 정렬한다.

    • 시간 복잡도 O(n²)의 정렬들 중 빠른 편이나, 자료구조에 따라선 뒤로 밀어내는데 걸리는 시간이 크다.
    • 이미 정렬되어 있는 자료구조에 자료를 하나씩 삽입/제거하는 경우에는 최고의 정렬 알고리즘이 되는데, 탐색을 제외한 오버헤드가 매우 적기 때문이다.
    • 배열이 작을 경우에 상당히 효율적이다.

    - 코드

    public void InsertionSort(int[] arr)
    {
        for (int i = 1; i < arr.Length; i++)
        {
            for (int j = i; j > 0; j--)
            {
                if (arr[j] < arr[j - 1])
                    (arr[j], arr[j - 1]) = (arr[j - 1], arr[j]);
            }
        }
    }

     

    ※ 이해를 위한 영상

    출처 : 나무위키 - 정렬 알고리즘

     

    시간 복잡도가 O(n log n)

    └ 병합/합병 정렬(Merge Sort)

    : 원소 개수가 1 또는 0이 될 때까지 두 부분으로 쪼개고 쪼개서 자른 순서의 역순으로 크기를 비교해 병합해 나가며 정렬한다.

    • O(n log n) 의 시간복잡도를 가진다.
    • 성능은 퀵 정렬보다 전반적으로 뒤떨어지고, 데이터 크기만한 메모리가 더 필요하다.
    • 최대의 장점은 데이터의 상태에 별 영향을 받지 않는다. --> 동일 값에 대해서 기존 기준의 정렬순서가 유지되는 안정 정렬이다.

     

    - 도식화

     

    → Stack(1)의 Merge() 함수 도식화

     

    - 코드

    int[] temp;
    
    void Main()
    {
        temp = new int[arr.Length];
    }
    
    void MergeSort(int[] arr, int left, int right)
    {
        if (left < right)
        {
            int mid = (left + right) / 2;
    
            MergeSort(arr, left, mid);	// 왼쪽 절반 정렬
            MergeSort(arr, mid + 1, right);	// 오른쪽 절반 정렬
            Merge(arr, left, mid, right);	// 정렬된 두 부분 병합
        }
    }
    
    void Merge(int[] arr, int left, int mid, int right)
    {
        int i = left;	// 왼쪽 부분 시작점
        int j = mid + 1;	// 오른쪽 부분 시작점
        int k = left;	// temp 배열에 저장할 위치
    
        while (i <= mid && j <= right)  // 왼쪽과 오른쪽의 각 index가 끝을 넘을 때까지 반복
        {
            if (arr[i] <= arr[j])	    // 왼쪽과 오른쪽의 각 데이터 값을 비교하여 더 작은 쪽을 temp에 추가
            {
                temp[k++] = arr[i++];
            }
            else
            {
                temp[k++] = arr[j++];
            }
        }
    
        // 남은 요소 처리
        while (i <= mid)
        {
            temp[k++] = arr[i++];
        }
    
        while (j <= right)
        {
            temp[k++] = arr[j++];
        }
    
        // 임시 배열값을 실제 배열로 덮어쓰기
        for (int l = left; l <= right; l++)
        {
            arr[l] = temp[l];
        }
    }

     

    ※ 이해를 위한 영상

    출처 : 나무위키 - 정렬 알고리즘

     

    └ 퀵 정렬(Quick Sort)

    : 분할 정복 방법을 통해 정렬하는 알고리즘으로 다음과 같은 과정을 통해 정렬한다.

    1. 원소 하나를 기준(피벗, pivot)으로 삼는다.
    2. 피벗보다 작은 것을 앞으로 빼내고 그 뒤에 피벗을 옮겨 피벗보다 작은 것, 큰 것으로 나눈다. --> 이를 'partition step'이라 한다.
    3. 나누어진 각각에서 다시 피벗을 잡고 정렬해서 각각의 크기가 0이나 1이 될 때까지 정렬한다.

     

    >> 퀵 정렬의 특징

    • 같은 값의 순서가 바뀔 수 있는 불안정 정렬이다.
    • 평균 O(n log n)의 시간복잡도를 갖지만, 최악의 경우 O(n²)의 시간복잡도를 갖는다.

     

    → 퀵 정렬의 핵심은 어떻게 pivot을 선정하는가?

    : 피벗을 최솟값이나 최댓값으로 계속해서 잡게 되는 최악의 경우 O(n²)의 시간복잡도를 갖게 된다.

     

    >> pivot을 선정하는 여러가지 방식

    1. 첫번째 값이나 마지막 값을 pivot으로 선정한다. --> 퀵 정렬의 원리와 과정을 이해하기 가장 용이한 방법
    2. 중간값을 pivot으로 선정한다.
    3. 무작위 값을 pivot으로 선정한다.
    4. Median of Three : 첫번째 값, 중간값, 마지막 값을 먼저 정렬하고 그 중 중간값을 pivot으로 선정한다. --> 성능적으로 더욱 향상된 방식이다.

     

    >> Partition Step 방식

    1. Hoare Partition : 주로 첫번째 값을 pivot으로 선정하고, 양쪽 끝에서 포인터를 시작해 pivot보다 작은 값을 앞으로, pivot보다 큰 값을 뒤로 모은 뒤, 마지막에 pivot을 정확한 자리에 이동시킨다.
      •     구현이 좀 복잡하지만 성능이 더 우수하다.
    2. Lomuto Partition : 마지막 값을 pivot으로 고정하고, 하나의 포인터를 사용해서 pivot보다 작은 값들을 앞으로 모은 뒤, 마지막에 pivot을 정확한 자리에 이동시킨다.
      •     비효율적이라 큰 배열이나 중복이 많은 데이터에서 성능이 저하되지만 간단하고 직관적이다.

     

    >> 첫번째 값을 pivot으로 선정한 방식 (Hoare Partition)

    - 도식화

     

    - 코드

    private int Partition(int[] arr, int l, int h)
    {
        int pivot = arr[l]; // pivot 설정
        int low = l + 1;    // low는 pivot의 바로 다음 위치
        int high = h;
    
        while (low <= high)
        {
        	if (arr[low] < pivot)	// pivot보다 큰 값이 나올 때까지 이동
            {
                low++;
                continue;
            }
    
            if (arr[high] > pivot)  // pivot보다 작은 값이 나올 때까지 이동
            {
                high--;
                continue;
            }
            
            if (low < high)    // low와 high가 멈춘 지점이 서로 역전되지 않았다면
                (arr[low], arr[high]) = (arr[high], arr[low]);  // swap
        }
        (arr[l], arr[high]) = (arr[high], arr[l]);  // pivot과 high swap
        return high;    // pivot 위치 반환
    }
    
    public void MyQuickSort(int[] arr, int l, int h)
    {
        if (l < h)
        {
            int pivot = Partition(arr, l, h);
            MyQuickSort(arr, l, pivot - 1);
            MyQuickSort(arr, pivot + 1, h);
        }
    }

     

    ※ 이해를 위한 영상

    https://youtu.be/3San3uKKHgg

     

    >> 랜덤으로 Pivot을 선정하는 방식

    : 랜덤으로 Pivot을 선정하고 이를 첫번째 값과 Swap하여 Hoare 방식으로 Quick Sort

    • 입력 패턴에 관계없이 QuickSort의 평균 성능을 안정적으로 유지하는 효과 --> 최악의 경우가 발생할 확률을 줄이고, 항상 평균적으로 O(n log n) 의 시간복잡도에 가깝게 만든다.

     

    - 코드

    Random rand = new Random();
    
    private int Partition(int[] arr, int l, int h)
    {
        int randomPivot = rand.Next(l, h + 1); 		// 랜덤으로 pivot 설정
        (arr[l], arr[randomPivot]) = (arr[randomPivot], arr[l]) // Pivot과 첫번째 값 Swap
        
        int pivot = arr[l]	// 첫번째 값 pivot 설정
        int low = l + 1;    // low는 pivot의 바로 다음 위치
        int high = h;
    
        while (low <= high)
        {
        	if (arr[low] < pivot)	// pivot보다 큰 값이 나올 때까지 이동
            {
                low++;
                continue;
            }
    
            if (arr[high] > pivot)  // pivot보다 작은 값이 나올 때까지 이동
            {
                high--;
                continue;
            }
            
            if (low < high)    // low와 high가 멈춘 지점이 서로 역전되지 않았다면
                (arr[low], arr[high]) = (arr[high], arr[low]);  // swap
        }
        (arr[l], arr[high]) = (arr[high], arr[l]);  // pivot과 high swap
        return high;    // pivot 위치 반환
    }
    
    public void MyQuickSort(int[] arr, int l, int h)
    {
        if (l < h)
        {
            int pivot = Partition(arr, l, h);
            MyQuickSort(arr, l, pivot - 1);
            MyQuickSort(arr, pivot + 1, h);
        }
    }

     

    >> Median Of Three 방식

    : 맨 앞, 중앙, 맨 뒤의 값을 미리 정렬하고 정렬된 세 값 중 중앙값을 Pivot으로 선정한다. 이후 선정된 pivot을 첫번째 값과 Swap하여 Hoare 방식으로 QuickSort

    • 항상 중앙값을 Pivot으로 선정하기 때문에 대부분의 상황에서 O(n log n) 의 시간복잡도에 가깝게 유지된다.

     

    - 코드

    private int MedianOfThree(int[] arr, int l, int h)
    {
        int mid = (l + h) / 2;
        
        // 중앙값 정렬
        if (arr[mid] < arr[l])
            (arr[l], arr[mid]) = (arr[mid], arr[l]);
        if (arr[h] < arr[l])
            (arr[l], arr[h]) = (arr[h], arr[l]);
        if (arr[h] < arr[mid])
            (arr[mid], arr[h]) = (arr[h], arr[mid]);
    
        // 찾은 중앙값을 Pivot으로 쓰기 위해 첫번째 값과 Swap 
        (arr[l], arr[mid]) = (arr[mid], arr[l]);
        
        return arr[l];
    }
    
    private int Partition(int[] arr, int l, int h)
    {
        int pivot = MedianOfThree(arr, l, h); // pivot 설정
        int low = l + 1;    // low는 pivot의 바로 다음 위치
        int high = h;
    
        while (low <= high)
        {
            if (arr[low] < pivot)	// pivot보다 큰 값이 나올 때까지 이동
            {
                low++;
                continue;
            }
    
            if (arr[high] > pivot)  // pivot보다 작은 값이 나올 때까지 이동
            {
                high--;
                continue;
            }
        
            if (low < high)    // low와 high가 멈춘 지점이 서로 역전되지 않았다면
                (arr[low], arr[high]) = (arr[high], arr[low]);  // swap
        }
        (arr[l], arr[high]) = (arr[high], arr[l]);  // pivot과 high swap
        return high;    // pivot 위치 반환
    }
    
    public void MyQuickSort(int[] arr, int l, int h)
    {
        if (l < h)
        {
            int pivot = Partition(arr, l, h);
            MyQuickSort(arr, l, pivot - 1);
            MyQuickSort(arr, pivot + 1, h);
        }
    }

     

    시간 복잡도가 O(n + k)

    : 여기서 k 는 데이터의 최댓값을 의미한다.

    └ 계수 정렬(Counting Sort)

    : 특정 데이터의 개수를 데이터의 값에 대응하는 위치(index)에 저장한 뒤, 자신의 위치에서 앞에 있던 값을 모두 더한 배열을 만든 뒤, 거기서 데이터가 들어가야 할 위치를 찾아내는 정렬 알고리즘

    • O(n + k) (k 는 데이터의 최댓값을 의미한다.)의 시간복잡도를 가진다. 즉, 최댓값에 따라 효율이 좌지우지된다.
    • 배열을 사용하는 특성상, 정수라는 전제를 깔고 한다.

     

    >> 음수 제외, 비안정 정렬

    : 자연수로 이루어진 배열을 입력 순서 상관없이 정렬한다.

    • 코드가 단순하여 구현하기 쉬우며 코드 처리 속도가 빠르다.

     

    - 코드

    private void CountingSort(int[] arr)
    {
        // 최댓값 찾기
        int max = 0;
        for (int i = 0; i < arr.Length; i++)
        {
            if (arr[i] > max)
                max = arr[i];
        }
    
        // 수의 개수를 저장할 배열 생성 및 카운트
        int[] count = new int[max + 1];
        for (int i = 0; i < arr.Length; i++)
        {
            count[arr[i]]++;
        }
    
        // count 배열을 기반으로 원본 배열 정렬
        int index = 0;
        for (int i = 0; i < count.Length; i++)
        {
            while (count[i] > 0)
            {
                arr[index++] = i;
                count[i]--;
            }
        }
    }

     

    >> 음수 포함, 안정 정렬

    : 음수가 포함된 정수로 이루어진 배열을 입력 순서를 신경써서 정렬한다.

    • 코드를 구현하기가 비교적 복잡하며, 비안정 정렬에 비해 코드 처리 속도가 약간 느리다.

     

    - 코드

    private void CountingSort(int[] arr)
    {
        // 최솟값, 최댓값 찾기
        int min = arr[0];
        int max = arr[0];
        
        foreach (int num in arr)
        {
            if (num < min) min = num;
            if (num > max) max = num;
        }
        
        // 수의 개수를 저장할 배열 생성 및 카운트
        int range = max - min + 1;
        int[] count = new int[range];
        
        for (int i = 0; i < range; i++)
        {
            count[arr[i] - min]++;  // 음수 보정 --> 최솟값의 index가 0이 되도록
        }
        
        // 누적 합 계산 --> 안정 정렬을 위해
        for (int i = 1; i < range; i++)
        {
            count[i] += count[i - 1];
        }
        
        // 출력용 임시 배열 생성
        int[] temp = new int[arr.Length];
    
        // 역순으로 정렬 결과 채우기 --> 안정성 유지
        for (int i = arr.Length - 1; i >= 0; i--)
        {
            int num = arr[i];
            int index = count[num - min] - 1;
            temp[index] = num;
            count[num - min]--;
        }
        
        // 정렬 결과를 원본 배열로 덮어쓰기
        for (int i = 0; i < arr.Length; i++)
        {
            arr[i] = temp[i];
        }
    }