数据结构-快速排序 归并排序

£可爱£侵袭症+ 提交于 2020-03-03 16:03:46


注:所有的代码在我的Github中有均具体C++代码实现。

这里主要讲的是时间复杂度为O(nlogn)的两种排序算法:快速排序(Qiuck sort)和 归并排序 (Merge sort)。

这两种排序都是用了分治的思想,我们因此可以借鉴这个思想来解决非排序的一些问题,例如:如何在O(n)的时间复杂度内查找一个无序数组中的第K大的元素?

归并排序 (Merge sort)

简介

简单地说,如果要排序一个数组,我们首先吧数组从中间分成前后两个部分,然后对前后两个部分分别排序,最后将排序好的两部分进行合并。

img

这里使用了分治的思想,也就是分而治之,将一个大问题分成几个小的子问题来解决,小的问题解决了,大的问题也就解决了。

这里,主要就是merge函数的实现问题了,merge(A[p...r], A[p...q], A[q+1...r])也就是将已经有序的A[p...q]A[q+1...r]合并成一个有序的数组,这里我们使用了一个额外的临时数组,其空间带下为r - p + 1,具体操作如下:

img

代码

void merge(int arr[], int l, int m, int r)
{
	int n = r - l + 1;
    // 分配临时的数组空间
	int *tmp = new int[n];
	int i, j, k;
	for(i = l, j = m + 1, k = 0; i <= m && j <= r;)
	{
		if(arr[i] <= arr[j])
			tmp[k++] = arr[i++];
		else
			tmp[k++] = arr[j++];
	}
	if(i == m + 1)
	{
		for(; j <= r;)
			tmp[k++] = arr[j++];
	}else
	{
		for(; i <= m;)
			tmp[k++] = arr[i++];
	}
	
	for(i = l; i < n + l; i++)
		arr[i] = tmp[i - l];
		
	delete tmp;
}
// 另外一种实现方式
//void merge(int arr[], int l, int m, int r)
//{
//    int i, j, k;
//    int n1 = m - l + 1;
//    int n2 = r - m;
//
//    int L[n1], R[n2];
//
//    for (i = 0; i < n1; i++)
//        L[i] = arr[l + i];
//    for (j = 0; j < n2; j++)
//        R[j] = arr[m + 1 + j];
//
//    i = 0;
//    j = 0;
//    k = l;
//    while (i < n1 && j < n2)
//    {
//        if (L[i] <= R[j])
//        {
//            arr[k] = L[i];
//            i++;
//        }
//        else
//        {
//            arr[k] = R[j];
//            j++;
//        }
//        k++;
//    }
//
//    while (i < n1)
//    {
//        arr[k] = L[i];
//        i++;
//        k++;
//    }
//
//    while (j < n2)
//    {
//        arr[k] = R[j];
//        j++;
//        k++;
//    }
//}

void mergeSort(int arr[], int l, int r)
{
    if (l < r)
    {
        int m = l + ((r - l) >> 1);
        mergeSort(arr, l, m);
        mergeSort(arr, m + 1, r);
        merge(arr, l, m, r);
    }
}

性能分析

  • 归并排序是稳定排序,具体的过程可以看代码中的merge()函数的实现过程。

  • 归并排序的时间复杂度为O(nlogn),我们可以使用递推公式的方法来进行计算。

    我们知道假设对 n 个元素进行归并排序需要的时间为T(n),那么分解为两个子数组排序的时间为T(n/2)。并且merge()函数的时间复杂度为O(n)。所以,套用公式,计算可为:

    T(1) = C;   n=1时,只需要常量级的执行时间,所以表示为C。
    T(n) = 2*T(n/2) + n; n>1
    
    
    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^k) = T(1)**时,我们可以得到:
    k=log2n k=log_{2}n
    将 k 的值带入进去 我们可以得到:
    T(n)=Cn+nlog2n T(n) = Cn + n log_{2}n

  • 因为在marge()函数中,我们使用了额外的数组空间,所以时间复杂度为O(n)。注意:虽然在合并的过程中,每次合并都需要申请额外的内存空间,但是合并之后,临时开辟的空间就释放了。在任意时刻,CPU只会有一个函数在执行,也就只会有一个临时的内存空间在使用。

