排序的概念

排序分为内部排序和外部排序,内部排序中,排序用的数据都在主存内,外部排序则可能存在在磁盘或者远端,访问数据需要进行I/O。

排序的稳定性是指数组中存在相同值得元素,在完成排序后,其前后相对位置不会发生变化。

稳定的排序算法 不稳定的排序算法
插入排序 选择排序
冒泡排序 快速排序
归并排序 堆排序

下面用Golang分别演示排序算法,为了简化讨论,本文中排序的目标都是升序。

比较排序

假设N是参与排序的元素个数,在内部排序中,存在几种直观的比较排序算法,插入排序、选择排序和冒泡排序,它们的时间复杂度为O(N^2)。

插入排序

插入排序是最直观的排序算法之一,它由N-1趟排序过程完成,从第p=1到N-1趟,插入排序保证从位置0到p的元素处于已排序状态。因此,插入排序利用了这样一个事实:在第p趟排序前,位置0到p-1的元素已经处于排序状态,第p趟需要在0到p的元素中选择一个合适的位置,放置元素p。随着排序的推进,已排序区的范围扩大,最终整个数组完成排序。

插入合适位置的策略是,在第p趟,将所有大于第p+1个元素的元素向左移动。

1
2
3
4
5
6
7
8
9
10
11
12
//go insert sort
func InsertSort(nums []int) {
	var j int
	for p := 1; p < len(nums); p++ {
		tmp := nums[p]
		// mind the j scope
		for j = p; j > 0 && tmp < nums[j-1]; j-- {
			nums[j] = nums[j-1]
		}
		nums[j] = tmp
	}
}

当输入数组是反序时,插入排序的时间复杂度达到最高O(N^2),当输入数组几乎被排序时,插入排序可以很快完成,到达O(N)。

冒泡排序

冒泡排序的思路是通过N-1趟遍历,每次比较两个元素,每趟将最小的一个元素传递到最前,形成一个顺序区,最终所有元素都与其邻居保持顺序,完成排序。

稍作优化的是,若在一轮遍历中,没有发现需要冒泡的逆序对,整个数组就认定为完成排序了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//bubble sort
func BubbleSort(nums []int) {
	for i := 0; i < len(nums)-1; i++ {
		var doSwap bool //if no swap done in this loop, the whole array must be in-order
		for j := len(nums) - 1; j > 0; j-- {
			if nums[j-1] > nums[j] {
				doSwap = true
				nums[j], nums[j-1] = nums[j-1], nums[j] //bubble up
			}
		}

		if !doSwap {
			break
		}
	}
}

冒泡排序和插入排序一样,在处理反序数组时需要O(N^2)的时间复杂度,而几乎排序好的数组只需要O(N)。

选择排序

选择排序和插入排序的思想基本相同,都是维护一个“顺序区”,不同的是,插入排序通过调整“顺序区”来维护,选择排序则通过选择一个“非顺序区”中最大的元素来维护新“顺序区”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//selection sort
func SelectionSort(nums []int) {
	for i := 0; i < len(nums); i++ {
		var minIdx int
		minKey := math.MaxInt32
		//select the min element in 'unorder' region
		for j := i; j < len(nums); j++ {
			if nums[j] < minKey {
				minIdx = j
				minKey = nums[j]
			}
		}
		//put min element in the back of 'ordered region'
		nums[i], nums[minIdx] = nums[minIdx], nums[i]
	}
}

选择排序的时间复杂度平均为O(N^2)。实际上,任何通过交换相邻元素进行排序的算法平均都需要O(N^2)的时间。因此,如果算法要在这个时间以下运行,必须执行一些比较,交换相距较远的元素,使得每次交换删除的逆序对不止一个。

希尔排序

希尔排序是第一批突破O(N^2)的排序算法。它的特点是允许位置相距很远的元素进行交换,每趟比较所用的距离逐渐减小,从而减少元素位置移动的次数。希尔排序也被成为缩减增量排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//shell sort
func ShellSort(nums []int) {
	//compare ans swap element with distance gap
	for gap := len(nums) / 2; gap > 0; gap /= 2 {
		//offset i on current gap
		for i := gap; i < len(nums); i++ {
			var j int
			tmp := nums[i] //to put nums[i] at its right place
			//insert sort, move all nums[j] at gap 'gap' that is greater
			//	than tmp
			for j = i; j >= gap && nums[j] < nums[j-gap]; j -= gap {
				nums[j] = nums[j-gap]
			}
			nums[j] = tmp //found the right place for tmp
		}
	}
}

