數據結構與算法分析(七)--- 排序算法分析 + 排序優化

一、如何分析一個排序算法

學習排序算法,我們除了學習它的算法原理、代碼實現之外,更重要的是要學會如何評價、分析一個排序算法。那分析一個排序算法,要從哪幾個方面入手呢?

1.1 排序算法的執行效率

對於排序算法執行效率的分析,我們一般會從這幾個方面來衡量:

  • 最好、最壞、平均情況時間複雜度

我們在分析排序算法的時間複雜度時,要分別給出最好情況、最壞情況、平均情況下的時間複雜度。除此之外,你還要說出最好、最壞時間複雜度對應的要排序的原始數據是什麼樣的。

爲什麼要區分這三種時間複雜度呢?第一,有些排序算法會區分,爲了好對比,所以我們最好都做一下區分。第二,對於要排序的數據,有的接近有序,有的完全無序,有序度不同的數據,對於排序的執行時間肯定是有影響的,我們要知道排序算法在不同數據下的性能表現。

  • 時間複雜度的係數、常數 、低階

我們知道,時間複雜度反應的是數據規模 n 很大的時候的一個增長趨勢,所以它表示的時候會忽略係數、常數、低階。但是實際的軟件開發中,我們排序的可能是 10 個、100 個、1000 個這樣規模很小的數據,所以,在對同一階時間複雜度的排序算法性能對比的時候,我們就要把係數、常數、低階也考慮進來。

  • 比較、交換或移動的次數

前面介紹過的插入排序、希爾排序、歸併排序、快速排序等都是基於比較的排序算法。基於比較的排序算法的執行過程,會涉及兩種操作,一種是元素比較大小,另一種是元素交換或移動。所以,如果我們在分析排序算法的執行效率的時候,應該把比較次數和交換(或移動)次數也考慮進去。

1.2 排序算法的內存消耗

算法的內存消耗可以通過空間複雜度來衡量,排序算法也不例外。不過,針對排序算法的空間複雜度,我們還引入了一個新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空間複雜度是 O(1) 的排序算法。

1.3 排序算法的穩定性

僅僅用執行效率和內存消耗來衡量排序算法的好壞是不夠的。針對排序算法,我們還有一個重要的度量指標,穩定性。對於穩定的排序算法,如果待排序的序列中存在值相等的元素,經過排序之後,相等元素之間原有的先後順序不變。

針對排序算法的穩定性舉例解釋一下,比如我們有一組數據 2,9,3,4,8,3,按照大小排序之後就是 2,3,3,4,8,9,這組數據裏有兩個 3。經過某種排序算法排序之後,如果兩個 3 的前後順序沒有改變,那我們就把這種排序算法叫作穩定的排序算法;如果前後順序發生變化,那對應的排序算法就叫作不穩定的排序算法。

你可能疑惑,兩個 3 哪個在前,哪個在後有什麼關係啊,穩不穩定又有什麼關係呢?爲什麼要考察排序算法的穩定性呢?

很多數據結構和算法課程,在講排序的時候,都是用整數來舉例,但在真正軟件開發中,我們要排序的往往不是單純的整數,而是一組對象,我們需要按照對象的某個 key 來排序。

比如說,我們現在要給電商交易系統中的“訂單”排序。訂單有兩個屬性,一個是下單時間,另一個是訂單金額,如果我們現在有 10 萬條訂單數據,我們希望按照金額從小到大對訂單數據排序,對於金額相同的訂單,我們希望按照下單時間從早到晚有序。對於這樣一個排序需求,我們怎麼來實現呢?

最先想到的方法是:我們先按照金額對訂單數據進行排序,然後,再遍歷排序之後的訂單數據,對於每個金額相同的小區間再按照下單時間排序。這種排序思路理解起來不難,但是實現起來會很複雜。而且假如原始訂單就是按照下單時間排好序的,沒有充分利用這個有效信息,會讓計算機多做很多無效工作,降低算法的執行效率。

藉助穩定排序算法,這個問題可以非常簡潔地解決。解決思路是這樣的:我們先按照下單時間給訂單排序(如果訂單本身是按照下單時間排好序的,此步可以省略)。排序完成之後,我們用穩定排序算法,按照訂單金額重新排序。兩遍排序之後,我們得到的訂單數據就是按照金額從小到大排序,金額相同的訂單按照下單時間從早到晚排序的。爲什麼呢?

穩定排序算法可以保持金額相同的兩個對象,在排序之後的前後順序不變。第一次排序之後,所有的訂單按照下單時間從早到晚有序了。在第二次排序中,我們用的是穩定的排序算法,所以經過第二次排序之後,相同金額的訂單仍然保持下單時間從早到晚有序。
排序算法對比

二、基礎排序算法分析

2.1 插入排序算法分析

插入排序動畫
在博客:遞推與遞歸 + 減治排序中已經詳細介紹過插入排序算法,這裏爲了分析方便,把遞歸函數變換爲迭代循環的形式。按照博客:隊列、棧及其STL容器末尾介紹的將尾遞歸函數轉換爲迭代循環函數的方法,轉換後的插入排序算法如下:

// algorithm\sort.c

void insert_sort(int *data, int n)
{
	int k = 1;
    while(k < n)
    {   
    	int i = k-1, temp = data[k];
    	while (i >= 0 && data[i] > temp)
    	{
        	data[i+1] = data[i];
        	i--;
	    }
    	data[i+1] = temp;
		
		k = k + 1;
	}
}

使用前面介紹的分析方法,來分析一下插入排序算法:

  • 插入排序的時間複雜度是多少?

