【排序算法(三)】交换排序(冒泡排序&&快速排序)(下)

简介: 【排序算法(三)】交换排序(冒泡排序&&快速排序)(下)

2.2.3 前后指针版本

1962年Hoare大神运用双指针算法重新设计了交换排序的单趟排序,并运用分治递归实现代替了嵌套循环实现,完成了交换排序的终极优化

单趟排序算法实现思想:

1.选取arr[left]作为key(key变量作为下标指向key元素)

2.slow初值为left,fast指针从left+1位置开始遍历数组

3.若fast指针找到了比key小的元素,则令slow指针向后走一步,并交换slow和fast指针指向的元素

4.若fast指针找到了比key大的元素,slow指针不动,fast指针继续向后遍历数组

5.重复上述过程直到fast指针完成数组的遍历,最后再将key元素交换到slow最终指向的位置

6.最终从left位置到slow位置的所有元素就是整个数组中比key小的所有元素.

先看动图:65808eda81b044a1bbe91a87f6c6c407.gif前后指针法的思路:

定义三个变量,prev=begin,cur=begin+1,key=begin,主要使用 cur 来遍历数组。

1.如果cur 指向的元素比key 小,此刻停下来,++prev ,交换调整后的prev 位置和 cur 位置的值,之后 cur++ 。

2.如果cur 指向的元素大于等于key ,cur++ ,其他啥也不干。

3.就这样循环往复,直到cur>end ,此刻交换prev 和 key 位置的值。

当前的 prev 作为新的key ,为分割点。

639d98b9062e421f9e2ebc5395537536.png这里的 prev 和cur 有两种状况:

1.紧跟cur ,这时 a[cur]<a[key] ,它俩同步往后走

2.指向比 key 大的位置的前一个,pprev 和 cur 之间就是 ≥ a [ key ]个值。

每当 a[cur]<a[key] ,cur 位置小于a[key] 的值总是会被推到前面。循环的过程相当于是将小于a[key] 的值往前翻,大于 a[key] 的值往后像“翻跟头”一样推着走。image.png最后prev 的位置指向比 a[key] 大的位置的前一个,那么交换a[prev] 和 a[key] ,让key=prev ,此刻key 为分割点。

优化思路:

实际上我们发现当prev 紧跟cur 时,它俩指向的是一个位置的元素,所以这种情况是没必要交换的,所以可以提前判断一下 ++prev!=cur ,如果不满足这个条件,那么直接就不要交换了,反正是同一个位置的值。


接着来写一下代码:

