1. 程式人生 > >各種排序演算法總結和比較

各種排序演算法總結和比較

       排序演算法可以說是一項基本功,解決實際問題中經常遇到,針對實際資料的特點選擇合適的排序演算法可以使程式獲得更高的效率,有時候排序的穩定性還是實際問題中必須考慮的,這篇部落格對常見的排序演算法進行整理,包括:插入排序、選擇排序、氣泡排序、快速排序、堆排序、歸併排序、希爾排序、二叉樹排序、計數排序、桶排序、基數排序。

      程式碼都經過了CodeBlocks的除錯,但是很可能有沒注意到的BUG,歡迎指出。

      比較排序和非比較排序

      常見的排序演算法都是比較排序,非比較排序包括計數排序、桶排序和基數排序,非比較排序對資料有要求,因為資料本身包含了定位特徵,所有才能不通過比較來確定元素的位置。

      比較排序的時間複雜度通常為O(n2)或者O(nlogn),比較排序的時間複雜度下界就是O(nlogn),而非比較排序的時間複雜度可以達到O(n),但是都需要額外的空間開銷。

      比較排序時間複雜度為O(nlogn)的證明:

      a1,a2,a3……an序列的所有排序有n!種,所以滿足要求的排序a1',a2',a3'……an'(其中a1'<=a2'<=a3'……<=an')的概率為1/n!。基於輸入元素的比較排序,每一次比較的返回不是0就是1,這恰好可以作為決策樹的一個決策將一個事件分成兩個分支。比如氣泡排序時通過比較a1和a2兩個數的大小可以把序列分成a1,a2……an與a2,a1……an(氣泡a2上升一個身位)兩種不同的結果,因此比較排序也可以構造決策樹。根節點代表原始序列a1,a2,a3……an,所有葉子節點都是這個序列的重排(共有n!個,其中有一個就是我們排序的結果a1',a2',a3'……an')。如果每次比較的結果都是等概率的話(恰好劃分為概率空間相等的兩個事件),那麼二叉樹就是高度平衡的,深度至少是log(n!)。

      又因為 1. n! < nn ,兩邊取對數就得到log(n!)<nlog(n),所以log(n!) = O(nlogn).

                2. n!=n(n-1)(n-2)(n-3)…1 > (n/2)^(n/2) 兩邊取對數得到 log(n!) > (n/2)log(n/2) = Ω(nlogn),所以 log(n!) = Ω(nlogn)。

      因此log(n!)的增長速度與 nlogn 相同,即 log(n!)=Θ(nlogn),這就是通用排序演算法的最低時間複雜度O(nlogn)的依據。

      排序的穩定性和複雜度

      不穩定:

      選擇排序(selection sort)— O(n2)

      快速排序(quicksort)— O(nlogn) 平均時間, O(n2) 最壞情況; 對於大的、亂序串列一般認為是最快的已知排序

      堆排序 (heapsort)— O(nlogn)

      希爾排序 (shell sort)— O(nlogn)

      基數排序(radix sort)— O(n·k); 需要 O(n) 額外儲存空間 (K為特徵個數)

      穩定:

      插入排序(insertion sort)— O(n2)

      氣泡排序(bubble sort) — O(n2)

      歸併排序 (merge sort)— O(n log n); 需要 O(n) 額外儲存空間

      二叉樹排序(Binary tree sort) — O(nlogn); 需要 O(n) 額外儲存空間

      計數排序  (counting sort) — O(n+k); 需要 O(n+k) 額外儲存空間,k為序列中Max-Min+1

      桶排序 (bucket sort)— O(n); 需要 O(k) 額外儲存空間

      每種排序的原理和實現

      插入排序

      遍歷陣列,遍歷到i時,a0,a1...ai-1是已經排好序的,取出ai,從ai-1開始向前和每個比較大小,如果小於,則將此位置元素向後移動,繼續先前比較,如果不小於,則放到正在比較的元素之後。可見相等元素比較是,原來靠後的還是拍在後邊,所以插入排序是穩定的。

      當待排序的資料基本有序時,插入排序的效率比較高,只需要進行很少的資料移動。

複製程式碼

void insertion_sort (int a[], int n) {
    int i,j,v;
    for (i=1; i<n; i++) {
      //如果第i個元素小於第j個,則第j個向後移動
        for (v=a[i], j=i-1; j>=0&&v<a[j]; j--)
            a[j+1]=a[j];
        a[j+1]=v;
    }
}

