【算法】排序 (二):冒泡排序&快速排序&歸併排序&基數排序&拓撲排序(C++實現)

一. 交換排序

1. 冒泡排序

  • 冒泡排序是所有排序算法中最原始的,重複的遍歷要排序的數串,一次比較兩個元素,假如順序錯誤就交換這兩個元素,直到不再需要交換,則數串已經排序完畢。數串中的最大元素會逐漸冒上來,所以稱之爲“冒泡排序”。
    冒泡排序流程圖如下所示:
    冒泡排序流程圖

  • 時間複雜度分析
    無論數串的紊亂狀態如何,只要數串的長度確定,比較次數就是確定的。假設數串的長度爲n,那麼每次遍歷(即內層循環)需要的比較操作爲 (n-i-1) 步,而i又是從0遍歷到 (n-1) 。因此最後得出的比較步數爲n(n1)2 ,比較操作時間複雜度爲O(n2)
    另外,交換操作也十分費時,每次交換兩個數字至少需要三步,但是交換操作受數串的紊亂程度的影響。最好情況是一開始就有序,不需要交換。最壞情況是全部需要交換,也就是需要3n(n1)2 步,時間複雜度也爲O(n2)

  • 空間複雜度分析
    只開數組不用遞歸,因此空間複雜度就維持在O(1)的常數級水平。

  • 優劣及穩定性
    冒泡排序的效率比較一般,雖然都是O(n2) ,但是冒泡排序前面的係數比較大,而且還有餘項,因此實際用時都比較長。
    但是冒泡排序是穩定的。

2. 快速排序

  • 快速排序也是分治的思想,將不斷縮小一個大問題的規模,使其變成可處理的小問題。假設有如下場景時,有兩堆數,一堆只有一位數,另外一堆只有兩位數,那麼要如何實現排序?顯然是分別對兩個堆進行排序,然後再直接拼接起來就可以了。迴歸到本質,我們是找到一個分隔標準分成兩個堆,然後再向上述的做法進行處理。這也就是快速排序算法的由來。此時距離構建算法我們還要解決三個關鍵問題:一是怎樣分數組,二是要將數組分到多小;三是怎麼合併這些數組。
    對於第一個問題,我們發現要把數組縮小規模,類似於歸併排序取中間元素,快速排序也要找到一個對照的標準元素,然後以這個元素爲參照,比這個元素小的放在一起,比這個元素大的放在另一堆,這就完成了拆分的過程。但這又引發了一個問題,如何找標準元素。首先考慮標準元素要找數組裏面的還是數組外面的,這一點很好判斷,如果是數組外的元素,我們不好確定到底要取多大,這樣很容易造成劃分的不均勻(極端的例子就是都被放在一個堆裏),因此應當要選擇數組裏面的元素(這樣的好處是就算被選擇的數字是最大元素或者最小元素,也無需擔心出現死循環的問題,所以這個元素的選取其實是任意的),我們可以直接選取數串的第一個元素作爲先導元素。但是此時假如數串原本是有序的,那麼每次選取的先導元素都是最小/最大值,這導致了算法的效率十分低下,所以我們可以隨機選取先導元素
    對於第二個問題,顯然我們要將數組劃分到最原始最簡單的小問題,對於排序來說最原始的數組就是隻含有一個元素的數組,因爲此時的數組一定是有序的。
    對於第三個問題,快速排序比歸併排序要簡單。因爲遞歸之後第一堆元素都比對照元素要小,第二堆元素都比對照元素要大。因此我們直接將排完序後的第一堆元素放在對照元素前面,將排完序後的第二堆元素放在對照元素的後面,就完成了合併過程。
    弄清楚算法的大致過程後,我們應當考慮用什麼數據結構來存儲數據。對比數組的隨機讀取結構及鏈表的鏈式結構:對於劃分數據,此時需要頻繁地移動數據,對於鏈表來說需要從頭遍歷到對應元素,而且需要大量的指針交換,而數組只需要控制下標即可,雖然交換時間差不多,但是數組更好實現,所以數組優於鏈表;對於合併兩個有序串,數組通過控制首末位置的下標就能夠很好的實現,鏈表也不難,直接把第二段的頭指針接到第一段的末尾即可,但是找到第一段的末尾需要遍歷整個前半段鏈表,因此鏈表在此操作上也沒有優勢,所以一般選擇數組實現快速排序。
    照例簡單的模擬一下過程,有待排序數串5 2 4 6 1 3,具體如下:
    快速排序示例

  • 時間複雜度分析
    快速排序中遞歸函數相當於任務分配,其調用過程中用時基本上可以忽略不計,因此主要耗時的環節落在了比較和交換上,我們先分析某一層遞歸的情況,假設這一層遞歸傳進的數串長度爲n,那麼每個元素都要與pivot做一次比較,一共 n-1 次,因此比較的時間複雜度爲O(n);而交換的次數是不固定的,最好情況就是不用做交換(即原來傳入的數串有序),此時時間複雜度僅僅只是比較的複雜度,即爲O(n),最壞情況是每次都要做交換(即原來傳入的數串倒序),那每一層的交換次數爲O(32n) (每次交換需要三步),此時每一層的時間複雜度爲O(2n)。
    以上是一層的情況,我們還要關心遞歸了多少層。最好的情況是每次劃分都是二分,此時遞歸層數爲log2n ,因此最終的時間複雜度爲O(nlog2n) 。最壞情況是每次都選中了數串中的最小元素或者最大元素做主元,這樣的遞歸層數爲n,因此最終時間複雜度接近於O(2n2) ,此時比插入排序的最壞情況還要慢。所以我們要分析平均情況,假設一開始傳進去的有n個數據,第一層O(n)的比較賦值次數是必須的,我們將所有的遞歸下去的情況(一共有n種情況,從全部小於先導元素(T(n-1)+T(0)))到全部大於先導元素(T(0)+T(n-1))視作等可能發生,那麼我們就可以得到下面這個遞推關係式。

    T(n)=1n[nO(n)+2k=0n1T(k)]

    通過迭代可以大致估算出
    T(n)=1.39nlog2n+O(n)

    因此時間複雜度爲O(nlog2n)
  • 空間複雜度分析
    空間複雜度與遞歸層數有關,顯然,綜合上述分析,最好情況下遞歸層數約爲log2n 層,最壞情況下遞歸層數約爲n層,在平均情況下遞歸層數約爲klog2n 層,因此空間複雜度爲O(log2n)

  • 優劣及穩定性
    快速排序算法已經達到了基於比較的排序算法的時間下界O(nlog2n) ,而且比較好實現,因此對於數組的隨機存儲結構和排序首選快速排序算法。
    但是快速排序運算速度上不穩定(最好及最壞情況時間空間複雜度相去甚遠,而且非常受一開始先導元素選取的影響);其次快速排序不是穩定的排序算法。

