【算法】排序 (一):插入排序&希爾排序&選擇排序&堆排序(C++實現)

排序算法的具體實現都在文章末端

一. 插入排序

1. 直接插入排序(穩定)

  • 插入排序是日常中比較常見的算法,比如在平時撲克牌遊戲中,我們分到的牌都是無序的,開局前我們會對牌進行排序,此時牌被分爲兩堆——有序堆和無序堆。初始時有序堆中的撲克牌數量爲0,我們每一次從無序堆中拿出一張牌,和有序堆中的最後一張牌比較到第一張牌,直到下一張牌大於當前牌,則放在這張牌後面。這樣有序堆撲克牌數量多了一張,無序堆的撲克牌數量少了一張。
    比如我們有一個數字序列如下
    9 4 2 0 5 1 6 7
    其排序過程如下所示,每次都從無序部分選出一個元素,從右往左比較有序部分中的元素,直到當前元素小於要插入元素則將元素插入其後。
    直接插入排序示例
  • 時間複雜度分析
    假設數字串的長度爲n
    • 最好情況
      原本數串就是有序串,不需要移動數串中的元素,只需要n次比較即可,則複雜度爲O(n)。
    • 最壞情況
      原本數串是倒序串,此時每次都要做O(n)的比較,而且還需要做1+2+…+(n-1)=n(n-1)/2次的移動元素的操作。因此最終最壞情況下的時間複雜度爲O(n2)
    • 平均情況
      假設每種情況的概率相同,我們可以通過期望算出平均複雜度,通常與最壞情況大致一樣差。則直接插入排序的平均時間複雜度爲O(n2)
  • 空間複雜度分析
    只需要開數組不需遞歸,因此空間複雜度維持在O(1)的常數級水平。

2. 希爾排序(不穩定)

  • 希爾排序是插入排序的增強版。利用了插入排序的兩點性質:
    (1) 在基本有序的前提下,插入排序的耗時總會很短。因爲插入排序耗時的部分在於尋找插入位置上,直接插入排序每次在尋找插入位置都是從有序部分的最後一個開始看,通過不斷比較向前推進,每推進一步就移動一次數據(注意:是移動數據而不是交換數據,因此比冒泡排序要好很多)。如果數據插入在有序部分靠後的位置則需要的步數就較少,此時說明原來的無序數組是基本有序的。
    (2) 如果數組很短,就算基本無序我們要做的插入的耗時跟基本有序的情況差不多。
    希爾排序就是利用這兩個性質把一個很長的數串分成若干個小部分,通過對這些部分進行插入排序來營造基本有序的數列。接下來再將這些部分不斷擴大,使得這個數列逐漸有序。此時的關鍵策略是如何分塊,主要有兩種:一種是不斷中分(歸併排序);另一種是設定一個小於數組長度n的實數k,凡是位置編號(index)模k得到同樣數值的分爲一塊(希爾排序,是類似於散列中處理關鍵字的方法)。
    現在的另一個關鍵問題是k應該取多少合適:在大量數據實驗後得到的經驗值爲k越接近n2 ,然後讓k不斷折半,直到k爲1(此時爲直接插入排序),得到的就是有序的了。
    同樣的一個數字序列如下
    9 4 2 0 5 1 6 7
    具體排序過程如下圖所示
    希爾排序示例圖
  • 時間複雜度分析
    由於shell排序是直接插入排序的優化,其最壞最好平均情況都與插入排序一致。目前沒有明確的數學推導可以得到其時間複雜度,但是大量模擬實驗後,可以大致認爲時間複雜度爲O(n1.25)
  • 空間複雜度分析
    仍然爲O(1)的常數級。
  • 優劣及穩定性
    希爾排序是對直接插入排序算法的優化,在運行效率會比直接插入排序、簡單選擇排序及冒泡排序稍高。但希爾算法不算是對插入排序算法一種特別高效的優化,也就是說基於比較的排序的時間複雜度還有進一步提升的空間。
    希爾排序是不穩定的,因爲相同的元素在排序過程中前後位置可能會發生交換,這是因爲一開始step的值不爲1時,可能將相同元素中原來排在後面的元素扔到了前面,使得排序後相同元素順序會發生交換。

二. 選擇排序

1. 簡單選擇排序

  • 這是一種非常簡單而且在日常生活中常見的排序算法,將數串分爲有序部分S和無序部分U,每次都從無序部分中選出最小元素放在S的尾部,知道無序部分元素數目爲零,即可得到有序數串。只需要N-1次選擇,即可完成選擇排序。同樣的一個數字序列如下
    9 4 2 0 5 1 6 7
    具體排序過程如下圖所示
    簡單插入排序示例
  • 時間複雜度分析
    假設數串的長度爲n,對於選擇排序,不管是最好情況、最壞情況、平均情況的時間複雜度大致相同。在一次循環中只需要一次交換值,所以交換值的時間複雜度爲O(1),可以不考慮 。找到最小的、第二小的、一直找到最後需要n-1次循環,而每次循環內選出最小元素的下標,需要O(n)的時間複雜度。因此總體的複雜度爲O(n2)
  • 空間複雜度分析
    O(1)常數級。
  • 優劣及穩定性
    選擇排序和插入排序都是直觀的排序算法,而且運行效率差不多。選擇排序在找出第k個最值的題目中有着比插入排序更大的優勢(因爲插入排序需要將其全部排好序才能確定,而選擇排序不需要,因此省下很多時間)。
    選擇排序除了低效以外,還是不穩定的。
    備註:選擇排序從一個n元素數組中找到最大值和最小值的時間複雜度都必須爲O(n)。

