【数据结构】排序(插入、选择、交换、归并) -- 详解(下)

简介: 【数据结构】排序(插入、选择、交换、归并) -- 详解(下)

【数据结构】排序(插入、选择、交换、归并) -- 详解(上)https://developer.aliyun.com/article/1514538?spm=a2c6h.13148508.setting.25.4b904f0ejdbHoA

(4)快速排序优化 · 三数取中法
  • 三数取中法依然无法完全解决针对某种特殊序列(比如元素全部相同)复杂度变为 O(n) 的情况。
  • 三数取中法一般选取首、尾和正中三个数进行取中。
  • 该方法的基本思想是从待排序数组中随机选择三个元素,并将它们按升序排列。然后,选择中间位置的元素作为 mid 。

这样处理的方式有几个优点,可以提高排序算法的效率:

  1. 减少最坏情况:选择中间位置的元素,可以避免最坏情况的发生。最坏情况是在每次划分过程中总是选择了最大或最小的元素,导致划分不平衡,使得排序算法的时间复杂度变为 O(n²) 。而三数取中法可以减少最坏情况的发生,提高划分的平衡性。
  2. 提高划分效率:选择适当的中间元素可以增加划分的平衡性,使得每次划分都能将数组分为近似相等大小的两个部分。这样,在后续的排序过程中,可以减少比较和交换的次数,提高排序算法的效率。
  3. 三数取中法可以有效地应对一些特殊情况,如数组已经有序或部分有序的情况。在这些情况下,选择中间位置的元素可以避免不必要的划分和比较操作。
  • 总的来说,三数取中法通过选择合适的中间元素,可以减少最坏情况的发生,提高划分的平衡性和效率,从而提高排序算法的整体效率。
// 三数取中法
int GetMidIndex(int* a, int begin, int end)
{
  //int mid = (begin + end) / 2;
    int mid = left + (right - left) / 2; // 防止溢出版本
 
  if (a[begin] < a[mid])
  {
    if (a[mid] < a[end])
    {
      return mid;
    }
    else if (a[begin] < a[end])
    {
      return end;
    }
    else
    {
      return begin;
    }
  }
  else // (a[begin] >= a[mid])
  {
    if (a[mid] > a[end])
    {
      return mid;
    }
    else if (a[begin] < a[end])
    {
      return begin;
    }
    else
    {
      return end;
    }
  }
}
  1. 三数取中法选 key。
  2. 递归到小的子区间时,可以考虑使用插入排序

优化后的代码:

void Swap(int* p1, int* p2)
{
  int tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}
 
// Hoare
int PartSort1(int* a, int begin, int end)
{
  int left = begin, right = end;
  int keyi = left;
 
    // 加入三数取中的优化
  int midi = GetMidIndex(a, begin, end);
  Swap(&a[keyi], &a[midi]);
 
  while (left < right)
  {
    // 右边先走,找小 -- 升序
    while (left < right && a[right] >= a[keyi])
    {
      --right;
    }
  
    // 左边再走,找大
    while (left < right && a[left] <= a[keyi])
    {
      ++left;
    }
 
    Swap(&a[left], &a[right]);
  } 
  Swap(&a[keyi], &a[left]);
  keyi = left;
 
  return keyi;
}
// 挖坑法
int PartSort2(int* a, int begin, int end)
{
  int key = a[begin];
  int piti = begin;
 
    // 加入三数取中的优化
  int midi = GetMidIndex(a, begin, end);
  Swap(&a[keyi], &a[midi]);
 
  while (begin < end)
  {
    // 右边找小,填到左边的坑里面去。这个位置形成新的坑
    while (begin < end && a[end] >= key)
    {
      --end;
    }
    a[piti] = a[end]; // 填坑
    piti = end; // 新坑
 
    // 左边找大,填到右边的坑里面去。这个位置形成新的坑
    while (begin < end && a[begin] <= key)
    {
      ++begin;
    }
    a[piti] = a[begin]; // 填坑
    piti = begin; // 新坑
  }
  a[piti] = key; // 将key填到最后一个坑里
  return piti;
}
void Swap(int* p1, int* p2)
{
  int tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}
 
