본문 바로가기

Study/Data Structure

[자료구조 스터디] chap.12 정렬

12.1 정렬이란?

12.2 선택 정렬

12.3 삽입 정렬

12.4 버블 정렬

12.5 쉘 정렬

12.6 합병 정렬

12.7 퀵 정렬

12.8 히프 정렬

12.9 기수 정렬

12.10 정렬 알고리즘의 비교

12.11 정렬의 응용: 영어 사전을 위한 정렬 

 


12.1 정렬 

정렬

- 물건을 크기 순으로 오름차순이나 내림차순으로 나열하는 것 (자료 탐색에서 필수!)

- 정렬 대상 => 일반적으로 레코드(record) ( 예를 들면 학생들이 레코드면, 학번 이름 등이 필드)

- 레코드는 필드라는 보다 작은 단위로 구성되고 키필드(이름 등)로 레코드와 레코드를 구별 

- 모든 경우에 최적인 정렬 알고리즘은 X

- 정렬 알고리즘 평가 기준 => 비교 횟수 빈도, 이동 횟수 빈도 (빅오표기법으로)

 

분류1 

- 단순, 비효율 => 삽입 정렬 , 선택 정렬 , 버블정렬

- 복잡, 효율 => 퀵정렬 , 히프정렬 , 합병정렬 , 기수정렬

 

분류2

- 내부 정렬 : 모든 데이터가 주기억장치에 저장된 상태에서 정렬 (여기서는 내부정렬만 다룸)

- 외부 정렬 : 외부기억장치에 대부분 데이터, 일부만 주기억장치에 저장된 상태에서 정렬

 

분류3

- 안정 O (같은 키 값  레코드들이 정렬 후에도 바뀌지 X) => 삽입 정렬 , 선택 정렬 , 합병정렬

- 안정 X  => 선택 정렬, 히프 정렬 

 

***in-place sorting 제자리 정렬: 입력 배열 이외에는 다른 추가 메모리를 요구하지 않는 정렬 방법


12.2 선택 정렬

선택정렬

 

 

- 정렬된 왼쪽 리스트와 정렬이 안 된 오른쪽 리스트 가정

- 초기에 왼쪽 0리스트 비어 있고, 정렬할 숫자들은 모두 오른쪽 리스트에 

- 오른쪽 리스트에서 가장 작은 숫자 선택해서 왼쪽 리스트로 이동하는 작업 반복 

 

 

 

 

 

 

선택정렬 알고리즘 

selection_sort(A,n)
1. for i<-0 to n-2 do
2.      least <- A[i], A[i+1],..., A[n-1] 중에서 가장 작은 값의 인덱스;
3.      A[i]와 A[least]의 교환;
4.      i++;

- i 값은 0~n-2까지만 변화되는 점. 왜냐면 만약 list[0]~list[n-1]까지 정렬 되어있으면 list[n-1]이 이미 가장 커서 n-1까지 정렬할 필요 X

선택정렬 코드 

#include <stdio.h>
#include <stdlib.h>
#define MAX_SIZE 10

#define SWAP(x,y,t)  ( (t)=(x), (x)=(y), (y)=(t) ) //세번의 이동(교환)

int list[MAX_SIZE];
int n; 

void selection_sort(int list[], int n) 
{
  int i,j,least,temp;
  for (i=0; i<n-1; i++)  //한 round마다 한 개의 record가 최종 정렬된 위치를 찾아 가면서, sorted/unsorted part 길이가 각각 1씩 증가/감소
    least = i;  //비교 횟수 = (n-1) + (n-2) + ... + 1 = n(n-1)/2 = O(n^2)
    for (j=i+1; j<n; j++) //최솟값 탐색
       if (list[j] < list[least]) least = j;
    SWAP(list[i], list[least], temp); //이동회수 = 3x(n-1)번의 swapping = O(n)
  }
}


//main 함수
int main(void)
{
  int i;
  n = MAX_SIZE;
  srand(time(NULL));
  for (i=0; i<n; i++)  //난수 생성 및 출력
    list[i] = rand() % 100;  //난수 발생 범위 0-99
    
  selection_sort(list, n); //선택정렬 호출
  for (i=0; i<n; i++)
    printf("%d", list[i]);
  printf("\n");
  return 0;
}

 