最好情況下,要排序的數據已經是有序的了,我們只需要進行一次遍歷操作(數據有序時,上面代碼的內循環退化爲常數時間複雜度),就可以結束了,所以最好情況時間複雜度是 O(n)。而最壞的情況是,要排序的數據剛好是倒序排列的,內循環每次都要比較到最開頭插入數據(在數組中插入元素的最壞情況複雜度是O(n)),所以最壞情況時間複雜度爲 O(n2)。

最好、最壞情況下的時間複雜度很容易分析,那平均情況下的時間複雜是多少呢?我們前面講過,平均時間複雜度就是加權平均期望時間複雜度,分析的時候要結合概率論的知識。對於包含 n 個數據的數組,這 n 個數據就有 n! 種排列方式。不同的排列方式,插入排序執行的時間肯定是不同的。如果用概率論方法定量分析平均時間複雜度,涉及的數學推理和計算就會很複雜。

針對排序算法,有一種更簡單的方法,通過“有序度”和“逆序度”這兩個概念來進行分析。有序度是數組中具有有序關係的元素對的個數,有序元素對可以用數學表達式表示如下:

有序元素對:a[i] <= a[j], 如果i < j。

對於一個倒序排列的數組,比如 6,5,4,3,2,1,有序度是 0;對於一個完全有序的數組,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2 ,也即Cn2,對於上面的數組也就是C62 = 15,我們把這種完全有序的數組的有序度叫作滿有序度。

逆序度的定義正好跟有序度相反(默認從小到大爲有序),逆序元素對可以用數學表達式表示如下:

逆序元素對:a[i] > a[j], 如果i < j。

關於這三個概念,我們還可以得到一個公式:逆序度 = 滿有序度 - 有序度。我們排序的過程就是一種增加有序度,減少逆序度的過程,最後達到滿有序度,就說明排序完成了。

插入排序包含兩種操作,一種是元素的比較,一種是元素的移動。當我們需要將一個數據 a 插入到已排序區間時,需要拿 a 與已排序區間的元素依次比較大小,找到合適的插入位置。找到插入點之後,我們還需要將插入點之後的元素順序往後移動一位,這樣才能騰出位置給元素 a 插入。對於不同的插入點,元素的比較次數是有區別的。但對於一個給定的初始序列,移動操作的次數總是固定的,就等於逆序度,也就是n*(n-1)/2 – 初始有序度。

比如要排序的數組的初始狀態是 4,5,6,3,2,1 ,其中有序元素對有 (4,5) 、(4,6)、(5,6),所以有序度是 3。n=6,所以排序完成之後終態的滿有序度爲 n*(n-1)/2=15,逆序度就是 15 – 3 = 12,插入排序要進行 12 次移動操作。

對於包含 n 個數據的數組進行插入排序,平均移動次數是多少呢?最壞情況下,初始狀態的有序度是 0,所以要進行 n*(n-1)/2 次移動。最好情況下,初始狀態的有序度是 n*(n-1)/2,就不需要進行移動。我們可以取箇中間值 n*(n-1)/4,來表示初始有序度既不是很高也不是很低的平均情況。換句話說,平均情況下,需要 n*(n-1)/4 次移動操作,比較操作肯定要比移動操作多,而複雜度的上限是 O(n2),所以平均情況下的時間複雜度就是 O(n2)。

這個平均時間複雜度推導過程其實並不嚴格,但是很多時候很實用,畢竟概率論的定量分析太複雜,不太好用。

  • 插入排序是原地排序算法嗎?

從實現過程可以很明顯地看出,插入排序算法的運行並不需要額外的存儲空間,所以空間複雜度是 O(1),也就是說,這是一個原地排序算法。

  • 插入排序是穩定的排序算法嗎?

在插入排序中,對於值相同的元素,我們可以選擇將後面出現的元素,插入到前面出現元素的後面,這樣就可以保持原有的前後順序不變,所以插入排序是穩定的排序算法。

2.2 冒泡排序算法分析

冒泡排序動畫
冒泡排序只會操作相鄰的兩個數據。每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求。如果不滿足就讓它倆互換。一次冒泡會讓至少一個元素移動到它應該在的位置,重複 n 次,就完成了 n 個數據的排序工作。當某次冒泡操作已經沒有數據交換時,說明已經達到完全有序,不用再繼續執行後續的冒泡操作。冒泡排序算法實現代碼如下:

// algorithm\sort.c

void bubble_sort(int *data, int n)
{
    int i, j;
    bool flag;

    for(i = 0; i < n; i++)
    {
        flag = true;
        for(j = 1; j < n - i; j++)
        {
            if(data[j] < data[j-1])
            {
                swap_data(&data[j], &data[j-1]);
                flag = false;
            }
        }
        if(flag == true)
            break;
    }
}

使用前面介紹的分析方法,來分析一下冒泡排序算法:

  • 冒泡排序的時間複雜度是多少?

最好情況下,要排序的數據已經是有序的了,我們只需要進行一次冒泡操作,就可以結束了,所以最好情況時間複雜度是 O(n)。而最壞的情況是,要排序的數據剛好是倒序排列的,我們需要進行 n 次冒泡操作,所以最壞情況時間複雜度爲 O(n2)。

冒泡排序包含兩個操作原子,比較和交換。每交換一次,有序度就加 1。不管算法怎麼改進,交換次數總是確定的,即爲逆序度,也就是n*(n-1)/2–初始有序度。

對於包含 n 個數據的數組進行冒泡排序,最壞情況下,初始狀態的有序度是 0,所以要進行 n*(n-1)/2 次交換。最好情況下,初始狀態的有序度是 n*(n-1)/2,就不需要進行交換。平均情況下,需要 n*(n-1)/4 次交換操作,比較操作肯定要比交換操作多,而複雜度的上限是O(n2),所以平均情況下的時間複雜度就是 O(n2)。

  • 冒泡排序是原地排序算法嗎?

