1학년 / 2학기 / C++ Exercise / 5강

Exercise 05 · 퀵 정렬과 힙 정렬

Exercise 05의 참조·배열 개념과 퀵 정렬·힙 정렬을 문제부터 구현까지 연결해 정리합니다.

Exercise 02 · Exercise 03 · Exercise 04 · Exercise 05

출처: CPP Programming Exercise 05.pdf와 CPP Programming Exercise Solution 05.pdf. 정답은 Solution PDF에 나뉘어 있는 코드 조각을 각각 하나의 실행 가능한 프로그램으로 연결했습니다.

핵심 개념: 참조, 배열, vector

1. 참조는 원본의 다른 이름

int count = 1;
int& alias = count;
++alias;             // count도 2가 됩니다.
int other = 9;
alias = other;       // count에 9를 대입합니다.

2. 값 전달과 참조 전달 비교

매개변수무엇을 받나요?함수 안에서 변경하면?
int x복사본원본은 유지
int& x원본의 참조원본도 변경
const int& x읽기 전용으로 사용하는 참조이 참조를 통해서는 변경 불가

const int&로 읽는다고 원본 자체가 항상 불변인 것은 아닙니다. 원본은 다른 경로로 변경될 수 있습니다.

이번 정렬 코드에 적용하면

  • std::vector<int>& a: 호출자의 vector를 직접 정렬합니다.
  • for (int& x : a): 각 원소에 직접 입력합니다.
  • for (int x : a): 복사본을 사용하므로 여기에 입력해도 원소는 바뀌지 않습니다.

3. 내장 배열과 vector 비교

항목내장 배열std::vector
크기고정변경 가능
원소 저장연속 메모리연속 메모리
[] 접근범위 검사 없음범위 검사 없음
검사하는 접근별도 멤버 함수 없음at()은 잘못된 인덱스에 예외
끝 원소 추가·제거크기를 바꿀 수 없음push_back, pop_back

4. 배열을 함수에 전달할 때

  • 1차원 내장 배열: 함수의 int a[] 매개변수는 포인터로 조정됩니다. 배열 전체를 복사하지 않으며 길이를 별도로 전달해야 합니다.
  • 2차원 내장 배열: 행 배열이 이어진 구조입니다. 함수 매개변수에 열 크기 정보가 필요합니다.
  • 중첩 vector: 각 행의 원소는 연속이지만 모든 행이 하나의 연속 메모리에 놓인다고 보장되지는 않습니다.

5. 정렬 코드의 구간 읽기

알고 싶은 값표현
원소 개수end - begin
마지막 원소a[end - 1]
전체 배열의 구간[0, n)
구간 [0, 4)
인덱스:  0   1   2   3  │  4
        └── 포함 ──────┘  제외

Exercise 05

Quiz 1 - 퀵 정렬

Problem (Original)

CPP Programming Exercise 05.pdf, slide 15

Quiz 1: Quick Sort

Problem. Sort an array of integers in nondecreasing order using Quick Sort. Implement partitioning and recursive calls yourself. Do not use library sorting functions.

Input. The first line contains n. The second line contains n integers aᵢ.

Output. Print the sorted integers on one line, separated by spaces.

Constraints. 1 ≤ n ≤ 2000, −10⁹ ≤ aᵢ ≤ 10⁹. Duplicate values are allowed.

SampleInput line 1 (n)Input line 2Output
169 3 7 1 8 21 2 3 7 8 9
21-5-5
374 -2 4 0 -2 9 1-2 -2 0 1 4 4 9
문제 한국어 번역 보기

문제. 퀵 정렬로 정수 배열을 비내림차순(작거나 같은 값이 앞)으로 정렬합니다. 분할(partition)과 재귀 호출은 직접 구현하고, 라이브러리 정렬 함수는 사용하지 않습니다.

입력. 첫 줄에 n, 둘째 줄에 정수 n개 aᵢ가 주어집니다.

출력. 정렬된 정수를 공백으로 구분해 한 줄에 출력합니다.

제약 조건. 1 ≤ n ≤ 2000, −10⁹ ≤ aᵢ ≤ 10⁹. 같은 값이 여러 번 나올 수 있습니다.

예제 입력과 출력은 위 표와 같습니다.