希尔排序使用了h1,h2,…hk序列作为增量序列,在使用增量hk完成排序后,对于任意i,有a[i]<=a[i+hk]成立,也就是说所有相隔hk的元素都被排好序。

快速排序

快速排序的平均时间为O(NlogN),它使用了分而治之的思路,对于数据集合的每个区间,取出一个划分位(parition),移动集合元素,使得划分位前的元素小于划分位,之后的元素大于划分位。同时推广这种方法于划分位前后子区间。

快速排序的直观实现为,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//quick sort
func QuickSort(nums []int) {
	if len(nums) > 1 {
		var smaller, equal, larger []int
		pivot := nums[len(nums)/2]
		//split input into three disjoint subset
		for _, n := range nums {
			if n < pivot {
				smaller = append(smaller, n)
			} else if n > pivot {
				larger = append(larger, n)
			} else {
				equal = append(equal, n)
			}
		}

		//sort the two subset
		QuickSort(smaller)
		QuickSort(larger)
		//combine the subset
		copy(nums[:], smaller)
		copy(nums[len(smaller):], equal)
		copy(nums[len(smaller)+len(equal):], larger)
	}
}

这种实现虽然容易理解,但是需要付出大量的额外内存,考虑一种in-place的快速排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//in-place quick sort
func partition(nums []int, low, high int) int {
	pivot := nums[high] //select the last element as pivot
	i := low            //set 'smaller' boundary at low

	//traversal over all elements
	for j := low; j < high; j++ {
		if pivot > nums[j] {
			//if element is smaller than pivot, put it
			//	at low boundary and adjust boundary
			nums[i], nums[j] = nums[j], nums[i]
			i++
		}
	}
	//put pivot element at the low boundary
	nums[i], nums[high] = nums[high], nums[i]
	return i
}

func quickSortHelper(nums []int, low, high int) {
	if low < high {
		pi := partition(nums, low, high)
		quickSortHelper(nums, low, pi-1)
		quickSortHelper(nums, pi+1, high)
	}
}

func QuickSort2(nums []int) {
	quickSortHelper(nums, 0, len(nums)-1)
}

对于枢纽元pivot的处理,关键在于枢纽元要将元素平均地划分到前后两个集合。几种方案是,

  • 选择第一个或者最后一个元素作为pivot

    当输入数组正好是正序或者反序时,这种划分会非常糟糕

  • 随机选取一个元素作为pivot

    这种策略虽然安全,但是产生随机数发生器的开销可能非常大,有时根本无法减少算法的平均运行时间

  • N数中值划分法,选择第N/2大的元素

    通常这是一种太理想的方法,计算中位数的开销大

  • 三数中值划分法,选左右中三个元素取中位数

    这是一种可行的方法,它消除了最糟糕的情形

堆排序

堆排序利用了优先队列,实现O(NlogN)时间的排序。堆排序中,首先利用N个元素建立二叉堆(需要O(N)的时间),优先队列的根节点总是存放着最小(大)的元素,取根节点值进行删除,直到二叉堆为空,得到的根节点序列即为排序好的数组。

算法的一个主要问题是需要额外的数组来存放结果,但是利用二叉堆每删除一次元素少一个的性质,可以用二叉堆的末尾位置存放结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//heap sort
func leftChild(i int) int {
	return 2*i + 1
}

func precDown(heap []int, i, hsize int) {
	var child int
	var tmp int

	for tmp = heap[i]; leftChild(i) < hsize; i = child {
		child = leftChild(i)
		if child < hsize-1 && heap[child] < heap[child+1] {
			child++
		}
		//exchange left child
		if tmp < heap[child] {
			heap[i] = heap[child]
		} else {
			break
		}
	}
	heap[i] = tmp
}

func HeapSort(nums []int) {
	for i := len(nums)/2 - 1; i >= 0; i-- {
		precDown(nums, i, len(nums))
	}

	for i := len(nums) - 1; i > 0; i-- {
		nums[i], nums[0] = nums[0], nums[i]
		precDown(nums, 0, i)
	}
}

归并排序

归并排序的最坏情形时间复杂度也是O(NlogN)。算法的基本操作是合并两个已经排好序的数组,使用了“分而治之”的思路,将数据集合划分直到子集合只有一个元素,然后在归并子集合的过程中完成排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//merge sort
func merge(nums []int, low, mid, high int) {
	var L, R []int

	//left must include mid, since low could be equal with mid when
	//	high - low < 2, otherwise L would be empty and sort would fail
	L = append(L, nums[low:mid+1]...)
	R = append(R, nums[mid+1:high+1]...)

	var left, right, idx int
	llen, rlen := len(L), len(R) //mid-low+1, high-mid

	idx = low
	for left < llen && right < rlen {
		if L[left] < R[right] {
			nums[idx] = L[left]
			left++
		} else {
			nums[idx] = R[right]
			right++
		}
		idx++
	}

	if left < llen {
		copy(nums[idx:], L[left:])
	} else if right < rlen {
		copy(nums[idx:], R[right:])
	}
}

