归并排序与快速排序

参考文章来源:极客时间

链接地址:为什么插入排序比冒泡排序更受欢迎?


归并排序和快速排序都用到了分治的思想。

归并排序

归并排序原理

归并排序的核心思想就是分两部分进行分别排序,然后将排序好的两部分再合并到一起,这样整个数组都有序了。

归并分解

归并的核心就是分治,分治跟递归很像,而且分治算法一般就是通过递归实现的,分治是一种思想,递归是一种编程技巧。

如何用递归实现归并排序呢?

首先分析递归公式,然后找到终止条件,最后将递归公式翻译成代码。

1
2
3
4
5
递推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))

终止条件:
p >= r 不用再继续分解

这里merge_sort(p…r)表示给下标为p到r的数组进行排序,将这个问题转化为两个子问题:merge(merge_sort(p…q),merge_sort(q+1…r))。其中q表示p和r中间的为孩子,也就是(p+r)/2,将p到q和q+1到r的两个子数组都排好序后再合并起来,那么p到r的数据也就排好序了。

具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package sorts;

/**
* Created by wangzheng on 2018/10/16.
*/
public class MergeSort {

// 归并排序算法, a是数组,n表示数组大小
public static void mergeSort(int[] a, int n) {
mergeSortInternally(a, 0, n-1);
}

// 递归调用函数
private static void mergeSortInternally(int[] a, int p, int r) {
// 递归终止条件
if (p >= r) return;

// 取p到r之间的中间位置q,防止(p+r)的和超过int类型最大值
int q = p + (r - p)/2;
// 分治递归
mergeSortInternally(a, p, q);
mergeSortInternally(a, q+1, r);

// 将A[p...q]和A[q+1...r]合并为A[p...r]
merge(a, p, q, r);
}

private static void merge(int[] a, int p, int q, int r) {
int i = p;
int j = q+1;
int k = 0; // 初始化变量i, j, k
int[] tmp = new int[r-p+1]; // 申请一个大小跟a[p...r]一样的临时数组
while (i<=q && j<=r) {
if (a[i] <= a[j]) {
tmp[k++] = a[i++]; // i++等于i:=i+1
} else {
tmp[k++] = a[j++];
}
}

// 判断哪个子数组中有剩余的数据
int start = i;
int end = q;
if (j <= r) {
start = j;
end = r;
}

// 将剩余的数据拷贝到临时数组tmp
while (start <= end) {
tmp[k++] = a[start++];
}

// 将tmp中的数组拷贝回a[p...r]
for (i = 0; i <= r-p; ++i) {
a[p+i] = tmp[i];
}
}

}

这里面的merge函数是将排序后的两个子序列合并到一起,示意图如下:

merge示意图

merge时,先申请一个临时数组tmp,大小与要排序的数组a相同,用i和j分别指向a[p…q]和a[q+1…r]的第一个元素,比较a[i]和a[j],如果a[i]<=a[j],就把a[i]放到临时数组tmp中,并且i后移一位,否则将a[j]放入tmp,j后移一位。一直到其中一个子序列中的所有元素都放入到tmp中,再把另一个数组中的数据依次加入到tmp的末尾,这时tmp中就是已经排好序的啦。最后将tmp的数据拷贝到原数组a中就完成了。

