介绍
快排的思想是,选取数组中一个数,作为基准(pivot
),然后将数组分成左右两部分。左边部分全部小于基准,右边部分全部大于基准。随后再去递归地对左边部分和右边部分做相同操作,直至数组被分割成单一一个元素,排序完成。网络上大部分快排的实现都是递归实现,这里给出模板。
// 快排模板
func QuickSort(nums []int, leftEnd, rightEnd int) {
if leftEnd < rightEnd {
mid := Partition(nums, leftEnd, rightEnd)
QuickSort(nums, leftEnd, mid-1)
QuickSort(nums, mid+1, rightEnd)
}
}
鉴于递归的本质是编译器自动给你维护了一个栈,所以也可以用非递归的方式手动维护一个栈实现快排。
// 快排模板,非递归
func QuickSort(nums []int, leftEnd, rightEnd int) {
stack := make([][]int, 0)
stack = append(stack, []int{leftEnd, rightEnd})
for len(stack) != 0 {
frame := stack[len(stack)-1]
stack = stack[:len(stack)-1]
l := frame[0]
r := frame[1]
mid := Partition2(nums, l, r)
if mid > l {
stack = append(stack, []int{l, mid - 1})
}
if mid < r {
stack = append(stack, []int{mid + 1, r})
}
}
}
稍微不同于递归的是,我们需要判断一下mid和左右指针的大小,再分别向栈压入左右指针。
快排函数接收数组(nums
)、左边界(leftEnd
)、右边界(rightEnd
)。左边界必须小于右边界。数组传入后,先通过Partition()
函数分区。数组和左右边界被传入Partition()
后,数组会被区分左右两部分,左边的元素都小于右边的元素,Partition()
会返回一个数值mid
代表分区左右的基准值的下标。随后通过递归,再对基准数左右两边作出同样操作。
快排的关键算法,在于Partition()
的设计。优化的关键,大部分在于基准数的选取。
实现
东尼霍尔的实现
快速排序是东尼霍尔(Tony Hoare)设计的,他设计的算法思路,是选取要排序部分的第一个元素的作为基准。然后有左右两个指针从左右往中间靠近。右指针遇到比基准小的元素,会停下。左指针遇到比基准大的元素,会停下。两个指针停止后,会交换左右指针的元素,随后继续往中间前进,直至两个指针重合。然后再把最左边的基准值插入指针所在位置。
// 霍尔分区,采用最左边的元素作为基准
func HoarePartition(nums []int, leftEnd, rightEnd int) int {
pivot := nums[leftEnd]
l := leftEnd
r := rightEnd
for l < r {
for l < r && nums[r] >= pivot {
r--
}
for l < r && nums[l] <= pivot {
l++
}
nums[l], nums[r] = nums[r], nums[l]
}
nums[leftEnd], nums[l] = nums[l], nums[leftEnd]
return l
}
这个实际上也是大部分人写的版本。
算法导论的实现
算法的导论的版本,是以最右边的元素作为基准。然后有两个前后指针从前到后扫描。我在代码里仍然称其为左指针和右指针,但实际上他们都是从左往右移动的。首先是右指针,开始扫描,如果遇到比最右边元素小的,则让左指针向右移动一步,并替换左右指针元素。直到右指针扫描到最右边元素(也就是基准)的前一个元素。随后再将左指针的下一元素于基准元素对换。
这样子看起来好像很难理解。实际上,在左右指针向右移动的过程中,左指针左边的元素都会比基准小,左指针到右指针的部分都比基准大,右指针到基准的前一个元素,都是还没排序的。当扫描完成后,再将最右方的基准与左指针的下一元素对换。最后的结果仍然是基准左方小于基准,基准右方大于基准。建议看这位UP主做的视频理解【排序算法精华3】快速排序 (上)
// 算法导论分区,以最右为基准
func IOAPartition(nums []int, leftEnd, rightEnd int) int {
pivot := nums[rightEnd]
l := leftEnd - 1
r := leftEnd
for ; r < rightEnd; r++ {
if nums[r] <= pivot {
l++
//if l!=r
nums[l], nums[r] = nums[r], nums[l]
}
}
nums[rightEnd], nums[l+1] = nums[l+1], nums[rightEnd]
return l + 1
}
之所以最后要使用l+1
与基准对调,是因为包括l
在内的左边所有元素都小于基准,从l
指针下一个元素开始,才会大于等于基准。另外也因为这样,即使数组是逆序排列,l
没动过,也不会越界。
这种方式,交换次数会比较多,在实践中我发现会比霍尔的慢,在leetcode上会超时。在l++
后加个if l!=r
的判断可以勉强不超时。
优化
快排的最优时间复杂度是O(Nlog(2)N)
,最坏情况下是O(N^2)
。这个其实可以从递归树和递推公式大概猜想到。快排通常用递归实现,参考自《数据结构与算法分析 C语言描述》7.7.5,其时间复杂度可以描述为T(N)=T(i)+T(N-i-1)+cN
。i是递归时子数组长度,c是一个常数,与Partition()
函数相关。
若是每次分割数组都将数组分为大小相等的两组,那时间复杂递推公式可描述为T(N)=T(N/2)+cN
。若是每次分割为1和N-1长度的数组,那时间复杂度为T(N)=T(1)+T(N-1)+cN
。由此可以得出最好T(N)=O(Nlog(2)N)
和最坏T(N)=O(N^2)
。
也可也用递归树的想法来推导,若每次平等分割数组,那快排的递归树深度就为 log(2)N。若每次只分成1和N-1长度的,那递归树深度就会变成N。再对每个节点乘以Partition函数所花费的时间。可以分别得出O(Nlog(2)N)和O(N^2)的结论。
因此,优化的一个关键是如何让每次分割尽可能平均,取得中位数。
随机优化
上面描述的取第一和最后一个数的分区方法,在遇到逆序和顺序数组时,会退化到最坏情况。因此可以使用随机选取基准的方式,破坏这一情况。
// 算法导论分区优化1
// 随机选取基准
func IOAPartition1(nums []int, leftEnd, rightEnd int) int {
// rand.Seed(time.Now().Unix()) 将会超时
random := leftEnd + rand.Intn(rightEnd-leftEnd+1)
nums[rightEnd], nums[random] = nums[random], nums[rightEnd]
pivot := nums[rightEnd]
l := leftEnd - 1
r := leftEnd
for ; r < rightEnd; r++ {
if nums[r] <= pivot {
l++
nums[l], nums[r] = nums[r], nums[l]
}
}
nums[rightEnd], nums[l+1] = nums[l+1], nums[rightEnd]
return l + 1
}
注意,在Go中直接使用rand.Intn()
产生的只是伪随机数,也就是输入同样的参数,每次调用都返回同样结果,但因为我们在每层递归时,都设定了不同的上下界,所以获得的随机数也是每次不同的,这个时可以接受的。如果尝试在每层递归都设置随机数种子,将会导致超时,因为这个操作太耗时了。我测试的结果,每次加时间戳种子的耗时是不加种子的452倍左右。
三数中值
为了尽量将数组平分,应该尽量取中位数,但为了准确取到中位数本身就需要一次排序。所以为了尽可能的取得中位数,我们选择数组的第一、最后、以及中间的三个数,求这三个数的中位数,作为对这个子数组中位数的估计。
// 算法导论分区优化2
// 选择三个求中位数
func IOAPartition2(nums []int, leftEnd, rightEnd int) int {
median := rightEnd
if len(nums) > 2 {
half := (leftEnd + rightEnd) / 2
if nums[leftEnd] <= nums[half] && nums[half] <= nums[rightEnd] {
median = half
}
if nums[leftEnd] <= nums[rightEnd] && nums[rightEnd] <= nums[half] {
median = rightEnd
}
if nums[half] <= nums[leftEnd] && nums[leftEnd] <= nums[rightEnd] {
median = leftEnd
}
if nums[half] <= nums[rightEnd] && nums[rightEnd] <= nums[leftEnd] {
median = rightEnd
}
if nums[rightEnd] <= nums[half] && nums[half] <= nums[leftEnd] {
median = half
}
if nums[rightEnd] <= nums[leftEnd] && nums[leftEnd] <= nums[half] {
median = leftEnd
}
}
nums[rightEnd], nums[median] = nums[median], nums[rightEnd]
pivot := nums[rightEnd]
l := leftEnd - 1
r := leftEnd
for ; r < rightEnd; r++ {
if nums[r] <= pivot {
l++
nums[l], nums[r] = nums[r], nums[l]
}
}
nums[rightEnd], nums[l+1] = nums[l+1], nums[rightEnd]
return l + 1
}
快排及其优化就介绍这么多,此外还有混合插入排序以及尾递归优化等方式,建议各位看其他博客。
参考
- [1] MarkAllenWeiss. 数据结构与算法分析:C语言描述[M]. 机械工业出版社, 2004:183-186.
- [2] Bilibili 五点七边:【排序算法精华3】快速排序 (上)
- [3] 快速排序算法的时间复杂度分析[详解Master method]
- [4] 算法导论第七章快速排序
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。