快速排序是从冒泡排序演变而来的算法,但是比冒泡排序要高效得多,所以叫做快速排序
最好先弄清楚荷兰国旗问题,这样比较好理解
算法步骤:如果要排序的数组中下标从p到r之间的一组数据,
递推公式如下:
递推公式: q u i c k S o r t ( p … r ) = q u i c k S o r t ( p … q − 1 ) + q u i c k S o r t ( q + 1 , r ) 递推公式:quickSort(p…r) = quickSort(p…q-1) + quickSort(q+1, r) 递推公式:quickSort(p…r)=quickSort(p…q−1)+quickSort(q+1,r)
终止条件: p > = r 终止条件:p >= r 终止条件:p>=r
因此,我们可以写出如下代码框架:
// 快速排序,a是数组,n表示数组的大小
void quickSort(int []a, int n){
quickSortInternally(a, 0, n-1);
}
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);
}
因此我们可以画出如下流程:
问题是:
这两个是快速排序要解决的核心问题,也是partition函数需要时间的核心功能。
这个partition有个专有名词,叫做荷兰国旗问题
基准元素,用于在分治过程中以此为中心,把其他元素移动到基准元素的左右两边。
那么基准元素如何选择呢?
从上面可以看出:
可以看出,这种O( n 2 n^2 n2)时间复杂度出现的主要原因还是因为分区点选的不管合理,那么分区点应该怎么选择呢?也就是说什么样的分区点才是好分区点呢?
最理想的分区点是:*被分区点分开的两个分区中,数据的数量差不多
如果很粗暴地直接选择第一个或者最后一个数据作为分区点,不考虑数据的特点,肯定会出现之前讲的那样,在某些情况下,排序的最坏情况时间复杂度是 O( n 2 n^2 n2) 。为了提高排序算法的性能,我们也要尽可能地让每次分区都比较平均。
因此,比较常用的分区由两种:
我们该怎么避免这种情况发生呢?
这样一来,即使在数列完全逆序的情况下,也可以有效地将数列分成两部分。
当然,即使是随机选择基准元素,每一次也有极小的几率选到数列的最大值或最小值,同样会影响到分治的效果。
这种方法并不能保证每次分区点都选得比较好,但是从概率的角度来看,也不太可能出现每次分区点都选得很差的情况,所以平均角度下,这样选的分区点是比较好的。时间复杂度退化为最糟糕的O( n 2 n^2 n2) 的情况,出现的可能性不大。
我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这3个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取一个数据要好。但是,如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”。
选定了基准元素以后,我们要做的就是把其他元素当中小于基准元素的都移动到基准元素一边,大于基准元素的都移动到基准元素另一边。
如果我们不考虑空间消耗的话,partition()分区函数可以写的非常简单。我们申请两个临时数组X和Y,遍历A[p…r],将小于pivot的元素都拷贝到临时数据X,将大于pivot的元素都拷贝到临时数组Y,最后再将数组X和数据Y中数据顺序拷贝到A[p…r]
但是,如果按照这种思路实现的话,partition()函数就需要很多额外的内存空间,所以快排就不是原地排序算法了。如果我们希望快排是原地排序算法,那它的空间复杂度就需要是O(1),那 partition() 分区函数就不能占用太多额外的内存空间,我们就需要在 A[p…r] 的原地完成分区操作。
有两种方法:
// 暂时只实现了将大于基准元素的放到右边
std::vector<int> partition(std::vector<int> &arr, int L, int R, int priv){
int less = L - 1;
int more = R + 1;
int curr = L; // 就是上图的L,当然也可以写成下面那样
while (curr < more){
if(arr[curr] < priv){
std::swap(arr[curr++], arr[++less]);
}else if(arr[curr] > priv){
std::swap(arr[curr] , arr[--more]);
}else{
curr++;
}
}
return { less + 1, more - 1 }; // 和p相等的边界
}
void quickSort(std::vector<int> & arr, int left, int right){
if (arr.size() < 2 ){
return;
}
if (left < right){
int random = left + rand() % (right - left + 1);//在序列中随机选取一个元素
std::swap(arr[left], arr[random]); // 和基准元素交换,这样基准元素就是随机的了
auto mids = partition(arr, left, right, arr[left]);
quickSort(arr, left, mids[0] - 1);
quickSort(arr, mids[1] + 1, right);
}
}
void quickSort(std::vector<int> & arr){
quickSort(arr, 0, arr.size() - 1);
}
还有一种思路:
int partition(std::vector<int> & arr, int p, int r){
int pivot = arr[r];
int i = p;
for(int j = p; j < r; ++j){
if(arr[j] < pivot){
if(i == j){
++i; // 有序区扩大
}else{
std::swap(arr[i], arr[j]);
++i; //有序区扩大
}
}
}
std::swap(arr[i], arr[r]); //处理[基准]
return i;
}
int partition(std::vector<int> & array, int left, int right){
int pivot = array[left]; // 初始坑
while (left < right){
while (left < right && array[right] > pivot){ //找到右边第一个比基准元素小的元素
right--; // 如果当前正在比较的元素小于等于基准元素,就跳出循环,right就是右边第一个比基准元素小[等于]的索引
}
if (left == right) break; // 如果时因为left == right而跳出循环,那就直接返回
array[left] = array[right]; // 将元素放入坑中, right成为新的坑
while (left < right && array[left] <= pivot){ // 找到左边第一个比基准元素大的元素
left++; // 如果当前正在比较的元素大于基准元素,就跳出循环,left就是左边第一个大于基准元素的索引。
}
if (left == right) break; // 如果时因为left == right而跳出循环,那就直接返回
array[right] = array[left]; // 将元素放入坑中, left成为新的坑
}
// 跳出循环时,一定时因为left = right
array[right] = pivot;
return right;
}
void quickSort(std::vector<int> & arr, int left, int right){
if (arr.size() < 2 ){
return;
}
if (left < right){
int mid = partition(arr, left, right);
quickSort(arr, left, mid - 1);
quickSort(arr, mid + 1, right);
}
}
void quickSort(std::vector<int> & arr){
quickSort(arr, 0, arr.size());
}
因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。
(1)快排的时间复杂度
快排也是用递归实现的。如果每次分区操作,都能正好的把数组分成大小接近的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以,快排的时间复杂度也是 O(nlogn)。
T ( 1 ) = C ; n = 1 时,只需要常量级的执行时间,所以表示为 C 。 T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。 T(1)=C;n=1时,只需要常量级的执行时间,所以表示为C。
T ( n ) = 2 ∗ T ( n / 2 ) + n ; n > 1 T(n) = 2*T(n/2) + n; n>1 T(n)=2∗T(n/2)+n;n>1
但是公式成立的前提是每次分区操作,我们选择的pivot都很合适,正好能将大区间对等的一分为二。但实际上这种情况是很难实现的。
举一个比较极端的例子,如果数组中的数据原来就是已经有序的了,比如1、3、5、6、8,如果我们每次选择最后一个元素作为pivot,那每次分区得到的两个区间但是不均等的,我们需要进行大约n次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O( n 2 n^2 n2)。
我们刚刚讲了两个极端情况下的时间复杂度,一个是分区极其均衡,一个是分区极其不均衡。它们分别对应快排的最好情况时间复杂度和最坏情况时间复杂度。那快排的平均情况时间复杂度是多少呢?
T(n) 在大部分情况下的时间复杂度都可以做到O(nlogn),只有在极端情况下,才会退化到 O( n 2 n^2 n2)。而且,我们也有很多方法将这个概率降到很低
所以,快速排序的平均时间复杂度是 O(nlogn),最坏情况下的时间复杂度是 O(n^2)。
问题:快排和归并用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢?
可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后在合并。而快排的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为O(nlogn)的排序算法,但是是非原地排序算法。归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快排通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题
因此:
几乎所有的编程语言都会提供排序函数,比如C语言中的qsort()、C++ STL中的sort()、stable_sort(),还有 Java 语言中的Collections.sort()。在平时的开发中,我们也都是直接使用这些现成的函数来实现业务逻辑中的排序工具,那这些函数是如何实现的呢?底层都利用了哪种排序算法,应该如何实现一个通用的、高性能的排序函数呢?
第一个要解决的问题是:如果要实现一个通用的、高效率的排序函数,我们应该选择哪种排序算法?
因此,快排比较合适用来实现排序函数。但是,快排在最坏情况下的时间复杂度是O( n 2 n^2 n2),如何解决这个“复杂度恶化”的问题呢?随机选择基准点或者多路取中法
以拿 Glibc 中的 qsort() 函数分析举例
为什么O( n 2 n^2 n2) 时间复杂度的算法并不一定比O(nlogn)的算法执行时间长呢?
package main
import (
"fmt"
"math/rand"
"time"
)
func QuickSort(arr[] int)[]int{
length := len(arr)
if length <= 1{
return arr
}else{
base := arr[0] // 第一个作为基准
low := make([]int, 0) //存储比我小的
mid := make([]int, 0) //存储与我相等
high := make([]int, 0) //存储比我大的
for i := 0; i < length ; i++ {
if arr[i] < base {
low = append(low, arr[i])
}else if arr[i] == base {
mid = append(mid, arr[i])
}else{
high = append(high, arr[i])
}
}
low, high = QuickSort(low), QuickSort(high)
return append(append(low, mid...), high...)
}
}
func QuickSortRandom(arr[] int)[]int{
length := len(arr)
if length <= 1{
return arr
}else{
rand.Seed(time.Now().UnixNano())
base := arr[rand.Intn(length)] // 随机基准
low := make([]int, 0)
mid := make([]int, 0)
high := make([]int, 0)
for i := 0; i < length ; i++ {
if arr[i] < base {
low = append(low, arr[i])
}else if arr[i] == base {
mid = append(mid, arr[i])
}else{
high = append(high, arr[i])
}
}
low, high = QuickSort(low), QuickSort(high)
return append(append(low, mid...), high...)
}
}
func main() {
arr := []int{13,14,94,33,82,25,59,94,65,23,45,27,73,25,39,10, 11, 13, 13, 13}
fmt.Println(QuickSortRandom(arr))
fmt.Println(arr)
}