개념과 예제

알고리즘: 기준값으로 나누고 작은 문제를 정렬하기

마지막 원소를 피벗(pivot)으로 선택합니다. 한 번의 분할로 피벗보다 작은 원소를 왼쪽, 피벗 이상인 원소를 오른쪽에 배치합니다. 이때 피벗은 최종 정렬 위치에 놓이지만 좌우 구간 내부는 아직 정렬되지 않았습니다. 따라서 좌우를 같은 방식으로 재귀 정렬합니다. 이것이 큰 문제를 작은 문제로 나누는 분할 정복입니다.

i는 작은 원소를 다음에 넣을 위치이고, j는 현재 검사하는 위치입니다. 검사 직전에는 [begin, i)에 피벗보다 작은 값, [i, j)에 피벗 이상인 값이 있습니다. [j, end-1)은 아직 검사하지 않은 부분이고 마지막 원소는 피벗입니다.

  1. i = begin으로 시작합니다. 작은 원소 구간은 아직 비어 있습니다.
  2. j를 begin부터 end-2까지 옮기며 원소를 검사합니다. 피벗 자체는 검사하지 않습니다.
  3. a[j] < pivot이면 a[i]와 a[j]를 교환하고 i를 늘립니다. 작은 값 하나가 왼쪽 구간에 추가됩니다.
  4. 모든 검사가 끝나면 a[i]와 마지막 피벗을 교환합니다. 반환한 i가 피벗의 확정 위치 p입니다.
  5. [begin, p)와 [p+1, end)를 정렬합니다. 이미 확정된 피벗은 두 재귀 구간에서 제외합니다.
  6. 원소 수가 0 또는 1이면 이미 정렬되어 있으므로 즉시 반환합니다.

정답 코드 · Solution 05

출처: Solution 05 PDF 23쪽(슬라이드 3334). 마지막 원소를 피벗으로 사용하는 제공 코드를 그대로 연결했습니다.

정답 코드 보기정답 코드 숨기기
exercise05_quiz1.cpp
#include <iostream>
#include <utility>
#include <vector>
int partition(std::vector<int>& a, int begin, int end) {
    int pivot = a[end - 1];
    int i = begin;
    for (int j = begin; j < end - 1; ++j) {
        if (a[j] < pivot) {
            std::swap(a[i], a[j]);
            ++i;
        }
    }
    std::swap(a[i], a[end - 1]);
    return i;
}
void quickSort(std::vector<int>& a, int begin, int end) {
    if (end - begin <= 1) return;
    int p = partition(a, begin, end);
    quickSort(a, begin, p);
    quickSort(a, p + 1, end);
}
 
int main() {
    int n;
    std::cin >> n;
    std::vector<int> a(n);
    for (int& x : a) std::cin >> x;
    quickSort(a, 0, n);
    for (int i = 0; i < n; ++i) {
        if (i > 0) std::cout << ' ';
        std::cout << a[i];
    }
    std::cout << '\n';
}

분할 추적: 4 2 5 1 3

자료의 분할 예제이며 전체 구간은 [0, 5), 피벗은 3입니다.

검사판단과 동작동작 후 배열다음 i
j = 04는 3 이상, 유지4 2 5 1 30
j = 12는 3 미만, 위치 0과 1 교환2 4 5 1 31
j = 25는 3 이상, 유지2 4 5 1 31
j = 31은 3 미만, 위치 1과 3 교환2 1 5 4 32
검사 종료위치 2와 피벗 위치 4 교환2 1 3 4 5p = 2

다음 호출은 quickSort(a, 0, 2)와 quickSort(a, 3, 5)입니다. 왼쪽의 2 1은 피벗 1을 위치 0에 놓아 1 2가 됩니다. 오른쪽 4 5는 피벗 5가 이미 제자리에 있습니다. 이후 빈 구간과 원소 하나의 구간에서 반환하며 전체가 1 2 3 4 5가 됩니다.

정확성은 세 부분으로 확인할 수 있습니다. 분할은 작은 값/큰 값의 경계를 유지하고, 재귀는 더 짧은 구간을 정렬하며, 정렬된 왼쪽 ≤ 피벗 ≤ 정렬된 오른쪽이므로 전체도 정렬됩니다. 같은 값은 < 조건을 만족하지 않아 오른쪽에 모일 수 있지만 정렬 결과에는 문제가 없습니다.

