C++编写经典算法之一:快速排序QucikSort(通俗易懂)

快速排序

  • “快速排序”是数列排序的算法之一。
  • 与其他的算法相比,它的特点是数字的比较和交换次数少,在许多情况下可以高速地进行排序。
  • 其思路引点来自于我们打牌中的一种排序方法:我们在抽牌时,可以先把所有的牌抽起来,然后选定一个牌,把比它小的牌都有序地放在左面,把比它大的牌都有序地放在右面,从而实现整理牌组的目的。
  • 快速排序则是优化了上述的过程,利用了分治的思想(上文《归并排序》已经解释过了,不懂的可以去补一补)。让我们来看看实际的算法流程。

一、算法思路

有如下数列:
在这里插入图片描述

在这里插入图片描述
第一个操作的对象是数列中的所有的数字。
选择一个数字作为排序的基数。这个数字被称为pivot。
pivot通常随机选择一个数字。为了方便起见,我们选择最右边的数字作为pivot。

在这里插入图片描述

为了清楚起见,我们将在pivot上做一个标记。
接下来,在最左边的数字上标记,称为左标记。同理,在最右边的数字上标记,称为右标记。

  • 快速排序是一种使用这些标记递归地重复一系列操作的算法。

在这里插入图片描述

我们将左边的标记向右移动。,直到左标记达到的数超过了pivot所标记的数字时,停止移动。
在这里插入图片描述

这一次,因为8>=6而停止了移动。

然后,将右标记向左移动。当右标记达到小于的数字时,停止移动。
在这里插入图片描述
在这里插入图片描述

这一次,由于4<6,右标记停止了移动。

在这里插入图片描述

当左右侧标记停止时,将左右标记所指的数字进行交换。

  • 因此,左标记的作用是找到一个大雨pivot的数字,右标记的作用是找到一个小于pivot的数字。通过交换数字,可以在数列的左侧收集小于pivot的数字,右侧收集大雨pivot的数字。

交换之后,再次将左标记向右移动。

在这里插入图片描述

和之前一样,当左边的标记移动到大于或等于pivot的数字时停止移动。

这一次,由于9>=6,标记停止移动。

继续将右标记向左移动。

在这里插入图片描述

当右标记碰撞到左标记时也会停止移动。

在这里插入图片描述

如果左右都停止了,并标记在同一位置上,就将这个数字和pivot的数字交换。

在这里插入图片描述

将有左右侧标记的数字认为已排序完成。

这就完成了第一次操作。

通过一系列的操作,我们就把比pivot的数字小的数字放在了pivot的左边,同理,右面是比它大的数字。

然后,我们递归地将这个数列分成左右区,进行同样一系列的操作即可。

在这里插入图片描述

接下来,我们将操作左区的数列,

在这里插入图片描述

我们依旧建立3个标记。和之前一样进行操作。

  • 但! 不要跑开,接下里才是快速排序最需要注意的地方!!!

在递归进行上述操作后,会得到部分排序的数列:

在这里插入图片描述

在这里插入图片描述

对左区进行操作。如果操作的数列只有一个数字,则认为它已经完成了排序。

在这里插入图片描述

第二轮将对右区的数列进行操作。

在这里插入图片描述

在这里插入图片描述

同样,建立3个标记。

在这里插入图片描述

将左标记向右移动。

在这里插入图片描述

当左标记和右标记碰撞时,即左标记撞击右标记,是不会停止移动的,它在这方面与右标记不同。当左标记达到数列的最右边时才停止移动。这意味着pivot数字是这个区的最大的数字。

接下里,移动右标记。

在这里插入图片描述

但如果它被路过的左标记所标记,则完成操作并不进行移动。

在这里插入图片描述

如果左标记已达到数列的最右边时,pivot数字被认为已排序完成,并完成这一轮的操作。

此后,重复相同的操作,直到所有的数字完成排序。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

所以,我们必须特别注意:

  • 左标记的停止判定条件:碰撞了pivot或遇到大于等于pivot所指的数字
  • 右标记的停止判断条件:碰撞了左标记或遇到小于pivot所指的数字

不注意这点,会犯很多错误。

二、动画演示

在这里插入图片描述

三、代码清单及其测试结果

#include <iostream>
template <class T>

int getSizeOfArray(T& bs){
    return sizeof(bs)/ sizeof(bs[0]);
}

/*
 * Method swapData can swap two data by index from initial array.
 * swapData方法可以通过初始数组的索引将所指数据交换。
 */
void swapData(int *qs,int one,int another){
    int cup = 0;
    cup = qs[one];
    qs[one] = qs[another];
    qs[another] = cup;
}

/*
 * Method quickSort can sort array by QuickSort.
 * quickSort可以利用快速排序算法将数组排序。
 */
