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

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

1、冒泡排序

1.1 排序思路

冒泡排序属于交换排序,所谓交换排序就是就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。

这个排序像水面冒气泡一样,从底部(数组开头)冒到水面上(数组结尾),一次冒一个数据,所以被形象的称为“冒泡排序”。

看一下动图:image.gif

1.2 代码实现

冒泡排序的单趟排序会把前(n-j)个元素中的最大值交换到(n-j-1)的位置(增加数组的有序后缀长度)

 // 单趟排序
    int j = 0;  //记录排序的趟数 
    for (int i = j; i < n - j - 1; i++)
    { //交换次数随着趟数减少
      if (a[i] > a[i + 1])
      {
        Swap(&a[i], &a[i + 1]);
        flag = 0;
      }
    }

排n 个数,那么就需要冒泡n−1 趟,将数据冒到结尾,在每趟冒泡排序中,比较相邻两个元素,如果满足条件,则交换,交换到其最终应该出现的位置(比如将第n大的数交换到数组下标为N-n的位置)

void BubbleSort(int* a, int n)
{
    //n个元素排 n - 1 趟
  for (int j = 0; j < n - 1; j++) 
  {
    int flag = 1;//flag用于标记数组是否已经有序
        // 单趟排序
    for (int i = 0; i < n - j - 1; i++)
    {//交换次数随着趟数减少
      if (a[i] > a[i + 1])
      {
        Swap(&a[i], &a[i + 1]);
        flag = 0;
      }
    }
    if (flag)
    {
      break;
    }
  }
}

仔细看,其实这里我们的代码是优化过的:如果一趟冒泡排序中,没有发生交换,说明有序,那么 break 退出循环。

比如序列1 2 2 4 6

在一趟冒泡排序中始终满足 a [ i ] ≤ a [ i + 1 ] ,没有发生交换,说明它本身是有序的,所以无需排序了,直接退出。

1.3 特性及复杂度

冒泡排序的时间复杂度为O(N^2),可能会感到疑惑,冒泡排序不是优化过了吗?为什么时间复杂度为 O(N^2)?


因为冒泡排序每次的比较次数,是随着趟数而减少的,找一下规律,其实可以发现,它的总执行次数是一个公差为 −1 的等差数列:(n - 1) + (n - 2) + … + 1 ,根据等差数列求和公式得:image.pngimage.png根据时间复杂度规律,最终为 O(N^2) 。

,根据时间复杂度规律,最终为 O(N^2) 。


而我们的优化是只对 数组前半部分有序 或 整体几乎有序 才有优化作用,如果有序的部分在后半段,每趟排序还是要从头开始一一比较,相当于还是重新排序。


其实数组前半部分有序的优化程度也不大,最好情况是 优化后 在 整体有序 的情况下,遍历一遍数组,通过 break 退出,这时 最好时间复杂度为O(N) 。


综上,取最坏情况,时间复杂度为O(N^2)。

空间复杂度为 O(1) 。

冒泡排序虽然效率不高,但他有特殊的意义:教学意义(bushi)

特性:冒泡排序是一种非常容易理解的排序。

时间复杂度:O(N^2) 。

空间复杂度:O(1) 。

稳定性:稳定。

2、快速排序

2.1 算法思想

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

2.2 快排递归版本

快排为交换排序的一种。用更详细的方式来讲,快排在开始时,会选择一个 key 做基准值,并设定 给定,然后进行单趟排序,单趟排序后,当排序停止,会把 key 的索引或 key 值本身与边界某一值交换,形成区间划分。


这个区间划分通常为 key 左边的值都小于 key ,key 右边的值都大于 key ,这样就使得区间被划分了。中间的 key 值不用管,当前 key 值已经到了正确的位置。那么现在排序就变为:对左区间和右区间的排序。


那么每次排序后都会确定一个元素的位置,确定位置后继续划分区间…这样的过程实际上就是递归,通过递归,对设定的基准值分割开的左右区间完成排序。


递归的返回条件为begin >= end ,此刻说明区间只有 1 个元素或无元素。

image.png根据上述,我们可以写出大概思路:

void QuickSort(int* a, int begin, int end)
{
  if (begin >= end)
  {
    return;
  }
  // 对左右区间进行划分
    int key = partion(a, begin, end);
    // 递归左右区间
    QuickSort(a, begin, key - 1);
    QuickSort(a, key + 1, end);
}

上述为快速排序递归实现的主思路,我们可以发现与这个思路与二叉树前序遍历非常像,在写递归框架时可回想一下二叉树前序遍历规则,最后我们只要分析如何按照 基准值key 来对区间中数据进行划分即可。


而按照基准值划分区间的方法有三种,分别为 hoare 版本、挖坑法、双指针版本 ,接下来我们一一学习。

