icon

안동민 개발노트

9장: 정렬 알고리즘

기초 정렬의 동작 원리와 적용 관점


버블/선택/삽입 정렬은 단순해 보이지만 정렬 감각을 만드는 기초입니다. 비교 방향이나 반복 범위를 한 칸만 틀려도 결과가 조용히 틀어집니다. 그래서 여기서 경계 조건 감각을 정확히 잡아야 합니다.

세 알고리즘은 모두 O(N^2)여도 입력 모양에 따라 체감 성능이 다릅니다. 여기서는 이름 암기보다 어떤 입력에서 어떤 정렬이 유리한지를 고르는 기준에 집중합니다. 이 기준을 잡아두면 고급 정렬을 배울 때도 훨씬 수월해집니다.

핵심 개념 맵

기초 정렬은 이름을 외우기보다 각 알고리즘이 어떤 이동을 반복하는지 보면 빠르게 익힐 수 있습니다. 입력이 거의 정렬된 상태인지부터 우선 체크하고 아래 개념을 읽어 보세요. 삽입 정렬은 현재 값을 앞쪽 정렬 구간에 끼워 넣는 방식입니다. i번째 반복마다 [0..i-1] 구간 정렬 불변식을 유지하면서 적절한 위치까지 값을 밀어 넣습니다. [1,2,4,3]에서는 34 앞에 한 번 이동시키는 것만으로 정렬이 완료됩니다.

버블 정렬은 인접 원소를 비교해 큰 값을 오른쪽으로 밀어내는 방식입니다. 패스를 한 번 돌 때마다 현재 구간의 최대값이 끝 위치에 확정됩니다. [3,2,1]을 한 패스 처리하면 3이 맨 뒤로 이동하는 동작이 바로 보입니다.

선택 정렬은 남은 구간의 최소값을 찾아 현재 위치와 교환하는 방식입니다. 각 단계에서 최소 인덱스를 선택해 확정 구간을 앞에서부터 확장합니다. [4,1,3]이면 첫 단계에서 최소값 1을 찾아 첫 칸과 바꾸면서 시작 위치를 확정할 수 있습니다.

정렬 기준 선택이 실무에서 중요한 이유

정렬 구현의 실제 장애는 알고리즘 이름을 모르는 데서 생기지 않습니다. 대부분은 인덱스 경계, 비교 연산자 방향, 안정성 요구를 놓쳐서 발생합니다. 기초 정렬은 이런 오류를 작은 코드 안에서 반복 학습하기에 적합합니다.

또한 하이브리드 정렬의 내부를 이해하려면 작은 구간에서는 단순 정렬이 더 빠를 수 있다는 감각이 필요합니다. 삽입 정렬이 그 대표 사례입니다.

기초 정렬 오답 재현으로 검증하기

오답 -> 디버깅 -> 교정 -> 검증 순서를 먼저 고정하고 예제를 따라가면 경계 버그를 빠르게 줄일 수 있습니다.

기초 정렬의 차이를 작은 배열로 비교하면서, 오답 구현을 어떻게 교정하는지 확인합니다.

  • 입력: [5, 3, 4]
  1. 오답: 삽입 정렬 내부 루프를 j > 0으로 작성해 인덱스 0 비교를 누락합니다.
  2. 디버깅: 결과가 [3, 5, 4]처럼 중간 상태에서 멈추거나 마지막 삽입 위치가 어긋나는지 확인합니다.
  3. 교정: 조건을 j >= 0으로 수정하고, 루프 종료 뒤 arr[j + 1] = key를 보장합니다.
  4. 검증: 삽입/버블/선택 정렬 모두 [3, 4, 5]를 반환하는지 확인한 뒤 비교·교환 횟수를 함께 기록합니다.

입력 분포별 적용 관점

입력의 모양을 우선 보고 선택해야 합니다.

  • 거의 정렬된 데이터: 삽입 정렬이 유리합니다.
  • 교환 횟수를 직관적으로 확인하고 싶은 학습 단계: 버블 정렬이 유리합니다.
  • 비교는 많이 해도 쓰기(write) 횟수를 줄이고 싶은 경우: 선택 정렬을 고려합니다.

실무 정렬은 보통 내장 정렬을 쓰되, 문제 풀이와 알고리즘 학습 단계에서는 적용 관점을 명확히 기록하는 습관이 중요합니다.

기초 정렬: 삽입 정렬과 이동 비용

