【初阶数据结构与算法】八大排序之归并排序,递归与非递归,一次性讲清!

(一)递归版归并排序 Mergesort

(升序为例)如图,想排数组,就可以将数组一分为二,排好前半部分和后半部分之后,再像合并两个有序链表 那样,将两个有序的小数组从前向后比较之后插入到额外开辟的数组里,再将额外数组里的数据依次拷贝回原数组,就完成了原数组的排序。

这里有一个大前提:进行比较的两个数组必须是有序数组。

可很显然,将待排数组一分为二,并不能得到两个有序的小数组。

这说明,我们比较小数组的数据大小前,应该先对小数组进行排序,那么就要再将小数组进行划分,然后再比较小数组的两个小小数组的大小,可小小数组也不一定有序,所以还要再进行一分为二......当数组越分越小,直到数组只有一个数据时,就不用再向下分了。(一个数据的数组天然成序)

这里就体现了递归思想,大问题分成了若干个小问题,小问题都是一样的思路:当划分的两个小数组有序后,比较大小后插入到额外的数组中,再拷贝回原数组中

(1)代码实现:

先递归找数据个数为1的数组,再进行大小的比较、插入到额外数组和拷贝回原数组。

复制代码
void _Mergesort(int* arr,int tmp,int left,int right)
{
  //递归结束的条件
  if(left>=right)
     return;
  
  int mid = (left+right)/2;
  int begin1 = left,begin2 = mid+1;
  int end1 = mid;end2 = right;

  //递归
  _Mergesort(arr,tmp,begin1,end1);
  _Mergesort(arr,tmp,begin2,end2);
  
  //tmp
  int i = left;
  while(begin1>=end1 && begin2>=end2)
  {
    if(arr[begin1]<arr[begin2])
    {
        tmp[i++] = arr[begin1];
        begin1++;
    }
    else
    {
        tmp[i++] = arr[begin2];
        begin2++;
    }
  }
  //处理一方还未完全放入tmp数组中的情况
  while(begin1<=end1)
    tmp[i++] = arr[begin1++];
  while(begin2<=end2)
    tmp[i++] = arr[begin2++];

  //放入tmp之后,再将tmp数组中的数据拷贝到arr数组中
  memcpy(arr+left,tmp+left,sizeof(int)*(right-left+1));
  
}

void Mergesort(int* arr,int n,int left,int right)
{
   int* tmp = (int*)malloc(sizeof(int)*n);
   if(tmp == NULL)
       return;
   Mergesort(arr,tmp,left,right);
   free(tmp);
   tmp = NULL;

}

(2)细节理解

①归并的mid和快排的mid有区别吗?

有区别。归并排序里的mid(GetMid的返回值)是首尾和中间三个数比较后的中间值,它不是位置的中间,而是数据大小的中间,而快排里的mid就是字面意思的中间位置,它就等于(begin+end)/2。

②为什么int i = left?

i是tmp数组的下标,每次两个有序的小区间数据进行大小比较时,就会插入到tmp数组的相应位置上,很显然,不可能每次插入都是从tmp数组的0位置开始,如图所示。

但其实并不显然哈哈,i = left在教材中常见,可我觉得i = 0也行,我们完全可以每次比较大小时就从tmp数组的0下标处开始插入啊,旧数据会被新数据覆盖,只不过i的赋值改动了,后续memcpy的时候,实参也需要改动,原地址不再是tmp+left,而是tmp。

③i从第一个框中的循环到第二个框中的循环,不需要做任何处理?

确实不需要做任何处理,从第一个循环到第二个循环,情况如下图所示。

剩余还没有被插入到tmp数组中的红色部分,将从i下标向后插入,所以i不用改动。

④memcpy函数为啥放在了子函数里?为啥不等额外的数组里的数据就是arr数据的成序形式后,再统一拷贝一次呢?

先说结论:在归并排序中,必须合并好一段就立即拷贝回arr数组。

如果不立即拷贝回arr数组,那么只是tmp数组有序,等合并更大的区间时,arr数组里的区间还是乱的,就无法保证进行比较的两个区间数组必须是有序数组的前提。

将memcpy放在子函数里才能排好一段后,就立刻拷贝回arr数组。

⑤memcpy的参数分析:
复制代码
 memcpy(arr+left,tmp+left,sizeof(int)*(right-left+1));

第一个参数是目的地址,放回原数组中需要对应,所以是arr+left,第二个参数是源地址,比较大小后插入时需要对应,所以是tmp+left,第三个参数是拷贝的总数据的空间大小,单位为字节。

⑥为什么要额外写一个子函数呢?

在Mergesort中,我们创建了一个临时数组tmp,然后再调用了函数_Mergesort来完成归并排序,如果我们将两块内容放在同一个函数Mergesort里,在递归的时候,就会不断地创建临时数组tmp,重复开辟空间,会造成浪费和效率低下。

