1. 程式人生 > >剖析八種經典排序演算法

剖析八種經典排序演算法

排序(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

可以輕易的得出,冒泡在 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; } } } }

我們對可以對冒泡進行優化,迴圈時,當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--;
    }   
}

雖然我們對冒泡進行了優化,但優化後的時間複雜度邏輯上還是-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 

選擇排序的時間複雜度也是 -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; 
    }
}
#endif

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

設無序陣列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 
...

插入排序的時間度仍然是-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;
        }
    } 
}

四,最快的排序——快速排序
我真的很敬佩設計出這個演算法的大神,連起名字都這麼霸氣——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 

初始時,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 

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

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

再對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);
    }
}

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

五,分而治之——歸併排序
歸併排序是建立在歸併操作上的一種優秀的演算法,也是採用分治思想的典型例子。
我們知道將兩個有序數列進行合併,是很快的,時間複雜度只有-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 

可見歸併排序的時間複雜度是拆分的步數 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);
}

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

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

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

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

設第一次增量為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 

縮小增量,gap=4/2。

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

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

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

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

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

希爾排序的時間複雜度為-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;
                }               
            }       
        }
    }
} 

很顯然,上面的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;
            }   
        }
    }
}

希爾排序的縮小增量思想很重要,學習資料結構主要就是學習思想。我們上面排序的步長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);
}

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

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

堆排序與快速排序,歸併排序一樣都是時間複雜度為 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;
}

更簡短的表達為:

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]);
    }

插入時://在最小堆中加入新的資料nNum

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

堆的刪除
按定義,堆中每次都只能刪除第 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;
}

//在最小堆中刪除數

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

堆化陣列
有了堆的插入和刪除後,再考慮下如何對一個數據進行堆化操作。要一個一個的從陣列中取出資料來建立堆吧,不用!先看一個數組,如下圖:
這裡寫圖片描述
很明顯,對葉子結點來說,可以認為它已經是一個合法的堆了即 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);
}

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

堆排序
首先可以看到堆建好之後堆中第 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);
    }
}

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

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