【排序】n2以下級別的基本排序算法:希爾排序、歸併排序、堆排序和快排

介紹基於比較的排序剩下的幾個,剩下幾個的算法效率都高於前面幾個,除了希爾排序之外都是 O(nlogn)O(n\log{n}) 級別的。

1. 希爾排序

1.1 思路

插入排序的算法複雜度爲 O(n2)O(n^{2})但如果序列爲正序可提高到 O(n)O(n),而且直接插入排序算法比較簡單,希爾排序利用這兩點得到了一種改進後的插入排序。
希爾排序在數組中採用跳躍式分組的策略,通過某個增量將數組元素劃分爲若干組,然後分組進行插入排序,隨後逐步縮小增量,繼續按組進行插入排序操作,直至增量爲1。希爾排序通過這種策略使得整個數組在初始階段達到從宏觀上看基本有序,小的基本在前,大的基本在後。然後縮小增量,到增量爲1時,其實多數情況下只需微調即可,不會涉及過多的數據移動。
在每趟的排序過程都有一個增量,至少滿足一個規則 增量關係 d[1] > d[2] > d[3] >..> d[t] = 1 (t趟排序);根據增量序列的選取其時間複雜度也會有變化

1.2 演示

例一

  • step1

在這裏插入圖片描述

  • step2

在這裏插入圖片描述

  • step3
    在這裏插入圖片描述

  • step4
    在這裏插入圖片描述

例二

在這裏插入圖片描述

1.3 實現要點

子序列排序方法

希爾排序的一個重要性質是,隨着排序次數增長,之前的排序成果會保留下來,整個序列的逆序數會不斷減少

又因爲,插入排序的運行時間正比於逆序數,所以我們選擇它作爲底層排序算法。這樣算法的整體運行效率會越來越高。

詳細分析,見數據結構mooc

增量(步長)取法

對於步長取法,唯一的要求就是最後一步必須爲1,不同的取法對於算法效率的影響很大。

在這裏插入圖片描述

1.4 實現

希爾排序雖然複雜,但實現起來很簡單,準確來講把直接插入算法加個外殼,再把 1 改成 gap 就成了希爾排序

/**
 * 希爾排序
 * 使用 2^n 作爲增量,初始爲 len / 2,反覆整除最後一定可以爲1
 * 仔細觀察發現,一下代碼其實就是把插入排序中的 1 改成 gap 
*/
void ShellSort(int arr[], int len)
{
    for (int gap = len / 2; gap > 0; gap /= 2)
    {
        //這裏基本和插入排序一樣了,只是把 1 改成了 gap 而已
        for (int i = gap; i < len; ++i)
        {
            int v = arr[i], j;
            for (j = i; j >= gap && arr[j - gap] > v; j -= gap)
                arr[j] = arr[j - gap];
            arr[j] = v;
        }
    }
}

這個算法可能有點迷,讓人搞不清它在對誰排序,在這裏我說明一下。

  • i
    它所定位的是每一個不同的組別,也就是之前圖中所看到的矩陣的每一列;而指向了每個組別中的要排序元素,並對其進行相應的操作

  • j
    相當於插入排序的j,只不過j的移動是以gap爲單位的,因此判斷條件中:

    • arr[j - gap] > v
      相當於插入排序的arr[j - 1] > v
    • j >= gap
      相當於插入排序的j > 0也就是j >= 1

但是在這裏,有一個不好看懂的就是該算法的操作並非是一次性將每一組的元素排好序,而是一次只對一組中的一個元素進行排序,然後 i 指針就會移動到下一組等到 i 移動的次數剛好是j的倍數之後,j 就又會指向之前拍過一次序的組別中的下一個元素並對其排序

換而言之,該算法的流程是這樣的:

start
step1
step2
stepx
group1:e1
group2:e1
...
groupn:e1
group1:e2
group2:e2
...
groupn:e2
...

而不是

start
step1
step2
stepx
group1:e1
group1:e2
...
group1:en
group2:e1
group2:e2
...
group2:en
other groups

雖然以上二者在效果上是等價的,但是前者在操作上要簡便一些(雖然理解上難一些)。

2. 歸併排序

2.1 思路

歸併排序是建立在歸併操作上的一種有效的排序算法,該算法是採用 分治法(Divide and Conquer) 的一個非常典型的應用。將已有序的子序列合併,得到完全有序的序列;即先使每個子序列有序,再使子序列段間有序。若將兩個有序表合併成一個有序表,稱爲二路歸併。
速度僅次於快速排序,爲穩定排序算法,一般用於對總體無序,但是各子項相對有序的數列

在這裏插入圖片描述

2.2 算法分析

  • 空間複雜度:
    歸併的空間複雜度就是那個臨時的數組和遞歸時壓入棧的數據佔用的空間n+lognn + \log_{}n;所以空間複雜度爲: O(n)O(n)

  • 時間複雜度

    • 最好: O(nlogn)O(n\log_{}n)
    • 最壞: O(nlogn)O(n\log_{}n)
    • 平均: O(nlogn)O(n\log_{}n)

