1. 程式人生 > 程式設計 >十種內部排序排序演演算法全解析

十種內部排序排序演演算法全解析

排序演演算法

給一列元素排序算是演演算法中一個基礎問題。排序演演算法可分為兩種,內部排序外部排序,內部排序是所有資料能夠一次加入記憶體中,直接進行排序的演演算法;外部排序是指資料不能夠一次載入到記憶體中,例如資料量太大等,這時候就需要採取一些辦法。當我們討論一個排序演演算法時,除了需要討論穩定性,最好時間複雜度,最差時間複雜度空間複雜度,討論空間複雜度時,可以得到是否是原址排序(如果輸入陣列中僅有常數個元素需要再排序過程中儲存在陣列之外,則稱排序演演算法是原址的(in place)),還應該去討論演演算法的適用範圍和改進點,也就是在什麼時候會出現最好時間複雜和最壞時間複雜度以及改進策略,通常演演算法的最壞事件複雜度發生在待排元素完全逆序的時候。另外,沒有一種排序演演算法是在所有情況下都是最好的,選擇合適的排序演演算法才是最重要的。演演算法的穩定性並不是一成不變的,例如插入排序中,我們使用嚴格小於/嚴格大於時,才能保證演演算法穩定,當使用小於等於/大於等於時,演演算法就是不穩定的。

為了方便討論,我們只討論基於比較的排序(相對的面是什麼???),即所有的元素都是可比較的,並且假定排序的結果是從小到大的。

簡單排序演演算法

這三種排序演演算法特點是程式碼簡單,容易實現,但是在效能上不是很好,但是 1) 可以作為高階排序演演算法中小規模資料時應用,2) 在此基礎上改進,例如對希爾排序的子陣列應用直接插入排序,堆排序是簡單選擇排序的優化。

直接插入排序

直接插入排序的主要思想是將元素插入到合適的位置,逐步構建有序序列。將元素分成兩個部分,前半部分是有序的,後半部分是無序的。每次從後半部分取出一個元素,在有序序列中從後向前掃描比較,找到合適的位置插入。 可以把插入排序想象成抓牌的過程,通常我們左手拿牌,右手抓牌,每抓到一張牌,從右向左比較牌面大小,如果牌面大於抓到的牌,就將這張牌向右移動一個位置(留出一個空位),當牌面小於等於抓到的牌時,就將抓到的牌放入。

  • 對於一組包含N個元素的非遞減有序序列,採用插入排序成非遞增序列,比較次數至多是 \frac{1+N-2}{2}\times(N-2) ,移動的次數至多為 \frac{1+N-2}{2}\times(N-2)=\frac{(N-1)\times(N-2)}{2}

  • 簡單插入排序的時間複雜度是O(N^2)

  • 當待排序列是有序時,取得最好時間複雜度O(N)

  • 在簡單插入演演算法中,只需要申請臨時變數即可,空間複雜度是O(N)

  • 當保證嚴格小於/嚴格大於時,演演算法是穩定的

對於下標i<j,如果A[i]>A[j],],則稱(i,j)是一對逆序對(inversion),例如序列\{34,8,64,51,32,21\}中有九個逆序對,插入排序需要做九次交換,交換的次數和逆序對的數目是相同的,交換兩個元素正好消去1個逆序對。因此插入排序 T(N,I)=O(N+I),I是逆序對的個數,因此,

  • 如果序列基本有序,則插入排序簡單且高效。(當然,如果序列完全逆序,此時 I是 N^2 級別)
偽碼

insertion_sort(A,length)

for j = 1 to length-1
    key = A[j]
    //將key 插入有序子序列序列A[0...j-1]
    i = j
    while i-1 >= 0 && A[i-1] > key
        A[i] = A[i-1]
        i--
    A[i] = key
複製程式碼
時間複雜度下界
  • 定理:任意N個不同元素組成的序列平均具有 N(N-1)/4 個逆序對。
  • 定理:任何僅以交換相鄰兩元素來排序的演演算法,其平均時間複雜度為 \Omega(N^2)
  • 這意味著:
  • 要提高演演算法效率,我們必須每次消去不止1個逆序對!
  • 每次交換相隔較遠的2個元素!

