1. 程式人生 > >深度剖析八大經典排序演算法—C++實現(通俗易懂版)

深度剖析八大經典排序演算法—C++實現(通俗易懂版)

內容會持續更新,有錯誤的地方歡迎指正,謝謝!

引言

需握各種排序和常用的資料結構的核心思想,並知道是以什麼樣的方式解決了什麼樣的問題。

該部落格的示例程式碼均以遞增排序為目的~
學習建議:切忌看示例程式碼去理解演算法,而是理解演算法原理寫出程式碼,否則會很快就會忘記。

//排序原始陣列:
int a[10]= { 7,5,3,4,4,1,9,45,121,3 };

演算法分類

八大經典排序演算法分類:

  1. 交換類排序(氣泡排序、快速排序)
  2. 插入類排序(簡單插入排序、希爾排序)
  3. 選擇類排序(簡單選擇排序、堆排序)
  4. 歸併排序
  5. 計數排序

總結:
所需輔助空間最多:歸併排序
平均速度最快:快速排序
不穩定:快速排序,希爾排序,簡單選擇排序,堆排序

資料初始排序狀態對堆排序不會產生太大的影響,而快速排序卻恰恰相反。還有基數排序、桶排序可以瞭解一下。

演算法的實現

非線性時間比較類排序

交換類排序

一、最基礎的排序—氣泡排序

原理:

  1. 比較相鄰的兩個數的大小,將最大的數放在右邊,計數器i++;
  2. 繼續重複操作1,直到a[n-2]和a[n-1]比較結束,陣列a中最大的值已在a[n-1];
  3. 將進行排序的陣列長度n減1,重複操作1和操作2,直到n為1,排序完畢。

複雜度:

平均時間複雜度:O(n^2)

最好的情況:如果待排序資料序列為正序,則一趟冒泡就可完成排序,排序的比較次數為n-1次,且沒有移動,時間複雜度為O(n)。要實現O(n)的複雜度,程式碼裡需要加一個標誌位(Bool變數)。

最壞的情況:如果待排序資料序列為逆序,則氣泡排序需要n-1次趟,每趟進行n-i次排序的比較和移動,即比較和移動次數均達到最大值:比較次數=n(n−1)/2=O(n^2),移動次數等於比較次數,因此最壞時間複雜度為O(n^2)。

穩定性:

穩定排序(穩定性和不穩定性指:相同元素的前後順序沒變或變了)

程式碼:

void BubbleSort(int* array, int length)
{
    for (int i = 0; i < length - 1; ++i)
    {
        //bool is_Swap=false;
        for (int j = 0
; j < length - 1 - i; ++j) { if (array[j] > array[j + 1]) { //is_Swap=true; int temp = array[j]; array[j] = array[j + 1]; array[j + 1] = temp; /* 交換還可使用如下方式 a = a + b; b = a - b; a = a - b; 交換還可使用如下方式 a=a^b; b=b^a; a=a^b; */ } } //if(is_Swap==false) //return; } }

二、最快的排序—快速排序

在交換類排序演算法中,快排是速度最快的。採用分治的思想。

原理:

  1. 從n個元素中選擇一個元素作為分割槽的標準,一般選第一個元素;
  2. 把小於該元素的放在左邊,把大於等於該元素的放在右邊,中間就放該元素;
  3. 再分別對左右子序列重複操作1和2,直到每個子序列裡只有一個元素,排序完畢。

複雜度:

平均時間複雜度:O(n*log n)。原因:快排是將陣列一分為二到底,所以需要O(log n)次此操作,每次操作需要排序n次,所以,大多數情況下,時間複雜度都是O(n*log n)。

最好的情況:是每趟排序結束後,每次劃分使兩個子檔案的長度大致相等,時間複雜度為O(n*log n)。

最壞的情況:是待排序元素已經排好序。第一趟經過n-1次比較後第一個元素保持位置不變,並得到一個n-1個元素的子序列;第二趟經過n-2次比較,將第二個元素定位在原來的位置上,並得到一個包括n-2個元素的子序列,依次類推,這樣總的比較次數是:n(n-1)/2=O(n^2)。

穩定性:

不穩定排序

程式碼:

第一種快速排序:一個“指標”從左往右,一個“指標”從右往左

void QuickSort(int* array,int low,int high) 
{
    if (low >= high)
        return;
    int left = low;
    int right = high;
    int key = array[left];//選擇第一個元素作為區分元素,當然也可以選最後一個元素。
    while (left != right)
    {
        while (left != right&&array[right] >= key)//從右往左,把小於key的元素放到key的左邊
            --right;
        array[left] = array[right];
        while (left != right&&array[left] <= key)//從左往右,把大於key的元素放到key的右邊
            ++left;
        array[right] = array[left];
    }
    array[left] = key;//此時left等於right

    //一分為二,分治思想,遞迴呼叫。
    QuickSort(array, low, left - 1);
    QuickSort(array, left + 1, high);
}

眾所周知,Partition函式不管是在快速排序中,還是在找第K大這類問題中,都有很重要的地位。上述程式碼將Partition函式寫入了QuickSort函式裡,並未單獨寫成函式。現在我把它單獨寫成函式:

int Partition(int* array,int left,int right)
{
    int key = array[left];
    while (left != right)
    {
        while (left != right&&array[right] >= key)//從右往左,把小於key的元素放到key的左邊
            --right;
        array[left] = array[right];
        while (left != right&&array[left] <= key)//從左往右,把大於key的元素放到key的右邊
            ++left;
        array[right] = array[left];
    }
    array[left] = key;
    return left;//返回區分函式
}

//快排主函式
void quicksort(int* arr, int left, int right)
{
    if(left< right)
    {
        int middle = mypartition(arr, left, right);
        quicksort(arr, left, middle-1);
        quicksort(arr, middle+1, right);
    }
}

第二種快速排序:兩個“指標”一前一後地往前走,以一定的規律swap兩個元素

int Partition(int* arr, int left, int right)
{
    int pivot = arr[left];//選第一個元素作為樞紐元,也可以選最後一個元素。
    int location = left;//location指向left
    for(int i = left+1; i <= right; i++)//從left+1到right
       if(arr[i] < pivot)
           swap(arr[i], arr[++location]);
    swap(arr[left], arr[location]);//樞紐元和一個大於樞紐元的數互換位置
    return location;//返回現在樞紐元的位置
}

插入類排序

三、撲克牌法排序—簡單插入排序

插入排序是拿元素去找位置,氣泡排序是拿位置去找元素。

原理:

俗氣的說法:打撲克牌時,一張張地摸牌並將摸到的牌按大小順序插入到現有的序列中。
正式的說法:從待排序的陣列的第二個元素開始,將其與前面的數進行大小比較,尋找合適的位置並插入,直到全部元素都已插入。

複雜度:

平均時間複雜度:O(n^2)

最好的情況:當待排序記錄已經有序,這時需要比較的次數為n-1=O(n)

最壞的情況:如果待排序記錄為逆序,則最多的比較次數為n*(n-1)/2=O(n^2)

穩定性:

穩定排序

程式碼:

void InsertSort(int* array,int length) 
{
    int i = 0, j = 0, temp = 0;
    for (i = 1; i < length; ++i) 
    {
        //如果該元素小於前面的元素,大於該元素的元素全部後移一位,
        //直到找到該元素要插入的位置並插入之。
        if (array[i] < array[i-1])
        {
            temp = array[i];
            for (j = i-1; temp < array[j] && j >= 0 ; --j) 
            {
                array[j+1] = array[j];
            }
            array[j + 1] = temp;
        }
    }
}

四、縮小增量—希爾排序

希爾排序又叫縮小增量排序,插入排序的改進版。

原理:

  1. 對相鄰指定距離(即增量)的元素進行分組,再對每個組內的元素進行插入排序。
  2. 每個組排序結束後,再把增量除以2,重複操作1。
  3. 直到增量變為1,最後再進行一次插入排序。
假設:對5 9 7 4 8 2 9 1排序

第一次增量為8/2=4a[0]和a[4]、a[1]和a[5]、a[2]和a[6]、a[3]和a[7]一組
每組插入排序的結果彙總:5 2 7 1 8 9 9 4

第二次增量為4/2=2a[0]和a[2]和a[4]和a[6]、a[1]和a[3]和a[5]和a[7]一組
每組插入排序的結果彙總:5 1 7 2 8 4 9 9

第三次增量為2/2=1:全部元素一組
全部元素插入排序的結果:1 2 4 5 7 8 9 9

複雜度:

希爾排序的時間複雜度取決於其增量的選擇,範圍在O(n*log n)到O(n^2)。

穩定性:

不穩定排序

程式碼:

void ShellSort(int* array, int length) 
{
    int i = 0, j = 0, k = 0 , temp = 0, gapVal = 0;
    for (gapVal = length / 2; gapVal > 0; gapVal = gapVal / 2)
    {
        //分為gapVal個組
        for (i = 0; i < gapVal; ++i)
        {
            //開始插入排序
            for (j = i + gapVal; j < length; j += gapVal)
            {
                if (array[j] < array[j - 1])
                {
                    temp = array[j];
                    for (k = j - 1; k >= 0 && temp < array[k]; --k) 
                    {
                        array[k + 1] = array[k];
                    }
                    array[k+1] = temp;
                }
            }
        }
    }
}

選擇類排序

五、最易理解的排序—簡單選擇排序

選擇排序比冒泡更容易理解。

原理:

遍歷一遍找到最小的元素,將其和第一個元素交換位置;再找到第二小的元素,將其和第二個元素交換位置;直到排序完畢。看起來像是冒泡,但它不是相鄰元素之間的交換。

複雜度:

平均時間複雜度:n*(n-1)/2=O(n^2)

最好的情況:也是O(n^2)

最壞的情況:也是O(n^2)

穩定性:

不穩定排序(例如:5,4,9,5,1,7。第一個5會和1互換位置導致不穩定。)

程式碼:

void SelectSort(int* array, int length) 
{
    int i = 0, j = 0, k = 0, temp = 0;
    for (i = 0; i < length-1; ++i)
    {
        k = i;
        for (j = i+1; j < length; ++j)
        {
            if (array[j] < array[k])
                k = j;
        }
        temp = array[i];
        array[i] = array[k];
        array[k] = temp;
    }
}

六、樹形選擇排序—堆排序

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

簡單選擇排序中,第一次選擇經過了n-1次比較操作,只是從陣列中選出了一個最小的元素,而沒有儲存其他中間比較結果。所以後一趟排序時又要重複許多比較操作,降低了效率。而堆排序避免這一缺點。

要掌握堆排序,必須要先學習堆的知識,務必點選連結進行學習後,再接著看~

原理:

  1. 先將初始陣列a[0..n-1]建成一個最大堆,此堆為初始的無序區。
  2. 再將值最大的元素a[0]和無序區的最後一個元素a[n-1]交換,由此得到新的無序區a[0..n-2]和有序區a[n-1]。
  3. 由於交換後新的根a[0]可能違反堆性質,故應將當前無序區a[0..n-2]調整為最大堆。然後再次將a[0..n-2]中值最大的元素a[0]和該區間的最後一個記錄a[n-2]交換,由此得到新的無序區a[0..n-3]和有序區a[n-2..n-1]。
  4. 重複此操作,直到無序區只有一個元素為止。

    注意:
    1.只需做n-1趟排序;
    2.堆排序中無序區總是在有序區之前,且有序區是在原向量的尾部由後往前逐步擴大至整個向量為止。

複雜度:

分析:建堆需N / 2次向下調整,每次調整的時間複雜度為O(logN);每次堆調整的時間複雜度為O(logN),共需N - 1次堆調整操作。兩次操作時間相加還是O(N * logN)。故堆排序的時間複雜度為O(N * logN)。

平均時間複雜度:O(n*log n)

最好的情況:如果根據待排序陣列建的堆是最大堆,則不需要建堆操作,但需要O(n*log n)次堆調整操作,時間複雜度為O(n*log n)。

最壞的情況:如果根據待排序陣列建的堆是最小堆,不僅要O(n*log n)的建堆操作,還需要O(n*log n)次堆調整操作,時間複雜度還是O(n*log n)。

穩定性:

不穩定排序

程式碼:

void MaxHeapSort(int* array, int length) 
{
    //建最大堆
    MakeMaxHeap(array, length);
    int temp = 0;
    for (int i = 0;i<length-1; ++i) 
    {
        //最大堆的根節點和最後一個子葉節點交換
        temp = array[0];
        array[0] = array[length - 1 - i];
        array[length - 1 - i] = temp;

        //調整為最大堆
        MaxHeapFixDown(array, length - 1 - i, 0);
    }
}

七、分而治之—歸併排序

號稱比較類排序中效能最佳者,應用較廣。

原理:

歸併排序是分治法的一個典型的應用,先使每個子序列有序,再使每個子序列間有序。將兩個有序子序列合併成一個有序表,稱為二路歸併。
步驟:首先將有序數列一分二,二分四……直到每個區都只有一個數據,此時每個子序列都可看做有序序列。然後進行合併,每次合併都是有序序列在合併,所以效率比較高。

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

複雜度:

平均、最好、最壞的時間複雜度都為:O(n*log n)

可以這樣理解:合併需要O(log n)步操作,每步將排好序的子序列合併需要O(n)的操作。那時間複雜度肯定是O(n*log n)。

穩定性:

穩定排序

程式碼:

先考慮下如何將將二個有序數組合並。這個非常簡單,比較二個數組的第一個數,誰小就先取誰,取了後就在對應陣列中刪除這個數。繼續重複前面的操作,直到有一個數組為空,再直接將另一個數組的資料依次取出即可。

//合併兩個有序的陣列成一個有序的陣列
void MergeArray(int* arrayA, int lengthA, int* arrayB, int lengthB, int* temp)
{
    int i = 0, j = 0, k = 0;
    while (i < lengthA&&j < lengthB) 
    {
        if (arrayA[i] < arrayB[j])
            temp[k++] = arrayA[i++];
        else
            temp[k++] = arrayB[j++];
    }
    while (i < lengthA) 
    {
        temp[k++] = arrayA[i++];
    }
    while (j < lengthB) 
    {
        temp[k++] = arrayB[j++];
    }
}

先遞迴地分解陣列到每個區只有一個數據,再遞迴的合併陣列(參考上方程式碼可寫出下方程式碼):

void MergeArray(int* array, int first, int mid, int last, int* temp)
{
    //將a[first...mid]和a[mid+1...last]合併
    int i = first, j = mid + 1, k = 0;
    int lengthA = mid+1, lengthB = last+1;
    while (i < lengthA&&j < lengthB) 
    {
        if (array[i] < array[j])
            temp[k++] = array[i++];
        else
            temp[k++] = array[j++];
    }
    while (i < lengthA) 
    {
        temp[k++] = array[i++];
    }
    while (j < lengthB) 
    {
        temp[k++] = array[j++];
    }
    for (i = 0; i < k; ++i) 
    {
        array[first + i] = temp[i];
    }
}

void MergeSort(int* array, int first, int last, int* temp) 
{
    if (first >= last)
        return;
    int mid = (first + last) / 2;
    MergeSort(array, first, mid, temp);//左邊有序
    MergeSort(array, mid + 1, last, temp);//右邊有序
    MergeArray(array, first, mid, last, temp);//合併兩個有序的子序列
}
//總結:有些書上的歸併排序程式碼,是在MergeArray()或MergeSort()中申請temp[]數
//組的記憶體,這樣的做法,非常浪費效能!像我這樣,So Easy~

另外,由於要申請和陣列大小相同的空間n,即空間複雜度為O(n)。這是速度和空間的相愛相殺,總要找一個平衡點才好~

總結:歸併排序在處理很多個數據的時候,是非常快的!而當,資料個數小於50的時候,還不如冒泡快!哈哈哈~

線性時間非比較類排序

八、集中資料的排序—計數排序

計數排序的優勢在於在對於較小範圍內的整數排序。它的複雜度為Ο(n+k)(其中k是待排序數的範圍),快於任何比較排序演算法,缺點就是非常消耗空間。很明顯,如果而且當O(k)>O(n*log(n))的時候其效率反而不如基於比較的排序。

原理:

有n個數,待排序的元素是0到k之間的整數。對於每一個元素x,計算小於或等於元素x的元素的個數,然後根據計數情況將元素放入到相應的位置。

要求:待排序的元素中最大數值不能太大。

複雜度:

分析:為了計算出每個數出現的次數,需要遍歷該n個數,也就需要O(n)次;小於x的要累加,累加總次數為k次,也就需要O(k)次。

時間複雜度:O(n+k)

空間複雜度:O(k)

穩定性:

穩定

程式碼:

void CountSort(int* array, int length, int min, int max)
{
    //length為陣列的長度,min為最小值,max為最大值。
    int i = 0;
    int j = 0;
    int sizeOfCount = max - min + 1;
    int* count = new int[sizeOfCount];
    memset(count, 0, sizeof(int) * sizeOfCount);

    for (i = 0;i<length; ++i)
        count[array[i] - min]++;
    for (i = 0, j = 0; i < sizeOfCount;) 
    {
        if (count[i] != 0)
        {
            array[j++] = min + i;
            count[i]--;
        }
        else
            ++i;
    }
    delete[] count;
}

注意:計數排序是典型的以空間換時間的排序演算法,對待排序的資料有嚴格的要求。比如待排序的數值中包含負數,最大值很大,最好就別用了。

九、基數排序

屬於“分配式排序”,又稱“桶子法”。

原理:

將整形10進位制按每位拆分,然後從低位到高位依次比較各個位。主要分為兩個步驟:
1.分配,先從個位開始,根據位值(0-9)分別放到0~9號桶中(比如32,個位為2,則放入2號桶中);
2.收集,再將放置在0~9號桶中的資料按順序放到陣列中;
重複1和2,從個位到最高位。

複雜度:

時間複雜度:O(dn)(d即表示整形的最高位數)

空間複雜度:O(10n) (10表示0~9,用於儲存臨時的序列)

穩定性:

穩定

十、桶排序

桶排序也是分配排序的一種,但它是基於比較排序的,這也是與基數排序最大的區別所在。

原理:

桶排序演算法想法類似於散列表。首先,假設待排序的元素符合某種均勻分佈,例如資料均勻分佈在[ 0,100)區間上,則可將此區間劃分為10個小區間,稱為桶,對散佈到同一個桶中的元素再排序。

例如待排序列K= {27、 86、 27、 57、 12、 4、 98、 56}。這些資料全部在1—100之間。因此我們定製10個桶,然後確定對映函式f(k)=k/10。則第一個元素27將定位到第2個桶中(27/10=2)。依次將所有元素全部放入桶中,並在每個非空的桶中進行快速排序。

複雜度:

對n個元素進行桶排序的時間複雜度分為兩個部分:
1.迴圈計算每個元素的桶對映函式,這個時間複雜度是O(N)。
2.利用快速排序演算法對每個桶內的所有資料進行排序,對於N個待排資料,M個桶,平均每個桶[N/M]個數據,則桶內排序的時間複雜度為 O(M*N/M*log(N/M)) = O(N*log(N/M))。

平均時間複雜度為線性的O(N+N*log(N/M))。

最好的時間複雜度為:O(N),也就是每個桶只有一個數的情況。

穩定性:

穩定

八大排序演算法動態圖文講解

看完上面的內容,再來看這個圖解,一目瞭然,便可銘記於心!

參考文獻