void quickSort(int *qs,int startIndex,int endIndex){
    //非单位区间进行分治
    if(startIndex<endIndex){
        //设置pivot(这个本身是个随机数,为了方便写算法,先用最右边的数)
        int pivot = endIndex;//初始化基数
        int leftIndex = startIndex;//初始化当前分治区域的左索引
        int rightIndex = pivot-1;//初始化当前分治区域的右索引
        int drFlag = 0;//分治标记,因算法使用while循环,需要中断标记

        while(leftIndex<=rightIndex && drFlag!=1){//当左索引越位右索引或分治标记亮起,退出循环
            //左标记启动
            while(qs[leftIndex]<qs[pivot]&&leftIndex!=pivot){//当左索引找到一个大于等于基数的数或到达最右索引时,停止
                leftIndex++;
            }
            //右标记启动
            while(qs[rightIndex]>qs[pivot]&&leftIndex<rightIndex){//当右索引找到一个小于等于基数当数或被左索引标记过时,停止
                rightIndex--;
            }

            if(leftIndex==rightIndex){//当左索引与右索引重合时,当前数据与基数交换位置,实现分区(左区均小于等于基数,右区均大于等于基数)
                //将撞击索引的数字与pivot索引的数字交换
                swapData(qs,leftIndex,pivot);
                drFlag = 1;//下一步进行分治,亮起分治标记,退出while循环
            }
            else if(leftIndex<rightIndex){
                //左标记右标记数字交换
                swapData(qs,leftIndex,rightIndex);
                leftIndex++;//继续移动
            }
        }

        //左区分治
        quickSort(qs,startIndex,leftIndex-1);
        //右区分治
        quickSort(qs,rightIndex+1,endIndex);
    }
}

int main() {
    using namespace std;
//2,3,5,1,0,8,6,9,7
    int qs[] = {2,3,5,1,0,8,6,9,7};
    int size = getSizeOfArray(qs);

    cout<< "原数列:";

    for(int i = 0;i<size;i++)
    {
        cout<< qs[i] << " ";
    }

    cout<< "\n" << "快速排序后:";

    quickSort(qs,0,size-1);

    for(int i = 0;i<size;i++)
    {
        cout<< qs[i] << " ";
    }

    return 0;
}

在这里插入图片描述

四、算法分析

