在了解了简单排序之后,我们仍不满足简单排序的效率,在此驱动之下,又有效率更高的排序算法被设计出来
希尔排序
在学习了冒泡和插入排序之后,我们发现,对于同一数列而言,采用冒泡排序和采用插入排序所进行的交换次数是一致的,进而可以得知,排序实际是消除逆序对的过程,由此我们可以有以下思路
实现思路
- 冒泡和插入排序每次只消除一个逆序对
既然排序是为了消除逆序对,那么能不能通过一次交换消除多个逆序对呢
- 显然,我们可以采用隔几个数字的方式来进行排序,这样一来,进行一次交换就可消除不止一个逆序对
结和以上思路,便实现了希尔排序的一般实现方式
代码1
1 | void ShellSort(ELEMENT_TYPE a[], int n) { // 希尔1 |
简单的希尔排序如上,也就是分别隔n/2``n/4
...... 直至n = 1
但是采用这种方法可能出现尴尬的情况
1 | 9 | 2 | 10 | 3 | 11 | 4 | 12 | 5 | 13 | 6 | 14 | 7 | 15 | 8 | 16 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 9 | 2 | 10 | 3 | 11 | 4 | 12 | 5 | 13 | 6 | 14 | 7 | 15 | 8 | 16 |
1 | 9 | 2 | 10 | 3 | 11 | 4 | 12 | 5 | 13 | 6 | 14 | 7 | 15 | 8 | 16 |
1 | 9 | 2 | 10 | 3 | 11 | 4 | 12 | 5 | 13 | 6 | 14 | 7 | 15 | 8 | 16 |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
也就是数列刚好每隔n/2``n/4
...... 都是排序完成的,那么扫描就成了无用功
发现上述问题之后,有专家就提出来一个交换间隔的数列
Hibbard 增量序列
- \(D_K\) = \(2^k\) - 1(\(D_k\)为间隔数)
- 最坏情况:\(T = Θ{(N^{\frac{2}{3}})}\)
- 猜测:\(T_{avg} = O(N{\frac{5}{4}})\)
Sedgewick增量序列
{1, 5, 19, 41, 109, ...}
—— \(9\times4^i - 9\times2^i + 1\) 或者 \(4^i - 3\times2^i + 1\)
猜想:\(T_{avg} = O(N^\frac{7}{6})\) , \(T_{worst} = O(N\frac{4}{3})\)
则改进后的代码为:(只写Sedgewick)
代码2
1 | void ShellSort(ELEMENT_TYPE a[], int n) { // 希尔2 |
堆排序
那么简单排序中的选择排序是否可以优化呢?答案是肯定的
实现思路
- 首先选择排序的外层循环是不可变的,优化的只能是内层循环,即寻找数列之中最小的元素的过程
- 选择排序中,此过程运用了遍历数组的方式,复杂度为 \(O(N)\)
- 而在之前,我们学习了堆的知识,所以我们可以利用最小堆来进行寻找
代码1
1 | void HeapSort(ELEMENT_TYPE a[], int n) { // 堆 |
- 如上代码,首先将
a
调整为一个最小堆 - 之后取出依次最小的元素置于一个新临时数组中
- 最后,将排好序的
tmpA
数组中的元素复制回原数组
这样,虽然将时间复杂度降到了\(O(log(n))\) ,但是空间复杂度提升了一倍,在数据量较大之时应用不是很方便
代码2
1 | void HeapSort(ELEMENT_TYPE a[], int n) { // 堆 |
- 首先通过第一个循环将其建为最大堆
- 将最大堆的根与
a[0]
交换,之后再将a
调整为最大堆 - 重复2步骤,n次
由定理可知,其平均比较次数为 \(2NlogN - O(Nlog(logN))\)
归并排序
归并排序的核心就是有序子列的归并,也就是将两个已经排好序的数列,归并到一个更大的数列中去
实现思路
- 分别设立两个指针,从两个数组的开头扫到结尾,比较指针所在处数组的值,将小值复制到大数组中,之后将小值所在数组的指针后移一位
- 重复步骤一,直至所有数字都挪入大数组
核心理解之后就可以进行排序了,也就是将需排序的数组拆分成若干已排序的小数组直接进行归并
递归代码
1 | void Merge(int a[], int l, int r, int rightEnd, int temp[]){ |
- 三个函数,分别为归并函数,主函数,和接口函数
- 归并函数就是将两个有序数组归并为一个大数组
- 主函数起到将数组左边右边分别进行归并的作用,也就是当数组分割到只有一个元素之后,直接进行归并
- 接口函数为了使其符合排序函数的规律,仅传入数组及其长度
循环代码
1 | void Merge(int a[], int temp[], int l, int r, int rightEnd){ |
简单的递归可以用循环代替,所以也就有了上面的代码
- 即先将数组元素,相邻的两个一对进行归并
- 之后相邻的元素便排序完成,再四个一对进行归并,同理,之后就是八个......
快速排序
快排是很有名的一个排序算法,在许多语言的库函数中直接调用的排序函数就是快速排序,据说快排是在实际应用中最快的算法,其由归并排序衍生而来,也主要采用递归的方法来实现,其主要原理寻找主元和针对主元对数组进行调整的过程
选主元
主元的选择是快排中至关重要的一个环节,如果主元失误的话就整个排序就容易废掉,那么如何选取主元呢?
- 随机选择主元,
rand()
函数也是需要时间的 - 取数组头,中,尾三个数字中的中位数
- 等等 等等......
这里采取选取三者中位数的方式来选取主元
代码:
1 | ELEMENT_TYPE Media3 (ELEMENT_TYPE a[], int left, int right) { |
- 功能就是将三个数排列,并将中位数置于
right-1
的位置,并返回主元
子集划分
- 子集划分主要由两个 “指针” 从两端进行查找,将比主元小的元素调整到数组左边,比主元大的元素调整到数组的右边
- 之后将主元移动到数组固定的位置
在子集划分之后,采用递归处理, 将数组左边进行快排,右边进行快排
了解了子集划分之后,就又有了新的问题,因为快排的实现方式是采用递归的方法,所以对于小规模数据的排序可能还不如插入排序,所以我们应该设置一个阈值,在数据量低于此阈值的情况下直接采用插入排序
代码:
1 | void QSort (ELEMENT_TYPE a[], int left, int right) { |
- 此段代码最精髓之处我觉得是采用两个
while
循环进行子集划分的操作,很帅 pivot
为主元cutoff
为阈值 我将其设为1000
之后对接口进行统一化处理
代码:
1 | void QuickSort (ELEMENT_TYPE a[], int n){ |