【手撕归并排序】

简介: 一、归并排序是什么?归并排序是将一段区间分成若干个子问题,子问题再次分成子问题,这个是分治过程;最后分成的子问题只存在一个数时,就可以开始合并,合并的过程就是比较两个子问题的过程,合并完成后将合并的新数据拷贝到原数据即可。

一、归并排序是什么?

归并排序是将一段区间分成若干个子问题,子问题再次分成子问题,这个是分治过程;最后分成的子问题只存在一个数时,就可以开始合并,合并的过程就是比较两个子问题的过程,合并完成后将合并的新数据拷贝到原数据即可。

二、递归实现归并排序

递归实现归并排序,就是把一个大的数组分治分治,不断分治下去成一个小的数组,

最后分治成只有一个数字为止,然后每一个数字之间两两合并成2个数字,两组数组的两个数字之间再合并成4个数字,以此类推,知道合并成最后一个大的数组为止。

e747350a2cf04e46a42f13d38f947de6.png

第一步:通过left和right下标找到数组中间位置的下标,以该下标为界限,划分成两组数据。

57d4e265fe094e1da09ac149669916d0.png

第二步:重复第一步的过程,但是先把左边的组彻底分完,再分右边的组,是二叉树的前序遍历的思想。

6bf6c61b84ab4130a658b0647124a730.png

第三大步:不断进行分治,直到分解到还剩一个元素时停下来,判断只有一个元素,就是当left>=right


1d29bc9351b74423b90f61f6b24eb755.png

第四步:两两比较,四四比较合并

注意:每次合并完都需要把tmp的数据拷贝回原数组。


image.gif

最后一步:两个子区间合并成总的区间:

注意:每次合并完都需要把tmp的数据拷贝回原数组。


网络异常,图片无法展示
|

实现代码:

void _MergeSort(SortDataType* a, int left, int right, SortDataType* tmp)
{
  if (left >= right)
  {
    return;
  }
  int mid = (left + right) >> 1; // 右移一位相当于/2
  int begin1 = left, end1 = mid;
  int begin2 = mid + 1, end2 = right;
  int index = left; // tmp的下标,不能从0开始,因为有些归并是不会从0开始的。
  _MergeSort(a, begin1, end1, tmp);
  _MergeSort(a, begin2, end2, tmp);
  while (begin1 <= end1 && begin2 <= end2)
  {
    if (a[begin1] <= a[begin2])
    {
      tmp[index++] = a[begin1++];
    }
    else
    {
      tmp[index++] = a[begin2++];
    }
  }
  //到这里不知道是谁先结束的,所以都要判断
  while (begin1 <= end1)
  {
    tmp[index++] = a[begin1++];
  }
  while (begin2 <= end2)
  {
    tmp[index++] = a[begin2++];
  }
  //拷贝回去
  //for (int i = left; i <= right; ++i)
  //{
  //  a[i] = tmp[i];
  //}
  // source, destination , size
  //每次归并完都拷贝一次
  memcpy(a + left, tmp + left, sizeof(SortDataType) * (right - left + 1));
}
void MergeSort(SortDataType* a, int n)
{
  SortDataType* tmp = (SortDataType*)malloc(sizeof(SortDataType) * n);
  _MergeSort(a, 0, n - 1, tmp);
}

三、非递归实现归并排序

对于递归实现归并排序来说,是把大问题分成小问题,是自上往下分的。

而对于非递归来说,是从小问题开始合并成大问题,是从下往上分的。

以上面的数字为例:

大致思路如下:

24602599521e4370a02a2c37a1f3e0cd.png

非递归难点1:

但面临第一个问题:

如何选择从一一开始比较到两两开始比较

选择用gap

gap表示每次归并时每组的数据个数

初始时gap = 1,表示第一次是一一比较,每合并完一轮,gap*2,下一轮进行两两比较,以此类推。

非递归难点2:

不过,第二个理解的难点在于:begin1和end1,begin2和end2该如何选择的问题!

image.png

首先是i每次跳跃2×gap,因为一开始是一一比较,比较完一次相当于比较了两个数据,

而gap的含义就是每次合并时每组的数据个数!

那么就需要跳过2 ×gap的长度。


其次是begin1 和end1,begin1 = i 好理解;

end1 = i+gap-1是这样的:i+gap表示从begin1开始的往后的gap个数据, 由于是数据,那么-1才是下标。

而begin2 = i+gap也好理解,end1的后面一个就是begin2;

