从2路归并到k路归并(二)

1,490 阅读6分钟

上文讲到了如何将两个有序数组合并成一个有序数组,这也是归并方法merge的职责.但我们的最终要求是将一个无序数组进行排序,所以如何把一个无序数组分解成两个有序数组是本文将要描述的重点

以数组a:2,3,4,1,3,5,7为例,不论你怎么分,都不可能一下子就将这个数组分成两个有序数组,那怎么办呢?这里就要引入归并排序的核心知识点:动态归并,什么意思?

其实也简单,最开始我们把每个元素都看成一个长度为1的子数组,这样每个子数组都是天然有序的,我们把两两相邻的子数组传递给merge方法,那么就会得到一个长度为2的有序数组,然后再把这两两相邻的长度为2的子数组传递给merge方法,会得到长度为4的有序数组,依次类推,直到两个子数组的长度为a.length/2,此时再次调用merge方法就得到最终结果. 由此可知,归并排序就是动态调用merge方法以得到更大的有序子数组,然后对更大的两个有序子数组继续调用merge,直到最终有序.

我猜你可能没看懂,所以还是结合图再来一遍

2 3 4 1 3 5 7

上面是原始数组,我们不断对数组进行二分,当分解到只包含一个元素为止,这样我们就得到了一系列长度为1的有序子数组,这就为merge创造了条件

第一次分解:

2 3 4 1
3 5 7

第二次分解:

2 3
4 1
3 5
7

第三次分解:

2
3
4
1
3
5
7

此时可以两两merge了,

2 3
1 4
3 5
7

再次两两merge

1 2 3 4
3 5 7

再次两两merge

1 2 3 3 4 5 7

如何用程序实现这个过程呢? 如果此时你能想到递归,那么恭喜你,接下来的事情就很简单了,想不到也没关系,我也没想到.严格来说,上图并不是递归过程的精确表示(但原理一样).其实为了更简洁地说明过程,我故意没有一上来就把递归细节混杂在上述过程.

一直觉得递归是一个很伟大的发明,它可以用简单的代码表示复杂的过程

递归实现之前,我们先把merge方法做一个简单改造,原始的merge方法是这样的

  public static int[] merge(int[] a,int[] b){
    int len1=a.length;
    int len2=b.length;
    int len=len1+len2;
    int[] c=new int[len];
    int i=0,j=0;
    for(int k=0;k<len;k++){
      //a数组元素用完,依次把b剩余元素放入c中
      if(i==len1) c[k]=b[j++];
      //b数组元素用完,依次把a剩余元素放入c中
      else if(j==len2) c[k]=a[i++];
      //如果两个数组都没用完,取较小者放入c中
      else if(a[i]<b[j]) c[k]=a[i++];
      else c[k]=b[j++];
    }
    return c;
  }

两个数组是通过两个参数来表示,为了更好的跟递归方法结合,我们改成如下形式

  public static void merge(int[] a,int[] aux,int lo,int mid, int hi) {
    //i,j 最初指向两个子数组的其实元素
    int i = lo,j = mid + 1;
    //把要归并的元素先拷贝到辅助数组aux中
    for(int k = lo; k<=hi ;k++){
      aux[k] = a[k];
    }
    //开始归并
    for(int k = lo; k<= hi; k++){
      if(i > mid) a[k] = aux[j++];
      else if(j > hi) a[k] = aux[i++];
      else if(aux[j]<aux[i]) a[k] = aux[j++];
      else a[k] = aux[i++];
    }
  }

a代表原始的无序数组,aux代表辅助数组,lo代表第一个有序数组的开始索引,mid代表第一个有序数组的结束索引,hi代表第二个有序数组的结束索引.那第二个有序数组的开始索引呢? 对滴,就是mid+1,也就是说我们现在用多个索引来表示两个有序子数组

为什么需要aux?,比如我想merge下面的两个子数组

a:2   3    1     4
  lo  mid  mid+1 hi

第一个子数组从lomid,第二个子数组从mid+1hi.如果不事先把元素复制到aux,那么当merge时,因为a[mid+1] < a[lo] 所以a[lo]对应的2会被a[mid+1]对应的1覆盖,一旦被覆盖,那么后续的比较将再也无法获取到2,所以我们把a的元素事先复制到aux中,也就是第一个for循环干的事

第二个for循环也很简单,一旦把两个有序子数组的所有元素复制到aux,我么就可以用aux的元素来比较,每次把较小值放到a中,因为数组是引用类型,在方法内部对数组元素的修改,会作用到方法外,所以我们也无需像第一个merge方法一样返回数组.

根据上图的分析过程并结合递归方法的特性,一旦长度为1的数组merge后,会自动返回到数组长度为2的递归状态,然后层层返回,直到归并长度为a.length/2的子数组.

为了帮助大家理解上面的内容,我这次以递归方式再次绘制这个过程:

最开始无序状态:

2 3 4 1 3 5 7

把数组二分成左右两个子数组

2 3 4 1
3 5 7

因为我们采用递归,此时是先递归左子数组2 3 4 1,右子数组仍然为3 5 7

2 3 
4 1
3 5 7

同样道理,继续递归左子数组2 3,右子数组仍然为4 1

2 
3 
4 1
3 5 7

因为此时子数组长度为1,所以此时合并2 3两个子数组,合并完成后,自动返回到上一递归状态.

2 3 
4 1
3 5 7

此时左子数组合并完成,递归右子数组4 1

2 3 
4 
1
3 5 7

因为4 ,1两个子数组的长度为1,合并,合并后成1 4.此时右子数组递归完成,返回到上一状态

2 3 
1 4
3 5 7

此时合并2 31 4两个子数组,合并完成后返回到上一状态.

1 2 3 4
3 5 7

此时左子数组1 2 3 4完成排序,继续递归右子数组3 5 7

1 2 3 4
3 5 
7

继续递归左子数组3 5

1 2 3 4
3 
5 
7

子数组长度为1,开始合并

1 2 3 4
3 5 
7

左子数组3 5完成合并,因为右子数组只有7,直接返回,然后合并3 57两个子数组

1 2 3 4
3 5 7

此时左右两个子数组都完成递归,继续合并,得到最终结果

1 2 3 3 4 5 7

上述过程看上去很复杂,但是对应的递归代码很简单:

  private void sort(int[] a,int[] aux,int lo,int hi){
    if(hi <= lo) return;
    int mid = lo+(hi-lo)/2;
    //分解左子数组
    sort(a,aux,lo,mid);
    //分解右子数组
    sort(a,aux,mid+1,hi);
    //合并两个有序数组
    merge(a,aux,lo,mid,hi);
  }

再加上我们的入口函数,搞定

  public void sort(int[] a){
    aux = new int[a.length];
    sort(a,aux,0,a.length-1);
  }

总结:

通过上面的分析可知,我们利用二分法把原始无序的数组逐步分解,直到子数组长度为1,此时开始归并.归并时,会将两个子数组合并成一个大的子数组,然后返回到上一递归状态,此时lohi的值也正好是分解前的位置,然后继续归并,直到最终有序.

上面是通过把数组进行两两分解,然后合并,这就是二路归并.理论上我们也可以把数组三三分解,然后每次合并三个子数组,这就是三路归并.当然我们也可以做到k路归并.在遇到具体问题时,可以根据测试来确定合适的k值从而而得到最优性能.