簡單選擇排序

簡單選擇排序就跟它的名字一樣,關鍵在於選擇合適的元素,逐步構建有序序列。將序列分成兩個部分,前半部分是有序的,每次從無序的序列中選取最小元素,追加在有序序列末尾(與無序第一個元素交換)。在尋找最小元素時,需要遍歷無序序列\Theta(N^2),成為提高效率的瓶頸。

  • 簡單選擇排序的時間複雜度是T(N)=\Theta(N^2),在找最小值時,無論如何要遍歷整個無序序列
  • 空間複雜度是 O(1),只需要申請一個臨時變數儲存當前最小值
  • 簡單排序演演算法是不穩定的
  • 簡單排序演演算法並不是一種優秀的演演算法,適用於資料量比較小,並且對穩定性沒有要求的情況
偽碼

selection_sort(A,length)

for i = 0 to length-1
    min_idx = i
    for j = i+1 to length-1
        if(A[j] < A[min_idx])
            min_idx = j
    A[i] <-> A[min_idx]
複製程式碼

氣泡排序

氣泡排序是一種直觀的排序方法,每次比較相鄰的兩個元素,如果逆序就將他們順序交換,一輪冒泡後,最大的元素放在序列尾部,序列右邊是排序好的子序列。重複這個過程,直到所有的元素都排好。 如果序列已經是有序的,在這個排序過程中並不能發現,因此我們給每一輪排序加一個標記,如果整輪排序中都沒有交換過,說明序列已經排好了,那麼停止排序。

  • 氣泡排序的最好時間複雜度O(N),序列有序時
  • 氣泡排序的最好時間複雜度O(N^2),序列逆序時
  • 氣泡排序的空間複雜度是O(1)
  • 氣泡排序是穩定的
  • 氣泡排序交換的次數也是逆序對的數目,因此,氣泡排序適用於序列基本有序的情況
偽碼

bubble_sort(A,length)

for i = N-1 to 0
    flag = 0 //標識是否發生交換
    for j = 0 to i-1
        if(A[j] > A[j+1])
            A[j] <-> A[j+1]
            flag = 1 
    if flag == 0
        break   //全程無交換
複製程式碼

高階排序演演算法

希爾排序

希爾排序是對直接插入排序的一種優化,實質是將直接插入排序變成了分組插入排序。其基本思想就是將待排元素按照步長(gap)分割成N個組,對每個組進行直接插入排序,然後再減小步長進行直接插入排序,直到gap達到最小值,即陣列基本有序時,再對陣列進行直接插入排序,此時直接插入排序可以達到最高效率。當gap=1時,希爾排序退化成直接插入排序。因此,我們可以1)讓gap>1,然後跳轉到直接插入排序,或者2)gap減少至1,流暢地進入直接插入排序。
所有的gap值組成的序列叫做增量序列。

  • 不同的增量序列得到不同的時間複雜度
  • 增量元素不互質,則小的增量元素可能根本不起作用
  • 原始增量序列,最壞事件複雜度是\Theta(N^2),每一次插入排序都不起作用,最後退化成簡單插入排序
  • Sedgewick增量序列\{1,5,19,41,109,...\}9\times 4^i-9\times2^i+1,4^i-3\times2^i+1的最差時間複雜度猜想是O(N^{\frac{4}{3}})
  • 希爾排序的空間複雜度是O(1)
  • 希爾排序是不穩定的排序,分組排序導致它不穩定
偽碼

1.原始增量序列 D_0=\lfloor\frac{length}{2}\rfloor,D_k=\lfloor\frac{D_{k+1}}{2}\rfloor
shell_sort(A,length)

D = length/2
while D > 0 //gap逐漸減小
    for i = D to length - 1
        /// 一輪插入排序
        key = A[D]
        j=D
        while j-D >= 0 && A[j-D]>key ///j-D >= 0 保證索引值>0
            A[j] = A[j-D] ///空出A[j]
            j-=D
        A[j] = key
    D = D / 2
複製程式碼