二. 歸併排序

  • 歸併排序是插入排序的另一種改良,同樣利用了插入排序“在基本有序的前提下,插入排序的耗時總會很短”以及“如果數組很短,就算基本無序的情況下,插入的耗時與基本有序的情況是差不多的”這兩點性質。因此歸併排序採用分治法,把一個大數組分成若干小塊。對於一個數組,我們可以將大數組分成若干個小數組進行排序,再用
    合理的方法將小數組通過一定的規則合併起來。類似於快速排序,我們同樣要處理三個關鍵問題:一是怎麼分數組;二是要將數組分到什麼程度;三是怎麼合併數組。
    首先解決第一個問題,怎樣將數組縮小規模。用二分法進行實現,每次對數組做一箇中分操作。
    對於第二個問題,要將數組分到什麼程度,與快速排序相同,我們最終目的是得到一個元素的數組。
    對於第三個問題,怎麼合併。對於兩個已經有序的數組,我們只需要不斷比較兩個兩個數組中的最大值(或者最小值)的大小,可以得到合併數組中的最大值、次大值等等,以此類推,就可以將數組合並。如下所示
    合併已排好序的數組
    考慮用什麼數據結構存儲,同樣比較數組的隨機讀取結構或者是鏈表的鏈式結構,對比兩者的差距:
    同樣對於劃分數據,數組的隨機結構是最佳的,因爲隨機讀取結構我們只需要控制首末位置的下標即可一步到位輕鬆實現劃分。但對於鏈表來說就沒有那麼輕鬆,因爲鏈表至少要從頭到尾遍歷一遍才知道一共有多少個元素,然後我們還得算出來到中間有多少個元素,然後還得從頭開始再遍歷到中間,若原來有n個數據,數組只需要1步,而鏈表需要32n 步。
    對於合併兩個有序串,在比較操作上是一樣的,但是對於移動元素,兩邊的操作步數大致相同,但是每一步花費的時間卻有很大差別。鏈表只要把指針傳過去就可以實現移動元素的操作,而指針是一個整型值,四個字節,但數組需要把所有元素一個個搬過去。如果數組裏存的是大整數或者是一個結構體或者是一個佔用空間很大的值,我們也只能全部搬過去。移動的數據量可就不只四個字節,移動數據一大,帶來的直接後果就是時間變得很長,對於這種需要大量合併操作的算法來說是極其耗時的。因此對於歸併排序來說,雖說在拆分數據上數組有優勢但這也是得不償失的,因此推薦使用鏈表實現,而且鏈表還有一個好處,就是空間可以增大,但數組的空間在一開始就是指定的不允許隨意修改的。
    爲了彌補鏈表在拆分數據上的劣勢,我們從頭結點開始規定兩個指針,一個每次沿着鏈表移動一個元素,另一個每次移動兩個元素,當那個移動兩個元素的指針到末尾時,那個移動一個元素的指針指向的就是中間位置。這樣的話,原來就有n個數據,只需要n步。
    對於指針操作必須指定所有空指針爲NULL,而且到了末尾要釋放掉分配的空間,同時注意指針的傳值和傳引用。對於不需要修改鏈表結構我們傳值即可,在需要修改鏈表結構時我們需要傳引用,否則系統會在拷貝的新空間上執行我們的操作,使得返回原函數後我們原先操作無效(注意free函數是特殊的,他只要拿到地址的值就可以實現釋放操作,所以只需要進行值傳遞,而不需要引用傳遞)。日常模擬一波過程:
    歸併排序過程

  • 時間複雜度分析
    首先分析第一層遞歸,對於歸併排序來說主要耗時間的有兩塊,一塊是將數據進行合併(因爲遞歸函數相當於將任務分配下去,所以每次任務的耗時是可以忽略不計的)。數據的拆分在之前分析過,如果採用雙指針的方法,對於長度爲n的數據來說是件複雜度爲O(n);數據的合併對於一個合併後爲n個數據的鏈來說,我們每做一次判斷就可以找到那個當前“最大”的元素,因此時間複雜度爲O(n)。
    對於遞歸第二層來說,原來的數據被分成兩塊,每一塊都變成原來時間複雜度的一半,因此總體複雜度也是O(n),以此類推第三層、第四層等等,每一層的時間複雜度都爲O(n),對於一個n個數據的數串來說,最多能夠分log2n 次,因此一共只會有log2n 層,所以總體的時間複雜度爲O(nlog2n)

  • 空間複雜度分析
    這個算法的空間複雜度和遞歸層數有關,顯然遞歸層數爲log2n 層,因此空間複雜度爲O(log2n)

  • 優劣及穩定性
    歸併算法同樣是插入排序算法的改進,利用分治法不斷縮小題目規模,最終達到簡化問題的目的,因此歸併排序算法在時間複雜度上有不小的優勢。可以證明基於比較的排序算法的時間下界爲O(nlog2n) ,也就是說沒有基於比較的排序算法在時間複雜度上可以超過歸併排序。
    歸併排序是穩定的排序,因爲在歸併時出現相同元素時,我們優先選取第一條子鏈中的元素,而第一個子鏈中的元素正好是原來排在前面的元素。

