1. 程式人生 > >【算法】一個小白的算法筆記: 歸並排序算法的編碼和優化 (,,? ? ?,,)

【算法】一個小白的算法筆記: 歸並排序算法的編碼和優化 (,,? ? ?,,)

oid pub 大小 角色 bcd 存在 ffd return 實現

參考資料

《算法(第4版)》 — — Robert Sedgewick, Kevin Wayne

歸並排序的概念

歸並排序的實現我是這樣來描述的:先對少數幾個元素通過兩兩合並的方式進行排序,形成一個長度稍大一些的有序序列。然後在此基礎上,對兩個長度稍大一些的有序序列再進行兩兩合並,形成一個長度更大的有序序列,有序序列的的長度不斷增長,直到覆蓋整個數組的大小為止,歸並排序就完成了。

歸並排序的兩種實現方式:遞歸和循環

歸並排序有兩種實現方式: 基於遞歸的歸並排序和基於循環的歸並排序。(也叫自頂向下的歸並排序自底向上的歸並排序
這兩種歸並算法雖然實現方式不同,但還是有共同之處的: 1. 無論是基於遞歸還是循環的歸並排序, 它們調用的核心方法都是相同的:完成一趟合並的算法,即兩個已經有序的數組序列合並成一個更大的有序數組序列 前提是兩個原序列都是有序的!) 2. 從排序軌跡上看,合並序列的長度都是從小(一個元素)到大(整個數組)增長

單趟歸並算法

單趟排序的實現分析

下面我先介紹兩種不同歸並算法調用的公共方法, 即完成單趟歸並的算法。(兩個已經有序的數組序列合並成一個更大的有序數組序列) 在開始排序前創建有一個和原數組a長度相同的空的輔助數組aux 單趟歸並的過程如下:
1. 首先將原數組中的待排序序列拷貝進輔助數組的相同位置中,即將a[low...high]拷貝進aux[low...high]中 2. 輔助數組aux的任務有兩項:比較元素大小, 並在aux中逐個取得有序的元素放入原數組a中 (通過1使aux和a在low-high的位置是完全相同的!這是實現的基礎) 3. 因為aux[low...high]由兩段有序的序列:aux[low...mid]和aux[mid...high]組成, 這裏稱之為aux1和aux2,我們要做的就是從aux1和aux2的頭部元素開始,比較雙方元素的大小。較小的元素放入原數組a中(若a[0]已被占則放在a[1]...依次類推),並取得較小元素的下一個元素
, 和另一個序列中較大的元素比較
。因為前提是aux1和aux2都是有序的,所以通過這種方法我們能得到更長的有序序列
4. 如果aux的兩段序列中,其中一段中的所有元素都已"比較"完了, 取得另一段序列中剩下的元素,全部放入原數組a的剩余位置。 過程3和4的實現方法
  • 設置兩個遊標 i 和 j 用於“元素比較” (在aux中進行):變量,i 和 j,分別代表左遊標和右遊標,開始時分別指向aux[low]和aux[mid]
  • 設置遊標k用於確定在a中放置元素的位置(在a中進行),k在開始時候指向a[low]
  • 總體上來說i, j, k的趨勢都是向右移動的
過程3和4的圖示解說 圖A 技術分享圖片

技術分享圖片 結合上面的過程3, 比較 i 和 j 當前所指的aux中的元素的大小, 取得其中比較大的那個元素(例如上圖中的i),將其放入數組a中, 此時(在圖中假設情況下): i加1,左遊標右移。 同時k也加1, k遊標也向右移動 圖B 技術分享圖片

技術分享圖片 結合上面的過程4, 在 i 和 j 都向右移動的過程中, 在圖中假設情況下,因為j當前所指的元素(圖中位置)大於左半邊即a[low...mid]的所有元素,導致 i 不斷增加(右移)且越過了邊界(mid), 所以這時候就不需要比較了,只要把j當前所指位置到high的元素都搬到原數組中,填滿原數組中剩下的位置, 單趟歸並就完成了, 在這一段過程中 j 連續加1,右遊標連續右移。 同時k也連續加1, k遊標也連續右移, 直到 j == high且k == high為止 基於上面的表述, 總結出單趟歸並算法中最關鍵的4個條件判斷情形:
  1. 左半邊用盡(取右半邊的元素)
  2. 右半邊用盡(取左半邊的元素)
  3. 右半邊元素小於左半邊當前元素(取右半邊的元素)
  4. 右半邊元素大於等於左半邊當前元素(取左半邊的元素)