결과:

16 24 25 38 48 64 87 90 93 96

**만일 정렬해야할 데이터 셋이 이미 원하는 순서로 정렬되어 있어도 swapping(이동) 발생. 아래 if 문 추가하면 됨 

즉. 최소값이 자기 자신이면 자료이동 X

if ( i != least)

   SWAP(list[i], list[least], temp);    

 

선택정렬 복잡도 분석

- 비교 횟수(= 내부루프 실행 횟수) : (n-1) + (n-2) + ... + 1 = n(n-1)/2 = O(n^2)

- 이동 횟수(= 외부루프 실행 횟수) : 3(n-1)

-  전체 시간적 복잡도 : O(n^2) = 비교횟수 + 이동횟수 한 것 

-  안정성 만족 X. 같은 레코드가 있는 경우에 상대적인 위치 변경 가능  ( unstable, in-place sort ) 

- 메모리 사용 최소화 (고정된 양의 추가 메모리만 필요 ) ==> 공간 복잡도 : O(1)

 


12.3 삽입정렬 

삽입정렬 (insertion sort)

- 정렬되어 있는 부분에 새로운 레코드를 정렬이 유지되는 위치에 삽입 반복

 

 

 

 

 

 

 

 

삽입정렬 알고리즘

insertion_sort(A, n) //오름차순 
1. for i<-1 to n-1 do //A[0]은 기정렬된 것으로 간주하여 A[1]부터 시작 
2.      key <- A[i];
3.      j <- i-1;   //#3~#6 sorted part에서 A[i]가 순서있게 놓일 위치 찾는 과정 
4.      while j ≥ 0 and A[j] > key do
5.            A[j+1] <- A[j]; //key값이 들어갈 자리를 확보하기 우함 
6.            j <- j-1; //하나 더 앞에 것과 비교 
7.      A[j+1] <- key //while문에서 exit 할 때 마지막으로 비교된 element가 A[j] (0<=j<=i-1)이고 비어있는 element는 A[j+1]

(i=5) round에서 

(1) 만일 A[5]=key가 A[4]보다 크다면... j가 4이므로 하나 증가시켜 원래자리 A[5]에 key 값 저장

(2) 만일 A[5]=key가 A[0]보다 작다면... j가 -1이므로 하나 증가시켜 A[0]에 key값 저장 

 

삽입정렬 코드 

//삽입정렬
void insertion_sort(int list[], int n)
{
  int i, j, key; //추가 공간은 변수(key) 하나. 상수이므로 공간복잡도 O(1)
  for (i=1; i < n; i++)
  {
     key = list[i];
     for (j=i-1; j>=0 && list[j] > key; j--) 
        list[j+1] = list[j]; //레코드의 오른쪽 이동
     list[j+1]=key; 
  }
}

 

삽입정렬 복잡도 분석

(Inner for문 복잡도는 n 나눈 것 )

- 최선 : O(n) --->> 이미 정렬되어 있는 경우

     ㄴ 비교 n-1번 **바로 앞의 것과 1회씩만 비교 

        ㄴ 이동 2(n-1) (2번의 이동.) 

- 최악 : O(n^2) --->> 역순으로 정렬되어 있는 겨우 

    ㄴ모든 단계에서 앞에 놓인 자료 전부 이동

         ㄴ 비교: 1+2+ .... + (n-1) = n(n-1)/2 = O(n^2)

              ㄴ이동: n(n-1)/2 + 2(n-1) = O(n^2)

- 평균 : O(n^2) **small datasets에 적합

- 많은 이동 필요 -> 레코드가 크면 불리

- 안정된 정렬방법

- 대부분 정렬되어 있으면 효율적 

- **in-place sort 메모리 사용 최적화(고정된 양의 추가 메모리만 필요) ==> 공간복잡도 O(1)

 


12.4 버블 정렬 

버블정렬

 