複製程式碼

      選擇排序

      遍歷陣列,遍歷到i時,a0,a1...ai-1是已經排好序的,然後從i到n選擇出最小的,記錄下位置,如果不是第i個,則和第i個元素交換。此時第i個元素可能會排到相等元素之後,造成排序的不穩定。

複製程式碼

void selection_sort (int a[], int n) {
    int i,j,pos,tmp;
    for (i=0; i<n-1; i++) {
      //尋找最小值的下標
        for (pos=i, j=i+1; j<n; j++)
            if (a[pos]>a[j])
                pos=j;
        if (pos != i) {
            tmp=a[i];
            a[i]=a[pos];
            a[pos]=tmp;
        }
    }
}

複製程式碼

      氣泡排序

      氣泡排序的名字很形象,實際實現是相鄰兩節點進行比較,大的向後移一個,經過第一輪兩兩比較和移動,最大的元素移動到了最後,第二輪次大的位於倒數第二個,依次進行。這是最基本的氣泡排序,還可以進行一些優化。

      優化一:如果某一輪兩兩比較中沒有任何元素交換,這說明已經都排好序了,演算法結束,可以使用一個Flag做標記,預設為false,如果發生互動則置為true,每輪結束時檢測Flag,如果為true則繼續,如果為false則返回。

      優化二:某一輪結束位置為j,但是這一輪的最後一次交換髮生在lastSwap的位置,則lastSwap到j之間是排好序的,下一輪的結束點就不必是j--了,而直接到lastSwap即可,程式碼如下:

複製程式碼

void bubble_sort (int a[], int n) {
    int i, j, lastSwap, tmp;
    for (j=n-1; j>0; j=lastSwap) {
        lastSwap=0;     //每一輪要初始化為0,防止某一輪未發生交換,lastSwap保留上一輪的值進入死迴圈
        for (i=0; i<j; i++) {
            if (a[i] > a[i+1]) {
                tmp=a[i];
                a[i]=a[i+1];
                a[i+1]=tmp;
           //最後一次交換位置的座標
                lastSwap = i;
            }
        }
    }
}

複製程式碼

      快速排序

      快速排序首先找到一個基準,下面程式以第一個元素作為基準(pivot),然後先從右向左搜尋,如果發現比pivot小,則和pivot交換,然後從左向右搜尋,如果發現比pivot大,則和pivot交換,一直到左邊大於右邊,此時pivot左邊的都比它小,而右邊的都比它大,此時pivot的位置就是排好序後應該在的位置,此時pivot將陣列劃分為左右兩部分,可以遞迴採用該方法進行。快排的交換使排序成為不穩定的。

複製程式碼

int mpartition(int a[], int l, int r) {
    int pivot = a[l];

    while (l<r) {
        while (l<r && pivot<=a[r]) r--;
        if (l<r) a[l++]=a[r];
        while (l<r && pivot>a[l]) l++;
        if (l<r) a[r--]=a[l];
    }
    a[l]=pivot;
    return l;
}

void quick_sort (int a[], int l, int r) {

    if (l < r) {
        int q = mpartition(a, l, r);
        msort(a, l, q-1);
        msort(a, q+1, r);
    }
}

複製程式碼

       堆排序

       堆排序是把陣列看作堆,第i個結點的孩子結點為第2*i+1和2*i+2個結點(不超出陣列長度前提下),堆排序的第一步是建堆,然後是取堆頂元素然後調整堆。建堆的過程是自底向上不斷調整達成的,這樣當調整某個結點時,其左節點和右結點已經是滿足條件的,此時如果兩個子結點不需要動,則整個子樹不需要動,如果調整,則父結點交換到子結點位置,再以此結點繼續調整。

      下述程式碼使用的大頂堆,建立好堆後堆頂元素為最大值,此時取堆頂元素即使堆頂元素和最後一個元素交換,最大的元素處於陣列最後,此時調整小了一個長度的堆,然後再取堆頂和倒數第二個元素交換,依次類推,完成資料的非遞減排序。

      堆排序的主要時間花在初始建堆期間,建好堆後,堆這種資料結構以及它奇妙的特徵,使得找到數列中最大的數字這樣的操作只需要O(1)的時間複雜度,維護需要logn的時間複雜度。堆排序不適宜於記錄數較少的檔案

複製程式碼

void heapAdjust(int a[], int i, int nLength)
{
    int nChild;
    int nTemp;
    for (nTemp = a[i]; 2 * i + 1 < nLength; i = nChild)
    {
        // 子結點的位置=2*(父結點位置)+ 1
        nChild = 2 * i + 1;
        // 得到子結點中較大的結點
        if ( nChild < nLength-1 && a[nChild + 1] > a[nChild])
            ++nChild;
        // 如果較大的子結點大於父結點那麼把較大的子結點往上移動,替換它的父結點
        if (nTemp < a[nChild])
        {
            a[i] = a[nChild];
            a[nChild]= nTemp;
        }
        else
        // 否則退出迴圈
            break;
    }
}

// 堆排序演算法
void heap_sort(int a[],int length)
{
    int tmp;
    // 調整序列的前半部分元素,調整完之後第一個元素是序列的最大的元素
    //length/2-1是第一個非葉節點,此處"/"為整除
    for (int i = length / 2 - 1; i >= 0; --i)
        heapAdjust(a, i, length);
    // 從最後一個元素開始對序列進行調整,不斷的縮小調整的範圍直到第一個元素
    for (int i = length - 1; i > 0; --i)
    {
        // 把第一個元素和當前的最後一個元素交換,
        // 保證當前的最後一個位置的元素都是在現在的這個序列之中最大的
      ///  Swap(&a[0], &a[i]);
          tmp = a[i];
          a[i] = a[0];
          a[0] = tmp;
        // 不斷縮小調整heap的範圍,每一次調整完畢保證第一個元素是當前序列的最大值
        heapAdjust(a, 0, i);
    }
}

複製程式碼

       歸併排序

      歸併排序是採用分治法(Divide and Conquer)的一個非常典型的應用。首先考慮下如何將將二個有序數列合併。這個非常簡單,只要從比較二個數列的第一個數,誰小就先取誰,取了後就在對應數列中刪除這個數。然後再進行比較,如果有數列為空,那直接將另一個數列的資料依次取出即可。這需要將待排序序列中的所有記錄掃描一遍,因此耗費O(n)時間,而由完全二叉樹的深度可知,整個歸併排序需要進行.logn.次,因此,總的時間複雜度為O(nlogn)。

     歸併排序在歸併過程中需 要與原始記錄序列同樣數量的儲存空間存放歸併結果,因此空間複雜度為O(n)。

     歸併演算法需要兩兩比較,不存在跳躍,因此歸併排序是一種穩定的排序演算法。 

複製程式碼

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

    while (i <= m && j <= n)
    {
        if (a[i] <= a[j])
            temp[k++] = a[i++];
        else
            temp[k++] = a[j++];
    }

    while (i <= m)
        temp[k++] = a[i++];

    while (j <= n)
        temp[k++] = a[j++];

    for (i = 0; i < k; i++)
        a[first + i] = temp[i];
}
void merge_sort(int a[], int first, int last, int temp[])
{
    if (first < last)
    {
        int mid = (first + last) / 2;
        merge_sort(a, first, mid, temp);    //左邊有序
        merge_sort(a, mid + 1, last, temp); //右邊有序
        mergearray(a, first, mid, last, temp); //再將二個有序數列合併
    }
}

複製程式碼

      有的地方看到在mergearray()合併有序數列時分配臨時陣列,即每一步mergearray的結果存放的一個新的臨時數組裡,這樣會在遞迴中消耗大量的空間。因此做出小小的變化。只需要new一個臨時陣列。後面的操作都共用這一個臨時陣列。合併完後將臨時陣列中排好序的部分寫回原陣列。

      歸併排序計算時間複雜度時可以很容易的列出遞迴方程,也是計算時間複雜度的一種方法。

      希爾排序

      希爾排序是對插入排序的優化,基於以下兩個認識:1. 資料量較小時插入排序速度較快,因為n和n2差距很小;2. 資料基本有序時插入排序效率很高,因為比較和移動的資料量少。

      因此,希爾排序的基本思想是將需要排序的序列劃分成為若干個較小的子序列,對子序列進行插入排序,通過則插入排序能夠使得原來序列成為基本有序。這樣通過對較小的序列進行插入排序,然後對基本有序的數列進行插入排序,能夠提高插入排序演算法的效率。

      希爾排序的劃分子序列不是像歸併排序那種的二分,而是採用的叫做增量的技術,例如有十個元素的陣列進行希爾排序,首先選擇增量為10/2=5,此時第1個元素和第(1+5)個元素配對成子序列使用插入排序進行排序,第2和(2+5)個元素組成子序列,完成後增量繼續減半為2,此時第1個元素、第(1+2)、第(1+4)、第(1+6)、第(1+8)個元素組成子序列進行插入排序。這種增量選擇方法的好處是可以使陣列整體均勻有序,儘可能的減少比較和移動的次數,二分法中即使前一半資料有序,後一半中如果有比較小的資料,還是會造成大量的比較和移動,因此這種增量的方法和插入排序的配合更佳。

      希爾排序的時間複雜度和增量的選擇策略有關,上述增量方法造成希爾排序的不穩定性。

 

複製程式碼

void shell_sort(int a[], int n)
{
    int d, i, j, temp; //d為增量
    for(d = n/2;d >= 1;d = d/2) //增量遞減到1使完成排序
    {
        for(i = d; i < n;i++)   //插入排序的一輪
        {
            temp = a[i];
            for(j = i - d;(j >= 0) && (a[j] > temp);j = j-d)
            {
                a[j + d] = a[j];
            }
        a[j + d] = temp;
        }
    }
}

複製程式碼

      二叉樹排序

      二叉樹排序法藉助了資料結構二叉排序樹,二叉排序數滿足三個條件:(1)若左子樹不空,則左子樹上所有結點的值均小於它的根結點的值; (2)若右子樹不空,則右子樹上所有結點的值均大於它的根結點的值; (3)左、右子樹也分別為二叉排序樹。根據這三個特點,用中序遍歷二叉樹得到的結果就是排序的結果。

      二叉樹排序法需要首先根據資料構建二叉排序樹,然後中序遍歷,排序時間複雜度為O(nlogn),構建二叉樹需要額外的O(n)的儲存空間,有相同的元素是可以設定排在後邊的放在右子樹,在中序變數的時候也會在後邊,所以二叉樹排序是穩定的。

      在實現此演算法的時候遇到不小的困難,指標引數在函式中無法通過new賦值,後來採用取指標地址,然後函式設定BST** tree的方式解決。

複製程式碼

int arr[] = {7, 8, 8, 9, 5, 16, 5, 3,56,21,34,15,42};

struct BST{
    int number; //儲存陣列元素的值
    struct BST* left;
    struct BST* right;
};

void insertBST(BST** tree, int v) {
    if (*tree == NULL) {
        *tree = new BST;
        (*tree)->left=(*tree)->right=NULL;
        (*tree)->number=v;
        return;
    }
    if (v < (*tree)->number)
        insertBST(&((*tree)->left), v);
    else
        insertBST(&((*tree)->right), v);
}

void printResult(BST* tree) {
    if (tree == NULL)
        return;
    if (tree->left != NULL)
        printResult(tree->left);
    cout << tree->number << "  ";
    if (tree->right != NULL)
        printResult(tree->right);
}

void createBST(BST** tree, int a[], int n) {
    *tree = NULL;
    for (int i=0; i<n; i++)
        insertBST(tree, a[i]);
}

int main()
{
    int n = sizeof(arr)/sizeof(int);

    BST* root;
    createBST(&root, arr, n);
    printResult(root);

}

複製程式碼

       計數排序

      如果通過比較進行排序,那麼複雜度的下界是O(nlogn),但是如果資料本身有可以利用的特徵,可以不通過比較進行排序,就能使時間複雜度降低到O(n)。

      計數排序要求待排序的陣列元素都是 整數,有很多地方都要去是0-K的正整數,其實負整數也可以通過都加一個偏移量解決的。

      計數排序的思想是,考慮待排序陣列中的某一個元素a,如果陣列中比a小的元素有s個,那麼a在最終排好序的陣列中的位置將會是s+1,如何知道比a小的元素有多少個,肯定不是通過比較去覺得,而是通過數字本身的屬性,即累加陣列中最小值到a之間的每個數字出現的次數(未出現則為0),而每個數字出現的次數可以通過掃描一遍陣列獲得。

      計數排序的步驟:

  1. 找出待排序的陣列中最大和最小的元素(計數陣列C的長度為max-min+1,其中位置0存放min,依次填充到最後一個位置存放max)
  2. 統計陣列中每個值為i的元素出現的次數,存入陣列C的第i
  3. 對所有的計數累加(從C中的第一個元素開始,每一項和前一項相加)
  4. 反向填充目標陣列:將每個元素i放在新陣列的第C(i)項,每放一個元素就將C(i)減去1(反向填充是為了保證穩定性)

      以下程式碼中尋找最大和最小元素參考程式設計之美,比較次數為1.5n次。

      計數排序適合資料分佈集中的排序,如果資料太分散,會造成空間的大量浪費,假設資料為(1,2,3,1000000),這就需要1000000的額外空間,並且有大量的空間浪費和時間浪費。

