quicksort 演化改进过程

最简单容易理解的版本,我觉的是Nico Lomuto 的单向划分, c++ stl 的std::partition 就是用这个来实现的, 就是根据谓词函数的返回值,把返回true的全都调到数组的开头位置,返回false的放在尾部, 形成这两组:

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func makeRandInts(len, n int) []int {
	rand.Seed(time.Now().UnixNano())
	nums := make([]int, 0, len)
	for i := 0; i < len; i++ {
		nums = append(nums, rand.Intn(n))
	}
	return nums
}

func Lomuto(nums []int, predict func(e int) bool) int {
	len := len(nums)
	m := -1
	for i := 0; i < len; i++ {
		if predict(nums[i]) {
			m++
			nums[i], nums[m] = nums[m], nums[i]
		}
	}
	return m
}

func main() {
	nums := makeRandInts(10, 100)
	fmt.Printf("%+v\n", nums)
	i := Lomuto(nums, func(e int) bool {
		if e%2 == 0 {
			return true
		}
		return false
	})
	fmt.Printf("%+v, i=%d\n", nums, i)
}

这个例子就是把所有的偶数调到开头,形成偶奇两组,循环结束时,m 指向最后一个偶数(如果存在偶数)。

快速排序的划分结束后,要达到的效果就是数组左边全是不大于枢纽元素,右边全是不小于枢纽元素,Lomuto 划分可以达到这个目的,只要改下谓词函数:

func lomuto(nums []int) int {
	len := len(nums)
	pivot := nums[0]
	m := 0
	for i := 1; i < len; i++ {
		if nums[i] < pivot {
			m++
			nums[i], nums[m] = nums[m], nums[i]
		}
	}
	nums[0], nums[m] = nums[m], nums[0]
	return m
}

选取第一元素作为枢纽,从索引1开始扫描到末尾, nums[i] < pivot 充当了谓词函数, 所有小于pivot 的元素调在了开始位置, 结束后m 指向了最后一个小于pivot 的位置, 然后用m 指向的元素与pivot 交换, 达到了quicksort 划分过程的要求。

tony hoare 的划分是双向划分,一个指针i从左往右扫描, 另一个j从右往左扫描, 直到两个指针相遇:

func partition2Way(nums []int) int {
	pivot := nums[0]
	i := 0
	len := len(nums)
	j := len
	for {
		i++
		j--
		for nums[i] < pivot && i < len-1 {
			i++
		}
		for pivot < nums[j] {
			j--
		}
		if i >= j {
			break
		}
		nums[i], nums[j] = nums[j], nums[i]
	}
	nums[0], nums[j] = nums[j], nums[0]
	return j
}

循环开始之前, i = 0, j = len, i 的背后没有大于枢纽的元素(没有元素),j的背后没有小于枢纽的元素(没有元素), 不变量保持;循环过程中,第一小循环对应i从左往右扫描, 第二个对应j从右往左扫描,i,j 都期望不变量没有被破坏, 一旦被破坏,双方通过互换破坏元素,再次恢复不变量;Robert Sedgewick 说有过证明i, j 在扫描过程中,遇到等于枢纽的元素时, 双方都应该停止,这样划分会更均匀,所以 for 循环的条件是<, 不是<=;需要检查i 是否越界,如降序文件,j 不需要检查,最坏如升序文件, j 会停靠在最左边。

benchmark 显示双向划分好过单向划分, 当前的pivot 选取对应比较随机的文件效果不错, 但是完全升序或者降序文件效果很差, 选取index 0 作为pivot 元素,如果index 0 是最大的或者最小的元素,划分相当不均匀,一边是空文件, 一边是以前的文件大小减1, 完全升序或者降序文件,递归过程中,每次都是如此。有一些针对pivot 选取的改进: 随机抓取一个pivot; 选取一些元素采样,获得一个尽量接近数组中位数的值,作为pivot 。

