【说人话的算法小课堂】快速排序的正确性证明及时间复杂度分析

快排代码:(输入都是闭区间而不是STL常见的左闭右开)

template<class _Ty> _Ty* partition(_Ty* _begin, _Ty* _end) {
    
    
	_Ty* i = _begin, * j = _end, t = *_begin;
	while (i != j) {
    
    
		while (j > i && *j >= t)--j;
		*i = *j;
		while (i < j && *i <= t)++i;
		*j = *i;
	}
	*i = t;
	return i;
}
template<class _Ty> void quick_sort(_Ty* _begin, _Ty* _end) {
    
    
	if (_begin < _end) {
    
    
		_Ty* i = partition(_begin, _end);
		quick_sort(_begin, i - 1);
		quick_sort(i + 1, _end);
	}
}

使用快速排序需要调用quick_sort函数。该函数通过调用partition函数,将数组划分成两个部分(长度可以不等),分别递归进行快排。递归终止条件是数组长度为1。此时无法进入划分的主循环,递归调用直接返回。

划分的过程的正确性证明:
先任选一个枢轴,这里取第一个元素,记为t;两个指针i,j,初始位置分别位于需要划分的数组的头部和尾部。i只能递增,j只能递减。
当i与j不重合时,反复执行如下步骤(下面的叙述中,“副本”指的是从一个元素复制出来的与该元素完全相同的实例。如果待排序的数组本来就有多个完全相同的元素,那么将这些元素都看成互相不同的,而不是互相视为副本):
【1】从右往左查找第一个小于枢轴t的元素(查找时j递减)。找到后执行【2】,否则继续查找。
【2】* i = * j,即将找到的小于枢轴的元素放到左侧,产生了一个找到的元素的副本。
【3】从左往右查找第一个大于枢轴t的元素(查找时i递增)。找到后执行【4】,否则继续查找。
【4】* j = * i,即将找到的大于枢轴的元素放到右侧,产生了一个找到的元素的副本。
每次将小于或大于枢轴的元素移到对面后,被放到对面的元素在数组中会含有一个副本,副本写入的位置原有的元素被覆盖。执行这种操作时:
(1)如果是第一次执行,那么i一定位于初始位置,即枢轴的位置,且枢轴会被用j指向的元素覆盖(即执行的一定是步骤【2】)。不可能先将左侧的元素写入j指向的位置。因为已经规定“从右往左查找第一个小于枢轴t的元素”这一步(即步骤【1】)先进行。也就是说,j递减到找到一个小于枢轴t的元素,或与i重合时,才会停止递减。这时候要么i = j,此时步骤【2】和【4】等效于不执行(因为是自己覆写自己),然后因为外层循环的i≠j的条件不再符合,外层循环会被退出;要么仍有i≠j,此时i指向的元素(即枢轴)被替换为j指向的元素。
(2)如果不是第一次执行,原先重复的元素的其中一个副本会被新的找到的符合条件的元素覆盖。这个新的元素在数组里也有两个,一个是原有的,一个是新的副本。
(1)(2)告诉我们:无论在什么时候执行具有覆写效果的步骤【2】或【4】,数组中始终只有一个元素具有副本。且原数组中只有被选为枢轴的元素会丢失。
当i,j重合后,执行*i = t,此时枢轴覆盖了最后一个,也是唯一一个具有副本的元素的副本。
因为第一次执行步骤【2】后,枢轴就被j指向的元素覆盖了。为了确保枢轴不丢失,就需要用一个临时变量t将枢轴暂时保存。
步骤【2】和【4】分别总是把小于或大于枢轴的元素移到对面。由于i和j分别是递增、递减的,因此在i = j后,i,j重合的位置的左侧全部是小于枢轴的元素,右侧全部是大于枢轴的元素。这就证明了划分过程的正确性。QED.

快速排序的时间复杂度由两部分决定:quick_sort函数,partition函数。
先来看划分操作的复杂度。
i,j指针的移动和覆写操作*i = *j,*j = *i都是O(1)的。
设数组长度为n。两个指针i,j移动的总次数恰为n – 1,立得划分操作的时间复杂度为O(n)。
再看递归执行快排的复杂度。
每一层递归会调用两次quick_sort函数。递归层数与划分出的两个数组的长度有关。
最好的情况是每次划分出的两个数组的长度都相等或只差1。这时候只需log n的递归深度就可以彻底把数组分成单个元素。最后只需要等递归逐渐返回,就完成了快排。所以,快速排序的最好情况的时间复杂度为O(n log n)。
最坏的情况是每次都只能将长度为k的数组划分为枢轴、长为0的子数组和长为k – 1的子数组。每加深一层递归时,较长的子数组的长度只减1。这时递归深度为n。所以,快速排序的最坏情况的时间复杂度为O(n2)。
快速排序的平均时间复杂度也是O(n log n)。篇幅所限,这里不予推导,请参考《算法导论》第7章。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述在这里插入图片描述

实践中几乎不可能达到最坏情况,且因为快速排序的内存访问遵循局部性原理,多数情况下快速排序的表现大幅优于堆排序等其他复杂度为O(nlogn)的排序算法。
内省排序(introspective sort)是快速排序和 堆排序 的结合,由 David Musser 于 1997 年发明。内省排序其实是对快速排序的一种优化,保证了最差时间复杂度为O(nlogn)。
内省排序的思路其实很简单,就是限制快速排序最大递归深度为floor(logn),超过限制时就转换为堆排序。这样既保留了快速排序内存访问的局部性,又可以防止快速排序在某些情况下性能退化为O(n^2)。
2000 年 6 月,SGI C++ STL 的 stl_algo.h 中 sort()函数的实现采用了内省排序算法。

C++11以前,使用快速排序的std::sort
(1)期望时间复杂度:O(nlogn)
(2)最坏时间复杂度:O(n^2)

C++11开始,使用快速排序的std::sort
(1)期望时间复杂度:O(nlogn)
(2)最坏时间复杂度:O(nlogn)

std::sort()在 libstdc++ 和 libc++ 中使用的都是 Introsort 。

Introsort 限制了快速排序的分治深度,当分治达到一定深度之后,改用最坏时间复杂度为O(nlogn)的排序算法(比如堆排序)来给子数组排序。

快速排序和堆排序都是不稳定的,但归并排序是稳定的。STL中支持稳定的快速排序std::stable_sort。

猜你喜欢

转载自blog.csdn.net/COFACTOR/article/details/108570502
今日推荐