單趟排序算法的代碼

有了上面的解釋,寫這個算法就不難了吧
/**
   * @description: 完成一趟合並
   * @param a 輸入數組
   * @param low,mid,high a[low...high] 是待排序序列, 其中a[low...mid]和 a[mid+1...high]已有序
   */
  private static void merge (int a [],int low,int mid,int high) {
    for(int k=low;k<=high;k++){
      aux[k] = a[k]; // 將待排序序列a[low...high]拷貝到輔助數組的相同位置
    }
    int i = low;    // 遊標i,開始時指向待排序序列中左半邊的頭元素
    int j = mid+1;  // 遊標j,開始時指向待排序序列中右半邊的頭元素
    for(int k=low;k<=high;k++){
      if(i>mid){
        a[k] = aux[j++]; // 左半邊用盡
      }else if(j>high){
        a[k] = aux[i++]; // 右半邊用盡
      }else if(aux[j]<aux[i]){
        a[k] = aux[j++]; // 右半邊當前元素小於左半邊當前元素, 取右半邊元素
      }else {
        a[k] = aux[i++]; // 右半邊當前元素大於等於左半邊當前元素,取左半邊元素
      }
    }
  }
}
 

【註意】在排序之初創建了一個長度和原數組a相同的輔助數組aux,這部分代碼上文未給出

單趟排序的過程圖解

為了更詳細的描述單趟排序的過程,下面在上面的圖A和圖B的基礎上給出每一步的圖解: 我們要排序的序列是 2 4 5 9 1 3 6 7, 合並的前提是2 4 5 9 和 1 3 6 7都是有序的 先比較aux中2和1的大小,因為2>1,所以將1放入a[0]。這時, 遊標 i 不動, 遊標 j 右移, 遊標 k 右移 技術分享圖片 技術分享圖片

比較aux中2和3的大小,因為2<3,所以將2放入a[1]。這時, 遊標 j 不動, 遊標 i 右移, 遊標 k 右移 技術分享圖片 技術分享圖片

比較aux中4和3的大小,因為3<4,所以將3放入a[2]。這時, 遊標 i 不動, 遊標 j 右移, 遊標 k 右移 技術分享圖片 技術分享圖片

類似以上, 不解釋 技術分享圖片 技術分享圖片

類似以上, 不解釋 技術分享圖片 技術分享圖片

類似以上, 不解釋 技術分享圖片 技術分享圖片

類似以上, 不解釋 技術分享圖片

技術分享圖片 註意, 這這裏 j 增加導致 j> high, 現在的情形是“右半邊用盡”, 所以將aux左半邊剩余的元素9放入a剩下的部分a[7]中, 單趟排序完成 技術分享圖片

技術分享圖片 【註意】 上面這個例子中的序列只是數組的一部分, 並不一定是整個數組 我在上面介紹過,兩種不同歸並算法: 基於遞歸的歸並和基於循環的歸並, 都是以單趟歸並的算法為基礎的。 下面先來講一下基於遞歸的歸並排序(自頂向下的歸並排序)

基於遞歸的歸並排序(自頂向下)

基於遞歸的歸並排序又叫做自頂向下的歸並排序

遞歸歸並的思想

技術分享圖片 技術分享圖片

最關鍵的是sort(int a [], int low,int high)方法裏面的三行代碼:
sort(a,low,mid); 
sort(a,mid+1,high);
merge(a,low,mid,high);

