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

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

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


相关文章
|
9月前
|
机器学习/深度学习 算法 安全
【无人机三维路径规划】基于非支配排序的鲸鱼优化算法NSWOA与多目标螳螂搜索算法MOMSA求解无人机三维路径规划研究(Matlab代码实现)
【无人机三维路径规划】基于非支配排序的鲸鱼优化算法NSWOA与多目标螳螂搜索算法MOMSA求解无人机三维路径规划研究(Matlab代码实现)
407 5
|
9月前
|
机器学习/深度学习 运维 算法
基于非支配排序遗传算法NSGAII的综合能源优化调度(Matlab代码实现)
基于非支配排序遗传算法NSGAII的综合能源优化调度(Matlab代码实现)
457 0
基于非支配排序遗传算法NSGAII的综合能源优化调度(Matlab代码实现)
|
10月前
|
机器学习/深度学习 算法 安全
【无人机3D路径规划】基于非支配排序遗传算法NSGAII的无人机3D路径规划研究(Matlab代码实现)
【无人机3D路径规划】基于非支配排序遗传算法NSGAII的无人机3D路径规划研究(Matlab代码实现)
340 1
|
9月前
|
机器学习/深度学习 算法 安全
【无人机三维路径规划】多目标螳螂搜索算法MOMSA与非支配排序的鲸鱼优化算法NSWOA求解无人机三维路径规划研究(Matlab代码实现)
【无人机三维路径规划】多目标螳螂搜索算法MOMSA与非支配排序的鲸鱼优化算法NSWOA求解无人机三维路径规划研究(Matlab代码实现)
314 0
|
9月前
|
机器学习/深度学习 算法 安全
【微电网】【创新点】基于非支配排序的蜣螂优化算法NSDBO求解微电网多目标优化调度研究(Matlab代码实现)
【微电网】【创新点】基于非支配排序的蜣螂优化算法NSDBO求解微电网多目标优化调度研究(Matlab代码实现)
265 0
|
10月前
|
机器学习/深度学习 算法 安全
【优化调度】基于matlab非支配排序遗传算法求解车辆充电调度优化问题研究(Matlab代码实现)
【优化调度】基于matlab非支配排序遗传算法求解车辆充电调度优化问题研究(Matlab代码实现)
185 0
|
9月前
|
存储 算法 搜索推荐
软考算法破壁战:从二分查找到堆排序,九大排序核心速通指南
专攻软考高频算法,深度解析二分查找、堆排序、快速排序核心技巧,对比九大排序算法,配套动画与真题,7天掌握45%分值模块。
382 1
软考算法破壁战:从二分查找到堆排序,九大排序核心速通指南
|
9月前
|
供应链 算法 Java
【柔性作业车间调度问题FJSP】基于非支配排序的多目标小龙虾优化算法求解柔性作业车间调度问题FJSP研究(Matlab代码实现)
【柔性作业车间调度问题FJSP】基于非支配排序的多目标小龙虾优化算法求解柔性作业车间调度问题FJSP研究(Matlab代码实现)
379 1
|
10月前
|
传感器 并行计算 算法
【无人机编队】基于非支配排序遗传算法II NSGA-II高效可行的无人机离线集群仿真研究(Matlab代码实现)
【无人机编队】基于非支配排序遗传算法II NSGA-II高效可行的无人机离线集群仿真研究(Matlab代码实现)
578 3
|
9月前
|
机器学习/深度学习 算法 安全
【无人机三维路径规划】基于非支配排序的鱼鹰优化算法NSOOA求解无人机三维路径规划研究(Matlab代码实现)
【无人机三维路径规划】基于非支配排序的鱼鹰优化算法NSOOA求解无人机三维路径规划研究(Matlab代码实现)
199 0