Take a look at GW

【算法】快速排序

目录结构:

contents structure

 

1.简介

 算法思想:基于分治的思想,是冒泡排序的改进型。首先在数组中选择一个基准点(该基准点的选取可能影响快速排序的效率,后面讲解选取的方法),然后分别从数组的两端扫描数组,设两个指示标志(lo指向起始位置,hi指向末尾),首先从后半部分开始,如果发现有元素比该基准点的值小,就交换lo和hi位置的值,然后从前半部分开始扫秒,发现有元素大于基准点的值,就交换lo和hi位置的值,如此往复循环,直到lo>=hi,然后把基准点的值放到hi这个位置。一次排序就完成了。以后采用递归的方式分别对前半部分和后半部分排序,当前半部分和后半部分均有序时该数组就自然有序了。
排序过程:
 
下面贴上一张动图:

 
 
给出C代码实现:
#include <stdio.h>

int partition(int* arr,int lo,int hi);
void quickSort(int* arr,int lo,int hi);
int main()
{
    int arr[]={1,8,2,9,3,7,0,1,0,10};
    quickSort(&arr,0,9);
    for(int i=0;i<10;i++){
        printf("%d\n",arr[i]);
    }
   return 0;
}
int partition(int* arr,int lo,int hi){
    int key=arr[lo];   //固定切分,选择最左边的点,作为基准点
    while(lo<hi){
        while(lo<hi && arr[hi]>=key){    //从右边进行遍历
            hi--;
        }
        arr[lo]=arr[hi];
        while(lo<hi && arr[lo]<=key){    //从左边进行遍历
            lo++;
        }
        arr[hi]=arr[lo];
    }
    arr[lo]=key;
    return lo;
}
void quickSort(int* arr,int lo,int hi){
    if(lo>=hi){
        return;
    }
    int index=partition(arr,lo,hi);    //得出分界点
    quickSort(arr,lo,index-1);
    quickSort(arr,index+1,hi);
}

 

2.快速排序优化

对于基准位置的选取一般有三种方法:固定切分,随机切分和三数取中切分。三数取中选择基准点,是最理想的一种。

三数取中切分:

int partition(int* arr,int lo,int hi){
    //三数取中,使中间数处于lo下标处
    int mid=lo+(hi-lo)/2;
    if(arr[mid]>arr[hi]){
        swapArray(arr,mid,hi);
    }
    if(arr[lo]>arr[hi]){
        swapArray(arr,lo,hi);
    }
    if(arr[mid]>arr[lo]){
        swapArray(arr,mid,lo);
    }
    //调换后的结果,arr[hi]最大,arr[lo]其次,arr[mid]最小
    int key=arr[lo];
    while(lo<hi){
        while(lo<hi && arr[hi]>=key){
            hi--;
        }
        arr[lo]=arr[hi];
        while(lo<hi && arr[lo]<=key){
            lo++;
        }
        arr[hi]=arr[lo];
    }
    arr[hi]=key;
    return hi;
}
//互换数组中两个下标元素的值
void swapArray(int* arr,int a,int b){
    int temp=arr[a];
    arr[a]=arr[b];
    arr[b]=temp;
}

3.快排的时间复杂度

 在讨论了快排的基本实现之后,接下里继续讨论快排的时间复杂度。快排的平均时间复杂度和最优时间复杂度都是O(n*logn),而最差时间复杂度O(n2)。

在进行讨论快排的最差,最优和平均时间复杂度之前,先来看一下快排的模型图。

一个数列会被分成两个子数列,它们的长度分别是 i - 1 和 n - i , 所以两个子数列的时间就是T(i - 1) 和 T(n - i)。 还有就是分割操作本身也是需要时间的,分割函数 partition 比较简单,只有一个大的循环 while(lo < hi) ,所以这里记一次分割所需的时间为 cn, 其中 c 是一个常数

所以总的时间方程式就是:

T(n)  =  左边的时间 + 右边的时间 + 分割所需的时间 

        =  T(i - 1) + T(n - i) + cn