分別表示對左半邊序列遞歸、對右半邊序列遞歸、單趟合並操作。 全部代碼:
/**
* @Author: HuWan Peng
* @Date Created in 9:44 2017/11/29
*/
public class MergeSort {
  private static int aux [];
  /**
   * @description: 1. 初始化輔助數組aux,使其長度和原數組相同
   *               2. 包裝sort,向外只暴露一個數組參數
   */
  public static void sort(int a []){
    aux = new int[a.length];
    sort(a,0,a.length-1);
  }
  /**
   * @description: 基於遞歸的歸並排序算法
   */
  private static void sort (int a [], int low,int high) {
    if(low>=high) { return; } // 終止遞歸的條件
    int mid =  low + (high - low)/2;  // 取得序列中間的元素
    sort(a,low,mid);  // 對左半邊遞歸
    sort(a,mid+1,high);  // 對右半邊遞歸
    merge(a,low,mid,high);  // 單趟合並
  }
 
  /**
   * @description:  單趟合並算法
   * @param a 輸入數組
   * @param low,mid,high a[low...high] 是待排序序列,其中a[low...mid]和 a[mid+1...high]已有序
   */
  private static void merge (int a [],int low,int mid,int high) {
    int i = low;    // 遊標i,開始時指向待排序序列中左半邊的頭元素
    int j = mid+1;  // 遊標j,開始時指向待排序序列中右半邊的頭元素
    for(int k=low;k<=high;k++){
      aux[k] = a[k]; // 將待排序序列a[low...high]拷貝到輔助數組的相同位置
    }
    for(int k=low;k<=high;k++){
      if(i>mid){
        a[k] = aux[j++]; // 左半邊用盡
      }else if(j>high){
        a[k] = aux[i++]; // 右半邊用盡
      }else if(aux[j]<aux[i]){
        a[k] = aux[j++]; // 右半邊當前元素小於左半邊當前元素, 取右半邊元素
      }else {
        a[k] = aux[i++]; // 右半邊當前元素大於等於左半邊當前元素,取左半邊元素
      }
    }
  }
}

測試代碼:
public class Test {
  public static void main (String args[]){
    int [] a = {1,6,3,2,9,7,8,1,5,0};
    MergeSort.sort(a);
    for(int i=0;i<a.length;i++){
      System.out.println(a[i]);
    }
  }
}

輸出結果
0
1
1
2
3
5
6
7
9

遞歸棧深度和調用順序

遞歸導致的結果是,形成了一系列有層次、有先後調用順序merge, 如下圖左邊的寫入編號的merge列表 從上到下,是各個merge的先後調用順序,1最先調用, 15最後調用 從右到左, 遞歸棧由深到淺,例如 1,2,4,5的遞歸深度是相同的, 而3比它們淺一個層次 技術分享圖片

技術分享圖片 (這裏是按照字母排序, A最小, Z最大) 對上圖可根據代碼來理解
sort(a,low,mid);      // A
sort(a,mid+1,high);   // B
merge(a,low,mid,high);// C

首先,在第一層遞歸的時候,先進入的是第一行的sort方法裏(A處),然後緊接著又進入了第二層遞歸的第一行sort方法(A處), 如此繼續,由(a, low,mid)的參數列表可知其遞歸的趨勢是一直向左移動的,直到最後一層遞歸,所以最先執行merge的對象是a[0]和a[1](上圖編號1),再然後執行的是最後一層遞歸的第二行代碼(B處),這時候merge的對象是a[2]和a[3](上圖編號2)。 再然後, 返回上一層遞歸,對已經有序的a[0]、a[1]和a[2]、a[3]進行merge。(上圖編號3)如此繼續,遞歸的深度不斷變淺, 直到對整個數組的左右兩半進行merge。 (上圖編號3)

遞歸歸並的軌跡圖像

(下面展示的歸並進行了一些優化,對小數組使用插入排序)

技術分享圖片

技術分享圖片

根據上文所講的遞歸棧和調用順序, 下面的軌跡圖像就不難理解了: 從最左邊的元素開始合並,而且左邊的數組序列在第一輪合並後,相鄰右邊的數組按同樣的軌跡進行合並, 直到合並出和左邊相同長度的序列後,才和左邊合並(遞歸棧上升一層) 技術分享圖片 技術分享圖片

基於遞歸歸並排序的優化方法

優化點一:對小規模子數組使用插入排序

用不同的方法處理小規模問題能改進大多數遞歸算法的性能,因為遞歸會使小規模問題中方法調用太過頻繁,所以改進對它們的處理方法就能改進整個算法。 因為插入排序非常簡單, 因此一般來說在小數組上比歸並排序更快。 這種優化能使歸並排序的運行時間縮短10%到15%; 怎麽切換呢? 只要把作為停止遞歸條件的
  if(low>=high) { return; }