end2 = i+2*gap-1,就是从i位置开始,跳跃2×gap的数据个数到达最后一个需要比较的数据,-1就是这个最后的数据的下标。

非递归难点3:

难点3在于边界如何处理

先讲讲归并完一串数字如何拷贝回原数组:

1.一次性拷贝法,也叫梭哈拷贝法(不推荐)
2.每合并一次,就拷贝一次(推荐)

1.梭哈拷贝法:就是到合并完所有的数据之后再一次性拷贝回原数组,简单粗暴。

cc5a96f9fc2142bdaf7ea37f0b514652.png

2.每合并一次就拷贝一次:在一一合并成两个有序数据之后,就拷贝会原数组。

image.png

这里的边界有三种情况:

第一种:end1越界了,如下情况,当合并到四四比较时,begin1刚好为末位置,那么end1开始都越界了:


image.png

这里的处理方法有两种,但不同的方法是根据如何将归并好的数据拷贝回原数组决定的。


如果是梭哈拷贝法,不管哪种情况,都要修正过来。


先说end1越界的情况,如果是采用梭哈拷贝法一次性拷贝会原数组,就要让end1修正到

end1 = n-1 ,让begin2和end2修正到一个不存在的区间,比如:

begin2 = n ,end2 = n-1。这样做的目的是不让begin2、end2这个区间进入循环,防止拷贝到界外的数据。

如下:

image.png

begin2 和end2的修正当然不唯一,只要修正到一个不存在的区间即可。

第二种:begin2越界

可能发生的begin2越界如下:


image.png

第二种情况处理方式与第一种相同,在梭哈拷贝法的前提下,需要修正begin2 、end2这两个数据到一个不存在的区间,防止它们被拷贝。

比如:begin2 = n,end2 = n-1。

如下


image.png

第三种:end2越界


image.png


此时只需要把end2修正到n-1位置即可,

如下:


image.png

注意:begin1是不可能越界的,begin1是不可能越界的,begin1是不可能越界的,因为如果begin1越界了,那后面的end1,begin2,end2全都越界了,那还归并啥!

梭哈写法的代码如下:

void MergeSortNonR(SortDataType* a, int n)
{
  SortDataType* tmp = (SortDataType*)malloc(sizeof(SortDataType) * n);
  assert(tmp);
  int gap = 1;
  //gap 是归并过程中,每组数据的个数
  while (gap < n)
  {
    for (int i = 0; i < n; i+=2*gap)
    {
      //理解难点
      //当gap为2时,i每次都会走2步,相当于跳过一个归并组
      int begin1 = i, end1 = i + gap - 1;
      int begin2 = i + gap, end2 = i + 2 * gap - 1;
      int index = i;
      //梭哈修正写法,但是不推荐
      if (end1 >= n)
      {
        end1 = n - 1;
        begin2 = n;
        end2 = n - 1;
      }
      else if (begin2 >= n)
      {
        begin2 = n;
        end2 = n - 1;
      }
      else if (end2 >= n)
      {
        end2 = n - 1;
      }
      while (begin1 <= end1 && begin2 <= end2)
      {
        if (a[begin1] <= a[begin2])
        {
          tmp[index++] = a[begin1++];
        }
        else
        {
          tmp[index++] = a[begin2++];
        }
      }
      //到这里不知道是谁先结束的,所以都要判断
      while (begin1 <= end1)
      {
        tmp[index++] = a[begin1++];
      }
      while (begin2 <= end2)
      {
        tmp[index++] = a[begin2++];
      }
    }
    //不推荐
    //法1:梭哈法:一次性整体拷贝 
    memcpy(a, tmp, sizeof(SortDataType) * n);
    gap *= 2;
  }
  free(tmp);
  tmp = NULL; 
}

二、如果是每归并一次,就拷贝一次数据回到原数组的拷贝方法的话,处理情况就不同。

在合并一次拷贝一次的情况下:

1.end1 越界了


image.png

因为是合并一次拷贝一次,则前面的红色的数据已经全部从tmp临时数组拷贝回到原数组了,至于3这个数据,不需要再拷贝到tmp了,让他留在原来的地方即可。

所以处理方法是直接break

2.begin2 越界了

image.png

与end1越界的情况相同,因为是合并一次拷贝一次,则前面的红色的数据已经全部从tmp临时数组拷贝回到原数组了,至于后面的数据,不需要再拷贝到tmp了,让他留在原来的地方即可。

所以直接break

3.end2越界

image.png

