【排序算法】快速排序

256 阅读8分钟

快速排序

快速排序是一种分治策略的排序算法,是由英国计算机科学家 Tony Hoare 发明的, 该算法被发布在 1961 年的 Communications of the ACM 国际计算机学会月刊

注: ACM = Association for Computing Machinery,国际计算机学会,世界性的计算机从业员专业组织,创立于1947年,是世界上第一个科学性及教育性计算机学会。

快速排序是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。

算法介绍

快速排序通过一趟排序以一个数为基准,将要排序的数据分割成独立的两部分,其中一部分所有的数据会比另外一部分的所有数据都要小,然后再按这个方法重新对这两部分数据做同样的事情,整个排序过程都可以用递归进行,以达到整个数据变成有序序列。

这个过程充斥着分而治之的思想。

步骤如下:

  1. 先从数列中取一个数作为基准数,一般取第一个数,没有明确规定。
  2. 分区过程,将大于基准数的放在它的右边,小于等于基准数的放在它的左边。
  3. 再对左右分区重复第二步。
  4. 直到分区只有一个数为止。

举个例子:5 9 1 6 8 14 6 49 25 4 6 3

quickSort_1

注:

  • 黄色是基准数
  • 橙色是已经排序完成的元素
  • 紫色是大于基准数的元素
  • 绿色是小于基准数的元素
  • 红色是正在与基准数进行比较的元素

时间复杂度

最好的情况下,每一轮都能够平均切分,需要递归的深度是 log2(n),同样每一轮时间复杂度为:O(n)。下一次遍历元素的长度为 n/2,总的时间复杂度计算公式为:T(n) = 2*T(n/2) + O(n)。按照主定理公式计算,我们可以知道时间复杂度为:O(nlogn),如果不太清楚,我们可以具体计算一下:

我们来分析最好情况,每次切分遍历元素的次数为 n/2

T(n) = 2*T(n/2) + n
T(n/2) = 2*T(n/4) + n/2
T(n/4) = 2*T(n/8) + n/4
T(n/8) = 2*T(n/16) + n/8
...
T(4) = 2*T(2) + 4
T(2) = 2*T(1) + 2
T(1) = 1

进行合并也就是:

T(n) = 2*T(n/2) + n
     = 2^2*T(n/4)+ 2n
     = 2^3*T(n/8) + 3n
     = 2^4*T(n/16) + 4n
     = ...
     = 2^logn*T(1) + logn * n
     = 2^logn + nlogn
     = n + nlogn

因为当问题规模 n 趋于无穷大时 nlogn 比 n 大,所以 T(n) = O(nlogn)。

最好时间复杂度为:O(nlogn)。

在最坏的情况下,每次不是平均的切分,找的基准数是最大的或者是最小的,不能分成两个数列,这样需要递归的深度是 n 次了,计算公式为:T(n) = T(n-1) + O(n),按照主定理计算可以直到时间复杂度为:O(N^2),我们也可以来具体算算:

我们来分析最差情况,每次切分遍历元素的次数为 n

T(n) = T(n-1) + n
     = T(n-2) + n-1 + n
     = T(n-3) + n-2 + n-1 + n
     = ...
     = T(1) + 2 +3 + ... + n-2 + n-1 + n
     = O(n^2)

最差时间复杂度为:O(n^2)。

**根据熵的概念,数量越大,随机性越高,越自发无序,所以待排序数据规模非常大时,出现最差情况的情形较少。**在综合情况下,快速排序的平均时间复杂度为:O(nlogn)。对比之前介绍的排序算法,快速排序比那些动不动就是平方级别的初级排序算法更佳。

空间复杂度

快速排序每次都是利用常量进行排序(原地排序),每次存储空间复杂度为:O(1),但是快排的实现是递归调用,递归层数是 logn~n,所以空间复杂度范围为:O(logn)~log(n),其实很少情况会到达最坏的情况,所以一般空间复杂度为:O(logn)

算法实现

func QuickSort(list []int,begin,end int)  {
	//退出条件就是当数列中元素只有一个时候。
	if begin<end {
		//获取基准数当前的位置
		location:=partition(list,begin,end)
		//分割成两个独立的数列进行处理
		//小于基准数的部分
		QuickSort(list,begin,location-1)
		//大于基准数的部分
		QuickSort(list,location+1,end)
	}
}

