【算法】归并排序算法的编码和优化

简介: 参考资料 《算法(第4版)》          — — Robert Sedgewick, Kevin Wayne   归并排序的概念 归并排序的实现我是这样来描述的:先对少数几个元素通过两两合并的方式进行排序,形成一个长度稍大一些的有序序列。

参考资料

《算法(第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)
 
 
 

 

 

 

 
其实啊,我只是把你们喝咖啡的时间,都用来喝啤酒而已
目录
相关文章
|
6天前
|
算法 BI Serverless
基于鱼群算法的散热片形状优化matlab仿真
本研究利用浴盆曲线模拟空隙外形,并通过鱼群算法(FSA)优化浴盆曲线参数,以获得最佳孔隙度值及对应的R值。FSA通过模拟鱼群的聚群、避障和觅食行为,实现高效全局搜索。具体步骤包括初始化鱼群、计算适应度值、更新位置及判断终止条件。最终确定散热片的最佳形状参数。仿真结果显示该方法能显著提高优化效率。相关代码使用MATLAB 2022a实现。
|
9天前
|
机器学习/深度学习 数据采集 存储
一文读懂蒙特卡洛算法:从概率模拟到机器学习模型优化的全方位解析
蒙特卡洛方法起源于1945年科学家斯坦尼斯劳·乌拉姆对纸牌游戏中概率问题的思考,与约翰·冯·诺依曼共同奠定了该方法的理论基础。该方法通过模拟大量随机场景来近似复杂问题的解,因命名灵感源自蒙特卡洛赌场。如今,蒙特卡洛方法广泛应用于机器学习领域,尤其在超参数调优、贝叶斯滤波等方面表现出色。通过随机采样超参数空间,蒙特卡洛方法能够高效地找到优质组合,适用于处理高维度、非线性问题。本文通过实例展示了蒙特卡洛方法在估算圆周率π和优化机器学习模型中的应用,并对比了其与网格搜索方法的性能。
81 1
|
22天前
|
算法 搜索推荐 Java
算法实战:手写归并排序,让复杂排序变简单!
归并排序是一种基于“分治法”的经典算法,通过递归分割和合并数组,实现O(n log n)的高效排序。本文将通过Java手写代码,详细讲解归并排序的原理及实现,帮助你快速掌握这一实用算法。
34 0
|
24天前
|
算法
基于GA-PSO遗传粒子群混合优化算法的CVRP问题求解matlab仿真
本文介绍了一种基于GA-PSO混合优化算法求解带容量限制的车辆路径问题(CVRP)的方法。在MATLAB2022a环境下运行,通过遗传算法的全局搜索与粒子群算法的局部优化能力互补,高效寻找最优解。程序采用自然数编码策略,通过选择、交叉、变异操作及粒子速度和位置更新,不断迭代直至满足终止条件,旨在最小化总行驶距离的同时满足客户需求和车辆载重限制。
|
3天前
|
算法 Python
群智能算法:灰狼优化算法(GWO)的详细解读
在优化问题中,寻找最优解是核心目标。灰狼优化算法(GWO)受到自然界灰狼狩猎行为和社会等级结构的启发,通过模拟Alpha(头狼)、Beta(助手狼)、Delta(支配狼)和Omega(普通狼)的角色,高效搜索最优解。本文详细解析GWO的原理与步骤,并提供Python代码实现,帮助读者理解并应用这一算法。
|
3天前
|
算法 Python
群智能算法:【WOA】鲸鱼优化算法详细解读
本文详细解读了鲸鱼优化算法(WOA),这是一种受鲸鱼捕食行为启发的新兴群体智能优化算法,具有强大的全局搜索能力和快速收敛速度。文章分为五个部分,分别介绍了引言、算法原理、主要步骤、特点及Python代码实现。通过模拟鲸鱼的捕食行为,该算法能够在复杂的优化问题中找到全局最优解。
|
24天前
|
算法 语音技术
支付宝商业化广告算法问题之在ODL模型优化过程中,采取什么策略来提高模型的泛化能力呢
支付宝商业化广告算法问题之在ODL模型优化过程中,采取什么策略来提高模型的泛化能力呢
|
14天前
|
数据采集 算法
基于PSO粒子群算法的三角形采集堆轨道优化matlab仿真
该程序利用PSO算法优化5个4*20矩阵中的模块采集轨迹,确保采集的物品数量及元素含量符合要求。在MATLAB2022a上运行,通过迭代寻优,选择最佳模块组合并优化轨道,使采集效率、路径长度及时间等综合指标最优。具体算法实现了粒子状态更新、需求量差值评估及轨迹优化等功能,最终输出最优轨迹及其相关性能指标。
|
18天前
|
算法
【Azure Developer】完成算法第4版书中,第一节基础编码中的数组函数 histogrm()
【Azure Developer】完成算法第4版书中,第一节基础编码中的数组函数 histogrm()
|
22天前
|
数据采集 搜索推荐 算法
【高手进阶】Java排序算法:从零到精通——揭秘冒泡、快速、归并排序的原理与实战应用,让你的代码效率飙升!
【8月更文挑战第21天】Java排序算法是编程基础的重要部分,在算法设计与分析及实际开发中不可或缺。本文介绍内部排序算法,包括简单的冒泡排序及其逐步优化至高效的快速排序和稳定的归并排序,并提供了每种算法的Java实现示例。此外,还探讨了排序算法在电子商务、搜索引擎和数据分析等领域的广泛应用,帮助读者更好地理解和应用这些算法。
15 0