본문 바로가기
Game DevTip/Algorithm

4. 선택정렬과 이중 선택정렬에 대해서 알아보자.

by LIKE IT.라이킷 2025. 7. 26.

 

정렬 알고리즘의 기초 개념

정렬의 대상: 레코드(Record)

struct Student {
    int id;           // 학번
    string name;      // 이름
    int score;        // 점수
};

 

 

정렬의 대상: 레코드(Record)

 - 정렬키 : 정렬의 기준이 되는 필드

 - 위 예시에서 id, name, score 중 어느 것이든 정렬키가 될 수 있다. 

 

정렬 방향

 - 오름차순 (Ascending) : 작은 값 -> 큰 값

 - 내림차순 (Descending) : 큰 값 -> 작은 값

 

정렬 분류 : 메모리 사용에 따른 분류

1. 내부 정렬 (internal Sorting)

 - 정렬 대상을 한꺼번에 메인 메모리에 로딩 가능 할 때, 

 - 대부분의 기본 정렬 알고리즘이 해당된다. 

 - 빠른 접근 속도, 랜덤 엑세스가 가능하다. 

 

2. 외부 정렬 (External Sorting)

 - 정렬 대상을 메인 메모리에 한꺼번에 로딩할 수 없을 때, 

 - 메인 메모리와 보조 메모리(디스크)를 함께 사용한다. 

 - 대용량 데이터 처리에 필요하다. 

 

정렬 분류 : 안정성에 따른 분류. 

1. 안정 정렬(Stable Sort)

 - 값은 키 값을 갖는 데이터 간의 정렬 전 순서가 정렬 후에도 유지된다. 

 - 예시 : Insertion SOrt, Merget Sort, Bubble Sort

 

2. 불안정 정렬(Not Stable Sort)

 - 같은 키 값을 갖는 데이터의 순서가 바뀔 수 있는 정렬. 

 - 예시 : Quick OSrt, Heap Sort ,  Selection Sort

 

안정성 예시

정렬 전: [(3, A), (1, B), (3, C), (2, D)]

 

안정 정렬 후: [(1, B), (2, D), (3, A), (3, C)]

-> 같은 키 값 3을 가진 A와 C의 순서 유지

 

불안정 정렬 후: [(1, B), (2, D), (3, C), (3, A)]

-> A와 C의 순서가 바뀜

 

정렬 분류 : 정렬 방식에 따른 분류

1. 직접 정렬

 - 실제 데이터의 위치를 바꿈

 - 메모리 사용량은 적지만 데이터 이동 비용 발생

 

2. 간접 정렬

 - 인덱스(포인터)만 바꾸고 실제 데이터는 그대로

- 데이터 접근이 한단계 더 필요하지만 대용량 레코드에 유리하다. 

 


 

선택 정렬이란?

선택 정렬(Selection Sort)**은 가장 직관적인 정렬 알고리즘 중 하나로,
매 단계에서 가장 큰(또는 작은) 원소를 선택하여 올바른 위치에 배치하는 방식이다.

핵심 아이디어

  1. 전체 배열에서 가장 큰(또는 작은) 원소를 찾음
  2. 그 원소를 올바른 위치(끝 또는 시작)와 교환
  3. 정렬된 부분을 제외하고 위 과정을 반복

처리 과정

비교 → 선택(최대/최소값의 위치 찾기) → 교환

 

 

선택 정렬의 동작 원리

오름차순 정렬 과정

초기 배열: [64, 25, 12, 22, 11, 90]

1단계: 최대값 90을 마지막 위치로

[64, 25, 12, 22, 11, 90]
                    ↑   (최대값 90 찾음)

[64, 25, 12, 22, 11, 90]  (이미 올바른 위치)
정렬된 부분: [90]

2단계: 남은 부분에서 최대값 64를 끝으로

[64, 25, 12, 22, 11] | [90]
 ↑                     (최대값 64 찾음)

[11, 25, 12, 22, 64] | [90]  (64와 11 교환)
정렬된 부분: [64, 90]

3단계: 남은 부분에서 최대값 25를 끝으로

[11, 25, 12, 22] | [64, 90]
     ↑             (최대값 25 찾음)

[11, 22, 12, 25] | [64, 90]  (25와 22 교환)
정렬된 부분: [25, 64, 90]