func randRange(min, max int) int {
	return min + myrand.Int()%(max-min+1)
}
func partition2WayRandomPivot(nums []int) int {
	{
		i := randRange(0, len(nums)-1)
		nums[0], nums[i] = nums[i], nums[0]
	}
	pivot := nums[0]
	i := 0
	len := len(nums)
	j := len
	for {
		i++
		j--
		for nums[i] < pivot && i < len-1 {
			i++
		}
		for pivot < nums[j] {
			j--
		}
		if i >= j {
			break
		}
		nums[i], nums[j] = nums[j], nums[i]
	}
	nums[0], nums[j] = nums[j], nums[0]
	return j
}

针对有序或者逆序总体有些改善, median3 对这种情况有大幅提升,选取最左边, 中间位置, 最右边三个数的中位数,交换到index 0, 作为pivot:

func partition2WayMedian3(nums []int, lo, hi int) int {
	//median of 3
	m := lo + (hi-lo)/2
	if nums[m] < nums[lo] {
		nums[m], nums[lo] = nums[lo], nums[m]
	}
	if nums[hi] < nums[lo] {
		nums[hi], nums[lo] = nums[lo], nums[hi]
	}
	if nums[hi] < nums[m] {
		nums[hi], nums[m] = nums[m], nums[hi]
	}
	nums[m], nums[lo] = nums[lo], nums[m]

	pivot := nums[lo]
	i := lo
	j := hi + 1
	for {
		i++
		j--
		for nums[i] < pivot {
			i++
		}
		for pivot < nums[j] {
			j--
		}
		if i >= j {
			break
		}
		nums[i], nums[j] = nums[j], nums[i]
	}
	nums[lo], nums[j] = nums[j], nums[lo]
	return j
}

还有median3 的拓展Tukey的中位数的中位数

func median3(nums []int, i, j, k int) int {
	if nums[i] < nums[j] {
		if nums[j] < nums[k] {
			return j
		}
		if nums[i] < nums[k] {
			return k
		}
		return i
	}
	// i > j
	if nums[k] < nums[j] {
		return j
	}
	//i > j  &&  k > j
	if nums[k] < nums[i] {
		return k
	}
	return i
}

func partition2WayMom(nums []int) {
	len := len(nums)

	if len >= 9 {
		lo := 0
		hi := len - 1
		step := len / 8
		mid := len / 2
		m1 := median3(nums, lo, lo+step, lo+step+step)
		m2 := median3(nums, mid-step, mid, mid+step)
		m3 := median3(nums, hi-step-step, hi-step, hi)
		mom := median3(nums, m1, m2, m3)
		nums[lo], nums[mom] = nums[mom], nums[lo]
		.....
		.....
	}
}

根据数组大小调整步长,比如使用tukey方法最小数组大小9,步长1,index 0,1,2 之间找出中位数的index m1, 在index 3,4,5 中找到中位数的index m2, 在index 6,7,8中找到中位数的index m3, 三个中位数的中位数的位置mom, 然后交换到最左边作为pivot。

人们观察到复杂的方法在应对小规模数据时往往都不如简单方法,然后就有了小的子文件切断的改进。这是大小是10时,插入排序与快速排序的对比:

func qsort2WayMedian3CutOff(nums []int) {
	size := len(nums)
	if size <= cutoff {
		insertSort(nums)
		return
	}
	i := partition2WayMedian3(nums, 0, size-1)
	qsort2WayMedian3CutOff(nums[0:i])
	qsort2WayMedian3CutOff(nums[i+1:])
}

切断后效果:

还有三路划分过程,首先是Dijkstra 版本:

func partition3Way1(nums []int) (int, int) {
	lt := 0
	i := 1
	gt := len(nums) - 1
	pivot := nums[0]
	const (
		LESS = iota
		EQUAL
		GREAT
	)
	cmp := func(a, b int) int {
		if a == b {
			return EQUAL
		} else if a < b {
			return LESS
		} else {
			return GREAT
		}
	}
	for i <= gt {
		switch cmp(nums[i], pivot) {
		case LESS:
			nums[lt], nums[i] = nums[i], nums[lt]
			i++
			lt++
		case GREAT:
			nums[gt], nums[i] = nums[i], nums[gt]
			gt--
		case EQUAL:
			i++
		}
	}
	return lt - 1, gt + 1
}
func qsort3Way1(nums []int) {
	if len(nums) <= 1 {
		return
	}
	lt, gt := partition3Way1(nums)
	qsort3Way1(nums[0 : lt+1])
	qsort3Way1(nums[gt:])
}

