【探索排序算法的魅力:优化、性能与实用技巧】(下)

简介: 【探索排序算法的魅力:优化、性能与实用技巧】

【探索排序算法的魅力:优化、性能与实用技巧】(中):https://developer.aliyun.com/article/1425258


根据上面的算法,要排序的数是一组数据量极大的重复数字,此时就是上面的第二种写法,此时一直++cur就可以排序好这个数组,时间复杂度就是O(N)。

void PartSort(int *a,int left,int right)
{
  if(left >= left)
    return;
  // 三数取中 
  int midi = GetMidi(a,left,right);
  Swap(&a[midi],&a[left]);
  int begin = left;
  int end = right;
  int key = left;
  int cur = left;
  while(cur <= right)
  {
    if(a[key] > a[cur])
    {
      Swap(&a[key],&a[left]);
      ++cur;
      ++left;
    }
    else if(a[key == a[cur])
    {
      ++cur;
    }
    else
    {
      Swap(&a[key],&a[right]);
      --right;
    }
  }
  // [begin,left-1][left,right][right+1,end]
  PartSort(a,begin,left-1);
  PartSort(a,right+1,end);
}


我们现在再来想一下快排的效率,当数组的数据每次交换后,key就是中间位置,那么此时的时间复杂度就是O(N*logN),但是当数组数据有序的时候,key每次就是第一个位置,那么此时的时间复杂度就是O(N^2),那么此时怎么优化呢???


1. 三数取中法选key

2. 递归到小的子区间时,可以考虑使用插入排序


三数取中法选key:有了三数取中,快排就不会出现最坏情况。

//三数取中
int GetMidi(int* a, int left, int right)
{
  int mid = (left + right) / 2;
  if (a[left] < a[mid])
  {
    if (a[right] > a[mid])
    {
      return mid;
    }
    else if(a[right] < a[left]) //mid最大
    {
      return left;
    }
    else
    {
      return right;
    }
  }
  else //a[left] > a[mid]
  {
    if (a[mid] > a[right])
    {
      return mid;
    }
    else if (a[left] > a[right]) //mid最小
    {
      return right;
    }
    else
    {
      return left;
    }
  }
}
int PartSort(int* a, int left, int right)
{
  int midi = GetMidi(a, left, right);
  Swap(&a[left], &a[midi]);
  ......//三种PartSort任意一种
}


根据完全二叉树的特点,最后一层的节点个数占总数的50%。对比到快速排序的递归而言,递归层数越深,每层递归的次数变多,消耗也是越大的。我们拿10个数据对比一下:


我们要快速排序10个数,就要递归3层,消耗太多,非常的不划算。因此递归到小的子区间时,可以考虑使用插入排序。该小区间就可以设置为只剩下10个数时候开始使用直接插入排序,最后一层的节点个数占总数的50%,倒数第二层的节点个数占总数的25%,倒数第三层的节点个数占总数的12.5%,根据上面的计算,能优化87.5%递归算法。


递归到小的子区间时,可以考虑使用直接插入排序。

// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
  //= 代表只剩下一个值
  if (left >= right)
    return;
  //将剩下的10个元素进行直接插入排序
  if ((right - left + 1) > 10)
  {
    // 按照基准值对array数组的 [left, right)区间中的元素进行划分
    int div = PartSort(array, left, right);
    // 划分成功后以div为边界形成了左右两部分 [left, div-1) 和 [div+1, right)
    // 递归排[left, div-1)
    QuickSort(array, left, div - 1);
    // 递归排[div+1, right)
    QuickSort(array, div + 1, right);
  }
  else
  {
    //指针+-跳过指向类型大小
    InsertSort(array + left, right - left + 1);
  }
}


掌握了递归思路的快速排序,我们再来掌握一下非递归思路的快速排序,非递归的快速排序需要使用栈来解决。我们先处理左边数据,再处理右边数据,根据栈先进后出的特点,因此右边数据先入栈,左边数据再入栈。这里也可以使用队列实现,不过队列不是先处理左边数据,再处理右边数据而是是一层一层处理,所以这里我们不用队列,使用栈能更好体现非递归的思路。

