面试中,我们遇到很多排序算法的考核。
快速排序,堆排,希尔排序,插入排序,归并排序等等…
由于现在竞争激烈,面试官除了问这些经典的排序算法,偶尔也会考核一下候选人对很多工业排序的认知,比如MySQL的order by,PHP的array系列排序函数,Go的sort包,Java的Collections.sort()。
对排序这块,确实让人头疼,存在这么几个痛处:
- 找不到比较规范的算法
- 很难完全理解规范的算法
- 好不容易学会了又会忘记
- 和工作中的内容不搭边
- 由于上面几点,面试的排序怎么都准备不踏实
这里我介绍一个比较实用的排序学习方向,也是今天要探讨的一部分:
算法知识 -> 普通练手 -> 工业源码 -> 工作场景排序
- 算法知识:特定排序对应的知识,比如插入排序的规范写法(教材),原理等
- 普通练手:理解了算法后,在编辑器中敲出来并运行,配合LeetCode之类
- 工业源码:去了解你熟悉的语言包如何实现排序,因为它们经受住了工业级别的应用,显然非常有价值
- 工作场景:现实场景中的排序,比如1000万用户的多维度排序,限定内存的多文件大文件排序等
只要踏踏实实掌握上面4点,排序算法这块就可以安心了,即使你会忘记一些细节,你在跟别人讲述排序的时候,别人都可以感受到你「由点及面」的能力。
今天我们就第三点「工业源码」的排序展开学习,由于我最近使用Go比较多,也比较生疏,正好就拿它的排序包sort来学习了。
读源码是一项脑力+体力活,难度确实不小。
作为工程师,「拆解问题」是我们的本能,对于今天这个特殊的主题:「Go语言Sort包中的工程级排序算法」,我们将它拆解成不同粒度的子问题,一直到可以理解。
我会从入口开始,一层一层分析,直接最后理解了整个排序,希望也能帮助到你。
开始:
从使用层面的粒度:
// Sort sorts data.
// It makes one call to data.Len to determine n, and O(n*log(n)) calls to
// data.Less and data.Swap. The sort is not guaranteed to be stable.
func Sort(data Interface) {
n := data.Len()
quickSort(data, 0, n, maxDepth(n))
}
注释里写到:Sort不是一个稳定排序算法,也就是说排序后有可能会破坏数据的稳定性,比如 [5, 1, 2, 3, 5],经过不稳定排序后,有可能最后一个元素5会跑到第一个5的前面,变成[1, 2, 3, 5(原来的最后一个), 5(原来的第一个)]
本篇关注的重点是工程级的排序算法如何实现,所以假设各位都清楚了排序相关的一些前置知识,包括:时间复杂度分析,插入排序,希尔排序,堆排序,快速排序和Go语言的基本语法,如果还不是很清楚这些知识点的话,建议先逐个击破,再回到该篇一起探讨这个算法,会更高效。
说明一点,源码的注释我都保留了,以免因为我的片面理解歪曲了作者的本意。
进入正题,先看一下Interface的结构:
// A type, typically a collection, that satisfies sort.Interface can be
// sorted by the routines in this package. The methods require that the
// elements of the collection be enumerated by an integer index.
type Interface interface {
// Len is the number of elements in the collection.
Len() int
// Less reports whether the element with
// index i should sort before the element with index j.
Less(i, j int) bool
// Swap swaps the elements with indexes i and j.
Swap(i, j int)
}
经典三大配件,无需多言。
熟悉排序的我们知道,排序过程中,比较和交换是必要环节,比较可以判断是否需要交换,而交换会减少逆序度(人话:数据集合会变得有序一些)。
了解了data要求的结构之后,我们就可以进入Sort这个入口函数了,但在这提醒一点,Less()的实现完全是可以由我们来自定义的,所以对于复杂对象的比较,比如 注册时间+用户年龄的多维度排序,我们可以自定义Less()。
往下走:
入口函数告诉我们,会调用一次data.Len来获取数据的长度,并且以O(n*log(n))的规模来调用Less和Swap,像这种说明,先看一眼,不去细究。
重点来了:
quickSort(data, 0, n, maxDepth(n))
这个 maxDepth(n)是干什么用的? 我们先大概看一眼它的功能,不要太深究背后的数学原理。
根据数据规模,不断二分,累计深度。
// maxDepth returns a threshold at which quicksort should switch
// to heapsort. It returns 2*ceil(lg(n+1)).
func maxDepth(n int) int {
var depth int
for i := n; i > 0; i >>= 1 {
depth++
}
//下面这行不要纠结,后面会解释
return depth * 2
}
注释告诉我们:maxDepth返回一个适当的阈值,用于判断当前的排序算法应不应该由快排切换到堆排,这里maxDepth的作用就是限制递归的深度。递归的空间成本很高,面对巨大规模的数据,递归还有内存溢出风险,maxDepth的存在规避了风险。
这里我们可能会好奇最后return的时候为什么乘于2,没事,后面进入快排的时候,我会解释一下。
看完maxDepth,我们回到quickSort。
func quickSort(data Interface, a, b, maxDepth int) {
for b-a > 12 {
// Use ShellSort for slices <= 12 elements
if maxDepth == 0 {
heapSort(data, a, b)
return
}
maxDepth--
mlo, mhi := doPivot(data, a, b)
// Avoiding recursion on the larger subproblem guarantees
// a stack depth of at most lg(b-a).
if mlo-a < b-mhi {
quickSort(