《資料結構與演算法》之十大基礎排序演算法

~java小白~發表於2023-05-20

一.氣泡排序

什麼是氣泡排序?

氣泡排序是一種交換排序,它的思路就是在待排序的資料中,兩兩比較相鄰元素的大小,看是否滿足大小順序的要求,如果滿足則不動,如果不滿足則讓它們互換。

然後繼續與下一個相鄰元素的比較,一直到一次遍歷完成。一次遍歷的過程就被成為一次冒泡,一次冒泡的結束至少會讓一個元素移動到了正確的位置。

所以要想讓所有元素都排序好,一次冒泡還不行,我們得重複N次去冒泡,這樣最終就完成了N個資料的排序過程。

氣泡排序又有兩種情況:向上冒泡和向下冒泡

向上冒泡指的是:在排序的時候把大的資料排在前面,小資料在後面,這樣排序出來的小序列會是從大到小的順序

向下冒泡指的是:在排序的時候把大的資料排在後面,小資料在前面,這樣排序出來的順序會是從小到大的

具體的冒泡方向要根據自己的要求去設計

氣泡排序實際上是相鄰兩個資料的按條件交換排序,然後遍歷式的交換排序

它排序需要兩次迴圈來設計:

第一迴圈控制排序幾次

第二迴圈負責每次交換遍歷資料的相鄰資料交換

對於排序次數,可以根據資料的個數n,確定次數為:n-1次

下面我們來圖示一下我們的排序方法:

 下面我們來看看氣泡排序的虛擬碼:

 氣泡排序可以說是比較簡單的排序方法了:它的時間複雜的是O(n^2)

氣泡排序的交換關鍵在於那個if判斷語句:

這裡的【if語句】可以用大於等於也可以使用大於符號,

但是我使用的是大於號,因為排序的穩定性,如果使用大於等於號,兩個相等的資料,它也會交換,這樣的排序計算就不穩定,也提高了系統開銷,

因為當兩個相同的資料本身是有序的,如果讓它們交換,在記憶體中它們也會發生相應的移動,提升了開銷,所以這裡建議使用大於號就好了

二.選擇排序

什麼是選擇排序?

選擇排序的原理就是在一個待排序的陣列中,首先從前至後(或從後至前)對陣列遍歷後選取最大 (或最小)的數,與陣列首(尾)的數進行交換。

選擇排序的排序次數也是資料個數n,排序次數就是:n - 1

他會根據次數的減少,待排序的資料也會減少

它與氣泡排序不同的是它不會頻繁的發生交換,交換次數比氣泡排序要少,每次就直接遍歷資料,然後把最大/小的資料放在序列後/前面

這裡圖解一下選擇排序:

 選擇排序只會在一次排序中交換一次,所以交換的次數就是,n - 1次

時間複雜度是O(n^2)

我們使用虛擬碼來實現一下選擇排序:

 選擇排序一次遍歷只會把當前的序列的最大數找出來,然後放在最前或者最後面

遍歷的次數也會隨著已排序的次數減少,因為沒排序最大/小數就已經被找出來了,不需反覆去校對

三.插入排序

什麼是插入排序?

插入排序,也被稱為直接插入排序。對於少量元素的排序,它是一個有效的演算法 。插入排序是一種最簡單的排序方法,它的基本思想是將一個記錄插入到已經排好序的有序表中,從而一個新的、記錄數增1的有序表。

在其實現過程使用雙層迴圈,外層迴圈對除了第一個元素之外的所有元素,內層迴圈對當前元素前面有序表進行待插入位置查詢,並進行移動。

插入排序是穩定的。

 插入排序是最簡單容易理解的一種排序演算法,它依靠的是直接覆蓋,只要找到它因該在的位置,就讓其它的資料往後移一個一個的覆蓋,直到留出它應該插入的位置

這個覆蓋其實是動態的,只要沒找到位置,就會先往後覆蓋一個,然後向前繼續找位置,繼續覆蓋

這個資料在開始就被其它變數儲存了,所以不會存在丟失資料的情況

我們一起去看看插入排序的虛擬碼:

 演算法是由for迴圈和while共同構成的,著重看看while迴圈

j > = 0  如果這個條件不滿足,說明前面的資料都比它大,這個資料肯定是最小資料,就直接插入在 第一個位置