快速排序(Quick sort)

简介

快排利用的也是分治的思想:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。

我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。

img

然后我们在采用递归的方法对p到q-1 和 q+1到r的数据集进行排序,直到区间缩小为1,这就说明所有的数据都有序了。

递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1… r)

终止条件:
p >= r

这里有个重点就是partition函数,就是随机选择一个元素作为 pivot(分界点)(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后将分界左边就是比pivot小的数据,分界点右边就是比pivot大的数据。

例如:

【9,8,7,6,5,4,3,2,1,0】如果选择第一个点9作为pivot,那么最终就分为了===》

【8,7,6,5,4,3,2,1,0,9】,所以选择pivot点是很重要的。

代码

partition()函数的实现常见的有两种实现方式:

int partition(int *a, int p, int r)
{
	int i, j;
	i = j = p;
	int pivot = a[r];
	for(; j < r; j ++)
	{
		if(a[j] < pivot)
		{
			if(i != j)
			{
				swap(a[i], a[j]);
			}
			i ++;
		}
	}
	swap(a[i], a[r]);
	return i;
}

这里的处理有点类似选择排序。我们通过游标 i 把 A[p…r-1]分成两部分。A[p…i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1]是“未处理区间”。我们每次都从未处理的区间 A[i…r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。

img

int partition(int *a, int p, int r)
{
	int pivot = a[p];
	
	while(p < r)
	{
        // 从右向左边扫描,找比pivot小的
		while (p < r && a[r] > pivot)	r --;
		a[p] = a[r];
		// 从左向右边扫描,找pivot大的
		while (p < r && a[p] < pivot)		p ++;
		a[r] = a[p];
	}
	
	a[p] = pivot;
	return r;
}

这种方法貌似是在算法导论这本书中实现方法,其实和第一种不同的就是,这里是两个指针同时移动。

经过调查,貌似第一种方式要比第二种的速度略快一点

性能分析

如果我们每次都分的很均匀的情况下,也就是每次都能分到n/2的区间的话,利用上面的公式可以得到快速排序的时间复杂度为 O(nlogn),只有在极端情况下,例如在数组本身就是排序的情况下,才会退化到 O(n^2)。

针对极端情况下的情况,有很多优化的方法:见此===》博客

优化一:三数取中法,解决数据基本有序的(就是找到数组中最小下标,最大下标,中间下标的数字,进行比较,把中间大的数组放在最左边)。

优化二:随机选取基准。

原因:在待排序列是部分有序时,固定选取枢轴使快排效率底下,要缓解这种情况,就引入了随机选取枢轴。

优化三:优化小数组的交换,就是为了解决大才小用问题。

原因:对于很小和部分有序的数组,快排不如插排好。当待排序序列的长度分割到一定大小后,继续分割的效率比插入排序要差,此时可以使用插排而不是快排。

截止范围:待排序序列长度N = 10,虽然在5~20之间任一截止范围都有可能产生类似的结果,这种做法也避免了一些有害的退化情形。

排序算法的结合

为了让你对如何实现一个排序函数有一个更直观的感受,我拿 Glibc 中的 qsort() 函数举例说明一下。虽说 qsort() 从名字上看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法。

如果你去看源码,你就会发现,qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大。现在计算机的内存都挺大的,我们很多时候追求的是速度。但如果数据量太大,就跟我们前面提到的,排序 100MB 的数据,这个时候我们再用归并排序就不合适了。

所以,要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序。那 qsort() 是如何选择快速排序算法的分区点的呢?如果去看源码,你就会发现,qsort() 选择分区点的方法就是“三数取中法”。是不是也并不复杂?

还有我们前面提到的递归太深会导致堆栈溢出的问题,qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的。

实际上,qsort() 并不仅仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序,因为,在小规模数据面前,O(n^2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。

对于小规模数据的排序,O(n^2) 的排序算法并不一定比 O(nlogn) 排序算法执行的时间长。对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法。

这样的方法还有很多,例如Java中Arrays.sort的方法,也是采用了一些优化的方法,并将几种方法结合起来。

总结

  • 归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题
  • 归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题
  • 我们知道,快速排序是用递归来实现的。为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出,我们有两种解决办法:第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制。

img

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!