<排序算法三>轻松理解“快速排序”

目录

​​​​​​​​编辑

1.导入:快排与分治

2.展望快排

3.基准元素选择

4.元素的交换

4.1双边循环法

4.2挖坑法

 4.3单边循环法

5.脚踢快排

6.快排的优化


1.导入:快排与分治

冒泡排序大家都知道,通过不断的比较和交换位置来达到排序的目的,属与交换排序的一种,只不过效率十分感人就对了。

这时候就不得不提及另一个被誉为20世纪十大算法之一的快速排序算法了。作为从冒泡排序演变而来的一种算法,它不仅一改冒泡排序低效的面貌,而且在算法思想上更是“遥遥领先”,因为其使用了分治法。

快排的思想是这样的:任取待排序元素序列中的某元素作为基准值,按照该基准值将待排序集合分割成两子序列,使左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

这种将不断将大问题转换成若干个小问题的思想就是分治法,下面通过图片来加深一下理解:

中分不穿背带,戏说不是胡说。像是不加以改变,直接在数据集中拆分再拆分,这种“分治”是没有任何意义的,即便分成再多份也没有意义。

发明者Tony Hoare先生的分治法是将“排序数据集”这个这大个问题拆分成了无数个“使元素满足为左右值的中间值”这一小问题来解决,这样就容易的多了。

分治法下的快排是这样的:

如图,当不能再细分的时候每一组仅有一个元素,都满足元素左右值的中间值,此时排序成功。

那么分治了之后快排能比冒泡排序快多少呢?

在大量数据情况下,我们忽略基准元素不参与下一轮排序带来的误差,当元素数为n时,因为每一轮所有元素都要遍历一遍,所以一轮的时间复杂度就为O(N)。

接着我们以堆或者二叉树的视角来分析轮数,对于同样有n个元素的满二叉树,树高度为log(n+1),由此我们认为平均情况下快排需要log(n)轮。

已知快排的时间复杂度=轮数 * 每一轮的时间复杂度=O(N*log(N))

(注:一般情况下)

2.展望快排

快排的基本逻辑是“以基准元素分割数据集-->分割左序列-->分割右序列”的循环过程,由此我们可以得到代码:

    public void quickSort(int[] array) {
        //为方便封装在qSort方法中实现
        qSort(array, 0, array.length-1);
    }
    
    public void qSort(int[] array, int left, int right) {
        //当left = right说明array内仅有一个元素,无需操作
        //left > right证明上一轮的基准元素没有左序列或者右序列
        if(left >= right) {
            return;
        }
        //将array一分为二,并返回基准元素下标(还未实现)
        int index = partition(array,left,right);
        //分割左序列
        qSort(array,left,index-1);
        //分割右序列
        qSort(array,index+1,right);
    }

相信经过导入部分 代码并不难理解,唯一算得上难点的是递归的循环结束条件。

上图中序列{3,1,2}执行完partition方法后基准元素3分割序列,在执行完分割左序列的qSort方法后开始执行分割右序列的qSort方法,因为partition方法返回值index=right,index+1>right,所以当分割右序列的qSort方法执行后满足left>=right退出方法。

除了上述情况还有当基准元素左侧无序列的情况和仅有基准元素的情况。当元素左侧无序列时,index=left,index-1<left,分割右序列的qSort方法执行后满足left>=right退出方法;当仅有基准元素时,left=right=right,同样直接退出。

至此所有情况均已囊括。

3.基准元素选择

在分治中,基准元素为中心,其他元素被移至两侧,在完成剩下代码前,有一个问题需要解决,那就是如何选择基准值。

最简单的方式是选择数据集中的第一个元素。

乍一看似乎没啥问题,在图例中选择5作为基准值再合适不过了,不过既然将基准元素选择单拎出来,那必然是有极端情况需要单独说明。

例如,当数据集逆序时。

再继续呢?

过程中每一轮都仅有一个序列,并没满足分治要求,依据导入时的思路,时间复杂度=轮数 * 每一轮的时间复杂度,在单轮时间复杂度不变的情况下,轮数变为了n

也就是说,时间复杂度=轮数 * 每一轮的时间复杂度=O(N^2)

相较于更快的O(N*log(N)),我们应该尽量避免O(N^2)这种根本无法发挥分治法优势情况的出现。

解决方案可以是随机选择一个元素作为基准元素,让这个元素和首元素互换

原理:

情况①逆序情况下:

原首元素为最大值概率为1/n%,随机选择基准元素后首元素为最大值概率为1/n%

原O(N^2)概率为100%,随机选择基准元素后O(N^2)概率为(1/n)^n%

情况②非逆序情况下:

原首元素为最大值概率为1/n%,随机选择基准元素后首元素为最大值概率为1/n%

4.元素的交换

确定基准元素后,还要把其他元素中小于基准元素的移动到一侧,大于基准基准元素的移动到另一侧,常见的方法有发明者Hoare的双边循环法,经常出现的挖坑法,以及单边循环法……博主这里仅介绍双边循环发和挖坑法。

4.1双边循环法

