(十)更快的排序算法(歸並、快排、基數)


目標

1) 使用下列方法將一個數組按升序排序:歸並排序、快速排序和基數排序

2) 評估排序的效率,討論不同的方法的相對效率

 

目錄

 9.1 歸並排序

  9.1.1 歸並數組

  9.1.2 遞歸歸並排序

  9.1.3 歸並排序的效率

  9.1.4 迭代歸並排序

  9.1.5 Java類庫中的歸並排序

9.2 快速排序

  9.2.1 快速排序的效率

  9.2.2 創建划分

  9.2.3 實現快速排序

  9.2.4 Java類庫中的快速排序 

9.3 基數排序

  9.3.1 基數排序的偽代碼

  9.3.2 基數排序的效率

9.4 算法比較

小結

 

  排序小數組時,之前的排序算法已經足夠了,想排序一個更大的數組,那些算法可能也是一個合理的選擇。插入排序是對鏈式節點鏈進行排序的好方法。當需要對頻繁地對非常大的數組進行排序時,那些方法花時間太多。

 

9.1 歸並排序

  歸並排序merge sort數組分為兩半,分別對兩半進行排序,然后將它們合並為一個有序的數組。歸並排序算法常常用遞歸方式來描述。遞歸常用同一問題的更小版本來表示解決問題的方案。當你將問題划分為兩個或多個更小的但獨立的問題時,解決每個新問題,然后將它們的方案合並為解決原始問題的方案,這個策略稱為分治divide and conquer)算法。即,將問題划分為小塊,然后攻克每個小塊以成解決方案

  當用遞歸表示時,分治算法含有兩個或多個遞歸調用。

 

9.1.1 歸並數組

  歸並兩個有序數組,只需從頭開始處理到末尾,將一個數組中的項與另一個數組中的項進行比較,較小的項復制到新的數組中。

 

9.1.2 遞歸歸並排序

算法

  在歸並排序中,歸並兩個有序數組,實際上它們是原始數組的兩半。即,將數組分為兩半,排序每一半,然后將這兩段有序部分合並到第二個臨時數組中,然后將臨時數組復制回原始數組中。

  歸並排序有下列遞歸形式:

Algorithm mergeSort(a, tempArray, first, last)
// 遞歸地排序數組項a[first]到a[last]
if (first < last){
   mid = first + (last - first) / 2
   mergeSort(a, tempArray, first, mid)
   mergeSort(a, tempArray, mid+1, last)
   使用數組tempArray合並有序的兩半a[first…mid]和a[mid_1…last]
}

  注意:算法沒有處理少於或等於一項的數組。

  下列偽代碼描述了歸並步驟

Algorithm merge(a, tempArray, first, mid, last)
// 合並相鄰的子數組a[first…mid]和a[mid+1…last]
beginHalf1 = first
endHalf1 = mid
beginHalf2 = mid+1
endHalf2 = last
// 當兩個子數組都不空時,讓一個子數組中的項與另一個子數組中的項進行比較
// 然后將較小的項復制到臨時數組中
index = 0 // tempArray中下一個可用的位置
while( (beginHalf1 <= endHalf1) 和 (beginHalf2 <= endHalf2) ){
   if(a[beginHalf1] <= a[beginHalf2]){
      tempArray[index] = a[beginHalf1]
      beginHalf1++
}
else{
   tempArray[index] = a[beginHalf2]
   beginHalf2++
}
index++
}
// 斷言:一個子數組已經全部復制到tempArray中
將另一個子數組中的剩余項復制到tempArray中
將tempArray中的項復制到數組a中

跟蹤算法中的步驟

  當對數組的兩半調用mergeSort時會發生什么。

 

1 遞歸調用的效果及歸並排序過程中的合並

  箭頭上的數字表示遞歸調用及合並的次序。第一次合並發生在4次遞歸調用mergeSort之后及其他遞歸調用mergeSort之前。所以遞歸調用mergeSort和merge是交織在一起的。真正的排序是發生在合並步驟而不是發生在遞歸調用步驟

  注:歸並排序在合並步驟中重排數組中的項。

實現說明

  注意應該只分配一次臨時數組。因為數組是實現細節,所以也許會冒險將空間分配隱藏在方法merge中。但是,因為每次遞歸調用mergeSort時都會調用merge,所以這個方法會導致分配臨時數組及初始化很多次。我們可以在下列共有mergeSort方法中分配一個臨時數組,然后將它傳給之前給出的偽代碼的私有mergeSort方法:

public static <T extends Comparable<? super T>> void mergeSort(T[] a, int first, int last) {
  // The cast is safe because the new array contains null entries
  @SuppressWarnings("unchecked")
  T[] tempArray = (T[])new Comparable<?>[a.length];  // Unchecked cast
  mergeSort(a, tempArray, first, last);
} // end mergeSort

  ? super T 表示T的任意父類。當我們分配Comparable對象的數組時,使用了通配符?來表示任意的對象。然后將數組轉型為類型T對象的數組。

 

9.1.3 歸並排序的效率

  一般地,如果n是2k,就會發生k層遞歸調用

  合並步驟,真正工作所在。在共有n項的兩個子段中,合並步驟最多進行n-1次比較。每次合並還需要向臨時數組的n次移動及移回原始數組的n次移動。總計,每次合並最多需要3n-1次操作

  每次調用mergeSort時需要調用merge一次。作為調用mergeSort的結果,合並操作最多需要3n-1次操作。這是O(n)的。兩次遞歸調用mergeSort導致兩次調用merge。每次調用最多用3n/2-1次操作合並n/2項。然后兩次合並最多需要3n-2次操作。它們是O(n)的。下一層遞歸調用22mergeSort,導致4次調用merge。每次調用merge最多3n/22-1次操作合並n/22項。四次合並,最多3n-22次操作,也是O(n)的。

  如果n是2k的,則遞歸調用mergeSort方法的K層,導致進行K層合並。每一層的合並都是O(n)的。因為k是log2n的,所以mergeSort是O(n log n)的。

  注意:合並步驟是O(n)的,不管數組的初始狀態如何。最壞、最優及平均情形下,歸並排序都是O(n log n)的。歸並排序的缺點是在合並階段需要一個臨時數組

另一種評估效率的方法

  遞推關系t(n)表示最壞情形mergeSort的時間需求,則兩個遞歸調用的每個需要時間t(n/2),合並步驟是O(n),所以有

t(n) = t(n/2) + t(n/2) + O(n) = 2 x t(n/2) + O(n)      當n > 1時

t(1) = 0

n猜想再證明

 

9.1.4 迭代歸並排序

  為了使用迭代替代遞歸,我們需要控制合並過程。這樣一個算法不管是時間還是空間,都比遞歸算法更高效,因為它消除了遞歸調用,所以去掉了活動記錄的棧。但迭代歸並排序更難寫出沒有錯誤的代碼。

  基本上,迭代歸並排序從數組頭開始,將一對對的單項合並為含兩項的子段。然后返回到數組頭,將一對對的兩項的子段合並為4項的子段,以此類推。但是,合並某個長度的所有子段對后,可能還剩余若干項。合並這些項時需要格外小心。(可以節省很多將臨時數組復制回原始數組所需的時間)

 

9.1.5 Java類庫中的歸並排序

  java.util包中的類Arrays定義了不同版本的幾個靜態方法sort,它們用來將數組按升序排序。對於對象數組,sort使用歸並排序。方法

public static void sort(Object[] a)

將對象數組a的全部內容進行排序,而方法

public static void sort(Object[] a, int first, int after)

a[first]到a[after-1]之間的對象進行排序。對於這兩個方法,數組中的對象必須定義了Comparable接口。

  如果數組左半段中的項都不大於右半段中的項,則這些方法中使用的歸並排序會跳過合並步驟。因為兩端都已經有序,所以這種情形下合並步驟不是必需的。

  注:穩定的排序

如果排序算法不改變相等對象的相對次序,則稱為穩定的(stable)。歸並排序是穩定的。

 

9.2 快速排序

  另一個使用分治策略的數組排序。快速排序(quick sort)將數組划分為兩部分,但與歸並排序不同,這兩部分不一定是數組的一半。相反,快速排序選擇數組中的一項(稱為樞軸(pivot))來重排數組項,滿足

1) 樞軸所處的位置就是在有序數組中的最終位置

2) 樞軸前的項都小於或等於樞軸

3) 樞軸后的項都大於或等於樞軸