4단계: 남은 부분에서 최대값 22를 끝으로

[11, 22, 12] | [25, 64, 90]
     ↑         (최대값 22 찾음)

[11, 12, 22] | [25, 64, 90]  (22와 12 교환)
정렬된 부분: [22, 25, 64, 90]

5단계: 남은 두 원소 비교

[11, 12] | [22, 25, 64, 90]
 ↑        (최대값 12 찾음)

[11, 12] | [22, 25, 64, 90]  (교환 필요 없음)
최종 결과: [11, 12, 22, 25, 64, 90]


코드 구현과 분석

기본 클래스 구조

#pragma once
#include <cassert>
#include <vector>
#include <iostream>
using namespace std;

// 정렬 알고리즘 기본 클래스
class Sort {
protected:
    vector<int> m_data;
    int m_size;

public:
    Sort() {}
    ~Sort() {}

    void initData(vector<int> data) {
        m_data = data;
        m_size = data.size();
    }

    void printAll() {
        for (int i = 0; i < m_size; i++) {
            cout << m_data[i] << " ";
        }
        cout << endl;
    }

    virtual void sorting() = 0;  // 순수 가상 함수
};

선택 정렬 구현 (오름차순)

#pragma once
#include "sorting.h"

class Selection : public Sort {
public:
    void sorting() override;
};

void Selection::sorting() {
    // 마지막 위치부터 첫 번째 위치까지 역순으로 진행
    for (int last = m_size - 1; last >= 1; --last) {
        int largestIndex = 0;  // 최대값의 인덱스

        // 0부터 last까지 범위에서 최대값 찾기
        for (int cur = 1; cur <= last; cur++) {
            if (m_data[cur] > m_data[largestIndex]) {
                largestIndex = cur;
            }
        }

        // 최대값을 현재 마지막 위치로 이동
        swap(m_data[last], m_data[largestIndex]);
    }
}

코드 상세 분석

1. 외부 루프 (정렬 범위 제어)

for (int last = m_size - 1; last >= 1; --last)

 

- last : 현재 정렬할 마지막 위치

- m_size -1 부터 1 까지 : 배열 끝에서부터 시작해서 점진적으로 범위 축소

- last >= 1. : 원소 하나 남으면 자동으로 정렬 완료. 

 

 

2. 최대값 찾기

int largestIndex = 0;  // 최대값 인덱스 초기화

for (int cur = 1; cur <= last; cur++) {
    if (m_data[cur] > m_data[largestIndex]) {
        largestIndex = cur;  // 더 큰 값 발견 시 인덱스 갱신
    }
}

3. 교환 연산

swap(m_data[last], m_data[largestIndex]);

 

 - C++ STL의 swap 함수 사용

 - 최대값값을 현재 정렬 범위의 마지막 위치로 이동. 

 

실행 과정 추적

입력 배열: [10, 7, 9, 1, 3, 2, 8, 6, 4, 5]

패스정렬 범위최대값최대값 위치교환 후 배열

1 [0..9] 10 0 [5, 7, 9, 1, 3, 2, 8, 6, 4, 10]
2 [0..8] 9 2 [5, 7, 4, 1, 3, 2, 8, 6, 9, 10]
3 [0..7] 8 6 [5, 7, 4, 1, 3, 2, 6, 8, 9, 10]
4 [0..6] 7 1 [5, 6, 4, 1, 3, 2, 7, 8, 9, 10]
5 [0..5] 6 1 [5, 2, 4, 1, 3, 6, 7, 8, 9, 10]
6 [0..4] 5 0 [3, 2, 4, 1, 5, 6, 7, 8, 9, 10]
7 [0..3] 4 2 [3, 2, 1, 4, 5, 6, 7, 8, 9, 10]
8 [0..2] 3 0 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
9 [0..1] 2 1 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

 


 

시간 복잡도 분석

수학적 분석

비교 횟수 계산

  • 1번째 패스: n-1번 비교 (전체 배열)
  • 2번째 패스: n-2번 비교 (마지막 원소 제외)
  • 3번째 패스: n-3번 비교
  • ...
  • (n-1)번째 패스: 1번 비교

총 비교 횟수:

T(n) = (n-1) + (n-2) + (n-3) + ... + 1
     = n(n-1)/2
     = (n² - n)/2
     = O(n²)

