IT_Architecture/자료구조 · 알고리즘

C로 구현한 정렬 (sort) 알고리즘

JJun ™ 2007. 12. 25. 11:32
 

정렬 (sort)

 

- 정렬의 종류

◇ 삽입법 : 삽입정렬, 쉘정렬
◇ 선택법 : 선택정렬, 힙정렬
◇ 교환법 : 버블정렬, 힙정렬
◇ 병합법 : 병합정렬
◇ 기타    : 카운트 정렬

 

- 알고리즘별 속도 비교

 

최악

평균

최선

추가 메모리

버블 정렬

O(n^2)

O(n^2)

O(n^2)

필요 없음

선택 정렬

O(n^2)

O(n^2)

O(n^2)

필요 없음

삽입 정렬

O(n^2)

O(n^2)

O(n)

필요 없음

퀵 정렬

O(n^2)

O(n log n)

O(n log n)

필요 없음

합병 정렬

O(n log n)

O(n log n)

O(n log n)

원소 수 만큼

힙 정렬

O(n log n)

O(n log n)

O(n log n)

필요 없음

 

 여기서 데이터 갯수가 5000개 일 때의 속도를 계산해보면

▷ 버블, 선택, 삽입 : O( n^2 ) = 25000000
▷ 쉘 정렬              : O(n^1.2) = 약 27464
▷ 퀵정렬               : O(n log n) = 약 18495

 

 이론상 가장 빠른 속도는 O(n log n)이다.

위의 표를 보면 힙 정렬과 합병정렬이 모든 경우에 있어 가장 빠른 속도를 보이는 것을 알 수 있다.

하지만 추가적인 메모리 사용에 있어 힙 정렬이 더 유리하다. 그렇지만 힙 정렬이 모든 경우에

다 좋은 것은 아니다. 버블 정렬 같은 경우 간단한 알고리즘으로 인해 소스가 가장 짧지만

힙 정렬이나 합병정렬은 소스가 길어졌다.

(소스코드에서 힙정렬과 병합정렬을 제외하고)

위의 값에서 보면 알겠지만, 퀵정렬이 가장 빠르고 다음이 쉘정렬, 나머지가 이론상으로는 제일 느리다. 하지만 이 값들은 정렬할 자료의 양이나 초기의 배열상태에 따라 다를 수가 있다.

바꿔 말해서 자료의 양이 한 10이고 몇 개밖에 안 흐뜨러져 있으면 오히려 퀵정렬의 속도가

다른 것보다 느려질 수 있다. 그래서 자신이 사용할 자료의 성격을 잘 파악한 뒤에 가장 효율적인

알고리즘을 선택해야 한다. 

참고로 프로파일링을 해본 결과는 다음과 같다.(자료는 랜덤으로 생성시켰고, 갯수는 5000개이다.)

Func Time

Function

1302.544

Bubble

715.201

Select

495.086

Select2

96.696

Insert

0.226

Shell

0.001

Quick

 

- 힙 정렬 (Heap Sort)

void heap_sort(int *list, int n)
{
     int i, temp;
     for(i=(n/2); i>=1; i--)   // 초기 히프 만들기
          adjust(list, i, n);
     for(i=(n-1); i>=1; i--) {  // 히프 정렬의 두 번째 단계
          temp = list[i+1];      
// 마지막 노드와 뿌리 노드의 교환
          list[i+1] = list[1];
          list[1] = temp;
          adjust(list, 1, i);     
// i개의 키에 대하여 adjust 적용
     }
}

void adjust(int *list, int i, int n)
// i : adjust 알고리즘을 시작하는 노드의 인덱스
// n : 전체 노드의 개수