(二)非递归版归并排序 MergesorNonR

在递归中,我们是深入到数据个数只有1的数组才返回开始比较数据、和并的,在非递归中,我们也是这样的,先从数据个数为1的数组开始,如图,给诸多两个数据个数为1的数组排序合并,该拷贝的拷贝,再给诸多数据个数为2的数组排序合并,该拷贝的拷贝,接着给诸多数据个数为4的数组排序合并,该拷贝的拷贝......直到合并完后tmp数组大小为arr数组大小,就不用再比较排序合并了。

1-1 arr数组数据个数为2^n

(1)代码实现

我们先只考虑arr数组数据个数为2^n的情况。

复制代码
void MergesortNonR(int* arr,int n)
{
  int* tmp = (int*)malloc(sizeof(int)*n);
  if(tmp == NULL)
    return ;
  //不用递归就不需要子函数了
  int gap = 1;
  while(gap<n)
 {   
   for(int j = 0;j<n;j+=2*gap)
  { 
   int begin1 = j,end1 = begin1+gap-1;
   int begin2 = end1+1,end2 = begin2+gap-1
   int i = j;
   while(begin1<=end1 && begin2<=end2)
   {
    if(arr[begin1]>arr[begin2])
    {
       tmp[i++] = arr[begin2];
       begin2++;
    }
    else
    {
       tmp[i++] = arr[begin1];
       begin1++;
    }
   }
    while(begin1<=end1)
      tmp[i++] = arr[begin1++];
    while(begin2<=end2)
      tmp[i++] = arr[begin2++];

    memcpy(arr+j,tmp+j,sizeof(int)*(end2-j+1));
  }
    gap *= 2;
 }

} 

(2)细节分析

①循环的理解:

理解非递归的归并,首先理解两个循环在干嘛。

②为啥是while(gap<n)?

以图为例,数组长度为16,当gap=1/2/4/8时,都可以进行大小比较排序,当gap=16时,所有的排序已经完成了,所以gap==n是结束条件,那么gap<n是循环继续的条件。

③为啥是for(int j = 0;j<n;j+=2*gap)?

如图,每一条的初始比较,都是从下标为0处开始的,以gap=2为例,每次是四个数据一起比较,比较完了之后,j就要跳过这四个数据定位到下一轮的四个数据,所以循环变量的改变就是j+2gap,等j来到n下标处,就不需要大小比较排序了,所以循环条件是j<n。

④为啥int i = j?

i是tmp数组的下标,具体来说是当arr数组中的区间进行大小比较之后,要插入到tmp数组时的插入下标,每一轮的起始比较都是从arr的j下标处开始的,那么对应tmp的插入,肯定也是从j下标处开始,所以将j赋值给i。

⑤memcpy的参数分析:
复制代码
  memcpy(arr+j,tmp+j,sizeof(int)*(end2-j+1));

每一轮的大小比较从哪里开始,tmp数组的插入就从哪里开始,tmp数组插入成有序后拷贝回arr数组也就从哪里开始,那么memcpy时的目的地址和源地址都需要加上j。

一次性拷贝多少个数据回arr数组很容易出错,如上图所示,借助四个边界和j我们可以确定一次性需要拷贝end-j+1个数据。

注意:借助j和end2是因为这两个变量在一轮循环中不会改变。如果我们借助begin1,写成end2-begin1+1,乍一看还挺对,但实际上是错的,因为在比较大小的过程中,begin1是变化的。

⑥为啥gap*=2?

起始是1个数据的有序区间和1个数据有序区间(gap=1)比较大小后插入到tmp数组中,1个和1个数据就能组成2个数据的有序区间,那么再就是2个数据的有序区间和2个数据的有序区间(gap=2)比较大小后插入到tmp数组中,再之后就是4个和4个比,8个和8个比......可以发现,每次gap的变化都是*2,所以是gap*=2。

(3)错误呈现

复制代码
void MergesortNonR(int* arr,int n,int left,int right)
{
  int* tmp = (int*)malloc(sizeof(int)*n);
  if(tmp == NULL)
    return ;
  //不用递归就不需要子函数了

  while(gap<n)
 { 
   int gap = 1;
   int begin1 = left,end1 = begin1+gap-1;
   int begin2 = end1+1,end2 = begin2+gap-1

   for(int j = left;j<n;j+=2*gap)
  { 
   int i = j;
   while(begin1<=end1 && begin2<=end2)
   {
    if(arr[begin1]>arr[begin2])
    {
       tmp[i++] = arr[begin2];
       begin2++;
    }
    else
    {
       tmp[i++] = arr[begin1];
       begin1++;
    }
   }
    while(begin1<=end1)
      tmp[i++] = arr[begin1++];
    while(begin2<=end2)
      tmp[i++] = arr[begin2++];

    memcpy(arr+left,tmp+left,sizeof(int)*(right-left+1));
  }
    gap *= 2;
 }

} 