这个版本比较容易理解:有三个指针, lt 用来收集小于pivot 的元素,i 从左往右扫描,检查当前元素跟pivot 的关系, gt 用来收集大于pivot 的元素; 开始时lt定位在index 0, pivot的位置, i 定位在index 1, gt定位在最后一个元素; i 开始检查元素,发现小于pivot 的元素,就往lt 处扔, lt 紧跟上来, i 继续往前; 发现等于pivot 的元素,继续往前; 发现大于pivot 的元素往gt处扔,i 接着检查gt扔过来的元素, gt 往i的位置前进; i 越过gt 后, 所有元素检查完。效果就是:

三路划分对大量重复元素的数组效果比较好:

Jon Bentley和Douglas McIlroy 又折腾出一个快速三路划分方法:把所有跟枢纽元素相等的元素都扔到数组左右两端, 挨着左边相等区间的是所有小于枢纽的元素,挨着右边相等区间的是所有大于枢纽的元素,划分结束后,再把两个相等区间的元素,往中间扔。达到下图的效果:

func partition3Way2(nums []int) (int, int) {
	pivot := nums[0]
	len := len(nums)
	i := 0
	j := len
	p := 0
	q := len
	for {
		i++
		j--
		for nums[i] < pivot && i < len-1 {
			i++
		}
		for pivot < nums[j] {
			j--
		}
		if i >= j {
			break
		}
		nums[i], nums[j] = nums[j], nums[i]
		if nums[i] == pivot {
			p++
			nums[i], nums[p] = nums[p], nums[i]
		}
		if nums[j] == pivot {
			q--
			nums[q], nums[j] = nums[j], nums[q]
		}
	}
	nums[0], nums[j] = nums[j], nums[0]

	mlo := j - 1
	mhi := j + 1
	for k := 1; k <= p; k, mlo = k+1, mlo-1 {
		nums[k], nums[mlo] = nums[mlo], nums[k]
	}
	for k := len - 1; k >= q; k, mhi = k-1, mhi+1 {
		nums[k], nums[mhi] = nums[mhi], nums[k]
	}
	return mlo, mhi
}
func qsort3Way2(nums []int) {
	len := len(nums)
	if len <= 1 {
		return
	}
	mlo, mhi := partition3Way2(nums)
	if mlo > 0 {
		qsort3Way2(nums[0 : mlo+1])
	}
	if mhi < len-1 {
		qsort3Way2(nums[mhi:])
	}
}

代码主体和两路划分一样,新增指针p,q, p开始初始化为i,q初始化为j,划分循环内部,当i, j 停止后,分别检查i,j 是否因为遇到跟pivot 相等的元素而停止,如果左边遇到,p往i靠近,p, i 互换元素,如果右边遇到,q往j靠近,q,j互换元素, 使用p,q 收集左右两边遇到等于pivot的元素,划分结束后,数组的状态: nums[0]=(some value), nums[1:p+1]=(=pivot), nums[p+1:j]=(<pivot), nums[j]=(pivot), nums[j+1:q]=(>pivot), nums[q:r]=(=pivot) 最后把nums[1..p] 往pivot 位置 j 的左边搬运, nums[q..len-1]往pivot j的右边搬运。三路划分版本2 比版本1 处理随机的情况更好:

自己手写的版本可能会胜过标准库的性能,猜测可能是因为golang 没有一个编译时泛型的机制,interface 提供了某种程度的运行时"泛型", sort.Sort interface 的Less方法和Swap方法属于排序过程中的高频操作, 它们不能被内联。试验代码