{
     int j, k, done;

      done = 0;     // 아직 끝나지 않았음을 표시
      k = list[i];    // 뿌리 노드의 값, 즉 옮겨야 할 원소의 값
      j = 2 * i;       // i 노드의 좌 자 노드

      while ((j <= n) && (!done)) {    // 자식노드가 있고 not done일 때까지 반복
           if ( j < n)      
// j + 1 <= n 과 마찬가지인데 우 자 노드의 존재를 검사
           if (list[j] < list[j + 1])       
           j ++;        // 자 노드들 중 큰 노드를 선택

           if (k >= list[j])
                done = 1;     
// 자 노드보다 크므로 수행을 중단

           else{
                list[j / 2] = list[j];       
// 자 노드를 부노드로 끌어 올림,
                                                // 자 노드에 k 값을 쓰지 않는 이유는 나중에
                                                // 수행이 다 끝난 다음에 쓰기 때문임.

                 j *= 2;
          }
     }
     list[j / 2] = k;  
 // 수행이 끝나면 찾아낸 위치에 맨 처음 저장한 뿌리 노드의 값을 저장

 

- 병합정렬 (Merge Sort)

#define MAXLENGTH 1024

void merge_sort(int *list, int n)
{
     int len;
     int temp[MAXLENGTH];     
// 중간에 저장을 위해 사용하는 기억 장소

     len = 1;          
// 처음 합병할 리스트들의 길이는 1이다.
     while (len < n) {  
 // 합병된 리스트의 길이가 n이 될 때까지 반복
          merge_pass(list, temp, n, len);      
 // x에 저장된 리스트를 y에 합병
          // 이 과정이 끝나고 나면 리스트 전체가 y 에 들어가 있기 때문에 이를
          // 다시 x로 옮겨야 한다. 이 때 그냥 복사하는 것이 아니고
          // merge_pass를 통해서 한 단계 더 정렬을 시도한다.

 
          len *= 2;    
// 한 번의 merge_pass 후에는 합병할 리스트의 길이가 2 배가 된다.
          merge_pass(temp, list, n, len);    // y에 저장된 리스트를 x에 합병
          len *= 2;
          
// 다시 x에 저장이 된다.
     }
}

void merge_pass (int *x, int *y, int n, int len)
{
     int i, j;
     i = 1;

     while (i <= (n - 2 * len + 1)) {
     //  윗 줄에서 n - 2 * len+ 1은 길이가 len인 두 개의 리스트가
     // 존재할 때 까지를 의미한다. 만약 i 가 n - 2 * len+ 1 보다 크게 되면
     // 처리하고 남아 있는 부분이 길이가 length인 두 개의 리스트를
     // 형성하지 못한다. 이럴 경우는 다른 방식으로 처리해야하기 때문에
     // while 루프의 조건문이 이렇게 형성되어 있다.
   
     // i + len- 1은 첫번째 리스트의 마지막 인덱스이고,
     // i + 2 * len- 1은 두번째 리스트의 마지막 인덱스이다.
          twoway_merge (x, y, i, i + len- 1, i + 2 * len- 1);
          i += 2 * len;      
// i 는 len의 2 배만큼씩 증가시킨다.
    }
    if ((i + len- 1) < n)
  
  // 남은 두 리스트를 합병하는데 두 번째 리스트의 마지막 인덱스는
    // n 이 된다. 따라서 두 번째 리스트는 길이가 len보다 작게 된다.
         twoway_merge (x, y, i, i + len - 1, n);     
    
    else {
    // 길이가 len보다 작은 리스트가 없는 경우는 그냥 복사한다
         for (j = i; j <= n; j++)                
              y[j] = x[j];
    }
}

void twoway_merge(int *x, int *z, int l, int m, int n)
{
     int i, j, k, t;

     i = l;           // 첫 번째 리스트의 시작 인덱스
     j = m + 1;   
// 두 번째 리스트의 시작 인덱스
     k = l;         
// 합병 결과를 저장할 리스트의 시작 인덱스,
                   
  //  이 인덱스는 주어진 내용이 l 부터 n 까지를 합병하는 것이기
                     // 때문에 새로운 리스트에서도 l 부터 합병해야 한다.


     while ((i <= m ) && ( j <= n)) {   // 두 리스트 중 하나가 끝날 때까지 반복
          if (x[i] <= x[j]) {    // 첫 번째 리스트가 작은 경우
               z[k] = x[i];   
                i++;                 
// 인덱스 이동
         
          } else {    
// 두 번째 리스트가 작은 경우
               z[k] = x[j];  
               j++;     
// 인덱스 이동
          }
          k++;
     }  

     if (i > m)     
// 첫 번째 리스트는 다 사용했음을 의미함
          for (t = j; t <= n; t++)   
               z[k + t - j] = x[t];     
 // 두 번째 리스트를 복사
     
     else
          for (t = i; t <= m; t++)
               z[k + t - i] = x[t];      
// 두 번째 리스트를 복사
}

 

- 카운트 정렬

원리

 정렬을 한다는 것은 사실상 원소를 재배치하는 작업을 말합니다. 즉 정렬하기 전에 있었던 위치에서

  새로운 위치로 이동하는 것이죠. 그러데 그 이동하는 규칙은 무엇일까요? 바로 자신보다 작은 원소는

  모두 자신 앞에 위치해야 하고, 큰 원소는 모두 뒤에 위치해야 한다는 것 아닐까요?

  따라서 정렬이 끝난 후 새로 배치될 자신의 위치는 자신보다 작은 원소 개수에 의존한다는 것을 알 수

  있죠. 여기서 '의존한다'라는 추상적인 단어를 사용했는데, 정확히 말하면 자신보다 작은 원소 개수에다

  1을 더한 위치에 존재하는 것입니다. 이 카운트 정렬은 바로 앞서 설명한 원리를 이용한 것입니다.

    typedef struct data_ {
         int key;
         int count;
    } data;

    void count_all(data *list, int n)
    {
         int i, j;
         for(i=1; i<=n; i++)
              for(j=1; j<i; j++) {
                   if(list[i].key > list[j].key)
                        list[i].count++;
                   else
                        list[j].count++;
              }
    }

    // 메모리를 더 사용하는 알고리즘
    void count_sort1(data *x, int n)
    {
         int i;
         data y[MAXDATA];

         for(i=1; i<=n; i++)
              y[i] = x[i];
         count_all(y, n);
         for(i=1; i<=n; i++)
             x[y[i].count+1] = y[i];
    }

    // 메모리를 더 사용하지 않는 방법
    void count_sort2(data *list, int n)
    {
         int i, k;

         count_all(list, n);    
    // count를 한다.
         for(i=1; i<n; i++) {
              k = list[i].count+1;  
    // i 번째 원소가 들어갈 위치 계산
              while(k!=i) {         
    // i 번째 원소가 제 위치에 있지 않을 경우
                   swap(list[i], list[k]);  
    // 두 원소의 위치를 바꾼다.
                   k = list[i].count+1;
              }
         }
    }    

 

- 버블정렬 (Bubble Sort)

순서

1. 우선 맨 처음에 모든 원소들의 최대값을 찾으려면 맨 처음 원소와 둘째 원소를 비교한다.

    만약 앞의 원소가 뒤의 원소보다 크면 두 원소의 자리를 바꾼다.

2. 둘째 원소와 셋째 원소를 비교해 자리를 바꾼다.

3. 이 과정을 마지막 원소까지 반복하면 맨 마지막 자리에 제일 큰 원소가 자리잡게 된다.

4. 최대값을 가진 원소를 제외하고 나머지에 대해서 이 과정을 반복하면 그 다음으로 큰 원소를

    찾아내 마지막에서 둘째 자리에 넣게 된다.

5. 과정을 끝까지 반복하면 모든 원소가 오름차순으로 정리된다.

 

    void Bubble(int item[], int count)
    {
            register int i, j, temp;

            for(i=0; i<count-1; i++) {
                    for(j=0; j<count-i-1; j++) {
                           
     // 오름차순이 되게 비교를 하여 데이터 교환
                            if(item[j] > item[j+1]) {
                                    temp = item[j];
                                    item[j] = item[j+1];
                                    item[j+1] = temp;
                            }
                    }
            }
    }

 

- 선택정렬 (Select Sort)

    void Select(int item[], int count)
    {
            register int i, j, temp;

            for(i=0; i<count-1; i++) {
                    for(j=i+1; j<count; j++) {
                            
    // 비교한 후 교환
                            if(item[i] > item[j]) {
                                    temp = item[j];
                                    item[j] = item[i];
                                    item[i] = temp;
                            }
                    }
            }
    }

위의 함수를 보면 버블정렬과 거의 외관상 비슷하다. 하지만 위와 같이 만들면 좀 비합리적이다.

좀 유심히 살펴보면 알겠지만 킷값보다 작은 것이 나타나서 자리바꿈이 일어난 뒤에도 계속 비교를

하여 또 작은 것이 나타나면 같은 일(자리바꿈)을 반복하게 된다. 그래서 안쪽 루프를 돌면서 자리바꿈을

하지 말고, 단지 일어날 가장 최대치의 자리만 기억했다가 루프를 빠져나오면서 그 일을 하면 한번만

하게 되어 좀더 속도를 빠르게 할 수 있다. 그 개선된 함수는 다음과 같다.

 

- 개선된 선택정렬

    void Select2(int item[], int count)
    {
            register int i, j, temp, k;

            for(i=0; i<count-1; i++) {
                    k = i;
                    temp = item[i];
                    for(j=i+1; j<count; j++) {
                            if(temp > item[j]) {
                                    
    // 자리바꿈할 위치를 기억
                                    k = j;
                                    temp = item[j];
                            }
                    }
                    
    // 기억한 자리에서 자리바꿈
                    item[k] = item[i];
                    item[i] = temp;
            }
    }

 

- 삽입정렬 (Insert Sort)

    void Insert(int item[], int count)
    {
            register int i, j, temp;

            for(i=0; i<count; i++) {
                    temp = item[i];
    // 삽입할 키값을 기억
                    j = i-1;   
    // j는 삽입시 뒤로 밀려나는 것의 위치
                    while(j >= 0 && item[j] > temp) {
                            item[j+1] = item[j];
                            j--;
                    }
    // 삽입할 위치 결정및 뒤로 이동시키는 작업을 함
                    item[j+1] = temp;
            }       
    }

 

- 쉘정렬 (Shell sort)

    void Shell(int item[], int count)
    {
            register int begin, inc=count;

            do {
                    inc = inc/3+1;
    // 간격을 계산한다.
                    
                    for(begin=0; begin<inc; begin++)
                            InsertSort(begin, inc, item, count);
                            // 계산된 간격으로 삽입정렬을 한다.     
            } while(inc>1);
    }       

    // 쉘정렬에 사용된 삽입정렬
    void InsertSort(int start, int inc, int item[], int count)
    {
            register int i, j, temp;

            for(i=start; i<count; i+=inc) {
                    temp = item[i];
                    j = i-inc;    
                    while(j >= 0 && item[j] > temp) {
                            item[j+inc] = item[j];
                            j-=inc;
                    }
                    item[j+inc] = temp;
            }               
    }

 

- 퀵정렬 (Quick Sort)

순서

1. 어떤 특정 값을 기준으로 한 리스트를 두 개의 리스트로 분할한다. 그 중 한 리스트에는 그 기준값보다

    작은 원소만 존재하고, 다른 리스트에는 그 기준값보다 큰 원소만 존재하도록 리스트를 분할한다.

2. 분할된 2개의 리스트를 또 다시 1과 같은 방식으로 분할한다.

3. 이 과정을 모든 리스트의 크기가 1이 될 때까지 반복하면 정렬이 완료된다.

    void Quick(int item[], int count)
    {
            qs(item, 0, count-1);
    }

    void qs(int item[], int left, int right)
    {
            register int i=left, j=right+1, pivot, temp;

            pivot = item[left];  // 비교할 중간의 값을 맨 왼쪽에서 따옴

            // 재귀호출의 BASE CASE
            if(left<right) {
                    do {    
                            
    // 자리바꿈할 위치를 찾는다.
                            do i++; while(item[i] < pivot);
                            do j--; while(item[j] > pivot);

                            // 자리바꿈을 한다.
                            if(i<j) {
                                    temp = item[i];
                                    item[i] = item[j];
                                    item[j] = temp;
                            }
                    } while(i<j);

                    item[left] = item[j];
                    item[j] = pivot;

                    // 나뉘어진 각각에 대해 다시 퀵정렬 실시
                    qs(item, left, j-1);
                    qs(item, j+1, right);
            }
    }

 

- 테스트 프로그램의 코드에 관해서

 컴파일러는 VC++ 6.0을 사용하였다. 다음은 테스트 해본 프로그램의 소스에 대한 설명이다.

다음에 쓰인 샘플 프로젝트는 여기서 받으면 되죠..

1. SDI, 프로젝트명은 Exam 으로 정했다.

 

2. 데이터 갯수를 임의로 바꾸어 보기 위해 다음을 뷰의 헤더파일에 추가한다.

        #define DATA_NUM 5000

 

3. 다음 함수들을 뷰 클래스에 만든다.

void CExamView::Bubble(int item[], int count)
void CExamView::Select(int item[], int count)
void CExamView::Select2(int item[], int count)
void CExamView::Insert(int item[], int count)
void CExamView::Shell(int item[], int count)
void CExamView::InsertSort(int start, int inc, int item[], int count)
void CExamView::Quick(int item[], int count)
void CExamView::qs(int item[], int left, int right)

 

4. 뷰 클래스에 다음 변수들을 추가한다. 이 변수들은 각 정렬함수들의 속도를 받아 화면에 그 값들을 출력하기 위한 것이다.

        CString StrBubble, StrSelect, StrSelect2;
        CString StrInsert, StrShell, StrQuick;

 

5. 클래스 위저드에서 WM_INITIALUPDATE 메시지를 오버라이드하고, 다음과 같이 고친다.

 간단히 설명하자면 srand(time(NULL)); 는 실행할 때마다 새로운 난수가 발생하도록 초기화해주는 것이다. 만약 이렇게 초기화 해주지 않으면 항상 같은 난수가 발생할 것이다. 그리고 각 정렬함수에 앞서 memcpy 함수를 사용한 것은 각 정렬함수가 앞의 정렬함수에 영향을 받지 않게 하기 위해서이다. 그러니까 각 정렬함수는 같은 값을 가지고 정렬을 하게 된다.

 

void CExamView::OnInitialUpdate()
{
        CView::OnInitialUpdate();
        
        // TODO: Add your specialized code here and/or call the base class
        int num[DATA_NUM], origin_num[DATA_NUM];
        clock_t start, finish;
        double duration;

        srand(time(NULL));   // 난수 발생 초기화
        for(int i=0; i<DATA_NUM; i++)
                num[i] = rand();  

        memcpy(origin_num, num, DATA_NUM);  // 원래값 기억
        
        // 버블정렬 검사   
        start = clock();
                Bubble(num, DATA_NUM);
        finish = clock();
        duration = (double)(finish-start);
        StrBubble.Format("Bubble Sort -> %f", duration);

        memcpy(num, origin_num, DATA_NUM);
        
        // 선택정렬 검사  
        start = clock();
                Select(num, DATA_NUM);
        finish = clock();
        duration = (double)(finish-start);
        StrSelect.Format("Select Sort -> %f", duration);

        memcpy(num, origin_num, DATA_NUM);
        
        
// 개선된 선택정렬 검사  
        start = clock();
                Select2(num, DATA_NUM);
        finish = clock();
        duration = (double)(finish-start);
        StrSelect2.Format("Enhanced Select Sort -> %f", duration);

        memcpy(num, origin_num, DATA_NUM);

        // 삽입정렬 검사  
        start = clock();
                Insert(num, DATA_NUM);
        finish = clock();
        duration = (double)(finish-start);
        StrInsert.Format("Insert Sort -> %f", duration);

        memcpy(num, origin_num, DATA_NUM);

        // 쉘정렬 검사  
        start = clock();
                Shell(num, DATA_NUM);
        finish = clock();
        duration = (double)(finish-start);
        StrShell.Format("Shell Sort -> %f", duration);

        memcpy(num, origin_num, DATA_NUM);

        // 퀵정렬 검사  
        start = clock();
                Quick(num, DATA_NUM);
        finish = clock();
        duration = (double)(finish-start);
        StrQuick.Format("Quick Sort -> %f", duration);
}

6. 화면에 값들을 출력하는 onDraw 함수는 다음과 같이 고친다. 각 CString형 변수들은 프로그램이 처음 시작될 때 실행되는 onInitialUpdate 함수에서 값들이 입력된다.

void CExamView::OnDraw(CDC* pDC)
{
        CExamDoc* pDoc = GetDocument();
        ASSERT_VALID(pDoc);
       
 // TODO: add draw code for native data here
        pDC->TextOut(10, 10, StrBubble);
        pDC->TextOut(10, 25, StrSelect);
        pDC->TextOut(10, 40, StrSelect2);
        pDC->TextOut(10, 55, StrInsert);
        pDC->TextOut(10, 70, StrShell);
        pDC->TextOut(10, 85, StrQuick);
}

- end of this article -

exam.zip
0.04MB