这是一段很典型的错误代码,出错根源在于,写完归并的递归后,盲目地敲非递归的代码,导致写下了许多属于递归代码中的不具有迁移性的部分代码。

(4)错误分析

红色🔴

写完递归很顺手就把left和right加上了,但非递归函数中不用找mid,也没有别的需要用到left和right的地方,所以并不用传left和right参数。

橙色🟠

很粗心的错误,把gap作为循环条件判断的变量,为啥却在循环里定义gap?应当放到循环的外面,才能在第一次判断是否能进入循环。

黄色💛

最主要的错误是:四个代表边界的变量是应该随着比较而改变的,定义应该放在for循环里面,而不是for循环外面,并且,如果给begin1赋left,那么begin1就算在for循环里,也是个定值,所以应该吧四个边界的定义赋值都放到for循环里,并且给begin1赋值j。

绿色****💚

left是定值0,它并不像递归那样每次都会改变每次都从left开始大小比较,在非递归里,每次都从aj下标处开始大小比较和插入拷贝,所以应该双双改成arr+j,tmp+j。

蓝色💙

right-left+1=n得到的是待排数组的总元素个数,很显然,每次拷贝咋可能都是对n个元素进行拷贝你?

1-2 数组数据个数不为2^n

(1)思路分析

四个边界都跟gap有关系,我们来看看在非2^n个数据的情况下,每次比较大小时的边界是咋样的,如下图所示,在for语句中加上打印语句,通过打印结果,我们知道,四个边界,除了begin1,其余三个都有越界可能,这就是问题所在。

(此处检测数组大小为10)

这里越界的情况可以分成两类:

上框中,第二个区间的边界都越界,那么就不用再执行后续的两个有序区间的大小比较然后插入拷贝了,直接跳出while循环就行;

下框中,第二个区间只有end2越界,即第二个区间中还有有效数据,那么就还需要进行后续的两个有序区间的大小比较然后插入拷贝,可end2不再是有效数据区间的边界,所以,我们需要对end2进行修正,直接将n-1赋给end2就行。

这也能说明,分类的标准是是否还需要后续的两个有序区间的大小比较然后插入拷贝。如果第二个区间全部越界,那就是没有必要了,但但凡第二个区间有一个有效值也就是,那就是有必要,然后对end2进行修正。

(2)完整代码

复制代码
void MergesortNonR(int* arr,int n,int left,int right)
{
  int* tmp = (int*)malloc(sizeof(int)*n);
  if(tmp == NULL)
    return ;
  //不用递归就不需要子函数了

  while(gap<n)
 { 
   int gap = 1;
   int begin1 = left,end1 = begin1+gap-1;
   int begin2 = end1+1,end2 = begin2+gap-1
   
   //没必要的情况,当begin2越界,则第二个区间全员越界
   if(begin2>n-1) 
     break;
   //有必要的情况
   //如果没有进入上一个if语句,则说明begin2没越界
   //此时end2越界,也就是说第二个区间里还有有效值
   if(end2>n-1)
     end2 = n-1;

   for(int j = left;j<n;j+=2*gap)
  { 
   int i = j;
   while(begin1<=end1 && begin2<=end2)
   {
    if(arr[begin1]>arr[begin2])
    {
       tmp[i++] = arr[begin2];
       begin2++;
    }
    else
    {
       tmp[i++] = arr[begin1];
       begin1++;
    }
   }
    while(begin1<=end1)
      tmp[i++] = arr[begin1++];
    while(begin2<=end2)
      tmp[i++] = arr[begin2++];

    memcpy(arr+left,tmp+left,sizeof(int)*(right-left+1));
  }
    gap *= 2;
 }

} 

------end------

相关推荐
sali-tec1 小时前
C# 基于OpenCv的视觉工作流-章77-物体测量
图像处理·人工智能·opencv·算法·计算机视觉
smj2302_7968265213 小时前
解决leetcode第3943题递增后的数对数量
数据结构·python·算法·leetcode
炽烈小老头14 小时前
【每天学习一点算法 2026/05/25】矩阵中的最长递增路径
学习·算法·矩阵
叁散15 小时前
实验报告:5G 仿真环境与基本链路模拟
算法
从负无穷开始的三次元代码生活15 小时前
算法零碎灵感点分享
算法
梓䈑15 小时前
【算法题攻略】快速排序 和 归并排序
数据结构·c++·排序算法
染指111015 小时前
9.LangChain框架(实现RAG)
数据库·人工智能·算法·机器学习·ai·大模型
大数据三康16 小时前
在spyder进行的遗传算法练习
开发语言·python·算法
Gene_202216 小时前
轮式底盘的微分平坦
算法