双边循环法,总体思路就是设置两个变量left和right表示序列的范围,先通过不断向左移动right找到小(大于)于基准元素值的元素,再向右移动left找到大(小)于基准值的元素位置,接着使两者互换;再次向左移动right找到小(大于)于基准元素值的元素……重复上述步骤,直至left和right相遇,相遇后将首元素(基准元素)与该位置元素互换。

如有数组array={4,7,6,5,3,2,8,1}

先移动right,判断array[right]是否小于array[0],若小于,则right不再移动,开始移动left;若大于,则right--,向左移动一位。此时right<4,right不变,开始移动left……

left=array[0],此时不做比较,left++与下一位作比较,7>array[0],left移动到合适位置,array[left]和array[right]互换。

交换后,使两个不满足条件的位置满足了条件。

接着重复移动的过程,right找到2,left找到6,两者交换——>right找到3,left找到5——>right找到5,此时left=right,两者相遇,使该位置与首元素(基准元素)互换。(下同将通过绿色和橙色来表现满足条件部分的变化)

left和right相遇,将该位置与首元素互换,分割完成

代码实现:

    public static void swap(int[] array, int s, int k) {
        int cur = array[s];
        array[s] = array[k];
        array[k] = cur;
    }
    public int partition(int[] array, int startIndex, int endIndex) {
        int left = startIndex;
        int right = endIndex;
        int pivot = array[startIndex];
        
        while(left < right) {
            //right左移
            while(left<right && array[right] >= pivot) {
                right--;
            }
            //left右移
            while(left<right && array[left] <= pivot) {
                left++;
            }
            //交换
            if(left != right)
            swap(array,left,right);
        }
        
        //left和right重合位置和首元素交换
        swap(array,startIndex,left);
        
        //返回基准元素位置
        return left;
    }
4.2挖坑法

物理世界中挖坑是一个挖与填的的过程,信息世界中则完全不必如此,挖不需要将该位置的元素完全删除,只需要将其从copy出来就行,填的话直接用其他元素的值对其进行覆盖即可。

挖坑法,是双边排序法的改进型,它的总体思路也是设置两个变量left和right表示序列的范围,首先将基准元素的值存储起来,接着通过不断向左移动right找到小(大于)于基准元素值的元素,使其覆盖left位置,再向右移动left找到大(小)于基准值的元素位置,使其覆盖right位置,重复该过程,不断移动交换,当left和right相遇时直接以存储的基准值覆盖此位置。

同样一个数组array={4,7,6,5,3,2,8,1}

先将首元素的值存入pivot,接着左移right找到首个小于pivot的元素1,使其覆盖left位置,即array[0] = array[7];在右移left找到首个大于pivot的元素7,使其覆盖right位置array[1] = array[7]

接下来是不断重复的过程

最后,让pivot覆盖相遇位置

代码实现:

    private int partition2(int[] array, int startIndex, int endIndex) {
        int left = startIndex;
        int right = endIndex;
        int pivot = array[startIndex];
        while (left < right) {
            while (left < right && array[right] >= pivot) {
                right--;
            }
            array[left] = array[right];
            while (left < right && array[left] <= pivot) {
                left++;
            }
            array[right] = array[left];
        }
        array[left] = pivot;
        return left;
    }
4.3单边循环法
    private static int partition(int[] array, int left, int right) {
        int prev = left ;
        int cur = left+1;
        while (cur <= right) {
            if(array[cur] < array[left] && array[++prev] != array[cur]) {
                swap(array,cur,prev);
            }
            cur++;
        } 
        swap(array,prev,left);
        return prev;
    }

5.脚踢快排

“你已完成所有训练内容,快去拯救世界吧!!”

开个玩笑快排的所有代码我们都已实现,可以试着自己敲一便加深理解。

    public void quickSort(int[] array) {
        //为方便封装在qSort方法中实现
        qSort(array, 0, array.length-1);
    }

    public void qSort(int[] array, int left, int right) {
        //当left = right说明array内仅有一个元素,无需操作
        //left > right证明上一轮的基准元素没有左序列或者右序列
        if(left >= right) {
            return;
        }
        //将array一分为二,并返回基准元素下标
        int index = partition1(array,left,right);
        //分割左序列
        qSort(array,left,index-1);
        //分割右序列
        qSort(array,index+1,right);
    }

    public void swap(int[] array, int s, int k) {
        int cur = array[s];
        array[s] = array[k];
        array[k] = cur;
    }
    public int partition1(int[] array, int startIndex, int endIndex) {
        int left = startIndex;
        int right = endIndex;
        int pivot = array[startIndex];

        while(left < right) {
            //right左移
            while(left<right && array[right] >= pivot) {
                right--;
            }
            //left右移
            while(left<right && array[left] <= pivot) {
                left++;
            }
            //交换
            if(left != right)
            swap(array,left,right);
        }

        //left和right重合位置和首元素交换
        swap(array,startIndex,left);

        //返回基准元素位置
        return left;
    }


    private int partition2(int[] array, int startIndex, int endIndex) {
        int left = startIndex;
        int right = endIndex;
        int pivot = array[startIndex];
        while (left < right) {
            while (left < right && array[right] >= pivot) {
                right--;
            }
            array[left] = array[right];
            while (left < right && array[left] <= pivot) {
                left++;
            }
            array[right] = array[left];
        }
        array[left] = pivot;
        return left;
    }
    private int partition3(int[] array, int left, int right) {
        int prev = left ;
        int cur = left+1;
        while (cur <= right) {
            if(array[cur] < array[left] && array[++prev] != array[cur]) {
                swap(array,cur,prev);
            }
            cur++;
        }
        swap(array,prev,left);
        return prev;
    }