복잡도와 최악의 경우

분할 한 번은 길이 n에 비례합니다. 작은 값이 k개이면 T(n) = T(k) + T(n-k-1) + Θ(n)입니다. 매번 균형 있게 나뉘면 단계 수가 로그이고 단계별 총 검사가 선형이므로 Θ(n log n)입니다. 평균적으로도 O(n log n)이지만 이 고정 피벗 구현이 모든 입력에서 이를 보장하지는 않습니다.

이미 정렬된 1 2 3 4 5에서는 마지막 피벗이 항상 최댓값이라 크기 4와 0, 3과 0으로 나뉩니다. 비교 횟수는 4 + 3 + 2 + 1 = 10, 일반적으로 n(n-1)/2이므로 최악 시간은 **Θ(n²)**입니다. 역순이나 모든 값이 같은 입력도 편향된 분할을 만들 수 있습니다. 재귀 스택 공간은 균형 시 O(log n), 최악 O(n)입니다. 입력 vector 자체의 O(n) 공간은 별도입니다.

피벗을 재귀 구간에 다시 넣으면 구간이 줄지 않아 끝나지 않을 수 있습니다. 또한 end는 유효한 원소 인덱스가 아닙니다. 이 코드는 같은 값의 원래 상대적 순서를 보장하지 않는 불안정 정렬입니다.

Quiz 2 - 힙 정렬

Problem (Original)

CPP Programming Exercise 05.pdf, slide 21

Quiz 2: Heap Sort

Problem. Sort an array of integers in nondecreasing order using Heap Sort. Build a max-heap and implement heapify yourself. Do not use library sorting or heap functions.

Input. The first line contains n. The second line contains n integers aᵢ.

Output. Print the sorted integers on one line, separated by spaces.

Constraints. 1 ≤ n ≤ 100 000, −10⁹ ≤ aᵢ ≤ 10⁹. Duplicate values are allowed.

SampleInput line 1 (n)Input line 2Output
154 10 3 5 11 3 4 5 10
2100
387 -3 7 2 0 -3 12 2-3 -3 0 2 2 7 7 12
문제 한국어 번역 보기

문제. 힙 정렬로 정수 배열을 비내림차순으로 정렬합니다. 최대 힙을 만들고 heapify는 직접 구현합니다. 라이브러리의 정렬 함수나 힙 함수는 사용하지 않습니다.

입력. 첫 줄에 n, 둘째 줄에 정수 n개 aᵢ가 주어집니다.

출력. 정렬된 정수를 공백으로 구분해 한 줄에 출력합니다.

제약 조건. 1 ≤ n ≤ 100 000, −10⁹ ≤ aᵢ ≤ 10⁹. 같은 값이 여러 번 나올 수 있습니다.

예제 입력과 출력은 위 표와 같습니다.

개념과 예제

트리에서 최대 힙까지

트리는 사이클 없이 노드가 연결되어 두 노드 사이의 경로가 하나인 구조입니다. 루트는 맨 위 노드, 부모와 자식은 위아래로 직접 연결된 노드, 리프는 자식이 없는 노드입니다. 서브트리는 한 노드와 그 아래 자손 전체입니다. 깊이는 루트에서 내려온 간선 수, 높이는 아래 리프까지 가장 긴 간선 수입니다. 이진 트리는 자식이 최대 둘인 트리입니다.

완전 이진 트리는 마지막 층을 제외한 층이 모두 차고 마지막 층은 왼쪽부터 빈틈없이 채워집니다. 이를 층별로 왼쪽부터 배열에 저장하면 별도의 노드 포인터 없이 부모와 자식을 찾을 수 있습니다.

0부터 시작하는 배열에서 부모 i의 왼쪽 자식은 2*i+1, 오른쪽 자식은 2*i+2, 루트가 아닌 노드의 부모는 (i-1)/2입니다. 반면 1부터 저장하면 자식은 2i와 2i+1입니다. 이번 코드는 0 기반 규칙을 begin만큼 이동해서 사용합니다.

