1. 程式人生 > >資料結構常考題 —— 八種經典內部排序演算法

資料結構常考題 —— 八種經典內部排序演算法

經典排序演算法

我們經典的排序有內部排序外部排序,內部排序是資料記錄在記憶體中進行排序,而外部排序是因排序的資料很大,一次不能容納全部的排序記錄,在排序過程中需要訪問外存。
這裡寫圖片描述
演算法複雜度如下圖:
演算法複雜度
下面我們一一來總結這每一種演算法:

一、插入排序

插入排序的基本方法是:每步將一個待排序的記錄,按其排序碼大小,插到前面已經排序的檔案中的適當位置,直到全部插入完為止。

1.直接插入排序
原理:從待排序的n個記錄中的第二個記錄開始,依次與前面的記錄比較並尋找插入的位置,每次外迴圈結束後,將當前的數插入到合適的位置。

穩定性:穩定排序。
時間複雜度: O(n)至,平均時間複雜度是。
最好情況:當待排序記錄已經有序,這時需要比較的次數最少。
最壞情況:如果待排序記錄為逆序,則比較次數最多。

程式碼:


//A:輸入陣列,len:陣列長度
void insertSort(int A[],int len)
{
    int temp;
    for(int i=1;i<len;i++)
    {
      int j=i-1;
      temp=A[i]; 
      //查詢到要插入的位置
      while(j>=0&&A[j]>temp)
      {
          A[j+1]=A[j];
          j--;
      }
      if(j!=i-1)
        A[j+1]=temp;
    }
}

2.Shell排序
Shell 排序又稱縮小增量排序, 是對直接插入排序的改進。這是第一個突破O(n2)的排序演算法,是簡單插入排序的改進版。
它與插入排序的不同之處在於,它會優先比較距離較遠的元素。

原理: Shell排序法是對相鄰指定距離(稱為增量)的元素進行比較,並不斷把增量縮小至1,完成排序。

先將整個待排序的記錄序列分割成為若干子序列分別進行直接插入排序,具體演算法描述:

  • 選擇一個增量序列t1,t2,…,tk,其中ti>tj,tk=1;
  • 按增量序列個數k,對序列進行k 趟排序;
  • 每趟排序,根據對應的增量ti,將待排序列分割成若干長度為m 的子序列,分別對各子表進行直接插入排序。僅增量因子為1 時,整個序列作為一個表來處理,表長度即為整個序列的長度。
  • 在直接插入排序的基礎上,將直接插入排序中的1全部改變成增量d即可,因為Shell排序最後一輪的增量d就為1。

穩定性:不穩定排序。
時間複雜度:O(n*n)。Shell排序演算法的時間複雜度分析比較複雜,實際所需的時間取決於各次排序時增量的個數和增量的取值。研究證明,若增量的取值比較合理,Shell排序演算法的時間複雜度約為O(n)。

對於增量的選擇,Shell 最初建議增量選擇為n/2,並且對增量取半直到 1。