//导入之前写的栈实现接口
#include "Stack.h"
void QuickSortNonR(int* a, int left, int right)
{
  Stack st;
  StackInit(&st);
  //先进后出,先入右,再入左
  StackPush(&st, right);
  StackPush(&st, left);
  while (!StackEmpty(&st))
  {
    int left = StackTop(&st);
    StackPop(&st);
    int right = StackTop(&st);
    StackPop(&st);
    int keyi = PartSort(a, left, right);
    //分为三个区间:[left,keyi-1] keyi [keyi+1,right]
    //先入右区间,再入左区间
    if (keyi + 1 < right)
    {
      StackPush(&st, right);
      StackPush(&st, keyi + 1);
    }
    if (left < keyi - 1)
    {
      StackPush(&st, keyi - 1);
      StackPush(&st, left);
    }
  }
  StackDestroy(&st);
}


这里提出一个问题:递归是使用的系统栈,而上面的栈是使用的人工栈,栈的深度不是一样的嘛,有什么区别???

人工栈是通过数组实现,是在堆上开辟的,而递归使用的系统栈,系统会自动为每个函数调用分配一帧。递归的栈深度受系统栈的限制,通常比人工栈小得多,系统栈很容易满栈。

#include <stdio.h>
int Func(int n)
{
  if (n == 0)
    return 0;
  return Func(n - 1) + n;
}
int main()
{
  printf("%d\n", Func(10000));
  return 0;
}



我们可以看到当n为5215时,我们的系统栈就满了,递归是由消耗的,所以掌握非递归的快速排序是非常有意义的。


快速排序的特性总结:

  • 1.快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
  • 2.时间复杂度:O(N*logN)

  • 3.空间复杂度:O(logN)
  • 4.稳定性:不稳定


2.4 归并排序


2.4.1基本思想


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


2.4.2归并排序


我们这里的归并是让小区间数据有序,先将数组分为若干小区间,然后将小区间的数按照从小到大的顺序尾插到tmp数组中,再拷贝回原数组,之后再让两个小区间的数尾插插到tmp数组中,再拷贝回原数组......依次,直至整个数组有序。

//为防止每次递归调用都会malloc空间,这里写一个子函数
void _MergeSort(int* a, int left, int right, int* tmp)
{
  if (right <= left)
    return;
  int mid = (right + left) / 2;
  //分割
  //[left,mid] [mid+1,right]
  _MergeSort(a, left, mid, tmp);
  _MergeSort(a, mid + 1, right, tmp);
  //归并到tmp数组,再拷贝回去
  // a->[left,mid] [mid+1,right]->tmp
  int begin1= left, end1 = mid;
  int begin2 = mid + 1, end2 = right;
  int index = left;
  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++];
  }
  //把tmp的数组归并到原数组上
  memcpy(a + left, tmp + left, (right - left + 1) * sizeof(int));
}
void MergeSort(int* a, int n)
{
  int* tmp = (int*)malloc(sizeof(int) * n);
  if (tmp == NULL)
  {
    perror("malloc fail");
    return;
  }
  _MergeSort(a, 0, n - 1, tmp);
  free(tmp);
}


递归图:


上面的快速排序我们写了非递归的写法,它是一种很明显的前序方法,左边排完序就不需要再管了,而这里的归并是否也有非递归的写法呢?很明显,归并排序当人也有非递归的写法,但是我们这里的归并是一种后序,排完左边的序列还需要回到根,比如上面的 10 6 7 1,左边排序完是 6 10,右边排序完是 1 7,其序列还未有序,需要回到根后再排序,比较麻烦,这里我们就不使用栈的方法呢?这里我们可以借鉴一下斐波那契数列的非递归的方法。


我们怎么才能实现上面的归并呢?我们可以定义一个gap,通过gap确定每次排序的区间。我们先来实现一下一一归并的写法。