최대 힙은 완전 이진 트리이며 모든 부모 값이 자식 값 이상입니다. 따라서 루트가 전체 최댓값입니다. 형제끼리는 어느 값이 더 커도 되며 배열 전체가 정렬된 상태는 아닙니다. 이진 탐색 트리의 왼쪽 < 부모 < 오른쪽 규칙과도 다릅니다.

heapify: 잘못된 루트를 아래로 내려 보내기

heapify는 양쪽 자식 서브트리는 이미 힙이라는 전제에서 현재 루트의 위반만 고칩니다. 부모와 존재하는 두 자식 중 가장 큰 값을 찾고, 부모가 가장 크면 종료합니다. 그렇지 않으면 더 큰 자식과 교환한 뒤 내려간 자리에서 같은 작업을 반복합니다.

예를 들어 부모 1, 자식 10과 8에서 작은 자식 8과 바꾸면 8 아래에 10이 있어 여전히 위반입니다. 반드시 두 자식을 모두 비교해야 합니다. 교환 후 위쪽은 올바르고 위반 가능성은 내려간 원소 쪽에만 남습니다.

코드의 root - begin은 힙 내부의 상대 인덱스입니다. 왼쪽 자식은 begin + 2*(root-begin) + 1입니다. 예를 들어 begin이 3이고 root가 4이면 상대 위치 1의 자식은 절대 위치 6과 7입니다. 자식 인덱스가 end보다 작은지 먼저 확인한 뒤 배열을 읽습니다. &&의 단락 평가가 범위 밖 접근을 막습니다.

전체 알고리즘: 힙 만들기와 최댓값 꺼내기

  1. 리프는 자식이 없어 이미 힙입니다. 마지막 내부 노드 begin + n/2 - 1부터 루트까지 역순으로 heapify합니다. 자식 쪽을 먼저 처리하므로 매번 heapify의 전제를 만족합니다.
  2. 루트의 최댓값을 힙 마지막 원소와 바꿉니다. 최댓값의 최종 위치가 정해집니다.
  3. heapEnd를 하나 줄여 정렬 완료 구간을 힙에서 제외합니다.
  4. 남은 힙은 루트만 잘못되었을 수 있으므로 루트에서 heapify합니다.
  5. 힙 원소가 하나 남을 때까지 반복합니다. 큰 값부터 오른쪽에 고정되므로 결과가 오름차순입니다.

정답 코드 · Solution 05

출처: Solution 05 PDF 46쪽(슬라이드 3537). 반복형 heapify, heapSort, main을 연결한 제공 코드입니다.

정답 코드 보기정답 코드 숨기기
exercise05_quiz2.cpp
#include <iostream>
#include <utility>
#include <vector>
void heapify(std::vector<int>& a,
             int begin, int end, int root) {
    while (true) {
        int largest = root;
        int left = begin + 2 * (root - begin) + 1;
        int right = left + 1;
        if (left < end && a[left] > a[largest])
            largest = left;
        if (right < end && a[right] > a[largest])
            largest = right;
        if (largest == root) return;
        std::swap(a[root], a[largest]);
        root = largest;
    }
}
 
void heapSort(std::vector<int>& a, int begin, int end) {
    int n = end - begin;
    if (n <= 1) return;
    for (int i = begin + n / 2 - 1; i >= begin; --i)
        heapify(a, begin, end, i);
    for (int heapEnd = end; heapEnd - begin > 1; ) {
        std::swap(a[begin], a[heapEnd - 1]);
        --heapEnd;
        heapify(a, begin, heapEnd, begin);
    }
}
 
int main() {
    int n;
    std::cin >> n;
    std::vector<int> a(n);
    for (int& x : a) std::cin >> x;
    heapSort(a, 0, n);
    for (int i = 0; i < n; ++i) {
        if (i > 0) std::cout << ' ';
        std::cout << a[i];
    }
    std::cout << '\n';
}

heapify 단독 추적

배열 1 10 8 6 9 3 2에서 루트 1의 자식 서브트리는 각각 힙입니다. 자식 10과 8 중 10을 선택해 교환하면 10 1 8 6 9 3 2입니다. 내려간 1의 자식 6과 9 중 9와 교환하면 10 9 8 6 1 3 2가 됩니다. 위치 4는 리프이므로 종료합니다. 한 번 교환하고 끝내면 아래에 위반이 남을 수 있습니다.

