归并排序
介绍
先简单介绍一下归并排序。归并排序的思想就是分治+递归。
- 分治:分而治之,大模块拆分小模块
- 递归:由浅入深,层层递进(递归三要素:参数、条件、出口)
可能刚入门的还不太了解,我偷了一张图 一看便知
看过数据结构与算法之美的应该看过这张图,我这篇文章用白话给你搞懂归并和快排。
步骤分析
这张图画的很清楚,就俩步骤,分解和合并。
-
分解: 分解过程就是一个递归的过程,拆分一半,调用方法层层递归。(分解一半 用代码来解释 不就是利用一个指针指向中间嘛)
-
合并: 合并过程看起来和分解差不多,但是暗藏玄机。分解的时候,不必考虑其他的,弄个中间指针就可以分开,可是合并的时候,就涉及到排序问题。
-
那么怎么保证合并有序呢?
老规矩 先偷个图 这张图讲解了合并的过程(代码中都有提到)
i
和j
指针对应左右序列的头temp
作为临时数组 用于存放合并后的元素,最后归还给传入的数组q
和r
指针是一种哨兵的思想,如果i
先到到q
或者j
先到达r
那么直接将剩余的存入temp
(代码中每一次递归的mid
和high
就是图中对应的q
和r
)
代码(附详细注释)
/**
* 归并排序入口 递归
* @param arr 传入的数组
* @param low 头
* @param high 尾
* @param temp 临时数组 用于存放合并后的元素
*/
public static void mergeSort(int[] arr,int low,int high,int[] temp){
//当子序列中只有一个元素时结束递归
if(low<high){
//分解
int mid = (low+high)/2;
//对左边序列进行归并排序
mergeSort(arr,low,mid,temp);
//对右边序列进行归并排序
mergeSort(arr,mid+1,high,temp);
//合并两个有序序列
merge(arr,low,mid,high,temp);
}
}
/**
* 合并
* @param arr 传入的数组
* @param low 头
* @param mid 左边序列的尾
* @param high 尾
* @param temp 临时数组 用于存放合并后的元素
*/
public static void merge(int[] arr,int low,int mid,int high,int[] temp){
//数组没有add方法,只能用下标存,这个i就是一个浮标,标记下次该存哪了,也标记了数组的长度
int i = 0;
//左边序列和右边序列起始索引(这就是上面提到的哨兵)
int j = low,k = mid+1;
//左右只要有一个先加完,就结束循环
while(j <= mid && k <= high){
//如果相等的情况,随便存一个就好 不必考虑三种情况
//细节:先存入temp 再++
if(arr[j] <= arr[k]){
temp[i++] = arr[j++];
}else{
temp[i++] = arr[k++];
}
}
//若左边序列还有剩余,则将其全部存入temp中
while(j <= mid){
temp[i++] = arr[j++];
}
//若右边还有剩余,则全部存入
while(k <= high){
temp[i++] = arr[k++];
}
//把temp中的元素依次归还给arr
for(int t=0;t<i;t++){
arr[low+t] = temp[t];
}
}
性能分析
-
合并过程中如果有值相同的元素,先把前半段中的元素先放入temp,所以归并排序可以是稳定的排序
-
不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)
-
尽管每次合并操作需要额外的空间,可是合并完成空间会被销毁,所以空间复杂度是 O(n)
相信你已经掌握了归并排序,归并排序的重点就是开头说到的分治+递归,现在明白了吧。那接下来就开启快排!
快速排序
介绍(图文配合使用效果最佳)
如果要排序数组中下标从 p
到 r
之间的一组数据,我们选择 p
到r
之间的任意一个数据作为 pivot(分区点)通常是第一个或者最后一个。
我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。
根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
步骤分析
- 先分区一次,得到一个分区点
- 然后再将分区点左右两侧的序列,各自再次分区(递归)
- 递归的出口,就是分区只有一个元素的时候
如果不考虑空间的消耗,那么分区很简单,可以把大于临界点的存一个数组,小于临界点的存一个数组
可是算法不就是为了追求极致性能吗!
来一起学习一个原地分区!
原地分区(很巧妙的思想,建议反复食用)
还是老规矩 先偷个图
光看图肯定有些难理解,我来解释一下。
- 利用双指针
i
和j
(和代码中的ij
一致) j
指针用来依次扫描每一个元素,i
指针用于标记- 如果
j
指针的元素大于pivot
的元素,则i
不动,j
继续扫描 - 如果
j
指针的元素小于pivot
的元素,则ij
元素互换,i向前移动 - 最后!
j
移动到最后的时候,则ij
互换,这时i
就是分区点pivot
应该在的位置了
代码(附详细注释)
/**
* 快排入口 递归
* @param arr 传入数组
* @param head 头部下标
* @param tail 尾部下标
*/
private static void quickSort(int[] arr, int head,int tail){
//递归出口(分区只有一个元素的时候)
if (head >= tail) return;
//获取分区点
int pivot = partition(arr, head, tail);
//分区点左右部分各自快排(不包含分区点)
quickSort(arr,head,pivot-1);
quickSort(arr,pivot+1,tail);
}
/**
* 分区
* @param arr 数组
* @param head 头
* @param tail 尾
* @return 分区的节点 pivot
*/
private static int partition(int[] arr,int head ,int tail){
//默认分区点的元素为末尾元素
int pivotValue = arr[tail];
//巧妙的思想,实现原地分区(建议配合前面的文字说明一起食用)
int i = head;
for (int j = head; j < tail; j++) {
if (arr[j] < pivotValue) {
//ij在同一个位置的时候没必要交换,只向前移动就好
if (i == j) {
i++;
} else {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
i++;
}
}
}
//循环结束后,交换i与尾部的元素,此时i的位置就是分区点pivot应该在的位置
int temp = arr[i];
arr[i] = arr[tail];
arr[tail] = temp;
//返回分区点pivot
return i;
}
性能分析
-
快排是原地,不稳定的排序算法
-
快排的时间复杂度也是 O(nlogn)
归并与快排对比
-
归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n)。正因为此,它也没有快排应用广泛。
-
快速排序算法虽然最坏情况下的时间复杂度是 O(n2),但是平均情况下时间复杂度都是 O(nlogn)。不仅如此,快速排序算法时间复杂度退化到 O(n2) 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。
那么接下来就是优化部分了,就是选取合理的pivot,尽量保证左右分区平均,还有就是借助C++中STL源码,就会发现,sort()函数中,开始使用快排处理,当分组的元素个数小于某个阈值时,就改用插入排序处理。这样做的原因是当划分的区间在5~20之间时,快排效率不高,很容易出现一侧没有值的情况,而插入排序对于已经近似排好序的数组分组效果很好,因此可以采用插入排序来优化快排。在STL中,如果快排递归深度归多,还会换成堆排序来完成排序。