這個排列稱為數組的划分(partition)

  創建划分將數組分為兩部分,稱為較小部分和較大部分,它們由樞軸分開。因為較小部分中的項小於或等於樞軸,而較大部分中的項大於或等於樞軸,所以樞軸位於有序數組中正確且最終的位置上。現在如果對兩個子段的較小部分和較大部分進行排序(當然是用快速排序)則原始數組將是有序的。下列算法描述了排序策略:

Algorithm quicksort(a, first, last)
// 遞歸地排序數組項a[first]到a[last]
if (first < last){
  選擇樞軸
   基於樞軸划分數組
   pivotIndex = 樞軸的下標
   quicksort(a, first, pivotIndex - 1)   // 排序較小值部分
   quicksort(a, pivotIndex + 1, last)  // 排序較大值的部分
}


9.2.1 快速排序的效率

  注意,創建划分(它完成了quickSort的大部分工作)在遞歸調用quickSort之前進行。這一點與歸並排序不同,它的大部分工作是在遞歸調用mergeSort之后的合並步驟完成的。划分過程需要不超過n次的比較,故與合並一樣,它將O(n)的任務

  當樞軸移動到數組的中心時是理想情形,這樣划分的兩個子數組有相同的大小。如果對quickSort的每次遞歸調用都划分了相等大小的子數組,則快速排序與歸並排序一樣遞歸調用數組的兩半。所以快速排序將是O(n log n)的,這是最優情形。

  最壞情形下,每次划分都有一個空子段,另一個調用必須排序n-1項。結果是n層遞歸調用而不是log n層。所以最壞情形下,快速排序是O(n2)的。

  樞軸的選擇將影響快速排序的效率如果數組已經有序或接近有序,有些選擇樞軸的機制可以導致最壞情形。實際上,出現接近有序數組的情形,可能會更頻繁。

快速排序在平均情形下是O(n log n)的,歸並排序總是O(n log n)的,而實際上,快速排序可能比歸並排序更快,且不需要歸並排序中合並操作所需的額外內存。

 

9.2.2 創建划分

  樞軸與最后一項交換。

等於樞軸的項

  注意,在較小部分和較大部分子數組中,都可能含有等於樞軸的項。為什么不總是將等於樞軸的項放到同一個子段中呢?這樣的策略能讓一個子段大於另一個。但是,為了提升快速排序的性能,讓子數組盡可能地等長。

  注意,從左至右的查找和從右至左的查找,在它們遇到等於樞軸的項時都會停止。這意味着,這樣的項不是放在原地,而是要進行交換。也意味着,這樣的項有機會放在任何一個子數組中。

樞軸的選擇

  理想地,樞軸應該是數組的中位值,所以較小部分和較大部分子數組都有相等(或接近相等)的項數。找到中位值的一種方法是排序數組,然后選擇位於中間的值,但數組排序是原始問題,所以這個思路不行。

  選擇樞軸需要不花太多時間,所以至少應該避開壞的樞軸。所以不是去找數組的中位值,而是找到數組中這3個項的中位值:第一項、中間項及最后一項。一個辦法是僅將這3個值進行排序,使用這3個值的中間值作為樞軸。這個選擇策略稱為三元中值樞軸選擇(median-of-three pivot selection)

  注:三元中值樞軸選擇避免了快速排序當給定數組已經有序或接近有序時的最壞情形性能。但理論上,不能避免其他情況下數組的最壞性能,這樣的性能在實際中不太可能出現

修改划分算法

  三元中值樞軸選擇說明,對划分機制要做小的修改。之前樞軸與數組最后一項交換,因為現在已知最后一項至少大於等於樞軸,所以最后一項不動,將樞軸與倒數第二項交換。所以,划分算法從下標last – 2處開始從右至左的查找。

  同樣,第一項直多等於樞軸,所以也不動,划分算法從下標first+1開始從左至右的查找。

  這個機制使得進行兩個查找的循環簡單了。從左至右的查找查看大於等於樞軸的項。這個查找將會停止,因為它至少會停在樞軸處,從右至左的查找查看小於或等於樞軸的項。這個查找將會停止,因為至少會停在第一項。所以循環不需要為阻止查找越出數組邊界而做什么特殊的事情。

  查找循環停止后,必須將樞軸放到較小部分和較大部分的中間。通過交換a[indexFromLeft]和a[last - 1]處的項可做到這一點。

  注:快速排序在划分過程中重排數組中的項。每次划分都將一個項(樞軸)放在其正確的有序位置。在兩個子數組中位於樞軸之前和之后的項仍留在各自的子數組中。

 