int partion3(int* a, int begin, int end)
{
  int prev = begin;
  int cur = begin + 1;
  int key = begin;
  while (cur <= end)
  {
    // 找到比 key 小的值时,跟 ++prev 位置交换,
    // 小的往前翻,大的往后翻
    // 重复数据不会交换 - 优化后
    if (a[cur] < a[key] && ++prev != cur)
    //a[cur] < a[key] 小才执行++prev,否则不++
      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 和 key 是相等的
  key = prev;
  return key;
}

2.3 缺陷分析及优化

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

其实对于快排来说,它的时间复杂度是不稳定的,比如上方三个版本,在乱序的序列中,效率可能还可以,因为选取的 key 值是随机的。b39ee9bed6594e4d96b852c37603383c.png但是对于有序序列,比如要排正序(逆序),但是序列是逆序(正序)。如果每次选 key 还是按照之前的选法,那么每次可能就会选中最边上的一个,选中最大或最小的数,假设序列长度为N ,每次选取一个极端值,就会选取 N 次,就会递归 N 层,每一层中的时间复杂度为 O ( N ) ,那么最终时间复杂度为O(N^2) 。且栈会溢出,递归 N 层,建立N个栈帧。

8eb8a6f19a4f4a558ec5ec24d1028307.png改进方法:

1.随机选K,会优化很多,但若刚好随机到最左边,所以不是很推荐d522eaf0c5404d9b884865c869e36786.png

d522eaf0c5404d9b884865c869e36786.png2.我们能否每次选 key 作为一段区间的中位数 ,让每段区间被二分,那么最终就只会递归 logN 层,每层时间复杂度为 O(N) ,总时间复杂度为O(N×logN) 。就像下图,像一棵二叉树一样。image.png所以这边就引申出了第一个优化:三数取中

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

代码:

int GetMidNum(int* a, int begin, int end)
{
  int mid = (begin + end) >> 1;//   /2
  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 个数,那么这种情况采用递归是不是浪费空间,是不是多此一举?

所以当 end−begin+1<10 时,可以采用插入排序优化,这样子就不用开太多层函数栈帧。对于一棵满二叉树,最后一层的节点数占总结点数的 image.png我们就假定快排递归展开后,最后形态为完全二叉树。假设当前有10个节点,那么对于下图中红色箭头标出的点来说就无须递归,因为再细分也就是一个数:

0e5269b527654c099c1bda4cbf5fb868.png大约可以节省下三层的递归,下三层的节点数占总结点数的 87.5 % ,省去了大部分的递归

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

但区间不能太大,不然也没有意义了,建议end−begin+1< 10左右

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

缺陷3:对于相同数据来说,三数取中无效,时间复杂度过高

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

这就引申出了第三个优化:三路划分。

之前我们是主要将区间划分为两段,[begin,key−1] 和[key+1,end] 。 左区间值小于key ,右区间值大于key ,可以称为 两路划分 。

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

思路:

1.设定一个 cur=begin+1,left = begin, right = end, key = a[left],将区间分割为左区间小于key ,中间区间等于 key ,右区间大于key 。

2.给定一个循环,循环中如果a[cur]<key ,此刻交换 cur 和left 指向的元素,使 left++ ,cur++ (如果一开始这个条件就满足,则会把 key 逐渐往中间推)

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

4.如果a[cur]==key ,那么只需要 cur++。

5.当cur>right 时,right 后的元素都是大于key 的,区间也都调整好了,这时候循环也就停止了。


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

最后分割成的区间就是[begin,left−1],[left,right],[right+1,end],这时等于 key 的区间不用递归,只需要递归排序左右区间即可。


代码:

// 三路划分 处理重复数据量大的情况,处理完中间区间就是 22222222222
void QuickSortT(int* a, int begin, int end)
{
  if (begin >= end)
  {
    return;
  }
    // 三数取中一下
  int mid = GetMidNum(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);
}

2.4 快排递归版本完整代码

这边调用的 partion3 是 前后指针版 的代码:

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);
  }
}

2.5 快排非递归版本

由于递归版本存在缺陷:

1.效率

2.深度太深,会栈溢出

所以我们要具备一个能力:把递归改成非递归

1.直接该成循环。eg.斐波那契数

2.使用栈辅助改循环

非递归的好处就是不需要多次递归开辟多层函数栈帧,在空间消耗上略有优势。

快排的非递归需要借助数据结构 - 栈 来完成。image.png快排递归的过程相当于对每一段区间进行处理,那非递归可以用两个变量来模拟各个区间


思路:

1.开始,我们将 begin 和 end 分别入栈。给定一个循环,如果栈不为空就继续循环。

2.由于栈是后进先出,所以先用right 接收end 右区间,再用 left 接收左区间,在接收完之后,将这两个元素分别出栈。

3.得到了区间后,对区间进行单趟排序(可以调用上面的 hoare 等),用 key 接收分隔点。

4先处理左区间[left,key−1] ,再处理 [key+1,right] 。由于栈先进后出,所以要先入右区间,在入左区间。

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


优化思路:

如果区间内只有 1 个元素,就无需处理了,所以可以加个条件判断一下,举个例子,对于右区间来说, key 是分割点, key+1 则是右区间的起始位置,如果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);
    //[left,key−1] key [key+1,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);
}

2.6 特性及复杂度

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


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

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

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

image.png对于递归的版本,需要开 logN 层栈帧;对于非递归的版本,原理和递归类似,也认为处理logN 次。每次单趟递归处理中,时间复杂度为 O(N) 。所以快排的时间复杂度为O(N*logN)


空间复杂度也因为优化的原因,几乎不会出现极端情况,我们认为最佳情况就是像二叉树一样,最多开辟logN 层栈帧


递归版本空间复杂度的计算公式:递归深度×每次递归中额外空间消耗,每次递归消耗空间为O(1) ,一共 logN 层,所以空间复杂度为 O(logN) ,非递归同理


特性:快速排序整体的综合性能和使用场景都是比较好的,故称快速排序。

时间复杂度:O(N*logN) 。快速排序的过程类似于高度为 logN 的二叉树,且每层约有 N