- 인접한 2개의 레코드 비교하여 순서대로 되어 있지 않으면

서로 교환 

 

- **In each round, unsorted part에서 가장 큰 record가 최종 위치를 찾아 감 (여기서는 8). 마치 거품 보글보글 같다. 

 

 

버블정렬 알고리즘 

BubbleSort(A, n)
1.  for i<-n-1 to 1 do
2.       for j<-0 to i-1 do
3.              j와 j+1번째의 요소가 크기 순이 아니면 교환
4.              j++;
5.      i--;

 

버블정렬 코드 

#define SWAP(x,y,t)  ( (t)=(x), (x)=(y), (y)=(t) )
void bubble_sort(int list[], int n) //오름차순
{
   int i,j,temp;
   for (i=n-1; i>0; i--)
   {
      for (j=0; j<i; j++) //앞뒤의 레코드를 비교한 후 교체
         if (list[j] > list[j+1])
             SWAP(list[j], list[j+1], temp); //3회 이동 연산
   }
}

 

버블 정렬 복잡도 분석

- 비교 횟수 (최상, 평균, 최악 모두 일정) :1+2+...+(n-1) =  n(n-1)/2 = O(n^2) 

- 이동 횟수 

   ㄴ 최악 - 역순으로 정렬된 경우 이동 횟수 = 3 (swap이 3개 이동이라)* 비교 횟수

   ㄴ 최선 - 이미 정렬된 경우(이동 한 번도 발생X) = 이동 횟수 = 0

   ㄴ 평균 - O(n^2)

- 레코드의 이동 과다 : 이미 정렬 위치에 있어도 교환되는 일잉 일어남. 이동연산은 비교연산보다 더 많은 시간 소요

===>> 부분적으로 정렬된 데이터셋에서 더 효율적, small datasets에 적합, stable-sort, simplicity

 


12.5 쉘 정렬

셀 정렬

 

- 삽입정렬이 어느 정도 정렬된 배열에서 대단히 빠른 것에 착안.  (삽입 정렬은 이웃한 위치로만 이동하므로, 많은 이동에 의해서만 제자리 찾아감)

-> 요소들이 멀리 떨어진 위치로 이동 -> 적게 이동하여 제자리 찾을 수 있음

 

- 전체 리스트를 일정 간격(gap)의 부분 리스트로 나눔

==> 나뉘어진 각각의 부분 리스트를 삽입정렬 

그 후 간격을 1/2 줄여서 다시. 마지막은 간격 1

 

 

 

 

 

셀 정렬 프로그램 

//gap 만큼 떨어진 요소들을 삽입정렬
//정렬의 범위는 first에서 last

inc_insertion_sort (int list[], int first, int last, int gap)
{
   int i, j, key;
   for (i=first+gap; i<=last; i=i+gap) {
      key = list[i];
      for (j=i-gap; j>=first && key<list[j]; j=j-gap)
         list[j+gap] = list[j];
      list[j+gap] = key;
   }
}

//
void shell_sort (int list[], int n) //n=size
{
   int i, gap;
   for (gap=n/2; gap>0; gap=gap/2) {
     if ((gap%2)==0) 
        gap++;
     for (i=0; i<gap; i++) //부분 리스트의 개수는 gap
        inc_insertion_sort(list, i, n-1, gap);
   }
}

 

 

셀 정렬 복잡도 분석

- 셀 정렬 장점 ⓛ : 불연속적인 부분 리스트에서 원거리 자료 이동으로 보다 적은 위치교환 가능성 증가 (삽입정렬은 한칸씩만 이동 ㅜ)

- 셀 정렬 장점   : 부분 리스트가 점진적으로 정렬된 상태가 되므로 삽입정렬 속도 증가

- 시간 복잡도 ***gap(간격)에 많은 영향

  ㄴ 최악 : O(n^2) 

  ㄴ 평균 : O(n^1.5)

-**stable sort, in-place sort -> space complextity = O(1)

 


12.6 합병 정렬

합병 정렬 ( merge sort )

 

- 리스트를 두개의 균등한 크기로 분할하고 분할된 부분리스트를 정렬