func partition(list []int,begin,end int)  int {
	//数列中左边的数
	i:=begin+1
	//数列中最右边的数
	j:=end
	//分区过程
	for i<j{
		//遍历数列中每一个数,与基准数 begin 进行比较
		if list[i]>list[begin]{
			//如果大于基准数,那么将后面的数进行交换
			list[i],list[j]=list[j],list[i]
			//j 后面的都是大于基准数的
			j--
		}else {
			//小于基准数的元素不动。
			i++
		}
	}
	/* 跳出 for 循环后,i = j。
	 * 此时数组被分割成两个部分  -->  array[begin+1] ~ array[i-1] < array[begin]
	 *                        -->  array[i+1] ~ array[end] > array[begin]
	 * 这个时候将数组array分成两个部分,再将array[i]与array[begin]进行比较,决定array[i]的位置。
	 * 最后将array[i]与array[begin]交换,进行两个分割部分的排序!以此类推,直到最后i = j不满足条件就退出!
	 */
	//你也可以这么理解,判断退出循环的 i 是否小于基准数数列的右边界,还是已经超出去了,如果超出去了需要选择小于基准数数列的右边界
	if list[i]>=list[begin]{
		i--
	}
	//将基准数放到合适的地方
	list[i],list[begin]=list[begin],list[i]
	return i
}

算法改进

快排的局限性:

  • 快排是一个效率很高的排序算法,但是对于长度很小的序列,快排效率低。
  • 当数组中有大量重复的元素,快排效率会比较低。
  • pivot 选择不当,将导致树的不平衡,导致快排的时间复杂度接近为 O(n^2)

快排可以继续进行算法改进:

  1. 在小规模数组的情况下,直接插入排序的效率最好,当快速排序递归部分进入小数组范围,可以切换成直接插入排序。
  2. 排序数列可能存在大量重复值,使用三向切分快速排序,将数组分成三部分,大于基准数,等于基准数,小于基准数,这个时候需要维护三个下标。
  3. pivot 可以采用 random 随机数取进行选择。
  4. 使用伪尾递归减少程序栈空间占用,使得栈空间复杂度从 O(logn)~log(n) 变为:O(logn)

小规模数组使用直接插入排序

func QuickSort1(array []int, begin, end int) {
    if begin < end {
        // 当数组小于 4 时使用直接插入排序
        if end-begin <= 4 {
            InsertSort(array[begin : end+1])
            return
        }

        // 进行切分
        loc := partition(array, begin, end)
        // 对左部分进行快排
        QuickSort1(array, begin, loc-1)
        // 对右部分进行快排
        QuickSort1(array, loc+1, end)
    }
}

当当前数组的规模很小的时候,推荐使用插入排序来代替部分快速排序。

三向切分

// QuickSort2 快速排序改进2:三向切分
func QuickSort2(list []int,begin,end int)  {
	//退出条件就是当数列中元素只有一个时候。
	if begin<end {
		//这里分别记录了重复元素的左边界和右边界
		l,r:=partition2(list,begin,end)
		// 从lt到gt的部分是三切分的中间数列
		// 左边三向快排
		QuickSort(list,begin,l-1)
		// 右边三向快排
		QuickSort(list,r+1,end)
	}
}
func partition2(list []int,begin,end int) (int,int) {
	//为什么要从基准数开始呢?
	//可以将小于基准数的都放到基准数重复范围的左边,就可以不用去进行最后一步的交换值了
	lt:=begin//基准数的左边界
	qt:=end//基准数的右边界
	i:=begin+1//开始位置
	v:=list[begin]//基准数
	//确保所有的重复值在一起
	for i<=qt{
		//当当前值大于基准值,进行交换,同时边界--
		if list[i]>v{
			list[i],list[qt]=list[qt],list[i]
			qt--
		}else if list[i]<v{
			//当当前值小于基准值时候,进行交换,边界++
			list[i],list[lt]=list[lt],list[i]
			i++
			lt++
			//上面两个判断操作都是为了保证索引小于lt都是小于基准值的,索引大于qt都是大于基准值,那么就保证了lt~qt都是基准数重复的值
		}else {
			i++
		}
	}
	return lt,qt
}

演示:

数列:4 8 2 4 4 4 7 9,基准数为 4