2.2.1 hoare 版本

hoare 大佬是快速排序发明者,但是在实现这个版本时很容易踩坑。

快速排序的单趟排序要达到的目的:

1.将数组中某个元素(key元素)交换到其在有序序列中最终应该出现的位置,即:

单趟排序结束后要保证key元素之前的元素都比key元素小

单趟排序结束后要保证key元素之后的元素都比key元素大

2.单趟排序接口返回key元素最后的位置下标(作为数组分割点)

先看动图:

image.png

hoare 版本的思路:

1.首先取左边界为基准值 key ,定义两个指针left、right 分别指向最左边和最右边的元素。

2.right 先走,如果right 指向的元素大于 key 指向的元素,则right−− ,如果指向元素小于 key 指向的元素,则停止;

3.停止之后,left 开始走,如果 left 指向的元素小于key 指向的元素,则left++ ,如果指向元素大于key 指向的元素,则停止;

4.当 left 和right 都停下后,交换left 和 right 位置的值。

5.直到left≥right ,循环停止。


上面过程就保证了 left 和 right 相遇的位置的值是小于key 的, 此刻交换 a[left](或a[right])和a[key] ,令key=left(或right) ,此刻左右区间就已经被划分好了,key 就是分割点。


规定:如果取左边作为key 就要保证左边比 key 小,右边比key 大;如果取右边作为key 则右边比 key 小,左边比key 大。


一些相关的问题 :

问题1:为什么 left 和 right 相遇的位置是小于 key或直接就是key的位置(序列为升序) 的?是巧合还是必然?


这是必然的,接下来分析一下,一共有两种情况:image.png


第一种是 right 停住,left 遇到right ,相遇位置就是right 停住的位置。

当前情况就是right 在走时,遇到比 a[key] 小的元素停住了,然后 left 走时,和right 位置是小于key 。


第二种是left 停止,right 遇到left ,相遇位置是 left 停住的位置。

left 和right 已经交换过一次元素,所以left 的位置必定小于key ,left 停住了,之后right 走,直到和left 相遇,相遇位置是小于key 的。


还有一种特殊情况,就是right 先走,右边的数字都比 a[key] 大,right−− ,right 一直走到与 left 重合,a[key] 和 a[left] 交换后不变。


否则,当key在左,L先走的时候,就会出现问题

image.png

问题2:开头说过hoare 大佬的版本很容易踩坑,坑在哪里?如何解决?

其实这里容易引起的错误还是很多的,比如:

1.左右两边都有和 key 相等的值,导致左右两边卡死。

举个例子:

image.png

例如这种情况,a[key]=6 ,右边先走时,由于 a[right]=a[key] ,不走;左边走时,由于 a[left]=a[key] ,也不走。这样就死循环了!


所以,我们一开始的思路是需要 改进 的,当a[right]≥a[key] 时,right−−;在 a [ left ] < = a[left]<=a[key] 时,left++。这样就解决了 死循环的问题 。

bf24baa44dab41dd963d9c67f6eee862.png2.但是修好了这个 bug 另一个 bug 就冒出来了,一旦加上上面的条件,如果 key 右边的值都大于key ,且循环内部不加限制,right 就会一直 −− ,由于没有限制,right 就 停不下来了。image.png所以在 right 和left 每次走的时候需要加上限制 left<right 。image.png3.但还是存在问题,由于一开始的时候++left,导致最后交换的时候没有考虑到最开始keyi的值的比较image.png如何解决呢?去掉++leftimage.png由此,我们就可以写出代码:

// hoare 版本
int partion1(int* a, int begin, int end)
{
  int left = begin, right = end;
  int key = left;
  while (left < right)
  {
    // 右边先走
    // 两个条件一个防止跑出去,找大于 a[key] 的值
    while (left < right && a[right] >= a[key])
    {
      right--;
    }
    while (left < right && a[left] <= a[key])
    {
      left++;
    }
    Swap(&a[left], &a[right]);
  }
  // 交换值, key 作为分割点
  Swap(&a[left], &a[key]);
  key = left;
  return key;
}

2.2.2 挖坑法

由于hoare 大佬版本存在的 “坑” 比较多,后面就有一些未留名的大佬在 hoare 版本的基础上,对快排做出了一些改良,比如我们的 挖坑法 。

优点:不需要左边作key,右边先走等要求,且排出来顺序一般与hoare的版本不一样

单趟排序算法实现思想:

1.选择arr[left]作为key(key变量存储key元素的值)

2.以初始left指向的位置作为初始坑位(坑位下标用hole变量来记录)

3.right指针向前遍历寻找比key小的数,找到后将right指向的元素赋值到坑位上,将right下标值赋给hole(更新坑位)