冒泡的過程只涉及相鄰數據的交換操作,只需要常量級的臨時空間,所以它的空間複雜度爲 O(1),是一個原地排序算法。

  • 冒泡排序是穩定的排序算法嗎?

在冒泡排序中,只有交換纔可以改變兩個元素的前後順序。爲了保證冒泡排序算法的穩定性,當有相鄰的兩個元素大小相等的時候,我們不做交換,相同大小的數據在排序前後不會改變順序,所以冒泡排序是穩定的排序算法。

2.3 選擇排序算法分析

選擇排序動畫
選擇排序算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。選擇排序算法的實現代碼如下:

// algorithm\sort.c

void select_sort(int *data, int n)
{
    int i,j, min;
	for(i = 0; i < n; i++)
    {
        min = i;
        for(j = i + 1; j < n; j++)
        {
            if(data[j] < data[min])
                min = j;
        }
        if(min != i)
            swap_data(&data[i], &data[min]);
    }
}

使用前面介紹的分析方法,來分析一下選擇排序算法:

  • 選擇排序的時間複雜度是多少?

選擇排序的最好、最壞、平均時間複雜度都是O(n2),因爲無論數據序列是完全有序,還是完全逆序,都需要找出後邊的最小值進行交換。

  • 選擇排序是原地排序算法嗎?

選擇排序不需要額外的存儲空間,空間複雜度爲O(1),所以是原地排序算法。

  • 選擇排序是穩定的排序算法嗎?

選擇排序每次都要找剩餘未排序元素中的最小值,並和前面的元素交換位置,這樣破壞了穩定性。因此,選擇排序是一種不穩定的排序算法。

比如 5,8,5,2,9 這樣一組數據,使用選擇排序算法來排序的話,第一次找到最小元素 2,與第一個 5 交換位置,那第一個 5 和中間的 5 順序就變了,所以就不穩定了。正是因此,相對於冒泡排序和插入排序,選擇排序就稍微遜色了。

  • 爲什麼插入排序更受歡迎呢?

上面三種排序算法都是原地排序算法,這一點三方打平。選擇排序是一種不穩定的排序算法,這使其遜色於另外兩種排序算法。因爲選擇排序算法即便在最好情況下,時間複雜度仍爲O(n2),因此不能有效利用“原序列已經存在的部分順序信息”,這一點也使其遜色於另外兩種排序算法。

冒泡排序不管怎麼優化,元素交換的次數是一個固定值,是原始數據的逆序度。插入排序是同樣的,不管怎麼優化,元素移動的次數也等於原始數據的逆序度。從代碼實現上來看,冒泡排序的數據交換要比插入排序的數據移動要複雜,冒泡排序需要 3 個賦值操作,而插入排序只需要 1 個。

我們把執行一個賦值語句的時間粗略地計爲單位時間(unit_time),然後分別用冒泡排序和插入排序對同一個逆序度是 K 的數組進行排序。用冒泡排序,需要 K 次交換操作,每次需要 3 個賦值語句,所以交換操作總耗時就是 3*K 單位時間。而插入排序中數據移動操作只需要 K 個單位時間。

所以,雖然冒泡排序和插入排序在時間複雜度上是一樣的,都是 O(n2),但是如果我們希望把性能優化做到極致,那肯定首選插入排序。

從這三種基礎算法的複雜度分析也可以看出,冒泡排序與插入排序都依賴於相鄰元素的交換或移動,相鄰元素每交換或移動一次逆序度只減少1,因此借用前面逆序度的分析,可得出一個結論:通過交換或移動相鄰元素來完成排序的算法,其平均時間複雜度下界爲Ω(n2)。

選擇排序比較特殊,找到最小元素與未排序區間第一個元素進行的並非是相鄰元素交換,兩者假如跨度較大,每次遠距離元素交換可能逆序度減少大於1。比如數列5,4,3,2,1,選擇排序第一次交換後,數列變爲了1,4,3,2,5,也就是說它這一次交換就使得數列的逆序數-7。所以選擇排序可以使用更少的交換來完成排序,當然這並不意味着選擇排序就會很快,因爲選擇排序的元素比較次數(實現代碼中的內循環部分)的下界爲Ω(n2),因此選擇排序的最好、最壞、平均時間複雜度均爲O(n2),也正是因爲遠距離元素交換才讓選擇排序成爲不穩定的排序算法。

要想讓排序算法的時間複雜度低於O(n2),我們需要借鑑選擇排序這種遠距離元素交換或移動方法,同時借鑑插入排序這種充分利用“原序列已存在的有序度”的方法,設計更高效的排序算法。

前面博客介紹過的:希爾排序就是第一批融合上面兩種算法優點的排序算法,希爾排序通過一個增量序列,將原序列通過某個增量值提取出多組小序列,某個小序列元素間相隔一個增量值,在這些小序列內部實行插入排序時,小序列內部的相鄰元素移動就相當於原序列跨度爲增量值的遠距離元素移動,通過這種方法可以突破O(n2)時間複雜度邊界,實現更高效的排序。

希爾排序不佔用額外存儲空間,它也是一個原地排序算法。但是,希爾排序涉及遠距離元素移動,它並不是一個穩定的排序算法。
基礎排序算法分析結果

三、高級排序算法分析

3.1 歸併排序算法分析

歸併排序動畫
在博客:分治與減治 + 分治排序中對歸併排序的原理與實現有比較詳細的介紹,這裏就不再贅述,爲了方便後面分析,再次給出實現代碼:

// algorithm\sort.c

void merge_sort(int *data, int n)
{
    int *temp = malloc(n * sizeof(int));

    recursive_merge(data, temp, 0, n - 1);

    free(temp);
}

void recursive_merge(int *data, int *temp, int left, int right)
{
    if(left >= right)
        return;

    int mid = left + (right - left) / 2;
    recursive_merge(data, temp, left, mid);
    recursive_merge(data, temp, mid + 1, right);

    merge_data(data, temp, left, mid, right);
}

