2007年,當總統候選人Baeack Obama訪問Google公司時,Google的CEO Eric Schmidt問了Obama一個問題,對100萬32位整數排序的最有效的方式是什么。Obama回答冒泡算法將不是好的選擇。他的回答正確嗎?我們先來考察各種排序算法,然后看看他是否正確。
一、插入排序
插入排序重復地將新的元素插入到一個排序好的子線性表中,直到整個線性表排好序。
插入排序( Insertion sort)是一種簡單直觀且穩定的排序算法。
插入排序的工作方式非常像人們排序一手撲克牌一樣。開始時,我們的左手為空並且桌子上的牌面朝下。然后,我們每次從桌子上拿走一張牌並將它插入左手中正確的位置。為了找到一張牌的正確位置,我們從右到左將它與已在手中的每張牌進行比較,如下圖所示
下圖描述如何用插入排序法對線性表{49 38 65 97 76 13 27}進行排序。
i=1:最開始,排好序的子線性表只包含線性表中第一個元素49。把未排序的第一個元素38插入到該子線性表中
i=2:38比49小,49往后挪一個位置,將38插入到49前面,排好序的子線性表為{38,49},把未排序的第一個元素65插入到子線性表中
i=3:排好序的子線性表為{38,49,65},將未排序的第一個元素97插入到子線性表中
i=4:排好序的子線性表為{38,49,65,97},將未排序的第一個元素76插入到子線性表中
i=5:排好序的子線性表為{38,49,65,76,97},將未排序的第一個元素13插入到子線性表中
i=6:排好序的子線性表為{13,38,49,65,76,97},將未排序的第一個元素27插入到子線性表中
i=7:現在整個線性表已經排好序了
這樣,有序子線性表逐漸擴大,未排序的逐漸減少,直到整個線性表已經排好序,我們關心的是你怎么采取直接插入排序就將數據給插入進去了呢,所以我們對最后一個元素27具體是怎么一步步實現插入的進行詳細的說明。
首先:27比97要小,我們要插入到這個有序序列中,97就需要向后移動一個位置,向后移動一個位置就會把27給覆蓋掉,所以我們需要把27進行暫存(currentElement,我們稱為哨兵或者崗哨),然后我把有序序列最后一個元素和哨兵比,如果這個元素比崗哨小,很顯然,崗哨就要在它后面,直接寫回去就可以了,但是現在27是比97小的,所以j指針所指向的元素向后移動一個位置,接着j指針往前移,直到遇到比它小的元素(如果沒有比崗哨小的元素,說明崗哨就在第一個位置)。
step1:將27保存到一個臨時變量currentElement
step2:將list[5]移到list[6]
step3:將list[4]移到list[5]
step4:將list[3]移到list[4]
step5:將list[2]移到list[3]
step6:將list[1]移到list[2]
step7:將currentElement賦值給list[1]
這個算法可以描述如下:
for(int i = 1; i < list.length;;i++){
將list[i]插入已排好序的子線性表中,這樣list[0...i]也是排好序的
}
為了將list[i]插入list[0...i],需要將list[i]存儲在一個名為currentElement的臨時變量中。如果list[i-1]>currentElement,就將list[i-1]移到list[i];如果list[i-2]>currentElement,就將list[i-2]移到list[i-1],依次類推,直到list[i-k]<=currentElement或者k>i(傳遞的是排好序的的數列的第一個元素)。將currentElement賦值給list[i-k+1].
算法可以擴展和執行:
public static void insertioinSort(int[] list) { for(int i = 1; i < list.length; i++) { int currentElement = list[i];//未排序的第一個元素,稱為哨兵 int j; for(j = i - 1; j >= 0 && currentElement < list[j]; j--) list[j + 1] = list[j]; list[j + 1] = currentElement; } }
算法評價:
插入排序重復地將新的元素插入到一個排序好的子線性表中,直到整個線性表排好序。在第k次次迭代中,為了將一個元素插入到一個大小為k的數組中,將進行k次比較來找到插入的位置,還要進行k次的移動來插入元素。使用T(n)表示插入排序的復雜度,c表示諸如每次迭代中的賦值和額外的比較的操作總數,則
T(n) = (2+c) + (2*2+c)+...+(2*(n-1) +c)
=2(1+2+...n-1)+c(n-1)
=2[(n-1)n/2]+cn=n2 -n+cn-c
=O(n2)
因此,插入排序的時間復雜度為:
思考
簡單插入排序的本質?
比較和交換
序列中逆序的個數 決定交換次數。
平均逆序數量為C(n,2) / 2 ,所以T(n)=O(n2)
簡單插入排序復雜度由什么決定?逆序個數
如何改進簡單插入排序復雜度?
• 分組,比如C(n,2)/2 > 2C((n/2),2)/2
• 3,2,1有3組逆序對(3,1)(3,2)(2,1)需要交換3次。但相隔較遠的 3,1交換一次后1,2,3就沒有逆序對了。
• 基本有序的插入排序算法復雜度接近O(n)
二、希爾排序(縮小增量法)
希爾排序是簡單插入排序算法的一種更高效的改進版本。
基本思想:分割成若干個較小的子文件,對各個子文件分 別進行直接插入排序,當文件達到基本有序時,再對整個 文件進行一次直接插入排序。
對待排記錄序列先作“宏觀”調整,再作“微觀”調整。
“宏觀”調整,指的是,“跳躍式”的插入排序。(前面學習插入排序的時候,我們會發現一個很不友好的事兒,如果已排序的分組元素為{2,5,7,9,10} ,未排序的分組元素為{1,8} ,那么下一個待插入元素為1 , 我們需要着1從后往前,依次和10,9,7,5,2進行交換位置,才能完成真正的插入,每次交換只能和相鄰的元素交換位置。那如果我們要提高效率,直觀的想法就是一次交換 ,能把1放到更前面的位置,比如一次交換就能把1插到2和5之間,這樣一次交換1就向前走了5個位置,可以減少交換的次數,這樣的需求如何實現呢?接下來我們來看看希爾排序的原理)。
排序原理:
1.選定一個增長量d ,按照增長量d作為數據分組的依據,對數據進行分組;
2.對分好組的每一組數據完成插入排序;
3.減小增長量,最小減為1 , 重復第二步操作。
算法示例:
public static void shellSort(int[] list) {
//第一步:確定希爾增量,采用Hibbard’s增量序列(1,3,7.... 2^k -1) int ht = 1; //根據數組的長度確定增量值 while(ht < list.length / 2)ht = ht * 2 + 1; //排序 while(ht >= 1) { //完成每組的插入排序(1.找到待插入的元素,即為ht對應的元素) for(int i = ht;i < list.length; i++) {
int currentElement = list[i]; int j; //將元素插入到有序序列中 for( j = i - ht; j >= 0 && currentElement < list[j];j -= ht) { list[j + ht] = list[j]; } list[j + ht] = currentElement; } //縮減希爾增量 ht /= 2; } }
希爾排序特點
- 子序列的構成不是簡單的“逐段分割”,而是將相隔某個增量的記錄組成 一個子序列
- 希爾排序可提高排序速度,因為
- 分組后n值減小,n²更小,而T(n)=O(n²),所以T(n)從總體上看是減小了
- 關鍵字較小的記錄跳躍式前移,在進行最后一趟增量為1的插入排序時, 序列已基本有序
- 增量序列取法
- 無除1以外的公因子
- 最后一個增量值必須為1
希爾排序的復雜度和增量序列是相關的
【定理】使用希爾增量的最壞時間復雜度為 O( N2 ).
〖Example〗A bad case:
Hibbard’s 增量序列: hk = 2 k - 1 ---- 持續增量沒有公共因子.
使用Hibbard’s 增量的最壞時間復雜度 O ( N3/2 ).
Conjectures: Tavg – Hibbard ( N ) = O ( N5/4 ).
Sedgewick’s best sequence is {1, 5, 19, 41, 109, … } in which the terms are either of the form 9 * 4 i – 9 * 2 i + 1 or 4 i – 3 * 2 i + 1.
Tavg ( N ) = O ( N7/6 ) and Tworst ( N ) = O ( N4/3 ).
希爾排序算法本身很簡單,但復 雜度分析很復雜. 他適合於中等 數據量大小的排序(成千上萬的 數據量).
三、簡單選擇排序
基本思想:從無序子序列中“選擇”關鍵字最小或最大的記錄,並將它加入到有序子序列中,以此方法增加記錄的有序子序列的長度。
假設排序過程中,待排記錄序列的狀態為:
排序過程
- 首先通過n-1次關鍵字比較,從n個記錄中找出關鍵字最小的記錄,將它與 第一個記錄交換
- 再通過n-2次比較,從剩余的n-1個記錄中找出關鍵字次小的記錄,將它與 第二個記錄交換
- 重復上述操作,共進行n-1趟排序后,排序結束
算法描述
public static void selectionSort(int[] list) { for(int i = 0; i < list.length - 1; i++) { int currentMin = list[i]; int currentIndex = i; for(int j = i + 1; j < list.length; j++) { if(currentMin > list[j]) {//選擇最小的元素 currentMin = list[j]; currentIndex = j;// } } if(currentIndex != i) { list[currentIndex] = list[i]; list[i] = currentMin; } } }
簡單選擇排序性能分析
對 n 個記錄進行簡單選擇排序,所需進行的 關鍵字間的比較次數為:
移動記錄的次數,最小值為 0, 最大值為3(n-1)
T(n)=O(n2)
穩定性分析
初始數據:3,3,1,4
第一趟排序: 1,3,3,4
1,3,3,4
1,3,3,4
所以選擇排序是不穩定排序
四、冒泡排序
冒泡排序算法多層遍歷數組,在每次遍歷中連續比較相鄰的元素,如果元素沒有按照順序排列,則互換他們的值。由於較小的值像“氣泡”一樣逐漸符向頂部,而較大的值沉向底部,所以稱這種技術為冒泡排序(bubble sort)或下沉排序(sinking sort)。第一次遍歷后,最后一個元素稱為數組中的最大值。在第二次遍歷后,倒數第二個元素成為數組中的第二大數。整個過程持續到所有元素都排好序。
算法描述
注意到如果在某次遍歷中沒有發生交換,那么就不必進行下一次遍歷,因為所有的元素都已經排好序了。使用該特征可以改變上述算法:
public static void bubbleSort(int[] list) { boolean needNextPass = true; for(int i = 1; i < list.length && needNextPass; i++) {//如果在某次迭代中沒有元素發生交換,不需要進行下一次的比較了 needNextPass = false; for(int j = 0; j < list.length - i; j++) { if(list[j] > list[j + 1]) { int temp = list[j]; list[j] = list[j + 1]; list[j + 1] = temp; needNextPass = true; } } } }
算法分析
在最佳情況下,冒牌排序算法只需要一次遍歷就能確定數組已排好序,不需要下一次遍歷。由於第一次遍歷的比較次數為n-1,因此在最佳情況下,冒泡排序的時間為O(n).在最差情況下,冒泡排序需要進行n-1次遍歷。在第一次遍歷需要n-1次比較;第二次遍歷需要n-2次比較;依次進行,最后一次遍歷需要1次比較。因此,總的比較次數為:,因此,在最差情況下,冒泡排序的時間復雜度為O(n2)。
五、歸並排序
歸並排序將數組分為兩半,對每部分遞歸地應用歸並排序,在兩部分都排好序后,對它們進行合並
算法描述
public static void mergeSort(int[] list) { if(list.length > 1) { //將數組拆分成兩個子數組 int[] firstList = new int[list.length / 2]; System.arraycopy(list, 0, firstList, 0, list.length / 2); mergeSort(firstList); int secondSize = list.length - list.length / 2; int[] secondList = new int[secondSize]; System.arraycopy(list, list.length / 2, secondList, 0, secondSize); mergeSort(secondList); //合並兩個子數組 merge(firstList, secondList, list); } } private static void merge(int[] list1,int[] list2,int[] temp) { int index1 = 0; int index2 = 0; int index3 = 0; while(index1 < list1.length && index2 < list2.length) { if(list1[index1] < list2[index2]) { temp[index3++] = list1[index1++]; }else { temp[index3++] = list2[index2++]; } } while(index1 < list1.length) {//如果list1中還有沒有移動到臨時數組中的元素 temp[index3++] = list1[index1++]; } while(index2 < list2.length) {//如果list21中還有沒有移動到臨時數組中的元素 temp[index3++] = list2[index2++]; } }
下圖演示了歸並排序。
遞歸調用持續將數組划分為子數組,直到每個子數組只包含一個元素。然后,該算法將這些小的子數組歸並為稍大的子數組,直到最后形成一個有序的數組。
算法實現
合並兩個有序數組
current1和current2指向list1和list2中要考慮的當前元素,重復的比較list1和list2中當前元素。並將較小的元素移動到temp中,如果較小元素在list1中,current1和current3增加1,如果較小元素在list2中,current2和current3增加1。最后,其中一個數組中的所有元素都都被移動到temp中。如果list1中還有未移動的元素,就將它們復制到temp中,同理如果list2中還有未移動的元素,就將它們復制到temp中。如下圖所示:
算法評價
時間復雜度:每一趟歸並的時間復雜度為O(n), 總共需要歸並 log2n趟,因而,總的時間復雜度為O(nlog2n)。
空間復雜度:歸並排序過程中,需要一個與表等長的存儲單 元數組空間,因此,空間復雜度為O(n)。
歸並排序並行版
歸並排序可以使用並行處理高效執行。JDK7引入了新的Fork/Join框架用於並行編程,從而利用多核處理器。它可以實現線程池中任務的自動調度,並且這種調度對用戶來說是透明的。為了達到這種效果,必須按照用戶指定的方式對任務進行分解,然后再將分解出的小型任務的執行結果合並成原來任務的執行結果。這顯然是運用了分治法(divide-and-conquer)的思想。
import java.util.Arrays; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.RecursiveAction;
/**
* Fork/Join框架高效地自動執行和協調所有任務
*/ public class ParallelMergeSort { public static final int SIZE = 5_000_0000; public static void main(String[] args) { int[] list1 = new int[SIZE]; int[] list2 = new int[SIZE]; for(var i = 0; i < SIZE; i++) { list1[i] = list2[i] = (int)(Math.random() * 1000000000); } var startTime = System.currentTimeMillis(); SelectionSort.mergeSort(list1); System.out.println("歸並排序所花時間:" + (System.currentTimeMillis() - startTime)); startTime = System.currentTimeMillis(); parallelMergeSort(list2); System.out.println("並行歸並排序所花時間:" + (System.currentTimeMillis() - startTime)); } private static void parallelMergeSort(int[] list) { var action = new SortTask(list);
//巨量的子任務可以在池中創建和執行 var pool = new ForkJoinPool();
//主任務執行完后將返回 pool.invoke(action); } private static class SortTask extends RecursiveAction{ private static final long serialVersionUID = 1L; private int[] list; public SortTask(int[] list) { this.list = list; } @Override protected void compute() { if(list.length < 500) {//當問題分解到可求解程度時直接計算結果 Arrays.sort(list); }else { int[] firstHalf = new int[list.length / 2]; System.arraycopy(list, 0, firstHalf, 0, firstHalf.length); int[] secondHalf = new int[list.length - list.length / 2]; System.arraycopy(list, list.length / 2, secondHalf, 0, secondHalf.length);
//執行主任務時,任務分為子任務,通過使用invokeAll調用子任務,該方法在所有子任務都完成后將返回(每個子任務又遞歸地分為更加小的任務) invokeAll(new SortTask(firstHalf),new SortTask(secondHalf)); SelectionSort.merge(firstHalf, secondHalf, list); } } } }
快速排序
實際使用中已知最快的算法,快速排序工作機制如下,該算法在數組中選擇一個稱為主元(pivot)的元素,將數組分為兩部分,使得第一部分中的所有元素都小於或等於主元,而第二部分中的所有元素都大於主元。對第一部分遞歸地應用快速排序算法,然后對第二部分遞歸地應用快速排序算法。
算法描述
public static void quickSort(int[] list) { if (1ist.1ength > 1) { select a pivot; partition list into list1 and list2 such that all elements in list1 <= pivot and all elements in 1ist2 > pivot; quickSort(list1); quickSort(1ist2); } }
主元
該算法的每次划分都將主元放在了恰當的位置。主元的選擇會影響算法的性能。在理想情況下,應該選擇能平均划分兩部分的主元。
- 錯誤的方法: pivot= list[0 ]
最糟糕的情況: list[ ]有序或者逆序 quicksort= O( N2)
- 安全的方法: pivot = random select from list[ ]
隨機數生成很花時間(expensive)
- 3者取中法:
pivot = median ( left, center, right )
這種方法能夠排除序列有序的樞紐是最小或者最大值情況,實際運行時間能夠減少約5%.
為了簡單起見,假定將數組中的第一個元素作為主元進行說明
排序過程:
- 對list[first.....last]中記錄進行一趟快速排序 ,附設兩個指針low和high,設划分元記錄pivot=list[first]
- 初始時令low=first+1,high=last
- 首先從high所指位置向前搜索第一個小於pivot的記錄,並和pivot交換(主元頻繁參與交換時很不划算的,思考如何改進)
- 再從low所指位置起向后搜索,找到第一 個大於pivot的記錄,和pivot交換
- 重復上述兩步,直至low==high為止
-
再分別對兩個子序列進行快速排序,直到每個子序列只含有一個記錄為止
算法實現
/** * 快速排序 * */ public class QuickSort { public static void quickSort(int[] list) { quickSort(list,0,list.length - 1); } public static void quickSort(int[] list,int first,int last) { if(last > first) { //划分左右子數組,返回主元的索引 int pivotIndex = patition(list,first,last); quickSort(list,first,pivotIndex - 1); quickSort(list,pivotIndex + 1,last); } } //partition list into list1 and list2 private static int patition(int[] list, int first, int last) { //選擇主元元素pivot,為了便於理解,選擇數組中的第一個元素作為主元元素 int pivot = list[first]; int low = first + 1; int high = last; while(high > low) { //從左側查找第一個大於主元的元素 while(low <= high && list[low] <= pivot) low++; //從右側查找第一個小於主元的元素 while(low <= high && list[high] > pivot) high--; //交換着兩個元素 if(high > low) { int temp = list[high]; list[high] = list[low]; list[low] = temp; } } while(high > first && list[high] >= pivot) high--; if(pivot > list[high]) {//判定是否交換主元 list[first] = list[high]; list[high] = pivot; return high; } return first; } }
算法評價
- 快速排序算法是不穩定的
對待排序序列 49 49' 38 65, 快速排序結果為: 38 49' 49 65
- 快速排序的性能跟初始序列中關鍵字的排列和選取的樞紐有關
- 當初始序列按關鍵字有序(正序或逆序)時,性能最差,蛻化為冒泡 排序,時間復雜度為O(n2 )
- 常用“三者取中”法來選取划分記錄,即取首記錄list[first].尾記錄 list[last]和中間記錄list[(first + last) / 2]三者的中間值為划分記錄。
- 快速排序算法的平均時間復雜度為O(nlogn)
請嘗試用三者取中法完成快速排序,並編寫程序與取第一個元素為樞紐的快速排序方法進行比較測試。然后仔細研究快排還可以做哪些改進!
桶排序
基數排序
外部排序