4.left指针向后遍历寻找比key大的数,找到后将left指向的元素赋值到坑位上,将left下标值赋给hole(更新坑位)

5.重复上述迭代过程直到left指针和right指针相遇

image.png

挖坑法的思路:

1.挖坑法多了一个hole ,也就是坑位。我们将 key = a[left] ,将 key 保存到一个临时变量中,假设 key 这个位置的值被挖掉了,形成一个坑位。此时 hole 就是坑位的下标。

2.依然是右边先走,循环条件为left<right 并且right 指向的元素大于等于key ,一旦 right 停下来,那么就把 a[hole]=a[right] ,把right 指向的值填到坑中,此刻right 作为新的坑。

3.而 right 之前的值也被转移到了别的地方,这个位置就被看做为空,变成坑。

4.左边则是相同的思路,同理左边停下后,让 a[hole]=a[left] ,让 left 作为新的坑。

5.直到left≥right 停止。

6.最后的hole 就是key 值该去的地方,把这个地方填上 key ,此刻 hole 为分割点。


代码实现:

int partion2(int* a, int begin, int end)
{
  int left = begin, right = end;
  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;
  // 最后 left 和 right 都在坑上,和 hole 是重合的
  return hole;
}


相关文章
|
11天前
|
搜索推荐 C语言
【排序算法】快速排序升级版--三路快排详解 + 实现(c语言)
本文介绍了快速排序的升级版——三路快排。传统快速排序在处理大量相同元素时效率较低,而三路快排通过将数组分为三部分(小于、等于、大于基准值)来优化这一问题。文章详细讲解了三路快排的实现步骤,并提供了完整的代码示例。
39 4
|
13天前
|
搜索推荐 算法 C语言
【排序算法】八大排序(上)(c语言实现)(附源码)
本文介绍了四种常见的排序算法:冒泡排序、选择排序、插入排序和希尔排序。通过具体的代码实现和测试数据,详细解释了每种算法的工作原理和性能特点。冒泡排序通过不断交换相邻元素来排序,选择排序通过选择最小元素进行交换,插入排序通过逐步插入元素到已排序部分,而希尔排序则是插入排序的改进版,通过预排序使数据更接近有序,从而提高效率。文章最后总结了这四种算法的空间和时间复杂度,以及它们的稳定性。
56 8
|
13天前
|
搜索推荐 算法 C语言
【排序算法】八大排序(下)(c语言实现)(附源码)
本文继续学习并实现了八大排序算法中的后四种:堆排序、快速排序、归并排序和计数排序。详细介绍了每种排序算法的原理、步骤和代码实现,并通过测试数据展示了它们的性能表现。堆排序利用堆的特性进行排序,快速排序通过递归和多种划分方法实现高效排序,归并排序通过分治法将问题分解后再合并,计数排序则通过统计每个元素的出现次数实现非比较排序。最后,文章还对比了这些排序算法在处理一百万个整形数据时的运行时间,帮助读者了解不同算法的优劣。
44 7
|
26天前
|
搜索推荐
冒泡排序算法
【10月更文挑战第19天】冒泡排序是一种基础的排序算法,虽然在实际应用中可能不是最优的选择,但对于理解排序算法的基本原理和过程具有重要意义。
|
1月前
|
算法 搜索推荐 Shell
数据结构与算法学习十二:希尔排序、快速排序(递归、好理解)、归并排序(递归、难理解)
这篇文章介绍了希尔排序、快速排序和归并排序三种排序算法的基本概念、实现思路、代码实现及其测试结果。
23 1
|
1月前
|
算法 搜索推荐 Java
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
基数排序是一种稳定的排序算法,通过将数字按位数切割并分配到不同的桶中,以空间换时间的方式实现快速排序,但占用内存较大,不适合含有负数的数组。
24 0
数据结构与算法学习十三:基数排序,以空间换时间的稳定式排序,速度很快。
|
1月前
|
算法 搜索推荐
数据结构与算法学习十一:冒泡排序、选择排序、插入排序
本文介绍了冒泡排序、选择排序和插入排序三种基础排序算法的原理、实现代码和测试结果。
17 0
数据结构与算法学习十一:冒泡排序、选择排序、插入排序
|
26天前
|
算法 安全 Java
介绍一下比较与交换算法
【10月更文挑战第20天】介绍一下比较与交换算法
14 0
|
1月前
|
搜索推荐 C语言
排序算法--冒泡排序
排序算法--冒泡排序
14 0
|
1月前
|
算法
❤️算法笔记❤️-(每日一刷-83、删除排序链表中的重复项)
❤️算法笔记❤️-(每日一刷-83、删除排序链表中的重复项)
31 0