2. 堆排序

  • 假如現在的題目是找出第k個最大元素或者是第k個最小元素,我們只要將前面k個數或者是後面k個數排序就可以了,其他部分並不關心。此時類似於分治排序之類的全排序算法已經失去優勢。反而選擇排序類的算法更有優勢。堆排序算法就是對簡單選擇排序的改進,可以降低時間複雜度。
    堆是一個完全二叉樹(設二叉樹的深度爲h,除了第h層以外,其他各層的節點數都達到最大個數),其次完全二叉樹上每個節點比左右兩個子節點都大(最大堆),或者比左右兩個子節點都小(最小堆)。此時最大(小)元素總在堆頂,而且每個堆的左右子樹也爲堆。以最大堆爲例,得到有序序列只需要每次取出堆頂元素,然後將剩下元素恢復成堆,重複上述過程即可得到有序序列。
    堆排序的關鍵點如下:
    • 當堆頂元素被抽出之後,怎麼調整堆纔可以維護好堆的性質?
      此時可以從堆的左右孩子中選出最大的元素補到堆頂元素的位置上。此時該節點原來的位置又是一個空節點,此時這個空節點作爲子樹的堆頂元素重複上述過程,直到空節點下降至葉節點。舉例如下:
      堆性質恢復
      或者可以直接把最底層最右節點補到根節點的位置,並且不斷向下恢復堆性質,實現代碼中就是採用這種方法。
    • 怎麼建堆?即怎麼把任意一個完全二叉樹變成堆。
      從底層開始判斷,
      對於一個左右子節點(若存在)均爲葉節點的根節點來說,只需要比較該根節點與其子節點的大小,使得最大的元素爲根節點即可。
      對於左右節點(假如存在)不都是葉節點,則需要遞歸的維護堆的性質,假如孩子節點發生變化,需要檢查其子樹是否依舊符合性質。舉例如下:
      構建堆
      確定了堆排序算法的大致過程,我們需要確定數據結構。從上述例子可以看出,該算法對於數據的隨機讀取效率要求比較高,所以我們考慮用數組來實現。
      注意,要用數組實現樹狀結構是不可行的,即使是完全二叉樹也是不可行的(因爲得出的二叉樹不唯一)。但是對於特定情況來說是確實可行的,因爲使用數組的目的是藉助這個結構來理解堆排序,建出來的堆並不要求是唯一的,只要求具有堆的性質。因此對於任意給定的一個數組,我們都能把它排成隨便一種二叉樹的形式,建堆過程後我們都會得到一個堆。
      把一個數組變成隨便一種完全二叉樹的形式最好的方法,就是從左往右,從上往下的放,儘量佔滿每一層的節點位置,如果不夠再轉到下一層。這樣做的好處十分明顯,一是查找父節點和孩子節點十分容易:假設根節點對應的下標爲k,那麼其左節點和右節點對應的下標分別爲2k+1或者2k+2;二是數組很好操作,當我們要刪除一個元素時,交換兩個節點只需要交換對應下標的數據即可。
      可以看出恢復堆性質操作的後半段過程與創建堆一致,可以通過一個函數來完成這一任務,增強代碼複用性。
      同樣用一個簡單的例子展示堆排序的過程,如下所示
      堆排序示例
  • 時間複雜度分析
    在堆排序中主要耗時的有兩個環節。一是恢復堆的性質,二是建堆。
    對於恢復堆性質,其函數的時間複雜度與完全二叉樹的層數有很大關聯。最好情況就是第一個元素本身就是最大元素,因此只需要一次比較,爲O(1)的時間複雜度。對於最壞情況,基本上有多少層就要做多少次比較和賦值操作。由於完全二叉樹是一個高度平衡的樹,所以樹的層數基本上爲log2n ,因此這一部分的時間複雜度爲O(log2n)
    建堆的時間複雜度爲n2× 恢復堆性質的時間複雜度(因爲建堆需要循環數串裏的一半元素),所以建堆的時間複雜度爲n2log2n ,因此時間複雜度爲nlog2n
  • 空間複雜度分析
    對於尾部遞歸函數,可以很容易改成非遞歸函數,因此空間複雜度接近於O(1)。
  • 優劣及穩定性
    堆排序算法成功解決了選擇排序中每次選出最大元素都要經歷O(n)時間的問題,通過堆的特殊性質成功將O(n)時間變成了O(log2n) ,而且實現也比較簡單,因此對於找出第k個最大元素或者第k個最小元素時首選堆排序。
    然而堆排序不是穩定排序。另外雖說其時間複雜度爲O(nlog2n) ,但是其前面的係數接近於3,因此對於數組的全排序還是建議用快速排序的方法。

三、其他

  1. C語言的指針和C++的引用主要有以下區別:
    (1)引用必須被初始化,但是不分配存儲空間。 指針不聲明時初始化,在初始化的時候需要分配存儲空間。
    (2) 引用初始化以後不能被改變,指針可以改變所指的對象。
    ( 3) 不存在指向空值的引用,但是存在指向空值的指針。
    注意:引用作爲函數參數時,會引發一定的問題,因爲讓引用作參數,目的就是想改變這個引用所指向地址的內容,而函數調用時傳入的是實參,看不出函數的參數是正常變量,還是引用,因此可能會引發錯誤。所以使用時一定要小心謹慎。

四、參考和代碼

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

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