- 정렬된 두 개의 부분 리스트를 합하여 전체 리스트를 정렬 

(divide and conquer 분할 정복 기법에 바탕)

 

 

 

 

 

2개의 정렬된 리스트 합하는 과정

 

합병정렬 알고리즘

1. 분할 (Divide) : 배열을 같은 크기의 2개의 부분 배열로 분할
2. 정복 (Conquer) : 부분 배열을 정렬.(합병 정렬 순환 적용)  부분 배열 크기가 충분히 작지 않으면... 재귀호출로 다시 분할 정복기법 적용
3. 결합 (Combine) : 정렬된 부분배열을 하나의 배열에 통합 ( 이 단계에서 정렬 수행!! )


merge_sort(list, left, right) :
1. if left < right 
2.       mid = (left + right) / 2;
3.       merge_sort(list, left, mid); ==> 분할 
4.       merge_sort(list, mid+1, right); ==> 분할
5.       merge(list, left, mid, right); ==> 합병

 

합병 알고리즘

 

//합병 알고리즘

merge(list, left, mid, right):
//2개의 인접한 배열 list[left..mid]와 list[mid+1..right]를 합병

i<-left;
j<-mid+1;
k<-left;
sorted 배열 생성;
while i <= mid and j <= right do
    if (list[i] < list[j] ) then
          sorted[k]<-list[i];
          k++;
          i++;
    else
          sorted[k]<-list[j];
          k++;
          j++;
요소가 남아있는 부분배열을 sorted로 복사;
sorted를 list로 복사;

**합병 중간 sorted 배열 C 필요. 배열 C는 임시 저장소이므로 원 배열 list[]에 copy해애 recursive call 이 제대로 수행 

==>> 추가 공간 필요, not in-place , 공간 복잡도 O(n)

 

합병정렬 프로그램 

int sorted[MAX_SIZE]; //추가 공간 필요
//i는 정렬된 왼쪽리스트에 대한 인덱스
//j는 정렬된 오른쪽리스트에 대한 인덱스
//k는 정렬될*** 리스트에 대한 인덱스