三. 基數排序

  • 基數排序的發明可以追溯到1887年赫爾曼·何樂禮在打孔卡片製表機(Tabulation Machine)上的貢獻。它是這樣實現的:將所有待比較數值(正整數)統一爲同樣的數位長度,數位較短的數前面補零。然後,從最低位開始,依次進行一次排序。這樣從最低位排序一直到最高位排序完成以後, 數列就變成一個有序序列。
    基數排序(radix sort)屬於分配式排序。該算法的實現基於兩個步驟:分配以及收集。首先根據數據的進制,從最低位(或者最高位)開始,將數據分配到不同桶中,然後按順序收集,在根據次低位(次高位)再次分配,再收集……以此類推,知道最高位(最低位),收集到的就是有序數據。基數排序有兩種實現方法 —— 最高位優先法和最低位優先法。MSD由鍵值的最左邊開始,LSD由鍵值的最右邊開始。
    最高位優先(Most Significant Digit first)法,簡稱MSD法:先按k1排序分組,同一組中記錄,關鍵碼k1相等,再對各組按k2排序分成子組,之後,對後面的關鍵碼繼續這樣的排序分組,直到按最次位關鍵碼kd對各子組排序後。再將各組連接起來,便得到一個有序序列。
    最低位優先(Least Significant Digit first)法,簡稱LSD法:先從kd開始排序,再對kd-1進行排序,依次重複,直到對k1排序後便得到一個有序序列。
    模擬一波過程,以LSD爲例如下所示
    LSD

  • 時間複雜度分析
    設待排序數串長度爲n,數據的最大位數爲d,每一位的取值範圍爲r,則進行鏈式基數排序的時間複雜度爲O(d(r+n)),其中,分配一次的時間複雜度爲O(n),收集一次的時間複雜度爲O(n),共進行d次分配和收集。由於d遠遠小於n,所以更加接近線性級別算法。

  • 空間複雜度分析
    需要radix個桶,以及用於靜態鏈表的n個指針。一般情況下,n遠大於radix。

  • 優劣及穩定性
    對比桶排序,基數排序的性能略差。基數排序的優點在於所需要的桶不多,而且基數排序幾乎不需要任何的“比較”操作,而桶排序在桶相對比較少的情況下,桶內多個數據必須基於比較操作的排序。在實際應用中,基數排序的應用範圍更加廣泛。
    LSD和MSD都可以穩定實現,但是對於MSD,需要從大桶裏分小桶,實現比較麻煩,將每個桶裏的數值按照下一數位的值分配到子桶中,在進行完最低位數的分配後再合併回單一數組(遞歸實現)。LSD的實現比較易於理解,在文章末尾有代碼實現。
    LSD的基數排序適用於位數小的數列,如果位數多的話,使用MSD的效率會比較好。
    儘管基數排序執行的循環輪數少於快速排序,但是每一輪基數排序所需的時間要長得多,利用計數排序作爲中間穩定排序的基數排序不是原址排序,而很多基於比較的排序是原址排序。因此當主存的容量比較寶貴(如嵌入式)時,會更傾向於如快速排序之類的原址排序。