改成
    if(low + M>=high) { // 數組長度小於10的時候
      InsertSort.sort(int a [], int low,int high) // 切換到插入排序
      return;
    }

就可以了,這樣的話,這條語句就具有了兩個功能: 1. 在適當時候終止遞歸 2. 當數組長度小於M的時候(high-low <= M), 不進行歸並排序,而進行插排 具體代碼:
  private static void sort (int a [], int low,int high) {
    if(low + 10>=high) { // 數組長度小於10的時候
      InsertSort.sort(int a [], int low,int high) // 切換到插入排序
      return;
    } // 終止遞歸的條件
    int mid =  low + (high - low)/2;  // 取得序列中間的元素
    sort(a,low,mid);  // 對左半邊遞歸
    sort(a,mid+1,high);  // 對右半邊遞歸
    merge(a,low,mid,high);  // 單趟合並
  }

優化點二: 測試待排序序列中左右半邊是否已有序

通過測試待排序序列中左右半邊是否已經有序, 在有序的情況下避免合並方法的調用。 例如對單趟合並,我們對a[low...high]中的a[low...mid]和a[mid...high]進行合並 因為a[low...mid]和a[mid...high]本來就是有序的,存在a[low]<a[low+1]...<a[mid]和a[mid+1]<a[mid+2]...< a[high]這兩種關系, 如果判斷出a[mid]<=a[mid+1]的話, 不就可以保證從而a[low...high]本身就是不需要排序的有序序列了嗎?
  private static void sort (int a [], int low,int high) {
    if(low>=high) {
      return;
    } // 終止遞歸的條件
    int mid =  low + (high - low)/2;  // 取得序列中間的元素
    sort(a,low,mid);  // 對左半邊遞歸
    sort(a,mid+1,high);  // 對右半邊遞歸
    if(a[mid]<=a[mid+1]) return; // 避免不必要的歸並
    merge(a,low,mid,high);  // 單趟合並
  }

優化點三:去除原數組序列到輔助數組的拷貝

在上面介紹的基於遞歸的歸並排序的代碼中, 我們在每次調用merge方法時候,我們都把a對應的序列拷貝到輔助數組aux中來,即
    for(int k=low;k<=high;k++){
      aux[k] = a[k]; // 將待排序序列a[low...high]拷貝到輔助數組的相同位置
    }

實際上,我們可以通過一種看起來比較逆天的方式把這個拷貝過程給去除掉。。。。。 為了達到這一點,我們要在遞歸調用的每個層次交換輸入數組和輸出數組的角色,從而不斷地把輸入數組排序到輔助數組,再將數據從輔助數組排序到輸入數組。 臥槽?! 還有這麽騷的操作要怎麽搞? 請看:
  public static void sort(int a []){
    aux = a.clone(); // 拷貝一個和a所有元素相同的輔助數組
    sort(a,aux,0,a.length-1);
  }
  /**
   * @description: 基於遞歸的歸並排序算法
   */
  private static void sort (int a[], int aux[], int low,int high) {
    if(low>=high) { return; } // 終止遞歸的條件
    int mid =  low + (high - low)/2;  // 取得序列中間的元素
    sort(aux, a,low,mid);  // 對左半邊遞歸
    sort(aux, a,mid+1,high);  // 對右半邊遞歸
    merge(a, aux, low,mid,high);  // 單趟合並
  }

在這裏我們做了兩個操作:
  • 在排序前拷貝一個和原數組元素完全一樣的輔助數組(不再是創建一個空數組了!)
  • 在遞歸調用的每個層次交換輸入數組和輸出數組的角色
註意, 外部的sort方法和內部sort方法接收的a和aux參數剛好是相反的 技術分享圖片 技術分享圖片 這樣做的話, 我們就可以去除原數組序列到輔助數組的拷貝了! 但是你可能會問: 騷年, 我們要排序的可是原數組a啊! 你不怕一不小心最後完全排序的是輔助數組aux而不是原數組a嗎? Don‘t worry !! 這種情況不會發生, 看圖: 技術分享圖片 技術分享圖片