複製程式碼

void findArrMaxMin(int a[], int size, int *min, int *max)
{
    if(size == 0) {
        return;
    }
    if(size == 1) {
        *min = *max = a[0];
        return;
    }

    *min = a[0] > a[1] ? a[1] : a[0];
    *max = a[0] <= a[1] ? a[1] : a[0];


    int i, j;
    for(i = 2, j = 3; i < size, j < size; i += 2, j += 2) {
        int tempmax = a[i] >= a[j] ? a[i] : a[j];
        int tempmin = a[i] < a[j] ? a[i] : a[j];

        if(tempmax > *max)
            *max = tempmax;
        if(tempmin < *min)
            *min = tempmin;
    }

    //如果陣列元素是奇數個,那麼最後一個元素在分組的過程中沒有包含其中,
    //這裡單獨比較
    if(size % 2 != 0) {
        if(a[size -1] > *max)
            *max = a[size - 1];
        else if(a[size -1] < *min)
            *min = a[size -1];
    }
}

void count_sort(int a[], int b[], int n) {
    int max, min;
    findArrMaxMin(a, n, &min, &max);
    int numRange = max-min+1;
    int* counter = new int[numRange];

    int i, j, k;
    for (k=0; k<numRange; k++)
        counter[k]=0;

    for (i=0; i<n; i++)
        counter[a[i]-min]++;

    for (k=1; k<numRange; k++)
        counter[k] += counter[k-1];

    for (j=n-1; j>=0; j--) {
        int v = a[j];
        int index = counter[v-min]-1;
        b[index]=v;
        counter[v-min]--;
    }
}

複製程式碼

       桶排序

       假設有一組長度為N的待排關鍵字序列K[1....n]。首先將這個序列劃分成M個的子區間(桶) 。然後基於某種對映函式 ,將待排序列的關鍵字k對映到第i個桶中(即桶陣列B的下標 i) ,那麼該關鍵字k就作為B[i]中的元素(每個桶B[i]都是一組大小為N/M的序列)。接著對每個桶B[i]中的所有元素進行比較排序(可以使用快排)。然後依次列舉輸出B[0]....B[M]中的全部內容即是一個有序序列。

      桶排序利用函式的對映關係,減少了計劃所有的比較操作,是一種Hash的思想,可以用在海量資料處理中。

      我覺得計數排序也可以看作是桶排序的特例,陣列關鍵字範圍為N,劃分為N個桶。

      基數排序

      基數排序也可以看作一種桶排序,不斷的使用不同的標準對資料劃分到桶中,最終實現有序。基數排序的思想是對資料選擇多種基數,對每一種基數依次使用桶排序。

      基數排序的步驟:以整數為例,將整數按十進位制位劃分,從低位到高位執行以下過程。

      1. 從個位開始,根據0~9的值將資料分到10個桶桶,例如12會劃分到2號桶中。

      2. 將0~9的10個桶中的資料順序放回到陣列中。

      重複上述過程,一直到最高位。

      上述方法稱為LSD(Least significant digital),還可以從高位到低位,稱為MSD。

複製程式碼

int getNumInPos(int num,int pos) //獲得某個數字的第pos位的值
{
    int temp = 1;
    for (int i = 0; i < pos - 1; i++)
        temp *= 10;

    return (num / temp) % 10;
}

#define RADIX_10 10    //十個桶,表示每一位的十個數字
#define KEYNUM 5     //整數位數
void radix_sort(int* pDataArray, int iDataNum)
{
    int *radixArrays[RADIX_10];    //分別為0~9的序列空間
    for (int i = 0; i < RADIX_10; i++)
    {
        radixArrays[i] = new int[iDataNum];
        radixArrays[i][0] = 0;    //index為0處記錄這組資料的個數
    }

    for (int pos = 1; pos <= KEYNUM; pos++)    //從個位開始到31位
    {
        for (int i = 0; i < iDataNum; i++)    //分配過程
        {
            int num = getNumInPos(pDataArray[i], pos);
            int index = ++radixArrays[num][0];
            radixArrays[num][index] = pDataArray[i];
        }

        for (int i = 0, j =0; i < RADIX_10; i++) //寫回到原陣列中,復位radixArrays
        {
            for (int k = 1; k <= radixArrays[i][0]; k++)
                pDataArray[j++] = radixArrays[i][k];
            radixArrays[i][0] = 0;
        }
    }
}

複製程式碼