void merge_data(int *data, int *temp, int left, int mid, int right)
{
    int i = left, j = mid + 1, k = 0;

    while(i <= mid && j <= right)
    {
        if(data[i] <= data[j])
            temp[k++] = data[i++];
        else
            temp[k++] = data[j++];
    }

    while(i <= mid)
        temp[k++] = data[i++];

    while(j <= right)
        temp[k++] = data[j++];

    for(i = left, k = 0; i <= right; i++, k++)
        data[i] = temp[k];
}

使用前面介紹的分析方法,來分析一下歸併排序算法:

  • 歸併排序的時間複雜度是多少?

歸併排序涉及遞歸,時間複雜度的分析稍微有點複雜。在遞歸那篇博客中介紹過,遞歸的適用場景是,一個問題 a 可以分解爲多個子問題 b、c,那求解問題 a 就可以分解爲求解問題 b、c。問題 b、c 解決之後,我們再把 b、c 的結果合併成 a 的結果。

如果我們定義求解問題 a 的時間是 T(a),求解問題 b、c 的時間分別是 T(b) 和 T( c),那我們就可以得到這樣的遞推關係式:

T( a ) = T( b ) + T( c ) + K
其中 K 等於將兩個子問題 b、c 的結果合併成問題 a 的結果所消耗的時間。

從剛剛的分析,我們可以得到一個結論:不僅遞歸求解的問題可以寫成遞推公式,遞歸代碼的時間複雜度也可以寫成遞推公式。套用這個公式,我們來分析一下歸併排序的時間複雜度。

我們假設對 n 個元素進行歸併排序需要的時間是 T(n),那分解成兩個子數組排序的時間都是 T(n/2)。我們知道,merge() 函數合併兩個有序子數組的時間複雜度是 O(n)。所以,套用前面的公式,歸併排序的時間複雜度的計算公式就是:

T(1) = C; n=1時,只需要常量級的執行時間,所以表示爲C。
T(n) = 2*T(n/2) + n; n>1

通過這個公式,如何來求解 T(n) 呢?我們再進一步分解一下計算過程:

T(n) = 2*T(n/2) + n     
	 = 2*(2*T(n/4) + n/2) + n 
	 = 4*T(n/4) + 2*n     
	 = 4*(2*T(n/8) + n/4) + 2*n 
	 = 8*T(n/8) + 3*n     
	 = 8*(2*T(n/16) + n/8) + 3*n 
	 = 16*T(n/16) + 4*n     
	 ......     
	 = 2^k * T(n/2^k) + k * n     
	 ......

通過這樣一步一步分解推導,我們可以得到 T(n) = 2k * T(n / 2k) + k * n。當 T(n/2k)=T(1) 時,也就是 n/2k=1,我們得到 k=log2n 。我們將 k 值代入上面的公式,得到 T(n)=C * n+n * log2n 。如果我們用大 O 標記法來表示的話,T(n) 就等於 O(nlogn),所以歸併排序的時間複雜度是 O(nlogn)。

從我們的原理分析和僞代碼可以看出,歸併排序的執行效率與要排序的原始數組的有序程度無關,所以其時間複雜度是非常穩定的,不管是最好情況、最壞情況,還是平均情況,時間複雜度都是 O(nlogn)。

  • 歸併排序的空間複雜度是多少?

從歸併排序的合併函數代碼可以看出,在合併兩個有序數組爲一個有序數組時,需要藉助額外的存儲空間。因此,歸併排序不是原地排序算法

那麼,歸併排序的空間複雜度到底是多少呢?如果我們繼續按照分析遞歸時間複雜度的方法,通過遞推公式來求解,那整個歸併過程需要的空間複雜度就是 O(nlogn)。但是,遞歸代碼的空間複雜度並不能像時間複雜度那樣累加。在任意時刻,CPU 只會有一個函數在執行,也就只會有一個臨時的內存空間在使用,臨時內存空間最大也不會超過 n 個數據的大小,所以歸併排序的空間複雜度是 O(n)

  • 歸併排序是穩定的排序算法嗎?

從歸併排序的實現代碼可以看出,歸併排序穩不穩定關鍵要看合併函數,也就是兩個有序子數組合併成一個有序數組的那部分代碼。

在合併的過程中,如果 data[left…mid] 和 data[mid+1…right] 之間有值相同的元素,那我們可以像上面實現代碼中那樣,先把 data[left…mid] 中的元素放入 temp 數組,這樣就保證了值相同的元素,在合併前後的先後順序不變。所以,歸併排序是一個穩定的排序算法。

3.2 快速排序算法分析

快速排序動畫
在博客:分治與減治 + 分治排序中對快速排序的原理與實現有比較詳細的介紹,這裏就不再贅述,上面的動畫沒有體現三數取中的過程,只體現了選擇最後一個元素作爲樞紐值的過程。爲了方便後面分析,再次給出實現代碼:

// algorithm\sort.c

void quick_sort(int *data, int left, int right)
{
    if(right - left <= 1)
    {
        if(right - left == 1 && data[left] > data[right])
            swap_data(&data[left], &data[right]);
  
        return;
    }

    int divide = partition(data, left, right);
    quick_sort(data, left, divide - 1);
    quick_sort(data, divide + 1, right);
}

int partition(int *data, int left, int right)
{
    /*
    // 隨機選擇分界線或樞紐值
    int index = left + rand() % (right - left + 1);
    swap_data(&data[index], &data[right]);
    int i = left, j = right - 1, pivot = right;
    */
    // 三數取中法選擇分界線或樞紐值
    int mid = left + (right - left) / 2;
    if(data[left] > data[mid])
        swap_data(&data[left], &data[mid]);
    if(data[left] > data[right])
        swap_data(&data[left], &data[right]);
    if(data[mid] > data[right])
        swap_data(&data[mid], &data[right]);

    swap_data(&data[mid], &data[right-1]);
    int i = left + 1, j = right - 2, pivot = right - 1;
    
    while (true)
    {
        while (data[i] < data[pivot])
            i++;

        while (j > left && data[j] >= data[pivot])
            j--;
        
        if(i < j)
            swap_data(&data[i], &data[j]);
        else
            break;
    }
    
    if(i < right)
        swap_data(&data[i], &data[pivot]);

    return i;
}

使用前面介紹的分析方法,來分析一下快速排序算法:

  • 快速排序的時間複雜度是多少?

快排也是用遞歸來實現的,對於遞歸代碼的時間複雜度分析,前面總結的公式,這裏也還是適用的。如果每次分區操作,都能正好把數組分成大小接近相等的兩個小區間,那快排的時間複雜度遞推求解公式跟歸併是相同的。所以,快排的時間複雜度也是 O(nlogn)。

T(1) = C; n=1時,只需要常量級的執行時間,所以表示爲C。
T(n) = 2*T(n/2) + n; n>1

但是,公式成立的前提是每次分區操作,我們選擇的 pivot 都很合適,正好能將大區間對等地一分爲二。但實際上這種情況是很難實現的。舉一個比較極端的例子,如果每次分區得到的兩個區間都是極不均等的(其中一個區間只有1個元素),我們需要進行大約 n 次分區操作,才能完成快排的整個過程。每次分區我們平均要掃描大約 n/2 個元素,這種情況下,快排的時間複雜度就從 O(nlogn) 退化成了 O(n2)。

已經分析了兩個極端情況下的時間複雜度,一個是分區極其均衡,一個是分區極其不均衡。它們分別對應快排的最好情況時間複雜度和最壞情況時間複雜度。那快排的平均情況時間複雜度是多少呢?

我們假設每次分區操作都將區間分成大小爲 9:1 的兩個小區間。我們繼續套用遞歸時間複雜度的遞推公式,就會變成這樣:

T(1) = C; n=1時,只需要常量級的執行時間,所以表示爲C。
T(n) = T(n/10) + T(9*n/10) + n; n>1

這個公式的遞推求解的過程比較複雜,就不展開分析了,這裏直接給出結論:T(n) 在大部分情況下的時間複雜度都可以做到 O(nlogn),只有在極端情況下,纔會退化到 O(n2)。而且,我們也有很多方法將這個概率降到很低,比如上面實現代碼中給出的隨機選擇樞紐值、三數取中法等。

如果想分析快速排序的平均時間複雜度,可以藉助遞歸樹來分析,更簡單些,想了解使用遞歸樹如何分析快速排序的平均時間複雜度,可以參考博客:遞歸樹與決策樹應用

  • 快速排序的空間複雜度是多少?

從快速排序的實現代碼可以看出,快速排序並沒有使用額外的存儲空間,空間複雜度爲O(1)。所以,快速排序是一種原地排序算法。

  • 快速排序是穩定的排序算法嗎?

從快速排序的分區函數partition()代碼可以看出,分區的過程涉及交換操作,如果數組中有兩個相同的元素,比如序列 6,8,7,6,3,5,9,4,在經過第一次分區操作之後,兩個 6 的相對先後順序就會改變。所以,快速排序並不是一個穩定的排序算法。

  • 爲什麼快速排序更受歡迎呢?

歸併排序算法是一種在任何情況下時間複雜度都比較穩定的排序算法,但它存在一個致命的缺點,即歸併排序不是原地排序算法,空間複雜度比較高,是 O(n)。正因爲此,它也沒有快速排序應用廣泛。

快速排序算法雖然最壞情況下的時間複雜度是 O(n2),但是平均情況下時間複雜度是 O(nlogn)。而且,快速排序算法時間複雜度退化到 O(n2) 的概率非常小,我們可以通過合理地選擇 pivot 來避免這種情況。快速排序算法相比歸併排序算法減少了遞歸的迴歸合併過程和數據元素複製過程,在工程上,快速排序也會比歸併排序快兩到三倍。

快速排序並不是一個穩定的排序算法,如果要求使用高效穩定的排序算法,還是得使用歸併排序。

歸併排序、快速排序、包括後面要介紹的堆排序實際上都是基於比較的排序算法,我們可以藉助決策樹來分析這類基於比較的排序算法的時間複雜度下界是Ω(N*logN),如果想了解使用決策樹的分析過程,可以參考博客:遞歸樹與決策樹應用

由此可見,要想對N個數據完成排序,基於相鄰元素交換或移動的排序算法的時間複雜度下界是Ω(N2),基於元素間比較的排序算法的時間複雜度下界是Ω(N*logN),要想實現更高效的排序算法,需要避免元素間的比較,那怎麼在避免元素間比較的情況下使元素有序呢?

如果我們把每個元素值映射到一個原本就有序的結構中,然後再從有序結構中取出,就可以完成排序了吧。前面介紹的順序表中連續的存儲單元編號(也即數組下標)本身就是從小到大編址的有序結構啊,本來數組下標用來表示元素的存儲位置,如果我們把元素值映射到數組下標中,而將元素存儲的位置放到數組某下標對應的存儲單元中,就可以藉助該數組將對應的元素放到正確的位置,整個過程並沒有涉及比較過程,只是完成了兩次元素映射(用空間換時間),可以達到接近線性的時間複雜度,這就是下面要介紹的線性排序算法(可以按上述邏輯重點分析下計數排序,桶式排序可以看作是半線性排序算法)。

四、線性排序算法分析

4.1 桶排序算法分析

桶排序,借鑑了快速排序按分界線劃分區間的技巧,核心思想是將要排序的數據通過m個分界線劃分爲(m + 1)個區間,將這些區間內的元素按順序分配到(m + 1)個桶裏,每個桶裏的數據再單獨進行排序。桶內排完序之後,再把每個桶裏的數據按照順序依次取出,組成的序列就是有序的了。
桶排序動畫
桶排序的實現原理比較簡單,桶內排序調用前面介紹的快速排序或歸併排序算法即可,在將原數據序列劃分放入m個桶內的過程相對複雜些,主要是每個桶內的元素個數不確定,可以採用變長數組作爲桶的容器。

使用前面介紹的分析方法,來分析一下桶排序算法:

  • 桶排序的時間複雜度是多少?

如果要排序的數據有 n 個,我們把它們均勻地劃分到 m 個桶內,每個桶裏就有 k = n/m 個元素。每個桶內部使用快速排序或歸併排序(看對排序的穩定性是否有要求),時間複雜度爲 O(k * logk)。m 個桶排序的時間複雜度就是 O(m * k * logk),因爲 k=n/m,所以整個桶排序的時間複雜度就是 O(n * log(n/m))。當桶的個數 m 接近數據個數 n 時,log(n/m) 就是一個非常小的常量,這個時候桶排序的最好情況時間複雜度接近 O(n)。

桶排序看起來很優秀,那它是不是可以替代我們之前講的排序算法呢?答案當然是否定的,因爲桶排序對要排序數據的要求是非常苛刻的。首先,要排序的數據需要很容易就能劃分成 m 個桶,並且桶與桶之間有着天然的大小順序,這樣每個桶內的數據都排序完之後,桶與桶之間的數據不需要再進行排序。其次,數據在各個桶之間的分佈是比較均勻的,如果數據經過桶的劃分之後,有些桶裏的數據非常多,有些非常少,很不平均,那桶內數據排序的時間複雜度就不是常量級了,在極端情況下,如果數據都被劃分到一個桶裏,那就退化爲 O(nlogn) 的排序算法了。

  • 桶排序是原地排序算法嗎?

桶排序中的桶需要額外佔用內存空間,因此桶排序並不是原地排序算法

  • 桶排序是穩定的排序算法嗎?

桶排序在將原數據序列劃分區間並放到各個桶中的過程中可以保持相等元素的原有順序不變。因此,桶排序是否是穩定的排序算法就取決於每個桶內元素的排序使用的是不是穩定排序算法。假如每個桶內使用的是快速排序,此時桶排序就不是穩定的排序算法,假如每個桶內使用的是歸併排序,則桶排序就是穩定的排序算法。

  • 桶排序的適用場景有哪些?

桶排序比較適合用在外部排序中。所謂的外部排序就是數據存儲在外部磁盤中,數據量比較大,內存有限,無法將數據全部加載到內存中。比如我們有 10GB 的訂單數據,我們希望按訂單金額(假設金額都是正整數)進行排序,但是我們的內存有限,只有幾百 MB,沒辦法一次性把 10GB 的數據都加載到內存中。這個時候該怎麼辦呢?這個時候可以藉助桶排序的處理思想來解決這個問題。

我們可以先掃描一遍文件,看訂單金額所處的數據範圍。假設經過掃描之後我們得到,訂單金額最小是 1 元,最大是 10 萬元。我們將所有訂單根據金額劃分到 100 個桶裏,第一個桶我們存儲金額在 1 元到 1000 元之內的訂單,第二桶存儲金額在 1001 元到 2000 元之內的訂單,以此類推。每一個桶對應一個文件,並且按照金額範圍的大小順序編號命名(00,01,02…99)。

理想的情況下,如果訂單金額在 1 到 10 萬之間均勻分佈,那訂單會被均勻劃分到 100 個文件中,每個小文件中存儲大約 100MB 的訂單數據,我們就可以將這 100 個小文件依次放到內存中,用快排來排序。等所有文件都排好序之後,我們只需要按照文件編號,從小到大依次讀取每個小文件中的訂單數據,並將其寫入到一個文件中,那這個文件中存儲的就是按照金額從小到大排序的訂單數據了。

不過,訂單按照金額在 1 元到 10 萬元之間並不一定是均勻分佈的 ,所以 10GB 訂單數據是無法均勻地被劃分到 100 個文件中的。有可能某個金額區間的數據特別多,劃分之後對應的文件就會很大,沒法一次性讀入內存。這又該怎麼辦呢?針對這些劃分之後還是比較大的文件,我們可以繼續劃分,比如,訂單金額在 1 元到 1000 元之間的比較多,我們就將這個區間繼續劃分爲 10 個小區間,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果劃分之後,101 元到 200 元之間的訂單還是太多,無法一次性讀入內存,那就繼續再劃分,直到所有的文件都能讀入內存爲止。

4.2 計數排序算法分析

計數排序可以看作是桶排序的一種特殊情況。當要排序的 n 個數據,所處的範圍並不大的時候,比如最大值是 k,我們就可以把數據劃分成 k 個桶。每個桶內的數據值都是相同的,省掉了桶內排序的時間。
計數排序動畫
我們都經歷過高考,高考查分數系統你還記得嗎?我們查分數的時候,系統會顯示我們的成績以及所在省的排名。如果你所在的省有 50 萬考生,如何通過成績快速排序得出名次呢?考生的滿分是 750 分,最小是 0 分,這個數據的範圍很小,所以我們可以分成 751 個桶,對應分數從 0 分到 750 分。根據考生的成績,我們將這 50 萬考生劃分到這 751 個桶裏。桶內的數據都是分數相同的考生,所以並不需要再進行排序。我們只需要依次掃描每個桶,將桶內的考生依次輸出到一個數組中,就實現了 50 萬考生的排序。因爲只涉及掃描遍歷操作,所以時間複雜度是 O(n)。

