【算法】排序 (一):插入排序&希尔排序&选择排序&堆排序(C++实现)

排序算法的具体实现都在文章末端

一. 插入排序

1. 直接插入排序(稳定)
  • 插入排序是日常中比较常见的算法,比如在平时扑克牌游戏中,我们分到的牌都是无序的,开局前我们会对牌进行排序,此时牌被分为两堆——有序堆和无序堆。初始时有序堆中的扑克牌数量为0,我们每一次从无序堆中拿出一张牌,和有序堆中的最后一张牌比较到第一张牌,直到下一张牌大于当前牌,则放在这张牌后面。这样有序堆扑克牌数量多了一张,无序堆的扑克牌数量少了一张。
    比如我们有一个数字序列如下
    9 4 2 0 5 1 6 7
    其排序过程如下所示,每次都从无序部分选出一个元素,从右往左比较有序部分中的元素,直到当前元素小于要插入元素则将元素插入其后。
    直接插入排序示例
  • 时间复杂度分析
    假设数字串的长度为n
    • 最好情况
      原本数串就是有序串,不需要移动数串中的元素,只需要n次比较即可,则复杂度为O(n)。
    • 最坏情况
      原本数串是倒序串,此时每次都要做O(n)的比较,而且还需要做1+2+…+(n-1)=n(n-1)/2次的移动元素的操作。因此最终最坏情况下的时间复杂度为 O(n2)
    • 平均情况
      假设每种情况的概率相同,我们可以通过期望算出平均复杂度,通常与最坏情况大致一样差。则直接插入排序的平均时间复杂度为 O(n2)
  • 空间复杂度分析
    只需要开数组不需递归,因此空间复杂度维持在O(1)的常数级水平。
2. 希尔排序(不稳定)
  • 希尔排序是插入排序的增强版。利用了插入排序的两点性质:
    (1) 在基本有序的前提下,插入排序的耗时总会很短。因为插入排序耗时的部分在于寻找插入位置上,直接插入排序每次在寻找插入位置都是从有序部分的最后一个开始看,通过不断比较向前推进,每推进一步就移动一次数据(注意:是移动数据而不是交换数据,因此比冒泡排序要好很多)。如果数据插入在有序部分靠后的位置则需要的步数就较少,此时说明原来的无序数组是基本有序的。
    (2) 如果数组很短,就算基本无序我们要做的插入的耗时跟基本有序的情况差不多。
    希尔排序就是利用这两个性质把一个很长的数串分成若干个小部分,通过对这些部分进行插入排序来营造基本有序的数列。接下来再将这些部分不断扩大,使得这个数列逐渐有序。此时的关键策略是如何分块,主要有两种:一种是不断中分(归并排序);另一种是设定一个小于数组长度n的实数k,凡是位置编号(index)模k得到同样数值的分为一块(希尔排序,是类似于散列中处理关键字的方法)。
    现在的另一个关键问题是k应该取多少合适:在大量数据实验后得到的经验值为k越接近 n2 ,然后让k不断折半,直到k为1(此时为直接插入排序),得到的就是有序的了。
    同样的一个数字序列如下
    9 4 2 0 5 1 6 7
    具体排序过程如下图所示
    希尔排序示例图
  • 时间复杂度分析
    由于shell排序是直接插入排序的优化,其最坏最好平均情况都与插入排序一致。目前没有明确的数学推导可以得到其时间复杂度,但是大量模拟实验后,可以大致认为时间复杂度为 O(n1.25)
  • 空间复杂度分析
    仍然为O(1)的常数级。
  • 优劣及稳定性
    希尔排序是对直接插入排序算法的优化,在运行效率会比直接插入排序、简单选择排序及冒泡排序稍高。但希尔算法不算是对插入排序算法一种特别高效的优化,也就是说基于比较的排序的时间复杂度还有进一步提升的空间。
    希尔排序是不稳定的,因为相同的元素在排序过程中前后位置可能会发生交换,这是因为一开始step的值不为1时,可能将相同元素中原来排在后面的元素扔到了前面,使得排序后相同元素顺序会发生交换。

二. 选择排序

1. 简单选择排序
  • 这是一种非常简单而且在日常生活中常见的排序算法,将数串分为有序部分S和无序部分U,每次都从无序部分中选出最小元素放在S的尾部,知道无序部分元素数目为零,即可得到有序数串。只需要N-1次选择,即可完成选择排序。同样的一个数字序列如下
    9 4 2 0 5 1 6 7
    具体排序过程如下图所示
    简单插入排序示例
  • 时间复杂度分析
    假设数串的长度为n,对于选择排序,不管是最好情况、最坏情况、平均情况的时间复杂度大致相同。在一次循环中只需要一次交换值,所以交换值的时间复杂度为O(1),可以不考虑 。找到最小的、第二小的、一直找到最后需要n-1次循环,而每次循环内选出最小元素的下标,需要O(n)的时间复杂度。因此总体的复杂度为 O(n2)
  • 空间复杂度分析
    O(1)常数级。
  • 优劣及稳定性
    选择排序和插入排序都是直观的排序算法,而且运行效率差不多。选择排序在找出第k个最值的题目中有着比插入排序更大的优势(因为插入排序需要将其全部排好序才能确定,而选择排序不需要,因此省下很多时间)。
    选择排序除了低效以外,还是不稳定的。
    备注:选择排序从一个n元素数组中找到最大值和最小值的时间复杂度都必须为O(n)。