교환 횟수 계산

  • 각 패스마다 최대 1번의 교환
  • 총 교환 횟수: 최대 n-1번 = O(n)

경우별 분석

최선의 경우 (Best Case)

  • 배열이 이미 정렬된 경우
  • 비교 횟수: O(n²) (변화 없음)
  • 교환 횟수: 0번 (최대값이 이미 올바른 위치)
  • 전체 시간 복잡도: O(n²)

최악의 경우 (Worst Case)

  • 배열이 역순으로 정렬된 경우
  • 비교 횟수: O(n²)
  • 교환 횟수: n-1번
  • 전체 시간 복잡도: O(n²)

평균의 경우 (Average Case)

  • 비교 횟수: O(n²)
  • 교환 횟수: 평균 n-1번
  • 전체 시간 복잡도: O(n²)

공간 복잡도

  • O(1): In-place 정렬로 추가 메모리 거의 사용 안함

버블 정렬과의 비교

공통점

  • 시간 복잡도: 둘 다 O(n²)
  • 공간 복잡도: 둘 다 O(1) (In-place)
  • 안정성: 둘 다 기본적으로 불안정 (선택 정렬)

차이점 비교

구분선택 정렬버블 정렬

핵심 전략 최대/최소값을 찾아 한 번에 이동 인접 원소끼리 비교하며 점진적 이동
교환 횟수 각 패스마다 최대 1번 각 패스마다 여러 번
총 교환 횟수 최대 n-1번 최대 n(n-1)/2번
최선의 경우 O(n²) O(n)
실제 성능 버블 정렬보다 빠름 선택 정렬보다 느림
조기 종료 불가능 가능 (이미 정렬된 경우)

성능 차이의 원인

선택 정렬의 장점:

// 한 패스에서 교환은 최대 1번만
int maxIndex = findMaxIndex(0, last);
if (maxIndex != last) {
    swap(arr[last], arr[maxIndex]);  // 1번의 교환
}

버블 정렬의 단점:

// 한 패스에서 여러 번의 교환 발생
for (int i = 0; i < n-1; i++) {
    if (arr[i] > arr[i+1]) {
        swap(arr[i], arr[i+1]);  // 여러 번의 교환
    }
}


내림차순 구현

알고리즘 수정

오름차순에서는 최대값을 뒤로 보냈다면, 내림차순에서는 최대값을 앞으로 보냅니다.

void Selection::sorting() {
    // 첫 번째 위치부터 마지막 위치까지 순서대로 진행
    for (int first = 0; first < m_size - 1; ++first) {
        int bigIndex = first;  // 최대값의 인덱스

        // first+1부터 끝까지 범위에서 최대값 찾기
        for (int cur = first + 1; cur < m_size; cur++) {
            if (m_data[cur] > m_data[bigIndex]) {
                bigIndex = cur;
            }
        }

        // 최대값을 현재 첫 번째 위치로 이동
        swap(m_data[first], m_data[bigIndex]);
    }
}

동작 과정 예시

입력 배열: [10, 7, 9, 1, 3, 2, 8, 6, 4, 5]

1단계: 최대값 10을 첫 번째 위치로

[10, 7, 9, 1, 3, 2, 8, 6, 4, 5]
 ↑                               (최대값 10, 이미 올바른 위치)

결과: [10, 7, 9, 1, 3, 2, 8, 6, 4, 5]
정렬된 부분: [10]

2단계: 남은 부분에서 최대값 9를 두 번째 위치로

[10] | [7, 9, 1, 3, 2, 8, 6, 4, 5]
           ↑                        (최대값 9 찾음)

[10, 9, 7, 1, 3, 2, 8, 6, 4, 5]    (9와 7 교환)
정렬된 부분: [10, 9]

 

 


이중 선택 정렬이란?

일반 선택정렬은 한번에 하나의 원소만 정렬하지만, 이중 선택 정렬은 한번에 두개의 원소를 정렬한다. 

-  최대값 : 정렬 범위의 끝으로 이동

- 최소값 : 정렬 범위의 시작으로 이동. 

 

#pragma once
#include <cassert>
#include <vector>
#include <iostream>
using namespace std;

