算法系列(三):快速排序

199 阅读8分钟

快速排序介绍:

快速排序(Quicksort)是对冒泡排序的一种改进。 它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列.

快速排序的过程:

设要排序的数组是A[0]……A[N-1],首先任意选取一个数据(通常选用数组的第一个数)作为关键数据,然后将所有比它小的数都放到它前面,所有比它大的数都放到它后面,这个过程称为一趟快速排序。值得注意的是,快速排序不是一种稳定的排序算法,也就是说,多个相同的值的相对位置也许会在算法结束时产生变动。 一趟快速排序的算法是:

  1. 设置两个变量i、j,排序开始的时候:i=0,j=N-1;
  2. 以第一个数组元素作为关键数据,赋值给key,即key=A[0];
  3. 从j开始向前搜索,即由后开始向前搜索(j--),找到第一个小于key的值A[j],将A[j]和A[i]互换;
  4. 从i开始向后搜索,即由前开始向后搜索(i++),找到第一个大于key的A[i],将A[i]和A[j]互换;
  5. 重复第3、4步,直到i=j; (3,4步中,没找到符合条件的值,即3中A[j]不小于key,4中A[i]不大于key的时候改变j、i的值,使得j=j-1,i=i+1,直至找到为止。找到符合条件的值,进行交换的时候i, j指针位置不变。另外,i==j这一过程一定正好是i+或j-完成的时候,此时令循环结束)。

z取数组最右边的值为枢纽值,代码如下:

package com.nineweeks.simplesort;

/**
 * 快速排序 最右边值定枢纽法
 *
 * 时间复杂度:
 *    快速排序的速度要快于冒泡排序,特别是数据交换代价较高时,快速排序
 *    的有一部分为复制,而非交换,速度优于冒泡排序
 *    O(n*logn)
 * 重点在于 枢纽的选择,分两种:
 *     (1) 数组最右边的值充当枢纽
 *     (2) 三数据项取中, 取最左端,最右端,和中间的三个值,取中值
 * 快速排序运用分治法,每分割一次,枢纽左边的值,都小于枢纽的值,右边的值都大于枢纽的值
 * 基本条件:
 *     当分割的子数组长度为1时,已为有序数组
 *
 * @author NineWeek
 * @date 2020/05/07 16:30
 **/
public class QuickSort {

    private int[] theArray;
    private int nElems;

    public QuickSort(int max){
        theArray = new int[max];
        nElems = 0;
    }

    public void insert(int value){
        theArray[nElems++] = value;
    }

    /**
     * 数组的展示方法
     */
    public void display(){
        for (int i = 0; i < nElems ; i++) {
            System.out.print(theArray[i]+" ");
        }
        System.out.println();
    }

    public void quickSortFunction(){
        recQuickSort(0,nElems-1);
    }

    private void recQuickSort(int left, int right) {
        if (right <= left){
            return;
        }else{
            //获取枢纽值
            int pivot = theArray[right];
            //对当前数组进行分类和交换,使其左边的值都小于枢纽值,右边的值都大于枢纽值
            int partition = partitionInt(left,right,pivot);
            //此时枢纽值已经在正确的位置上了,故无需在排序此值
            //对左半边的数组进行递归啊用
            recQuickSort(left,partition-1);
            //对右半边的数组进行递归调用
            recQuickSort(partition+1,right);
        }
    }

    public int partitionInt(int left,int right,int pivot){
        int leftPtr = left -1;
        int rightPtr = right;
        //从子数组的两端向中间移动
        while(true){
            while (theArray[++leftPtr] < pivot){
                //如果左边的值比枢纽值小,则什么都不做
            }
            while (rightPtr > 0 && theArray[--rightPtr] > pivot){
                //如果右边的值比枢纽值大,则什么也不做
            }
            if(leftPtr >= rightPtr){
                //已经遍历和比较子数组的所有元素
                break;
            }else {
                // 交换左右两边不符合的当前子数组大小规则的元素
                swap(leftPtr,rightPtr);
            }
        }
        //交换所选定的枢纽值(此时还在最右端,移动到左右两个数组的交汇处)
        //使其真正的成为分割两个子数组的定位值,左边的数组值都小于它,
        //右边的数组值都大于它
        swap(leftPtr,right);
        //返回枢纽值当前的坐标
        return leftPtr;
    }

