資料結構和演算法面試題系列—排序演算法之基礎排序

ssjhust發表於2018-09-27

這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在CSDN。現整理為一個系列給需要的朋友參考,如有錯誤,歡迎指正。本系列完整程式碼地址在 這裡

0 概述

排序演算法也是面試中常常提及的內容,問的最多的應該是快速排序、堆排序。這些排序演算法很基礎,但是如果平時不怎麼寫程式碼的話,面試的時候總會出現各種bug。雖然思想都知道,但是就是寫不出來。本文打算對各種排序演算法進行一個彙總,包括插入排序、氣泡排序、選擇排序、計數排序、歸併排序,基數排序、桶排序、快速排序等。快速排序比較重要,會單獨寫一篇,而堆排序見本系列的二叉堆那篇文章即可。

需要提到的一點就是:插入排序,氣泡排序,歸併排序,計數排序都是穩定的排序,而其他排序則是不穩定的。本文完整程式碼在 這裡

1 插入排序

插入排序是很基本的排序,特別是在資料基本有序的情況下,插入排序的效能很高,最好情況可以達到O(N),其最壞情況和平均情況時間複雜度都是 O(N^2)。程式碼如下:

/**
 * 插入排序
 */
void insertSort(int a[], int n)
{
    int i, j;
    for (i = 1; i < n; i++) {
        /*
         * 迴圈不變式:a[0...i-1]有序。每次迭代開始前,a[0...i-1]有序,
         * 迴圈結束後i=n,a[0...n-1]有序
         * */
        int key = a[i];
        for (j = i; j > 0 && a[j-1] > key; j--) {
            a[j] = a[j-1];
        }
        a[j] = key;
    }
}
複製程式碼

2 希爾排序

希爾排序內部呼叫插入排序來實現,通過對 N/2,N/4...1階分別排序,最後得到整體的有序。

/**
 * 希爾排序
 */
void shellSort(int a[], int n)
{
    int gap;
    for (gap = n/2; gap > 0; gap /= 2) {
        int i;
        for (i = gap; i < n; i++) {
            int key = a[i], j;
            for (j = i; j >= gap && key < a[j-gap]; j -= gap) {
                a[j] = a[j-gap];
            }
            a[j] = key;
        }
    }
}
複製程式碼

3 選擇排序

選擇排序的思想就是第i次選取第i小的元素放在位置i。比如第1次就選擇最小的元素放在位置0,第2次選擇第二小的元素放在位置1。選擇排序最好和最壞時間複雜度都為 O(N^2)。程式碼如下:

/**
 * 選擇排序
 */
void selectSort(int a[], int n)
{
    int i, j, min, tmp;
    for (i = 0; i < n-1; i++) {
        min = i;
        for (j = i+1; j < n; j++) {
            if (a[j] < a[min])
                min = j;
        }
        if (min != i)
            tmp = a[i], a[i] = a[min], a[min] = tmp; //交換a[i]和a[min]
    }
}
複製程式碼

迴圈不變式:在外層迴圈執行前,a[0...i-1]包含 a 中最小的 i 個數,且有序。

  • 初始時,i=0a[0...-1] 為空,顯然成立。

  • 每次執行完成後,a[0...i] 包含 a 中最小的 i+1 個數,且有序。即第一次執行完成後,a[0...0] 包含 a 最小的 1 個數,且有序。

  • 迴圈結束後,i=n-1,則 a[0...n-2]包含 a 最小的 n-1 個數,且已經有序。所以整個陣列有序。

4 氣泡排序

氣泡排序時間複雜度跟選擇排序相同。其思想就是進行 n-1 趟排序,每次都是把最小的數上浮,像魚冒泡一樣。最壞情況為 O(N^2)。程式碼如下:

/**
 * 氣泡排序-經典版
 */
void bubbleSort(int a[], int n)
{
    int i, j, tmp;
    for (i = 0; i < n; i++) {
        for (j = n-1; j >= i+1; j--) {
            if (a[j] < a[j-1])
                tmp = a[j], a[j] = a[j-1], a[j-1] = tmp;
        }
    }
}
複製程式碼

迴圈不變式:在迴圈開始迭代前,子陣列 a[0...i-1] 包含了陣列 a[0..n-1]i-1 個最小值,且是排好序的。

對氣泡排序的一個改進就是在每趟排序時判斷是否發生交換,如果一次交換都沒有發生,則陣列已經有序,可以不用繼續剩下的趟數直接退出。改進後程式碼如下:

/**
 * 氣泡排序-優化版
 */
void betterBubbleSort(int a[], int n)
{
    int tmp, i, j;
    for (i = 0; i < n; i++) {
        int sorted = 1;
        for (j = n-1; j >= i+1; j--) {
            if (a[j] < a[j-1]) {
                tmp = a[j], a[j] = a[j-1], a[j-1] = tmp;
                sorted = 0;
            }   
        }   
        if (sorted)
            return ;
    }   
}
複製程式碼

