java实现排序(6)-快速排序

引言

快速排序,作为一个编程人员来说,肯定都是接触过的。那么,你还记得怎么去实现么,怎么优化呢?在本篇博文中,会详细介绍快速排序的过程,对于不是唯一的过程(可变或者可选),我们讨论各种优化的方法。笔者目前整理的一些blog针对面试都是超高频出现的。大家可以点击链接:http://blog.csdn.net/u012403290

快速排序

在开始之前,我们先介绍一下快速排序的基本思想:
我们要对S数组进行排序,那么
①如果S数组中的元素个数是0或者1,那么排序结束。单一元素本身就是有序的。
②在S中随机选取一个元素作为枢纽元
③将S除却枢纽元之外的集合遍历与枢纽元进行比较,把比枢纽元小的放在左边,比枢纽元大的放在右边。
④遍历结束之后,就是完成了一趟快速排序。我们递归③步骤中生成的2组数据,重复①②③步骤,直到满足①条件退出为止

下面是对上面步骤的图示:
这里写图片描述

细心的小伙伴会发现,这个随机选取的枢纽元将会决定你下次分组的复杂程度。比如说在7、8、9这个组中,如果选取了7或者9为枢纽元呢?那是不是还需要更多的一步递归?所以说,枢纽元的选取将会直接影响到你的算法的效率,在后面,我们会着重讨论关于枢纽元的选取。

快速排序,和上一篇博文介绍的归并排序,都是一种分治策略的体现,我们都是把一个大的数据拆分成一个更小的数据,直到不能拆分为止,再把所有的结果进行整合,得到最终的结果。

快速排序最容易理解的实现

如果你还是对快速排序不是很理解,那么我们用一组代码实现来进一步帮助你的理解,在该实现中,借用了list集合天然的方法。我们不去考虑这一套算法的性能和效率,因为它必然是低效的。

package com.brickworkers;

import java.util.ArrayList;
import java.util.List;

/**
 * 
 * @author Brickworker
 * Date:2017年5月9日下午3:24:56 
 * 关于类QuickSort.java的描述:快速排序
 * Copyright (c) 2017, brcikworker All Rights Reserved.
 */
public class QuickSort {

    //最容易理解的快速排序
    //我们以int排序为例,方便起见,就不像以前一样实现对象的compareTo方法了
    public static void sort(List<Integer> list){
        //递归结束条件
        if(list.size() > 1){//上面说的,当数据量为1或者0的时候结束递归
            //建立三个集合,分别表示小于枢纽元,大于枢纽元和等于枢纽元
            List<Integer> smallerList = new ArrayList<Integer>();
            List<Integer> largerList = new ArrayList<Integer>();
            List<Integer> sameList = new ArrayList<Integer>();


            //选取一个随机值作为枢纽元,在我们学习过程中,我们通常把第一个数作为枢纽元
            Integer pivot = list.get(0);

            //遍历list,把比pivot小的放smallerList中,比pivot大的放largerList中,相等的放sameList中
            for (Integer integer : list) {
                if(integer < pivot){
                    smallerList.add(integer);
                }
                else if(integer > pivot){
                    largerList.add(integer);
                }else{
                    sameList.add(integer);
                }
            }

            //递归实现分组后的子数据进行上面同样的操作
            sort(smallerList);
            sort(largerList);

            //对排序好的数据进行整合
            list.clear();//清楚原本的数据,用于存放有序的数据
            list.addAll(smallerList);
            list.addAll(sameList);
            list.addAll(largerList);
        }
    }

    public static void main(String[] args) {
        Integer[] target = {4,3,6,7,1,9,5,2,3,3};
        List<Integer> list = new ArrayList<>();
        for (int i = 0; i < target.length; i++) {
            list.add(target[i]);
        }
        sort(list);
        //查看排序结果
        for (Integer integer : list) {
            System.out.print(integer + " ");
        }
    }

}

//输出结果:
//1 2 3 3 3 4 5 6 7 9 
//

细心的小伙伴可能心里有疑惑了,为什么我要用一个List来存储一样大小的呢?这里就暴露出了快速排序算法中对相同数据的处理方式。在上述的实现中为什么不直接把相等的放入smallerList或者放入largerList呢?举个例子:假如说你选取的枢纽元是最小值,那么是不是可能发生每次递归的数据都是一样的?因为所有的数据都比你的枢纽元大,而且这个时候恰好你把相等的数据都放入了比枢纽元大的部分,那么就会造成栈溢出了。所以在这个过程中,我们需要对相等的数据单独存储起来。

