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

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

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


相关文章
|
12天前
|
算法 调度
基于遗传模拟退火混合优化算法的车间作业最优调度matlab仿真,输出甘特图
车间作业调度问题(JSSP)通过遗传算法(GA)和模拟退火算法(SA)优化多个作业在并行工作中心上的加工顺序和时间,以最小化总完成时间和机器闲置时间。MATLAB2022a版本运行测试,展示了有效性和可行性。核心程序采用作业列表表示法,结合遗传操作和模拟退火过程,提高算法性能。
|
12天前
|
机器学习/深度学习 算法 5G
基于MIMO系统的SDR-AltMin混合预编码算法matlab性能仿真
基于MIMO系统的SDR-AltMin混合预编码算法通过结合半定松弛和交替最小化技术,优化大规模MIMO系统的预编码矩阵,提高信号质量。Matlab 2022a仿真结果显示,该算法能有效提升系统性能并降低计算复杂度。核心程序包括预编码和接收矩阵的设计,以及不同信噪比下的性能评估。
31 3
|
12天前
|
人工智能 算法 大数据
Linux内核中的调度算法演变:从O(1)到CFS的优化之旅###
本文深入探讨了Linux操作系统内核中进程调度算法的发展历程,聚焦于O(1)调度器向完全公平调度器(CFS)的转变。不同于传统摘要对研究背景、方法、结果和结论的概述,本文创新性地采用“技术演进时间线”的形式,简明扼要地勾勒出这一转变背后的关键技术里程碑,旨在为读者提供一个清晰的历史脉络,引领其深入了解Linux调度机制的革新之路。 ###
|
15天前
|
机器学习/深度学习 算法 数据挖掘
提高时钟置换算法的性能
【10月更文挑战第25天】通过上述一种或多种方法的综合应用,可以在不同程度上提高时钟置换算法的性能,使其更好地适应各种复杂的系统环境和应用场景,提高虚拟内存管理的效率和系统的整体性能。
35 5
|
23天前
|
人工智能 算法 数据安全/隐私保护
基于遗传优化的SVD水印嵌入提取算法matlab仿真
该算法基于遗传优化的SVD水印嵌入与提取技术,通过遗传算法优化水印嵌入参数,提高水印的鲁棒性和隐蔽性。在MATLAB2022a环境下测试,展示了优化前后的性能对比及不同干扰下的水印提取效果。核心程序实现了SVD分解、遗传算法流程及其参数优化,有效提升了水印技术的应用价值。
|
22天前
|
存储 缓存 算法
优化轮询算法以提高资源分配的效率
【10月更文挑战第13天】通过以上这些优化措施,可以在一定程度上提高轮询算法的资源分配效率,使其更好地适应不同的应用场景和需求。但需要注意的是,优化策略的选择和实施需要根据具体情况进行详细的分析和评估,以确保优化效果的最大化。
|
23天前
|
并行计算 算法 IDE
【灵码助力Cuda算法分析】分析共享内存的矩阵乘法优化
本文介绍了如何利用通义灵码在Visual Studio 2022中对基于CUDA的共享内存矩阵乘法优化代码进行深入分析。文章从整体程序结构入手,逐步深入到线程调度、矩阵分块、循环展开等关键细节,最后通过带入具体值的方式进一步解析复杂循环逻辑,展示了通义灵码在辅助理解和优化CUDA编程中的强大功能。
|
23天前
|
存储 缓存 算法
前端算法:优化与实战技巧的深度探索
【10月更文挑战第21天】前端算法:优化与实战技巧的深度探索
20 1
|
26天前
|
算法 安全 数据安全/隐私保护
基于game-based算法的动态频谱访问matlab仿真
本算法展示了在认知无线电网络中,通过游戏理论优化动态频谱访问,提高频谱利用率和物理层安全性。程序运行效果包括负载因子、传输功率、信噪比对用户效用和保密率的影响分析。软件版本:Matlab 2022a。完整代码包含详细中文注释和操作视频。
|
11天前
|
算法 数据挖掘 数据安全/隐私保护
基于FCM模糊聚类算法的图像分割matlab仿真
本项目展示了基于模糊C均值(FCM)算法的图像分割技术。算法运行效果良好,无水印。使用MATLAB 2022a开发,提供完整代码及中文注释,附带操作步骤视频。FCM算法通过隶属度矩阵和聚类中心矩阵实现图像分割,适用于灰度和彩色图像,广泛应用于医学影像、遥感图像等领域。