    public void swap(int dex1,int dex2){
        int temp = theArray[dex1];
        theArray[dex1] = theArray[dex2];
        theArray[dex2] = temp;
    }

    public static void main(String[] args){

        int maxSize = 16;
        QuickSort sort = new QuickSort(maxSize);
        for(int i=0;i<maxSize;i++){
            int n = (int)(Math.random()*99);
            sort.insert(n);
        }
        sort.display();
        sort.quickSortFunction();
        sort.display();
    }
}

输出结果:

三数据项取中值法,代码如下:

package com.nineweeks.simplesort;

/**
 * 快速排序 三数据项取中法
 *
 * 解决问题:
 *     当数组为逆序排列时,用最右边值定枢纽法,会致使
 *     每次划分的一个子数组只有一个数据项,另一个数组
 *     有N-1项数据,致使运行的时间复杂度上升为O(n2)
 *
 * @author NineWeek
 * @date 2020/05/07 16:51
 **/
public class QuickSort1 {
    private int[] theArray;
    private int nElems;

    public QuickSort1(int max){
        theArray = new int[max];
        nElems = 0;
    }

    public void insert(int value){
        theArray[nElems++] = value;
    }

    /**
     * 数组的展示方法
     */
    public void display(){
        for (int i = 0; i < nElems ; i++) {
            System.out.print(theArray[i]+" ");
        }
        System.out.println();
    }

    public void quickSortFunction(){
        recQuickSort(0,nElems-1);
    }

    public void recQuickSort(int left,int right){
        int size = right-left+1;
        if(size<=3){//判断当前数组的长度是否<=3
            manualSort(left,right);//是的话,因为数据过少,直接排序
        }else{
            long mediam = mediamOf3(left,right);
            int partition = partitionIt(left,right,mediam);
            recQuickSort(left,partition-1);
            recQuickSort(partition+1,right);
        }
    }

    /**
     *  三数据项取中 去数组的最左端,最右端,和中间的值
     *  返回三者中值居中的并且 这三者已经排好了顺序
     *  对左端,中间和右端的数据项排好顺序之后,划分过程
     *  就不需要再考虑这三项,划分可以从left+1,right-1
     *   开始
     * @param left
     * @param right
     * @return
     */
    public long mediamOf3(int left,int right){
        int center = (left+right)/2;

        if(theArray[left] > theArray[center]){
            swap(left,center);
        }
        if(theArray[left]>theArray[right]){
            swap(left,right);
        }
        if(theArray[center]>theArray[right]){
            swap(center,right);
        }

        swap(center,right-1);//把枢纽值放在右边
        return theArray[right-1];
    }

    public int partitionIt(int left,int right,long pivot){
        //此时子数组最左边的值,已比枢纽值小
        int leftPtr = left;
        //此时子数组最右边的值,已经比枢纽值大
        int rightPtr = right-1;
        //从子数组的两端向中间移动
        while(true){
            while(theArray[++leftPtr]<pivot){
                //如果左边的值比枢纽值小,则什么也不做
            }
            while(rightPtr>0 && theArray[--rightPtr]>pivot){
                //如果右边的值比枢纽值大,则什么也不做
            }
            if(leftPtr>=rightPtr){
                //已经遍历和比较子数组的所有元素
                break;
            }else{
                //交换左右两边不符合的当前子数组大小规则的
                swap(leftPtr,rightPtr);
            }
        }
        //交换所选定的枢纽值(此时还在最右端-1的位置,移动到左右两个数组的交汇处)
        //使其真正的成为分割两个子数组的定位值,左边的数组值都小于它,
        //右边的数组值都大于它
        swap(leftPtr,right-1);
        //返回枢纽值当前的坐标
        return leftPtr;
    }

