經典排序算法總結(快速排序、冒泡排序、二叉樹排序...)

排序(Sorting) 是計算機程序設計中的一種重要操作,它的功能是將一個數據元素(或記錄)的任意序列,重新排列成一個關鍵字有序的序列。 
  我整理了以前自己所寫的一些排序算法結合網上的一些資料,共介紹8種常用的排序算法,希望對大家能有所幫助。

八種排序算法分別是: 
1.冒泡排序; 
2.選擇排序; 
3.插入排序; 
4.快速排序; 
5.歸併排序; 
6.希爾排序; 
7.二叉排序; 
8.計數排序;

其中快排尤爲重要,幾乎可以說IT開發類面試必考內容,而希爾排序和歸併排序的思想也非常重要。下面將各個排序算法的排序原理,代碼實現和時間複雜度一一介紹。

—,最基礎的排序——冒泡排序 
  冒泡排序是許多人最早接觸的排序算法,由於邏輯簡單,所以大量的出現在計算機基礎課本上,作爲一種最基本的排序算法被大家所熟知。

設無序數組a[]長度爲N,以由小到大排序爲例。冒泡的原理是這樣的: 
  1.比較相鄰的前兩個數據,如果前面的數據a[0]大於後面的數據a[1] (爲了穩定性,等於不交換),就將前面兩個數據進行交換。在將計數器 i ++; 
  2.當遍歷完N個數據一遍後,最大的數據就會沉底在數組最後a[N-1]。 
  3.然後N=N-1;再次進行遍歷排序將第二大的數據沉到倒數第二位置上a[N-2]。再次重複,直到N=0;將所有數據排列完畢。

 

無序數組:  2 5 4 7 1 6 8 3 
遍歷1次後: 2 4 5 1 6 7 3 8
遍歷2次後: 2 4 1 5 6 3 7 8
...
遍歷7次後: 1 2 3 4 5 6 7 8 12345

可以輕易的得出,冒泡在 N– 到 0 爲止,每遍近似遍歷了N個數據。所以冒泡的時間複雜度是 -O(N^2)。

按照定義實現代碼如下:

 

void BubbleSore(int *array, int n)
{   
    int i = 0;
    int j = 0;
    int temp = 0;

    for(i = 0; i < n; ++i){ 
        for(j = 1; j < n - i; ++j){
            if(array[j - 1] > array[j]){
                 temp = array[j-1];
                 array[j - 1] = array[j];
                 array[j] = temp;
            }
        }
    }
}12345678910111213141516

我們對可以對冒泡進行優化,循環時,當100個數據,僅前10個無序,發生了交換,後面沒有交換說明有序且都大於前10個數據,那麼以後循環遍歷時,就不必對後面的90個數據進行遍歷判斷,只需每遍從0循環到10就行了。

 

void BubbleSore(int *array, int n) //優化
{   
    int i = n;
    int j = 0;
    int temp = 0;
    Boolean flag = TRUE; 

    while(flag){
    flag = FALSE;  
        for(j = 1; j < i; ++j){
            if(array[j - 1] > array[j]){
            temp = array[j-1];
            array[j - 1] = array[j];
            array[j] = temp;
            flag = TRUE;
            }           
         }
         i--;
    }   
}1234567891011121314151617181920

雖然我們對冒泡進行了優化,但優化後的時間複雜度邏輯上還是-O(n^2),所以說冒泡還是效率比較低下的,數據較大時,建議不要採用冒泡。

二,最易理解的排序——選擇排序 
如果讓一個初學者寫一個排序算法,很有可能寫出的就是選擇排序(反正我當時就是 ^.^),因爲選擇排序甚至比冒泡更容易理解。

原理就是遍歷一遍找到最小的,與第一個位置的數進行交換。再遍歷一遍找到第二小的,與第二個位置的數進行交換。看起來比較像冒泡,但它不是相鄰數據交換的。

無序數組:  2 5 4 7 1 6 8 3 
遍歷1次後: 1 5 4 7 2 6 8 3 
遍歷2次後: 1 2 4 7 5 6 8 3  
...
遍歷7次後: 1 2 3 4 5 6 7 8 12345

選擇排序的時間複雜度也是 -O(N^2);

void Selectsort(int *array, int n)
{
    int i = 0;
    int j = 0;
    int min = 0;
    int temp = 0;    

    for(i; i < n; i++){
        min = i;
        for(j = i + 1; j < n; j++){
        if(array[min] > array[j])
            min = j;
        }
        temp = array[min];
        array[min] = array[i];
        array[i] = temp; 
    }
}
#endif12345678910111213141516171819

