[정렬 알고리즘, C#] 퀵 정렬(Quick Sort) - Hoare, Lomuto 분할 방식

2025. 6. 18. 19:39·Unity,C#/알고리즘
728x90

■ 퀵 정렬(Quick Sort)

퀵 정렬은 기준값(Pivot)을 하나 선택한 뒤, 그보다 작은 값은 왼쪽, 큰 값은 오른쪽으로 보내며 배열을 분할하고, 각 부분을 재귀적으로 다시 정렬하는 알고리즘이다.

[퀵 정렬의 한 사이클]

퀵 정렬의 핵심 원리는 다음과 같다.

  1. Pivot(기준값)을 하나 정한다.
  2. 배열을 pivot보다 작은 부분과 큰 부분으로 나눈다.
  3. 이 두 부분을 재귀적으로 다시 퀵 정렬한다.

이처럼 퀵 정렬은 분할(Divide) → 정복(Conquer) → 결합(Combine)의 순서를 따르는 전형적인 분할 정복(Divide and Conquer) 알고리즘이다.

  • 조금 복잡해 보일 수 있지만, pivot을 기준으로 어떻게 분할되고, 재귀가 어디까지 이어지는지만 이해하면 직접 구현하는 데에도 큰 어려움이 없다.

 

■ 퀵 정렬 구현 - Hoare 분할 방식(C#)

퀵 정렬에는 대표적으로 두 가지 분할 방식이 있는데, 이번에는 구현이 좀 복잡하지만 성능이 더 우수한 Horea 방식을 먼저 설명하겠다.

  • 1959년 퀵 정렬을 처음 고안한 Tony Hoare가 제안한 방식.
  • 양쪽 끝에서 포인터를 시작해 서로 이동시킨다.

 

1. Pivot값 선택

퀵 정렬에서 가장 중요한 요소 중 하나는 Pivot값 선택이다.

Pivot값이 항상 배열의 최솟값이나 최댓값으로 선택되는 경우, 시간복잡도가 최악의 경우 $O(n^2)$까지 증가할 수 있다.

public static class SortArray<T> where T : IComparable<T>
{
    private static Random rand = new Random();
    // 퀵 정렬
    public static void QuickSort(T[] arr, int low, int high)
    {
		if (low >= high) return;
		    
        var pivot = arr[rand.Next(low, high + 1)];
    }
}

그래서 pivot값을 선택할 때 배열의 맨 앞 혹은 맨 뒤를 선택해도 되지만, 공평한 선택을 위해 랜덤 하게 pivot을 선택하는 방식으로 구현하였다.

public static class SortArray<T> where T : IComparable<T>
{
    private static Random rand = new Random();
    // 퀵 정렬
    public static void QuickSort(T[] arr, int low, int high)
    {
		if (low >= high) return;
		    
        //var pivot = arr[rand.Next(low, high + 1)];
        var pivot = MedianOfThree(arr[low], arr[(low + high) / 2], arr[high]);
    }
    
    public static T MedianOfThree<T>(T a, T b, T c) where T : IComparable<T>
		{
		    if (a.CompareTo(b) > 0)
		    {
		        if (b.CompareTo(c) > 0)
		            return b; // a > b > c
		        else if (a.CompareTo(c) > 0)
		            return c; // a > c > b
		        else
		            return a; // c > a > b
		    }
		    else
		    {
		        if (a.CompareTo(c) > 0)
		            return a; // b > a > c
		        else if (b.CompareTo(c) > 0)
		            return c; // b > c > a
		        else
		            return b; // c > b > a
		    }
		}
}

혹은 배열의 첫 번째 원소, 중간 위치의 원소, 마지막 위치의 원소 중 중간값을 Pivot값으로 사용하는 Median-of-Three 방식을 사용할 수 있다.

  • 이 경우 pivot을 고를 때 데이터 분포를 어느 정도 고려하기 때문에, 더 안정적인 Pivot을 선택하게 된다.

 

2. 포인터 이동

public static class SortArray<T> where T : IComparable<T>
{
    private static Random rand = new Random();
    // 퀵 정렬
    public static void QuickSort(T[] arr, int low, int high)
    {
		if (low >= high) return;
		    
        var pivot = arr[rand.Next(low, high + 1)];
        
        var leftIndex = low;
		var rightIndex = high;
				
		while(true) 
		{
			while (arr[leftIndex].CompareTo(pivot) < 0) { leftIndex++; }
			while (arr[rightIndex].CompareTo(pivot) > 0) { rightIndex--; }
				
			if (leftIndex >= rightIndex) break;
				
			if (leftIndex <= rightIndex)
			{
				(arr[leftIndex], arr[rightIndex]) = (arr[rightIndex], arr[leftIndex]);
				
				 leftIndex++;
				 rightIndex--;
			}
		}
    }
}

leftIndex는 배열의 시작점(low), rightIndex는 배열의 끝 지점(high)에서 시작하여 서로를 향해 이동한다.

  1. 왼쪽 포인터(leftIndex)의 이동
  • leftIndex는 해당 위치의 값이 pivot보다 작을 때만 오른쪽(++)으로 이동한다.
  • 만약 pivot보다 크거나 같은 값을 만나면, 그 위치에서 멈춘다. → 이 값은 정렬이 필요한 대상이기 때문.
  1. 오른쪽 포인터(rightIndex)의 이동
  • rightIndex는 해당 위치 값이 pivot보다 클 때만 왼쪽(—)으로 이동한다.
  • pivot보다 작거나 같은 값을 만나면 멈춘다. → 이 값 역시 교환 대상이기 때문.

두 포인터가 모두 멈췄고, leftIndex가 rightIndex를 넘지 않았다면, 두 값을 교환한 뒤 각 포인터를 한 칸씩 이동한다.

  • 두 포인터가 서로 교차(leftIndex ≥ rightIndex)하면 분할 탐색을 종료하고 다음 재귀 단계로 넘어간다.

▶ 포인터는 항상 동시에 움직이는 것이 아니다! 한쪽 포인터가 교환 대상을 찾았다고 해도, 다른 포인터가 멈출 때까지 기다린 뒤 교환이 이루어진다.

 

3. 배열 분할

public static class SortArray<T> where T : IComparable<T>
{
    private static Random rand = new Random();

    // 퀵 정렬
    public static void QuickSort(T[] arr, int low, int high)
    {
        if (low >= high) return;

        var pivot = arr[rand.Next(low, high + 1)];

        var leftIndex = low;
        var rightIndex = high;

        while (true)
        {
            while (arr[leftIndex].CompareTo(pivot) < 0) { leftIndex++; }
            while (arr[rightIndex].CompareTo(pivot) > 0) { rightIndex--; }

            if (leftIndex >= rightIndex) break;

            if (leftIndex <= rightIndex)
            {
                (arr[leftIndex], arr[rightIndex]) = (arr[rightIndex], arr[leftIndex]);
                leftIndex++;
                rightIndex--;
            }
        }

        // 배열 분할
        if (low < rightIndex)
        {
            QuickSort(arr, low, rightIndex);
        }

        if (rightIndex + 1 < high)
        {
            QuickSort(arr, rightIndex + 1, high);
        }
    }
}

[배열의 분할]

두 포인터가 교차하여 분할이 완료되면, 왼쪽 구간(low ~ rightIndex), 오른쪽 구간(rightIndex + 1~ high)에 대해 각각 재귀적으로 다시 퀵 정렬을 수행한다.

  • 이때, 시작 인덱스가 끝 인덱스보다 작을 때만 재귀호출해야 한다. 그렇지 않으면 불필요한 호출이 발생하거나 예외가 발생할 수 있다.

 

 

우측 배열의 범위를 leftIndex ~ high가 아닌 rightIndex + 1 ~ high로 지정하는 이유

 

QuickSort(arr, rightIndex + 1, high)와 같이 우측 배열을 재귀 호출할 때, 범위의 시작점을 leftIndex 대신 rightIndex + 1로 사용하는 이유는 다음과 같다.

Hoare 분할 방식에서는 두 포인터(leftIndex, rightIndex)가 같은 위치에서 멈추는 경우가 있을 수 있다.

이때 우측 범위의 시작점을 leftIndex로 지정하면, 이미 분할에 사용된 중간 값을 다시 포함하게 되어 중복 정렬이 발생할 수 있다.

하지만 포인터가 교차되었을 때는 leftIndex == rightIndex + 1이기 때문에, rightIndex + 1을 시작점으로 사용하는 것이 중복 없이 정확하게 오른쪽 배열을 분할하는 방법이다.

 

■ 퀵 정렬 구현 - Lomuto 분할 방식(C#)

Lomuto분할 방식은 Pivot을 배열의 마지막 요소로 고정하고, 하나의 포인터(i)를 사용해서 Pivot보다 작은 값들을 앞으로 모은 뒤, 마지막에 Pivot을 정확한 자리에 이동시킨다.

[Lomuto방식]

i는 Pivot보다 작은 값들이 누적되는 마지막 위치를 가리킨다.

j는 순차적으로 배열을 탐색하면서, pivot보다 작은 값을 만나면,

  • i를 한 칸 증가시키고, arr[i]와 arr[j]를 교환한다.

이렇게 하면 pivot보다 작은 값은 왼쪽, 큰 값은 오른쪽으로 자연스럽게 정렬된다.

  • 최종적으로 i + 1 위치에 pivot을 교환하면, pivot은 자신보다 작은 값과 큰 값 사이, 정확한 위치에 놓이게 된다.

 

▶ 구현 전체 코드

public static class SortArray<T> where T : IComparable<T>
{
    
    // 퀵 정렬 Lomuto
    public static void LomutoQuickSort(T[] arr, int low, int high)
    {
        if (low >= high) return;

        var pivotIndex = LomutoSplit(arr, low, high);
        LomutoQuickSort(arr, low, pivotIndex - 1);
        LomutoQuickSort(arr, pivotIndex + 1, high);
    }

    private static int LomutoSplit(T[] arr, int low, int high)
    {
        var pivot = arr[high];
        var i = low - 1;

        for(var j = low; j < high; ++j)
        {
            if (arr[j].CompareTo(pivot) > 0) continue;

            i++;
            (arr[i], arr[j]) = (arr[j], arr[i]);
        }

        (arr[i + 1], arr[high]) = (arr[high], arr[i + 1]);
        return i + 1;
    }
}

위의 설명과 동일하게, j가 pivot보다 작거나 같다면 i를 한 칸 증가시키고, arr[i]와 arr[j]를 교환한다.

이 과정을 반복하면 pivot보다 작은 값들이 앞쪽에 정렬되며, 마지막으로 i + 1위치와 pivot(배열의 마지막 원소)을 교환하여 pivot이 정확한 위치에 들어오도록 한다.

  • 이후, pivot이 들어온 위치를 기준으로 우측과 좌측으로 나누어 다시 정렬을 실시한다.

 

■ 퀵 정렬의 시간복잡도

퀵 정렬은 Pivot을 기준으로 배열을 두 부분으로 분할하고, 각 부분에 대해 다시 퀵 정렬을 수행하는 분할 정복 알고리즘이다.

  • 수식이 부담스럽게 느껴지지만 의외로 간단한 구조다. 퀵 정렬을 통해 시간복잡도 계산에 익숙해지면, 다른 분할 정복 알고리즘도 한결 쉽게 이해할 수 있다.

$$ T(n) = 2T(\frac{n}{2}) + O(n) $$

[재귀 관계식]

 

Pivot을 기준으로 분할하는데 드는 시간은 $O(n)$이다.

  • 배열의 모든 요소를 pivot과 비교해야 함 → 비교 $n$번
  • 이 단계는 1회 분할마다 $O(n)$의 시간이 걸린다.

또한, 2개의 하위 배열에 대해 다시 퀵 정렬을 수행한다.

  • pivot 기준으로 나눈 두 배열의 크기가 각각 $\frac{n}{2}$라면,
  • $T(n)$ 크기의 배열을 두 번 정렬해야 한다. → $2T(\frac{n}{2})$

 

1. 마스터 정리(Master Theorem)

$$ T(n) = aT(\frac{n}{b}) + O(n^d) $$

[마스터 정리]

 

위 재귀식을 일반화하면 위와 같은 형태로 정리할 수 있다. 여기서 각각의 의미는 다음과 같다.

  • $a$ = 부분 문제의 갯수
  • $\frac{n}{b}$ = 각 부분 문제의 크기
  • $O(n^d)$ = 분할/ 병합 등 부분 문제 외의 작업에 걸리는 시간

이때, 시간복잡도는 a, b, d 값의 관계에 따라 3가지 경우로 나뉘게 된다.

 

Case 1. $d < \log_ba$

이 경우는 하위 문제를 푸는 데 걸리는 시간이 전체 시간을 지배하는 경우이다.

예를 들어, 정렬하는 것보다 재귀적으로 더 많이 나눠서 정렬하는 데 시간이 더 드는 상황이다.

  • 시간복잡도는 재귀가 좌우한다. 따라서 수식은 다음과 같이 정리된다.

$$ T(n) = \Theta(n^{\log_b a}) $$

 

Case 2. $d = \log_ba$

분할과 병합의 시간과 재귀 호출 시간복잡도가 같을 때이다. 대부분의 평균적인 정렬 알고리즘이 이 경우에 해당한다.

  • 이 경우 로그 항이 추가된다.

$$ T(n) = \Theta(n^d \log n) $$

 

Case 3. $d > \log_ba$

이 경우는 하위 문제를 푸는 시간보다 분할 자체가 더 많은 계산을 필요로 할 때이다. 따라서 분할이 시간복잡도를 지배하게 되어 아래와 같이 정리된다.

$$ T(n) = \Theta(n^d) $$

 

퀵 정렬을 마스터 정리에 대입하면 다음과 같다.

  1. $a = 2$ (두 부분으로 나누므로)
  2. $b = 2$ (한 번 나눌 때 절반 씩)
  3. $d = 1$ (분할하는 데 $O(n)$의 시간)

따라서 $\log_ba = \log_22 = 1$ 이므로, $d = \log_b a$가 된다. 결국 퀵 정렬은 두 번째 케이스에 해당하며, 평균적인 시간복잡도는 $O(n \log n)$이 된다.

  • 하지만 Pivot이 항상 가장 크거나, 가장 작은 값을 선택해 한쪽으로만 나뉘게 되면 $O(n^2)$의 시간복잡도를 가지게 된다. (분할 자체가 재귀보다 더 많은 계산을 필요로 하기 때문)

 

728x90

'Unity,C# > 알고리즘' 카테고리의 다른 글

[Unity] 쿼드트리(QuadTree) 공간분할 알고리즘  (3) 2025.08.26
[정렬 알고리즘, C#] 병합 정렬(Merge Sort)  (1) 2025.06.20
[정렬 알고리즘, C#] 삽입 정렬(Insertion Sort)  (0) 2025.06.17
[정렬 알고리즘, C#] 선택 정렬(Selection Sort)  (0) 2025.06.17
[정렬 알고리즘, C#] 버블 정렬(Bubble Sort)  (1) 2025.06.17
'Unity,C#/알고리즘' 카테고리의 다른 글
  • [Unity] 쿼드트리(QuadTree) 공간분할 알고리즘
  • [정렬 알고리즘, C#] 병합 정렬(Merge Sort)
  • [정렬 알고리즘, C#] 삽입 정렬(Insertion Sort)
  • [정렬 알고리즘, C#] 선택 정렬(Selection Sort)
브라더스톤
브라더스톤
유티니, C#과 관련한 여러 정보를 끄적여둔 블로그입니다. Email : dkavmdk98@gmail.com
  • 브라더스톤
    젊은 프로그래머의 슬픔
    브라더스톤
  • 전체
    오늘
    어제
    • 개발 노트 (37)
      • Unity,C# (28)
        • Unity 정보 (5)
        • 알고리즘 (11)
        • 자료구조 (3)
        • 절차적생성(PCG) (9)
      • 게임수학 (9)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    절차적지형생성
    알고리즘
    이진공간분할
    최단경로찾기
    아핀공간
    unity
    ProjectOnPlane
    정렬알고리즘
    게임수학
    자료구조
    절차적던전생성
    PerlinNoise
    C#
    기저벡터
    경사로이동
    pcg
    이동변환
    앞뒤판별
    시야판별
    BSP
  • 최근 댓글

  • 최근 글

  • 250x250
  • hELLO· Designed By정상우.v4.10.3
브라더스톤
[정렬 알고리즘, C#] 퀵 정렬(Quick Sort) - Hoare, Lomuto 분할 방식
상단으로

티스토리툴바