void merge(int list[], int left, int mid, int right)
{
  int i,j,k,l;
  i=left; j=mid+1; k=left;
  
  while (i<=mid && j<=right) { //분할 정렬된 list의 합병
     if (list[i] <= list[j]) sorted[k++] = list[i++];  //non-decreasing order -> 더 작은 것을 먼저 copy
     else sorted[k++] = list[j++];
  }
  
  if (i > mid) //왼쪽 배열A는 모두 copy 되었다는 뜻
     for (l=j; l<=right; l++)
        sorted[k++] = list[l];  //혹시 오른쪽 배열B에 남은 것 있으면 for문 실행 
  else  //남아 있는 레코드의 일괄 복사 
     for (l=i; l<=mid; l++)  //혹시 왼쪽 배열A에 남은 것 있으면 for문 실행 
        sorted[k++] = list[l];
    
  //배열 sorted[]의 리스트를 배열 list[]로 복사
  for (l=left; l<=right; l++)  //**sorted[]는 임시 저장소이므로 원 배열 list[]에 copy해야 recursive call이 제대로 수행
     list[l] = sorted[l];        // |right-left|만큼의 이동 연산 -> O(n)
     
 void merge_sort(int list[], int left, int right) 
{
  int mid;
  if (left < right)
  {
     mid = (left+right)/2; //리스트의 균등분할
     merge_sort(list, left, mid); //부분리스트 정렬
     merge_sort(list, mid+1, right); //부분리스트 정렬
     merge(list, left, mid, right); //합병
  }
}

**만일 record 개수가 33개인 dataset을 merge sort하면 merge() 함수는 총 몇번 호출?

recursive call 횟수는 log(n), n=dataset 크기 ( or 정렬하고자 하는 record 개수)

합병정렬 복잡도 분석 

merge 함수에서 비교 연산과 이동연산이 수행되는 것.

- 비교 횟수 :

크기 n 리스트 균등분배이므로(2^3->2^2->2^1->2^0) log(n)개의 레벨  + 각 레벨에서 모든 레코드 n개 비교하므로 n번 비교 연산 ==>> 최대 nlog2(밑)의 n(지수)번 필요

- 이동 횟수 : 

레코드의 이동이 각 패스에서 2n번 발생 ==> 전체 레코드 이동은 2n*log(n)번 발생

ㄴ 레코드 크기가 크면 매우 큰 시간적 낭비 / 레코드를 연결리스트로 구성하여 합병 정렬하면 매우 효율적

- 최적, 평균, 최악 모두 차이 없이 O(n*log(n))의 복잡도

- 안정적, 데이터 초기 분산 분포에 영향  ↓ ↓  ↓

- not in-place, 공간 복잡도 O(n)

 


12.7 퀵 정렬

퀵 정렬 ( quick sort ) 

 

- 평균적으로 가장 빠른 정렬 방법

- 분할정복법 사용

- 합병 정렬과 달리 리스트를 2개의 부분리스트로 비균등 분햘 -> 각각 부분리스트 다시 퀵정렬(재귀호출)

 

- **how to choose pivot? => 가능하면 선택된 pivot에 의해 얻어진 부분리스트의 길이가 비슷하도록. 즉, unsorted list 내 key들 중 중간 값 

 

퀵 정렬 알고리즘

void quick_sort(int list[], int left, int right)
{
   if (left < right)
   {
       int q = partition(list, left, right);
       quick_sort(list, left, q-1);
       quick_sort(list, q+1, right);
   }
}

 

퀵 정렬 코드 

int partition (int list[], int left, int right) //int q = partition(list, left, right);
{
  int pivot, temp;
  int low, high;
  
  low = left;
  high = right+1;
  pivot = list[left]; //현 리스트의 가장 왼쪽 record의 key가 pivot 
  
  do {
     do
       low++;
     while (low <= right && list[low] < pivot);
     do
       high--;
     while (high >= left && list[high] > pivot);
     if (low < high) SWAP(list[low], list[high], temp); //이때 high값이 pivot이 들어갈 자리이며 그 자리가 pivot 값의 최종자리!!
  } while (low < high); //if high < low then NO swapping and Done!
  
  SWAP(list[left], list[high], temp; //pivot(list[left])을 정렬된 자리로~!
  return high; //pivot이 정렬된 index를 반환
}


void quick_sort(int list[], int left, int right)
{
  if (left < right)
  { 
    int q = partition(list, left, right);
    quick_sort(list, left, q-1);
    quick_sort(list, q+1, right);
  }
}


int main(void)
{
  int i;
  n = MAX_SIZE;
  srand(time(NULL)); 
  for (i=0; i<n; i++) //난수 생성 및 출력
    list[i] = rand()%100;
    
  quick_sort(list, 0, n-1) //퀵 정렬 호출
  for (i=0; i<n; i++)
     printf("%d", list[i]);
  printf("\n");
  return 0;
}

실행결과 : 16 24 25 38 49 64 87 90 93 96

 

퀵 정렬 복잡도 분석 

- 최선 (거의 균등한 리스트로 분할한다고 가정)

  ㄴ 패스 수 : log(n)

  ㄴ 각 레벨에서 비교횟수: n

  ㄴ 총 비교횟수 : n*log(n)

  ㄴ 총 이동횟수 : 비교횟수에 비해 적으므로 무시 가능

  ㄴ **만일 pivot을 잘 정해서 두 sub list가 유사한 크기로 분할되면 quick_sort() 함수의 총 호출횟수는 O(logn) 

- 최악 (극도로 불균등한 리스트로 분할) 

  ㄴ 레벨 수 : n

  ㄴ 각 레벨 안 비교횟수: n

  ㄴ 총 비교횟수 : n^2

  ㄴ 총 이동횟수 : 무시 가능

  ㄴ **만일 pivot을 잘못 정해서 항상 두 sub list 중 하나의 크기가 1이면 quick_sort() 함수의 총 호출횟수는 O(n) 

- 중간값을 피벗으로 선택하면 불균등 분할 완화 가능

- 한번 선택된 pivot은 최종 자리 찾고 아후, 연산에서 배제

- 불필요한 데이터 이동 줄임 (fast and no extra memory needed)

-**fast, no extra memory needed, in-place sort algorithm


12.9 기수정렬

기수정렬 (Radix Sort)

 

 

- 대부분의 정렬 방법들은 레코드들을 비교함으로써 정렬 수행

- but 기수 정렬은 레코드를 비교하지 않고 정렬 수행

(비교 연산 전혀 사용하지 않음~!)

- O(dn)의 시간복잡도 가짐

(d:key값이 숫자인 경우, 자리수)

+ 추가적인 메모리를 필요

 

 

 

 

기수(radix) : 숫자의 자리수 (단계의 수는 자리수의 개수와 일치)

- 한자리수 기수정렬 : 단순히 자리수에 따라 버켓에 넣었다가 꺼내면 정렬 

- 두자리수 기수정렬 : 낮은 자리수로 먼저 분류한 다음 다시 높은 자리수로 분류

***추가 공간 필요. not in-place

기수정렬 알고리즘

RadixSort(list, n):
1. for d<-LSD의 위치 to MSD의 위치 do  {
2.       d번째 자리수에 따라 0번부터 9번 버켓에 넣는다
3.       버켓에서 숫자들을 순차적으로 읽어서 하나의 리스트로 합친다
4.       d++;
5. }
//LSD (Least Significant Digit) 가장 낮은 자리수 <-> MSD (Most)

 

- 버켓은 큐로 구현 (먼저 들어간 숫자들이 먼저 나와야 함)

- 버켓의 개수는 키의 표현 방법과 밀접한 관계 (알파벳문자면 버켓 26개)

- 패스의 개수는 자리 개수(d)에 밀접한 관계

기수정렬 프로그램 

//6장의 큐 소스를 여기에...
#define BUCKETS 10 //한 자리가 0~9까지 10가지로 표현되므로 bucket 수는 10
#define DIGITS 4 //key가 4자리 수 
void radix_sort(int list[], int n)
{ 
  int i, b, d, factor=1;
  QueueType queues[BUCKETS];
  
  for (b=0; b<BUCKETS; b++) //bucket만큼의 큐들의 초기화 
    init(&queues[b]); //큐들의 초기화
    
  for (d=0; d<DIGITS; d++) { //낮은 자리수부터 정렬!
    for (i=0; i<n; i++) //전체(n) 데이터들을 자리수에 따라 큐에 입력
       enqueue(&queues[(list[i]/factor) % 10], list[i]); //%10은 끝자리 반환
    for (b=i=0; b<BUCKETS; b++)  //버켓에서 꺼내어 list로 합침
      while (!is_empty(&queues[b]))
         list[i++] = dequeue(&queues[b]); //한 패스 정렬됨
    factor *= 10; //그 다음 자리수로 간다 d가 +1 => factor는 X10
  } //(outer for)d번 반복 X (inner for)n번 반복 => O(dn)
}


//define SIZE 10
int main(void)
{
  int list[SIZE];
  srand(time(NULL));
  for (int i=0; i<SIZE; i++) //난수 생성 및 출력
     list[i] = rand()%100;
     
  radix_sort(list, SIZE); //기수정렬 호출
  for (int i=0; i<SIZE; i++)
     printf("%d", list[i]);
  printf("\n");
  return 0;
}

실행 결과: 22 38 71 76 81 83 89 94 97 98

 

기수정렬 복잡도 분석

- n개의 레코드, d개의 자릿수로 이루어진 키를 기수 정렬할 경우 

ㄴ 메인 루프는 자릿수 d번 반복 / 큐에 n개의 레코드 입력 수행

- O(dn)의 시간적 복잡도  (d는 아주 작아서 O(n)이라고 해도 됨)

ㄴ 키의 자릿수 d는 10이하의 작은 수이므로 빠른 정렬

(실수, 한글, 한자로 이루어진 키는 정렬X 숫자만 가능)

***not in-place


12.10 정렬 알고리즘 비교

정렬 알고리즘들의 복잡도 비교