【数据结构】三万字图文讲解带你手撕八大排序(附源码)3

简介: 【数据结构】三万字图文讲解带你手撕八大排序(附源码)

6.6 缺陷分析及优化


缺陷1:有序或接近有序序列时间复杂度过高


其实对于快排来说,它的时间复杂度是不稳定的,比如上方三个版本,在乱序的序列中,效率可能还可以,因为选取的 k e y key key 值是随机的。


但是对于有序序列,比如要排正序,但是序列是逆序。如果每次选 k e y key key 还是按照之前的选法,那么每次可能就会选中最边上的一个,选中最大或最小的数,假设序列长度为 N N N ,每次选取一个极端值,就会选取 N N N 次,就会递归 N N N 层,每一层中的时间复杂度为 O ( N ) O(N) O(N) ,那么最终时间复杂度为 O ( N 2 ) O(N^{2}) O(N2) 。



e5f7e7b5c75c02d97e18be471771fc37.png


但是这速度对于快排来说,是不是说不过去,我们能否每次选 k e y key key 都 命中一段区间的中位数 ,让每段区间被二分,那么最终就只会递归 l o g N log N logN 层,每层时间复杂度为 O ( N ) O(N) O(N) ,总时间复杂度为 O ( N × l o g N ) O(N \times log N) O(N×logN) 。就像下图,像一棵二叉树一样。



7d6f64ce6663654f85b8ffb83c918198.png


所以这边就引申出了第一个优化:三数取中


所谓三数取中,就是不取最大,不取最小,在 b e g i n , e n d , ( b e g i n + e n d ) / 2 begin, end, (begin + end) / 2 begin,end,(begin+end)/2 中选取一个中间值,尽量让 k e y key key 可以命中每段区间中点。


而这段逻辑的话,其实也并不复杂,直接上代码:

int GetIndexMid(int* a, int begin, int end)
{
  int mid = (begin + end) >> 1;
  if (a[begin] < a[mid])
  {
    if (a[mid] < a[end])
    {
      return mid;
    }
    else if (a[begin] > a[end])
    {
      return begin;
    }
    else
    {
      return end;
    }
  }
  else // a[begin] >= a[mid]
  {
    if (a[mid] > a[end])
    {
      return mid;
    }
    else if (a[end] > a[begin])
    {
      return begin;
    }
    else
    {
      return end;
    }
  }
}


缺陷2:不必要的递归层数太多,空间浪费


假设我们只有 10 10 10 个数,那么这种情况采用递归是不是浪费空间,是不是多此一举?


所以当 e n d − b e g i n + 1 < 10 end - begin + 1 < 10 end−begin+1<10 时,可以采用插入排序优化,这样子就没必要开那么多层函数栈帧。


对于一棵满二叉树,最后一层的节点数占总结点数的 1 2 \frac{1}{2} 21 ,倒数第二、三层分别为 1 4 、 1 8 \frac{1}{4}、\frac{1}{8} 41、81 ,我们就假定快排递归展开后,最后形态为完全二叉树。


假设当前有10个节点,那么对于下图中红色箭头标出的点来说就无须递归,因为再细分也就是一个数:


b8c37e827766898c44c7add2094686f8.png


那么大约就可以节省下三层的递归,下三层的节点数占总结点数的 87.5 % 87.5\% 87.5% ,省去了大部分的递归。


所以这边就引申出了 第二个优化:小区间优化


这边我们范围给大一点,当 e n d − b e g i n + 1 < 15 end - begin + 1 < 15 end−begin+1<15 时,就采用直接插入排序优化。


递归框架中发生的变化:


void QuickSort(int* a, int begin, int end)
{
  if (begin >= end)
  {
    return;
  }
  // 小于一定数目使用 直接插入排序
  if ((end - begin + 1) < 15)
  {
        // 数组位置:a + begin
        // 元素个数:end - beghin + 1
    InsertSort(a + begin, end - begin + 1);
  }
  else
  {
    int key = partion(a, begin, end);
    // 递归左右区间
    QuickSort(a, begin, key - 1);
    QuickSort(a, key + 1, end);
  }
}



缺陷3(最致命的一点):对于相同数据来说,三数取中无效,时间复杂度过高

比如 2   2   2   2   2   2   2   2 2 \ 2 \ 2 \ 2 \ 2 \ 2 \ 2 \ 2 2 2 2 2 2 2 2 2 这个序列来说三数取中是完全无效的,特别数据量一大,不仅容易超时,还容易爆栈。