如何选取枢纽元

经过前面的概念分析与最傻的实现之后,我们讨论一下与快速排序效率息息相关的枢纽元选取。其实枢纽元选取的核心问题是,我们要把原本的待排序数据合理、平均的划分为两部分,我们就像要求平衡二叉树保持平衡一样。

1、以第一个值为枢纽元
在大学期间,我们学习到快速排序,告知我们的一般都是以第一个值为枢纽元。对于这种默认的选取方式,我们对他进行剖析:
①如果待排序的数据是随机的,那么如此选择枢纽元是可以接受的,因为在概率上来说,随机的情况下,在第一次快速排序之后,会分为两个差不多相等的新的数据。
②如果待排序的数据是有序的,那么这种情况下,就不能以第一个值为枢纽元了,因为它会产生一种恶心分割,直接导致所有的元素都被划分到左边子数据或者右边子数据。
所以这种办法是不可取的,也尽量不去实现它。也有人说可以选取第一个和第二个数据做比较,比较大的作为枢纽元。这种方式只是简单的规避了划分为空的情况,这种恶心的划分还是存在的。

2、随机选择一个枢纽元
在待排序的数据中随机选取一个数据为枢纽元会显得安全很多。它的随机可以保证在分割的过程中可以合理、平均的进行划分。但是我们要考虑随机数产生的开销,每趟分割之前还需要随机出一个随机数,那么开销会变得非常巨大。所以这种方式虽然比较安全,但是性能仍旧是不可取的。

3、三数中值分割法
三数中值分割法是目前比较高效的一种选取枢纽元的方式。按照选取枢纽元的要求:要尽可能合理、平均的划分为两部分。那么最好的是选取一个中值,那么就可以精准的分割两部分了。但是我们不可能划分这个开销去寻找中值,我们做的只是:
我们从待排序的数据中选取3个位置的值,分别是第一位置、最后位置、中间位置。然后我们用这3个数据中排序中间的数据作为枢纽元。

在这里,又会有细心的小伙伴有疑惑了,为什么三数中值分割法会更优秀?它需要选取3个值,还要判断拿到3个值中不大不小的那个。这么一来难道不会影响效率吗?
当然会影响效率,但是这个效率的开销是值当的,我们在选取好枢纽元之后仍旧需要遍历3个之中剩余的两个值,因为这里已经比较了一轮,我们只需要在枢纽元选取的时候就把剩余两个进行排序了,比枢纽元小的放在最前面,比枢纽元大的放在最后面,且在遍历的时候跳过这两个值。所以说三数中值分割法并没有白白花费这个效率开销。

快速排序核心思想

或许,对我上面的描述不是很理解,我们接下来已目前最好的快熟排序遍历的方式来说明这种情况:
在目前主流的快速排序中,我们把枢纽元与最后一个数据进行位置交换,也就是说把枢纽元分离出要进行数据交换的区段,然后通常是定义了2个指针,一个从开头往后比较,一个从后往前比较。进行大小比较和位置转换,且流行的情况是这样,下面我们用图解来说明情况:

我们要对数据:3,2,5,7,1,8,9进行快速排序。
我们随机选取一个值为枢纽元:5,那么我们就把5与9进行位置交换,把枢纽元独立到数据的边缘,避免它参与数据交换,所以初始情况就是这样:
3,2,9,7,1,8,5
我们定义两个指针,这两个指针我们称为头指针和尾指针,分别指向第一个元素和倒数第二个元素(倒数第一个元素是枢纽元)。接着就需要开始移动指针:
在头指针指向的位置小于尾指针指向的位置时:
①我们将头指针向右移动,遇到比枢纽元小的元素直接移动到下一个数据,直到遇到一个数据大于枢纽元,则头指针停止运动。
②相同的,移动尾指针向前移动,遇到比枢纽大的元素直接移动到下一个数据,直到遇到一个数据小于枢纽元。
③等两个指针都停止下来的时候,就需要把两个指针所指向的数据进行交换。并继续重复①②③步骤。
④直到尾指针指向的位置小于头指针指向的位置,通俗来说就是两个指针交错了就结束遍历。

下面是对于上面数据快速排序的一趟图解:
这里写图片描述