同样的,如果是end2越界,就需要修正end2到n-1位置,保证begin1 和begin2可比即可。

所以修正 :end2 = n-1

走一步拷贝一步的非递归写法如下:

void MergeSortNonR(SortDataType* a, int n)
{
  SortDataType* tmp = (SortDataType*)malloc(sizeof(SortDataType) * n);
  assert(tmp);
  int gap = 1;
  //gap 是归并过程中,每组数据的个数
  while (gap < n)
  {
    for (int i = 0; i < n; i+=2*gap)
    {
      //理解难点
      //当gap为2时,i每次都会走2步,相当于跳过一个归并组
      int begin1 = i, end1 = i + gap - 1;
      int begin2 = i + gap, end2 = i + 2 * gap - 1;
      int index = i;
      //法2:三种情况,但是前两种情况可以使用相同的方法解决
      //如果end1越界了,那就不归并了,
      //如果begin2越界了,那也不归并了
      if (end1 >= n || begin2 >= n)
      {
        break;
      }
      //如果end2越界了,让end2修正到n-1位置
      if (end2 >= n)
      {
        //修正
        end2 = n - 1;
      }
      while (begin1 <= end1 && begin2 <= end2)
      {
        if (a[begin1] <= a[begin2])
        {
          tmp[index++] = a[begin1++];
        }
        else
        {
          tmp[index++] = a[begin2++];
        }
      }
      //到这里不知道是谁先结束的,所以都要判断
      while (begin1 <= end1)
      {
        tmp[index++] = a[begin1++];
      }
      while (begin2 <= end2)
      {
        tmp[index++] = a[begin2++];
      }
      // destination  source  size
      //推荐
      //法2:归并一点,拷贝一点,需要画图理解
      //如果是end1 或begin2大于等于n的时候越界
      //不同于梭哈一次性拷贝,梭哈拷贝需要把所有的拷贝进tmp,必须再拷回去,虽然做了无用功,但是是必须做的,这也是比较挫的地方
      //这个法2没做无用功,既然end1或者begin2越界了,那就干脆不拷贝了
      memcpy(a + i, tmp + i, sizeof(SortDataType) * (end2 - i+1));
    }
    gap *= 2;
  }
  free(tmp);
  tmp = NULL; 
}

注意两种写法中,拷贝的代码放在了while循环的不同位置!

四、归并排序时间复杂度

归并排序具有稳定性,即对于两个及以上的相同数据,归并排序前后不会改变相同数据的相对位置,这个就是稳定性。

归并排序对数据的顺序是不敏感的。

归并排序时间复杂度为O(NlogN),从一一归并开始,每次归并都需要遍历所有数据,但由于是二路归并,所以n个数据的 ”高度“是logN,即没进行一层,就需要遍历一次所有数据,所以时间复杂度就是O(NlogN).


空间复杂度:O(N),因为需要开辟一个临时数组来保存合并好的值,所以空间复杂度是O(N).

相关文章
|
19天前
|
搜索推荐
手撕各种排序(中)
手撕各种排序(中)
49 0
|
19天前
|
算法 搜索推荐 索引
手撕各种排序(下)
手撕各种排序(下)
40 0
|
19天前
|
安全 Java C语言
手撕各种排序(上)
手撕各种排序
30 0
|
6月前
|
搜索推荐 算法
手撕排序算法4:归并排序及其非递归版本(下)
手撕排序算法4:归并排序及其非递归版本(下)
|
6月前
|
搜索推荐 算法
手撕排序算法4:归并排序及其非递归版本(上)
手撕排序算法4:归并排序及其非递归版本(上)
|
6月前
|
搜索推荐 算法
手撕排序算法2:堆排序和直接选择排序(下)
手撕排序算法2:堆排序和直接选择排序(下)
|
6月前
|
算法 搜索推荐 测试技术
手撕排序算法2:堆排序和直接选择排序(上)
手撕排序算法2:堆排序和直接选择排序(上)
|
6月前
|
机器学习/深度学习 搜索推荐 算法
【手撕排序算法1:插入排序与希尔排序】
【手撕排序算法1:插入排序与希尔排序】
|
9月前
|
机器学习/深度学习 存储 算法
【数据结构与算法篇】 手撕八大排序算法之选择排序
【数据结构与算法篇】 手撕八大排序算法之选择排序
38 0
|
9月前
|
算法 搜索推荐
【数据结构与算法篇】手撕八大排序算法之交换排序
【数据结构与算法篇】手撕八大排序算法之交换排序
33 0