void MergeSortNonR(int* a, int n)
{
  int* tmp = (int*)malloc(sizeof(int) * n);
  if (tmp == NULL)
  {
    perror("malloc fail");
    return;
  }
  int gap = 1;
  //i的位置依次是0,2,4......
  for (int i = 0; i < n; i += 2 * gap)
  {
    int begin1 = i, end1 = i + gap - 1;//[0,0]
    int begin2 = i + gap,end2 = i + 2 * gap - 1;//[1,1]
    //第一次一一归并的两个区间[0,0] [1,1]
    //第二次一一归并的两个区间[2,2] [3,3]
    int index = i;
    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++];
    }
    //拷贝回原数组
    memcpy(a + i, tmp + i, 2 * gap * sizeof(int));
  }
  free(tmp);
}


所以我们只需要控制gap就可以实现非递归的归并排序。gap的取值是1,2,4.......当gap小于数组的元素就停止。

void MergeSortNonR(int* a, int n)
{
  int* tmp = (int*)malloc(sizeof(int) * n);
  if (tmp == NULL)
  {
    perror("malloc fail");
    return;
  }
  int gap = 1;
  while (gap < n)
  {
    //i的位置依次是0,2,4......
    for (int i = 0; i < n; i += 2 * gap)
    {
      int begin1 = i, end1 = i + gap - 1;//[0,0]
      int begin2 = i + gap, end2 = i + 2 * gap - 1;//[1,1]
      //第一次一一归并的两个区间[0,0] [1,1]
      //第二次一一归并的两个区间[2,2] [3,3]
      int index = i;
      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++];
      }
      //拷贝回原数组
      memcpy(a + i, tmp + i, 2 * gap * sizeof(int));
    }
    gap *=2;
  }
  free(tmp);
}


上面的数据刚好是2的整个倍,可是如果数据是9个呢?


此时我们再进行归并就会出现越界的问题。数据个数为9,下标为9及以上的都是越界。


此时每次归并上都出现了越界的问题,越界的问题都出现再end1,begin2和end2上面。这里我们需要想一个问题,我们每次排序的数据必须成对出现才能归并嘛?其实,如果数据不成对出现,我们就不要归并这数据,因为它本身就是独自出现,本身就可以当作有序。所以我们只用加下面的代码就可以了。如果越界了,这组数据就不用管了,直接退出即可。但是当只有end2越界的时候,此时需要归并,因为此时有两组数据需要归并。

//如果第二组不存在,这一组就不用归并了
if (end1 >= n)
{
  break;
}
//如果第二组的右边界越界,修正下标
if (end2 >= n)
{
  //此时需要归并,只用修改下标即可
  end2 = n - 1;
}

同时我们还需要改一下memcpy拷贝的个数

memcpy(a + i, tmp + i, (end2 - i + 1) * sizeof(int));


归并排序的特性总结:

  • 1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
  • 2. 时间复杂度:O(N*logN)
  • 3. 空间复杂度:O(N)
  • 4. 稳定性:稳定


2.5 非比较排序


思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤:


  • 1. 统计相同元素出现次数
  • 2. 根据统计的结果将序列回收到原来的序列中


上面的这种就是我们的计数排序,如果我们的数据是101、105、199,我们再通过上面的方法就要开辟200个空间大小的数组,就会存在很大的空间浪费,所以我们就要不能使用绝对映射(一个数据存在对应的下标下面),这里需要使用我们的相对映射(最大值-最小值获取区间)(根据a[i] - min)就只用开辟相对较少的空间。

void CountSort(int* a, int n)
{
  int max = a[0];
  int min = a[0];
  for (int i = 1; i < n; i++)
  {
    if (a[i] < min)
      min = a[i];
    if (a[i] > max)
      max = a[i];
  }
  //开辟计数的空间
  int range = max - min + 1;
  int* count = (int*)malloc(sizeof(int) * range);
  if (count == NULL)
  {
    perror("malloc fail");
    return;
  }
  memset(count, 0, sizeof(int) * range);
  //统计数据出现的次数
  for (int i = 0; i < n; i++)
  {
    count[a[i] - min]++;
  }
  //排序
  int j = 0;
  for (int i = 0; i < range; i++)
  {
    while (count[i]--) 
    {
      a[j++] = i + min;
    }
  }
}