四. 拓撲排序

  • 拓撲排序的應用場景比較特殊,並不是通常意義上的“排序”。在許多實際應用中,我們都需要使用有向無環圖來指明事件的優先次序,拓撲排序是對有向無環圖中所有節點的一種線性排序,即爲事件的一種合理次序(注意次序有時是不唯一的)。拓撲排序是通過深度優先搜索實現的,可以證明有向無環圖進行深度優先搜索後不產生後向邊。
    拓撲排序的實現有兩種方式:
    1、Kahn算法,其思路是:(1) 從有向圖中選取一個沒有前驅的節點V並輸出;(2) 從有向圖中刪去沒有前驅的節點V,以及以節點V爲起點的邊;(3) 重複上述步驟,直到有向圖中不存在入度爲0的點。
    2、DFS算法,其思路爲遞歸:(1) 對於所有未訪問的出度爲0的節點V,假如其入度不爲0,先訪問節點V’(存在邊V’->V);(2) 重複步驟(1)直到節點V是入度爲0的點,輸出節點V,返回上一層。
    假如能夠確定給出的圖爲DAG,那麼可以使用DFS算法實現更加簡潔;假如圖不能確定是否爲DAG,那麼應當使用Kahn實現。
    考慮算法採用的數據結構,對於稀疏圖,一般採用list容器等存儲;對於稠密圖,可以使用數組來存儲鄰接矩陣。

  • 時間複雜度分析
    假設存在有向無環圖G,圖中有e條邊,n個節點。
    對於Kahn算法實現,首先建立數組記錄每個節點的入度,則需要遍歷所有邊,時間複雜度爲O(e)。找到入度爲0的節點,刪除節點及對應的邊,注意此時對應的點入度需要減1,一條邊對應一次入度減1操作,所以時間複雜度爲O(e)。找n次入度爲0的節點,時間複雜度爲O(n)。綜上,複雜度爲O(n+2e),即O(n+e)。
    對於DFS算法實現,其時間複雜度與DFS算法是一致的,只是在DFS的最後加上存儲節點值。對DFS算法,在遍歷圖時,每個節點最多調用一次DFS函數,一旦某個頂點被標記成已訪問,就不會再從該節點開始搜索。遍歷圖的過程實質上是對每個頂點查找鄰接點的過程,其耗費時間取決於使用的存儲結構。當使用二維數組表示圖時,查找每個頂點的鄰接點所需要的時間O(n2) ,而以鄰接表表示圖時,查找鄰接點所需要時間爲O(e)。因此以鄰接表表示圖的時候,其時間複雜度爲O(n+e)。

  • 空間複雜度分析
    對於Kahn算法實現,需要一個數組存儲每個節點的入度,即空間複雜度爲O(n)。
    對於DFS算法實現,空間複雜度爲O(1)。

