快速排序和归并排序的时间复杂度分析——通俗易懂
一、前言
今天面试的时候,被问到归并排序的时间复杂度,这个大家都知道是O(nlogn)
,但是面试官又继续问,怎么推导出来的。这我就有点懵了,因为之前确实没有去真正理解这个时间复杂度是如何得出的,于是就随便答了一波(理解了之后,发现面试的时候答错了......)。
归并排序和快速排序,是算法中,非常重要的两个知识点,同时也是在面试中被问的非常频繁的内容,我明知如此,却没有彻底理解,真是太不应该了。所以,今天这篇博客就来分析一下这两种排序算法的时间复杂度是如何得出的。我查了许多篇博客,很多都是通过公式进行分析,十分难理解,下面我就结合自己的理解,使用通俗易懂的方式进行描述(为了好理解,可能会有些啰嗦)。
二、正文
2.1 归并排序的时间复杂度分析
了解归并排序的应该都知道,归并排序的时间复杂度是O(nlogn)
,且这个时间复杂度是稳定的,不随需要排序的序列不同而产生波动。那这个时间复杂度是如何得来的呢?我们可以这样分析,假设我们需要对一个包含n
个数的序列使用归并排序,并且使用的是递归的实现方式,那么过程如下:
- 递归的第一层,将
n
个数划分为2
个子区间,每个子区间的数字个数为n/2
; - 递归的第二层,将
n
个数划分为4
个子区间,每个子区间的数字个数为n/4
; - 递归的第三层,将
n
个数划分为8
个子区间,每个子区间的数字个数为n/8
;
......
- 递归的第
logn
层,将n
个数划分为n
个子区间,每个子区间的数字个数为1
;
我们知道,归并排序的过程中,需要对当前区间进行对半划分,直到区间的长度为1
。也就是说,每一层的子区间,长度都是上一层的1/2
。这也就意味着,当划分到第logn层的时候,子区间的长度就是1了。而归并排序的merge
操作,则是从最底层开始(子区间为1
的层),对相邻的两个子区间进行合并,过程如下:
- 在第
logn
层(最底层),每个子区间的长度为1
,共n
个子区间,每相邻两个子区间进行合并,总共合并n/2
次。n
个数字都会被遍历一次,所有这一层的总时间复杂度为O(n)
;
......
- 在第二层,每个子区间长度为
n/4
,总共有4
个子区间,每相邻两个子区间进行合并,总共合并2
次。n
个数字都会被遍历一次,所以这一层的总时间复杂度为O(n)
; - 在第一层,每个子区间长度为
n/2
,总共有2
个子区间,只需要合并一次。n
个数字都会被遍历一次,所以这一层的总时间复杂度为O(n)
;
通过上面的过程我们可以发现,对于每一层来说,在合并所有子区间的过程中,n
个元素都会被操作一次,所以每一层的时间复杂度都是O(n)
。而之前我们说过,归并排序划分子区间,将子区间划分为只剩1
个元素,需要划分logn
次。每一层的时间复杂度为O(n),共有logn层,所以归并排序的时间复杂度就是O(nlogn)。
上面的描述算是非常详细了,应该不会太难理解。如果上面的过程还是不太理解,那么我们通过另外一种更直观的方式进行分析。上面描述的是递归的过程,下面我们通过非递归(迭代)方式实现的归并排序,再来分析一波,这种方式更加直观(为什么不直接通过非递归的方式描述,而是先通过递归的方式分析,是因为上面的过程也可以用来分析快速排序)。下面是通过非递归方式实现的归并排序代码,其中有两处分析时间复杂度的关键点,我标注出来了(重点关注注释):
/**
* 此方法用来定义子区间大小,子区间大小从1->2->4->8 ... ->n/2
* 可以近似地认为进行了logn次
*/
public static void merge(int[] arr) {
// 关键点1:划分子区间,每一次的子区间长度是上一次的两倍,所以这个循环需要执行logn次
for(int i = 1;i<arr.length;i *= 2){
// 关键点2:此方法每次执行的时间复杂度为O(n),具体看下方
mergeSort(arr,i);
}
}
/**
* 以下方法,每次执行的时间复杂度都是O(n),
* 因为需要将arr数组的每gap个数子,作为一个子区间,
* 然后对相邻的两个子区间执行归并排序的merge操作,
* 所以在这个方法中,arr数组中的每一个数都会在merge操作中,
* 被处理一次,所以下面这个方法的时间复杂度为O(n)
*/
public static void mergeSort(int[] arr, int gap) {
int[] tmp = new int[arr.length];
int index = 0;
int start1 = 0;
int end1 = start1 + gap - 1;
int start2 = end1 + 1;
int end2 = (start2 + gap - 1)>=arr.length?arr.length-1:start2+gap-1;
while(start2<arr.length){
while(start1<=end1&&start2<=end2){
if(arr[start1]<arr[start2]){
tmp[index++] = arr[start1++];
}else{
tmp[index++] = arr[start2++];
}
}
while(start1<=end1){
tmp[index++] = arr[start1++];
}
while(start2<=end2){
tmp[index++] = arr[start2++];
}
start1 = end2+1;
end1 = start1 + gap - 1;
start2 = end1 + 1;
end2 = (start2 + gap - 1)>=arr.length?arr.length-1:start2+gap-1;
}
while(start1<arr.length){
tmp[index++] = arr[start1++];
}
for(int j = 0;j<tmp.length;j++){
arr[j] = tmp[j];
}
}
上面的代码,merge
方法中的循环需要循环logn
次,每次循环都调用一次mergeSort
方法,mergeSort
方法的时间复杂度为O(n)
,所以很容易得出归并排序的时间复杂度为O(nlogn)
。
2.2 快速排序的时间复杂度
了解快速排序的应该知道,快速排序的时间复杂度在O(nlogn)~ O(n^2)
之间,下面我就来分别分析这两种情况:
(一)快速排序的最好情况O(nlogn)
这种情况下,其实和上面通过递归分析的归并排序很类似,理解了归并排序的时间复杂度分析,那这里应该也很好理解。快速排序的实现方式,就是在当前区间中选择一个轴,区间中所有比轴小的数都需要放到轴的左边,而比轴大的数则放到轴的右边。在理想的情况下,我们选取的轴刚好就是这个区间的中位数。也就是说,在操作之后,正好将区间分成了数字个数相等的左右两个子区间。此时就和归并排序基本一致了:
- 递归的第一层,
n
个数被划分为2
个子区间,每个子区间的数字个数为n/2
; - 递归的第二层,
n
个数被划分为4
个子区间,每个子区间的数字个数为n/4
; - 递归的第三层,
n
个数被划分为8
个子区间,每个子区间的数字个数为n/8
;
......
- 递归的第
logn
层,n
个数被划分为n
个子区间,每个子区间的数字个数为1
;
以上过程与归并排序基本一致,而区别就是,归并排序是从最后一层开始进行merge
操作,自底向上;而快速排序则是从第一层开始,交换区间中数字的位置,也就是自顶向下。但是,merge
操作和快速排序的调换位置操作,时间复杂度是一样的,对于每一个区间,处理的时候,都需要遍历一次区间中的每一个元素。这也就意味着,快速排序和归并排序一样,每一层的总时间复杂度都是O(n)
,因为需要对每一个元素遍历一次。而且在最好的情况下,同样也是有logn
层,所以快速排序最好的时间复杂度为O(nlogn)
。
(二)快速排序的最坏情况O(n^2)
下面我们再来说一说快速排序的最坏情况,这种情况就比较好理解了。什么是快速排序的最坏情况,那就是,对于每一个区间,我们在处理的时候,选取的轴刚好就是这个区间的最大值或者最小值。比如我们需要对n
个数排序,而每一次进行处理的时候,选取的轴刚好都是区间的最小值。于是第一次操作,在经过调换元素顺序的操作后,最小值被放在了第一个位置,剩余n-1
个数占据了2到n
个位置;第二次操作,处理剩下的n-1
个元素,又将这个子区间的最小值放在了当前区间的第1
个位置,以此类推......每次操作,都只能将最小值放到第一个位置,而剩下的元素,则没有任何变化。所以对于n
个数来说,需要操作n
次,才能为n
个数排好序。而每一次操作都需要遍历一次剩下的所有元素,这个操作的时间复杂度是O(n)
,所以总时间复杂度为O(n^2)
。
其实上面的过程,我们可以换一个角度理解:每次操作,找出最小值放到剩余区间的第一个位置,这不就是选择排序的实现方式吗?而选择排序的时间复杂度就是O(n^2)
,所以上面的过程也就O(n^2)
。
三、总结
以上内容,就是我基于自己的理解,对快速排序和归并排序时间复杂度的分析。为了更好理解,我的描述都尽可能的详细,所以可能会有点啰嗦,但是我认为还是很通俗易懂的。希望这篇博客能够为之前对这两种排序算法理解不是特别清晰的人提供帮助,同时,若上面的内容存在错误或不足,欢迎指正或补充。