SIZE=1e6  go test    -bench '2WayMedian3CutOff$|StdLib'

输出:

还有一些更好的改进,比如pdqsort, rust 标准库已经用上了。

Douglas McIlroy 还介绍了一种攻击quicksort 通用排序库历程的方法,通用的库接口,为了做到足够灵活, 就允许客户端定制比较函数,Douglas 的方法就是在这里做手脚,他说这种方法几乎适用于任何基于quicksort 的通用排序接口,可能是这个原因, 基于quicksort 的通用排序库,最后采用heapsort 作为回退方案。 quicksort killer

package main

import (
	"flag"
	"fmt"
	"math/rand"
	"sort"
	"strings"
	"time"
)

var n = flag.Int("count", 13, "specify size of nums generate")

type killer struct {
	data      []int // data be sorted
	visit     []int // record data array visit info, initialized to special gas value and changed by Less
	ncmp      int   // number of comparisons (calls to Less)
	nsolid    int   // number of elements that have been set to non-gas values
	candidate int   // guess at current pivot
	gas       int   // special value for unset elements, higher than everything else
}

func (k *killer) Len() int { return len(k.data) }

func (k *killer) Swap(i, j int) {
	k.data[i], k.data[j] = k.data[j], k.data[i]
}

func (k *killer) Less(i, j int) bool {
	x := k.data[i]
	y := k.data[j]
	k.ncmp++
	if k.visit[x] == k.gas && k.visit[y] == k.gas {
		if x == k.candidate {
			// freeze x
			k.visit[x] = k.nsolid
			k.nsolid++
		} else {
			// freeze y
			k.visit[y] = k.nsolid
			k.nsolid++
		}
	}
	if k.visit[x] == k.gas {
		k.candidate = x
	} else if k.visit[y] == k.gas {
		k.candidate = y
	}
	return k.visit[x] < k.visit[y]
}

func GenKillerSeq(size int) []int {
	killer := newKiller(*n)
	sort.Sort(killer)
	return killer.visit
}

func (k *killer) Verify() bool {
	for i := 1; i < len(k.data); i++ {
		if k.visit[k.data[i]] != k.visit[k.data[i-1]]+1 {
			return false
		}
	}
	return true
}

func (k *killer) String() string {
	var b strings.Builder
	len := len(k.visit)
	b.WriteString(fmt.Sprintf("n=%d, ncmp=%d\n", len, k.ncmp))
	for i := 0; i < len; i++ {
		b.WriteString(fmt.Sprintf("%d\n", k.visit[i]))
	}
	return b.String()
}

func newKiller(size int) *killer {
	k := &killer{}
	k.data = make([]int, size)
	k.visit = make([]int, size)
	k.gas = size - 1

	for i := 0; i < size; i++ {
		k.data[i] = i
		k.visit[i] = k.gas
	}
	return k
}

func makeRandInts(len, max int) []int {
	rand.Seed(time.Now().UnixNano())
	nums := make([]int, 0, len)
	for i := 0; i < len; i++ {
		nums = append(nums, rand.Intn(max))
	}
	return nums
}

func main() {
	flag.Parse()

	ns := makeRandInts(*n, *n)
	s := time.Now()
	sort.Slice(ns, func(i, j int) bool { return ns[i] < ns[j] })
	e := time.Now()
	fmt.Printf("rand time: %v\n", e.Sub(s))

	killer := newKiller(*n)
	s = time.Now()
	sort.Sort(killer)
	e = time.Now()
	fmt.Printf("killer time: %v\n", e.Sub(s))
}

为了观察到killer 的效果,需要把标准库的heapsort 回退关掉:

func quickSort(data Interface, a, b, maxDepth int) {
               /*
		if maxDepth == 0 {
			heapSort(data, a, b)
			return
		}
		*/
}

运行killer:

./killer  -count 10000

./killer  -count 1000000

时间退化的厉害:

转载于:https://my.oschina.net/evilunix/blog/3003736

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值