2.Sedgewick增量序列
shell_sort(A,length)

    int Si,D,P,i;
    ElementType Tmp;
    //這裡只列出一小部分增量
    Sedgewick[] = {929,505,209,109,41,19,5,1,0};
    Si = 0
    ///增量序列小於序列長度
    while Sedgewick[Si] > length
        Si++
    while Si >= 0
        D = Sedgewick[Si]
        for i = D to length - 1
            /// 一輪插入排序
            key = A[i]
            j=i
            while j-D >= 0 && A[j-D]>key ///j-D >= 0 保證索引值>0
                A[j] = A[j-D]
                j-=D
            A[j] = key
        Si--
複製程式碼

堆排序

堆排序是選擇排序的改進,在選擇排序中,找最小元的操作是提高速度的瓶頸。但是他利用了堆的性質,父節點的值大於子節點,且滿足完全二叉樹(除最後一層外,其它層都有2n個節點,最後一層的節點都連續集中在樹的左邊,堆是一棵完全二叉樹,儲存效率很高),而從堆中取得最大/最小值時間複雜度為O(1),大大提高了找最小元的效率。


二叉排序樹:父節點的值大於所有左子樹的值,小於所有右子樹的值
大頂堆:父節點的值大於子節點的值
小頂堆: 父節點的值小於子節點的值


  • 堆排序中,升序用大頂堆,降序用小頂堆,可以降低空間複雜度O(N)->O(1)
  • 堆排序是選擇排序的一種,它利用了陣列的特點快速定位指定索引的元素
  • 堆排序的時間複雜度為O(Nlog_{2}N),空間複雜度是O(1)
  • 不穩定排序(???)
  • 雖然堆排序給出最佳平均時間複雜度,但實際效果不如用Sedgewick增量序列的希爾排序(???)
  • max_heapify 時間複雜度是O(log_{2}N)(推導:)
虛擬碼

heap_sort(A,length)

///建立最大堆
i = length/2 ///下界
while i >= 0
    max_heapify(A,i,length-1)
    i--
i = length - 1
while i > 0
    A[0] <-> A[i]
    ///維護最大堆性質
    max_heapify(A,0,i)
    i--
複製程式碼

max_heapify(A,j)

///向下過濾函式:將陣列中以A[i]為根的子堆調整為最大堆
///調整根節點、兩個子節點的位置,讓它滿足大頂堆
Parent = i
while Parent*2+1 <= j
    Child = Parent*2+1
    if Child!=j && A[Child] < A[Child+1]
        Child++ //Child指向左右結點中較大的
    if A[Parent] < A[Child]
        ///交換最大項和根
        A[Parent] <-> A[Child]
    Parent = Child
複製程式碼

快速排序

快排是一種在實際應用中經常用到的演演算法,它的應用場景是大規模的資料排序,並且實際效能要優於歸併排序,快排可以看做氣泡排序的改進。快排採用了分而治之的思想,它的基本思路是,從陣列中選取一個主元,把所有大於主元的元素都放到它的後面,所有小於主元的元素都放到它的前面,主元將數列分成兩部分,再分別對這兩部分進行相同的操作,直到陣列不能切分為止。此時陣列為有序陣列。不同的主元選擇,影響時間複雜度。

  • 快速排序是不穩定
  • 最好情況下,每次選擇的主元都將陣列分成等長的兩部分,此時陣列的長度減小最快,時間複雜度為O(log_{2}N)
  • 主元的選擇:如果選取陣列的頭或尾作為主元,而且陣列是有序的,那麼退化成氣泡排序,時間複雜度退化成O(N^2);我們可以選擇,頭、尾、中間元素的中值作為主元
  • 快排的空間複雜度是O(log_{2}N)
  • 快排在給陣列分組時,有兩種方法:單邊掃描和雙邊掃描
  • 當陣列基本有序時,插入排序的速度很快,我們可以設定一個截斷值(cutoff),當陣列長度小於截斷值時,採用插入排序,不同截斷值對快排的四度影響也不同;同時,用快排小規模資料還不如插入排序快
  • 截斷值(cutoff)通常設定是10
虛擬碼

quick_sort(A,length) 雙邊掃描方法

left = 0
right = length - 1
if left < right
QSort(A,left,right)
複製程式碼

QSort(A,right)