9.2.3 實現快速排序

樞軸的選擇

  可以通過私有方法,簡單的比較及交換完成第一項、中間項、最后一項的比較。

// Sorts the first, middle, and last entries of an array into ascending order.
private static <T extends Comparable<? super T>> void sortFirstMiddleLast(T[] a, int first, int mid, int last)

划分

  若數組小於三項,則已經排好,所以不需要划分或快排,所以下列划分算法假設數組至少有四項

Algorithm partition (a, first, last)
// 作為快速排序的一部分,划分將數組a[first...last]分為兩個子數組
// 分別稱為Smaller 和 Larger,它由一個項(樞軸),名為pivoValue,分隔開。
// Smaller 中的項  ≤ pivotValue, 且位於數組中pivotValue 值的前面
// Larger 中的項  ≥ pivotValue, 且位於數組中pivotValue 值的后面
// first >= 0; first < a.length; last - first >= 3; last < a.length
// 返回樞軸的下標
mid = 數組中間項的下標 sortFirstMiddleLast(a, first, mid, last) // 斷言:a[first] <= pivotValue 且 a[last] >= pivotValue, 所以數組的這兩項不與pivotValue進行比較 // 將 pivotValue移到數組中倒數第二個位置 交換 a[mid] 和a[last - 1] pivotIndex = last - 1 pivotValue = a[pivotIndex]
// 判斷兩個子數組: // Smaller = a[first...endSamller] 且 // Larger = a[endSmaller+1...last-1] // 這樣,Smaller 中的項都 <= pivotValue, 且 // Larger 中的項都 >= pivotValue // 初始時,這些子數組都是空的 indexFromLeft = first + 1 indexFromRight = last - 2 done = false while (!done) { // 從數組頭開始,留下 < pivotValue 的項, // 找到 >= pivotValue 的第一個項。一定能找到一個, // 因為最后一項 >= pivotValue while (a[indexFromLeft] < pivotValue) indexFromLeft++
// 從數組尾開始,留下 > pivotValue 的項, // 找到 <= pivotValue 的第一個項 // 一定能找到一個,因為第一個項 <= pivotValue while (a[indexFromRight] > pivotValue) indexFromRight--
// 斷言:a[indexFromLeft] >= pivotValue 且 // a[indexFromRight] <= pivotValue if (indexFromRight > indexFromLeft) {   交換 a[indexFromLeft] 和 a[indexFromRight]   indexFromLeft++   indexFromRight-- } else   done = true } // 將 pivotValue 放到子數組 Smaller 和 Larger 之間 交換 a[pivotIndex] 和 [indexFromLeft] pivotIndex = indexFromLeft // 斷言:Smaller = a[first...pivotIndex-1] // pivotValue = a[pivotIndex] // Larger = a[pivotIndex+1...last] return pivotIndex

快速排序方法

  即使是對大數組進行划分,最終也會導致遞歸調用時涉及僅有兩項的小數組。快速排序的代碼必須篩選出這些小數組,並使用其他方法來排序它們。插入排序是小數組的好選擇。實際上,對於含10項的數組,使用插入排序替代快速排序都是合理的。實現如下:

/**
 * Sorts an array into ascending order. Uses quick sort with
 * median-of-three pivot selection for arrays of at least
 * MIN_SIZE entries, and uses insertion sort for smaller arrays.
 */
public static <T extends Cpmaprble<? super T>> void quickSort(T[] a, int first, int last) {
  if (last - first + 1 < MIN_SIZE) {
    insertionSort(a, first, last)
  }
  else {
    // Create the partition: Smaller | pivot | Larger
    int pivotIndex = partition(a, first, last);
    // Sort subarrays Smaller and Larger
    quickSort(a, first, pivotIndex - 1);
    quickSort(a, pivotIndex + 1, last);
  } // end if
} // end quickSort

 

9.2.4 Java類庫中的快速排序 

  包Java.util中的類Array使用快速排序對基本類型的數組進行升序排序。方法

public static void sort(type[] a)

對整個數組a進行排序,而方法 

public static void sort(type[] a, int first, int after)

對從a[first] a[after-1]的項進行排序。注意,type可以是byte、char、double、float、int、long 或 short 類型。

 

