數據結構與算法之美 - 11 | 排序(上):爲什麼插入排序比冒泡排序更受歡迎?

這系列相關博客,參考 數據結構與算法之美

排序對於任何一個程序員來說,可能都不會陌生。你學的第一個算法,可能就是排序。大部分編程語言中,也都提供了排序函數。在平常的項目中,我們也經常會用到排序。排序非常重要,所以我會花多一點時間來詳細講一講經典的排序算法。

排序算法太多了,有很多可能你連名字都沒聽說過,比如猴子排序、睡眠排序、麪條排序等。我只講衆多排序算法中的一小撮,也是最經典的、最常用的:冒泡排序、插入排序、選擇排序、歸併排序、快速排序、計數排序、基數排序、桶排序。我按照時間複雜度把它們分成了三類,分三節課來講解。
在這裏插入圖片描述
帶着問題去學習,是最有效的學習方法。所以按照慣例,我還是先給你出一個思考題:插入排序和冒泡排序的時間複雜度相同,都是O(n2n^{2}),在實際的軟件開發裏,爲什麼我們更傾向於使用插入排序算法而不是冒泡排序算法 呢?

你可以先思考一兩分鐘,帶着這個問題,我們開始今天的內容!

如何分析一個"排序算法"?

學習排序算法,我們除了學習它的算法原理、代碼實現之外,更重要的是要學會如何評價、分析一個排序算法。

那分析一個排序算法,要從哪幾個方面入手呢?

排序算法的執行效率

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

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

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

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

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

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

3. 比較次數和交換(或移動)次數

這一節和下一節講的都是基於比較的排序算法。基於比較的排序算法的執行過程,會涉及兩種操作,一種是元素比較大小,另一種是元素交換或移動。所以,如果我們在分析排序算法的執行效率的時候,應該把比較次數和交換(或移動)次數也考慮進去。

排序算法的內存消耗

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

排序算法的穩定性

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

我通過一個例子來解釋一下。比如我們有一組數據2,9,3,4,8,3,按照大小排序之後就是2,3,3,4,8,9。

這組數據裏有兩個3。經過某種排序算法排序之後,如果兩個3的前後順序沒有改變,那我們就把這種排序算法叫作穩定的排序算法;如果前後順序發生變化,那對應的排序算法就叫作不穩定的排序算法。

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

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

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

最先想到的方法是:我們先按照金額對訂單數據進行排序,然後,再遍歷排序之後的訂單數據,對於每個金額相 同的小區間再按照下單時間排序。這種排序思路理解起來不難,但是實現起來會很複雜。

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

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

冒泡排序(Bubble Sort)

我們從冒泡排序開始,學習今天的三種排序算法。

冒泡排序只會操作相鄰的兩個數據。每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求。 如果不滿足就讓它倆互換。一次冒泡會讓至少一個元素移動到它應該在的位置,重複n次,就完成了n個數據的排 序工作。

我用一個例子,帶你看下冒泡排序的整個過程。我們要對一組數據4,5,6,3,2,1,從小到到大進行排序。 第一次冒泡操作的詳細過程就是這樣:
在這裏插入圖片描述
可以看出,經過一次冒泡操作之後,6這個元素已經存儲在正確的位置上。要想完成所有數據的排序,我們只要進行6次這樣的冒泡操作就行了。
在這裏插入圖片描述
實際上,剛講的冒泡過程還可以優化。當某次冒泡操作已經沒有數據交換時,說明已經達到完全有序,不用再繼 續執行後續的冒泡操作。我這裏還有另外一個例子,這裏面給6個元素排序,只需要4次冒泡操作就可以了。
在這裏插入圖片描述
冒泡排序算法的原理比較容易理解,具體的代碼我貼到下面,你可以結合着代碼來看我前面講的原理。

// 冒泡排序,a表不數組,n表不數組大小public void bubbleSort(int[] a, int n) ( if (n <= 1) return; for (int i = 0

現在,結合剛纔我分析排序算法的三個方面,我有三個問題要問你。

第一,冒泡排序是原地排序算法嗎?

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

第二,冒泡排序是穩定的排序算法嗎?

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

第三,冒泡排序的時間複雜度是多少?

最好情況下,要排序的數據已經是有序的了,我們只需要進行一次冒泡操作,就可以結束了,所以最好情況時間複雜度是O(n)。而最壞的情況是,要排序的數據剛好是倒序排列的,我們需要進行n次冒泡操作,所以最壞情況時間複雜度爲O(n2n^{2})。
在這裏插入圖片描述
最好、最壞情況下的時間複雜度很容易分析,那平均情況下的時間複雜是多少呢?我們前面講過,平均時間複雜度就是加權平均期望時間複雜度,分析的時候要結合概率論的知識。

對於包含n個數據的數組,這n個數據就有 n! 種排列方式。不同的排列方式,冒泡排序執行的時間肯定是不同的。 比如我們前面舉的那兩個例子,其中一個要進行6次冒泡,而另一個只需要4次。如果用概率論方法定量分析平均時間複雜度,涉及的數學推理和計算就會很複雜。我這裏還有一種思路,通過”有序度”和"逆序度”這兩個概念來進行分析。

有序度是數組中具有有序關係的元素對的個數。有序元素對用數學表達式表示就是這樣:

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

在這裏插入圖片描述
同理,對於一個倒序排列的數組,比如6,5,4,3,2,1,有序度是0;對於一個完全有序的數組,比如1,2,3,4,5,6,有序度就是 n*(n-1) / 2,也就是15。我們把這種完全有序的數組的有序度叫作滿有序度。

逆序度的定義正好跟有序度相反(默認從小到大爲有序),我想你應該已經想到了。關於逆序度,我就不舉例子講了。你可以對照我講的有序度的例子自己看下。

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

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

我還是拿前面舉的那個冒泡排序的例子來說明。要排序的數組的初始狀態是4,5,6,3,2,1,其中,有序元素對有(4,5)(4,6)(5,6),所以有序度是3。n=6,所以排序完成之後終態的滿有序度爲 n*(n-1)/2 = 15。
在這裏插入圖片描述
冒泡排序包含兩個操作原子,比較和交換。每交換一次,有序度就加1。不管算法怎麼改進,交換次數總是確定的,即爲逆序度,也就是 n*(n-1)/2 - 初始有序度。此例中就是15 - 3 = 12,要進行12次交換操作。

對於包含n個數據的數組進行冒泡排序,平均交換次數是多少呢?最壞情況下,初始狀態的有序度是0,所以要進行n*(n-1)/2次交換。最好情況下,初始狀態的有序度是n*(n-1)/2,就不需要進行交換。我們可以取箇中間值n* (n-1)/4,來表示初始有序度既不是很高也不是很低的平均情況。

換句話說,平均情況下,需要n*(n-1)/4次交換操作,比較操作肯定要比交換操作多,而複雜度的上限是O(n2n^{2}),所以平均情況下的時間複雜度就是O(n2n^{2})。

這個平均時間複雜度推導過程其實並不嚴格,但是很多時候很實用,畢竟概率論的定量分析太複雜,不太好用。 等我們講到快排的時候,我還會再次用這種“不嚴格“的方法來分析平均時間複雜度。

插入排序(Insertion Sort)

我們先來看一個問題。一個有序的數組,我們往裏面添加一個新的數據後,如何繼續保持數據有序呢?很簡單,我們只要遍歷數組,找到數據應該插入的位置將其插入即可。
在這裏插入圖片描述
這是一個動態排序的過程,即動態地往有序集合中添加數據,我們可以通過這種方法保持集合中的數據一直有序。而對於一組靜態數據,我們也可以借鑑上面講的插入方法,來進行排序,於是就有了插入排序算法。

那插入排序具體是如何藉助上面的思想來實現排序的呢?

首先,我們將數組中的數據分爲兩個區間,已排序區間和未排序區間。初始已排序區間只有一個元素,就是數組的第一個元素。插入算法的核心思想是取未排序區間中的元素,在已排序區間中找到合適的插入位置將其插入,並保證已排序區間數據一直有序。重複這個過程,直到未排序區間中元素爲空,算法結束。

如圖所示,要排序的數據是4,5,6,1,3,2,其中左側爲已排序區間,右側是未排序區間。
在這裏插入圖片描述
插入排序也包含兩種操作,一種是元素的比較,—種是元素的移動。當我們需要將一個數據a插入到已排序區間時,需要拿a與已排序區間的元素依次比較大小,找到合適的插入位置。找到插入點之後,我們還需要將插入點之後的元素順序往後移動一位,這樣才能騰出位置給元素a插入。

對於不同的查找插入點方法(從頭到尾、從尾到頭),元素的比較次數是有區別的。但對於一個給定的初始序列,移動操作的次數總是固定的,就等於逆序度。

爲什麼說移動次數就等於逆序度呢?我拿剛纔的例子畫了一個圖表,你一看就明白了。滿有序度是n*(n-1)/2=15,初始序列的有序度是5,所以逆序度是10。插入排序中,數據移動的個數總和也等於10=3+3+4。
在這裏插入圖片描述
插入排序的原理也很簡單吧?我也將代碼實現貼在這裏,你可以結合着代碼再看下。

