Golang源码分析之sort

工程级的排序算法如何实现,所以假设各位都清楚了排序相关的一些前置知识,包括:时间复杂度分析,插入排序,希尔排序,堆排序,快速排序和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好像没有影响,但是如果第二元素存在相同,无法保证已排序的第一元素的相对顺序。

以上分析得不够全面仔细,对于一些问题的思考不一定正确,希望抛砖引玉,共同成长。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值