9.3 基數排序

  到目前為止,這些排序算法對可比較的對象進行排序。基數排序radix sort)不使用比較,但為了能進行排序,它必須限制排序的數據。對於這些受限的數據,基數排序是O(n)的,故它快於之前的任何一種排序方法。但是,它不適合作為通用的排序算法,因為它將數組項看做有相同長度的字符串

  基數排序需要相同的長度,不足的前面補0(對於數字來說),先按照最后一位比較,再按照倒數第二位比較,以此類推,最后用第一位比較,過程如下:

  對10個數進行排序:123, 398, 210, 019, 528, 003, 513, 129, 220, 294

先按最右邊的數字分組,將123放入3號桶,210放入0號桶,放完再移回數組,再按照中間數組分桶。

 

 

9.3.1 基數排序的偽代碼

  下列算法描述了對正的十進制整數數組的基數排序。從0開始對每個整數從右到左標記各位。所以,個位數字是數字0,十位數字是數字1,以此類推。

Algorithm radixSort(a, first, last, maxDigits)
// 升序排序十進制正整數數組a[first…last];
// maxDigits 是最長整數的數字位數
for (i = 0  to maxDigits - 1){
   清空 bucket[0], bucket[1],…, bucket[9]
   for (index = first to last){
      digit = digit I of a[index]
      將 a[index] 放到 bucket[digit] 的最后
}
將 bucket[0], bucket[1], …, bucket[9] 放回數組a 中
}

算法用到了桶的數組。沒有指定桶的特性。

 

9.3.2 基數排序的效率

  如果數組含有n個整數,則前一個算法中的內層循環迭代n次。如果每個整數含有d位,則外層循環迭代d次。所以基數排序是O(d x n)的。表達式中的d說明,基數排序的實際運行時間依賴於整數的大小。但在計算機中,一般的整數最大不超過10位十進制數,或32個二進制位。d固定且遠小於n時,基數排序僅僅是O(n)的算法

  注:雖然基數排序對某些數據是O(n)的算法,但它不適用於所有數據。

 

9.4 算法比較

  雖然基數排序是最快的,但它並不總能使用。一般來說歸並排序和快速排序比其他算法快。如果數組含有相對較少的項,或者如果接近有序,則插入排序是好的選擇。另外,一般來講快速排序是可取的。注意,當數據集合(collection)太大,不能全部放到內存而必須使用外部文件時,可以使用歸並排序。另一個排序——堆排序,也是O(n log n)的,但快排更可取。

 

平均情形

最優情形

最壞情形

基數排序

O(n)

O(n)

O(n)

歸並排序

O(n log n)

O(n log n)

O(n log n)

快速排序

O(n log n)

O(n log n)

O(n2)

希爾排序

O(n1.5)

O(n)

O(n2)或O(n1.5)

插入排序

O(n2)

O(n)

O(n2)

選擇排序

O(n2)

O(n2)

O(n2)

對比各增長速度

n

10

102

103

104

105

106

n log2 n

33

664

9966

132877

1660964

19931569

n1.5

32

103

31623

106

31622777

109

n2

102

104

106

108

1010

1012

 

小結

1) 歸並排序是分治算法,它將數組分半,遞歸地排序兩半,然后將它們合並為一個有序數組

2) 歸並排序是O(n log n)的。但是它需要用到額外的內存來完成合並過程

3) 快速排序是另一種分治算法,它由一項(樞軸)將數組划分為分開的兩個子數組。樞軸在其正確的有序位置上。一個子數組中的項小於或等於樞軸,而第二個子數組中的項則大於或等於樞軸。快速排序遞歸的對兩個子數組進行排序

4) 大多數情況下,快速排序是O(n log n)的。雖然最壞的情況下是O(n2)的,但通常選擇合適的樞軸可以避免這種情況

5) 即使歸並排序和快速排序都是O(n log n)的算法,但在實際中,快速排序通常更快,且不需要額外的內存

6) 基數排序將數組項看做有相同長度的字符串。初始時,基數排序根據字符串一端的字符(數字)將項分配到桶中,然后排序收集字符串,並根據下一個位置的字符或數字將它們再次分配到桶中。繼續這個過程,直到所有的字符位置都處理過為止

7) 基數排序不對數組項進行比較。雖然它是O(n)的,但它不能對所有類型的數據進行排序。所以它不能用作通用的排序算法。

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM