【数据结构与算法篇】手撕八大排序算法之交换排序

简介: 【数据结构与算法篇】手撕八大排序算法之交换排序

👻内容专栏: 《数据结构与算法篇》

🐨本文概括:常见交换排序包括冒泡排序与快速排序,本篇讲述冒泡排序与快速排序的思想及实现、复杂度分析。

🐼本文作者: 花 蝶

🐸发布时间:2023.8.27

一、冒泡排序

基本思想

冒泡排序(Bubble Sort)是一种简单的排序算法,其基本思想是通过两两交换相邻元素的位置,使得较大(或较小)的元素逐步“冒泡”到数组的一端(顶部或底部),重复“冒泡”的过程,直到序列没有要交换的元素为止,从而实现排序。

算法步骤

1、 从数组的起始位置开始,依次比较相邻的两个元素。如果相邻元素的顺序错误(前者大于后者,或者前者小于后者,取决于是升序还是降序排序),则交换这两个元素的位置;

2、继续进行相邻元素的比较和交换,直到遍历到数组的末尾。这样一轮下来,最大(或最小)的元素就会“冒泡”到数组的一端。

3、重复上述步骤,但不再考虑已经排序好的末尾部分。每一轮操作都会将一个最大(或最小)的元素移到正确的位置。

4、 经过多轮的比较和交换,最终整个数组会变得有序。如果在某一轮没有进行任何交换操作时,说明数组已经有序,可以直接跳出循环。

动图演示

代码实现

//冒泡排序
//时间复杂度:O(N^2)
void BubbleSort(int* a, int n)
{
  //控制冒泡排序的趟数
  for(int i = 0; i < n - 1; i++)
  {
    //假设数组有序
    int flag = 1;
    //控制每趟的过程
    for (int j = 0; j < n - 1 - i; j++)
    {
      if (a[j] > a[j + 1])
      {
         Swap(&a[j], &a[j + 1]);
         flag = 0;
      }
    }
    //说明没有发生交换,数组已经有序,跳出循环即可
    if (flag == 1) break;
  }
}

冒泡排序的特性

冒泡排序的核心思想就是通过反复比较相邻元素并交换它们的位置,从而逐步将最大(或最小)的元素移动到合适的位置。尽管冒泡排序的时间复杂度较高(平均和最坏情况下都是 O(N^2),可以看作是一个等差数列的求和),但它的实现相对简单,适用于小规模的数据集,因此具有较强的教学意义,想必大家在刚开始学习编程时学的一个排序就是冒泡排序吧哈哈。

二、快速排序(递归版本)

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

//快排(递归版本)
void QuickSort(int* a, int begin, int end)
{
  if (begin >= end) return;
  int keyi = Partition(a, begin, end); 
  //前序遍历
  //递归基准值左边的区间
  QuickSort(a, begin, keyi - 1);
  //递归基准值右边的区间
  QuickSort(a, keyi + 1, end);
}

上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,大家在写递归框架时可想想二叉树前序遍历规则即可快速写出来,后续只需分析如何按照基准值来对区间中数据进行划分的方式即可。

下面用三个版本实现:hoare版本、挖坑法、前后指针法

1.hoare版本

1、选择基准元素key,通常是数组中的第一个元素。

2、使用两个指针LR,一个指向数组的开头(较小值的区域),一个指向数组的末尾(较大值的区域)。

3、交换的目标是L找到比基准大的元素,R找到比基准小的元素。不断交换指针所指的元素,直到两个指针相遇。

4、当两个指针相遇时,交换基准元素与当前相遇位置的元素,此时数组被划分为左右两个子数组。

5、对左右两个子数组递归地应用相同的分区步骤,直到所有子数组都有序

算法分析

👇①:原始序列

👇②:right向前移动,找比key小的位置,left往后移动找比key大的位置,然后交换。

👇③:继续往后寻找,直到两个指针相遇。

👇④:交换基准元素与当前相遇位置的元素。

👇⑤:这样子我们发现基准值左边的区间里面的元素都比基准值小,右边的区间里面的元素都比基准值大。我们可以按照前序遍历的思想对左边区间进行递归操作,右边区间也进行递归操作。

代码实现

//hoare版本
int Partition1(int* a, int left, int right)
{
  int keyi = left;
  while (left < right)
  {
    //注意: 要加上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[left], &a[keyi]);
  return left;
}
//快排(递归版本)
void QuickSort(int* a, int begin, int end)
{
  //当区间不存在或者只剩一个元素,就回溯
  if (begin >= end) return;
  int keyi = Partition1(a, begin, end);
  //前序遍历
  //递归基准值左边的区间
  QuickSort(a, begin, keyi - 1);
  //递归基准值右边的区间
  QuickSort(a, keyi + 1, end);
}

2.挖坑法

挖坑法的基本思想是:

  • 1、首先,选择一个基准元素(通常是数组中的第一个元素)作为“坑”(hole)。前提需要将这个基准元素用一个临时变量key存起来。
  • 2、将基准元素挖出,形成一个空位(坑)。
  • 3、从数组的另一端开始,从右向左遍历,找到一个比基准元素小的元素,然后将这个元素填入之前的坑中。
  • 4、继续从左向右遍历,找到一个比基准元素大的元素,然后将这个元素填入上一步的坑中。
  • 5、重复执行步骤 3 和步骤 4,直到左右指针相遇。
  • 6、此时,基准元素的位置就是这个相遇的位置,将基准元素填入这个坑中。

算法分析

👇①:原始序列

👇②:将基准值存放到临时变量key中,形成一个坑位。

👇③:从右向左遍历,R找到比基准值小的元素,将这个元素填入到之前的坑中,此时当前位置就形成了新坑。

👇④:紧接着,从左往右遍历,L寻找比基准值大的元素,将这个元素填入到上一步的坑中,此时当前位置形成了新坑。

👇⑤:重复以上步骤后,直到左右指针相遇,最后会形成一个坑,将临时变量放入坑中。

👇⑥:这样子我们发现基准值左边的区间里面的元素都比基准值小,右边的区间里面的元素都比基准值大。我们可以按照前序遍历的思想对左边区间进行递归操作,右边区间也进行递归操作。

代码实现

//挖坑法
int Partition2(int* a, int left, int right)
{
  int key = a[left];
  //挖坑
  int hole = left;
  while (left < right)
  {
    //右边找小
    while (left < right && a[right] >= key)
    {
      right--;
    }
    a[hole] = a[right];
    hole = right;
    //左边找大
    while (left < right && a[left] <= key)
    {
      left++;
    }
    a[hole] = a[left];
    hole = left;
  }
  a[hole] = key;
  return hole;
}
//快排(递归版本)
void QuickSort(int* a, int begin, int end)
{
  //当区间不存在或者只剩一个元素,就回溯
  if (begin >= end) return;
  int keyi = Partition2(a, begin, end);
  //前序遍历
  //递归基准值左边的区间
  QuickSort(a, begin, keyi - 1);
  //递归基准值右边的区间
  QuickSort(a, keyi + 1, end);
}

3.前后指针法

基本思想:在快速排序的划分过程中,使用前后指针法来确定基准元素的位置,最后将数组划分成两部分,一部分小于基准元素,另一部分大于基准元素。

  • 1、首先,选择一个基准元素key(通常是数组中的第一个元素)。
  • 2、使用两个指针,prev指向数组的开头,cur指向prev的后一个位置。
  • 3、判断cur指向的数据是否小于key。若小于,则prev后移一位,cur指向的内容与prev指向的内容交换,然后cur++
  • 4、若cur指向的数据大于key,则cur++
  • 重复步骤3和步骤4,直到cur指针走完(最后一个元素的下一个位置)
  • 最后,将keyprev指向的元素交换。

动图演示

//前后指针法
int Partition3(int* a, int left, int right)
{
  int prev = left;
  int cur = prev + 1;
  int keyi = left;
  //cur小于key,交换++prev与cur的位置
  //将大的位置翻滚时往后挪动,小的位置移动前面
  while (cur <= right)
  {
    if (a[cur] < a[keyi] && ++prev != cur)
    {
      Swap(&a[cur], &a[prev]);
    }
    cur++;
  }
  //将key与prev指向的元素交换。
  Swap(&a[prev], &a[keyi]);
  return prev;
}
//快排(递归版本)
void QuickSort(int* a, int begin, int end)
{
  //当区间不存在或者只剩一个元素,就回溯
  if (begin >= end) return;
  int keyi = Partition3(a, begin, end);
  //前序遍历
  //递归基准值左边的区间
  QuickSort(a, begin, keyi - 1);
  //递归基准值右边的区间
  QuickSort(a, keyi + 1, end);
}

快速排序(递归版本)的特性

时间复杂度

选择基准元素影响时间复杂度:若基准元素key偏向于所有元素中的中位数,则时间复杂度处于较好的情况,若基准元素key是所有元素中最小的,或者接近最小值,那么时间复杂度就处于较坏的情况。

  • 平均情况下的时间复杂度: 在平均情况下,快速排序的时间复杂度为 O(n log n)。这是因为在每次分区过程中,数组会被划分成大致相等的两部分。在每次递归中,都会将问题的规模减半,递归的展开图可以看作是一颗满二叉树,所以递归的深度为 O(log n),每层递归的分区操作都需要 O(n) 的时间,因此总的时间复杂度为 O(n*log n)
  • 最坏情况下的时间复杂度: 在最坏情况下,快速排序的时间复杂度为 O(n^2)。这种情况发生在每次划分后,一个子数组为空,另一个子数组包含所有的元素。这样会导致递归树变得很不平衡,每次递归的问题规模只减少一个元素。虽然快速排序通常能够避免这种情况,但在某些情况下(例如已经有序的数组),最坏情况可能出现。

解决方案

  • 随机数选择基准元素: 在每次划分时,随机选择一个基准元素,这可以减少最坏情况的发生概率。
  • 三数取中法: 在选择基准元素时,不仅考虑第一个和最后一个元素,还考虑数组中间位置的元素,选择其中值大小居中的元素作为基准。

👇这里提供一种三数取中的方法,以hoare版本为例:

//三数取中,返回下标
int GetMidIndex(int* a, int left, int right)
{
  int mid = (left + right) >> 1;
  if (a[left] < a[mid])
  {
    if (a[mid] < a[right]) return mid;
    else if (a[left] > a[right]) return left;
    else return mid;
  }
  else //a[left] > a[mid]
  {
    if (a[mid] > a[right]) return mid;
    else if (a[right] > a[left]) return left;
    else return right;
  }
}
//hoare版本
int Partition1(int* a, int left, int right)
{
  //将中位数与数组的第一个元素(基准值)进行交换
  int midi = GetMidIndex(a, left, right);
  Swap(&a[left], &a[midi]);
  int keyi = left;
  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[left], &a[keyi]);
  return left;
}