五. 其他

  1. 計數排序、基數排序以及桶排序的比較?

    計數排序(counting sort):輸入數據範圍均落在0~k之間,構造一個數組大小爲k+1,計算每個元素出現的次數。

    COUNTING-SORT(A,B,k)
    let C[0..k] be a new array
    for i = 0 to k
        C[i] = 0
    for j = 1 to A.length
        C[A[j]] = C[A[j]]+1
    // C[i] now contains the number of elements equal to i.
    for i = 1 to k
        C[i] = C[i]+C[i-1]
    // C[i] contains the number of elements less than or equal to i
    for j = A.length to  1
        B[C[A[j]]] = A[j]
        C[A[j]] = C[A[j]] - 1
    

    桶排序(bucket sort):假設數據服從均勻分佈,平均情況下時間代價爲O(n)。計數排序假設輸入數據都屬於一個小區間內的整數,而桶排序則假設輸入是由一個隨機過程產生,該過程將元素均勻地分佈在[0, 1)區間上。桶排序將[0, 1)區間劃分爲n個相同大小的子空間,或稱爲桶。然後將n個輸入數分別放到各個桶中。然後對每個桶中的數進行排序,再遍歷每個桶,按照次序把各個桶中的元素列出來。

    BUCKET-SORT(A)
    n = A.length
    let B[0 .. n-1] be a new array
    for i = 0 to n-1
        make B[i] an empty list
    for i = 1 to n
        insert A[i] into list B[floor(nA[i])]
    for i = 0 to n-1
        sort list B[i] with insertion sort
    concatenate the lists B[0], B[1], ..., B[n-1] together in order
    

    基數排序(radix sort):基數排序使用的桶的個數與數據使用的進制相關,而且算法的複雜度與數據的大小相關。

  2. 二維數組與指針?

    • 二維數組名是數組的首地址,雖然值等於第一個元素的地址,但是並不代表元素的地址。
      數組名都僅僅是地址常量,但是不是指針,都是可以賦值給指針,但是一維數組名和二維數組名賦予給指針時是不一樣的。一維數組的數組名可以直接賦給指針(形如int a[3]; int *p=a;),二維數組的數組名不可以直接賦給指針(形如int a[3][4]; int *p=a;是錯誤的,正確的賦值方法爲int *p=a[0]; 另外可以直接定義二維數組的指針,int (*p)[4]; p=a;也是正確的)。
      此時好像有些矛盾,爲什麼數組名a是數組的首地址,但是不能直接使用p=a,而要使用p=a[0]?
      需要注意的是此時數據類型是不同的,因爲p是指向int的指針,而a可以看成指向int [3]的指針。而int和int [3]不是相同的類型,前者是簡單數據類型,後者是由簡單數據類型構成的數組類型。顯然這兩種數據類型不同,所以指向他們的指針類型也不同。指針運算是按照指針的類型進行的,所以p++只使p移動了一個整數所佔的字節長度,a++移動了三個整數所佔的字節長度。由指針運算可以看出這兩個指針不是同一類型。但是可以進行強制轉換,即p=(int*)a; 因爲a和a[0]雖然類型不同,但是值是相同的。
      除了sizeof、&和字符串常量之外的表達式中,array type會被自動轉換爲pointer type。
    • 注意區分指針數組和二維數組指針:
      指針數組可以聲明如 int *(p1[4]); 其中括號可以去除,因爲[]操作符的優先級高於*操作符。指針數組是一個數組,只是每個元素保存的都是指針,在32位環境下他佔用4x5=20個字節的內存。
      二維數組指針可以聲明爲 int (*p2) [4]; 。它是一個指針,指向一個二維數組,佔用4個字節的內存。

五. 參考及代碼

[1] 排序算法C++實現

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