// 前后指针法
int PartSort3(int* a, int begin, int end)
{
  int prev = begin;
  int cur = begin + 1;
  int keyi = begin;
 
  // 加入三数取中的优化
  //int midi = GetMidIndex(a, begin, end);
  //Swap(&a[keyi], &a[midi]);
 
  while (cur <= end)
  {
    // cur位置的值小于keyi位置的值
    if (a[cur] < a[keyi] && ++prev != cur)
    {
      Swap(&a[prev], &a[cur]);
    }
    ++cur;
  }
  Swap(&a[prev], &a[keyi]);
  keyi = prev;
 
  return keyi;
}

快速排序是一种高效的排序算法,通常在处理大规模数据时表现良好。然而,当递归到较小区间时,快速排序可能会变得相对较慢。这是因为递归调用本身会带来一定的开销,而对于较小的区间,插入排序可能更加高效。

插入排序是一种简单但有效的排序算法,对于小规模的数据集表现出色。它的时间复杂度为O(n²),但在实际应用中,由于具有低的常数因子和良好的局部性,插入排序通常会比其他 O(n²) 复杂度的排序算法更快。

因此,当快速排序递归到较小区间时,我们可以切换到插入排序。这样可以减少递归调用的次数,降低开销,并且利用插入排序的优势来提高整体性能。

void InsertSort(int* a, int n)
{
  for (int i = 0; i < n - 1; ++i)
  {
    // [0,end]有序,把end+1位置的值插入,保持有序
    int end = i;
    int tmp = a[end + 1];
    while (end >= 0)
    {
      if (tmp < a[end]) // 升序
      {
        a[end + 1] = a[end];
        --end;
      }
      else
      {
        break;
      }
    }
    a[end + 1] = tmp;
  }
}
 
// 快速排序(递归)
void QuickSort(int* a, int begin, int end)
{
 
  // 区间不存在,或者只有一个值则不需要再处理
  if (begin >= end)
  {
    return;
  }
 
  if (end - begin > 10)
  {
    int keyi = PartSort3(a, begin, end);
 
    // [begin, keyi-1] keyi [keyi+1, end]
    QuickSort(a, begin, keyi - 1);
    QuickSort(a, keyi + 1, end);
  }
  else // 递归到小的子区间时,可以考虑使用插入排序
  {
    InsertSort(a + begin, end - begin + 1);
  }
}


(5)快速排序非递归

递归的大问题是在极端场景下,如果栈帧深度太深,栈空间不够用,会出现栈溢出。下面用非递归实现快速排序:

递归改成非递归一般有两种方法:

  1. 直接改循环(简单);
  2. 借助数据结构栈模拟递归过程(复杂) 。
  • 快速排序的非递归遍历可以使用模拟二叉树的前序遍历的方式实现,也可以使用队列模拟二叉树的层序遍历的方式实现。
  • 快排的非递归是在模拟递归的过程,所以时间复杂度并没有本质的变化,但是没有递归,可以减少栈空间的开销。