計數排序的算法思想就是這麼簡單,跟桶排序非常類似,只是桶的大小粒度不一樣。爲什麼這個排序算法叫“計數”排序呢?“計數”的含義來自哪裏呢?

想弄明白這個問題,我們就要來看計數排序算法的實現方法。還拿考生那個例子來解釋,爲了方便說明對數據規模做了簡化。假設只有 8 個考生,分數在 0 到 5 分之間。這 8 個考生的成績我們放在一個數組 A[8] 中,它們分別是:2,5,3,0,2,3,0,3。考生的成績從 0 到 5 分,我們使用大小爲 6 的數組 C[6] 表示桶,其中下標對應分數。不過,C[6] 內存儲的並不是考生,而是對應的考生個數。像我剛剛舉的那個例子,我們只需要遍歷一遍考生分數,就可以得到 C[6] 的值。
C6數組
對於只包含一個整數值的元素序列,且對相等元素的先後順序沒有要求(也即對排序算法的穩定性沒有要求),我們可以依次輸出C[6]數組中相應分數(下標)的考生個數(數組元素值),對於上面的例子,輸出0, 0, 2, 2, 3, 3, 3, 5。

但在工程中,一個元素常包含比較多的維度信息,我們按照某個維度信息值進行排序,就不能採用上面的方法了。特別當要求使用穩定的排序算法時,我們應該如何實現呢?

從圖中可以看出,分數爲 3 分的考生有 3 個,小於 3 分的考生有 4 個,所以,成績爲 3 分的考生在排序之後的有序數組 R[8] 中,會保存下標 4,5,6 的位置。
R8數組
那我們如何快速計算出,每個分數的考生在有序數組中對應的存儲位置呢?這個處理方法非常巧妙,思路是這樣的:我們對 C[6] 數組順序求和,C[6] 存儲的數據就變成了下面這樣子。C[k] 裏存儲小於等於分數 k 的考生個數。
C6順序求和後的數組
有了前面的數據準備之後,現在我就要講計數排序中最複雜、最難理解的一部分了。我們從後到前依次掃描數組 A,比如當掃描到 3 時,我們可以從數組 C 中取出下標爲 3 的值 7,到目前爲止,包括自己在內,分數小於等於 3 的考生有 7 個,也就是說 3 是數組 R 中的第 7 個元素(也就是數組 R 中下標爲 6 的位置)。當 3 放入到數組 R 中後,小於等於 3 的元素就只剩下了 6 個了,所以相應的 C[3] 要減 1,變成 6。以此類推,當我們掃描到第 2 個分數爲 3 的考生的時候,就會把它放入數組 R 中的第 6 個元素的位置(也就是下標爲 5 的位置)。當我們掃描完整個數組 A 後,數組 R 內的數據就是按照分數從小到大有序排列的了。
計數排序圖示
上面的過程略複雜,給出計數排序的實現代碼供對比理解:

// algorithm\sort.c

void count_sort(int *data, int n)
{
    // Find the maximum and minimum values
    int i, min = 0, max = 0;
    for (i = 0; i < n; i++)
    {
        if(data[i] > max)
            max = data[i];
        if(data[i] < min)
            min = data[i];
    }
    // Allocate space for count array
    int *temp = malloc((max - min + 1) * sizeof(int));
    if(temp == NULL)
        return;
    memset(temp, 0, ((max - min + 1) * sizeof(int)));
    // Count the number of times each element appears
    for(i = 0; i < n; i++)
        temp[data[i] - min]++;
    // Sum of count array accumulation
    for (i = 1; i < (max - min + 1); i++)
        temp[i] += temp[i - 1];
    // Allocate space for sorted elements of storage
    int *res = malloc(n * sizeof(int));
    if(res == NULL)
        return;
    // Put the element in the right place
    for(i = n - 1 ; i >= 0; i--)
    {
        int count = temp[data[i] - min];
        if(count > 0)
        {
        	res[count - 1 + min] = data[i];
        	temp[data[i] - min]--;
        }
    }
    // Copy an ordered array to the original
    for (i = 0; i < n; i++)
        data[i] = res[i];
    // Free temporarily allocated space
    free(res);
    free(temp);
}

這種利用另外一個數組來計數的實現方式是不是很巧妙呢?這也是爲什麼這種排序算法叫計數排序的原因。前面對計數排序的實現技巧既能快速找到各個元素應該在的位置,還能保持相等元素的先後順序不變。因此,計數排序是一種穩定的排序算法,但需要佔用額外的計數數組,其並不是一個原地排序算法

總結一下,計數排序只能用在數據範圍不大的場景中,如果數據範圍 k 比要排序的數據 n 大很多,就不適合用計數排序了。而且,計數排序只能給非負整數排序,如果要排序的數據是其他類型的,要將其在不改變相對大小的情況下,轉化爲非負整數。

比如,還是拿考生這個例子。如果考生成績精確到小數後一位,我們就需要將所有的分數都先乘以 10,轉化成整數,然後再放到 9010 個桶內。再比如,如果要排序的數據中有負數,數據的範圍是 [-1000, 1000],那我們就需要先對每個數據都加 1000,轉化成非負整數。

4.3 基數排序算法分析

我們再來看這樣一個排序問題。假設我們有 10 萬個手機號碼,希望將這 10 萬個手機號碼從小到大排序,你有什麼比較快速的排序方法呢?我們之前講的快排,時間複雜度可以做到 O(nlogn),還有更高效的排序算法嗎?桶排序、計數排序能派上用場嗎?手機號碼有 11 位,範圍太大,顯然不適合用這兩種排序算法。針對這個排序問題,有沒有時間複雜度是 O(n) 的算法呢?下面介紹一種新的排序算法,基數排序。

