快速排序算法

也不怎么复杂的一个算法,面试的出场率却非常高。但工作中,基本上不会有手动写快排的场景,更多是直接使用封装好的第三方排序类。我观摩了 Go 的排序方法,是基于不同情况下多种组合排序实现的。我觉得也很有必要掰开、嚼碎了来看看

快排和归并

听了一次领导的分享会,讲了它经常发问的一道面试题,“不是简单的一拳,是一套组合拳”:快速排序的时间复杂度?归并排序的时间复杂度?快排的最坏的排序情况是什么,两者的优劣势?

快速排序的平均时间复杂度是O(NlogN),最坏的情况下,也就是在完全有序的情况下,时间复杂度会达到 O$(N^2)$。因为存在最坏的情况,所以快速排序算法并不稳定。而归并排序是稳定的排序算法,不管数组的初始顺序怎么样,时间复杂度都是O(NlogN)

但其实大家也有体会,就是归并排序的应用明显没有快速排序广泛,为什么呢?因为空间复杂度,快速排序是原地排序算法,不需要额外的空间进行排序。而归并排序不是原地排序算法,需要什么额外的空间。考虑到空间的复用情况,归并排序的空间复杂度是 O(N)

统筹的解释了这么多概念,其实还有一个经常被问到的细节,就是排序算法是否稳定。归并其实是可以保证算法稳定的,但快速排序不可以。这里的稳定主要是指:数值相等的两个元素在排序前后,位置是否会发生变化。两者仍然保持之前先后顺序的话,算法就是稳定的。

快速排序

快速排序算法可以从几个细节切入,就可以很快的将算法写出来。经常投身在业务的开发中,猛地让我写一个快排,对我个人而言,还挺难的,得好好想想。我之前有过翻来覆去地想快排的原理,脑子里浮现的是服务员按大小号整理服装衣架的例子,但这里例子对快排来说,并不怎么适合。

后来,我尝试结构化思维来对快排做拆分,这比较符合要素分类,首先区分算法原理和算法实现,算法实现上又区分 pivot 和左右排序。最后,我觉得特别扯,又一次践行从入门到放弃的大道理。

在这里插入图片描述

排序原理

下面这三个步骤,是从底层原理上来解释的。但概括的维度比较高,实现起来还是比较困难。不过,如果我们不考虑空间复杂度,按照下面的思路,每次申请三个数组,将数据进行排序拆分,也确实实现了快速排序。

  • 首先选一个轴值,可以是数组的第一个,也可以是最后一个,还可以是任意一个。
  • 将待排序记录划分成独立的三部分,左侧的记录均小于轴值,右侧记录均大于轴值,中间记录等于轴值。
  • 然后分别对左右两部分重复上述过程,重复进行,保证整个序列有序

但快速排序使用的是原地排序的原理,据说非常有技巧

原地排序

原地排序主要是指:排序不需要借住额外的存储空间,通过内部元素之间的相互交换位置,就可以将数组有序排列。很多算法都可以实现原地排序,比如插入排序。快速排序是借住轴值来进行原地排序的算法。理解了原地排序,才会对教科书上的排序算法有更加深刻的理解。

左右比较,数组头一个指针,数组尾一个指针,通过前后比较,和轴值的位置做交换,就能实现将数组拆分成左右两部分。一般来说,我们可以选择数组末尾的元素作为 pivot,这样的话,左指针指向的是数组的第一个元素,右指针指向的是数据的倒数第二个元素。

对于这个交换过程,我一直都是烂熟于心的,也大概明白其中的原理,是很难解释清楚其中的原理。

在这里插入图片描述

结合下面的例子,我们来实际看一遍原地排序:假设一个原始的数据是 [1,5,7,4,8,2,6],我们选定6作为轴值,我们将6这个位置空出来,用来做左右交换。左指针向右遍历到7的位置,和轴值交换。右指针遍历到2的位置和轴值交换。左指针遍历到8的位置和轴值交换。最后,那个空出来的位置就是轴值。

[1,5,7,4,8,2,_]		// 6作为pivot
[1,5,_,4,8,2,7]		// 左边遍历到大于6的数
[1,5,2,4,8,_,7]		// 右边遍历到小于6的数
[1,5,2,4,_,8,7]
[1,5,2,4,6,8,7]		// 将轴值写到最后的位置