所以就需要优化,这就引申出了第三个优化:三路划分。


之前的我们是主要将区间划分为两段, [ b e g i n , k e y − 1 ] [begin, key - 1] [begin,key−1] 和 [ k e y + 1 , e n d ] [key + 1, end] [key+1,end] 。 左区间值小于 k e y key key ,右区间值大于 k e y key key ,可以称为 两路划分 。


现在我们需要进行三路划分,就是将区间分割为左区间小于 k e y key key ,中间区间等于 k e y key key ,右区间大于 k e y key key 。


其实这个思路更为简单,简单讲一下思路:


   设定一个 c u r = b e g i n + 1 cur = begin + 1 cur=begin+1, l e f t = b e g i n , r i g h t = e n d , k e y = a [ l e f t ] left = begin, right = end, key = a[left] left=begin,right=end,key=a[left]。


   就是将区间分割为左区间小于 k e y key key ,中间区间等于 k e y key key ,右区间大于 k e y key key 。


   给定一个循环,循环中如果 a [ c u r ] < k e y a[cur] < key a[cur]<key ,此刻交换 c u r cur cur 和 l e f t left left 指向的元素,使 l e f t + + left++ left++ , c u r + + cur++ cur++ 。(如果一开始这个条件就满足,则会把 k e y key key 逐渐往中间推。)


   如果 a [ c u r ] > k e y a[cur] > key a[cur]>key ,此刻 r i g h t right right 这个位置的值比 k e y key key 大,也有可能比 k e y key key 小。交换 c u r cur cur 和 r i g h t right right 指向元素后,如果 c u r + + cur++ cur++ 可能该位置元素就不满足最终区间划分条件,所以这里只能 r i g h t − − right-- right−−.


   如果 a [ c u r ] = = k e y a[cur] == key a[cur]==key ,那么只需要 c u r + + cur++ cur++。

   当 c u r > r i g h t cur > right cur>right 时, r i g h t right right 后的元素都是大于 k e y key key 的,区间也都调整好了,这时候循环也就停止了。


   实际上这一过程就像把和 k e y key key 相等的值往中间推,把比 k e y key key小的值往左边甩,把比 k e y key key 大的值往右边甩,最后等于 k e y key key 的就在中间。


   最后分割成的区间就是 [ b e g i n , l e f t − 1 ] , [ l e f t , r i g h t ] , [ r i g h t + 1 , e n d ] [begin, left - 1], [left, right], [right + 1, end] [begin,left−1],[left,right],[right+1,end],这时等于 k e y key key 的区间不用递归,只需要递归排序左右区间即可。


   如果不太理解可以画一下草图,这边博主就不带着画了。


这一过程也是挺简单的,我们直接看代码:

// 三路划分 处理重复数据量大的情况,处理完中间区间就是 22222222222
void QuickSortT(int* a, int begin, int end)
{
  if (begin >= end)
  {
    return;
  }
    // 三数取中一下
  int mid = GetIndexMid(a, begin, end);
  Swap(&a[mid], &a[begin]);
  int left = begin, right = end;
  int cur = begin + 1;
  int key = a[left];
  // 跟 key 相等的值,往后推
  // 比 key 小的甩到左边
  // 比 key 大的甩到右边
  // 和 key 相等的就在中间
  while (cur <= right)
  {
    if (a[cur] < key)
    {
      Swap(&a[cur], &a[left]);
      left++;
      cur++;
    }
    else if (a[cur] > key) // 
    {
      // r 这个位置有可能比 Key 大,也有可能比 key 小
      // 所以 cur 不 ++ 
      // 如果 cur 比 key 大,之后还是得换回去处理
      Swap(&a[cur], &a[right]);
      right--;
    }
    else
    {
      cur++;
    }
  }
  // 区间被划分为 [begin, left - 1] [left, right] [right + 1, end]
  QuickSortT(a, begin, left - 1);
  QuickSortT(a, right + 1, end);
}



6.7 快排递归版本完整代码


这边调用的 partion 我们用 前后指针 的(代码少些doge):