2. 堆排序
  • 假如现在的题目是找出第k个最大元素或者是第k个最小元素,我们只要将前面k个数或者是后面k个数排序就可以了,其他部分并不关心。此时类似于分治排序之类的全排序算法已经失去优势。反而选择排序类的算法更有优势。堆排序算法就是对简单选择排序的改进,可以降低时间复杂度。
    堆是一个完全二叉树(设二叉树的深度为h,除了第h层以外,其他各层的节点数都达到最大个数),其次完全二叉树上每个节点比左右两个子节点都大(最大堆),或者比左右两个子节点都小(最小堆)。此时最大(小)元素总在堆顶,而且每个堆的左右子树也为堆。以最大堆为例,得到有序序列只需要每次取出堆顶元素,然后将剩下元素恢复成堆,重复上述过程即可得到有序序列。
    堆排序的关键点如下:
    • 当堆顶元素被抽出之后,怎么调整堆才可以维护好堆的性质?
      此时可以从堆的左右孩子中选出最大的元素补到堆顶元素的位置上。此时该节点原来的位置又是一个空节点,此时这个空节点作为子树的堆顶元素重复上述过程,直到空节点下降至叶节点。举例如下:
      堆性质恢复
      或者可以直接把最底层最右节点补到根节点的位置,并且不断向下恢复堆性质,实现代码中就是采用这种方法。
    • 怎么建堆?即怎么把任意一个完全二叉树变成堆。
      从底层开始判断,
      对于一个左右子节点(若存在)均为叶节点的根节点来说,只需要比较该根节点与其子节点的大小,使得最大的元素为根节点即可。
      对于左右节点(假如存在)不都是叶节点,则需要递归的维护堆的性质,假如孩子节点发生变化,需要检查其子树是否依旧符合性质。举例如下:
      构建堆
      确定了堆排序算法的大致过程,我们需要确定数据结构。从上述例子可以看出,该算法对于数据的随机读取效率要求比较高,所以我们考虑用数组来实现。
      注意,要用数组实现树状结构是不可行的,即使是完全二叉树也是不可行的(因为得出的二叉树不唯一)。但是对于特定情况来说是确实可行的,因为使用数组的目的是借助这个结构来理解堆排序,建出来的堆并不要求是唯一的,只要求具有堆的性质。因此对于任意给定的一个数组,我们都能把它排成随便一种二叉树的形式,建堆过程后我们都会得到一个堆。
      把一个数组变成随便一种完全二叉树的形式最好的方法,就是从左往右,从上往下的放,尽量占满每一层的节点位置,如果不够再转到下一层。这样做的好处十分明显,一是查找父节点和孩子节点十分容易:假设根节点对应的下标为k,那么其左节点和右节点对应的下标分别为2k+1或者2k+2;二是数组很好操作,当我们要删除一个元素时,交换两个节点只需要交换对应下标的数据即可。
      可以看出恢复堆性质操作的后半段过程与创建堆一致,可以通过一个函数来完成这一任务,增强代码复用性。
      同样用一个简单的例子展示堆排序的过程,如下所示
      堆排序示例
  • 时间复杂度分析
    在堆排序中主要耗时的有两个环节。一是恢复堆的性质,二是建堆。
    对于恢复堆性质,其函数的时间复杂度与完全二叉树的层数有很大关联。最好情况就是第一个元素本身就是最大元素,因此只需要一次比较,为O(1)的时间复杂度。对于最坏情况,基本上有多少层就要做多少次比较和赋值操作。由于完全二叉树是一个高度平衡的树,所以树的层数基本上为 log2n ,因此这一部分的时间复杂度为 O(log2n)
    建堆的时间复杂度为 n2× 恢复堆性质的时间复杂度(因为建堆需要循环数串里的一半元素),所以建堆的时间复杂度为 n2log2n ,因此时间复杂度为 nlog2n
  • 空间复杂度分析
    对于尾部递归函数,可以很容易改成非递归函数,因此空间复杂度接近于O(1)。
  • 优劣及稳定性
    堆排序算法成功解决了选择排序中每次选出最大元素都要经历O(n)时间的问题,通过堆的特殊性质成功将O(n)时间变成了 O(log2n) ,而且实现也比较简单,因此对于找出第k个最大元素或者第k个最小元素时首选堆排序。
    然而堆排序不是稳定排序。另外虽说其时间复杂度为 O(nlog2n) ,但是其前面的系数接近于3,因此对于数组的全排序还是建议用快速排序的方法。

三、其他

  1. C语言的指针和C++的引用主要有以下区别:
    (1)引用必须被初始化,但是不分配存储空间。 指针不声明时初始化,在初始化的时候需要分配存储空间。
    (2) 引用初始化以后不能被改变,指针可以改变所指的对象。
    ( 3) 不存在指向空值的引用,但是存在指向空值的指针。
    注意:引用作为函数参数时,会引发一定的问题,因为让引用作参数,目的就是想改变这个引用所指向地址的内容,而函数调用时传入的是实参,看不出函数的参数是正常变量,还是引用,因此可能会引发错误。所以使用时一定要小心谨慎。

四、参考和代码

[1] 排序算法C++实现

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值