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

本文涉及的产品
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
简介: 【探索排序算法的魅力:优化、性能与实用技巧】

【探索排序算法的魅力:优化、性能与实用技巧】(中):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放到坑位。


相关文章
|
9天前
|
算法
基于GA遗传算法的PID控制器参数优化matlab建模与仿真
本项目基于遗传算法(GA)优化PID控制器参数,通过空间状态方程构建控制对象,自定义GA的选择、交叉、变异过程,以提高PID控制性能。与使用通用GA工具箱相比,此方法更灵活、针对性强。MATLAB2022A环境下测试,展示了GA优化前后PID控制效果的显著差异。核心代码实现了遗传算法的迭代优化过程,最终通过适应度函数评估并选择了最优PID参数,显著提升了系统响应速度和稳定性。
|
6天前
|
算法
基于WOA鲸鱼优化的购售电收益与风险评估算法matlab仿真
本研究提出了一种基于鲸鱼优化算法(WOA)的购售电收益与风险评估算法。通过将售电公司购售电收益风险计算公式作为WOA的目标函数,经过迭代优化计算出最优购电策略。实验结果表明,在迭代次数超过10次后,风险价值收益优化值达到1715.1万元的最大值。WOA还确定了中长期市场、现货市场及可再生能源等不同市场的最优购电量,验证了算法的有效性。核心程序使用MATLAB2022a实现,通过多次迭代优化,实现了售电公司收益最大化和风险最小化的目标。
|
10天前
|
算法
通过matlab分别对比PSO,反向学习PSO,多策略改进反向学习PSO三种优化算法
本项目使用MATLAB2022A版本,对比分析了PSO、反向学习PSO及多策略改进反向学习PSO三种优化算法的性能,主要通过优化收敛曲线进行直观展示。核心代码实现了标准PSO算法流程,加入反向学习机制及多种改进策略,以提升算法跳出局部最优的能力,增强全局搜索效率。
|
6天前
|
算法
通过matlab对比遗传算法优化前后染色体的变化情况
该程序使用MATLAB2022A实现遗传算法优化染色体的过程,通过迭代选择、交叉和变异操作,提高染色体适应度,优化解的质量,同时保持种群多样性,避免局部最优。代码展示了算法的核心流程,包括适应度计算、选择、交叉、变异等步骤,并通过图表直观展示了优化前后染色体的变化情况。
|
10天前
|
算法
基于大爆炸优化算法的PID控制器参数寻优matlab仿真
本研究基于大爆炸优化算法对PID控制器参数进行寻优,并通过Matlab仿真对比优化前后PID控制效果。使用MATLAB2022a实现核心程序,展示了算法迭代过程及最优PID参数的求解。大爆炸优化算法通过模拟宇宙大爆炸和大收缩过程,在搜索空间中迭代寻找全局最优解,特别适用于PID参数优化,提升控制系统性能。
|
10天前
|
机器学习/深度学习 算法 数据安全/隐私保护
基于贝叶斯优化CNN-GRU网络的数据分类识别算法matlab仿真
本项目展示了使用MATLAB2022a实现的贝叶斯优化、CNN和GRU算法优化效果。优化前后对比显著,完整代码附带中文注释及操作视频。贝叶斯优化适用于黑盒函数,CNN用于时间序列特征提取,GRU改进了RNN的长序列处理能力。
|
8天前
|
算法 决策智能
基于遗传优化算法的TSP问题求解matlab仿真
本项目使用遗传算法解决旅行商问题(TSP),目标是在四个城市间找到最短路径。算法通过编码、选择、交叉、变异等步骤,在MATLAB2022A上实现路径优化,最终输出最优路径及距离。
|
7天前
|
算法
基于WOA算法的SVDD参数寻优matlab仿真
该程序利用鲸鱼优化算法(WOA)对支持向量数据描述(SVDD)模型的参数进行优化,以提高数据分类的准确性。通过MATLAB2022A实现,展示了不同信噪比(SNR)下模型的分类误差。WOA通过模拟鲸鱼捕食行为,动态调整SVDD参数,如惩罚因子C和核函数参数γ,以寻找最优参数组合,增强模型的鲁棒性和泛化能力。
|
13天前
|
机器学习/深度学习 算法 Serverless
基于WOA-SVM的乳腺癌数据分类识别算法matlab仿真,对比BP神经网络和SVM
本项目利用鲸鱼优化算法(WOA)优化支持向量机(SVM)参数,针对乳腺癌早期诊断问题,通过MATLAB 2022a实现。核心代码包括参数初始化、目标函数计算、位置更新等步骤,并附有详细中文注释及操作视频。实验结果显示,WOA-SVM在提高分类精度和泛化能力方面表现出色,为乳腺癌的早期诊断提供了有效的技术支持。
|
4天前
|
机器学习/深度学习 算法 数据安全/隐私保护
基于深度学习网络的宝石类型识别算法matlab仿真
本项目利用GoogLeNet深度学习网络进行宝石类型识别,实验包括收集多类宝石图像数据集并按7:1:2比例划分。使用Matlab2022a实现算法,提供含中文注释的完整代码及操作视频。GoogLeNet通过其独特的Inception模块,结合数据增强、学习率调整和正则化等优化手段,有效提升了宝石识别的准确性和效率。