[4] [8] 2 4 4 4 7 [9]  从中间[]开始:8 > 4,中右[]进行交换,右边[]左移
[4] [9] 2 4 4 4 [7] 8  从中间[]开始:9 > 4,中右[]进行交换,右边[]左移
[4] [7] 2 4 4 [4] 9 8  从中间[]开始:7 > 4,中右[]进行交换,右边[]左移
[4] [4] 2 4 [4] 7 9 8  从中间[]开始:4 == 4,不需要交换,中间[]右移
[4] 4 [2] 4 [4] 7 9 8  从中间[]开始:2 < 4,中左[]需要交换,中间和左边[]右移
2 [4] 4 [4] [4] 7 9 8  从中间[]开始:4 == 4,不需要交换,中间[]右移
2 [4] 4 4 [[4]] 7 9 8  从中间[]开始:4 == 4,不需要交换,中间[]右移,因为已经重叠了
第一轮结果:2 4 4 4 4 7 9 8

分成三个数列:

2
4 4 4 4 (元素相同的会聚集在中间数列)
7 9 8

接着对第一个和最后一个数列进行递归即可。

三向切分其实主要解决对于数组有大量重复值的问题。

其实是把有大量重复值的数组,分成了三部分:

  • 小于基准数的
  • 大于基准数的
  • 等于基准数的

这样子就减少了递归的深度,相同的元素就会聚集在中间,这些元素也不会参与下一次的排序。

三向切分主要来自荷兰国旗三色问题,该问题由 Dijkstra 提出。

img

假设有一条绳子,上面有红、白、蓝三种颜色的旗子,起初绳子上的旗子颜色并没有顺序,您希望将之分类,并排列为蓝、白、红的顺序,要如何移动次数才会最少,注意您只能在绳子上进行这个动作,而且一次只能调换两个旗子。

可以看到,上面的解答相当于使用三向切分一次,只要我们将白色旗子的值设置为 100,蓝色的旗子值设置为 0,红色旗子值设置为 200,以 100 作为基准数,第一次三向切分后三种颜色的旗就排好了,因为 蓝(0)白(100)红(200)

注:艾兹格·W·迪科斯彻(Edsger Wybe Dijkstra,1930年5月11日~2002年8月6日),荷兰人,计算机科学家,曾获图灵奖。

随机基准数

func partition3(list []int,begin,end int)  int {
	//设置随机数的种子
	rand.Seed(time.Now().UnixNano())
	//获得随机数 范围【end,begin】
	x:=rand.Intn(end-begin+1)+begin
	//交换第一个数与基准数
	list[x],list[begin]=list[x],list[begin]
	//数列中左边的数
	i:=begin+1
	//数列中最右边的数
	j:=end
	//分区过程
	for i<j{
		//遍历数列中每一个数,与基准数 begin 进行比较
		if list[i]>list[begin]{
			//如果大于基准数,那么将后面的数进行交换
			list[i],list[j]=list[j],list[i]
			//j 后面的都是大于基准数的
			j--
		}else {
			//小于基准数的元素不动。
			i++
		}
	}
	/* 跳出 for 循环后,i = j。
	 * 此时数组被分割成两个部分  -->  array[begin+1] ~ array[i-1] < array[begin]
	 *                        -->  array[i+1] ~ array[end] > array[begin]
	 * 这个时候将数组array分成两个部分,再将array[i]与array[begin]进行比较,决定array[i]的位置。
	 * 最后将array[i]与array[begin]交换,进行两个分割部分的排序!以此类推,直到最后i = j不满足条件就退出!
	 */
	//你也可以这么理解,判断退出循环的 i 是否小于基准数数列的右边界,还是已经超出去了,如果超出去了需要选择小于基准数数列的右边界
	if list[i]>=list[begin]{
		i--
	}
	//将基准数放到合适的地方
	list[i],list[begin]=list[begin],list[i]
	return i
}

为了防止基准数选择不当,导致导致树的不平衡,所以采用随机数获取基准数,尽可能的让他的时间复杂度远离 O(n)

伪尾递归优化

// 伪尾递归快速排序
func QuickSort3(array []int, begin, end int) {
    for begin < end {
        // 进行切分
        loc := partition(array, begin, end)

        // 那边元素少先排哪边
        if loc-begin < end-loc {
            // 先排左边
            QuickSort3(array, begin, loc-1)
            begin = loc + 1
        } else {
            // 先排右边
            QuickSort3(array, loc+1, end)
            end = loc - 1
        }
    }
}

很多人以为这样子是尾递归。其实这样的快排写法是伪装的尾递归,不是真正的尾递归,因为有 for 循环,不是直接 return QuickSort,递归还是不断地压栈,栈的层次仍然不断地增长。