计数排序的特性总结:

  • 1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
  • 2. 时间复杂度:O(MAX(N,范围))
  • 3. 空间复杂度:O(范围)
  • 4. 稳定性:稳定
  • 5.局限性:只能排序整型数据


2.6内排序和外排序


内排序和外排序是计算机科学中与排序算法相关的两个重要概念。

  1. 内排序(In-Place Sorting):
  • 内排序是指在排序过程中,所有数据都存储在计算机的内存中进行排序的方法。
  • 这意味着排序算法不需要使用外部存储(如硬盘或其他存储设备)来存储数据。
  • 内排序的优点是速度较快,因为内存访问通常比外部存储快得多。
  • 常见的内排序算法包括冒泡排序、选择排序、插入排序、快速排序、归并排序等。


  1. 外排序(External Sorting):
  • 外排序是指排序的数据量太大,无法完全放入计算机的内存中,因此需要使用外部存储设备来辅助排序的方法。
  • 外排序通常涉及到将大量数据分割成较小的块,分别在内存和外部存储之间进行排序,然后再将这些排序好的块合并起来以获得最终的有序结果。
  • 外排序的主要应用场景是处理大型数据集,如数据库排序、外部存储设备上的大文件排序等。
  • 常见的外排序算法包括归并排序、多路归并排序等。


假设我们当前的内存是1G,当我们要排序一个10亿个整型数据的时候,要怎么排序呢?


  • 10亿个整型数据 = 1024 * 1024 *1024 Byte * 4 = 4G > 内存1G,在内存中无法排序。
  • 4G的整型数据太大而无法一次性加载到内存中,需要使用外排序。
  • 外排序通常涉及将数据分成多个小块,每个小块可以适应内存大小。
  • 首先,将数据分块并逐块加载到内存中,对每个块使用内排序算法进行排序。
  • 排序后的块可以写回磁盘或者合并成更大的块。
  • 最后,进行块之间的合并操作,以获得最终排序结果。


3.排序算法复杂度及稳定性分析


稳定性:相同的数据进行排序后,其相对位置没有发生变化,就说明该排序具有稳定性。


4.选择题练习


1. 快速排序算法是基于( )的一个排序算法。

A分治法

B贪心法

C递归法

D动态规划法

解析:快速排序是基于分治法的一个排序算法。


2.对记录(54,38,96,23,15,72,60,45,83)进行从小到大的直接插入排序时,当把第8个记录45插入到有序表时,为找到插入位置需比较( )次?(采用从后往前比较)

A 3

B 4

C 5

D 6

解析:第8个记录45插入到有序表时,前7个数据已经有序(15,23,38,54,60,72,96),次数依次向前比较,需要比较5次。


3.以下排序方式中占用O(n)辅助存储空间的是

A 简单排序

B 快速排序

C 堆排序

D 归并排序

解析:归并排序需要将小区间排序的结果保存下来,然后再拷贝到原数组上


4.下列排序算法中稳定且时间复杂度为O(n2)的是( )

A 快速排序

B 冒泡排序

C 直接选择排序

D 归并排序


5.关于排序,下面说法不正确的是

A 快排时间复杂度为O(N*logN),空间复杂度为O(logN)

B 归并排序是一种稳定的排序,堆排序和快排均不稳定

C 序列基本有序时,快排退化成冒泡排序,直接插入排序最快

D 归并排序空间复杂度为O(N), 堆排序空间复杂度的为O(logN)


6.下列排序法中,最坏情况下时间复杂度最小的是( )

A 堆排序

B 快速排序

C 希尔排序

D 冒泡排序


7.设一组初始记录关键字序列为(65,56,72,99,86,25,34,66),则以第一个关键字65为基准而得到的一趟快速排序结果是()

