归并排序与快速排序时间复杂度均为O(nlogn),适合大规模的数据排序且很常用,它们都用到了分治思想,将大的问题分解成小问题来解决。接下来我们分别对其进行分析。
思路:归并排序的核心思想是先分后合。假如要排序一个数组,先将其从数组中间分解为两部分,分别进行排序,之后再将排好序的两部分按序合并在一起。
解决方法:归并排序用到的是分治思想,分治(处理思想)常常也用递归(编程技巧)来实现。下面给出代码。
public class MergeSort {
@Test
public void test() {
int[] a = {5,9,4,3,2,1,4,5,6,8,7,5,4,3,9,8};
mergeSort(a, 0, a.length-1);
for (int i = 0; i < a.length; i++) {
System.out.println(a[i]);
}
}
public void mergeSort(int[] a, int start, int end) {
if(start < end){
int mid = (start + end)/2;//划分子数组
mergeSort(a, start, mid);//排序左侧数组
mergeSort(a, mid + 1, end);//排序右侧数组
merge(a, start, mid, end);//将两部分数组排序合并
}
}
public void merge(int[] a, int start, int mid, int end){
int[] temp = new int[a.length];//辅助数组
int p = start,q = mid + 1, k = start;
//合并
while(p <= mid&& q <= end){
if(a[p] <= a[q]){
temp[k++] = a[p++];
}else{
temp[k++] = a[q++];
}
}
while(p<=mid){temp[k++] = a[p++];}
while(q<=end) {temp[k++] = a[q++];}
for (int i = start; i <= end; i++) {
//将排序好的数组部分复制回原数组
a[i] = temp[i];
}
}
}
其中,merge()函数用于将两个排序好的子数组合并。
性能分析
稳定性:判断归并函数的稳定性要观察merge()函数。你会发现,如果前半数组与后半数组有相同元素时,只要我们控制先把前半部分的元素放入数组(if(a[p] <= a[q])),就可以保证算法的稳定性。
时间复杂度:
分析归并排序的时间复杂度涉及到递归,所以分析归并排序的方法我们也可以应用到分析递归代码中。
T(a) = T(b)+T(c)+k
其中T(n)表示解决n问题需要的方法,上面这条式子就可以理解为解决a问题的时间等于解决b、c问题的和加上k,k表示将bc两个子问题合并成a所用的时间。所以,在归并排序中。
T(n) = 2*T(n/2)+n
=2*(2*T(n/4)+n/2)+n=4*T(n/4)+2n
=4*(2*T(n/8)+n/4)+2n=8*T(n/8)+3n
=8*(2*T(n/16)+n/8)+3n=16*T(n/16)+4n
......
=2^k*T(n/2^k)+k*n
因为排序的终止条件是T(n/2^k)=T(1),此时n/2^k=1,解得k=log(2)n。所以原式子等于T(n) =Cn+nlog(2)n。用大O表示法表示就为O(nlogn)。
我们从归并排序的代码可以看出,归并排序的执行效率与数据的有序程度无关,所以最差、最好、平均复杂度均为O(nlogn)。
空间复杂度:
归并排序的空间消耗来自于合并时的temp数组,每次合并时都需要申请一份额外空间,那是否空间复杂度就为这些额外空间的累加呢?
答案是否定的,我们要注意一点,递归代码的空间复杂度不能和时间复杂度一样累加,因为尽管每次合并都需要申请额外数组,但是当合并完成后就释放了,到下次合并时才会再次申请,所以每个时间点只有一份不超过n个数据的额外空间,所以空间复杂度就为O(n)。
思路:当我们要排序一个数组时,先任意选出一个数据(一般为第一个或最后一个)作为分区点(pivot),将比它小的放在左边,大的放在右边,再通过递归的思想,分别递归左右两部分,当区间缩小到1时,此时所有数据就全部有序了。下面给出代码。
public class QuickSort {
@Test
public void test() {
int[] a = {8,4,5,6,4,8,3,1,9,5,4,2,3,7,8,4,1,7,6,6};
quickSort(a, 0, a.length - 1);
for (int i = 0; i < a.length; i++) {
System.out.println(a[i]);
}
}
public void quickSort(int[] a, int start, int end) {
if (start < end) {
//分区并找出分区点
int i = partition(a, start, end);
//递归左右两部分
quickSort(a, start, i - 1);
quickSort(a, i + 1, end);
}
}
public int partition(int[] a, int start, int end) {
//指定最后一个为分区点
int pivot = a[end];
//快速排序在分区时运用了巧妙的方法因此不用申请额外空间
//将数据分为已排序(小于分区点)和未排序两部分
//使用双指针,当遍历到比分区点小的元素时将其与未排序的第一个数据进行交换
int i = start;
for (int j = start; j < end; j++) {
if(a[j] < pivot){
int temp = a[i];
a[i] = a[j];
a[j] = temp;
i++;
}
}
//将分区点放在未排序的第一位,完成了左右划分
a[end] = a[i];
a[i] = pivot;
return i;
}
}
性能分析
稳定性:快速排序无法保证数组稳定性,可以用一个实例结合快排原理验证。
时间复杂度:
快排的时间复杂度分析方法与归并排序类似,如果每次都把数组分解为两个大小相近的子数组,则时间复杂度为O(nlogn)。
但是,当最坏情况时,假如数组刚好倒序,则我们需要n次分区操作才能将数组排好序,这个过程类似冒泡排序,我们每次只能将最后一个数往前冒泡(可以用一个倒序数组进行验证),时间复杂度将退化为O(n²)。
但是,大多数情况下时间复杂度接近O(nlogn),我们也有很多方法避免坏情况的发生。
空间复杂度:
快排排序过程中无需借助额外空间,所以空间复杂度为O(1)。
在实际运用中,快排要比归并排序更加广泛,原因在于快速排序无需额外空间的支持。有的人会认为这两者的原理相似,其实是大有不同,我们可以理解归并排序是先分后合(先处理子问题再合并),由下到上的,而快速排序是由上到下的(先分区再处理子问题)。
本文是学习王争老师的《数据结构与算法之美》之后做的学习笔记,如有错误请评论指正,谢谢!