int GetIndexMid(int* a, int begin, int end)
{
  int mid = (begin + end) >> 1;
  if (a[begin] < a[mid])
  {
    if (a[mid] < a[end])
    {
      return mid;
    }
    else if (a[begin] > a[end])
    {
      return begin;
    }
    else
    {
      return end;
    }
  }
  else // a[begin] >= a[mid]
  {
    if (a[mid] > a[end])
    {
      return mid;
    }
    else if (a[end] > a[begin])
    {
      return begin;
    }
    else
    {
      return end;
    }
  }
}
int partion3(int* a, int begin, int end)
{
    // 三数取中
  int mid = GetIndexMid(a, begin, end);
  Swap(&a[begin], &a[mid]);
  int prev = begin;
  int cur = begin + 1;
  int key = begin;
  while (cur <= end)
  {
    // 找到比 key 小的值时,跟 ++prev 位置交换,
    // 小的往前翻,大的往后翻
    // 重复数据不会交换
    if (a[cur] < a[key] && ++prev != cur)
      Swap(&a[cur], &a[prev]);
    // 重复数据会交换
    /*if (a[cur] < a[key])
      Swap(&a[++prev], &a[cur]);*/
      // cur 必定会走一步
    cur++;
  }
  Swap(&a[prev], &a[key]);
  //return prev;
  key = prev;
  return key;
}
void QuickSort(int* a, int begin, int end)
{
  if (begin >= end)
  {
    return;
  }
  // 小于一定数目使用 直接插入排序
  if ((end - begin + 1) < 15)
  {
    InsertSort(a + begin, end - begin + 1);
  }
  else
  {
    int key = partion3(a, begin, end);
    // 递归左右区间
    QuickSort(a, begin, key - 1);
    QuickSort(a, key + 1, end);
  }
}


6.8 快排非递归版本


其实快排不仅能用递归,还是可以使用非递归的,非递归的好处就是不需要多次递归开辟多层函数栈帧,在空间消耗上略有优势。


快排的非递归需要借助数据结构 - 栈 来完成。


快排递归的过程就相当于对每一段区间进行处理,那么非递归我们可以用两个变量来模拟各个区间。

接下来我们开始展开思路:


   一开始,我们将 b e g i n begin begin 和 e n d end end 分别入栈。给定一个循环,如果栈不为空就继续循环。


   由于栈是后进先出,所以先用 r i g h t right right 接收 e n d end end 右区间,再用 l e f t left left 接收左区间,在接收完之后,将这两个元素分别出栈。


   得到了区间之后,就对区间进行单趟排序(可以调用上面的 h o a r e hoare hoare 等),用 k e y key key 接收分隔点。


   我们再想想处理完一次完整区间后,下一次要如何处理?


   先处理左区间 [ l e f t , k e y − 1 ] [left, key - 1] [left,key−1] ,再处理 [ k e y + 1 , r i g h t ] [key + 1, right] [key+1,right] 。由于栈先进后出,所以要先入右区间,在入左区间。


   每次循环只会取出两个值,那么就是一小段区间,在取出左区间后,会先处理左区间,然后不断分割小区间,每次取出两个值一直对栈顶上的两个元素的区间进行处理,这样就模拟除了快排的过程。


优化思路:


如果区间内只有 1 1 1 个元素,就无需处理了,所以可以加个条件判断一下,举个例子,对于右区间来说, k e y key key 是分割点, k e y + 1 key + 1 key+1 则是右区间的起始位置,如果 k e y + 1 < r i g h t key + 1 < right key+1<right ,那么说明区间中不止一个元素,这种情况就入栈处理。类比左边也是一样的道理。


// 快排非递归
void QuickSortNorR(int* a, int begin, int end)
{
  ST st;
  StackInit(&st);
  // 压栈
  StackPush(&st, begin);
  StackPush(&st, end);
  while (!StackEmpty(&st))
  {
    // 后进先出,先出 right
    int right = StackTop(&st);
    StackPop(&st);
    int left = StackTop(&st);
    StackPop(&st);
    // 先取左区间,后取右区间
    // 所以先入右区间再入左区间
    int key = partion3(a, left , right);
    // 如果区间内只有1个元素,则无需入栈
    if (key + 1 < right)
    {
      StackPush(&st, key + 1);
      StackPush(&st, right);
    }
    if (left < key - 1)
    {
      StackPush(&st, left);
      StackPush(&st, key - 1);
    }
  }
  StackDestroy(&st);
}



6.9 时空复杂度

