数据结构与算法 | 青训营笔记

87 阅读6分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第2篇笔记

课前复习

  • 插入排序

    主要思想为将元素不断插入已经排好序的数组中,可以参考下图。

image.png

  • 快速排序

    主要采用分治的思想,不断分割序列直到有序。

image.png

  • 堆排序 利用堆的性质,通过构造堆并不断取出值完成排序。

image.png

image.png

如何设计一个在绝大部分情况下时间复杂度优秀的算法?

对于go语言内置排序算法来说,其任何提升能够提升绝大部分上层项目的性能,因此有必要尽可能的对内置排序算法进行优化。 接下来将通过一步步的推导得出一个在绝大部分情况下时间复杂度优秀的算法。

  • 对比三种传统算法的优缺点
  • 如何综合传统算法的优势

三种传统算法优劣对比

时间复杂度对比:

bestavgworst
插入排序O(n)O(n)O(n)
快速排序O(n*logn)O(n*logn)O(n^2)
堆排序O(n*logn)O(n*logn))O(n*logn))

可以看出,插入排序在最优情况下有着最低时间复杂度,但在最差情况下时间复杂度退化到O(n^2) ;快速排序整体性能属于中间层次,但但在最差情况下时间复杂度退化到O(n^2);而堆排序最为稳定,在任何情况下时间复杂度都为O(nlogn)。

在不同实际场景下对比:

由于现实中各种情况纷杂,难以通过数学手段得到准确的速度对比,因此通过实际大量数据测验得到各算法花费时间。

1.在完全随机情况下

image.png

  • 可以看出,插入排序在短序列中速度最快
  • 快速排序在中长序列中速度最快
  • 堆排序和快速排序速度差距不大

2.在基本有序情况下

image.png

  • 可以看出,在基本有序的情况下,插入排序最快

如何综合传统算法优势

由于三种算法在不同情况下各有优势,因此我们可以设计一种算法将他们综合起来,在不同场景下选择合适的排序方式。

vesion1

在这一版本中,我们从一下几点对排序算法进行优化

  • 由于在完全随机和基本有序的情况下,对于短序列来说都是选择排序效率最高,因此对于短序列我们都采用选择排序
  • 而快速排序在其他情况下平均效率最高,因此默认采用快速排序
  • 考虑到在基本有序情况下快速排序的性能退化到O(n^2),应在此时考虑使用堆排序

在实现算法之前,应该确定两个标准:

  • 短序列的定义,通常是12~32,在go1.19中采用24
  • 何时转为堆排序:对于快速排序算法,如果pivot离序列两端较近(距离小于length/8),我们认为有可能序列是原本有序的,在分治的过程中,如果上述情况出现了limit=bits.Len(length)次,便将排序方式转化为堆排序。

因此,我们得到了version1的排序方式,图解如下:

image.png

需要注意的是:

  • 当快速排序分治至短序列时,直接采用选择排序而不再使用快速排序

version2

在最初版本中,我们得到了一个能够较好处理不同排列情况序列的算法,那么,是否还有优化空间呢?

我们回顾快速排序算法,当pivot越接近中位数时,快速排序耗时越短。从这个角度应该如何优化呢?

遍历数组是我们也许首先会想到的方法,但是遍历本身也需要耗费大量时间,反而容易得不偿失。

退而其次,我们不要求pivot是准确的中位数,只要pivot的选择离中位数较近,快速排序就能够有较好的性能,因此,我们采用抽样的方式得到pivot。

  • 短序列(<=8),选择固定元素
  • 中序列(<=50),采样三个元素
  • 长序列(>50),采样9个元素

而采样带来的优势是显而易见的:

  • 比较采样的元素,我们可以获得比较接近中位数的pivot(采样元素的中位数)
  • 通过采样的获得的信息,可以推测原序列可能具有的性质并采取处理
    • 采样的元素都是逆序排列=》原序列可能为逆序=》反转原序列
    • 采样的元素都是顺序排列=》原序列可能为顺序=》采用插入排序(这里采用的是partial insertiion sort而不是一般的插入排序)

通过采用优化,我们得到version2的排序方式,图解如下:

image.png

vesrion3

还有可以优化的空间吗?

在快速排序中,还有一种会使得快速排序缓慢的情况:具有大量重复元素,如果能分辨出这种情况,就能够采取相应的策略应对。

如何分辨序列是否具有大量重复元素呢?

通过采样数据检测?由于采样较少,对于大样本来说性能并不好。

在go1.19中,采用了这种策略:

如果两次partition生产的pivot相同,那么认为原序列很可能是有着大量重复元素的=》使用partitionEqual将重复元素序列排在一起,减小对pivot选择的干扰。

这种思想实际上来自与统计学,两次partition生产的pivot相同通常来说是小概率事件,如果这种事件发生了,我们不认为是偶然,而是认为统计数据本身具有问题(即原序列有大量重复元素)

终于,我们得到了最终版本的排序方式,图解如下:

image.png

  • 实际上,在真正的算法实现中,还作了其他的优化。如在pivot选择策略表现不佳时,随机交换元素,避免了部分极端情况以及部分黑客攻击。

我们可以看看在go1.19中内置的pdqsort算法(其思想与上述类似)性能上的优势:

image.png

可以看出pdqsort的优势是巨大的。

总结

本节的排序算法作为一个例子,启示我们算法并非由固定的最优解,在不同生产环境下应该采取不同的实现策略,不断优化,最终达到一个较好的性能。