A 34,56,25,65,86,99,72,66

B 25,34,56,65,99,86,72,66

C 34,56,25,65,66,99,86,72

D 34,56,25,65,99,86,72,66

解析:这里采用的是挖坑法,右边先找小,左边再找到,最后将关键字65放到坑位。


相关文章
|
6天前
|
存储 监控 NoSQL
Redis处理大量数据主要依赖于其内存存储结构、高效的数据结构和算法,以及一系列的优化策略
【5月更文挑战第15天】Redis处理大量数据依赖内存存储、高效数据结构和优化策略。选择合适的数据结构、利用批量操作减少网络开销、控制批量大小、使用Redis Cluster进行分布式存储、优化内存使用及监控调优是关键。通过这些方法,Redis能有效处理大量数据并保持高性能。
27 0
|
4天前
|
算法
MATLAB|【免费】融合正余弦和柯西变异的麻雀优化算法SCSSA-CNN-BiLSTM双向长短期记忆网络预测模型
这段内容介绍了一个使用改进的麻雀搜索算法优化CNN-BiLSTM模型进行多输入单输出预测的程序。程序通过融合正余弦和柯西变异提升算法性能,主要优化学习率、正则化参数及BiLSTM的隐层神经元数量。它利用一段简单的风速数据进行演示,对比了改进算法与粒子群、灰狼算法的优化效果。代码包括数据导入、预处理和模型构建部分,并展示了优化前后的效果。建议使用高版本MATLAB运行。
|
6天前
|
资源调度 算法 块存储
m基于遗传优化的LDPC码OMS译码算法最优偏移参数计算和误码率matlab仿真
MATLAB2022a仿真实现了遗传优化的LDPC码OSD译码算法,通过自动搜索最佳偏移参数ΔΔ以提升纠错性能。该算法结合了低密度奇偶校验码和有序统计译码理论,利用遗传算法进行全局优化,避免手动调整,提高译码效率。核心程序包括编码、调制、AWGN信道模拟及软输入软输出译码等步骤,通过仿真曲线展示了不同SNR下的误码率性能。
10 1
|
6天前
|
算法 Serverless
m基于遗传优化的LDPC码NMS译码算法最优归一化参数计算和误码率matlab仿真
MATLAB 2022a仿真实现了遗传优化的归一化最小和(NMS)译码算法,应用于低密度奇偶校验(LDPC)码。结果显示了遗传优化的迭代过程和误码率对比。遗传算法通过选择、交叉和变异操作寻找最佳归一化因子,以提升NMS译码性能。核心程序包括迭代优化、目标函数计算及性能绘图。最终,展示了SNR与误码率的关系,并保存了关键数据。
19 1
|
6天前
|
算法 调度
考虑需求响应的微网优化调度模型【粒子群算法】【matlab】
考虑需求响应的微网优化调度模型【粒子群算法】【matlab】
|
6天前
|
算法 调度
基于多目标粒子群算法冷热电联供综合能源系统运行优化(matlab代码)
基于多目标粒子群算法冷热电联供综合能源系统运行优化(matlab代码)
|
6天前
|
算法
【免费】面向多微网网络结构设计的大规模二进制矩阵优化算法
【免费】面向多微网网络结构设计的大规模二进制矩阵优化算法
|
6天前
|
算法 调度
【问题探讨】基于非支配排序的蜣螂优化算法NSDBO求解微电网多目标优化调度研究
【问题探讨】基于非支配排序的蜣螂优化算法NSDBO求解微电网多目标优化调度研究
|
6天前
|
算法
基于蜣螂优化算法DBO的VMD-KELM光伏发电功率预测(matlab代码+可提供讲解)
基于蜣螂优化算法DBO的VMD-KELM光伏发电功率预测(matlab代码+可提供讲解)
|
6天前
|
算法
基于白鲸优化算法BWO的VMD-KELM光伏发电功率预测(matlab代码+可提供讲解)
基于白鲸优化算法BWO的VMD-KELM光伏发电功率预测(matlab代码+可提供讲解)