对于快排的时间复杂度,本来是不太稳定的,因为处理有序序列或者序列中元素相同的情况下,可能会造成 O ( N 2 ) O(N^{2}) O(N2) 的时间复杂度。


但是当我们 三数取中 或者 三路划分 后,时间复杂度就相对稳定了。


加上这两个功能之后,如果画出每层的元素情况,就像下图一样,像一棵完全二叉树。


由于每次每一块都是被二分,一共 N N N 个节点,所以这边大约就是 l o g N log N logN 层。


样图:


eeb85af5d6c3934e56293b8b5380dfc2.png



那么对于递归的版本,就需要开 l o g N log N logN 层栈帧;对于非递归的版本,原理和递归类似,也认为处理 l o g N log N logN 次。


每次递归/处理中,时间复杂度为 O ( N ) O(N) O(N) 。所以快排的时间复杂度为 O ( N × l o g N ) O(N \times log N) O(N×logN) 。


而对于时间复杂度也因为优化的原因,几乎不会出现极端情况,我们认为最佳情况就是像二叉树一样,最多开辟 l o g N log N logN 层栈帧,根据递归版本根据空间复杂度的计算公式: 递归深度 × 每次递归中额外空间消耗 递归深度 \times 每次递归中额外空间消耗 递归深度×每次递归中额外空间消耗,每次递归消耗空间为 O ( 1 ) O(1) O(1) ,一共 l o g N log N logN 层,所以空间复杂度为 O ( l o g N ) O(log N) O(logN) 。对于非递归的也是一个道理,推一下就明白了~



7、归并排序⭐️


7.1 算法思想


   归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。


如果说快排是前序,那么归并恰巧就是它的对立面,归并排序相当于是二叉树的后序遍历,我们看一张图:


5fb35e077d2bbd2a8c7b08810ce36b52.png


归并排序是逐个分解为一个个小区间,直到不能分割为止,然后一步步 归并起来 ,逐层返回。


而这一过程需要借助一个辅助数组 tmp 来完成归并过程。


对于归并的详细过程可以参考下图:

ccefab42590b448285b221f6a0bbbddf.gif



7.2 归并递归版本


学习过之前我们算法笔记的同学们相信已经对归并有一些了解了,兼顾没怎么了解过的同学,我在这边简单梳理一下思路:


   对于归并排序来说,首先开辟一个辅助数组 t m p tmp tmp 。我们每一次取一个中间点 m i d mid mid 。


   然后按照后序遍历的方式,分别递归左右区间: [ b e g i n , m i d ] [begin, mid] [begin,mid] , [ m i d + 1 , e n d ] [mid + 1, end] [mid+1,end] 一直递归到底部,递归的返回条件为 b e g i n > = e n d begin >= end begin>=end 。


   然后开始归并,设定相关变量,然后将两区间内对应元素由小到大放置到 t m p tmp tmp 数组对应位置处。


   如果放置过程结束,一个数组没有放置完,则需要在循环结束后,将数组的数据全部倒入 tmp 数组中。


   在上面的过程完毕之后,再把 t m p tmp tmp 数组中的数据拷贝回原数组。


   最终,递归逐层返回后,就完成了归并过程。


过程不难,注意点我也都在注释部分标注了:


void _MergeSort(int* a, int begin, int end, int* tmp)
{
  if (begin >= end)
  {
    return;
  }
  int mid = (begin + end) >> 1;
    // 递归到底部
  _MergeSort(a, begin, mid, tmp);
  _MergeSort(a, mid + 1, end, tmp);
  int begin1 = begin, end1 = mid;
  int begin2 = mid + 1, end2 = end;
  /*
  * 第一种拷贝回原数组的方式 - memset
  * 此种做法 cnt 从 begin 开始
  * memset 从 begin 位置开始,一共拷贝 end - begin + 1 个元素
  * 和下面做法道理相同
  */
  // int cnt = begin;
  /*
  * 第二种拷贝回原数组的方式 - 循环拷贝
  * 此种做法 cnt 从 0 开始
  * 开始拷贝的位置从 begin 开始
  * cnt 最终的长度就是 [begin, end] 之间的长度
  * 没问题
  */
  int cnt = 0; 
  while (begin1 <= end1 && begin2 <= end2)
  {
    // 保持稳定性
    if (a[begin1] <= a[begin2])
    {
      tmp[cnt++] = a[begin1++];
    }
    else
    {
      tmp[cnt++] = a[begin2++];
    }
  }
  while (begin1 <= end1) tmp[cnt++] = a[begin1++];
  while (begin2 <= end2) tmp[cnt++] = a[begin2++];
  // 方法1
  // memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
    // 方法2
    for (int i = begin, j = 0; i <= end; i++, j++)
  {
    a[i] = tmp[j];
  }
}
void MergeSort(int* a, int n)
{
  int* tmp = (int*)malloc(sizeof(int) * n);
  if (tmp == NULL)
  {
    perror("mallol fail");
    return;
  }
  _MergeSort(a, 0, n - 1, tmp);
}


7.3 归并排序非递归版本


归并排序的非递归版本在这一块是一个难点,因为这个本身就不容易想到。


我们先想一下,对于归并排序来说,能不能借助辅助数据结构实现?


如果用栈,那是不太行的。因为归并是一种类似二叉树后序遍历的排序,当将区间入栈后,把区间拿出来处理,之后要继续分割时,一段区间可能就不见了,所以借助辅助数据结构时不太行的。


所以我们可以不借助数据结构,用一种相对简单的方法完成。


我们可以设定一个 r a n g e N rangeN rangeN ,控制我们的区间大小, r a n g e N rangeN rangeN 就是归并时每组的数据个数。由于我们是类似二叉树后序遍历的方式,所以我们一开始的归并实际上就是 r a n g e N rangeN rangeN 为 1 1 1 情况。


如下图:


eaea8f870856303b0a642103f95337d3.png


通过每次改变 r a n g e N rangeN rangeN 实际上也就是改变了区间大小,就模拟除了归并递归到底,从小区间合并逐渐到大区间合并的过程。所以我们就让 r a n g e N rangeN rangeN 每次 × 2 \times 2 ×2 ,这样子就是归并每次扩大区间的过程。


但是上面的方法只能解决数组长度恰巧被整除的情况,对于无法被整除的情况可能就会造成越界。

比如 n = 13 n = 13 n=13 。在 r a n g e N = 4 rangeN = 4 rangeN=4 时,最后一段区间 [ 13 , 16 ] [13, 16] [13,16] 越界,所以这里是需要做出一下调整的。


我们设置四个点 b e g i n 1 = i , e n d 1 = i + r a n g e N − 1 , b e g i n 2 = i + r a n g e N , e n d 2 = i + 2 ∗ r a n g e N − 1 begin1 = i, end1 = i + rangeN - 1, begin2 = i + rangeN, end2 = i + 2 * rangeN - 1 begin1=i,end1=i+rangeN−1,begin2=i+rangeN,end2=i+2∗rangeN−1 四个点来规定两段区间。


列举一下,这四个点的越界情况,我们可以分为三种情况:


   e n d 1 , b e g i n 2 , e n d 2 end1, begin2, end2 end1,begin2,end2 越界

abe64ce5ff6d1f960cd09c7f91a3cbc8.png


begin2,end2 越界


9cabe8f241bb0faea0cac09fc124bd00.png

end2 越界


ebe0b0b475df88abd79ef578ea0fd307.png



以上就是三种越界情况,我们需要分别处理:


处理方式分为 修正区间 和 不修正区间 :


修正区间 :


第一种越界情况,实际上就是 e n d 1 ≥ n end1 \ge n end1≥n ,那么这种情况修正区间的话,这时将 e n d 1 = n − 1 end1 = n - 1 end1=n−1 ,之后将没有越界的部分拷贝到 t m p tmp tmp 数组中,然后将 [ b e g i n 2 , e n d 2 ] [begin2, end2] [begin2,end2] 修正为一个不存在的区间。


第二种越界情况,就是 b e g i n 2 ≥ n begin2 \ge n begin2≥n ,这种情况下直接将 [ b e g i n 2 , e n d 2 ] [begin2, end2] [begin2,end2] 修正为不存在的区间即可。


第三种越界情况,就是 e n d 2 ≥ n end2 \ge n end2≥n,这种情况将 e n d 2 = n − 1 end2 = n - 1 end2=n−1 ,让两端区间正常归并。


这种情况可以边归并边拷贝,也可以一组归并完了拷贝。


不修正区间 :


第一种越界情况,修正区间之后实际上就是拷贝的原数组的数据,所以没必要修正, break 掉。


第二种越界情况,实际上也是拷贝原数据,也可以 break 。


但是第三种越界情况,就需要修正一下,否则这次归并无法完成,之后的归并也都错误了,让 e n d 2 = n − 1 end2 = n - 1 end2=n−1 。


这种情况只能边归并边拷贝,因为有些区间是未处理的,如果贸然进行拷贝会把随机值,或者错误数据拷贝进来。


好了,到这边,归并非递归的思路我们就理完了,接下来我把两个版本都写下来 :


修正区间:


void MergeSortNonR(int* a, int n)
{
  int* tmp = (int*)malloc(sizeof(int) * n);
  if (tmp == NULL)
  {
    perror("malloc fail");
    exit(-1);
  }
  int rangeN = 1;
  while (rangeN < n)
  {
        // 一组归并的跨距为 2 * rangeN 
    for (int i = 0; i < n; i += 2 * rangeN)
    {
      int begin1 = i, end1 = i + rangeN - 1;
      int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
      int j = i;
      // 修正区间
      if (end1 >= n)
      {
        end1 = n - 1;
        // begin2 和 end2 修正为不存在的区间
        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[j++] = a[begin1++];
        }
        else
        {
          tmp[j++] = a[begin2++];
        }
      }
      while (begin1 <= end1)
      {
        tmp[j++] = a[begin1++];
      }
      while (begin2 <= end2)
      {
        tmp[j++] = a[begin2++];
      }
      // 可以局部拷贝
      //memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
    }
    memcpy(a, tmp, sizeof(int) * n);
    rangeN *= 2;
  }
  free(tmp);
  tmp = NULL;
}


不修正区间

void MergeSortNonR(int* a, int n)
{
  int* tmp = (int*)malloc(sizeof(int) * n);
  if (tmp == NULL)
  {
    perror("malloc fail");
    exit(-1);
  }
  int rangeN = 1;
  while (rangeN < n)
  {
    for (int i = 0; i < n; i += 2 * rangeN)
    {
      int begin1 = i, end1 = i + rangeN - 1;
      int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
      int j = i;
      if (end1 >= n)
      {
        break;
      }
      else if (begin2 >= n)
      {
        break;
      }
      else if (end2 >= n)
      {
        end2 = n - 1;
        //break;
      }
      while (begin1 <= end1 && begin2 <= end2)
      {
        // 保持稳定性
        if (a[begin1] <= a[begin2])
        {
          tmp[j++] = a[begin1++];
        }
        else
        {
          tmp[j++] = a[begin2++];
        }
      }
      while (begin1 <= end1) tmp[j++] = a[begin1++];
      while (begin2 <= end2) tmp[j++] = a[begin2++];
      memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
    }
        // 这里不能外部拷贝,因为有些情况是直接 break 出来的,tmp 中不是正确数据
        // memcpy(a, tmp, sizeof(int) * n); // 会把错误数据拷入
    rangeN *= 2;
  }
  free(tmp);
  tmp = NULL;
}


7.4 时空复杂度


对于归并递归版本,每次都是区间二分,然后开始递归的。所以递归层数是严格的 l o g N log N logN ,每次递归中时间复杂度为 O ( N ) O(N) O(N) ,所以总体时间复杂度为 O ( N × l o g N ) O(N \times log N) O(N×logN) ;对于非递归, r a n g e N rangeN rangeN 每次乘 2 2 2 ,每次 r a n g e N rangeN rangeN 处理的时间复杂度为 O ( N ) O(N) O(N) ,时间复杂度也是 O ( N × l o g N ) O(N \times log N) O(N×logN)。


对于归并排序的空间复杂度,递归和非递归有一些计算上的区别,但是结果不影响。


归并排序首先需要一个 t m p tmp tmp 数组,空间复杂度为 O ( N ) O(N) O(N) 。如果对于递归,还会开 l o g N log N logN 层栈帧,所以递归版本消耗的总空间大月为 N + l o g N N + log N N+logN ,当 N N N 足够大时, l o g N log N logN 省略,所以为 O ( N ) O(N) O(N);对于非递归,那么就仅仅只有 t m p tmp tmp 的消耗。


所以综上所述,归并的空间复杂度为 O ( N ) O(N) O(N)。







相关文章
|
14天前
|
C语言
【数据结构】栈和队列(c语言实现)(附源码)
本文介绍了栈和队列两种数据结构。栈是一种只能在一端进行插入和删除操作的线性表,遵循“先进后出”原则;队列则在一端插入、另一端删除,遵循“先进先出”原则。文章详细讲解了栈和队列的结构定义、方法声明及实现,并提供了完整的代码示例。栈和队列在实际应用中非常广泛,如二叉树的层序遍历和快速排序的非递归实现等。
90 9
|
13天前
|
存储 搜索推荐 算法
【数据结构】树型结构详解 + 堆的实现(c语言)(附源码)
本文介绍了树和二叉树的基本概念及结构,重点讲解了堆这一重要的数据结构。堆是一种特殊的完全二叉树,常用于实现优先队列和高效的排序算法(如堆排序)。文章详细描述了堆的性质、存储方式及其实现方法,包括插入、删除和取堆顶数据等操作的具体实现。通过这些内容,读者可以全面了解堆的原理和应用。
56 16
|
13天前
|
C语言
【数据结构】二叉树(c语言)(附源码)
本文介绍了如何使用链式结构实现二叉树的基本功能,包括前序、中序、后序和层序遍历,统计节点个数和树的高度,查找节点,判断是否为完全二叉树,以及销毁二叉树。通过手动创建一棵二叉树,详细讲解了每个功能的实现方法和代码示例,帮助读者深入理解递归和数据结构的应用。
62 8
|
16天前
|
存储 C语言
【数据结构】手把手教你单链表(c语言)(附源码)
本文介绍了单链表的基本概念、结构定义及其实现方法。单链表是一种内存地址不连续但逻辑顺序连续的数据结构,每个节点包含数据域和指针域。文章详细讲解了单链表的常见操作,如头插、尾插、头删、尾删、查找、指定位置插入和删除等,并提供了完整的C语言代码示例。通过学习单链表,可以更好地理解数据结构的底层逻辑,提高编程能力。
44 4
|
17天前
|
存储 C语言
【数据结构】顺序表(c语言实现)(附源码)
本文介绍了线性表和顺序表的基本概念及其实现。线性表是一种有限序列,常见的线性表有顺序表、链表、栈、队列等。顺序表是一种基于连续内存地址存储数据的数据结构,其底层逻辑是数组。文章详细讲解了静态顺序表和动态顺序表的区别,并重点介绍了动态顺序表的实现,包括初始化、销毁、打印、增删查改等操作。最后,文章总结了顺序表的时间复杂度和局限性,并预告了后续关于链表的内容。
49 3
|
1月前
|
Java C++ 索引
让星星⭐月亮告诉你,LinkedList和ArrayList底层数据结构及方法源码说明
`LinkedList` 和 `ArrayList` 是 Java 中两种常见的列表实现。`LinkedList` 基于双向链表,适合频繁的插入和删除操作,但按索引访问元素效率较低。`ArrayList` 基于动态数组,支持快速随机访问,但在中间位置插入或删除元素时性能较差。两者均实现了 `List` 接口,`LinkedList` 还额外实现了 `Deque` 接口,提供了更多队列操作。
23 3
|
1月前
|
算法 搜索推荐 Java
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
基数排序是一种稳定的排序算法,通过将数字按位数切割并分配到不同的桶中,以空间换时间的方式实现快速排序,但占用内存较大,不适合含有负数的数组。
23 0
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
|
16天前
|
C语言
【数据结构】双向带头循环链表(c语言)(附源码)
本文介绍了双向带头循环链表的概念和实现。双向带头循环链表具有三个关键点:双向、带头和循环。与单链表相比,它的头插、尾插、头删、尾删等操作的时间复杂度均为O(1),提高了运行效率。文章详细讲解了链表的结构定义、方法声明和实现,包括创建新节点、初始化、打印、判断是否为空、插入和删除节点等操作。最后提供了完整的代码示例。
37 0
|
1月前
|
存储 搜索推荐 算法
【用Java学习数据结构系列】七大排序要悄咪咪的学(直接插入,希尔,归并,选择,堆排,冒泡,快排)以及计数排序(非比较排序)
【用Java学习数据结构系列】七大排序要悄咪咪的学(直接插入,希尔,归并,选择,堆排,冒泡,快排)以及计数排序(非比较排序)
22 1
|
1月前
|
搜索推荐 索引
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(二)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理

热门文章

最新文章