剛剛這個問題裏有這樣的規律:假設要比較兩個手機號碼 a,b 的大小,如果在前面幾位中,a 手機號碼已經比 b 手機號碼大了,那後面的幾位就不用看了。藉助穩定排序算法,這裏有一個巧妙的實現思路,先按照最後一位來排序手機號碼,然後再按照倒數第二位重新排序。以此類推,最後按照第一位重新排序,經過 11 次排序之後,手機號碼就都有序了。

手機號碼稍微有點長,這裏使用幾個數字以動畫形式展示基數排序過程:
基數排序動畫
注意,這裏按照每位來排序的排序算法要是穩定的,否則這個實現思路就是不正確的。因爲如果是非穩定排序算法,那最後一次排序只會考慮最高位的大小順序,完全不管其他位的大小關係,那麼低位的排序就完全沒有意義了。既然基數排序的每一位都使用了穩定的排序算法,基數排序本身也是一個穩定的排序算法。由於需要額外佔用k位個桶的內存空間,基數排序並不是原地排序算法

根據每一位來排序,我們可以用剛講過的桶排序或者計數排序,它們的時間複雜度可以做到 O(n)。如果要排序的數據有 k 位,那我們就需要 k 次桶排序或者計數排序,總的時間複雜度是 O(k*n)。當 k 不大的時候,比如手機號碼排序的例子,k 最大就是 11,所以基數排序的時間複雜度就近似於 O(n)。

有時候要排序的數據並不都是等長的,比如我們排序牛津字典中的 20 萬個英文單詞,最短的只有 1 個字母,最長的有 45 個字母,對於這種不等長的數據,基數排序還適用嗎?

我們可以把所有的單詞補齊到相同長度,位數不夠的可以在後面補“0”,因爲根據ASCII 值,所有字母都大於“0”,所以補“0”不會影響到原有的大小順序,這樣就可以繼續用基數排序了。

總結一下,基數排序對要排序的數據是有要求的,需要可以分割出獨立的“位”來比較,而且位之間有遞進的關係,如果 a 數據的高位比 b 數據大,那剩下的低位就不用比較了。除此之外,每一位的數據範圍不能太大,要可以用線性排序算法來排序,否則,基數排序的時間複雜度就無法做到 O(n) 了。

五、如何實現一個通用高效的排序算法

如果要實現一個通用的、高效率的排序函數,我們應該選擇哪種排序算法?我們先回顧一下前面講過的幾種排序算法:
排序算法對比
線性排序算法的時間複雜度比較低,適用場景比較特殊。所以,如果要寫一個通用的排序函數,不能選擇線性排序算法。

如果對小規模數據進行排序,可以選擇時間複雜度是 O(n2) 的算法;如果對大規模數據進行排序,時間複雜度是 O(nlogn) 的算法更加高效。所以,爲了兼顧任意規模數據的排序,一般都會首選時間複雜度是 O(nlogn) 的排序算法來實現排序函數。

時間複雜度是 O(nlogn) 的排序算法不止一個,我們已經介紹過的有歸併排序、快速排序,後面介紹堆的時候我們還會講到堆排序。堆排序和快速排序都有比較多的應用,比如 Java 語言採用堆排序實現排序函數,C 語言使用快速排序實現排序函數。因爲歸併排序並不是原地排序算法,空間複雜度是 O(n),故使用歸併排序的情況並不多。

快速排序和歸併排序是用遞歸實現的,遞歸需要警惕棧溢出。爲了避免遞歸過深而棧過小,導致棧溢出,我們可以通過在堆上模擬實現一個函數調用棧,手動模擬遞歸壓棧、出棧的過程,這樣就沒有系統棧大小的限制了。

爲了讓你對如何實現一個排序函數有一個更直觀的感受,我拿 Glibc 中的 qsort() 函數舉例說明一下。雖說 qsort() 從名字上看,很像是基於快速排序算法實現的,實際上它並不僅僅用了快排這一種算法。

如果你去看源碼,你就會發現,qsort() 會優先使用歸併排序來排序輸入數據,因爲歸併排序的空間複雜度是 O(n),所以對於小數據量的排序,比如 1KB、2KB 等,歸併排序額外需要 1KB、2KB 的內存空間,這個問題不大,現在計算機的內存都挺大的,我們很多時候追求的是速度。歸併排序是一個穩定的排序算法,通過空間換取排序的穩定性在某些情況下是值得的。

但如果數據量太大,比如排序 100MB 的數據,這個時候我們再用歸併排序就不合適了。所以,要排序的數據量比較大的時候,qsort() 會改爲用快速排序算法來排序。那 qsort() 是如何選擇快速排序算法的分區點的呢?如果去看源碼,你就會發現,qsort() 選擇分區點的方法就是“三數取中法”

還有我們前面提到的遞歸太深會導致堆棧溢出的問題,qsort() 是通過自己實現一個堆上的棧,手動模擬遞歸來解決的

實際上,qsort() 並不僅僅用到了歸併排序和快速排序,它還用到了插入排序。在快速排序的過程中,當要排序的區間中,元素的個數小於等於 4 時,qsort() 就退化爲插入排序,不再繼續用遞歸來做快速排序,因爲在小規模數據面前,O(n2) 時間複雜度的算法並不一定比 O(nlogn) 的算法執行時間長,此時選擇比較簡單、不需要遞歸的插入排序算法更有優勢。

好了,C 語言的 qsort() 已經分析完了,基本上用到了前面介紹過的大部分排序算法。由此可見,在工程中要實現一個通用且高效的排序函數,常常需要將多種排序算法的優點綜合起來,儘可能將性能優化到極致。

本章算法實現源碼下載地址:https://github.com/StreamAI/ADT-and-Algorithm-in-C/tree/master/algorithm

更多文章:

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