    public void swap(int dex1,int dex2){
        int temp = theArray[dex1];
        theArray[dex1] = theArray[dex2];
        theArray[dex2] = temp;
    }

    public void manualSort(int left,int right){
        int size = right-left+1;
        //备注 处理小划分,当元素个数小于10,20..
        //通过实验不同的分割值找到最好的执行效率
        //例如:小于10时,可以用插入排序进行排序
        if(size<=1){
            return; //只剩一个元素,天然有序
        }
        if(size == 2){
            if(theArray[left] > theArray[right]){
                swap(left,right);
            }
            return;
        }else{
            if(theArray[left] > theArray[right-1]){
                swap(left,right-1);
            }
            if(theArray[left] > theArray[right]){
                swap(left,right);
            }
            if(theArray[right-1]>theArray[right]){
                swap(right-1,right);
            }
        }
    }

    public static void main(String[] args){

        int maxSize = 16;
        QuickSort1 sort = new QuickSort1(maxSize);
        for(int i=0;i<maxSize;i++){
            int n = (int)(Math.random()*99);
            sort.insert(n);
        }
        sort.display();
        sort.quickSortFunction();
        sort.display();
    }
}

输出结果:

算法时间复杂度推导和分析:

最坏的情况:当数组已经排好序或者逆序时,每一次划分,两个子数组,一个没有数据,一个有n-1个数据,此时的时间复杂度公式为:

\begin{align}    
T(n)=T(0)+T(n-1)+\Theta (n)=\Theta(1)+T(n-1)+\Theta (n)=T(n-1)+\Theta (n)  & \text{(分化和记录的时间为Θ(n))}
\end{align}

由上面的式子,可知是一个等差级数,故其时间复杂度为:

\begin{align}  T(n)=\Theta (n^{2})\end{align}

通过递归树推导法,也可以推导出相同的结果,递归树推导公式:

\begin{align}T(n) = T(0)+T(n-1)+cn=\sum_{k=1}^{n}ck+\Theta(n)=\Theta(n^2)\end{align}

最好的情况:每次找到的枢纽值都可以将当前的数组平分,此时的时间复杂度公式为:

\begin{align}T(n)=2T(\frac{n}{2}) +  \Theta(n)\end{align}

由上面的式子,通过主定理或者递归树法推导可得:

\begin{align}T(n)=\Theta(n\lg n )\end{align}

再来考虑一下,每一次的分化恰好都发生在\frac{9}{10}\frac{1}{10}的地方,现在情况怎么样?

**\begin{align}T(n)=T(\frac{9n}{10})+T(\frac{n}{10})+\theta (n)\end{align}

通过代换法和递归图可以看到

\begin{align}T(n)\leq Cn\log\frac{10}{9n}+\theta (n)\end{align}

所以说T(n)小于以\frac{10}{9}为底的n的对数乘以n,也是线性的。

从上面可以得出,在非最差情况下,快速排序的时间都是nlogn的级别,那么再来考虑最差条件和最佳条件交替出现时的情况。 可以得到递归式如下:

\begin{align}L(n)=2U(\frac{n}{2})+\Theta (n)  &&\text{Lucky}\end{align}
\begin{align}U(n)=L(n-1)+\Theta (n)  &&\text{unlucky}\end{align}

由代换法

\begin{align}L(n)=2L\left \{ \left [( \frac{n}{2}-1)+\theta(n)  \right ] +\theta(n)\right \}=2L(\frac{n}{2}-1)+\theta(n)\end{align}

L(n)收敛速度还是以n/2为主,L(n)=θ(nlgn)可以总是保证lucky的结果 所以我们可以采用随机化的方法,选取随机值作为主元,那么快速排序就叫随机化快速排序,

这样的方法可以保证运行的时间不依赖于输入的顺序,无需对输入的排序作任何假设,没有一种输入会导致排序时间最差。

课程最后一部分构造了一个伯努利变量,通过求T(n)的期望通解,得到ET(n)≤anlogn 得到了在一般输入情况下,快速排序算法的时间期望值是nlgn的。