由圖示易知, 因為外部sort和merge的參數順序是相同的, 所以,無論遞歸過程中輔助數組和原數組的角色如何替換,對最後一次調用的merge而言(將整個數組左右半邊合為有序的操作), 最終被排為有序的都是原數組,而不是輔助數組! 全部代碼:
/**
* @Author: HuWan Peng
* @Date Created in 9:44 2017/11/29
*/
public class MergeSort {
  private static int aux [];
  /**
   * @description: 1. 初始化輔助數組aux,使其和原數組元素完全相同
   *               2. 包裝sort,向外只暴露一個數組參數
   */
  public static void sort(int a []){
    aux = a.clone(); // 拷貝一個和a所有元素相同的輔助數組
    sort(a,aux,0,a.length-1);
  }
  /**
   * @description: 基於遞歸的歸並排序算法
   */
 
  private static void sort (int a[], int aux[], int low,int high) {
    if(low>=high) { return; } // 終止遞歸的條件
    int mid =  low + (high - low)/2;  // 取得序列中間的元素
    sort(aux, a,low,mid);  // 對左半邊遞歸
    sort(aux, a,mid+1,high);  // 對右半邊遞歸
    merge(a, aux, low,mid,high);  // 單趟合並
  }
 
  /**
   * @description:  單趟合並算法
   * @param a 輸入數組
   * @param low,mid,high a[low...high] 是待排序序列,其中a[low...mid]和 a[mid+1...high]已有序
   */
  private static void merge (int a [],int aux [],int low,int mid,int high) {
    int i = low;    // 遊標i,開始時指向待排序序列中左半邊的頭元素
    int j = mid+1;  // 遊標j,開始時指向待排序序列中右半邊的頭元素
    // 這裏的for循環拷貝已經去除掉了
    for(int k=low;k<=high;k++){
      if(i>mid){
        a[k] = aux[j++]; // 左半邊用盡
      }else if(j>high){
        a[k] = aux[i++]; // 右半邊用盡
      }else if(aux[j]<aux[i]){
        a[k] = aux[j++]; // 右半邊當前元素小於左半邊當前元素, 取右半邊元素
      }else {
        a[k] = aux[i++]; // 右半邊當前元素大於等於左半邊當前元素,取左半邊元素
      }
    }
  }
}

測試代碼和輸出結果同上文。

基於循環的歸並排序(自底向上)

基於循環的歸並排序又叫做自底向上的歸並排序

循環歸並的基本思想

技術分享圖片 技術分享圖片

基於循環的代碼較為簡單,這裏就不多贅述了
/**
* @Author: HuWan Peng
* @Date Created in 23:42 2017/11/30
*/
public class MergeSort2 {
  private static int aux [];
 
  public static void sort(int a []){
    int N = a.length;
    aux = new int [N];
    for (int size =1; size<N;size = size+size){
      for(int low =0;low<N-size;low+=size+size) {
        merge(a,low,low+size-1,Math.min(low+size+size-1,N-1));
      }
    }
  }
 
  private static void merge (int a [],int low,int mid,int high) {
    int i = low;    // 遊標i,開始時指向待排序序列中左半邊的頭元素
    int j = mid+1;  // 遊標j,開始時指向待排序序列中右半邊的頭元素
    for(int k=low;k<=high;k++){
      aux[k] = a[k];
    }
    for(int k=low;k<=high;k++){
      if(i>mid){
        a[k] = aux[j++]; // 左半邊用盡
      }else if(j>high){
        a[k] = aux[i++]; // 右半邊用盡
      }else if(aux[j]<aux[i]){
        a[k] = aux[j++]; // 右半邊當前元素小於左半邊當前元素, 取右半邊元素
      }else {
        a[k] = aux[i++]; // 右半邊當前元素大於等於左半邊當前元素,取左半邊元素
      }
    }
  }
}

循環歸並的軌跡圖像

(下圖中的sz同上面的變量size) 技術分享圖片 技術分享圖片

技術分享圖片

【算法】一個小白的算法筆記: 歸並排序算法的編碼和優化 (,,? ? ?,,)