三,撲克牌法排序——插入排序 
打牌時(以挖坑爲例)我們一張張的摸牌,將摸到的牌插入手牌的”順子”裏,湊成更長的順子,這就是插入排序的含義。

設無序數組a[]長度爲N,以由小到大排序爲例。插入的原理是這樣的: 
    1.初始時,第一個數據a[0]自成有序數組,後面的a[1]~a[N-1]爲無序數組。令 i = 1; 
    2.將第二個數據a[1]加入有序序列a[0]中,使a[0]~a[1]變爲有序序列。i++; 
    3.重複循環第二步,直到將後面的所有無序數插入到前面的有序數列內,排序完成。

 

無序數組:  2 | 5 4 7 1 6 8 3 
遍歷1次後: 2 5 | 4 7 1 6 8 3 
遍歷2次後: 2 4 5 | 7 1 6 8 3 
遍歷3次後: 2 4 5 7 | 1 6 8 3 
...12345

插入排序的時間度仍然是-O(N^2),但是,插入排序是一種比較快的排序,因爲它每次都是和有序的數列進行比較插入,所以每次的比較很有”意義”,導致交換次數較少,所以插入排序在-O(N^2)級別的排序中是比較快的排序算法。

 

{
    int i = 0;
    int j = 0;
    int temp = 0;   

    for(i = 1; i < n; i++){
        if(array[i] < array[i-1]){
            temp = array[i]; 
        for(j = i - 1; j >= 0 && array[j] > temp; j--){   
            array[j+1] = array[j];
            }
            array[j+1] = temp;
        }
    } 
}123456789101112131415

四,最快的排序——快速排序 
  我真的很敬佩設計出這個算法的大神,連起名字都這麼霸氣——Quick Sort。爲什麼這麼自信的叫快速排序?因爲已經被數學家證明出 在交換類排序算法中,快排是是速度最快的! 
  快排是C.R.A.Hoare於1962年提出的一種劃分交換區的排序。它採用一種很重要的”分治法(Divide-and-ConquerMethod)”的思想。快排是一種很有實用價值的排序方法,很多IT公司在面試算法時幾乎都會去問,所以快排是一定要掌握的。

快排的原理是這樣的: 
 1. 先在無序的數組中取出一個數作爲基數。 
 2. 將比基數小的數扔到基數的左邊,成爲一個區。將比基數大的數扔到基數的右邊,成爲另一個區。 
 3. 將左右兩個區重複進行前兩步操作,使數列變成四個區。 
 4. 重複操作,直到每個區裏只有一個數時,排序完成。

快速排序初次接觸比較難理解,我們可以把快排看做挖坑填數,具體操作如下:

 

數組下標: 0  1  2  3  4  5  6  7
無序數列: 4  2  5  7  1  6  8  3 12

初始時,left = 0; right = 7; 將第一個數設爲基數 base = a[left]; 
  由於將a[0]保存到base中,可以理解爲在a[0]處挖了一個坑,可以將數據填入a[0]中。 
  從最右邊right挨個開始找比base小的數。當right==7符合,則將a[7]挖出來填入a[0]的坑裏面(a[0] = a[7]),所以又 形成了新坑a[7],並且left ++。 
  再從左邊left開始挨個找比base大的數(注意上一步left++),當left == 2符合,就將a[2]挖出來填入a[7]位置處,並且right–。 
  現在數組變爲: 

 

數組下標: 0  1  2  3  4  5  6  7
無序數列: 3  2  5  7  1  6  8  5 12

重複以上步驟,左邊挖的坑在右邊找,右邊找到比基數小的填到左邊,左邊++。右邊的坑在左邊找,找到比基數大的填在右邊,右邊–。 
循環條件是left > right,當排序完後,將基數放在循環停止的位置,比基數小的都到了基數的左邊,比基數大的都到了基數的右邊。

 

數組下標: 0  1  2  3  4  5  6  7
無序數列: 3  2  1  4  7  6  8  5 12

再對0~2區間和4~7區間重複以上操作。直到分的區間只剩一個數,證明排序已經完成。

可以看出快排是將數組一分爲二到底,需要log N次,再乘以每個區間的排序次數 N。所以時間複雜度爲:-O(N * log N)。

 

void Quicksort(int *array, int l, int r)
{
    int i = 0;
    int j = 0;
    int x = 0;

    if(l < r){
        i = l;
        j = r;
    x = array[l];
    while(i < j){
        while(i < j && array[j] >= x){
                j--;
        }
        if(i < j){
        array[i++] = array[j];
        }    
        while(i < j && array[i] <= x){
        i++;
        }
        if(i < j){
        array[j--] = array[i];
        }
    }
    array[i] = x;
    Quicksort(array, l, i - 1);
    Quicksort(array, i + 1, r);
    }
}1234567891011121314151617181920212223242526272829

快排還有許多改進版本,如隨機選擇基數,區間內數據較少時直接用其他排序來減小遞歸的深度等等。快排現在仍是很多人研究的課題,有興趣的同學可以深入的研究下。

五,分而治之——歸併排序 
  歸併排序是建立在歸併操作上的一種優秀的算法,也是採用分治思想的典型例子。 
  我們知道將兩個有序數列進行合併,是很快的,時間複雜度只有-O(N)。而歸併就是採用這種操作,首先將有序數列一分二,二分四……直到每個區都只有一個數據,可以看做有序序列。然後進行合併,每次合併都是有序序列在合併,所以效率比較高。

 

無序數組:  2 5 4 7 1 6 8 3 
第一步拆分:2 5 4 7 | 1 6 8 3 
第二步拆分:2 5 | 4 7 | 1 6 | 8 3 
第三步拆分:2 | 5 | 4 | 7 | 1 | 6 | 8 | 3
第一步合併:2 5 | 4 7 | 1 6 | 3 8 
第二步合併:2 4 5 7 | 1 3 6 8 
第三步合併:1 2 3 4 5 6 7 1234567

可見歸併排序的時間複雜度是拆分的步數 log N 乘以排序步數 N ,爲-O(N * log N)。也是高級別的排序算法(-O(N ^ 2)爲低級別)。

 

void Mergesort(int *array, int n)
{
    int *temp = NULL;

    if(array == NULL || n < 2)
    return;
    temp = (int *)Malloc(sizeof(int )*n);
    mergesort(array, 0, n - 1, temp);
    free(temp);
}

void mergesort(int *array, int first, int last, int *temp)
{
    int mid = -1;
    if(first < last){
        mid = first + ((last - first) >> 1);
    mergesort(array, first, mid, temp);
    mergesort(array, mid+1, last, temp);
    mergearray(array, first, mid, last, temp); 
    }
}

void mergearray(int *array, int first, int mid, int last, int *temp)
{
    int i = first;
    int m = mid;
    int j = mid + 1;
    int n = last;
    int k = 0;

    while(i <= m && j <= n){
        if(array[i] <= array[j]){
            temp[k++] = array[i++];
        }else{
            temp[k++] = array[j++];
        }
    }
    while(i <= m){
        temp[k++] = array[i++];
    }
    while(j <= n){
    temp[k++] = array[j++];
    }
    memcpy(array + first, temp, sizeof(int) * k);
}123456789101112131415161718192021222324252627282930313233343536373839404142434445

由於要申請等同於原數組大小的臨時數組,歸併算法快速排序的同時也犧牲了N大小的空間。這是速率與空間不可調和矛盾,接觸數據結構越多,越能發現這個道理,我們只能取速度與空間權衡點,不可能兩者兼得。

六,縮小增量——希爾排序 
  希爾排序的實質就是分組插入排序,該方法又稱爲縮小增量排序,因DJ.Shell與1959年提出而得名。

該方法的基本思想是:先將整個待排序列分割成若干個子序列(由相隔某個”增量”的元素組成)分別進行插入排序,然後依次縮減增量再次進行排序,待整個序列中的元素基本有序時(增量足夠小),再對全體進行一次直接插入排序。因爲直接插入排序在元素基本有序的情況下(接近最好情況),效率是很高的。

 

無序數組:      2 5 4 7 1 6 8 3 
第一次gap=8/2   2A      1A
                 5B       6B
                   4C      8C
                     7D      3D12345

設第一次增量爲N/2 = 4,即a[0]和a[4]插入排序,a[1]和a[5]插入排序,a[2]和a[6],a[3]和a[7].字母相同代表在同一組進行排序。 
排序完後變爲:

 

一次增量:      1 5 4 3 2 6 8 7
               A B C D A B C D 12

縮小增量,gap=4/2。

 

一次增量:      1 5 4 3 2 6 8 7
第二次gap=4/2 :1A  4A  2A  8A
                 5B  3B  6B  7B123

第二次增量變爲2,即a[0],a[2],a[4],a[6]一組進行插入排序。a[1],a[3],a[5],a[7]一組進行排序。結果爲:

 

二次增量:      1 3 2 5 4 6 8 71

第三次增量gap=1,直接進行選擇排序。

 

三次增量:      1 2 3 4 5 6 7 81

希爾排序的時間複雜度爲-O(N * log N),前提是使用最佳版本,後面有提到。

 