5 計數排序

假定陣列為 a[0...n-1] ,陣列中存在重複數字,陣列中最大數字為k,建立兩個輔助陣列 b[]c[]b[] 用於儲存排序後的結果,c[] 用於儲存臨時值。時間複雜度為 O(N),適用於數字範圍較小的陣列。

計數排序

計數排序原理如上圖所示,程式碼如下:

/**
 * 計數排序
 */
void countingSort(int a[], int n) 
{
    int i, j;
    int *b = (int *)malloc(sizeof(int) * n);
    int k = maxOfIntArray(a, n); // 求陣列最大元素
    int *c = (int *)malloc(sizeof(int) * (k+1));  //輔助陣列

    for (i = 0; i <= k; i++)
        c[i] = 0;

    for (j = 0; j < n; j++)
        c[a[j]] = c[a[j]] + 1; //c[i]包含等於i的元素個數

    for (i = 1; i <= k; i++)
        c[i] = c[i] + c[i-1];  //c[i]包含小於等於i的元素個數

    for (j = n-1; j >= 0; j--) {  // 賦值語句
        b[c[a[j]]-1] = a[j]; //結果存在b[0...n-1]中
        c[a[j]] = c[a[j]] - 1;
    }

    /*方便測試程式碼,這一步賦值不是必須的*/
    for (i = 0; i < n; i++) {
        a[i] = b[i];
    }

    free(b);
    free(c);
}
複製程式碼

擴充套件: 如果程式碼中的給陣列 b[] 賦值語句 for (j=n-1; j>=0; j--) 改為 for(j=0; j<=n-1; j++),該程式碼仍然正確,只是排序不再穩定。

6 歸併排序

歸併排序通過分治演算法,先排序好兩個子陣列,然後將兩個子陣列歸併。時間複雜度為 O(NlgN)。程式碼如下:

/*
 * 歸併排序-遞迴
 * */
void mergeSort(int a[], int l, int u) 
{
    if (l < u) {
        int m = l + (u-l)/2;
        mergeSort(a, l, m);
        mergeSort(a, m + 1, u);
        merge(a, l, m, u);
    }
}
 
/**
 * 歸併排序合併函式
 */
void merge(int a[], int l, int m, int u) 
{
    int n1 = m - l + 1;
    int n2 = u - m;

    int left[n1], right[n2];
    int i, j;
    for (i = 0; i < n1; i++) /* left holds a[l..m] */
        left[i] = a[l + i];

    for (j = 0; j < n2; j++) /* right holds a[m+1..u] */
        right[j] = a[m + 1 + j];

    i = j = 0;
    int k = l;
    while (i < n1 && j < n2) {
        if (left[i] < right[j])
            a[k++] = left[i++];
        else
            a[k++] = right[j++];
    }
    while (i < n1) /* left[] is not exhausted */
        a[k++] = left[i++];
    while (j < n2) /* right[] is not exhausted */
        a[k++] = right[j++];
}
複製程式碼

擴充套件:歸併排序的非遞迴實現怎麼做?

歸併排序的非遞迴實現其實是最自然的方式,先兩兩合併,而後再四四合並等,就是從底向上的一個過程。程式碼如下:

/**
 * 歸併排序-非遞迴
 */
void mergeSortIter(int a[], int n)
{
    int i, s=2;
    while (s <= n) {
        i = 0;
        while (i+s <= n){
            merge(a, i, i+s/2-1, i+s-1);
            i += s;
        }

        //處理末尾殘餘部分
        merge(a, i, i+s/2-1, n-1);
        s*=2;
    }
    //最後再從頭到尾處理一遍
    merge(a, 0, s/2-1, n-1);
}
複製程式碼

7 基數排序、桶排序

基數排序的思想是對數字每一位分別排序(注意這裡必須是穩定排序,比如計數排序等,否則會導致結果錯誤),最後得到整體排序。假定對 N 個數字進行排序,如果數字有 d 位,每一位可能的最大值為 K,則每一位的穩定排序需要 O(N+K) 時間,總的需要 O(d(N+K)) 時間,當 d 為常數,K=O(N) 時,總的時間複雜度為O(N)。

基數排序

而桶排序則是在輸入符合均勻分佈時,可以以線性時間執行,桶排序的思想是把區間 [0,1) 劃分成 N 個相同大小的子區間,將 N 個輸入均勻分佈到各個桶中,然後對各個桶的連結串列使用插入排序,最終依次列出所有桶的元素。

桶排序

這兩種排序使用場景有限,程式碼就略過了,更詳細可以參考《演算法導論》的第8章。

參考資料

相關文章