///調整A[left] A[right] A[median] 的位置,讓A[left] <= A[median] <= A[right]
///pivot = A[right]
if cutoff < right - left + 1
    pivot = Median(A,right)
    ///雙邊掃描
    i =  left + 1
    j = right - 1
    while i < j 
        while A[i] <= pivot
            i++
        while A[j] >= piort
            j++
        A[i] <-> A[j]
    A[i] <-> A[right]
    QSort(A,i - 1)
    QSort(A,i + 1,right)
else
    insertion_sort()
複製程式碼

歸併排序

歸併排序的核心是將兩個有序表合併,如果有序表總共有N個元素,那麼,時間複雜度是O(N)。歸併排序的核心思想是分而治之,他的基本思路是將兩個有序子數列合併(Merge)成一個有序數列,如果說快排是自頂向下的排序,那麼為了得到有序子序列,歸併排序就是自低向下的排序。

  • 歸併排序是一種穩定的排序方法,Merge過程沒有破壞穩定性
  • 因為歸併的過程需要藉助一個等長的陣列歸併排序不是原址排序,它的空間複雜度是O(N),同時,它能排序的元素規模小了一半
  • 歸併排序的時間複雜度是O(NlogN)
虛擬碼
1.遞迴版本的歸併排序

merge_sort(A,length)

    //申請新的空間
    new tempA
    if tempA != NULL
       MSort(A,tempA,length - 1)
    else
        print("empty")
複製程式碼

MSort(A,right_end)

    if left < right_end
        center =  (left + right_end) / 2
        MSort(A,center)
        MSort(A,center + 1,right_end)
        Merge(A,right_end)
複製程式碼

Merge(A,right,right_end)

//A[left,...right-1]和A[right,...,right_end]merge到A[left,right_end]
i = left
j = right
num = right_end - left + 1
temp = left
//歸併過程
while i <=  left-1 && j <= right_end
    if A[i] <= A[j]
        tempA[temp++] = A[i++]
    else
        tempA[temp++] = A[j++]
while i <= left-1   //直接複製左邊剩下的
    tempA[temp++] = A[i++]
while j <= right_end    //直接複製右邊剩下的
    tempA[temp++] = A[j++]
temp = right_end
//拷貝陣列
while temp >= right_end - num + 1
    A[temp] = tempA[temp]
複製程式碼
2.非遞迴版本的歸併排序

非遞迴版本的歸併排序更體現了歸併排序自底向上的特質
merge_sort(A,length)

//申請新記憶體
new tempA
子陣列長度
sub_length = 1 
if tempA!=NULL
    while sub_length < length
        Merge_pass(A,length,sub_length)  
        sub_length = sub_length * 2
        Merge_pass(tempA,A,sub_length)  //如果sub_length > length Merge_pass函式會將陣列原封不動的複製過去
        sub_length = sub_length * 2
else
 print("empty)
複製程式碼

Merge_pass(A,N,length)

//按照length長度作為子序列長度進行merge,歸併的結果方放在tempA中
left = 0
right = left + length
right_end = left + 2 * length - 1
while left <= N - 2 * length //不能讓right_end >= N
    Merge1(A,right_end) 
    left = left + 2*length
    right = left + length
    right_end = left + 2 * length - 1
if right < N ///尾部還有兩個序列
    Merge1(A,N - 1) 
else //尾部只有一個序列
    for i = left to N - 1
        tempA[i] = A[i]
複製程式碼

桶排序

桶排序不是一種基於比較的排序,假設我們知道待排元素的範圍為[1,N],那麼我們建立N個桶,掃描一遍所有元素,將元素放到合適的桶中,最後再掃描一遍所有的桶,依次列印桶中的元素。

  • 假設有M個元素,元素的範圍是[1,N],那麼時間複雜度是O(M+N),這個時間複雜度看似是線性的,但是當N>>M時,時間複雜度就是指數級別的
  • 另外桶排序也不是一種原址排序,但它可以做成穩定排序
虛擬碼

bucket_Sort(A,length)

count[]初始化;
while 讀入1個學生成績grade
    將該生插入count[grade]連結串列;
for i = 1 to M
    if ( count[i] )
        輸出整個count[i]連結串列;
複製程式碼

計數排序

基數排序