arr[ j ]  > temp  說明當前資料比前面一個資料小,我們就要排它前面

插入排序的時間複雜度:O(N ^2)

四.希爾排序

什麼是希爾排序?

希爾排序(Shell's Sort)是插入排序的一種又稱“縮小增量排序”(Diminishing Increment Sort),是直接插入排序演算法的一種更高效的改進版本。希爾排序是非穩定排序演算法。該方法因D.L.Shell於1959年提出而得名。

從上面我們很容易看出來,它是插入排序的高階版

希爾排序依賴於增量序列,一個好的增量序列可以是希爾排序的複雜度達到 N 的 4/3 次方,

最快的Sedgewick增量序列:時間複雜度只有 N的7/6次方

希爾排序在每次選定增量序列後,排序都是選擇排序,但是它的演算法優於選擇排序,因為增量序列是成比例縮小的,並不是一定會排序 n-1次

希爾排序的排序次數比選擇排序要少很多,

增量序列的選定:資料個數 N 

一般使用 N/2 , N/4 , N/8  等等構成的資料序列

我們使用的是 n/2 構成的序列組合

 在增量迴圈中時,我們使用的也是插入排序,

所以第二次增量排序為什麼能直接把 5 , 6 , 9給一次排序出來就是根據插入排序的移動覆蓋

我們來看看希爾排序的虛擬碼:

 希爾排序比插入排序的優點在於,它的增量序列比 插入排序的 n-1次 排序次數少

也導致了希爾排序比插入排序快

總的來說,希爾排序的優劣取決於增量序列

 五.歸併排序

什麼是歸併排序?

歸併排序(Merge sort)是建立在歸併操作上的一種有效的排序演算法。該演算法是採用分治法(Divide and Conquer)的一個非常典型的應用。

作為一種典型的分而治之思想的演算法應用,歸併排序的實現由兩種方法

  • 自上而下的遞迴
  • 自下而上的迭代

歸併排序的時間複雜度始終為O(NLogN),在排序演算法算是快的了,但是他對空間需求大,需要額外開闢一個相同大小的空間

歸併排序是分治法演化來的,他與分治法一樣,

先把資料分成最小單位元素,然後再按要求合併,最後在一次次合併中慢慢變成最後排序的答案

我們來圖解一下歸併排序:

我們在拆分的時候必須給陣列找一個分界點,在取分界點的時候,一般使用 陣列的  最大下標 / 2  ,大家可以想想為什麼不是陣列長度直接 /2(也可以用,但不建議) ?

找到分界點的時候劃分成兩半,然後依次進行,直到劃分到最小分量的時候就合併。

在合併的時候,我們要注意,有兩個半區,左半區和右半區都要有一個變數來指向左半區和右半區資料的當前的位置,然後左右半區的資料依次比較,資料值小的就先進入臨時陣列

當比較完了,可能有一個半區還有資料,因為那個(或那幾個資料都是)最大的,沒有資料能把它們比進臨時陣列,所以要注意把這些資料直接copy到臨時陣列中

現在我們用虛擬碼來描述一下這個演算法:

merge_sort  函式負責的是申請一個臨時的空間,輔助我們在歸併的時候轉存這個資料,因為這個函式是歸併排序的入口,當歸並排序完成以後他還會回到這個函式,

所以為了方便,我們直接把申請的空間,釋放程式碼部分也放在這裡,我們在使用C語言寫了這些記憶體的程式碼一定要釋放掉,不然會一直佔用系統資源,而且會發生記憶體洩漏。

msort  函式負責把傳過來的一個函式給他拆分成成區域性最小分量

merge  函式負責歸併,連=兩個小半區的比較,小的就先存入臨時陣列,當小分割槽比較完成以後,我們需要對有一個半區殘留的元素直接copy到臨時陣列中,殘留的元素一定是當前分割槽最大的且是有序的,

最後一定要記得把臨時陣列中的資料覆蓋到原陣列,畢竟最後的返回值還是原陣列;

 歸併排序:

每一層歸併的時間是O(N)

歸併層數為O(LogN+1)

所以:時間複雜度是 O(NLogN)

六. 快速排序

什麼是快速排序?

快速排序使用分治法(Divide and conquer)策略來把一個序列(list)分為兩個子序列(sub-lists)。

快速排序又是一種分而治之思想在排序演算法上的典型應用。本質上來看,快速排序應該算是在氣泡排序基礎上的遞迴分治法。

快速排序的名字起的是簡單粗暴,因為一聽到這個名字你就知道它存在的意義,就是快,而且效率高!它是處理大資料最快的排序演算法之一了。

  1. 從數列中挑出一個元素,稱為 "基準"(pivot);

  2. 重新排序數列,所有元素比基準值小的擺放在基準前面,所有元素比基準值大的擺在基準的後面(相同的數可以到任一邊)。在這個分割槽退出之後,該基準就處於數列的中間位置。這個稱為分割槽(partition)操作;

  3. 遞迴地(recursive)把小於基準值元素的子數列和大於基準值元素的子數列排序

在快速排序中,基準的選擇一般是資料第一個元素,中間元素,最後一個元素,

在快速排序的我們需要抽象兩個指標(或者陣列下標),

一個是左指標,它是從左往右去找比基準大的資料

一個是右指標,它會從右往左去找比基準小的資料,

當左右指標都找到以後進行交換,然後一直重複,直到兩指標相遇,再相遇點我們要比較,如果此資料比基準大,則交換,反之,左指標就往後移動一個位置

目的是:當前左指標所在的位置左邊是都比基準小的資料,右邊都是比基準大的資料

然後這個資料就是有序的了,我們需要對於有序的資料繼續去劃分左右半區,然後把左半區和右半區的最後一個資料作為兩個半區的基準,分別計算,

這裡就是分治的思想,只要一直分治下去,每一次分治都有一個資料被有序,而且隨著分治的層數變多,所需要計算的次數越來越小

所以快速排序的時間複雜度是:O(nlogn) 最壞的情況就是 O(n^2)

快速排序的最壞執行情況是 O(n²),比如說順序數列的快排。但它的平攤期望時間是 O(nlogn),且 O(nlogn) 記號中隱含的常數因子很小,比複雜度穩定等於 O(nlogn) 的歸併排序要小很多。

所以,對絕大多數順序性較弱的隨機數列而言,快速排序總是優於歸併排序。

下面我們來圖解一下快速排序:

快速排序在選定了基準以後,就會使用左右指標去查詢和基準對比的資料,當找到合乎要求 的資料以後就會交換,直到指標重合

當重合的時候要考慮一下是否需要和基準交換,只有比基準大的資料才能和基準交換否則就讓左指標向後移動一次

每次被選為基準的資料在使用完了以後他自己其實就是有序的瞭然後在左右兩邊有資料的情況下是會繼續劃分的,這是分治法的思想做出來的演算法

我們一定要注意:一定是左指標先移動,不然是拍不出來結果的

我們接下來使用虛擬碼實現一下(遞迴的方式):

我們可以看到程式碼其實和前面的歸併排序都有異曲同工之妙,畢竟都是基於分治法的思想,

歸併排序需要先拆分成最小元素後,然後在比較的時候,邊比較,便合併

快速排序是在比較完了一次就拆分,再拆分前就已經有資料是有序的了,當拆分完成後,序列就是各個有序的了,所以沒有合併的過程

在絕大多數情況下,快速排序都要優於歸併排序

七.堆排序 

什麼是堆排序?

堆排序(Heapsort)是指利用堆這種資料結構所設計的一種排序演算法。堆積是一個近似完全二叉樹的結構,並同時滿足堆積的性質:即子結點的鍵值或索引總是小於(或者大於)它的父節點。堆排序可以說是一種利用堆的概念來排序的選擇排序。

兩種堆的結構:

  1. 大頂堆:每個節點的值都大於或等於其子節點的值,在堆排序演算法中用於升序排列;
  2. 小頂堆:每個節點的值都小於或等於其子節點的值,在堆排序演算法中用於降序排列;

堆排序的核心思想就是在大頂堆和小頂堆上建立起來的,因為大/小頂堆的性質:根節點比子節點大/小,所以在這兩種頂堆中,最上面一層只有一個根節點的哪裡,他一定是此序列中最大/小的

基於這個性質,我們只需要把一組資料先構造成為大/小頂堆,每次把最上面的元素和最後一個元素交換,再然後斷開連線

這個資料就是有序的了即最大/小數,然後被換到根節點的數就開始維護,此時排序中最大的數又到了根節點,也就是整個序列的第二大數,然後重複操作

最後肯定是把序列從大到小或從小到大給排序出來的

堆排序的平均時間複雜度為 Ο(nlogn),它的排序過程很複雜,包括建堆,對維護,然後排序,它們相互依賴最後才做出最後的堆排序

首先我們來看看建堆的過程:

 在建堆的時候,一般是三個節點參與構建,父節點,左子節點,右子節點三個節點會有一個小型的維護過程,把最大的元素放到父節點,此時資料還剛開始

當下一次又會來新的資料,依舊構成三個節點構成,它們也會有一個維護階段,把最大的元素放到父節點,

堆多了以後,他們的串聯關係就會很明顯了,然後可能建堆時只有三個,當維護時就會維護到下一層去了,到這裡就表示它已經快要構成一個完全的資料堆了

當堆構建完成以後,我們就會開始進行排序:

 堆排序就是在大頂堆或小頂堆的基礎上,不斷的把最後一個元素和堆頂元素置換,每置換一次,就刪除一次連線,然後從堆頂開始維護,直到又構成一個大頂堆

然後二次交換,重複操作,最後完成排序

我們來看看堆排序的虛擬碼:

 程式碼中可能有的疑問:

堆排序中我們構造的堆其實也是抽象的,並不是在記憶體中真的開闢了一個圖形像堆一樣,它是利用陣列下標構造的一個聯絡,比如  下標為 0 的 ,它的子節點是 :下標為 1 和下標為2

我們圖中所說的刪除連線就是少一個數去維護,也就是迴圈的次數越來越少 : i --   就是在刪除連線

堆排序是不穩定的,大家可以自己去推一下

八.計數排序

它只適合小範圍的排序,空間開銷大

什麼是計數排序?

計數排序的核心在於將輸入的資料值轉化為鍵儲存在額外開闢的陣列空間中。作為一種線性時間複雜度的排序,計數排序要求輸入的資料必須是有確定範圍的整數。

計數排序思路很簡單,但是它需要的時間開銷很大

首先它需要一個陣列去計數,還需要一個資料去計算累計值,還需要一個陣列用於臨時儲存 原陣列和累計陣列的計算結果

原陣列arr[ ];

計數陣列 count [ ]:資料每出現一次 ,對應資料為下標的計數陣列加一 ,計數陣列是臨時陣列,需要申請,它的大小取決於原陣列資料的最大值的加一 

累計陣列 count 1 [ ]:計算原資料所在的位置,  它需要和計數陣列結合,count 1  [ i] = count 1 [ i - 1] + count [ i ] 

排序陣列output [ ] :  排序陣列是最後的結果,需要複製回原陣列,它是透過 原陣列和累計陣列計算的 output[ count 1 [ arr [ i ] ] -1 ]  = arr [ i ] 

下面我們使用圖解一下計數排序:

 然後是虛擬碼實現:

 計數排序都是線性的,但是空間開銷大,演算法和名字一樣,需要計算,其他的排序方法都是靠元素交換來排序的,計數排序是很特別的一種演算法

但是它只適合小範圍的計算,因為計算陣列的申請需要根據元素的最大值,而不是元素個數,也就是我只有一個元素 ,它的值是100,那麼我也得申請  100的空間大小

所以計數排序在執行時非常佔空間,一下要申請兩到三個,空間開銷太大

計數排序的時間複雜度是:O(n) ,空間開銷太大

九.桶排序

 什麼是桶排序?

桶排序是另外一種以O(n)或者接近O(n)的複雜度排序的演算法. 它假設輸入的待排序元素是等可能的落在等間隔的值區間內.

一個長度為N的陣列使用桶排序, 需要長度為N的輔助陣列. 等間隔的區間稱為桶, 每個桶內落在該區間的元素. 桶排序是基數排序的一種歸納結果。

桶排序與其它排序不同,它依靠的是下標代表元素,陣列裡的值只是確定整個序列有沒有此元素,我們最開始會初始化我們的桶陣列,都初始化為零

然後依次遍歷原資料,把元素作為桶陣列的下標,對應的陣列值加一,代表此下標有值,這是一次對原陣列的資料

當資料裝完了以後我們再去遍歷桶陣列,當陣列的值不為零的時候就列印下標,

所以桶排序是一種基於下標來作為元素的排序

我們來圖解一下桶排序:

 桶排序也是一個線性的排序,時間複雜度一般為O(n),但是空間開銷也很大,它申請請輔助陣列時,並不是根據原陣列的長度而判定的,

而是根據原陣列的資料中的最大值來判定的的,也就是我只有兩個元素分別是,100和1000的時候,在申請輔助陣列的時候,我們要根據陣列的中元素的最大值

也就是 1000 +1 來申請輔助陣列,原陣列大小才 2 ,申請的陣列長度為 1001,這個空間開銷浪費的資源太多了,

所以桶排序只適合小範圍的計數,他和計數排序的適用區間差不多,比如:記錄班級各個同學的成績,然後排序,這種計算是很合適的,它不僅能排序還能計數,這種小範圍的計數是可以使用桶排序的

此外,後來的人對桶排序又進行了最佳化,把桶不僅限於裝一個元素了,而是裝一個區間,這些使用都是基於桶排序的思想來的,目的是為了節約一下桶排序的空間開銷

下面就是使用區間來描述桶的容量:

 在區間內,如果一個桶中有多個元素,那麼就需要對單個桶進行插入排序,由於一般桶內的資料少,所以插入排序比較快

這種一個桶就代表一個區間的演算法就不能光使用輔助陣列了,還需要使用連結串列,把位於同一桶的元素串聯起來

最壞的情況就是全部位於一個區間(桶)內,我們的堆排序就不在是線性的了

它的複雜度就是:O(N ^2)

接下來我們看看桶排序的一般程式碼描述:

 這是一個桶只裝一個元素的程式碼描述,很顯然可以看出來它是一個線性的演算法

空間開銷就很大了,在使用桶排序的時候一定注意它的使用區間,它只是用於小範圍且連續的序列,而且序列中的最大元素儘量不斷地靠近  0 ,開闢的空間越小越好

桶排序的時間複雜度: O(n)

十.基數排序

什麼是基數排序?

基數排序是一種非比較型整數排序演算法,其原理是將整數按位數切割成不同的數字,然後按每個位數分別比較。

由於整數也可以表達字串(比如名字或日期)和特定格式的浮點數,所以基數排序也不是隻能使用於整數。

基數排序也延續了桶排序的思想,與桶排序不同的是,基數排序是根據資料被拆分的結果來劃分桶的位置

比如  :  我們對兩位數要根據基數排序  33 和 69 

先讓個位進入桶 :33 進入 3號桶,69進入9號桶

個位進桶了以後表示各位已經是在整個序列是有序的了,

然後十位進入桶:33 進入 3號桶,69進入6號桶

此時十位在整個序列是有序的了,只需要將桶內的元素按順序拿出,然後往後一個個拿桶,最後就的到了最後的資料

基數排序比區間桶排序的優點在於,基數排序不管元素多少,它只需要10個桶,即0~9號桶,空間開銷比桶排序小

但是思想上還是桶排序的思想

 我們來圖解一下基數排序:

 我們看圖,其實基數排序也是建立在桶排序的基礎上的,為了節省空間開銷我們還用了計數排序中的累計陣列思想,

對比桶排序,很明顯的就是在資料量變多的時候,桶排序所需的空間就會越來越大,反而基數排序就只需要十個桶,但它和基數排序一樣要申請一個輔助陣列,所以開銷依舊很大

基數排序結合了桶排序和計數排序,所以在思想上三個都有很多相同之處

我們來看看基數排序的程式碼實現:

 在虛擬碼中我們可以看到max和base這兩個變數:

max是陣列中的最大數,它是幾位數就決定了要進行幾次基數排序

base:就是用來分撥每一位數的變數,它每 乘十就意味著向上一位整數

基數排序的時間複雜度是:O(n) 也是線性的,也是穩定的排序

十一.總結

十大排序的時間空間複雜度以及穩定性

 前面的七種排序都是非常經典的排序方法,它們時間複雜度比後面三種大,但是空間開銷小

後面三種排序大多是線性的,理解起來也比較容易,時間複雜度最好的情況都是O(n),但是空間開銷真的很大

所以後面三種演算法重點還是在理解思想,怎麼計算的就行,

當然我推薦必須掌握的就是:快速排序,歸併排序,堆排序,這也是重點的演算法。