func mergeSortHelper(nums []int, low, high int) {
	if low < high {
		mid := low + (high-low)/2 //[0, 0, 1], low = mid
		mergeSortHelper(nums, low, mid)
		mergeSortHelper(nums, mid+1, high)
		merge(nums, low, mid, high)
	}
}

func MergeSort(nums []int) {
	mergeSortHelper(nums, 0, len(nums)-1)
}

归并排序在合并两个已经排好序的数组时用到了线性的附加数组,需要对元素进行拷贝,在任意时刻,可能有logN个临时数组在活动。

归并排序和快速排序的对比

归并排序和快速排序常常被标准库层面选择实现排序。归并排序需要复制和比较元素,而快速排序可以不需要复制元素。

不同的对象在进行复制和比较时的开销是不同的,例如,在Java中,进行元素比较可能是昂贵的(可能涉及泛型和动态调度),而由于对象是引用方式传递的,复制的代价是较小的。而在C++中,比较由内嵌的操作符实现,代价小,但是元素是通过传值的方式传递的,复制代价高昂。

因此,不同的语言会进行权衡,C++库常用快速排序实现,而Java较多使用归并排序。

桶排序–线性时间排序

桶排序和基数排序

桶排序在满足一些附加条件的情形下,可以达到O(N)的最坏时间复杂度。假设输入数据A1,…AN仅由小于M的正整数组成,使用一个大小为M的count数组,初始化为0,读入输入数据,记录count[Ai]的值,最后打印输出排序好的数组。桶排序对于输入只是一些小整数的情形十分有用。

基数排序是桶排序的一个应用,假设有0~99999的数字需要排序,我们显然不能用99999个桶来进行排序。但我们可以用几趟桶排序,首先用个位排序,然后是十位,最后是最高位,每一趟排序都是稳定的,即后序的桶排序会保留前面趟的顺序。

基数排序可以用于字符串排序,对于相同长为L的字符串,可以实现O(NL)时间内的基数排序。假设字符串的字符都是ASCII码,即Unicode的前256位。每一趟把一个元素放入合适的桶中,全部字符串放好后,再将桶中的字符串倒回数组中,依次推进,遍历L位字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//radix sort
func RadixSort(strs []string) {
	if len(strs) < 2 {
		return
	}
	const BUCKET = 256
	strLen := len(strs[0])
	buckets := make([][]string, BUCKET) //create buckets

	//from less significant byte to significant byte, each loop of 
	//	bucket is therefore stable
	for i := strLen - 1; i >= 0; i-- {
		//put strings into buckets
		for _, s := range strs {
			buckets[s[i]] = append(buckets[s[i]], s)
		}

		var idx int
		for _, b := range buckets {
			//build string array with buckets
			for _, s := range b {
				strs[idx] = s
				idx++
			}

			buckets = make([][]string, BUCKET) //reset buckets
		}
	}
}

上面一种算法在每趟桶排序中都需要将元素复制到桶中,再复制回数组中,考虑一种复制次数更少的基数排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func CountingRadixSort(strs []string) {
	if len(strs) < 2 {
		return
	}
	const BUCKET = 256
	strLen := len(strs[0])
	arrLen := len(strs)

	in := strs //reference to input array
	out := make([]string, len(strs))

	//bucket sort on the i-th char of all strings
	for pos := strLen - 1; pos >= 0; pos-- {
		count := make([]int, BUCKET+1)

		//iterate over all strings
		for j := 0; j < arrLen; j++ {
			count[in[j][pos]+1]++ //the i-th char of the j-th string
		}

		//accumlate counting of buckets
		for b := 1; b <= BUCKET; b++ {
			count[b] += count[b-1]
		}

		//put back string at the right place
		for j := 0; j < arrLen; j++ {
			out[count[in[j][pos]]] = in[j]
			count[in[j][pos]]++
		}

		//exchange bucket
		in, out = out, in
	}

	//if odd num of passes, in is buffer while out is arr, copy buffer
	//	to arr
	if strLen%2 == 1 {
		copy(out[:], in[:])
	}

	//copy to origin array
	copy(strs[:], out[:])
}

上面两种算法都要求数组中的字符串长度都相等,考虑字符串长度不相等的情形。