工程级的排序算法如何实现,所以假设各位都清楚了排序相关的一些前置知识,包括:时间复杂度分析,插入排序,希尔排序,堆排序,快速排序和Go语言的基本语法。话不多说,直接开干。
首先需要实现Interface接口中的三个函数。排序过程中,比较和交换是必要环节,比较可以判断是否需要交换,交换可以减少逆序度(集合变有序)。实现三个函数的目的是为了实现slice和user defined cllections。比如对于复杂数据类型包含注册时间+用户活跃度等的多维度排序,可以自定义Less()。以下分析仅是个人理解。
//sort的实现接口Sort,需要参数type data Interface。目的是为了实现类似模板的思想,可以对sliceh或者user defined collections进行排序。因此接口中的三个函数需要实现。
func Sort(data Interface) {
n := data.Len()
quickSort(data, 0, n, maxDepth(n))
}
// 定义type并实现接口,并且将data和func进行绑定
type intArr []int
func (ia intArr) Len() int { return len(ia) }
func (ia intArr) Less(i, j int) bool { return ia[i] < ia[j] }
func (ia intArr) Swap(i, j int) { ia[i], ia[j] = ia[j], ia[i] }
func main() {
data := intArr{1,2,3,4,6,2,1,2,2,1,1,2,3,3,32,3,0,1,2,19} //test
fmt.Println(data)
Sort(data)
fmt.Println(data)
}
quickSort中使用了以下函数:maxDepth,heapSort,medianOfThree,doPivot,shellSort和insertionSort。其实就是设计时针对可能存在的不同细节问题进行处理。接下来对每个函数进行分析。每个地方我都进行了注释,如果在阅读过程中还是存在不理解可以使用IDE进行调试分析。虽然代码看起来似乎优点长,但是去掉注释都非常的精简。沉淀下来进行思考很重要。
Go源码的实现非常规范且可读性强,一步一个脚印,修炼内功,共勉!
如果对于任何一个排序算法的实现还不熟悉可以参考博客:
十大排序算法的实现
// 关键点:为什么在maxDepth == 0选择堆排序;如何寻找pivot;如何有效的处理分区;元素个数少时为什么使用希尔和插入排序
func quickSort(data Interface, a, b, maxDepth int) {
for b-a > 12 {
if maxDepth == 0 {
heapSort(data, a, b)
return
}
maxDepth--
// 划分区间:左<=pivot,右>pivot,注意考虑存在大量相等的情况,mlo和mhi含义为-->midlo, midhi
mlo, mhi := doPivot(data, a, b)
// Avoiding recursion on the larger subproblem guarantees
// a stack depth of at most lg(b-a).
// 避免在更大的子问题上递归,保证堆栈深度最多为 lg(b-a)。
// 数据规模小进行递归,规模大在循环中进行。规模小的进行递归能快速完成并释放空间
if mlo-a < b-mhi {
// 当mlo-a较小时,b = mlo进行递归; 较大将赋值为mli继续在for循环中进行
quickSort(data, a, mlo, maxDepth)
a = mhi // i.e., quickSort(data, mhi, b)
} else {
quickSort(data, mhi, b, maxDepth)
b = mlo // i.e., quickSort(data, a, mlo)
}
}
// 元素个数少时,利用希尔和插入排序,插入排序是稳定排序
if b-a > 1 {
// Do ShellSort pass with gap 6
// It could be written in this simplified form cause b-a <= 12
// 进行一次(没有保证稳定性) gap = 6的希尔排序,为什么选取gap为6,个人觉得是当某个元素进行下面的插入排序时,swap次数最多为gap大小。
for i := a + 6; i < b; i++ {
if data.Less(i, i-6) {
data.Swap(i, i-6)
}
}
insertionSort(data, a, b)
}
}
maxDepth的作用就是限制递归的深度。递归的空间成本很高,面对巨大规模的数据,递归还有内存溢出风险,maxDepth的存在规避了风险。
// maxDepth returns a threshold at which quicksort should switch
// to heapsort. It returns 2*ceil(lg(n+1)).
// 树单层元素个数:2^(k - 1),总个数:2(k)-1; k >= 1. e.g.: 2 4 6 8 10增长且maxDepth = level*2
func maxDepth(n int) int {
var depth int
for i := n; i > 0; i >>= 1 {
depth++
}
return depth * 2 // 一个quickSort会调用两次子quickSort,所以要乘于2。
}
为什么操作了maxDepth之后转为堆排序。目的是—> 虽然是取中值的思想,但是依然是随机选择。在pivot选择比较糟糕的情况下(没有比较均匀分区),使用堆排序保证了最坏时间复杂度O(nlogn),因此需要计算maxDepth。也有其他算法例如归并排序也能保证最坏O(nlogn),但是需要额外空间;并且经过快排操作后能保证基本有序,使用堆排序heapify次数也会相对减少。
注意使用快排的好处之一在于能更好的利用局部性原理。
heapSort实现:初始化大顶堆和首尾交换实现排序。
// siftDown implements the heap property on data[lo:hi].
// first is an offset into the array where the root of the heap lies.
// 从根lo位置进行调整操作(一般叫做heapify),for死循环直到退出,注意所有slice的index左闭右开。最大堆满足A[root_i] >= A[i],以root为根的子树中root值最大。
func siftDown(data Interface, lo, hi, first int) {
root := lo
for {
child := 2*root + 1
if child >= hi {
break
}
if child+1 < hi && data.Less(first+child, first+child+1) {
child++
}
if !data.Less(first+root, first+child) {
return
}
data.Swap(first+root, first+child)
root = child // 继续向下调整,保证每个根都满足大顶堆的条件
}
}
func heapSort(data Interface, a, b int) {
first := a
lo := 0
hi := b - a
// Build heap with greatest element at top.
// 初始化大顶堆:保证每个非叶子节点都满足大顶堆性质,从最后一个父节点heapify
for i := (hi - 1) / 2; i >= 0; i-- {
siftDown(data, i, hi, first)
}
// Pop elements, largest first, into end of data.
// 将头尾(未排好序的尾部)交换,root为最大值,放到最后实现排序效果
for i := hi - 1; i >= 0; i-- {
data.Swap(first, first+i)
siftDown(data, lo, i, first)
}
}
最难理解的就是分区函数 doPivot 的实现。代码非常的长,我们一步一步来分析。首先把握快排的主要思想:先随机选择(三数取中和九数取中)pivot。然后从前往后找大于A[pivot]的元素,从后向前找小于等于A[pivot]的元素进行交换,直到双指针相遇。但是此处的分区函数进行了更细致的分区处理。
func doPivot(data Interface, lo, hi int) (midlo, midhi int) {
// 防止下标越界:m = lo + (hi - lo) / 2, 或者转为无符号数进行运算
m := int(uint(lo+hi) >> 1) // Written like this to avoid integer overflow.
// 数据量小直接三值取中,数据量大选择三值取中后再三值取中就是9个数的中间值
if hi-lo > 40 {
// Tukey's ``Ninther,'' median of three medians of three.
s := (hi - lo) / 8
medianOfThree(data, lo, lo+s, lo+2*s)
medianOfThree(data, m, m-s, m+s)
medianOfThree(data, hi-1, hi-1-s, hi-1-2*s)
}
medianOfThree(data, lo, m, hi-1)
// 以下操作就是在调整成如下情况
// Invariants are:
// (1) data[lo] = pivot (set up by ChoosePivot)
// (2) data[lo < i < a] < pivot
// (3) data[a <= i < b] <= pivot
// (4) data[b <= i < c] unexamined
// (5) data[c <= i < hi-1] > pivot
// (6) data[hi-1] >= pivot
// lo设置为pivot,从下一个开始排序(lo, hi] --- 实现(1)
pivot := lo
a, c := lo+1, hi-1
// a < pivot 右移,目的是为了划分区间 data[lo < i < a] < pivot --- (2)
for ; a < c && data.Less(a, pivot); a++ {
}
b := a
// 循环break条件为左右元素已经调整完成:b>=c。快排思想:就是左边找大于pivot右边找小于等于pivot进行交换
for {
for ; b < c && !data.Less(pivot, b); b++ { // data[b] <= pivot --- (3)
}
// c-1是因为在三数取中时(medianOfThree)已经保证了最后一个元素是大于等于pivot --- (6)
for ; b < c && data.Less(pivot, c-1); c-- { // data[c-1] > pivot --- (5)
}
if b >= c {
break
}
// 快排交换的思想:data[b] > pivot; data[c-1] <= pivot
data.Swap(b, c-1) // 此处的交换没有保证stable
b++
c--
}
--------以下为了处理[a,c]区间中存在大量和pivot相等的情况-----------
// 一轮for循环操作之后初步分区完成,但是需要考虑存在很多重复元素的情况---(3)(4)
// 如果数据中有大量重复值的元素(和data[pivot]相等),那么大量元素会累积在左边的分区。这个分区的时间复杂度就很可能远大于O(nlogn)。
// 因此需要进行调整,大量重复值应该放在pivot附近,最好形成一个「等于」的区间,这样递归的时候,就可以避开中间这个可能很长的区间。
// 在9值划分时,理想情况左右各4个。如果右边的元素小于3个,则可能出现“倾斜”。保守估计使用5,应该是经验值并且3->5也就多一次check。
// 然后就判断重复情况,如果存在多个重复需要进行调整:实际上就是[a,b]区间中找靠近a等于pivot的元素和b端小于pivot的元素进行交换,最后b就是小于pivot的元素index.
// If hi-c<3 then there are duplicates (by property of median of nine).
// Let's be a bit more conservative, and set border to 5.
protect := hi-c < 5
if !protect && hi-c < (hi-lo)/4 {
// Lets test some points for equality to pivot
dups := 0
if !data.Less(pivot, hi-1) { // data[hi-1] = pivot
data.Swap(c, hi-1)
c++
dups++
}
if !data.Less(b-1, pivot) { // data[b-1] = pivot
b--
dups++
}
// m-lo = (hi-lo)/2 > 6
// b-lo > (hi-lo)*3/4-1 > 8
// ==> m < b ==> data[m] <= pivot
if !data.Less(m, pivot) { // data[m] = pivot
data.Swap(m, b-1)
b--
dups++
}
// if at least 2 points are equal to pivot, assume skewed distribution
protect = dups > 1 // 如果存在至少两个等于pivot就设为true
}
if protect {
// Protect against a lot of duplicates
// Add invariant:
// data[a <= i < b] unexamined
// data[b <= i < c] = pivot
for {
for ; a < b && !data.Less(b-1, pivot); b-- { // data[b] == pivot
}
for ; a < b && data.Less(a, pivot); a++ { // data[a] < pivot
}
if a >= b {
break
}
// data[a] == pivot; data[b-1] < pivot
data.Swap(a, b-1)
a++
b--
}
}
// Swap pivot into middle
data.Swap(pivot, b-1)
return b - 1, c //返回值即为排除所有pivot后的左区间右端点和右区间左端点
}
如果有兴趣的同学可以分析稳定性算法的实现,需要结合代码阅读源paper。
不容易鸭,能静下心来钻研的同学才能学到真东西。
排序的设计考虑:
(1)改善重点部分,细致思考和分析可能的情况,才能提高整体效率
(2)针对不同的问题规模,处理方式也会不同
(3)前人的知识财产很宝贵—很多经典思路的paper,要加以利用
拓展:
循环不变式与排序的正确性:循环不变性的三个性质(初始化:循环第一次迭代前为true;保持:循环的某次迭代前为true,在下次迭代前仍然为true;终止:循环终止时,不变式性质能证明算法的正确性),上述三个性质可以类似数学归纳法来证明(算法导论P10)。
排序算法的稳定性(Stable):冒泡排序、插入排序、归并排序和基数排序。对于复杂数据类型排序稳定性很关键。比如MySQL中对姓名排序,e.g:{ac, bb, ad}。稳定排序能保证相对位置不变。如果先按第二个元素排序再按照第一个元素排序。稳定排序:{ac, bb, ad}–>{bb, ac, ad}–>{ac, ad, bb}。先排序第一个再排第二,对于是否为stable-sort好像没有影响,但是如果第二元素存在相同,无法保证已排序的第一元素的相对顺序。
以上分析得不够全面仔细,对于一些问题的思考不一定正确,希望抛砖引玉,共同成长。