后面会更新快排的非递归版本……可到数据结构专栏查看🥰

更多数据结构与算法系列文章👉😉==> 【传送门】

目录
相关文章
|
2月前
|
存储 人工智能 算法
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
这篇文章详细介绍了Dijkstra和Floyd算法,这两种算法分别用于解决单源和多源最短路径问题,并且提供了Java语言的实现代码。
91 3
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
|
2月前
|
机器学习/深度学习 存储 缓存
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
文章主要介绍了排序算法的分类、时间复杂度的概念和计算方法,以及常见的时间复杂度级别,并简单提及了空间复杂度。
39 1
数据结构与算法学习十:排序算法介绍、时间频度、时间复杂度、常用时间复杂度介绍
|
2月前
|
搜索推荐 算法
数据结构与算法学习十四:常用排序算法总结和对比
关于常用排序算法的总结和对比,包括稳定性、内排序、外排序、时间复杂度和空间复杂度等术语的解释。
25 0
数据结构与算法学习十四:常用排序算法总结和对比
|
2月前
|
算法 搜索推荐 Java
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
基数排序是一种稳定的排序算法,通过将数字按位数切割并分配到不同的桶中,以空间换时间的方式实现快速排序,但占用内存较大,不适合含有负数的数组。
34 0
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
|
2月前
|
存储 缓存 分布式计算
数据结构与算法学习一:学习前的准备,数据结构的分类,数据结构与算法的关系,实际编程中遇到的问题,几个经典算法问题
这篇文章是关于数据结构与算法的学习指南,涵盖了数据结构的分类、数据结构与算法的关系、实际编程中遇到的问题以及几个经典的算法面试题。
38 0
数据结构与算法学习一:学习前的准备,数据结构的分类,数据结构与算法的关系,实际编程中遇到的问题,几个经典算法问题
|
2月前
|
存储 搜索推荐 算法
【用Java学习数据结构系列】七大排序要悄咪咪的学(直接插入,希尔,归并,选择,堆排,冒泡,快排)以及计数排序(非比较排序)
【用Java学习数据结构系列】七大排序要悄咪咪的学(直接插入,希尔,归并,选择,堆排,冒泡,快排)以及计数排序(非比较排序)
29 1
|
2月前
|
机器学习/深度学习 存储 算法
【数据结构与算法基础】——算法复杂度
【数据结构与算法基础】——算法复杂度
|
2月前
|
算法
蓝桥杯宝藏排序 | 数据结构 | 快速排序 归并排序
蓝桥杯宝藏排序 | 数据结构 | 快速排序 归并排序
|
2月前
|
算法 安全 Java
介绍一下比较与交换算法
【10月更文挑战第20天】介绍一下比较与交换算法
18 0
|
2月前
|
机器学习/深度学习 搜索推荐 算法
探索数据结构:初入算法之经典排序算法
探索数据结构:初入算法之经典排序算法