但是,因为先让规模小的部分排序,栈的深度大大减少,程序栈最深不会超过 logn 层,这样堆栈最坏空间复杂度从 O(n) 降为 O(logn)

这种优化也是一种很好的优化,因为栈的层数减少了,对于排序十亿个整数,也只要:log(100 0000 0000)=29.897,占用的堆栈层数最多 30 层,比不进行优化,可能出现的 O(n) 常数层好很多。

非递归实现

非递归的实现仅仅将函数递归栈改成了人工栈来处理每一个部分的数列。

package main

import (
    "fmt"
    "sync"
)

// 链表栈,后进先出
type LinkStack struct {
    root *LinkNode  // 链表起点
    size int        // 栈的元素数量
    lock sync.Mutex // 为了并发安全使用的锁
}

// 链表节点
type LinkNode struct {
    Next  *LinkNode
    Value int
}

// 入栈
func (stack *LinkStack) Push(v int) {
    stack.lock.Lock()
    defer stack.lock.Unlock()

    // 如果栈顶为空,那么增加节点
    if stack.root == nil {
        stack.root = new(LinkNode)
        stack.root.Value = v
    } else {
        // 否则新元素插入链表的头部
        // 原来的链表
        preNode := stack.root

        // 新节点
        newNode := new(LinkNode)
        newNode.Value = v

        // 原来的链表链接到新元素后面
        newNode.Next = preNode

        // 将新节点放在头部
        stack.root = newNode
    }

    // 栈中元素数量+1
    stack.size = stack.size + 1
}

// 出栈
func (stack *LinkStack) Pop() int {
    stack.lock.Lock()
    defer stack.lock.Unlock()

    // 栈中元素已空
    if stack.size == 0 {
        panic("empty")
    }

    // 顶部元素要出栈
    topNode := stack.root
    v := topNode.Value

    // 将顶部元素的后继链接链上
    stack.root = topNode.Next

    // 栈中元素数量-1
    stack.size = stack.size - 1

    return v
}

// 栈是否为空
func (stack *LinkStack) IsEmpty() bool {
    return stack.size == 0
}

// 非递归快速排序
func QuickSort5(array []int) {

    // 人工栈
    helpStack := new(LinkStack)

    // 第一次初始化栈,推入下标0,len(array)-1,表示第一次对全数组范围切分
    helpStack.Push(len(array) - 1)
    helpStack.Push(0)

    // 栈非空证明存在未排序的部分
    for !helpStack.IsEmpty() {
        // 出栈,对begin-end范围进行切分排序
        begin := helpStack.Pop() // 范围区间左边
        end := helpStack.Pop()   // 范围

        // 进行切分
        loc := partition(array, begin, end)

        // 右边范围入栈
        if loc+1 < end {
            helpStack.Push(end)
            helpStack.Push(loc + 1)
        }

        // 左边返回入栈
        if begin < loc-1 {
            helpStack.Push(loc - 1)
            helpStack.Push(begin)
        }
    }
}

// 切分函数,并返回切分元素的下标
func partition(array []int, begin, end int) int {
    i := begin + 1 // 将array[begin]作为基准数,因此从array[begin+1]开始与基准数比较!
    j := end       // array[end]是数组的最后一位

    // 没重合之前
    for i < j {
        if array[i] > array[begin] {
            array[i], array[j] = array[j], array[i] // 交换
            j--
        } else {
            i++
        }
    }

    /* 跳出while循环后,i = j。
     * 此时数组被分割成两个部分  -->  array[begin+1] ~ array[i-1] < array[begin]
     *                        -->  array[i+1] ~ array[end] > array[begin]
     * 这个时候将数组array分成两个部分,再将array[i]与array[begin]进行比较,决定array[i]的位置。
     * 最后将array[i]与array[begin]交换,进行两个分割部分的排序!以此类推,直到最后i = j不满足条件就退出!
     */
    if array[i] >= array[begin] { // 这里必须要取等“>=”,否则数组元素由相同的值组成时,会出现错误!
        i--
    }

    array[begin], array[i] = array[i], array[begin]
    return i
}

func main() {
    list3 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
    QuickSort5(list3)
    fmt.Println(list3)

    list4 := []int{5, 9, 1, 6, 8, 14, 6, 49, 25, 4, 6, 3}
    QuickSort6(list4)
    fmt.Println(list4)
}

参考文章

快速排序