//插入排序,a表不數組,n表不數組大小 public void insertionSort(int[] a, int n) { if (n <= 1) return; for (int i = 1 ;i<n;++i) {

現在,我們來看點稍微複雜的東西。我這裏還是有三個問題要問你。

第一,插入排序是原地排序算法嗎?

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

第二,插入排序是穩定的排序算法嗎?

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

第三,插入排序的時間複雜度是多少?

如果要排序的數據已經是有序的,我們並不需要搬移任何數據。如果我們從尾到頭在有序數據組裏面查找插入位置,每次只需要比較一個數據就能確定插入的位置。所以這種情況下,最好是時間複雜度爲O(n)。注意,這裏是從尾到頭遍歷已經有序的數據。

如果數組是倒序的,每次插入都相當於在數組的第一個位置插入新的數據,所以需要移動大量的數據,所以最壞情況時間複雜度爲O(n2n^{2})。

還記得我們在數組中插入一個數據的平均時間複雜度是多少嗎?沒錯,是O(n)。所以,對於插入排序來說,每次插入操作都相當於在數組中插入一個數據,循環執行n次插入操作,所以平均時間複雜度爲O(n2n^{2})

選擇排序(Selection Sort)

選擇排序算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。
在這裏插入圖片描述
照例,也有三個問題需要你思考,不過前面兩種排序算法我已經分析得很詳細了,這裏就直接公佈答案了。

首先,選擇排序空間複雜度爲O(1),是一種原地排序算法。選擇排序的最好情況時間複雜度、最壞情況和平均情況時間複雜度都爲O(n2n^{2})。你可以自己分析看看。

那選擇排序是穩定的排序算法嗎?這個問題我着重來說一下。

答案是否定的,選擇排序是一種不穩定的排序算法。從我前面畫的那張圖中,你可以看出來,選擇排序每次都要找剩餘末排序元素中的最小值,並和前面的元素交換位置,這樣破壞了穩定性。

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

解答開篇

基本的知識都講完了,我們來看開篇的問題:冒泡排序和插入排序的時間複雜度都是都是O(n2n^{2}),原地排序算法,爲什麼插入排序比冒泡排序更受歡迎呢?

我們前面分析冒泡排序和插入排序的時候講到,冒泡排序不管怎麼優化,元素交換的次數是一個固定值,是原始數據的逆序度,插入排序是同樣的,不管怎麼優化,元素移動的次數也等於原始數據的逆序度。

但是,從代碼實現上來看,冒泡排序的數據交換要比插入排序的數據移動要複雜,冒泡排序需要3個賦值操作,而插入排序只需要一個。我們來看這段操作:

在這裏插入代碼片

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

這個只是我們非常理論的分析,爲了實驗,針對上面的冒泡排序和插入排序的Java代碼,我寫了一個性能對比測試程序,隨機生成10000個數組,每個數組中包含200個數據,然後在我的機器上分別用冒泡和插入排序算法來排序,冒泡排序算法大約700ms才能執行完成,而插入序只需要100ms左右就能搞定!

所以,雖然冒泡排序和插入排序在時間複雜度上是一樣的,都是O(n2n^{2}),但是如果我們希望把性能優化做到極致,那肯定首選插入排序。插入排序的算法思路也有很大的優化空間,我們只是講了最基礎的一種。如果你對插入排序的優化感興趣,可以自行學習一下希爾排序。

內容小結

要想分析、評價一個排序算法,需要從執行效率、內存消耗和穩定性三個方面來看。因此,這一節,我帶你分析了三種時間複雜度是O(n2n^{2})的排序算法,冒泡排序、插入排序、選擇排序。你需要重點掌握的是它們的分析方法。
在這裏插入圖片描述
這三種時間複雜度爲O(n2n^{2})的排序算法中,冒泡排序、選擇排序,可能就純粹停留在理論的層面了,學習的目的也只是爲了開拓思維,實際開發中應用並不多,但是插入排序還是挺有用的。後面講排序優化的時候,我會講到,有些編程語言中的排序函數的實現原理會用到插入排序算法。

今天講的這三種排序算法,實現代碼都非常簡單,對於小規模數據的排序,用起來非常高效。但是在大規據排序的時候,這個時間複雜度職稍微有點高,所以我們更傾向於用下一節要講的時間複雜度爲O(nlogn)的排序算法。

課後思考

我們講過,特定算法是依賴特定的數據結構的。我們今天講的幾種排序算法,都是基於數組實現的。如果數據存儲在鏈表中,這三種排序算法還能工作嗎?如果能,那相應的時間、空間複雜度度又是多少呢?

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