예제 4 10 3 5 1 전체 추적

길이는 5이고 마지막 내부 노드는 5/2-1 = 1입니다. 위치 1의 값 10은 자식 5와 1보다 크므로 그대로입니다. 위치 0의 값 4를 내려 보내면 최대 힙 10 5 3 4 1이 만들어집니다.

단계남은 힙 구간교환 및 복구 후 전체 배열정렬 완료 부분
힙 구성[0, 5)10 5 3 4 1없음
10 꺼내기[0, 4)5 4 3 1 1010
5 꺼내기[0, 3)4 1 3 5 105 10
4 꺼내기[0, 2)3 1 4 5 104 5 10
3 꺼내기[0, 1)1 3 4 5 103 4 5 10

첫 교환 직후는 1 5 3 4 10입니다. 10을 제외하고 1을 5, 이어서 4와 교환해 5 4 3 1 10을 만듭니다. 이미 고정한 10을 다시 heapify에 포함하면 정렬 완료 부분을 망가뜨리므로 줄어든 heapEnd를 전달해야 합니다.

매 회차 시작에 [begin, heapEnd)는 최대 힙이고 [heapEnd, end)는 정렬 완료 구간입니다. 루트는 남은 값 중 최댓값이므로 그 구간 바로 앞에 놓아도 전체 순서를 지킵니다. 이 관계가 끝까지 유지되어 올바른 정렬이 됩니다.

왜 힙 구성은 O(n)인가요?

한 번의 heapify는 트리 높이만큼 내려가므로 O(log n)입니다. 그러나 모든 노드가 루트 높이에서 출발하지는 않습니다. 대략 절반은 리프여서 내려갈 일이 없고, 그 위 약 1/4은 한 단계, 그 위 약 1/8은 두 단계만 내려갑니다.

총 작업량은 n/4 × 1 + n/8 × 2 + n/16 × 3 + ...처럼 상수배 n으로 제한되므로 아래에서 위로 만드는 힙 구성은 **O(n)**입니다. 각 호출을 모두 O(log n)으로 잡으면 O(n log n)이라는 느슨한 상한만 얻습니다.

최댓값 추출은 최대 n-1번이고 각 복구는 O(log n)이므로 전체 최악 시간은 **Θ(n log n)**입니다. 이 제공 코드는 반복형 heapify여서 입력 vector를 제외한 추가 공간은 **O(1)**입니다. 퀵 정렬과 달리 정렬된 입력에서도 최악 시간 보장이 유지됩니다. 힙 정렬 역시 같은 값의 상대 순서를 보장하지 않는 불안정 정렬입니다.

원소 하나이면 n <= 1에서 바로 반환합니다. 음수나 중복도 값의 대소 비교만 사용하므로 같은 코드로 처리합니다. 힙을 만드는 역순 반복의 i는 부호 있는 int여서 0 다음 -1이 되어 종료합니다. 이를 무심코 unsigned로 바꾸면 0 아래에서 큰 값으로 돌아가 종료 조건이 깨질 수 있습니다.

두 정렬 비교와 점검

항목제공 퀵 정렬제공 힙 정렬
핵심피벗 기준 분할 후 양쪽 재귀최대 힙 구성 후 루트 추출
최악 시간Θ(n²)Θ(n log n)
추가 공간최악 O(n) 재귀 스택O(1)
자료의 입력 크기최대 2000최대 100000
공통원본 vector 수정, 비내림차순원본 vector 수정, 비내림차순

피벗이 확정된 뒤 어느 구간을 재귀 호출하는지, heapEnd를 줄인 뒤 왜 정렬 완료 원소를 건드리지 않는지 직접 설명해 보세요. 두 코드 모두 원소 값을 더하거나 곱하지 않으므로 ±10^9 값도 비교와 교환으로 처리합니다. 배열 전체에 필요한 O(n) 저장 공간과 알고리즘의 추가 공간을 구분해서 기억합니다.

오늘도 한 걸음 앞으로.

내용을 이해했다면 학습 완료를 체크해 주세요.

학습 기록은 현재 브라우저에 저장됩니다.
HJS / STUDY한지석