排序算法


  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=n-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)

請嘗試用三者取中法完成快速排序,並編寫程序與取第一個元素為樞紐的快速排序方法進行比較測試。然后仔細研究快排還可以做哪些改進!

桶排序

基數排序

外部排序


免責聲明!

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



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