證明:可知 T(n)=2T(n2+Θ(n)T(n) = 2T(\frac{n}{2} + \Theta(n),由主定理可知:
nlog22=nf(n)=Θ(n)T(n)=O(nlogn) n^{\log_{2}2}=n \land f(n) = \Theta(n)\\ \therefore T(n) = O(n\log_{}n)

  • 穩定性:穩定

2.3 實現

源碼

/**
 * 歸併排序,arr[beg:end],左閉右開
*/
void MergeSortFunc(int arr[], int beg, int end)
{
    //排除只有一個元素,或者是空表的情況
    //注意這裏如果寫成 beg < end,會在元素只有一個時無限循環
    if ((end - beg) > 1)
    {
        int mid = (beg + end) / 2;
        MergeSortFunc(arr, beg, mid);
        MergeSortFunc(arr, mid, end);
        Merge(arr, beg, mid, end);
    }
}

void Merge(int arr[], int beg, int mid, int end)
{
    int temp[end - beg];
    int i = beg, j = mid, k = 0;
    while ((i < mid) && (j < end))
    {
        if (arr[i] <= arr[j])
            temp[k++] = arr[i++];
        else
            temp[k++] = arr[j++];
    }
    while (i < mid)
        temp[k++] = arr[i++];
    while (j < end)
        temp[k++] = arr[j++];
    for (int a = beg, b = 0; b < end - beg;)
    {
        arr[a++] = temp[b++];
    }
}

注意

剛開始的時候我的遞歸條件是這樣的:

if (beg < end)
{
        
}

然後我就陷入了死循環出不來了╮(╯▽╰)╭。

問題出在我劃分區間的方式,還有結束遞歸條件的設置上。在這裏我的區間設定是[beg, end),這樣就會在beg == end時退出遞歸,想的倒是挺好,不過來看看這種情況:

假設在某次遞歸中,beg代表了index = 1的元素,相應的end代表index = 2,整個區間只有一個元素。這樣mid = 1 + 1 / 2 = 1,然後我們就會發現函數一直朝MergeSort(mid, end);的方向遞歸下去,無法返回了。

所以爲了避免這種情況,請記住:

將遞歸結束條件設置爲區間內只有一個元素或者空表!!!

記住這一個原則,然後在根據自己的區間設置規範來調整就行了,比如:

//顯然這裏是[first, last]
//當first == last 說明只有一個元素結束遞歸 
if (first < last)  
{  
    int mid = (first + last) / 2;  
    mergesort(a, first, mid, temp);    //左邊有序  
    mergesort(a, mid + 1, last, temp); //右邊有序  
    mergearray(a, first, mid, last, temp); //再將二個有序數列合併  
}  

3. 快速排序

快速排序使用分治的思想,通過一趟排序將待排序列分割成兩部分其中一部分記錄的關鍵字均比另一部分記錄的關鍵字小。之後分別對這兩部分記錄繼續進行排序,以達到整個序列有序的目的。

3.1 思路

快速排序的原理

假設我們有這樣一個序列,它可以分爲前後兩個子序列,且滿足:

max(SL)min(SR) max(S_{L}) \leqslant min(S_{R})

這樣,如果我們將左右兩個序列分別排序(分治的思想),然後將子序列再連接起來,則整個序列一定是有序的

快速排序正是運用了這一個事實,將整個序列不斷分爲左小右大的兩個子序列遞歸排序,最終使得整體排序完成

可以看到,快排與歸併有一定相似之處。不同的是,歸併的關鍵在於怎麼將子序列合起來,快排的關鍵在於怎麼劃分出符合要求的子序列。快排的最大障礙也就在這裏。

軸點

爲了實現這一想法,我們首先要引入一個概念——軸點(pivot)

軸點是在序列中滿足一種特殊要求的元素在軸點左側區間的所有值都小於軸點的值,右側區間的所有值都大於軸點的值

在這裏插入圖片描述

結合以上說明,我們可以得出以下結論:

  • 以任意一個軸點爲界,整個區間呈現左小右大的狀態。將軸點左側和右側的序列分別排序,再直接串聯起來,就會得到整體有序的序列。(正好滿足我們的需求)
  • 每一個軸點的位置必然是它在最終有序序列中的位置。換而言之,每一個軸點其自身一定是無須移位的
  • 只要在序列中確定軸點,就可以遞歸式地解決問題。
Iterator pivot;
if (end - begin > 1)
{
    //劃分軸點
    pivot = Partition(begin, end);
    //對pivot左側的前綴排序
    QuickSort(begin, pivot);
    //對pivot右側的後綴排序(mid不需要排序!)
    QuickSort(pivot+1, end);
}

由此我們可以得知,快速排序的核心就是如何找到軸點

構建軸點

然而一個麻煩的問題在於:在一個序列中很可能根本就沒有先天性的軸點。比如任意一個亂序序列,每一個元素都不能作爲軸點。

因此,我們需要找到一些候補軸點,通過一系列地處理將它培養成爲軸點。上述算法中的Partition方法的作用就是構造一個有效的軸點。

在這裏插入圖片描述

如圖所示:

  • 用兩個指針指向兩段
  • 不斷將元素放入區間U
  • 最終將選定的軸點的值放入m處
  • 結束算法

演示

在這裏插入圖片描述

在這裏插入圖片描述

2.2 算法評估

快速排序的實際效率取決於選定軸點的最終位置。軸點越接近中點、劃分越平均,則算法效率越高。

  • 空間複雜度:

  • 時間複雜度:

    • 平均:O(nlogn)O(n\log_{}n)
    • 最好:每次都劃分在中點 O(nlogn)O(n\log_{}n)
    • 最壞:每次都劃分在最大(最小點) O(n2)O(n^{2}) (與冒泡排序相當)
  • 穩定性:不穩定

2.3 實現

/**
 * 快速排序實現的注意事項:
 * 注意pivot + 1,因爲pivot已經排序好了,不需要在加入進去
*/
void QuickSortFunc(int arr[], int beg, int end)
{
    //空表或者只有一個元素就返回
    if ((end - beg) > 1)
    {
        int pivot = Partition(arr, beg, end);
        QuickSortFunc(arr, beg, pivot);
        //注意 +1,pivot的順序是對的,不需要排序
        QuickSortFunc(arr, pivot + 1, end);
    }
}
int Partition(int arr[], int beg, int end)
{
    //注意,這裏的區間劃分:[beg, end)  [beg, last]
    int last = end - 1;
    int i = beg, j = last;
    int pivotVal = arr[i];
    //結束條件爲 i 和 j 指向同一位置
    //最後只要將 pivot 放入就可以結束整個算法了
    while (i < j)
    {
        //右側區間不斷擴展
        //別忘了i < j的條件
        while (i < j && arr[j] >= pivotVal)
            --j;
        //擴展結束說明遇到了本該放入左側區間的元素
        arr[i] = arr[j];
        //同理
        while (i < j && arr[i] <= pivotVal)
            ++i;
        arr[j] = arr[i];
    }
    arr[i] = pivotVal;
    return i;
}

附加說明:

注意一下在遞歸時,與歸併算法不同的一點是,這裏的 pivot 有獨特的含義,是已經就位了的軸點元素,因此進行子遞歸是不應該將它放入遞歸區間

應該是這樣:

//正確
if (end - beg > 1)
{
    int pivot = Partition(arr, beg, end);
    QuickSortFunc(arr, beg, pivot);
    QuickSortFunc(arr, pivot + 1, end);
}

而不是

//錯誤,不能將pivot也包括進去
if (end - beg > 1)
{
    int pivot = Partition(arr, beg, end);
    QuickSortFunc(arr, beg, pivot);
    QuickSortFunc(arr, pivot, end);
}

要是真一個手抖寫成了後者,就會陷入無盡的遞歸而不得脫身╰(‵□′)╯

這裏我們將候補軸點的選擇統一安排爲序列第一個元素,事實上這並不好(就像希爾排序選擇2的指數一樣不好)。
但是如果我們選取的樞軸不是第一個元素,在執行這個算法中,就會存在第一個元素丟失的風險。因此我們不妨這樣做:選取第一,中間,最後的元素中中間者作爲樞軸元素,然後將它與第一個元素交換

4. 堆排序

4.1 思路

堆的性質見其他博文。

思路很簡單:

把 arr[0, end) 建立成一個大頂堆
for j = [last, 0), --j
    Swap(0, j)
    調整 arr[0, j), 使之重新變爲大頂堆

在這裏插入圖片描述

4.2 實現

int Parent(int i)
{
    return (i + 1) / 2 - 1;
}

int Left(int i)
{
    return (i + 1) * 2 - 1;
}

void AdjustTop(int arr[], int top, int len)
{
    int topVal = arr[top];
    for (int child; Left(top) < len; top = child)
    {
        child = Left(top);
        if (child + 1 < len && arr[child + 1] > arr[child])
            ++child;
        if (topVal < arr[child])
            arr[top] = arr[child];
        else
            break;
    }
    arr[top] = topVal;
}

void HeapSort(int arr[], int len)
{
    int last = len - 1;
    //建立大頂堆
    for (int i = Parent(last); i >= 0; --i)
        AdjustTop(arr, i, len);
    //排序
    for (int last = len - 1; last > 0; --last)
    {
        Swap(arr[0], arr[last]);
        //交換完畢,把 last 前面的部分當作堆來調整
        AdjustTop(arr, 0, last);
    }
}

4.3 算法評估

  • 空間複雜度:O(1)O(1)

  • 時間複雜度:

    • 平均:O(nlogn)O(n\log_{}n)
    • 最好:O(nlogn)O(n\log_{}n)
    • 最壞:O(nlogn))O(n\log_{}n))
  • 穩定性:不穩定

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章