这里为方便起见,我们假设算法Quick_Sort的范围阈值为1(即一直将线性表分解到只剩一个元素),这对该算法复杂性的分析没有本质的影响。
我们先分析函数qucikSort的性能,该函数对于确定的输入复杂性是确定的。观察该函数,我们发现,对于有n个元素的确定输入L[p…r],该函数运行时间显然为θ(n)。
"

  • 最坏情况
    无论适用哪一种方法来选择pivot,由于我们不知道各个元素间的相对大小关系(若知道就已经排好序了),所以我们无法确定pivot的选择对划分造成的影响。因此对各种pivot选择法而言,最坏情况和最好情况都是相同的。
    我们从直觉上可以判断出最坏情况发生在每次划分过程产生的两个区间分别包含n-1个元素和1个元素的时候(设输入的表有n个元素)。下面我们暂时认为该猜测正确,在后文我们再详细证明该猜测。
    对于有n个元素的表L[p…r],由于函数qucikSort的计算时间为θ(n),所以快速排序在序坏情况下的复杂性有递归式如下:
    T(1)=θ(1),T(n)=T(n-1)+T(1)+θ(n) (1)
    用迭代法可以解出上式的解为T(n)=θ(n2)。
    这个最坏情况运行时间与插入排序是一样的。
    下面我们来证明这种每次划分过程产生的两个区间分别包含n-1个元素和1个元素的情况就是最坏情况。
    设T(n)是过程Quick_Sort作用于规模为n的输入上的最坏情况的时间,则
    T(n)=max(T(q)+T(n-q))+θ(n),其中1≤q≤n-1 (2)
    我们假设对于任何k<n,总有T(k)≤ck,其中c为常数;显然当k=1时是成立的。
    将归纳假设代入(2),得到:
    T(n)≤max(cq2+c(n-q)2)+θ(n)=c*max(q2+(n-q)2)+θ(n)
    因为在[1,n-1]上q2+(n-q)2关于q递减,所以当q=1时q2+(n-q)2有最大值n2-2(n-1)。于是有:
    T(n)≤cn2-2c(n-1)+θ(n)≤cn2
    只要c足够大,上面的第二个小于等于号就可以成立。于是对于所有的n都有T(n)≤cn。
    这样,排序算法的最坏情况运行时间为θ(n2),且最坏情况发生在每次划分过程产生的两个区间分别包含n-1个元素和1个元素的时候。
    时间复杂度为o(n2)。
  • 最好情况
    如果每次划分过程产生的区间大小都为n/2,则快速排序法运行就快得多了。这时有:
    T(n)=2T(n/2)+θ(n),T(1)=θ(1) (3)
    解得:T(n)=θ(nlogn)
    快速排序法最佳情况下执行过程的递归树如下图所示,图中lgn表示以10为底的对数,而本文中用logn表示以2为底的对数.
    由于快速排序法也是基于比较的排序法,其运行时间为Ω(nlogn),所以如果每次划分过程产生的区间大小都为n/2,则运行时间θ(nlogn)就是最好情况运行时间。
    但是,是否一定要每次平均划分才能达到最好情况呢?要理解这一点就必须理解对称性是如何在描述运行时间的递归式中反映的。我们假设每次划分过程都产生9:1的划分,乍一看该划分很不对称。我们可以得到递归式:
    T(n)=T(n/10)+T(9n/10)+θ(n),T(1)=θ(1) (4)
    请注意树的每一层都有代价n,直到在深度log10n=θ(logn)处达到边界条件,以后各层代价至多为n。递归于深度log10/9n=θ(logn)处结束。这样,快速排序的总时间代价为T(n)=θ(nlogn),从渐进意义上看就和划分是在中间进行的一样。事实上,即使是99:1的划分时间代价也为θ(nlogn)。其原因在于,任何一种按常数比例进行划分所产生的递归树的深度都为θ(nlogn),其中每一层的代价为O(n),因而不管常数比例是什么,总的运行时间都为θ(nlogn),只不过其中隐含的常数因子有所不同。(关于算法复杂性的渐进阶,请参阅算法的复杂性)
  • 平均情况
    快速排序的平均运行时间为θ(nlogn)。
    我们对平均情况下的性能作直觉上的分析。
    要想对快速排序的平均情况有个较为清楚的概念,我们就要对遇到的各种输入作个假设。通常都假设输入数据的所有排列都是等可能的。后文中我们要讨论这个假设。
    当我们对一个随机的输入数组应用快速排序时,要想在每一层上都有同样的划分是不太可能的。我们所能期望的是某些划分较对称,另一些则很不对称。事实上,我们可以证明,如果选择L[p…r]的第一个元素作为支点元素,qucikSort所产生的划分80%以上都比9:1更对称,而另20%则比9:1差,这里证明从略。
    平均情况下,qucikSort产生的划分中既有“好的”,又有“差的”。这时,与Partition执行过程对应的递归树中,好、差划分是随机地分布在树的各层上的。为与我们的直觉相一致,假设好、差划分交替出现在树的各层上,且好的划分是最佳情况划分,而差的划分是最坏情况下的划分。在根节点处,划分的代价为n,划分出来的两个子表的大小为n-1和1,即最坏情况。在根的下一层,大小为n-1的子表按最佳情况划分成大小各为(n-1)/2的两个子表。这儿我们假设含1个元素的子表的边界条件代价为1。
    在一个差的划分后接一个好的划分后,产生出三个子表,大小各为1,(n-1)/2和(n-1)/2,代价共为2n-1=θ(n)。一层划分就产生出大小为(n-1)/2+1和(n-1)/2的两个子表,代价为n=θ(n)。这种划分差不多是完全对称的,比9:1的划分要好。从直觉上看,差的划分的代价θ(n)可被吸收到好的划分的代价θ(n)中去,结果是一个好的划分。这样,当好、差划分交替分布划分都是好的一样:仍是θ(nlogn),但θ记号中隐含的常数因子要略大一些。关于平均情况的严格分析将在后文给出。
    在前文从直觉上探讨快速排序的平均性态过程中,我们已假定输入数据的所有排列都是等可能的。如果输入的分布满足这个假设时,快速排序是对足够大的输入的理想选择。但在实际应用中,这个假设就不会总是成立。
    解决的方法是,利用随机化策略,能够克服分布的等可能性假设所带来的问题。
    一种随机化策略是:与对输入的分布作“假设”不同的是对输入的分布作“规定”。具体地说,在排序输入的线性表前,对其元素加以随机排列,以强制的方法使每种排列满足等可能性。事实上,我们可以找到一个能在O(n)时间内对含n个元素的数组加以随机排列的算法。这种修改不改变算法的最坏情况运行时间,但它却使得运行时间能够独立于输入数据已排序的情况。
    另一种随机化策略是:利用前文介绍的选择支点元素pivot的第四种方法,即随机地在L[p…r]中选择一个元素作为支点元素pivot。实际应用中通常采用这种方法。
    快速排序的随机化版本有一个和其他随机化算法一样的有趣性质:没有一个特别的输入会导致最坏情况性态。这种算法的最坏情况性态是由随机数产生器决定的。你即使有意给出一个坏的输入也没用,因为随机化排列会使得输入数据的次序对算法不产生影响。只有在随机数产生器给出了一个很不巧的排列时,随机化算法的最坏情况性态才会出现。事实上可以证明几乎所有的排列都可使快速排序接近平均情况性态,只有非常少的几个排列才会导致算法的近最坏情况性态。
    一般来说,当一个算法可按多条路子做下去,但又很难决定哪一条保证是好的选择时,随机化策略是很有用的。如果大部分选择都是好的,则随机地选一个就行了。通常,一个算法在其执行过程中要做很多选择。如果一个好的选择的获益大于坏的选择的代价,那么随机地做一个选择就能得到一个很有效的算法。我们在前文已经了解到,对快速排序来说,一组好坏相杂的划分仍能产生很好的运行时间 [2] 。因此我们可以认为该算法的随机化版本也能具有较好的性态。
    "
    引用来自:

https://download.csdn.net/download/lishuyilily/3134423

这里的算法分析有点复杂,作者引用了书中的分析。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值