空间复杂度:O(logN) 。

稳定性:不稳定。

3.总结:

今天我们认识并具体学习了交换排序算法中的冒泡排序和快速排序,通过这次的学习,我们也感受到了快速排序的魅力。下一篇博客,我们将继续学习排序算法中的归并排序。希望我的文章和讲解能对大家的学习提供一些帮助。

当然,本文仍有许多不足之处,欢迎各位小伙伴们随时私信交流、批评指正!我们下期见~

c3ad96b16d2e46119dd2b9357f295e3f.jpg


相关文章
|
1月前
|
搜索推荐 C语言
【排序算法】快速排序升级版--三路快排详解 + 实现(c语言)
本文介绍了快速排序的升级版——三路快排。传统快速排序在处理大量相同元素时效率较低,而三路快排通过将数组分为三部分(小于、等于、大于基准值)来优化这一问题。文章详细讲解了三路快排的实现步骤,并提供了完整的代码示例。
57 4
|
22天前
|
搜索推荐 Python
利用Python内置函数实现的冒泡排序算法
在上述代码中,`bubble_sort` 函数接受一个列表 `arr` 作为输入。通过两层循环,外层循环控制排序的轮数,内层循环用于比较相邻的元素并进行交换。如果前一个元素大于后一个元素,就将它们交换位置。
125 67
|
22天前
|
存储 搜索推荐 Python
用 Python 实现快速排序算法。
快速排序的平均时间复杂度为$O(nlogn)$,空间复杂度为$O(logn)$。它在大多数情况下表现良好,但在某些特殊情况下可能会退化为最坏情况,时间复杂度为$O(n^2)$。你可以根据实际需求对代码进行调整和修改,或者尝试使用其他优化策略来提高快速排序的性能
115 61
|
1月前
|
搜索推荐 算法 C语言
【排序算法】八大排序(上)(c语言实现)(附源码)
本文介绍了四种常见的排序算法:冒泡排序、选择排序、插入排序和希尔排序。通过具体的代码实现和测试数据,详细解释了每种算法的工作原理和性能特点。冒泡排序通过不断交换相邻元素来排序,选择排序通过选择最小元素进行交换,插入排序通过逐步插入元素到已排序部分,而希尔排序则是插入排序的改进版,通过预排序使数据更接近有序,从而提高效率。文章最后总结了这四种算法的空间和时间复杂度,以及它们的稳定性。
94 8
|
1月前
|
搜索推荐 算法 C语言
【排序算法】八大排序(下)(c语言实现)(附源码)
本文继续学习并实现了八大排序算法中的后四种:堆排序、快速排序、归并排序和计数排序。详细介绍了每种排序算法的原理、步骤和代码实现,并通过测试数据展示了它们的性能表现。堆排序利用堆的特性进行排序,快速排序通过递归和多种划分方法实现高效排序,归并排序通过分治法将问题分解后再合并,计数排序则通过统计每个元素的出现次数实现非比较排序。最后,文章还对比了这些排序算法在处理一百万个整形数据时的运行时间,帮助读者了解不同算法的优劣。
106 7
|
2月前
|
搜索推荐
冒泡排序算法
【10月更文挑战第19天】冒泡排序是一种基础的排序算法,虽然在实际应用中可能不是最优的选择,但对于理解排序算法的基本原理和过程具有重要意义。
|
2月前
|
算法 安全 Java
介绍一下比较与交换算法
【10月更文挑战第20天】介绍一下比较与交换算法
19 0
|
2月前
|
搜索推荐 C语言
排序算法--冒泡排序
排序算法--冒泡排序
21 0
|
2月前
|
算法
❤️算法笔记❤️-(每日一刷-83、删除排序链表中的重复项)
❤️算法笔记❤️-(每日一刷-83、删除排序链表中的重复项)
34 0
|
16天前
|
算法
基于WOA算法的SVDD参数寻优matlab仿真
该程序利用鲸鱼优化算法(WOA)对支持向量数据描述(SVDD)模型的参数进行优化,以提高数据分类的准确性。通过MATLAB2022A实现,展示了不同信噪比(SNR)下模型的分类误差。WOA通过模拟鲸鱼捕食行为,动态调整SVDD参数,如惩罚因子C和核函数参数γ,以寻找最优参数组合,增强模型的鲁棒性和泛化能力。
下一篇
DataWorks