//A:輸入陣列,len:陣列長度,d:初始增量(分組數)
void shellSort(int A[],int len, int d)
{
    for(int inc=d;inc>0;inc/=2){        //迴圈的次數為增量縮小至1的次數
        for(int i=inc;i<len;++i){       //迴圈的次數為第一個分組的第二個元素到陣列的結束
            int j=i-inc;
            int temp=A[i];
            while(j>=0&&A[j]>temp)
            {
                A[j+inc]=A[j];
                j=j-inc;
            }
            if((j+inc)!=i)//防止自我插入
                A[j+inc]=temp;//插入記錄
        }
    }

二、選擇排序

選擇類排序的基本方法是:每步從待排序記錄中選出排序碼最小的記錄,順序放在已排序的記錄序列的後面,知道全部排完。
1.簡單選擇排序
原理:從所有記錄中選出最小的一個數據元素與第一個位置的記錄交換;然後在剩下的記錄當中再找最小的與第二個位置的記錄交換,迴圈到只剩下最後一個數據元素為止。

穩定性:不穩定排序。
時間複雜度: 最壞、最好和平均複雜度均為O(n*n),因此,簡單選擇排序也是常見排序演算法中效能最差的排序演算法。簡單選擇排序的比較次數與檔案的初始狀態沒有關係,在第i趟排序中選出最小排序碼的記錄,需要做n-i次比較。

void selectSort(int A[],int len)
{
    int i,j,k;
    for(i=0;i<len;i++){
       k=i;
       for(j=i+1;j<len;j++){
           if(A[j]<A[k])
               k=j;
       }
       if(i!=k){
           A[i]=A[i]+A[k];                //不需要多餘變數
           A[k]=A[i]-A[k];
           A[i]=A[i]-A[k];
       }
    }
}

2、堆排序
直接選擇排序中,第一次選擇經過了n-1次比較,只是從排序碼序列中選出了一個最小的排序碼,而沒有儲存其他中間比較結果。所以後一趟排序時又要重複許多比較操作,降低了效率。J. Willioms和Floyd在1964年提出了堆排序方法,避免這一缺點。

堆的性質:
(1)性質:完全二叉樹或者是近似完全二叉樹;
(2)分類:大頂堆:父節點不小於子節點鍵值,小頂堆:父節點不大於子節點鍵值;
圖展示一個最小堆:
這裡寫圖片描述
(3)左右孩子:沒有大小的順序。

(4)堆的儲存
一般都用陣列來儲存堆,i結點的父結點下標就為。它的左右子結點下標分別為 和 。如第0個結點左右子結點下標分別為1和2。
這裡寫圖片描述
(5)堆的操作
建立:
以最小堆為例,如果以陣列儲存元素時,一個數組具有對應的樹表示形式,但樹並不滿足堆的條件,需要重新排列元素,可以建立“堆化”的樹。
這裡寫圖片描述
插入:
將一個新元素插入到表尾,即陣列末尾時,如果新構成的二叉樹不滿足堆的性質,需要重新排列元素,下圖演示了插入15時,堆的調整。
這裡寫圖片描述
刪除:
堆排序中,刪除一個元素總是發生在堆頂,因為堆頂的元素是最小的(小頂堆中)。表中最後一個元素用來填補空缺位置,結果樹被更新以滿足堆條件。
這裡寫圖片描述
穩定性:不穩定排序。

插入程式碼實現:
每次插入都是將新資料放在陣列最後。可以發現從這個新資料的父結點到根結點必然為一個有序的數列,現在的任務是將這個新資料插入到這個有序資料中,這就類似於直接插入排序中將一個數據併入到有序區間中,這是節點“上浮”調整。不難寫出插入一個新資料時堆的調整程式碼:

//新加入i結點,其父結點為(i-1)/2
//引數:a:陣列,i:新插入元素在陣列中的下標  
void minHeapFixUp(int a[], int i)  
{  
    int j, temp;  
    temp = a[i];  
    j = (i-1)/2;      //父結點  
    while (j >= 0 && i != 0)  
    {  
        if (a[j] <= temp)//如果父節點不大於新插入的元素,停止尋找  
            break;  
        a[i]=a[j];     //把較大的子結點往下移動,替換它的子結點  
        i = j;  
        j = (i-1)/2;  
    }  
    a[i] = temp;  
}  

因此,插入資料到最小堆時:

//在最小堆中加入新的資料data  
//a:陣列,index:插入的下標,
void minHeapAddNumber(int a[], int index, int data)  
{  
    a[index] = data;  
    minHeapFixUp(a, index);  
} 

刪除程式碼實現:
按定義,堆中每次都只能刪除第0個數據。為了便於重建堆,實際的操作是將陣列最後一個數據與根結點,然後再從根結點開始進行一次從上向下的調整。

調整時先在左右兒子結點中找最小的,如果父結點不大於這個最小的子結點說明不需要調整了,反之將最小的子節點換到父結點的位置。此時父節點實際上並不需要換到最小子節點的位置,因為這不是父節點的最終位置。但邏輯上父節點替換了最小的子節點,然後再考慮父節點對後面的結點的影響。相當於從根結點將一個數據的“下沉”過程。下面給出程式碼:

//a為陣列,從index節點開始調整,len為節點總數 從0開始計算index節點的子節點為 2*index+1, 2*index+2,len/2-1為最後一個非葉子節點  
void minHeapFixDown(int a[],int len,int index){
    if(index>(len/2-1))//index為葉子節點不用調整
        return;
    int tmp=a[index];
    int lastIndex=index;
    while(index<=(len/2-1)){ //當下沉到葉子節點時,就不用調整了
        if(a[2*index+1]<tmp) //如果左子節點大於該節點
            lastIndex = 2*index+1;
        //如果存在右子節點且大於左子節點和該節點
        if(2*index+2<len && a[2*index+2]<a[2*index+1]&& a[2*index+2]<tmp)
            lastIndex = 2*index+2;
        if(lastIndex!=index){  //如果左右子節點有一個小於該節點則設定該節點的下沉位置
            a[index]=a[lastIndex];
            index=lastIndex;
        }else break;  //否則該節點不用下沉調整
    }
    a[lastIndex]=tmp;//將該節點放到最後的位置
}

根據思想,可以有不同版本的程式碼實現,以上是和孫凜同學一起討論出的一個版本,在這裡感謝他的參與,讀者可另行給出。個人體會,這裡建議大家根據對堆調整的過程的理解,寫出自己的程式碼,切勿看示例程式碼去理解演算法,而是理解演算法思想寫出程式碼,否則很快就會忘記。

建堆:
有了堆的插入和刪除後,再考慮下如何對一個數據進行堆化操作。要一個一個的從陣列中取出資料來建立堆吧,不用!先看一個數組,如下圖:
這裡寫圖片描述

很明顯,對葉子結點來說,可以認為它已經是一個合法的堆了即20,60, 65, 4, 49都分別是一個合法的堆。只要從A[4]=50開始向下調整就可以了。然後再取A[3]=30,A[2] = 17,A[1] = 12,A[0] = 9分別作一次向下調整操作就可以了。下圖展示了這些步驟:
這裡寫圖片描述

寫出堆化陣列的程式碼:

//建立最小堆
//a:陣列,n:陣列長度
void makeMinHeap(int a[], int n)  
{  
    for (int i = n/2-1; i >= 0; i--)  
        minHeapFixDown(a, i, n);  
}  

(6)堆排序的實現
由於堆也是用陣列來儲存的,故對陣列進行堆化後,第一次將A[0]與A[n - 1]交換,再對A[0…n-2]重新恢復堆。第二次將A[0]與A[n – 2]交換,再對A[0…n - 3]重新恢復堆,重複這樣的操作直到A[0]與A[1]交換。由於每次都是將最小的資料併入到後面的有序區間,故操作完成後整個陣列就有序了。有點類似於直接選擇排序。

因此,完成堆排序並沒有用到前面說明的插入操作,只用到了建堆和節點向下調整的操作,堆排序的操作如下:

//array:待排序陣列,len:陣列長度
void heapSort(int array[],int len){
    //建堆
    makeMinHeap(array, len); 
    //根節點和最後一個葉子節點交換,並進行堆調整,交換的次數為len-1次
    for(int i=0;i<len-1;++i){
        //根節點和最後一個葉子節點交換
        array[0] += array[len-i-1];  
        array[len-i-1] = array[0]-array[len-i-1];  
        array[0] = array[0]-array[len-i-1];

        //堆調整
        minHeapFixDown(array, 0, len-i-1);  
    }
}  

(7)堆排序的效能分析
由於每次重新恢復堆的時間複雜度為O(logN),共N - 1次堆調整操作,再加上前面建立堆時N / 2次向下調整,每次調整時間複雜度也為O(logN)。兩次次操作時間相加還是O(N * logN)。故堆排序的時間複雜度為O(N * logN)。

最壞情況:如果待排序陣列是有序的,仍然需要O(N * logN)複雜度的比較操作,只是少了移動的操作;

最好情況:如果待排序陣列是逆序的,不僅需要O(N * logN)複雜度的比較操作,而且需要O(N * logN)複雜度的交換操作。總的時間複雜度還是O(N * logN)。

因此,堆排序和快速排序在效率上是差不多的,但是堆排序一般優於快速排序的重要一點是,資料的初始分佈情況對堆排序的效率沒有大的影響。

三、交換排序

交換排序的基本方法是:兩兩比較待排序記錄的排序碼,交換不滿足順序要求的偶對,直到全部滿足位置。常見的氣泡排序和快速排序就屬於交換類排序。

1.氣泡排序
演算法思想:
從陣列中第一個數開始,依次遍歷陣列中的每一個數,通過相鄰比較交換,每一輪迴圈下來找出剩餘未排序數的中的最大數並”冒泡”至數列的頂端。

演算法步驟:
- 從陣列中第一個數開始,依次與下一個數比較並次交換比自己小的數,直到最後一個數。如果發生交換,則繼續下面的步驟,如果未發生交換,則陣列有序,排序結束,此時時間複雜度為O(n);
- 每一輪”冒泡”結束後,最大的數將出現在亂序數列的最後一位。重複步驟(1)。

穩定性:穩定排序。
時間複雜度: O(n)至,平均時間複雜度為。
最好的情況:如果待排序資料序列為正序,則一趟冒泡就可完成排序,排序碼的比較次數為n-1次,且沒有移動,時間複雜度為O(n)。
最壞的情況:如果待排序資料序列為逆序,則氣泡排序需要n-1次趟起泡,每趟進行n-i次排序碼的比較和移動,即比較和移動次數均達到最大值:
比較次數: 移動次數等於比較次數,因此最壞時間複雜度為O(n*n)。

void bubbleSort(int array[],int len){
    //迴圈的次數為陣列長度減一,剩下的一個數不需要排序
    for(int i=0;i<len-1;++i){
        bool noswap=true;
        //迴圈次數為待排序數第一位數冒泡至最高位的比較次數
        for(int j=0;j<len-i-1;++j){
            if(array[j]>array[j+1]){
                array[j]=array[j]+array[j+1];
                array[j+1]=array[j]-array[j+1];
                array[j]=array[j]-array[j+1];
                //交換或者使用如下方式
                //a=a^b;
                //b=b^a;
                //a=a^b;
                noswap=false;
            }
        }
        if(noswap) break;
    }
}

2、快速排序
氣泡排序是在相鄰的兩個記錄進行比較和交換,每次交換隻能上移或下移一個位置,導致總的比較與移動次數較多。快速排序又稱分割槽交換排序,是對氣泡排序的改進,快速排序採用的思想是分治思想。。

演算法原理:

  • 從待排序的n個記錄中任意選取一個記錄(通常選取第一個記錄)為分割槽標準;
  • 把所有小於該排序列的記錄移動到左邊,把所有大於該排序碼的記錄移動到右邊,中間放所選記錄,稱之為第一趟排序;
  • 然後對前後兩個子序列分別重複上述過程,直到所有記錄都排好序。

穩定性:不穩定排序。
時間複雜度: 至,平均時間複雜度為。
最好的情況:是每趟排序結束後,每次劃分使兩個子檔案的長度大致相等,時間複雜度為。
最壞的情況:是待排序記錄已經排好序,第一趟經過n-1次比較後第一個記錄保持位置不變,並得到一個n-1個元素的子記錄;第二趟經過n-2次比較,將第二個記錄定位在原來的位置上,並得到一個包括n-2個記錄的子檔案。

//a:待排序陣列,low:最低位的下標,high:最高位的下標
void quickSort(int a[],int low, int high)
{
    if(low>=high)
    {
        return;
    }
    int left=low;
    int right=high;
    int key=a[left];    /*用陣列的第一個記錄作為分割槽元素*/
    while(left!=right){
        while(left<right&&a[right]>=key)    /*從右向左掃描,找第一個碼值小於key的記錄,並交換到key*/
            --right;
        a[left]=a[right];
        while(left<right&&a[left]<=key)
            ++left;
        a[right]=a[left];    /*從左向右掃描,找第一個碼值大於key的記錄,並交換到右邊*/
    }
    a[left]=key;    /*分割槽元素放到正確位置*/
    quickSort(a,low,left-1);
    quickSort(a,left+1,high);
}

四、歸併排序

演算法思想:
歸併排序屬於比較類非線性時間排序,號稱比較類排序中效能最佳者,在資料中應用中較廣。

歸併排序是分治法(Divide and Conquer)的一個典型的應用。將已有序的子序列合併,得到完全有序的序列;即先使每個子序列有序,再使子序列段間有序。若將兩個有序表合併成一個有序表,稱為二路歸併。
歸併操作的工作原理如下:
- 申請空間,使其大小為兩個已經排序序列之和,該空間用來存放合併後的序列
- 設定兩個指標,最初位置分別為兩個已經排序序列的起始位置
- 比較兩個指標所指向的元素,選擇相對小的元素放入到合併空間,並移動指標到下一位置 重複步驟3直到某一指標超出序列尾
- 將另一序列剩下的所有元素直接複製到合併序列尾

穩定性:穩定排序演算法;
時間複雜度: 最壞,最好和平均時間複雜度都是Θ(nlgn)。

如圖所示,很容易理解:
這裡寫圖片描述

function mergeSort(arr) {  // 採用自上而下的遞迴方法
    var len = arr.length;
    if (len < 2) {
        return arr;
    }
    var middle = Math.floor(len / 2),
        left = arr.slice(0, middle),
        right = arr.slice(middle);
    return merge(mergeSort(left), mergeSort(right));
}

function merge(left, right) {
    var result = [];

    while (left.length>0 && right.length>0) {
        if (left[0] <= right[0]) {
            result.push(left.shift());
        } else {
            result.push(right.shift());
        }
    }

    while (left.length)
        result.push(left.shift());

    while (right.length)
        result.push(right.shift());

    return result;
}

五、計數排序

計數排序不是基於比較的排序演算法,其核心在於將輸入的資料值轉化為鍵儲存在額外開闢的陣列空間中。 作為一種線性時間複雜度的排序,計數排序要求輸入的資料必須是有確定範圍的整數。

演算法描述:

  • 找出待排序的陣列中最大和最小的元素;
  • 統計陣列中每個值為i的元素出現的次數,存入陣列C的第i項;
  • 對所有的計數累加(從C中的第一個元素開始,每一項和前一項相加);
  • 反向填充目標陣列:將每個元素i放在新陣列的第C(i)項,每放一個元素就將C(i)減去1。
function countingSort(arr, maxValue) {
    var bucket = new Array(maxValue + 1),
        sortedIndex = 0;
        arrLen = arr.length,
        bucketLen = maxValue + 1;

    for (var i = 0; i < arrLen; i++) {
        if (!bucket[arr[i]]) {
            bucket[arr[i]] = 0;
        }
        bucket[arr[i]]++;
    }

    for (var j = 0; j < bucketLen; j++) {
        while(bucket[j] > 0) {
            arr[sortedIndex++] = j;
            bucket[j]--;
        }
    }

    return arr;
}

總結

1、當n較大,則應採用時間複雜度為O(nlog2n)的排序方法:快速排序、堆排序或歸併排序序。
2、在比較類排序中,歸併排序號稱最快,其次是快速排序和堆排序,兩者不相伯仲,但是有一點需要注意,資料初始排序狀態對堆排序不會產生太大的影響,而快速排序卻恰恰相反。
3、快速排序:是目前基於比較的內部排序中被認為是最好的方法,當待排序的關鍵字是隨機分佈時,快速排序的平均時間最短;