void Shellsort(int *array, int n)
{
    int i,j,k,temp,gap;

    for(gap = n/2; gap > 0; gap /= 2){
        for(i = 0; i < gap; i++){
            for(j = i + gap; j < n; j += gap){    
                for(k = j - gap; k >= i && array[k] > array[k+1]; k -= gap){
                    temp = array[k+1];
                    array[k+1] = array[k];
                    array[k] = temp;
                }               
            }       
        }
    }
} 12345678910111213141516

很顯然,上面的Shell排序雖然對直觀理解希爾排序有幫助,但代碼過長循環過多,不夠簡潔清晰。因此進行一下改進和優化,在gap內部進行排序顯然也能達到縮小增量排序的目的。

 


void Shellsort(int *array, int n)
{
    int i,j,k,temp;

    for(gap = n/2; gap > 0; gap /= 2){
        for(j = gap; j < n; j ++){
            if(array[j] < array[j-gap]){
                temp = array[j];
                k = j - gap;
                while(k >= 0 && array[k] > temp){
                    array[k+gap] = array[k];
                    k -= gap;
                }
               array[k+gap] = temp;
            }   
        }
    }
}12345678910111213141516171819

希爾排序的縮小增量思想很重要,學習數據結構主要就是學習思想。我們上面排序的步長gap都是N/2開始,在進行減半,實際上還有更高效的步長選擇,如果你有興趣,可以去維基百科查看更多的步長算法推導。

七,集中數據的排序——計數排序 
  如果有這樣的數列,其中元素種類並不多,只是元素個數多,請選擇->計數排序。 
  比如一億個1~100的整型數據,它出現的數據只有100種可能。這個時候計數排序非常的快(親測,快排需要19秒,基數排序只需要不到1秒!)。

計數排序的思想是這樣的: 
  1. 根據數據範圍size(100),malloc構造一個用於計算數據出現次數的數組,並將其初始化個數都置爲0。 
  2. 遍歷一遍,將出現的每個數據的次數記錄於數組。 
  3. 再次遍歷,按照順序並根據數據出現的次數重現擺放,排序完成。

可見計數排序僅僅遍歷了兩遍。時間複雜度:-O(N) + -O(N) = -O(N)。

 

void count_sort(int *array, int length, int min, int max)
{
    int *count = NULL;
    int c_size = max - min + 1;
    int i = 0;
    int j = 0;

    count = (int *)Malloc(sizeof(int) * c_size);  
    bzero(count, sizeof(int) * c_size);   

    for(i = 0; i < length; ++i){
        count[array[i] - min]++;
    }               
    for(i = 0, j = 0; i < c_size;){
        if(count[i]){   
            array[j++] = i + min;
            count[i]--;
        }else{
            i++;
         } 
    }
    free(count);
}1234567891011121314151617181920212223

計數排序雖然時間複雜度最小,速度最快。但是,限制條件是數據一定要比較集中,要是數據範圍很大,程序可能會卡死。

八,構造樹——二叉堆排序  

堆排序與快速排序,歸併排序一樣都是時間複雜度爲 O(N*logN)的幾種常見排序方法。學習堆排序前,先講解下什麼是數據結構中的二叉堆。

二叉堆的定義: 
二叉堆是完全二叉樹或者是近似完全二叉樹。

二叉堆滿足二個特性: 
1.父結點的鍵值總是大於或等於(小於或等於)任何一個子節點的鍵值。 
2.每個結點的左子樹和右子樹都是一個二叉堆(都是最大堆或最小堆)。

當父結點的鍵值總是大於或等於任何一個子節點的鍵值時爲最大堆。當父結點的鍵值總是小於或等於任何一個子節點的鍵值時爲最小堆。下圖展示一個最小堆: 
 
由於其它幾種堆(二項式堆,斐波納契堆等)用的較少,一般將二叉堆就簡稱爲堆。

堆的存儲: 
  一般都用數組來表示堆,i 結點的父結點下標就爲(i – 1) / 2。它的左右子結點下標分別爲 2 * i + 1 和 2 * i + 2。如第 0 個結點左右子結點下標分別爲 1 和 2。 
 
堆的操作——插入刪除: 
  下面先給出《數據結構 C++語言描述》中最小堆的建立插入刪除的圖解,再給出代碼實現,最好是先看明白圖後再去看代碼。 


堆的插入: 
  每次插入都是將新數據放在數組最後。可以發現從這個新數據的父結點到根結點必然爲一個有序的數列,現在的任務是將這個新數據插入到這個有序數據中——這就類似於直接插入排序中將一個數據併入到有序區間中,寫出插入一個新數據時堆的調整代碼:

 