6.快排的优化

在“基准元素选择”这一部分提到过,以分治法排序,当数据集逆序时,那么每一轮仅有一个序列,此时轮数会达到峰值n,即有多少元素就要进行多少轮分治。

当数据量足够大时,我们以递归实现的快排方法就会创建非常多的栈帧来保存每一轮的数据,最后很可能会有栈溢出的情况出现。

栈溢出问题在递归方法中屡见不鲜,除了以上极端情况下,即便是最接近均匀的“满二叉树”情况下也依旧有可能发生。不管怎么样,我们为了降低栈溢出问题出现的概率完全可以从降低轮数(递归的次数)方向来优化。

那么如何降低轮数呢?

我们的分治法是以基准元素为中心,其他元素被移至两侧。单侧元素越多,需要递归的次数就越多,总体轮数就越大。

这样一来,优化方向就很清晰了:

尽量让每个序列两侧元素分布的更均匀,并在理想情况下达到左右侧元素数目一致(“类满二叉树”),具体可以使用三数取中法。

三数取中法,即在首元素,中间元素,末尾元素中选出中间大小的元素,使中间大小的元素和首元素互换位置。

正常情况下,上图序列{1,7,6,5,3,2,8,4}的基准元素1划分后只能分出一个序列,经过三数取中法后左右侧元素分布明显更加均匀,而且这种方法会让两侧都至少有一个元素(三个以上元素的序列)。

优化版如下:

    public int getMiddle(int[] array, int startIndex, int endIndex) {
        int midIndex = (startIndex + endIndex) / 2;
        if(array[startIndex] < array[endIndex]) {
            if(array[midIndex] < array[startIndex]) {
                return startIndex;
            } else if(array[midIndex] > array[endIndex]) {
                return endIndex;
            } else {
                return midIndex;
            }
        } else {
            if(array[midIndex] < array[endIndex]) {
                return endIndex;
            } else if(array[midIndex] > array[startIndex]) {
                return startIndex;
            } else {
                return midIndex;
            }
        }
    }
    private int partition(int[] array, int startIndex, int endIndex) {
        int left = startIndex;
        int right = endIndex;
        //获取中间大小元素下标
        int mid = getMiddle(array, startIndex, endIndex);
        //交换中间大小元素和首元素位置
        swap(array, mid, startIndex);
        int pivot = array[startIndex];
        while (left < right) {
            while (left < right && array[right] >= pivot) {
                right--;
            }
            array[left] = array[right];
            while (left < right && array[left] <= pivot) {
                left++;
            }
            array[right] = array[left];
        }
        array[left] = pivot;
        return left;
    }

别看增加了不少代码,但也就多了两次判断一次交换罢了,没有影响整体的时间复杂度。

除了三数取中法,还有一种方法可以减少递归次数,而且两种方法可以配合使用。

我们先把之前的一张图搞过来。

从图中我们可以看出,越靠近底层划分出的序列越多,倒数第一轮的序列数为n的话,前面所有轮数出现的所有序列加起来的数量都还要比n少一,写出快排代码的我们知道,这里的每一个序列都代表着一次递归,若是可以的话,我们可不可以不去做这些递归,而是通过其他方法来解决呢?

当然可以,我们可以把right-left小于一定数量的序列用直接插入排序来解决,因为直接插入排序更擅长解决更有序、更小量的排序。

优化代码如下:

    public void insertSort( int[] array , int left, int right) {
        for(int i = left; i <= right; i++) {
            int kmp = array[i];
            int j = i-1;
            for( ; j >= 0; j--) {
                if(kmp < array[j]) {
                    array[j+1] = array[j];
                } else {
                    break;
                }
            }
            array[j+1] = kmp;
        }
    }

    public void quickSort(int[] array) {
        qSort(array, 0, array.length-1);
    }

    public void qSort(int[] array, int left, int right) {
        if(left >= right) {
            return;
        }
        //当序列元素数<15,直接使用直接插入排序完成排序后退出
        if(left-right < 15) {
            insertSort(array,left,right);
            return;
        }
        int index = partition(array,left,right);
        qSort(array,left,index-1);
        qSort(array,index+1,right);
    }

博主是Java新人,每位同志的支持都会给博主莫大的动力,如果有任何疑问,或者发现了任何错误,都欢迎大家在评论区交流“ψ(`∇´)ψ

本文的参考,书籍《漫画算法:小灰的算法之旅》和《大话数据结构》,以及俺的老师

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值