문제 의도: 정렬된 앞 구간 불변식을 유지하며 원소 이동 비용을 확인합니다.
입력 예시: nums=[7,3,5,2,9,1]
출력 예시: 오름차순 정렬 결과 [1,2,3,5,7,9]

main.cpp
#include <iostream>
#include <vector>
using namespace std;

vector<int> insertionSort(vector<int> arr) {
    for (int i = 1; i < (int)arr.size(); ++i) {
        int key = arr[i];
        int j = i - 1;
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
    return arr;
}

int main() {
    vector<int> nums = {7, 3, 5, 2, 9, 1};
    auto out = insertionSort(nums);
    for (int x : out) cout << x << " ";
    cout << "\n";
    return 0;
}
Main.java
import java.util.Arrays;

public class Main {
    static int[] insertionSort(int[] arr) {
        int[] a = arr.clone();
        for (int i = 1; i < a.length; i++) {
            int key = a[i];
            int j = i - 1;
            while (j >= 0 && a[j] > key) {
                a[j + 1] = a[j];
                j--;
            }
            a[j + 1] = key;
        }
        return a;
    }

    public static void main(String[] args) {
        int[] nums = {7, 3, 5, 2, 9, 1};
        System.out.println(Arrays.toString(insertionSort(nums)));
    }
}
main.py
def insertion_sort(arr):
    a = arr[:]
    for i in range(1, len(a)):
        key = a[i]
        j = i - 1

        while j >= 0 and a[j] > key:
            a[j + 1] = a[j]
            j -= 1

        a[j + 1] = key

    return a

nums = [7, 3, 5, 2, 9, 1]
print(insertion_sort(nums))
# 출력: [1, 2, 3, 5, 7, 9]
main.rs
fn insertion_sort(arr: &[i32]) -> Vec<i32> {
    let mut a = arr.to_vec();
    for i in 1..a.len() {
        let key = a[i];
        let mut j = i as i32 - 1;
        while j >= 0 && a[j as usize] > key {
            a[(j + 1) as usize] = a[j as usize];
            j -= 1;
        }
        a[(j + 1) as usize] = key;
    }
    a
}

fn main() {
    let nums = [7, 3, 5, 2, 9, 1];
    println!("{:?}", insertion_sort(&nums));
}

불변식 체크 해설

삽입 정렬의 핵심 불변식은 "i 직전 구간은 항상 정렬돼 있다"입니다. 이 불변식이 유지되는지 보면 디버깅이 쉬워집니다.

거의 정렬된 입력에서는 내부 while 반복 횟수가 작아져 평균 체감 성능이 크게 좋아집니다.

  • 평균 시간복잡도: O(N^2)
  • 최악 시간복잡도: O(N^2)
  • 최선 시간복잡도: O(N)
  • 공간복잡도: O(1)

기초 정렬: 조기 종료 버블 정렬

문제 의도: 조기 종료 플래그가 거의 정렬된 입력에서 성능을 개선하는지 확인합니다.
입력 예시: nums=[1,2,3,5,4]
출력 예시: 오름차순 정렬 결과 [1,2,3,4,5]

main.cpp
#include <iostream>
#include <vector>
using namespace std;

vector<int> bubbleSortOptimized(vector<int> arr) {
    int n = (int)arr.size();
    for (int end = n - 1; end > 0; --end) {
        bool swapped = false;
        for (int i = 0; i < end; ++i) {
            if (arr[i] > arr[i + 1]) {
                swap(arr[i], arr[i + 1]);
                swapped = true;
            }
        }
        if (!swapped) break;
    }
    return arr;
}

int main() {
    vector<int> nums = {1, 2, 3, 5, 4, 6};
    auto out = bubbleSortOptimized(nums);
    for (int x : out) cout << x << " ";
    cout << "\n";
    return 0;
}
Main.java
import java.util.Arrays;

public class Main {
    static int[] bubbleSortOptimized(int[] arr) {
        int[] a = arr.clone();
        int n = a.length;
        for (int end = n - 1; end > 0; end--) {
            boolean swapped = false;
            for (int i = 0; i < end; i++) {
                if (a[i] > a[i + 1]) {
                    int t = a[i];
                    a[i] = a[i + 1];
                    a[i + 1] = t;
                    swapped = true;
                }
            }
            if (!swapped) break;
        }
        return a;
    }

    public static void main(String[] args) {
        int[] nums = {1, 2, 3, 5, 4, 6};
        System.out.println(Arrays.toString(bubbleSortOptimized(nums)));
    }
}
main.py
def bubble_sort_optimized(arr):
    a = arr[:]
    n = len(a)

    for end in range(n - 1, 0, -1):
        swapped = False
        for i in range(end):
            if a[i] > a[i + 1]:
                a[i], a[i + 1] = a[i + 1], a[i]
                swapped = True
        if not swapped:
            break

    return a

nums = [1, 2, 3, 5, 4, 6]
print(bubble_sort_optimized(nums))
# 출력: [1, 2, 3, 4, 5, 6]
main.rs
fn bubble_sort_optimized(arr: &[i32]) -> Vec<i32> {
    let mut a = arr.to_vec();
    let n = a.len();
    for end in (1..n).rev() {
        let mut swapped = false;
        for i in 0..end {
            if a[i] > a[i + 1] {
                a.swap(i, i + 1);
                swapped = true;
            }
        }
        if !swapped {
            break;
        }
    }
    a
}

fn main() {
    let nums = [1, 2, 3, 5, 4, 6];
    println!("{:?}", bubble_sort_optimized(&nums));
}

조기 종료 효과 해설

버블 정렬은 비효율적이라는 인식이 강하지만, 조기 종료 플래그를 추가하면 이미 정렬된 데이터에서 빠르게 종료할 수 있습니다. 학습 관점에서는 인접 비교와 swap 타이밍을 시각적으로 추적하기 좋습니다.

  • 평균 시간복잡도: O(N^2)
  • 최악 시간복잡도: O(N^2)
  • 최선 시간복잡도(조기 종료): O(N)
  • 공간복잡도: O(1)

기초 정렬: 선택 정렬과 쓰기 횟수

문제 의도: 선택 정렬의 교환(write) 횟수 특성을 다른 기초 정렬과 비교합니다.
입력 예시: nums=[7,3,5,2,9,1]
출력 예시: 오름차순 정렬 결과 [1,2,3,5,7,9]

main.cpp
#include <iostream>
#include <vector>
using namespace std;

vector<int> selectionSort(vector<int> arr) {
    int n = (int)arr.size();
    for (int i = 0; i < n - 1; ++i) {
        int minIdx = i;
        for (int j = i + 1; j < n; ++j) {
            if (arr[j] < arr[minIdx]) minIdx = j;
        }
        if (minIdx != i) swap(arr[i], arr[minIdx]);
    }
    return arr;
}

int main() {
    vector<int> nums = {8, 4, 6, 2, 7, 3};
    auto out = selectionSort(nums);
    for (int x : out) cout << x << " ";
    cout << "\n";
    return 0;
}
Main.java
import java.util.Arrays;

public class Main {
    static int[] selectionSort(int[] arr) {
        int[] a = arr.clone();
        int n = a.length;
        for (int i = 0; i < n - 1; i++) {
            int minIdx = i;
            for (int j = i + 1; j < n; j++) {
                if (a[j] < a[minIdx]) minIdx = j;
            }
            if (minIdx != i) {
                int t = a[i];
                a[i] = a[minIdx];
                a[minIdx] = t;
            }
        }
        return a;
    }

    public static void main(String[] args) {
        int[] nums = {8, 4, 6, 2, 7, 3};
        System.out.println(Arrays.toString(selectionSort(nums)));
    }
}
main.py
def selection_sort(arr):
    a = arr[:]
    n = len(a)

    for i in range(n - 1):
        min_idx = i
        for j in range(i + 1, n):
            if a[j] < a[min_idx]:
                min_idx = j
        if min_idx != i:
            a[i], a[min_idx] = a[min_idx], a[i]

    return a

nums = [8, 4, 6, 2, 7, 3]
print(selection_sort(nums))
# 출력: [2, 3, 4, 6, 7, 8]
main.rs
fn selection_sort(arr: &[i32]) -> Vec<i32> {
    let mut a = arr.to_vec();
    let n = a.len();
    for i in 0..(n - 1) {
        let mut min_idx = i;
        for j in (i + 1)..n {
            if a[j] < a[min_idx] {
                min_idx = j;
            }
        }
        if min_idx != i {
            a.swap(i, min_idx);
        }
    }
    a
}

fn main() {
    let nums = [8, 4, 6, 2, 7, 3];
    println!("{:?}", selection_sort(&nums));
}

쓰기 비용 해설

선택 정렬은 비교 횟수는 많지만, 각 패스에서 최대 한 번만 교환하기 때문에 쓰기 횟수는 상대적으로 예측 가능합니다. 쓰기 비용이 큰 환경에서는 이런 특성을 이해하는 것이 도움이 됩니다.

  • 평균 시간복잡도: O(N^2)
  • 최악 시간복잡도: O(N^2)
  • 공간복잡도: O(1)

구현/운영 비용 상쇄 지점

세 알고리즘은 같은 차수의 복잡도를 가지지만, 어떤 비용이 더 민감한지에 따라 선택이 달라집니다.

  • 삽입 정렬: 거의 정렬된 입력에서 강함, 역순 입력에서 느림
  • 버블 정렬: 구현 단순, 큰 데이터에서 비효율
  • 선택 정렬: 교환 횟수 예측 쉬움, 안정 정렬이 아님

정답은 하나가 아니라 입력 성질과 목적 함수에 따라 달라집니다.

정렬 로그로 보는 실수 포인트

기초 정렬에서 자주 발생하는 실패를 케이스 기준으로 정리합니다.

  • 케이스 A - 경계 오프바이원: 오답은 for j in range(i-1, 0, -1)처럼 0번째 인덱스를 건너뛰는 것입니다. 디버깅은 앞부분 정렬이 끝나도 첫 원소가 밀리지 않는 현상으로 확인합니다. 교정은 루프 경계를 -1까지 열어 비교 누락을 제거합니다.
  • 케이스 B - 안정성 조건 파손: 오답은 삽입 정렬 비교를 >=로 두어 같은 키의 상대 순서를 뒤집는 것입니다. 디버깅은 (값, 원래 순번) 쌍을 넣어 순서가 보존되는지 확인합니다. 교정은 안정 정렬 요구일 때 > 비교를 유지합니다.
  • 케이스 C - 원본 배열 오염: 오답은 함수 인자로 받은 배열을 그대로 수정해 호출자 상태를 바꾸는 것입니다. 디버깅은 같은 입력을 두 번 호출했을 때 두 번째 결과가 달라지는지 확인합니다. 교정은 사본 정렬인지 제자리 정렬인지 API 계약을 명확히 분리합니다.
  • 케이스 D - 기저 입력 생략: 오답은 빈 배열/길이 1 배열 테스트를 생략하는 것입니다. 디버깅은 배포 후 특정 경로에서 인덱스 에러가 발생하는 패턴으로 나타납니다. 교정은 최소 입력 테스트를 회귀 세트에 고정합니다.

반례 세트를 미리 고정해 두면 회귀 버그를 줄일 수 있습니다.

정렬 연산 비용 모델 정리

입력 분포가 치우치면 같은 알고리즘이라도 성능 체감이 달라지므로, 적용 관점을 데이터 특성과 함께 기록해 두는 편이 안전합니다.

알고리즘평균 시간최악 시간공간안정성
버블 정렬O(N^2)O(N^2)O(1)안정
선택 정렬O(N^2)O(N^2)O(1)비안정
삽입 정렬O(N^2)O(N^2)O(1)안정

복잡도 표만 보는 습관에서 벗어나 입력 분포와 구현 목적까지 함께 적어 두는 것이 중요합니다.

재확인 시 안내점

  • 기초 정렬 예제를 벤치마크할 때는 파이썬 인터프리터 오버헤드 영향을 고려해야 합니다.
  • 숫자 외 타입(문자열, 객체)을 정렬할 때는 비교 기준을 우선 정의해야 합니다.
  • 학습 코드와 실전 코드를 분리해 관리하지 않으면 오해가 생기기 쉽습니다.
  • 큰 데이터 처리에서는 내장 정렬을 기본값으로 두고, 기초 정렬은 교육용/검증용으로 한정하는 것이 안전합니다.

정렬 구현 전후 점검 목록

  • 인덱스 경계(0..n-1)를 정확히 처리했는가
  • 비교 연산자(>, >=)가 안정성 요구와 일치하는가
  • 빈 배열/길이 1 배열/중복 다수 반례를 테스트했는가
  • 학습용 구현과 실전 내장 정렬 사용 기준을 분리했는가

기초 정렬 선택 정리

기초 정렬의 가치는 속도가 아니라 사고력 훈련에 있습니다. 정렬 기준, 불변식, 경계 조건을 명확히 설명할 수 있다면 고급 정렬을 다룰 때도 구현 안정성이 크게 올라갑니다.

목차