归并排序性能分析

  • 归并排序是稳定排序算法吗?

    归并排序稳不稳定其实主要看merge函数,合并过程中可以发现,值相同的元素我们是先把前面一个序列的值先放入tmp数组的,这就保证了值相同的元素在合并后的先后顺序是不变的,所以归并排序是稳定排序算法.

  • 归并排序的时间复杂度是多少?

    归并排序是通过递归实现的,时间复杂度分析起来会有点复杂.

    递归使用的场景是一个问题a可以分解成多个子问题b,c,那么求解问题a就可以分解为求解问题b,c,问题b和c解决后再把b和c的结果合并成a的结果.如果我们定义求解问题a的时间是T(a),求解b和c的时间是T(b)和T(c),那么我们就可以得到这个公式:

    1
    T(a) = T(b) + T(c) + K

    其中K是两个子问题b和c结果合并成问题a所消耗的时间.

    假设对于n个元素进行递归排序的总时间是T(n),那么分解成两个子数组排序的时间都是T(n/2),merge函数合并两个子数组的时间复杂度是O(n),所以归并排序的时间复杂度就可以通过下面这个公式计算得到:

    1
    2
    T(1) = C;   n=1 时,只需要常量级的执行时间,所以表示为 C。
    T(n) = 2*T(n/2) + nn>1

    进一步分解:

    1
    2
    3
    4
    5
    6
    T(n) = 2*T(n/2) + n
    = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
    = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
    = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
    ......
    = 2^k * T(n/2^k) + k * n

    最终可以得到T(n)=2^kT(n/2^k) + kn,当数据区间变为1的时候排序就完成了,所以当T(n/2^k)=T(1)时,排序就结束了.这时,n/2^k=1,计算可以得到k=log2n,将k的值带入公式,得到T(n)=Cn+nlog2n,如果用大O标记法来表示的话,T(n)就等于O(nlog2n).

    从上面的分析可以看出,归并排序与原始数据的有序程度无关,所以其时间复杂度是极其稳定的,不管是最好最坏还是平均情况,时间复杂度都是O(nlog2n).

  • 归并排序的空间复杂度是多少?

    归并排序的时间复杂度不错,但是它应用的并没有像快速排序那样广泛,就是因为它有一个致命弱点,那就是归并排序不是原地排序算法.

    因为归并排序的合并函数在合并两个有序数组时,需要借助额外的内存空间.如果按照上面的递归方法分析空间复杂度,那么归并整个过程的时间复杂度就是O(nlog2n).但是这样是不对的.

    空间复杂度不会像时间复杂度一样累加,尽管每次合并都需要申请额外的内存空间,但是合并完成后临时开辟的内存空间就被释放了,在任何时刻,cpu只会有一个函数在执行,也就是只有一个临时的内存空间被使用,临时空间最大也不会超过n个数据的大小,所有空间复杂度就是O(n).

快速排序

快速排序原理

快速排序简称”快排”,快排利用跟归并一样的分治思想,但是快排选取的中间节点pivot是数组下标p到r的任意一个数据节点,这是跟归并选取中间节点不同的.

快排原理

选取完pivot后,遍历p到r之间的所有数据,将小鱼pivot的放到左边,大于pivot的放到右边,pivot放到中间.这样,数组p到r之间的数据就分成了三个部分.

然后再进行分区,递归实现,直到区间缩小为1,就说明所有的数据都有序了.用递推公式来表示上面的过程就是这样:

1
2
3
4
5
递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)

终止条件:
p >= r

翻译成代码就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package sorts;

/**
* Created by wangzheng on 2018/10/16.
*/
public class QuickSort {

// 快速排序,a是数组,n表示数组的大小
public static void quickSort(int[] a, int n) {
quickSortInternally(a, 0, n-1);
}

// 快速排序递归函数,p,r为下标
private static void quickSortInternally(int[] a, int p, int r) {
if (p >= r) return;

int q = partition(a, p, r); // 获取分区点
quickSortInternally(a, p, q-1);
quickSortInternally(a, q+1, r);
}

private static int partition(int[] a, int p, int r) {
int pivot = a[r];
int i = p;
for(int j = p; j < r; ++j) {
if (a[j] < pivot) {
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
++i;
}
}

int tmp = a[i];
a[i] = a[r];
a[r] = tmp;

System.out.println("i=" + i);
return i;
}
}

这里的partition函数是分区函数,用于随机选取一个元素作为pivot(一般情况下会选取p到
r区间的最后一个元素),然后对数组A[p…r]分区,返回pivot的下标.如果不考虑空间消耗,partition可以写得非常简单,比如像下图所示的那样:

partition分区

申请两个临时数组x和y,遍历A[p…r],将小于pivot的元素拷贝到x,将大于pivot的元素拷贝到y,最后将x和y中的数据顺序拷贝到A就可以了.

但是这种情况下需要消耗很多额外内存空间,导致快排不是原地排序算法啦.其实可以不同这样,这里有种非常巧妙的办法,就是上面代码中写的那样,有点类似选择排序.我们通过游标i把A分成连个部分:p到i-1的元素都小于pivot,暂称为”已处理区间”;i到r-1成为”未处理区间”.每次都从未处理区间A[i…r-1]取一个元素A[j]与pivot比较,如果小于pivot,就将这个元素加入已处理区间尾部,也就是A[i].

