引言
在这之前我们已经设计了一个简单的测试函数,并实现了冒泡排序(bubble sorting)、选择排序(selection sorting)、插入排序(insertion sorting)的排序算法。在这一节,我们将会要实现希尔排序(shell sorting)、归并排序(merge sorting)和快速排序(quick sorting)。
排序算法
与上一节实现的排序算法一样,这次要实现的排序算法仍然都是基于比较的排序算法。其中,希尔排序又称增量递减排序,是一种改进的插入排序;归并排序使用了分治思想,在这里我们使用了原地归并;快速排序同样使用分治思想,在这里我们采用原地快排。
希尔排序
希尔排序维护一个递减的增量 gap,当 gap 由大变小时,划分的子序列由多变少,子序列所含元素由少变多,排序的精度由粗变细。在 gap 递减的循环当中,程序依据 gap 划分子序列并对各个作了一次插入排序,对每个子序列的操作是异步的。希尔排序利用插入排序对已排好数操作的高效性来提升自身的排序效率。事实上,希尔排序就是基于插入排序的,可以想象当 gap 初始值为 1 时就完全是插入排序了。希尔排序是一种不稳定的排序算法,它的时间复杂度为 。
func ShellSort(arr []int) {
length := len(arr)
for gap := int(length / 2); gap > 0; gap /= 2 {
for i := gap; i < length; i++ {
for j := i; j >= gap; j -= gap {
if arr[j-gap] > arr[j] {
arr[j], arr[j-gap] = arr[j-gap], arr[j]
}
}
}
}
}
归并排序
归并排序采用了分而治之(divide and conquer)的思想。对于一组数据,如果它可分,归并排序总是将其划分为规模相当的两组数据然后分别对两组数据进行归并排序。在代码中,这一过程通过递归(recursion)实现。分治之后,会得到两组有序数组,接下来的任务是合并(merge)这两组有序的数组。
在我的实现当中,实际操作的主体函数是 divide()。它是一个可以递归调用的函数,传入数组以及需要排序部分的起始索引和终止索引,它会对其需要排序的部分(不包括终止索引对应的元素)进行归并排序。之所以需要多套一层,是为了使 MergeSort() 保持与之前的排序函数相同的输入和输出格式,在后来我们对于快速排序的实现上也采用了这种“封装”的方法。
func MergeSort(arr []int) {
length := len(arr)
divide(arr, 0, length)
}
func divide(arr []int, start, end int) {
if end-start < 2 {
return
}
middle := int((start + end) / 2)
divide(arr, start, middle)
divide(arr, middle, end)
merge(arr, start, middle, end)
}
归并排序的核心在于归并 merge()。归并是这样的一个操作,对于两个有序数组,归并操作将这两个数组合并成一个有序数组。在我的实现当中,merge() 传入一个数组及其起始、中间、终止索引(start, middle, end)这实际上指定了两个数组 arr[start, middle] 和 arr[middle, end],它们都是有序的。merge() 的任务就是将 arr[start, end] 这一部分序化成有序的整体。在具体实现当中,我采用了原地归并的方法,这样做的好处是无需开辟更多的内存;带来的损失就是更多的交换及比较操作。
原地归并的实现方法是重复地进行“两循环、一旋转、一更新”,这一实现方法需要维护 3 个本地变量 i, j, k(分别初始化为 start, middle, end)。
func merge(arr []int, start, middle, end int) {
i, j, k := start, middle, end
for i < j && j < k {
for i < j && arr[i] <= arr[j] {
i++
}
for j < k && arr[j] <= arr[i] {
j++
}
rotate(arr, i, middle, j)
i = i + j - middle
middle = j
}
}
- 在对变量 i, j, k 进行初始化后,进入一个 while 循环,在这个循环当中用“两循环、一旋转、一更新”的方式不断更新 i, j, k 的值,直到 i 不小于 j 或者 j 不小于 k 的时候终止。
- 在第一个循环中,从 arr[i] 向后遍历不超过 arr[j] 的部分,找到第一个大于元素 arr[j] 的元素,并用其索引更新 i 值。这时有:
- arr[start, i] 部分的所有元素都小于 arr[j]。
- arr[i, j] 部分的所有元素都大于 arr[j]。
- 在第二个循环中,从 arr[j] 向后遍历不超过 arr[k] 的部分,找到第一个大于元素 arr[i] 的元素,并用其索引更新 j 值。这时有:
- arr[start, i] 部分的所有元素都小于 arr[middle, j]。
- arr[i, middle] 部分的所有元素都大于 arr[middle, j]。
- 结合之前的分析,我们确信 arr[start, i] < arr[middle, j] < arr[i, middle],且这三部分均有序。因此我们只需交换 arr[i, middle] 和 arr[middle, j] 即可完全序化 arr[0, j]。旋转实现了这样的操作。
- 旋转过后,arr[start, j] 完全序化,问题变为归并 arr[start, j] 和 arr[j, k] 两个数组。因此需要更新 i, middle 和 j 的值。
- 我们无需从 start 重新遍历以找寻第一个大于元素 arr[j] 的元素。根据前面的判断和旋转的操作,我们只需要从 i + j - middle 开始就可以了。因此令 i = i + j - middle。
- 由于是归并 arr[start, j] 和 arr[j, k] 我们显然要令 middle = j。同时 j 也更新到了与 middle 相等。
- 更新完后检查 i, j, k 是否满足 while 条件。如是,则继续进行“两循环、一旋转、一更新”的循环。
可以看到,交换元素主要用到了旋转 rotate() 的操作,它接受一个数组,并需要一个 start、一个 middle 和 一个 end 作为索引。这个操作的目的是使 arr[start, middle] 与 arr[middle, end] 两个数组的位置交换,但保持两个子序列内的元素相对位置不变。它事实上是通过三次颠倒 reverse() 实现的。
func rotate(arr []int, start, middle, end int) {
reverse(arr, start, middle)
reverse(arr, middle, end)
reverse(arr, start, end)
}
颠倒 reverse() 是这样的一个操作。它接受一个数组和它的起始索引,它将这个数组的起始终止索引之间的部分(不包括终止索引对应的元素)的元素进行逆序。如,数组 [1, 2, 3, 4, 5] 经过颠倒后会得到 [5, 4, 3, 2, 1]。
func reverse(arr []int, start, end int) {
for start < end {
arr[start], arr[end-1] = arr[end-1], arr[start]
start++
end--
}
}
因此,旋转首先颠倒 arr[start, middle],然后颠倒 arr[middle, end], 最后颠倒 arr[start, end]。由于两个子序列经过了两次颠倒,保证了子序列内的元素相对位置不变。
由于我的归并排序使用了原地归并,需要较多的元素交换操作,因此它的时间效率不如那些使用开辟额外内存进行归并的算法高。在通常情况下,归并排序的时间复杂度可以降至
快速排序
快速排序同样采用了分治思想,对于一组数据,以其中的某一个元素为基准 pivot,并将数据分为两个部分:大于 pivot 的和小于 pivot 的。将 pivot 至于两个部分的中间,位于 pivot 前的元素全部小于 pivot 而位于 pivot 后的元素全部大于 pivot。然后分别对这两部分进行快速排序。这一过程通过递归实现。
与归并排序一样,为了保证函数输入输出的一致性,对 QuickSort() 进行了封装。实际操作数组的函数为 handler(),这是一个可以递归的函数。handler() 接受一组数据并传入 start 和 end 两个索引。若不达到递归终止的条件。则首先利用 scanfR() 来对数据进行初步排序并获得 pivot 所在的索引 middle。然后进入递归,分别再排序 arr[start, middle] 和 arr[middle+1, end], 元素 arr[middle] 不进入递归。
func QuickSort(arr []int) {
length := len(arr)
handler(arr, 0, length)
}
func handler(arr []int, start, end int) {
if end-start < 2 {
return
}
middle := scanfR(arr, start, end)
handler(arr, start, middle)
handler(arr, middle+1, end)
}
scanfR() 默认将 arr[start] 作为 pivot 并试图对元素进行排序,使 pivot 处在数组中的恰当位置,然后返回它的索引 middle。scanfR() 搭配 scanfL() 使用,两者互相调用。
func scanfR(arr []int, start, end int) int {
for i, j := start, end-1; i < j; j-- {
if arr[i] > arr[j] {
arr[i], arr[j] = arr[j], arr[i]
i++
return scanfL(arr, i, j+1)
}
}
return start
}
func scanfL(arr []int, start, end int) int {
for i, j := start, end-1; i < j; i++ {
if arr[i] > arr[j] {
arr[i], arr[j] = arr[j], arr[i]
j--
return scanfR(arr, i, j+1)
}
}
return end - 1
}
scanfR() 和 scanfL() 互相调用,追踪基准 pivot 所在的索引,试图对数组进行排序,并在排序最后返回基准 pivot 所在的索引。具体来说:
- scanfR() 认为 arr[start] 为基准,初始化左右指针 i 和 j。此时 i 指向 pivot 而 j 指向数组末尾元素 arr[end-1]。
- j 递减,直到找到第一个小于 arr[i] 的元素 arr[j]。交换这两个元素,并令 i += 1。则此时,j 指向 pivot 而且保证 j 右边的所有元素均大于 pivot,i 左边的所有元素均小于 pivot。
- 一旦进行了交换,就需要改变指针的遍历方向。调用 scanfL(),让它以 i 为 start,以 j 为 end-1。
- scanfL() 认为 arr[end-1] 为基准,初始化左右指针 i 和 j。此时 i 指向数组首位元素 arr[start] 而 j 指向 pivot。
- i 递增,直到找到第一个大于 arr[j] 的元素 arr[i]。交换这两个元素,并令 j -= 1。则此时,i 指向 pivot 而且保证 i 左边的所有元素均大于 pivott,j 右边的所有元素均小于 pivot。
- 一旦进行了交换,就需要改变指针的遍历方向。调用 scanfR(),让它以 i 为 start,以 j 为 end-1。
- 如果在某次遍历当中,当左右指针重合而仍然没有发生交换,那么认为基准 pivot 已经位于合适位置,此时返回 pivot 所在的索引。scanfR() 将返回 start 而 scanfL() 将返回 end-1。
快速排序是一种极其不稳定的排序方式。在最环情况下它的时间复杂度将达到 而在平摊期望下的时间复杂度为
总结
本节我们实现了希尔排序(shell sorting)、归并排序(merge sorting)、快速排序(quick sorting)。相比上一节所讲的三种排序,这三种排序在时间复杂度上都或多或少有了优化。归并排序和快速排序是采用分治思想的排序,它们的实现都有赖于函数的递归调用。在具体的实现中,我采用了原地排序的思想,不开辟新的数组存储,减少了内存占用但增加了交换元素的所需时间。在下一节,我们将具体实现计数排序(counting sorting)、基数排序(radix sorting)、堆排序(heap sorting)和桶排序(bucket sorting),它们都是非基于比较的排序方法。敬请期待!