王道C++班级参考资料
——
C语言部分卷6算法基础
节2基础排序算法

最新版本V3.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

在本小节中,我们先来学习三种基础的排序算法:

  1. 选择排序

  2. 冒泡排序

  3. 插入排序

其中插入排序是比较重要,比较常用的。

选择排序

Gn!

选择排序(Selection Sort)是一种简单直观、非常易于理解、符合人类"暴力求解问题"思维的排序算法。这是一种很笨的排序算法,了解就行,一般在任何情况下都不推荐使用选择排序。

注:在讲解七大排序算法时,我们都会以"将一个整数数组从小到大进行排序"为例子。这么做是为了简化实现,如果需要排序的元素是其他类型或者需要逆序排序,也并不影响排序算法的思想。

假如存在一个数组:[16, 1, 45, 23, 99, 2, 18, 67, 42, 10]

选择排序的基本思路可以参考下图:

选择排序-过程示意图

工作原理如下:

  1. 第一轮选择排序:一开始整个数组都处于未排序状态,记录未排序序列的开头位置(第一轮就是数组首元素),遍历整个数组找到最小值,将最小值放在未排序序列的开头,然后在未排序的序列中去掉该元素。

  2. 第二轮选择排序:除了首元素外,数组所有元素处于未排序状态,记录未排序序列的开头位置(第二轮就是数组的第二个元素),遍历剩余未排序序列中的元素找到最小值,将最小值放在未排序序列的开头,然后在未排序的序列中去掉该元素。

  3. ....

  4. 重复过程:重复1和2两个过程,每一轮选择排序中,都会从剩余未排序的元素中继续寻找最小元素,然后放到未排序的序列的开头。

  5. 最后一轮选择排序:直到未排序序列的开头已经是倒数第二个元素,进行最后一轮选择排序。随后未排序序列就只剩最后一个元素,此元素一定就是数组元素最大值,选择排序结束。

选择排序听起来逻辑简单直接,但真的要手写实现,其实还是要仔细琢磨的。用C语言实现,参考代码如下:

选择排序-参考代码

时间复杂度分析:

在分析以比较为核心的排序算法的时间复杂度时,我们重点关注两点就可以了:

  1. 比较次数

  2. 交换元素次数

在选择排序当中,无论什么情况下,比较次数和交换元素次数都是一样的:

  1. 比较次数:

  2. 每一轮的比较次数会随着未排序序列减少递减

  3. 即一共比较(n - 1) + (n - 2) + ... + 1 = n * (n - 1) / 2 次(等差数列求和)

  4. 交换元素次数:数组一共有n个元素,除了末尾元素不需要交换,其它元素都需要交换,固定交换(n - 1)次。

将两者加起来就是总的指令执行数量。

除此之外,我们发现选择排序,在数组已经有序、逆序还是完全无序的各种情况下,这些比较和交换都不会减少。

所以用大O表示法,选择排序的时间复杂度是O(n2),任何情况下都一样。

空间复杂度分析:

选择排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)

稳定性分析:

选择排序稳定吗?答:不稳定!

选择排序在每一轮中选择最小元素,并与未排序部分的第一个元素交换。如果存在相等的元素,选择排序可能会改变它们的相对顺序。所以选择排序不是一个稳定的排序算法。

这样的例子随手可举,比如对于一个数组:

[3, 5, 3', 2, 8]

两个3是相同的元素,相对位置是3在前,3'在后。在选择排序后,得出结果是:

[2, 3', 3 , 5, 8]

变成3'在前,3在后。

冒泡排序

Gn!

冒泡排序(Bubble Sort)同样是一种简单直观的排序算法,当然它的效率同样很一般。一般来说,冒泡排序同样被认为是一种仅具有教学意义的排序算法,不会作为实际应用。

以数组[16, 1, 45, 23, 99, 2, 18, 67, 42, 10]为例子,其基本的思路可以参考下图:

冒泡排序-过程示意图1冒泡排序-过程示意图2

工作原理如下(按照从小到大排序):

  1. 第一轮冒泡排序:从数组的第一个元素开始,比较相邻的元素。如果第一个元素比第二个元素大,则交换它们的位置。然后,移动到下一对相邻元素,重复这个过程,直到比较最后一对元素。每一轮冒泡排序都会使当前比较序列的最大值到达数组末尾,随后第二轮排序过程中,需要比较的元素就减1。(将尾部最大的元素减去不再排序)

  2. 第二轮冒泡排序:重复第一轮的过程,但这次只比较和交换直到倒数第二个元素(因为最后一个元素已经是最大的了)。在这一轮结束时,倒数第二大的元素会被“冒泡”到倒数第二的位置。

  3. ...

  4. 结束条件:

    1. 在不设置任何额外结束条件的前提下,冒泡排序每一轮都会将未排序序列的最大值"冒泡"到末尾。冒泡排序需要进行固定的(n - 1)轮!

    2. 但实际上在这(n - 1)轮冒泡排序的过程中,只要某一轮完全不存在元素的交换,就说明数组已经完全有序了,排序就可以结束了。

    3. 所以我们可以设定一个布尔值来标记此轮冒泡排序是否存在元素交换,如果没有元素交换,直接结束整个排序。这种做法可以优化冒泡排序的性能,尤其是当原数组已基本有序时。

参考代码如下:

冒泡排序-参考代码

需要注意的是:

在上述参考代码中使用了"swapped"标记来使得冒泡排序可以在"一轮冒泡没有任何交换时结束"。这在某些场景下,可以提升算法的效率,尤其是最佳情况——输入的数组已经是有序的情况下。

假设n为数组的长度,分析以下复杂度:

时间复杂度分析:

最佳情况:由于使用了"swapped"标记,所以在最佳情况下,即输入的数组已经是有序的情况下,算法仅需要一次数组遍历就可以确定排序完成。

即:

比较次数: (n - 1)

交换次数: 0

所以最佳情况下的时间复杂度是 O(n)。

最坏情况:

在最坏的情况下(当数组完全逆序时),冒泡排序第一轮需要执行( n - 1) 次比较和交换,第二次执行( n - 2 )次,依此类推,直到最后一次执行 1 次。所以,总的执行次数是等差数列求和:n * ( n - 1) / 2。所以最坏情况下的时间复杂度是 O(n2)。

平均情况:在平均情况下,时间复杂度也是 O(n2)。虽然不是所有的元素都需要交换,但总体而言,性能接近最坏情况。

空间复杂度分析:

冒泡排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)

稳定性分析:

冒泡排序显然是一种稳定的排序算法,因为交换的过程中不会交换任何两个相同的元素。

插入排序

Gn!

插入排序(Insertion Sort)同样是一种非常简单、直观的排序算法。当然它的效率同样不高。

插入排序的过程就非常类似于我们在打扑克的过程中,抓牌——整理手牌顺序——再抓牌...的过程。

插入排序类似抓牌过程

其基本的思路可以参考下图:

插入排序-过程示意图

工作原理是(将数组从小到大排序):

  1. 以数组的首元素为初始状态:这个初始状态相当于抓到的第一张牌,它默认就是有序的。

  2. 从数组的第二个元素开始遍历:相当于抓一张牌,然后从小到大整理手牌。

  3. 比较与交换:将新插入的元素和前面的元素逐一比较,如果新插入元素较小,则交换两个元素,直到完全不可交换,则完成一轮排序。

  4. 重复步骤2和3,直到步骤2遍历到最后一个元素。

参考代码如下:

插入排序交换元素实现-参考代码

上面的实现是通过交换元素取值实现排序的,交换是一个比较"重量级"的操作,需要连续三次赋值来完成。所以我们可以用移动元素来取代交换元素,这样可以轻微提升一些性能。思路是:

先使用一个temp临时存储新插入元素,然后和前面的元素逐一比较,若前面的元素较大就向后移动一位,直到比较完前面所有元素或者比较到一个元素比新插入元素小。

参考代码如下:

插入排序移位元素实现-参考代码

这个优化后的版本,由于减少了操作指令的次数,可以轻微的提升算法效率。虽然这个提升并不会带来什么质变,但对于算法而言,点滴的提升都是有必要的。

性能分析

Gn!

以版本2为选择排序的实现,假设n为数组的长度,分析以下复杂度:

时间复杂度分析:

最佳情况:

  1. 当输入的数组已经是排序好的,此时不需要移动任何元素,但比较还是需要的。

  2. 比较次数(外层for循环次数):(n - 1)

  3. 移动次数:0

所以最佳情况下的时间复杂度是 O(n)。

最坏情况:

  1. 在最坏的情况下,即数组完全逆序时。

  2. 比较和交换的次数是相同的,第一轮需要比较和移动1次,第二轮需要比较和移动2次....最后一个元素需要比较和移动 (n - 1)次

  3. 等比数列求和:需要比较和移动各n(n-1)/2次,加起来就是T(n2- n)

所以最坏情况下的时间复杂度是 O(n2)。

平均情况:

  1. 在平均情况下,可以粗略的认为移动次数和比较次数是最坏情况除以2

  2. 所以在平均情况下,时间复杂度也是 O(n2)。

空间复杂度分析:

插入排序是一种原地排序算法,不需要占用额外内存空间。空间复杂度是O(1)

稳定性分析:

插入排序显然也是一种稳定的排序算法,因为对于两个相同的元素,我们始终都不会交换它们的相对位置。

注意:如果判断的条件改成arr[j] >= arr[j + 1],即在前后元素相等时也交换/移动元素,算法就会变成不稳定的。

总结

Gn!

选择排序、冒泡排序和插入排序都是基础的排序算法,同样的基于比较而实现,同样的简单直观,同样的原地算法。

这里对它们做一个总结分析:

  1. 选择排序在任何情况下时间复杂度都是O(n2),且它是一个不稳定的排序算法。比起作为算法去使用,它更具有教学意义,一般不推荐去使用它。

  2. 冒泡排序插入排序看起来很类似,它们都有最坏和平均情况下O(n2)的时间复杂度,都有最优情况下O(n)的时间复杂度。但在小数据集或者数据集基本有序的情况下,仍然更推荐使用插入排序。

这是因为:

  1. 插入排序的移动操作比冒泡排序的交换操作效率更高。在冒泡排序中,每轮冒泡都可能涉及大量交换元素操作,而插入排序用移动操作替代了交换。从指令执行数量上来说,移动操作效率更高。

  2. 在数据基本有序的情况下,插入排序往往表现要更好。这是因为在数据基本有序的情况下,插入排序往往只需要挪动元素很少的次数,而冒泡排序可能还需要,慢慢的一步步的两两交换多次。这里同样存在移动操作和交换操作指令数量上的差异!

The End