void MinHeapFixup(int a[], int i)
{
    int j,temp;
    temp = a[i];
    j = (i - 1) / 2; //父結點
    while (j >= 0){
        if (a[j] <= temp)
        break;
        a[i] = a[j]; //把較大的子結點往下移動,替換它的子結點
        i = j;
        j = (i - 1) / 2;
    }
    a[i] = temp;
}1234567891011121314

更簡短的表達爲:

 

void MinHeapFixup(int a[], int i)
{
    for (int j = (i - 1) / 2; j >= 0 && a[i] > a[j]; i = j, j = (i - 1) / 2)
    Swap(a[i], a[j]);
    }12345

插入時://在最小堆中加入新的數據nNum

 

void MinHeapAddNumber(int a[], int n, int nNum)
{
    a[n] = nNum;
    MinHeapFixup(a, n);
}12345

堆的刪除: 
  按定義,堆中每次都只能刪除第 0 個數據。爲了便於重建堆,實際的操作是將最後一個數據的值賦給根結點,然後再從根結點開始進行一次從上向下的調整。調整時先在左右兒子結點中找最小的,如果父結點比這個最小的子結點還小說明不需要調整了,反之將父結點和它交換後再考慮後面的結點。相當於從根結點將一個數據的“下沉”過程。下面給出代碼:

 

// 從i節點開始調整,n爲節點總數 從0開始計算 i節點的子節點爲 2*i+1, 2*i+2
void MinHeapFixdown(int a[], int i, int n)
{
    int j, temp;
    temp = a[i];
    j = 2 * i + 1;
    while (j < n){
        if (j + 1 < n && a[j + 1] < a[j]) //在左右孩子中找最小的
        j++;
        if (a[j] >= temp)
        break;
        a[i] = a[j]; //把較小的子結點往上移動,替換它的父結點
        i = j;
        j = 2 * i + 1;
    }
    a[i] = temp;
}1234567891011121314151617

//在最小堆中刪除數

 

void MinHeapDeleteNumber(int a[], int n)
{
    Swap(a[0], a[n - 1]);
    MinHeapFixdown(a, 0, n - 1);
}12345

堆化數組: 
  有了堆的插入和刪除後,再考慮下如何對一個數據進行堆化操作。要一個一個的從數組中取出數據來建立堆吧,不用!先看一個數組,如下圖: 
 
  很明顯,對葉子結點來說,可以認爲它已經是一個合法的堆了即 20,60, 65,4, 49 都分別是一個合法的堆。只要從 A[4]=50 開始向下調整就可以了。然後再取 A[3]=30,A[2] = 17,A[1] = 12,A[0] = 9 分別作一次向下調整操作就可以了。下圖展示了這些步驟: 


寫出堆化數組的代碼:

 

//建立最小堆
void MakeMinHeap(int a[], int n)
{
    for (int i = n / 2 - 1; i >= 0; i--)
    MinHeapFixdown(a, i, n);
}123456

至此,堆的操作就全部完成了,再來看下如何用堆這種數據結構來進行排序。

堆排序: 
  首先可以看到堆建好之後堆中第 0 個數據是堆中最小的數據。取出這個數據再執行下堆的刪除操作。 
這樣堆中第 0 個數據又是堆中最小的數據,重複上述步驟直至堆中只有一個數據時就直接取出這個數據。由於堆也是用數組模擬的,故堆化數組後,第一次將 A[0]與 A[n - 1]交換,再對A[0…n-2]重新恢復堆。第二次將 A[0]與 A[n – 2]交換,再對 A[0…n - 3]重新恢復堆,重複這樣的操作直到 A[0]與 A[1]交換。 
由於每次都是將最小的數據併入到後面的有序區間,故操作完成後整個數組就有序了。有點類似於直接選擇排序。

 

// 堆排序 最小堆 –> 降序排序
void MinheapsortTodescendarray(int a[], int n)
{
    for (int i = n - 1; i >= 1; i--){
        Swap(a[i], a[0]);
        MinHeapFixdown(a, 0, i);
    }
}12345678

注意使用最小堆排序後是遞減數組,要得到遞增數組,可以使用最大堆。由於每次重新恢復堆的時間複雜度爲 O(logN),共 N - 1 次重新恢復堆操作,再加上前面建立堆時 N / 2 次向下調整,每次調整時間複雜度也爲 O(logN)。二次操作時間相加還是 O(N * logN)。故堆排序的時間複雜度爲 O(N * logN)。

八種排序算法已經介紹完畢,希望大家有所收穫! 
                                          染塵    16.4.29
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章