上面的方程式就是一般的时间复杂度方程式,也就是本文后面要讨论的平均时间复杂度的方程式,也是三种方程式(最差,最优,平均)中最难解的。这个方程式的化解,会留到本文的最后。

还有一个特殊的情况,就是当n为0的时候,这时候不需要排序,所以: T(0) = 0. 当n为1的时候,同样也是不需要排序的(只有一个判断 if(lo>=hi) ,消耗的时间为常数), 记:T(1)  = T(0) = 0.

因此完整的方程式如下:

       |-- 0                         (n = 0, or n = 1)
T(n) = |
       |-- T(i - 1) + T(n - i) + cn  (n > 1)

 

3.1 最差时间复杂度

接下来我们讨论 最差时间复杂度,在最差的情况下,一个长度为 n 的数列,每次分割得到的两个子数列长度为  n - 1 和 0  。换句话说,就是分割严重不均衡,所有的数据都到一边去了,而另一边没有数据。

两个子序列的长度分别为 n-1 和 0,再加上分割所需的时间,我们可以得出如下的方程式:

T(n) = T(n - 1) + T(0)  + cn

因为T(0) = 0,所以

T(n) = T(n - 1)  + cn

T(n)       =    T(n - 1)      +    C*n                    
T(n - 1)   =    T(n - 2)      +    C*(n - 1)      # 将 n 减 1,得到下个子序列
T(n - 2)   =    T(n - 3)      +    C*(n - 3)
....
T(2)       =    T(1)          +    C*2
T(1)       =    T(0)          +    C

将下一级表达式依次代入上一级,可以得到:

T(n)       =    T(n - 1)      +    C*n                    
T(n - 1)   =    T(n - 2)      +    C*(n - 1)      # 将 n 减 1,得到下个子序列
T(n - 2)   =    T(n - 3)      +    C*(n - 3)
....
T(2)       =    T(1)          +    C*2
T(1)       =    T(0)          +    C

# 将下一级表达式代入上一级
T(n) = T(0) + C*n + C*(n-1) + C*(n-2) + C*(n-3).....+C*(2)+C
# T(0)是等于0的
T(n) = C*n + C*(n-1) + C*(n-2) + C*(n-3).....+C*(2)+C
T(n) = C*[n + (n-1) + (n-2) + (n-3).....+2+1]
T(n) = C*(n + 1)*n/2

 最后一步,将括号展开,可以得到:

T(n) =C(n2)/2 + C(n/2)

最高阶为 n2, 所以 最差时间复杂度为:O(n2).

 

时间复杂度为O(n2)绝对是非常糟的(算法的时间复杂度和空间复杂度),那么我们如何避免这种情况出现呢?这里就不得不提上面的基准位置的选取了(第“2.快速排序的优化”)。如果我们选的基准位置是在最左或是最右的位置,而恰好要进行快排的数列是已经按照升序或降序排好了的,那么这个时候,就会出现最糟的情况,所以在这里建议,在选取基准位置的时候,不要选择在最左或是最右的位置,我个人推荐选取到中间(三数取中)。

3.2 最优时间复杂度

 上面讨论了最差时间复杂度,接下来继续探讨最优时间复杂度。先来说一下什么是最优的情况,在最优的情况下,每次分割取得的两个子序列的长度相等。换句话说,就是分割非常均匀,每次进行完分割操作(partition)后,分割点以左的长度 恰好等于 分割点以右的长度。

因为每次分割后分割点恰好是在中间(长度为奇数,才可以得到两个分割相等的长度),所以左边消耗的时间为T(n/2), 右边消耗的时间也是T(n/2). 同样,每次分割操作花费的时间依然是cn. 所以我们可以得出如下的表达式:

T(n) = T(n/2) + T(n/2) + cn

       = 2T(n/2) + cn

接下就是解递推了:

T(n) = 2T(n/2) + cn                                       # T(n/2) = 2T(n/4) + cn/2
        = 2*[2T(n/4) + cn/2] + cn
        = 4T(n/4) + 2cn                                     # T(n/4) = 2T(n/8) + cn/4
        = 4*[2T(n/8) + cn/4] + 2cn
        = 8T(n/8) + 3cn                                     # T(n/8) = 2T(n/16) + cn/8
        = 8*[2T(n/16) + cn/8] + 3cn
        = 16T(n/16) + 4cn
        ......
        = 2kT(n/2k) + kcn                                  # 一直递推下去,直到 n/2k = 1

        = n*T(1)  + lgncn                                  # n/2k = 1   ==>     2k = n     ==> k = logn   

        = cn*logn                                              # T(1) = T(0) = 0

最高阶为n logn, 所以 最优时间复杂度为:O(n logn).

 

3.3 平均时间复杂度

上面说的最优时间复杂度和最差时间复杂度都是比较极端的情况,更多普遍的情况是 平均时间复杂度。有趣的是,在快速排序中,平均时间复杂度等于最优时间复杂度,它们都是 n logn

 

因为网页不太好展示解答过程,我已经把推算过程整理成了word文档,下载:排序平均复杂度推算过程

 

平均时间复杂度为:O(n logn)

 

4.空间复杂度

上面已经讨论快排的最优时间复杂度,最差时间复杂度,以及 平均时间复杂度。接下来讨论空间复杂度,就容易多了。 因为每个人实现的算法的具体过程不一样,空间复杂度的情况也比较多。这里笔者只讨论上面的案例代码的空间复杂度,上面算法案例是比较普遍的实现过程,其中的分割(partition)也被称为 in-place partition.  

 

4.1 最优空间复杂度

在平均情况下,一个数列是会被左右等分的。通过观察partition函数,我们可以知道 partition 分配的额外变量是一个常数,也就是说不会随着n的规模(lo 到 hi 的个数)的改变而改变。

上面只分配了一个额外的变量key( 这里不考虑参数),其实即使算上参数消耗,消耗的空间也是固定的(因为参数中的变量大小和输入数组的规模几乎无关系),因此Partition所需要的额外空间是O(1). 

到这里我们知道了,快排的空间消耗几乎都来自于栈递归,因此我们只需要知道递归栈的深度就可以了。

总的空间复杂度 = 每次压栈所需的空间 * 总的递归栈的深度

总的递归深度,每一次在平均情况下,所有的数列都会被分为相等的两部分(n/2)。这里假设栈的深度为K,也就是在深度为K时,当前数列的长度为1。 可以得到下面的式子:

n (1 / 2)k = 1

k = log2n

 因此,总的空间复杂度就是:

总的空间复杂度 = 每次压栈所需的空间 * 总的递归栈的深度

                           = O(1) * O(log2n)

                        = O(logn)

 

因此,最优空间复杂度就是O(logn)

4.2 最差空间复杂度

 上面讨论过了最优空间复杂度,接下来继续分析最差空间复杂度。最糟糕的情况,就是所有的数全分到一边去了,而另一边完全没有数据。这种情况下,时间复杂度为O(n2)。这里我们来继续分析它的最差空间复杂度。

 

最差的情况下,程序递归的深度就是O(n),递归的次数就等于递归的深度, 而每次递归分配的空间是恒定的,所以我们可以得出:

最差空间复杂度  =  单次所需要的空间 * 递归的深度

                           =  O(1) * O(n)

                           =  O(n)

因此,最差空间复杂度就是O(n).

 

4.3 平均空间复杂度

其实,在平均情况下,递归的次数为logn。可以这样来理解,因为平均时间复杂度是nlogn,而且每次partition消耗的时间的几乎就是n, 所以可以得出递归的次数就是logn

由于递归的深度是不确定的,因为它既有可能是二均分,也有可能是所有元素都分到一边去了,也有可能是其他的情况。所以这里可以将递归次数理解为递归深度,都是logn

 

平均空间复杂度 = 单次所需要的空间 * 递归的深度

                          = O(1) * O(logn)

                          = O(logn)

因此,平均间复杂度就是O(logn).

 

 

posted @ 2017-05-19 00:21  HDWK  阅读(3090)  评论(3编辑  收藏  举报