【数据结构】排序算法(二)—— 快速排序、归并排序(Java版)
笔者对两种性能较优的算法 快速排序和归并排序 进行总结。
对二者算法放在一起进行总结,均在于都运用了递归和分治的两种重要思想。在这里递归就不做详细介绍。
分治:顾名思义,分而治之,这是在排序中我们非常常见的一种思想,同时也是在其他场景乃至日常生活的优秀解题方法。当我们遇到一个大的难题无从下手时,我们往往都会将其分成几个小块,当我们处理好每个小模块问题后,将其合并,大的问题便能够的以解决。同样,在我们处理排序问题时,也能充分利用分治思想来提高性能。
对于分治算法与递归的理解可以参考笔者的另外两篇博客:
分治:
递归:
归并和快速排序用的都是分治的思想,代码都通过递归来实现,过程非常相似。归并排序的重点是理解递推公式和 merge() 合并函数,理解快排的重点是理解递推公式,还有 partition()分区函数。
快速排序同样是一种分治思想的排序方法。它将一个数组分为连个数组,将两部分独立排序。快速排序和归并排序是互补的:归并排序将数组分成两个子数组分别排序,并将有序的子数组归并后整个排序,而快速排序将数组排序的方式则是当两个自数据都有序是整个数组也就自然有序了。
快速排序
快速排序作为应用最广泛的排序算法,流行的原因主要因为实现简单且快排排序具有非常大的优势。
- 快速排序是原地排序(只需要非常小的一个辅助栈)
- 快速排序时间消耗,长度为 n n n的数组排序时间与 n l g n nlgn nlgn成正比
目前大多数排序算法都不能将这两点结合实现。而且快速排序的内循环比大多数排序的内循环要简洁许多,这样无论从理论上还是实际使用上都会更快。但是快速排序主要的缺点已经非常明显,快速排序非常脆弱,使用时要非常小心才能避免性能变的低劣。很多例子表明快速排序因为一些错误使时间的消耗成为平方级。
快排的性能在所有排序算法里面是最好的,数据规模越大快速排序的性能越优。快排在极端情况下会退化成 [公式] 的算法,因此假如在提前得知处理数据可能会出现极端情况的前提下,可以选择使用较为稳定的归并排序。
快速排序思想
快速排序体现了分治算法思想。故从 D&C 出发思考
(1)基线条件
使用排序算法对数组进行排序,对于简单的排序算法而言,最简单的数组为: 空数组或只包含一个元素的数组;
因此,基线条件为数组为空或只包含一个元素,在这种情况下,只需要原样返回数组——根本不需要排序。
(2)分区(partitioning)
对于多余两个元素的数组要使用 D&C,需要将数组分解,直至满足基线条件。
首先,从数组中选择一个元素,称这个元素为基准值(pivot)
找出比基准值小的元素以及比基准值大的元素。这被称为分区。
进行一次分区后,现在有:
- 一个由所有小于基准值的1数字组成的子数组;
- 基准值;
- 一个由所有大于基准值的数字组成的子数组。
这只是进行了分区,得到的两个子数组是无序的。但是如果这两个数组是有序的,整个数组的排序将非常容易。由递归思想可得,如果对子数组进行上述分区操作,直至达到基线条件。
1.三步走:
(1) 选择基准值。
(2) 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。
(3) 对这两个子数组进行快速排序。
以后采用递归的方式分别对前半部分和后半部分排序,当前半部分和后半部分均有序时该数组就自然有序了。
一些小结论
从上面的过程中可以看到:
①先从队尾开始向前扫描且当low < high时,如果a[high] > tmp,则high–,但如果a[high] < tmp,则将high的值赋值给low,即arr[low] = a[high],同时要转换数组扫描的方式,即需要从队首开始向队尾进行扫描了
②同理,当从队首开始向队尾进行扫描时,如果a[low] < tmp,则low++,但如果a[low] > tmp了,则就需要将low位置的值赋值给high位置,即arr[low] = arr[high],同时将数组扫描方式换为由队尾向队首进行扫描.
③不断重复①和②,知道low>=high时(其实是low=high),low或high的位置就是该基准数据在数组中的正确索引位置.
以数组第一个元素为基准值的代吗实例
python 代码
def qsort[T](list: List[T])(implicit t:T=>Ordered[T]):List[T]=list match {
case Nil=>Nil
case ::(pivot,tail) => qsort(tail.filter(_<=pivot)) ++ List(pivot) ++ qsort(tail.filter(_>pivot))
}
java 代码
public class QuickSort {
public static void main(String[] args) {
int[] arr = { 49, 38, 65, 97, 23, 22, 76, 1, 5, 8, 2, 0, -1, 22 };
quickSort(arr, 0, arr.length - 1);
System.out.println("排序后:");
for (int i : arr) {
System.out.println(i);
}
}
private static void quickSort(int[] arr, int low, int high) {
if (low < high) {
// 找寻基准数据的正确索引
int index = getIndex(arr, low, high);
// 进行迭代对index之前和之后的数组进行相同的操作使整个数组变成有序
//quickSort(arr, 0, index - 1); 之前的版本,这种姿势有很大的性能问题,谢谢大家的建议
quickSort(arr, low, index - 1);
quickSort(arr, index + 1, high);
}
}
private static int getIndex(int[] arr, int low, int high) {
// 基准数据
int tmp = arr[low];
while (low < high) {
// 当队尾的元素大于等于基准数据时,向前挪动high指针
while (low < high && arr[high] >= tmp) {
high--;
}
// 如果队尾元素小于tmp了,需要将其赋值给low
arr[low] = arr[high];
// 当队首元素小于等于tmp时,向前挪动low指针
while (low < high && arr[low] <= tmp) {
low++;
}
// 当队首元素大于tmp时,需要将其赋值给high
arr[high] = arr[low];
}
// 跳出循环时low和high相等,此时的low或high就是tmp的正确索引位置
// 由原理部分可以很清楚的知道low位置的值并不是tmp,所以需要将tmp赋值给arr[low]
arr[low] = tmp;
return low; // 返回tmp的正确位置
}
}
https://blog.csdn.net/nrsc272420199/article/details/82587933
下面来看下快速排序的算法改进问题
当我们要将快排多次执行或者放置在一个大型的库函数上时,我们应该更多考虑快排的性能优化,优秀的优化方案能对性能有很大帮助。
切换到插入排序
这是我们之前就提到的一个思想,当我们对小数组进行操作时,插入排序可能要比递归归并速度有所提高(《算法》中指出在5-15大小数组时,性能更优秀),所有可以对小数组进行插入排序,实现起来也非常简单
if (left + M >= right) {
//插入排序代码,具体操作不进行实现
sort(arr,left,right);
return;
}
快速排序的性能分析
快速排序的性能高度依赖基准值,比如为有序数组并将第一个元素用作基准值时,将达到最坏情况。
在这种情况下,数组并没有被分成两半,相反,其中一个子数组始终为空,这导致调用栈非常长。
假设将中间元素作为基准值,调用栈如下:
第一个示例展示的是最糟情况,而第二个示例展示的是最佳情况。在最糟情况下,栈长为 O ( n ) O(n) O(n), 而在最佳情况下,栈长为 O ( l o g n ) O(logn) O(logn)。
不管以何种方式进行划分,在调用栈的每层都涉及 O ( n ) O(n) O(n)个元素。
在最好情况下,层数为 O ( l o g n ) O(logn) O(logn)(用专业术语说,调用栈的高度为 O ( l o g n ) O(logn) O(logn)),而每层需要的时间为 O ( n ) O(n) O(n)。因此整个算法需要的时间为 O ( n ) ∗ O ( l o g n ) = O ( n l o g n ) O(n)*O(logn)=O(nlogn) O(n)∗O(logn)=O(nlogn);
在最坏情况下,层数为 O ( n ) O(n) O(n)(用专业术语说,调用栈的高度为 O ( l o g n ) O(logn) O(logn)),而每层需要的时间为 O ( n ) O(n) O(n)。因此整个算法需要的时间为 O ( n ) ∗ O ( n ) = O ( n 2 ) O(n)*O(n)=O(n^2) O(n)∗O(n)=O(n2);
-
复杂度
时间复杂度:O(nlogn)
空间复杂度:快速排序使用递归,递归使用栈,因此它的空间复杂度为O(logn)
稳定性:快速排序无法保证相等的元素的相对位置不变,因此它是不稳定的排序算法
两个常见快排优化方法:
(1)三数取中法:从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好。如果要排序的数组长度很大,那么就要多取几个数,比如“5数取中”、“十数取中”。
(2)随机法:每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选的很差的情况,所以平均情况下,这样选的分区点是比较好的。
(一)三数取中法
就是取左端、中间、右端三个数,然后进行排序,将中间数作为基准值。
根据基准值进行分割
def quickSort(arr: Array[Int]): Unit = quickSort(arr,0,arr.length-1)
def quickSort(arr: Array[Int], left: Int, right: Int): Unit ={
if (left < right) {
dealPivot(arr, left, right)
//基准值放在倒数第二位
val pivot = right - 1
//左指针
var i = left
//右指针
var j = right - 1
while (i<j) {
while (arr(i) < arr(pivot)) {
i += 1}
while (j > left && arr(j) > arr(pivot)) {
j = j - 1}
swap(arr, i, j)
}
if (i < right) swap(arr, i, right - 1)
quickSort(arr, left, i - 1)
quickSort(arr, i + 1, right)
}
}
// 处理基准值
def dealPivot(arr: Array[Int], left: Int, right: Int): Unit = {
val mid = (left + right) / 2
if (arr(left) > arr(mid)) swap(arr, left, mid)
if (arr(left) > arr(right)) swap(arr, left, right)
if (arr(right) < arr(mid)) swap(arr, right, mid)
swap(arr, right - 1, mid)
}
// 交换元素通用处理
private def swap(arr: Array[Int], a: Int, b: Int) = {
val temp = arr(a)
arr(a) = arr(b)
arr(b) = temp
}
https://www.cnblogs.com/feiyumo/p/9296001.html
(2)随机数选取
随机快速排序Java代码实现
/**
* 快速排序,使得整数数组 arr 有序
*/
public static void quickSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
quickSort(arr, 0, arr.length - 1);
}
/**
* 快速排序,使得整数数组 arr 的 [L, R] 部分有序
*/
public static void quickSort(int[] arr, int L, int R) {
if(L < R) {
// 把数组中随机的一个元素与最后一个元素交换,这样以最后一个元素作为基准值实际上就是以数组中随机的一个元素作为基准值
swap(arr, new Random().nextInt(R - L + 1) + L, R);
int[] p = partition(arr, L, R);
quickSort(arr, L, p[0] - 1);
quickSort(arr, p[1] + 1, R);
}
}
/**
* 分区的过程,整数数组 arr 的[L, R]部分上,使得:
* 大于 arr[R] 的元素位于[L, R]部分的右边,但这部分数据不一定有序
* 小于 arr[R] 的元素位于[L, R]部分的左边,但这部分数据不一定有序
* 等于 arr[R] 的元素位于[L, R]部分的中间
* 返回等于部分的第一个元素的下标和最后一个下标组成的整数数组
*/
public static int[] partition(int[] arr, int L, int R) {
int basic = arr[R];
int less = L - 1;
int more = R + 1;
while(L < more) {
if(arr[L] < basic) {
swap(arr, ++less, L++);
} else if (arr[L] > basic) {
swap(arr, --more, L);
} else {
L++;
}
}
return new int[] { less + 1, more - 1 };
}
/*
* 交换数组 arr 中下标为 i 和下标为 j 位置的元素
*/
public static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
归并排序
归并排序思想
归并排序(MERGE-SORT)(先自顶向下分解,之后在自底向上合并)
归并排序的思想采用经典的分治(divide-and-conquer)策略。
总共分为两步,分与治
(分治法将问题**分(divide)成一些小的问题然后递归求解,而治(conquer)**的阶段则将分的阶段得到的各答案"修补"在一起,即分而治之)
归并排序时,我们使用递归的思想将一个数组分为数组最小单元,(通常为单个元素的数组或者为空数组);递归到不能再细分就可以开始合并了。此时我们可以认为要合并的每个数组均有 两个子数组,我们叫做 Left 和 Right ,如果我们将这两部分都进行排序完成后,即子数组 Left 和 Right 都是有序数组。那么我们将这两个数组进行合并,
合并的方式为:首先创建一个与原数组容量相同的数组用来存放合并时的数据,然后比较 Left 和 Right 中的数组,如果Left[0]<Right[0],将Left[0]放入新数组的0索引处,然后比较Left[1]和Right[0],依次类推按照升序或降序的方式便能将 Left 和 Right 中所有数组按照一定顺序拷贝进入新数组,此时就完成了数组排序。
下面让我们结合图片来进行详细的解释。
可以看到这种结构很像一棵完全二叉树,本文的归并排序我们采用递归去实现(也可采用迭代的方式去实现)。分阶段可以理解为就是递归拆分子序列的过程,递归深度为 l o g 2 n log_2n log2n。
再来看看治阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤。
从上文的图中可看出,每次合并操作的平均时间复杂度为
O
(
n
)
O(n)
O(n),而完全二叉树的深度为
∣
l
o
g
2
n
∣
|log2n|
∣log2n∣。总的平均时间复杂度为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)。而且,归并排序的最好,最坏,平均时间复杂度均为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)。
python 版代码
def mergedSort[T](le: (T, T) => Boolean)(list: List[T]): List[T] = {
def merged(xList: List[T], yList: List[T]): List[T] = {
(xList, yList) match {
case (Nil, _) => yList
case (_, Nil) => xList
case (x :: xTail, y :: yTail) =>
if (le(x, y)) x :: merged(xTail, yList)
else
y :: merged(xList, yTail)
}
}
val n = list.length / 2
if (n == 0) list
else {
val (x, y) = list splitAt n
merged(mergedSort(le)(x), mergedSort(le)(y))
}
}
java 版代码
package Packger;
import java.util.Arrays;
public class MergeSort {
public static void main(String[] args) {
int[] arr = new int[] {1,2,3,4,5,6};//{1,5,6,42,4,9,5,7,2};
mergeSort(arr, 0, arr.length - 1);
//mergeSort(arr);
System.out.println(Arrays.toString(arr));
//System.out.println(mergeSort(arr));
}
/*
public static void mergeSort(int[] arr) {
mergeSort(arr, 0, arr.length - 1);
}
*/
public static void mergeSort(int[] arr, int left, int right) {
//参数分别为 待排序数组,左指针,有指针,辅助数组
//因为使用了递归,所以我们必须规定递归条件否则将进行无线循环
if (left >= right) {
return;
}
int mid = (right + left) / 2;
//递归划分子序列
mergeSort(arr, left, mid);
System.out.println("左子树数组"+Arrays.toString(Arrays.copyOfRange(arr, left, mid+1)));
mergeSort(arr, mid + 1, right);
System.out.println("右子树数组"+Arrays.toString(Arrays.copyOfRange(arr, mid+1, right+1)));
//合并子序列
Merge(arr, left, mid, right);
}
//合并函数
public static void Merge(int[] arr, int left, int mid, int right) {
int[] temp= new int[right-left+1];
int i = left;
int j = mid + 1;
//t为辅助数组的索引
int t = 0;
while (i <= mid && j <= right) {
//当二者都没有到达最后一位时,进行比较并向辅助数组复制
if (arr[i] < arr[j]) {
temp[t++] = arr[i++];
} else {
temp[t++] = arr[j++];
}
}
//当其中一个数组复制完毕后,将另一个数组内的数组全部复制进辅助数组
while (i <= mid) {
temp[t++] = arr[i++];
}
while (j <= right) {
temp[t++] = arr[j++];
}
t = 0;
//将辅助数组内已经排好的数据全部复制进原数组,排序完成
while (left <= right) {
arr[left++] = temp[t++];
}
System.out.println("排序的数组"+Arrays.toString(temp));
}
}
输出结果为:
左子树数组[1]
右子树数组[2]
排序的数组[1, 2]
左子树数组[1, 2]
右子树数组[3]
排序的数组[1, 2, 3]
左子树数组[1, 2, 3]
左子树数组[4]
右子树数组[5]
排序的数组[4, 5]
左子树数组[4, 5]
右子树数组[6]
排序的数组[4, 5, 6]
右子树数组[4, 5, 6]
排序的数组[1, 2, 3, 4, 5, 6]
[1, 2, 3, 4, 5, 6]
归并排序总结
我们可以看到,归并排序将一个数组进行有限次的分割,再进行相同次数的合并,将整个数组进行排序,在我们使用递归实现归并排序时,并不需要过多的关注每一步都是如何进行操作,只需要将大体的思路分析清楚即可进行操作。
接下来我们简单谈一下,自底向上的归并排序。这种思想主要是先归并那些微型数组,然后再成对归并所得到的子数组,直到我们将整个数组进行完全的排序。实现这种归并的方式代码将更加简洁。
首先我们进行两两归并(把每个元素都当作大小为1的数组),然后四四归并(将大小为2的数组归并为大小为4的数组),通过这种方式,最后归并的两个数组可能大小不等,但和依旧为原数组。这种方式是从数据一端开始进行归并,而非自顶向下中从两端进行归并。
那么这两种方式区别又再哪里呢?当数组长度为2的N次幂时,这两种方式对数组的访问是相同的,对于时间消耗也是相同的。其他时候,这两种对数组访问次数和次序有所不同。
自底向上的归并排序比较适合用链表组织数据,当我们按照大小为1的子链表进行排序,然后是大小为2,大小为4.我们只需要重新将链表连接就能进行原地排序。不需要开辟额外空间。
归并排序性能分析
(1)稳定的排序算法,值相等的元素,在排序前后相对先后顺序不变。(原因在合并函数 mergeArr 中,自己看代码就知道了)。
(2)时间复杂度: O ( n l o g n ) O(nlogn) O(nlogn),归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,都是 O ( n l o g n ) O(nlogn) O(nlogn)。
(3)空间复杂度:归并排序不是原地排序,时间复杂度为 O ( n ) O(n) O(n)。(合并两个有序数组为一个有序数组时,需要借助额外的存储空间,即代码中的 temp 临时数组,长度不会超过原始数组长度,且每个合并操作结束,自动回收 temp 占用的内存,下次合并再新建 temp 数组,所以占用的额外空间并不会累加)。
快速排序和归并算法的区别
快速排序和归并的区别就在于它正好和递归的排序反过来。快速排序先排序再递归细分,排序是从上到下的。归并排序先递归细分再排序,排序是从下到上的。
排序算法总结
当我们面对一个很大的数组时,用以往学过的冒泡,插入,选择排序总是有些过于缓慢,希尔排序虽然可以,但是也不是最好的选择,因为它时间花费是亚平方级别的。如果我们内存中有足够大的空间,我们不妨使用提高空间复杂度来换取减少时间复杂度的思想,这样便能更快完成排序。
参考博客:
https://www.jianshu.com/p/806a547ab069
https://www.cnblogs.com/JMWan233/p/11175006.html
https://blog.csdn.net/weixin_41582192/article/details/81239266