// 快速排序非递归(栈)
void QuickSortNonR(int* a, int begin, int end)
{
  ST st;
  StackInit(&st);
  StackPush(&st, end);
  StackPush(&st, begin);
 
  while (!StackEmpty(&st)) // 栈不为空
  {
        // 取栈顶left,并Pop
    int left = StackTop(&st);
    StackPop(&st);
 
        // 再取栈顶right,并Pop
    int right = StackTop(&st);
    StackPop(&st);
 
    int keyi = PartSort3(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);
}

需要入栈的是数组的下标区间,且先入右再入左,因为栈是先进后出,条件是栈不为空。

依次把需要进行单趟排的区间入栈,依次取栈里的区间出来单趟排,再把需要处理的子区间入栈。

快速排序的特性总结:( 前序遍历 )( 层序遍历

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

4、归并排序

(1)基本思想
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用 分治法(Divide and Conquer)的一个非常典型的应用。分治,顾名思义,就是分而治之, 将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了

分治思想跟我们前面讲的递归思想很像。是的,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧 ,这两者并不冲突。

将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
(2)归并排序(MergeSort)

归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两

部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有

序了。

void _MergeSort(int* a, int begin, int end, int* tmp)
{
  if (begin >= end)
  {
    return;
  }
 
  //int mid = (begin + end) / 2;
    int mid = begin + (end - begin) / 2; // 防止溢出版本
 
  // [begin, mid] [mid+1, end] 分治递归,让子区间有序
  _MergeSort(a, begin, mid, tmp);
  _MergeSort(a, mid + 1, end, tmp);
 
  // 归并[begin, mid] [mid+1, end]
  int begin1 = begin, end1 = mid;
  int begin2 = mid + 1, end2 = end;
  int i = begin1;
  while (begin1 <= end1 && begin2 <= end2) // 其中一个结束就结束
  {
    if (a[begin1] <= a[begin2]) // 等于可以保证稳定性
    {
      tmp[i++] = a[begin1++];
    }
    else
    {
      tmp[i++] = a[begin2++];
    }
  }
 
    // 将剩余的数字直接填入tmp数组里
  while (begin1 <= end1) // begin2结束了,拷贝剩下的begin1
  {
    tmp[i++] = a[begin1++];
  }
 
  while (begin2 <= end2) //begin1结束了,拷贝剩下的begin2
  {
    tmp[i++] = a[begin2++];
  }
 
    // 归并后的结果,拷贝到原数组
  memcpy(a + begin, tmp + begin, (end - begin + 1) * sizeof(int));
 
    //for(int i = begin; i <= end; ++i)
    //{
    //    a[i] = tmp[i];
    //}
}
 
// 归并排序
void MergeSort(int* a, int n)
{
  int* tmp = (int*)malloc(sizeof(int) * n); // 临时数组
  if (tmp == NULL)
  {
    printf("malloc fail\n");
    exit(-1);
  }
  _MergeSort(a, 0, n - 1, tmp); // 子函数递归
 
  free(tmp);
}

a.归并排序是稳定的排序算法吗?

在合并的过程中,如果 a[p…q] 和 a[q+1…r] 之间有值相同的元素,先把 a[p…q] 中的元素放入 tmp 数组,这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法

b.归并排序的时间复杂度是多少?

归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(n*logn)

c.归并排序的空间复杂度是多少?

归并排序不是原地排序算法。这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。

如果我们继续按照分析递归时间复杂度的方法,通过递推公式来求解,那整个归并过程需要 的空间复杂度就是 O(n*logn)。不过,类似分析时间复杂度那样来分析空间复杂度,这个思 路并不对。 实际上,递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚我们忘记了最重要的一 点,那就是,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟 的内存空间就被释放掉了。在任意时刻, CPU 只会有一个函数在执行,也就只会有一个临 时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)

归并排序的特性总结:( 后序遍历

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

(3)归并排序非递归

非递归未必比递归简单,因为这里需要对边界进行精准控制。

// 归并排序(非递归)
void MergeSortNonR(int* a, int n)
{
  int* tmp = (int*)malloc(sizeof(int) * n);
  if (tmp == NULL)
  {
    printf("malloc fail\n");
    exit(-1);
  }
 
  int gap = 1;
  while (gap < n)
  {
    for (int i = 0; i < n; i += 2 * gap)
    {
      // [i, i+gap-1][i+gap, i+2*gap-1]
      int begin1 = i, end1 = i + gap - 1;
      int begin2 = i + gap, end2 = i + 2 * gap - 1;
 
      // end1越界或者begin2越界,则可以不归并
      if (end1 >= n || begin2 >= n)
      {
        break;
      }
      else if (end2 >= n) // 修正边界
      {
        end2 = n - 1;
      }
 
      int m = end2 - begin1 + 1; //实际归并的总体个数
      int j = begin1;
      while (begin1 <= end1 && begin2 <= end2)
      {
        if (a[begin1] < a[begin2])
        {
          tmp[j++] = a[begin1++];
        }
        else
        {
          tmp[j++] = a[begin2++];
        }
      }
 
      while (begin1 <= end1) // begin2结束了,拷贝剩下的begin1
      {
        tmp[j++] = a[begin1++];
      }
 
      while (begin2 <= end2) // begin1结束了,拷贝剩下的begin2
      {
        tmp[j++] = a[begin2++];
      }
      memcpy(a + i, tmp + i, sizeof(int) * m); // 拷贝回原数组
    }
    gap *= 2; // 迭代
  }
  free(tmp);
}


⚪非比较排序

(1)思想
  • 计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。

(2)计数排序(CountSort)

操作步骤:

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

// 计数排序
void CountSort(int* a, int n)
{
  int min = a[0], max = 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)
  {
    printf("malloc fail\n");
    exit(-1);
  }
  memset(count, 0, sizeof(int) * range); // 初始化为0
 
  //统计次数
  for (int i = 0; i < n; ++i)
  {
    count[a[i] - min]++;
  }
 
  //回写-排序
  int j = 0;
  for (int i = 0; i < range; ++i)
  {
    // 出现几次就要回写几个i+min
    while (count[i]--)
    {
      a[j++] = i + min;
    }
  }
    free(count);
}

  • 如果要开 0~5000 个数据的数组,但其实小于 1000 的数据一个都没有,空间浪费严重,这种方式叫作绝对映射
  • 针对此情况,如果我们只需要 0~4000 个数据的数组即可,放数据时是 a[i] - min,这种方式叫作相对映射。  

注意 计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数 据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。

计数排序的特性总结:

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

三、排序算法复杂度及稳定性分析


相关文章
|
1月前
|
算法 搜索推荐 Java
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
基数排序是一种稳定的排序算法,通过将数字按位数切割并分配到不同的桶中,以空间换时间的方式实现快速排序,但占用内存较大,不适合含有负数的数组。
23 0
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
|
1月前
|
存储 搜索推荐 算法
【用Java学习数据结构系列】七大排序要悄咪咪的学(直接插入,希尔,归并,选择,堆排,冒泡,快排)以及计数排序(非比较排序)
【用Java学习数据结构系列】七大排序要悄咪咪的学(直接插入,希尔,归并,选择,堆排,冒泡,快排)以及计数排序(非比较排序)
22 1
|
1月前
|
搜索推荐 索引
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(二)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
1月前
|
搜索推荐 C++
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(一)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
1月前
|
算法
蓝桥杯宝藏排序 | 数据结构 | 快速排序 归并排序
蓝桥杯宝藏排序 | 数据结构 | 快速排序 归并排序
05_用一个栈实现另一个栈的排序
05_用一个栈实现另一个栈的排序
|
1月前
|
人工智能 搜索推荐 算法
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(三)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
14天前
|
C语言
【数据结构】栈和队列(c语言实现)(附源码)
本文介绍了栈和队列两种数据结构。栈是一种只能在一端进行插入和删除操作的线性表,遵循“先进后出”原则;队列则在一端插入、另一端删除,遵循“先进先出”原则。文章详细讲解了栈和队列的结构定义、方法声明及实现,并提供了完整的代码示例。栈和队列在实际应用中非常广泛,如二叉树的层序遍历和快速排序的非递归实现等。
90 9
|
5天前
|
存储 算法
非递归实现后序遍历时,如何避免栈溢出?
后序遍历的递归实现和非递归实现各有优缺点,在实际应用中需要根据具体的问题需求、二叉树的特点以及性能和空间的限制等因素来选择合适的实现方式。
14 1
|
8天前
|
存储 算法 Java
数据结构的栈
栈作为一种简单而高效的数据结构,在计算机科学和软件开发中有着广泛的应用。通过合理地使用栈,可以有效地解决许多与数据存储和操作相关的问题。