// 정렬 알고리즘 기본 클래스
class Sort {
protected:
    vector<int> m_data;
    int m_size;

public:
    Sort() {}
    ~Sort() {}

    void initData(vector<int> data) {
        m_data = data;
        m_size = data.size();
    }

    void printAll() {
        for (int i = 0; i < m_size; i++) {
            cout << m_data[i] << " ";
        }
        cout << endl;
    }

    virtual void sorting() = 0;  // 순수 가상 함수
};

class DoubleSelection : public Sort
{
public:
    void sorting() override;
};

void DoubleSelection::sorting()
{
    for(int first = 0; first < m_size / 2; ++first)
    {
        int last = m_size-1-first;
        int bigIndex = last;    // 최대값 인덱스
        int smallIndex = first; // 최소값 인덱스
        
        for(int cur = first; cur <= last; ++ cur)
        {
            if(m_data[cur] < m_data[smallIndex])
            {
                smallIndex = cur;
            }
            if(m_data[cur] > m_data[bigIndex])
            {
                bigIndex = cur;
            }
        }
        
        if(smallIndex != first)
        {
            swap(m_data[first], m_data[smallIndex]);
        }
        
        if(bigIndex == first)
        {
            bigIndex = smallIndex;
        }
        
        if(bigIndex != last)
        {
            swap(m_data[last], m_data[bigIndex]);
        }
    }
}

 

특별한 처리가 필요한 경우

1. 불필요한 교환 방지

if (smallIndex != first)  // 이미 올바른 위치가 아닐 때만 교환
if (bigIndex != last)     // 이미 올바른 위치가 아닐 때만 교환

2. 인덱스 갱신

if (bigIndex == first) {
    bigIndex = smallIndex;
}

 

최대값이 첫 번째 위치에 있었는데 최소 값과 교환했다면, 최대값의 새 위치를 갱신해야 한다. 

 

 

동작 과정 예시

입력 배열: [29, 10, 14, 37, 13]

1회전 (first = 0, last = 4)

범위: [29, 10, 14, 37, 13]
최소값: 10 (인덱스 1) → 위치 0으로
최대값: 37 (인덱스 3) → 위치 4로

결과: [10, 29, 14, 13, 37]
정렬된 부분: [10] ... [37]

2회전 (first = 1, last = 3)

범위: [29, 14, 13] (인덱스 1~3)
최소값: 13 (인덱스 4) → 위치 1로
최대값: 29 (인덱스 1) → 위치 3으로

결과: [10, 13, 14, 29, 37]
정렬된 부분: [10, 13] ... [29, 37]

3회전 (first = 2, last = 2)

범위: [14] (인덱스 2)
원소가 하나뿐이므로 정렬 완료

최종 결과: [10, 13, 14, 29, 37]

시간 복잡도 분석

비교 횟수

  • 일반 선택 정렬과 동일: O(n²)
  • 하지만 실제 비교 횟수는 약 절반

교환 횟수

  • 각 패스마다 최대 2번 교환
  • 총 교환 횟수: 최대 n번

전체 성능

  • 점근적 복잡도: 여전히 O(n²)
  • 실제 성능: 일반 선택 정렬보다 약 2배 빠름

 


장단점 분석

장점

1. 직관적이고 이해하기 쉬움

  • "가장 큰 것을 찾아서 뒤로 보낸다"는 명확한 논리
  • 디버깅과 검증이 용이

2. 교환 횟수가 적음

  • 각 패스마다 최대 1번의 교환 (일반 버전)
  • 교환 비용이 큰 경우 유리

3. In-place 정렬

  • 추가 메모리 공간 거의 불필요
  • 메모리 제약이 있는 환경에서 유용

4. 예측 가능한 성능

  • 입력 데이터에 관계없이 항상 O(n²)
  • 최선/최악의 경우 성능 차이가 거의 없음

단점

1. 느린 시간 복잡도

  • O(n²)로 대용량 데이터에 부적합
  • n이 커질수록 급격한 성능 저하

2. 불안정 정렬

  • 같은 키 값을 가진 원소들의 순서가 바뀔 수 있음
  • 안정성이 필요한 경우 부적합

3. 조기 종료 불가능

  • 이미 정렬된 배열이어도 전체 비교 수행
  • 버블 정렬 대비 적응성 부족