递归

第一次分区我们将数组分成了 Left、Center、Right 三个子数组,在这个基础上,我们需要继续将 Left 排序分成 Left_Left,Left_Center、Left_Right 三个部分,对应的 Right 同理。持续这样的分片排序,最终保证最小的分片是有序的。

递归表达式,以及递归的退出条件就是算法的核心。表达式需要从上述原地排序的过程来抽象,递归退出的条件主要看什么时候不需要执行原地排序。最后拆分的数组元素个数为1时,就可以当做递归退出的条件

假设我们选择数组的最后一个值作为 pivot,在迭代过程中保留这个空位,用来做原地排序使用。我们来实现第一遍的排序操作,非常好理解,l从做左到右进行遍历,如果遇到大于 pivot 的,就和空位进行交换,然后,r从右向左进行遍历,如果遇到小于 pivot的就和空位进行交换。

当l和r相遇时,说明数组小于 pivot 和大于 pivot 的两个集和已经拆分完成,将空位写入 pivot 的值。当然,如果数组中存在多个和 pivot 相等的值,因为只有一个空位,并不能保证这些相等的值就一定都在数组中间的位置。

func sort(arr []int) {
	index := len(arr) - 1
	pivot := arr[index]

	l := 0
	r := index - 1
	for l < r {
		for arr[l] < pivot {
			l++
		}
		if l < r {
			arr[l], arr[index] = arr[index], arr[l]
			index = l
		}
		for arr[r] > pivot {
			r--
		}
		if l < r {
			arr[r], arr[index] = arr[index], arr[r]
			index = r
		}
	}
	if arr[l] > arr[index] {
		arr[index] = pivot
	}
}

上面的过程是在引入了“空位”这个逻辑上开发的代码,这只是为了帮助我们理解原地排序,但空位其实可以被我们优化掉,完全是不需要的,我们可以继续调整:l 向右遍历到大于 pivot 的值,r 向左遍历到小于 pivot 的值,然后两个位置相互交换数据。最终退出循环时,l 指向的是大于 pivot 的值,然后将该值和 pivot 进行交换。

这里的处理有点类似选择排序。我们通过游标 i 把 A[p…r-1]分成两部分。A[p…i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1]是“未处理区间”。我们每次都从未处理的区间 A[i…r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。

在这里插入图片描述

下面的过程还做了其他优化,当数组的长度为1时,直接退出;在l向右遍历时,保证了 l < r的限制条件,确保 l 不会越界,r的处理也同理;还有,当 l>=r 时直接 break 掉 for 循环,减少一次 for 循环比较。

func sort(arr []int) {
	if len(arr) <= 1 {
		return
	}

	index := len(arr) - 1
	pivot := arr[index]

	l := 0
	r := index - 1
	for l < r {
		for arr[l] < pivot && l < r {
			l++
		}
		for arr[r] > pivot && r > l {
			r--
		}
		if l < r {
			arr[l], arr[r] = arr[r], arr[l]
		} else {
			break
		}
	}

	if arr[l] > arr[index] {
		arr[l], arr[index] = arr[index], arr[l]
	}	
}

下面我们只需要引入递归的部分就可以了,分别对 index 左边的部分,和右边的部分进行排序。但需要我们考虑一些数组切片截取是边界的异常情况,比如当 index 正好是切片的首元素或者尾元素时,不会存在切片下标越界的问题。下面是递归的部分

	sort(arr[0:l])
	sort(arr[l+1:])

查找第K大的数

从一个无序数据中查找第K大的数,我首先想到堆排序。因为我们想要获取从小到大排序的第 K 个元素,等于找最小的 K 个元素,所以建立一个小顶堆,而堆排序的时间复杂度的O(NlogN)。但用上面的快速排序可以达到 O(N)的时间复杂度。

我们每次通过 pivot 将数据拆分成三部分,小于 pivot 的,等于 pivot 的,和大于 pivot 的。比较 pivot 的下标,如果刚好等于 K,就是我们要找的元素。如果 K 小于 pivot 的下标,我们左半部分,否则,我们找右半部分。

为什么说平均时间复杂度是 O(N)而不是O(NlogN)呢,因为我们总共遍历数组的次数为:N + N/2 + N/4 +......

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值