这里将A[j]插入a[i]的位置时,如果正常搬移数据那就太费时间了,这里采用交换的方式,将A[i]与A[j]交换一下即可,时间复杂度为O(1),很简单.整个过程如下图所示:

partition分区优化

上面讲的分区过程涉及到交换操作,如果有两个相同的元素比如这个序列:6,8,7,6,3,4,5,9,4.当比较到3时,进行第一次数据交换,第一个6就与3进行了位置互换,最后两个6的相对先后顺序就变了,所以快速排序不是稳定排序算法.

那么这里有一个问题,快速排序和归并排序都是采用分治思想,他们的区别在哪里呢?

快排与归并区别

  • 归并排序是先处理子问题,然后合并,整个处理流程是自底向上的.快排正好相反,它是先分区,在处理子问题.

  • 归并排序是稳定的,时间复杂度是O(nlogn),但它不是原地排序.快速排序通过巧妙设计原地分区函数partition,可以实现原地排序,解决了归并排序内存占用过多的问题.

快速排序性能分析

快排是稳定排序吗?

不是的,这在上面已经说过啦.

快排是原地排序吗?

是的,上面已经说过.

快排的时间复杂度是多少?

快排也是用递归实现的,可以套用前面的公式,如果每次分区都能正好把数组分成大小接近相等的两个区间,那么快排时间复杂度与归并排序就是一样的,因为他们此时的推导公式是完全一样的,都是:

1
2
T(1) = C;   n=1 时,只需要常量级的执行时间,所以表示为 C。
T(n) = 2*T(n/2) + nn>1

但是我们不能保证每次都能选择合适的pivot均分数组,比如一个已经有序的数组:1,3,5,6,8,如果每次选择最后以一个元素作为pivot,那么每次分区得到的两个区间就是极不均等的,我们需要大约n次操作才能完成快排整个过程,每次分区我们都需要扫描大约n/2个元素,这种情况下快排的时间复杂度就变成了O(n²).

上面分别降到了两种极端情况下快排的时间复杂度,一个是分区极度均衡,一个是分区极度不均衡,他们分别对应的就是快排最好情况时间复杂度和最坏情况时间复杂度.那么快排的平均时间复杂度是多少呢?

假设每次分区操作都将区间分为9:1的两个区间,时间复杂度递推公式就会变成下面这样:

1
2
3
T(1) = C;   n=1 时,只需要常量级的执行时间,所以表示为 C。

T(n) = T(n/10) + T(9*n/10) + nn>1

这个递推公式推倒起来相当麻烦,这里给出结论:T(n)大部分情况下可以做到O(nlogn),只有极端情况下才会退化到O(n²).

如何在 O(n) 的时间复杂度内查找一个无序数组中的第K大元素

比如有一个序列:4,2,5,12,3这样一组数据,第3大元素就是4.

采用分治思想,将数组区间A[0…n-1]最后一个元素A[n-1]作为pivot,对数组A进行原地分区成三部分:A[0…p-1],A[p],A[p+1…n-1]].如果p+1=k(这里结合下面的图片好好理解一下),那么A[p]就是要找的元素;如果K>p+1,则说明第K大元素出现在A[p+1…n-1]这个区间中,继续按照上面的思路在A[p+1…n-1]内继续分区查找.同理,如果K<p+1,那么就在A[0…p-1]区间内查找.

寻找第K大元素

上述过程的时间复杂度是O(n)吗? 第一次分区需要遍历n个元素,第二区遍历n/2,以此类推,n/4,n/8,n/16…

最后总的时间复杂度就是n+n/2+n/4+n/8…+1=2n-1.所以最终时间复杂度就是O(n).

总结

归并和快排相对于冒泡,插入,选择排序稍微复杂一点,他们都采用分治思想,通过递归实现,过程很相似,只不过在具体实现流程和细节方面有些许差别.归并排序主要是理解merger合并函数,快排主要是理解partition分区函数.

归并排序在任何情况下时间复杂度比较稳定,这也是它的缺点,即它不是原地排序算法,归并排序是稳定排序算法,空间复杂度也比较高,是O(n),正因为如此,他没有快排应用广泛.

快排虽然最坏情况下时间复杂度是O(n²),但是其均时间复杂度是O(nlogn),而且快排时间复杂度退化到O(n²)的概率非常小,可以通过合理选择pivot来避免,另外快排不是稳定排序,空间复杂度是O(1).