4. 캐시 효율성 부족

  • 비연속적인 메모리 접근 패턴
  • 현대 CPU의 캐시 시스템에 비효율적

실제 활용과 한계

사용하기 좋은 경우

1. 교육 목적

  • 정렬 알고리즘 학습의 첫 단계
  • 알고리즘 분석 연습에 적합

2. 매우 작은 데이터

  • 10~20개 이하의 원소
  • 구현 단순성이 성능보다 중요한 경우

3. 교환 비용이 큰 경우

struct LargeRecord {
    char data[1000];  // 큰 데이터 구조
    int key;
};

교환 횟수를 최소화해야 하는 상황

4. 메모리 제약이 극심한 환경

  • 임베디드 시스템
  • 추가 메모리를 전혀 사용할 수 없는 경우

사용하지 말아야 할 경우

1. 대용량 데이터

  • 1000개 이상의 원소
  • O(n log n) 알고리즘 사용 권장

2. 성능이 중요한 시스템

  • 실시간 시스템
  • 웹 서버, 데이터베이스 등

3. 안정성이 필요한 경우

  • 다중 키 정렬
  • 사용자 인터페이스 정렬

실제 성능 테스트

#include <chrono>
#include <random>
#include <algorithm>

void performanceComparison(int size) {
    vector<int> data(size);
    random_device rd;
    mt19937 gen(rd());
    uniform_int_distribution<> dis(1, 10000);

    for (int& num : data) {
        num = dis(gen);
    }

    // 선택 정렬 테스트
    vector<int> selectionData = data;
    auto start = chrono::high_resolution_clock::now();

    Selection selectionSort;
    selectionSort.InitData(selectionData);
    selectionSort.sorting();

    auto end = chrono::high_resolution_clock::now();
    auto selectionTime = chrono::duration_cast<chrono::milliseconds>(end - start);

    // 이중 선택 정렬 테스트
    vector<int> doubleData = data;
    start = chrono::high_resolution_clock::now();

    DoubleSelection doubleSort;
    doubleSort.InitData(doubleData);
    doubleSort.sorting();

    end = chrono::high_resolution_clock::now();
    auto doubleTime = chrono::duration_cast<chrono::milliseconds>(end - start);

    // 표준 정렬과 비교
    vector<int> stdData = data;
    start = chrono::high_resolution_clock::now();
    sort(stdData.begin(), stdData.end());
    end = chrono::high_resolution_clock::now();
    auto stdTime = chrono::duration_cast<chrono::milliseconds>(end - start);

    cout << "크기 " << size << "일 때:" << endl;
    cout << "선택 정렬: " << selectionTime.count() << "ms" << endl;
    cout << "이중 선택: " << doubleTime.count() << "ms" << endl;
    cout << "표준 정렬: " << stdTime.count() << "ms" << endl;
    cout << endl;
}

예상 성능 결과

데이터 크기선택 정렬이중 선택표준 정렬비고

100개 1ms 0.5ms 0.01ms 교육용으로 적합
1,000개 10ms 5ms 0.1ms 성능 차이 명확
10,000개 1000ms 500ms 1ms 실용성 없음
100,000개 100s 50s 10ms 사용 불가

다른 O(n²) 정렬과의 비교

성능 순서 (일반적인 경우)

  1. 삽입 정렬 - 거의 정렬된 데이터에서 최고 성능
  2. 선택 정렬 - 교환 횟수가 적어 안정적
  3. 버블 정렬 - 가장 비효율적

특성 비교표

정렬 알고리즘 시간복잡도 안정성 교환횟수 적용성 추천도
선택 정렬 O(n²) 불안정 O(n) 없음 중간
삽입 정렬 O(n²) 안정 O(n²) 높음 좋음
버블 정렬 O(n²) 안정 O(n²) 중간 별로

 


 

마무리

핵심 정리

1. 선택정렬은 직관적이고 구현이 간단한 기본정렬 알고리즘. 

2. 교환 횟수가 적어 교환 비용이 큰 경우에 유리. 

3. 시간복잡도는 O(n^2)로 대용량 데이터에는 부적합. 

4. 이중 선택 정렬로 실제 성능을 약 2배 향상시킬 수 있음.

5. 교육목적과 소규모 데이터를 제외하고는 실무에서 사용하지 않음. 

반응형

댓글