在此基础上,我们再来说说为什么三数中值分割法并没有浪费额外的开销:用三数中值分割法获取到枢纽元,那么其实比这个枢纽元小的数据可以放在最左边,比这个枢纽元大的数据放在最右边。那么这样一来,头指针就可以从第二个开始,尾指针就可以从倒数第二个开始,这样一来,一定程度上效率是有回升的。

代码实现标准快速排序

思考:
1、快速排序是分治策略的实现,所以递归必不可少。
2、采用最好的三数中值分割法选取枢纽值,并对选取后的数据进行合理存放,在上面已经描述过了。头指针可以跳过首数字,尾指针也可以跳过倒数第二个数字。但是对于子数组的大小我们要注意最小是2了,不然不满足三数中值分割法。
3、我们不用新的数组来参与递归和存储,所以我们定义好对于数组描述的下标left与right,尽可能的数组复用。

以下是代码实现:

package com.brickworkers;

/**
 * 
 * @author Brickworker
 * Date:2017年5月9日下午3:24:56 
 * 关于类QuickSort.java的描述:快速排序
 * Copyright (c) 2017, brcikworker All Rights Reserved.
 */
public class QuickSort {

    //暴露给外部的接口,对一个数组进行排序
    public static void quicksort(Integer [] target){
        quicksoort(target, 0, target.length - 1);
    }

    //具体实现
    //用left与right的方式,尽可能的实现数组复用
    private static void quicksoort(Integer[] target, int left, int right){
        if(left + 2 < right){//递归结束条件,之所以+2是因为三数中值分割法最起码需要两个数据
            //寻找枢纽元
            int pivot = findPivot(target, left, right, false);

            //定义头指针与尾指针
            int i = left + 1, j = right - 2; //因为三数中值分割法导致最前数据和最后数据不用判断

            for( ; ; ){
                //两个指针开始运动,直到两者都停止
                while(target[i] <= pivot){i++;}//如果头指针遍历到小于枢纽元的数据直接跳过
                while(target[j] >= pivot){j--;}//如果尾指针遍历到大于枢纽元的数据直接跳过
                //判断两个指针是否交错
                if(i < j){
                    //没有交错,且指针停止,那么进行数据交换
                    swap(target, i, j);
                }else{
                    break;//指针交错,那么结束循环
                }
            }

            //也就是上面描述的指针交错之后,需要把枢纽元交换到头指针的位置
            swap(target, i, right - 1);

            //继续递归子数组
            quicksoort(target, left, i - 1);
            quicksoort(target, i + 1, right);
        }else{
            //当数据少于2个的时候,直接用三数中值分割法进行排序
            findPivot(target, left, right, true);
        }

    }

    //三数中值分割法
    //这个判断用于说明是否最后的操作,最后的操作不需要把枢纽值放到最后
    private static Integer findPivot(Integer[] target, int left, int right, boolean end){
        int mid = (left + right) / 2;//获取中间值的位置
        //比较开始数据与中间数据
        if(target[left] > target[mid]){
            //如果开始数据比中间数据大,那么位置进行交换
            swap(target, left, mid);
        }
        if(target[left] > target[right]){
            //如果开始的数据比最后数据大,那么交换位置
            swap(target, left, right);
        }
        if(target[mid] > target[right]){
            //如果中间的数据比最后的数据大,那么交换位置
            swap(target, mid, right);
        }

        if(!end){
            //按照前面说的,把枢纽元放到最后面
            swap(target, mid, right - 1);

            //返回枢纽元
            return target[right - 1];
        }
        return null;

    }


    //交换数组中两个下标的数据
    private static final void swap(Integer[] target, int one, int anthor){
        int temp = target[one];
        target[one] = target[anthor];
        target[anthor] = temp;
    }

    public static void main(String[] args) {
        Integer[] target = {4,3,6,7,1,9,5,2,3,3};
        quicksort(target);
        for (Integer integer : target) {
            System.out.print(integer + " ");
        }
    }

}
//输出结果:
//1 2 3 3 3 4 5 6 7 9 
//

上面的实现加入了三数中值分割法,它所造成的影响就是元素判断基准变成了最起码子数组要有2个元素,同时,在最后一趟中,只需要用三数中值分割法进行排序就可以结束了。但是个人觉得这样实现有点傻傻的,其实更好的解决方式是在最后放入一个插入排序,因为在数据量很小的情况下,插入排序的效率十分高,两者排序算法结合用肯定比上面直接用三数中值分割法会好得多。

希望对你有所帮助

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章