数据结构之七大排序3—快速排序详解

目录

一、原理

二、基本代码实现

1.递归写法

2.带输入数组理解partition过程

3.优化:

1)和归并的一个优化相似:小集合(r-l<=15)上不用再递归而直接插入

2)基准值选择的优化(对于退化的修改)

3.快排的退化

4.快排非递归写法

三、二路快排

上述优化后的基本快排仍存在不足

1.图片分析

2.带入数组元素分析分区过程 

3.代码实现

四、挖坑法快排

1.思路

2.代码实现

五、三路快排(了解)

1.思路

2.图片分析

3.代码实现

 六、关于快速排序分区函数的衍生问题

leetcode215:数组中第k个最大元素


一、原理

1.总体原理

1)从待排序区间选一个数,作为基准值pivot

2)Partition(和merge是归并的核心操作一样,它是快排的核心操作):遍历整个待排序区间,将比基准值小的放到基准值左边,大于等于的放到基准值右边。

3)分治思想:对左右两个小区间按同样方式处理,直到小区间长度为1,代表已经有序,或者小区间长度为0,代表没有数据。

2.Partition分区的步骤图片分析:

      v:基准值(待比较值);e:当前在遍历的元素;i:当前正在扫描的元素的下标;l:基准值对应下标(分区点);j:小于基准值的最后一个元素。

1)若arr[i]>=v:只需要把紫色部分拉长,即i++即可,就把e放进了i>=v的区间,i++就遍历下一个e元素了。

2)若arr[i]<v:即e是属于橙色部分的,只需要将当前扫描的小于v的这个e,与>=v的第一个元素(即紫色部分的第一个元素(下标j+1))做交换,即swap(i,j+1);,交换后j++让橙色部分扩充把这个元素e囊括,i++继续向后移动遍历下一个元素。

3)当整个区间扫描完毕,整个区间就被我们分割成以下情况:左侧元素全<v,右侧元素全>v,i也走到了数组末尾。

4)此时j就落在了最后一个小于v的索引,此时只需要交换swap(l,j);交换后arr[j]这个元素就变成v了(就落在了最终的位置),arr[j]之前的都是小于v的元素,arr[j]之后的都是>=v的元素,分区完成。

5)继续在小于v和大于v的子区间上重复上述过程(递归),直到整个集合有序。

二、基本代码实现

注:这里快速排序的分区思想是基于《算法导论》的分区思想

1.递归写法

    public static void quickSort(int[] arr){
        quickSortInternal(arr,0,arr.length-1);
    }

    /**
     * 在arr[l...r]上进行快速排序
     * @param arr
     * @param l
     * @param r
     */
    private static void quickSortInternal(int[] arr, int l, int r) {
        if(l>=r){
            return;//只有一个元素时有序,不用再进行
        }
        //先获取分区点
        //所谓分区点,就是经过分区函数后,某个元素落在了最终的位置
        //分区点左侧全是小于该元素的区间,分区点右侧都是>+该元素的区间
        int p=partition(arr,l,r);
        //递归重复在分区点的左区间和右区间上重复上述流程
        quickSortInternal(arr,l,p-1);
        quickSortInternal(arr,p+1,r);
    }

    /**
     * 在arr[l...r]上的分区函数,返回分区点的索引
     * @param arr
     * @param l
     * @param r
     * @return
     */
    private static int partition(int[] arr, int l, int r) {
        //基准值
        int v=arr[l];
        //arr[l+1...j]<v
        //arr[j+1...i)>=v
        //i表示当前正在扫描的元素
        //最开始时上面两个区间没有元素,是空区间,赋值时注意
        int j=l;
        for (int i = l+1; i <= r; i++) {
            if(arr[i]<v){
                swap(arr,j+1,i);
                j++;
                //i++在循环条件里写了
            }//else arr[i]>v不需要交换只用i++
        }
        //走完循环交换基准值v和最后一个小于v的元素(j索引处)交换
        swap(arr,l,j);
        return j;//基准值对应索引
    }

结果:

2.带输入数组理解partition过程

3.优化:

1)和归并的一个优化相似:小集合(r-l<=15)上不用再递归而直接插入

 if(r-l<=15){
            insertionSort(arr,l,r);
            return;
        }
//        if(l>=r){
//            return;//只有一个元素时有序,不用再进行
//        }

2)基准值选择的优化(对于退化的修改)

1.默认选择边上元素(左或右)

      关于基准值的选择,我们现在是默认选择第一个元素作为分区点,但是在近乎有序的集合上默认使用第一个数作为分区点,快排会退化为O(N^2)的时间复杂度(可能造成栈溢出),他甚至完全不如插入排序。

      为何栈溢出:递归过程中实际上是在遍历数组,O(N^2)的算法,递归函数调用次数也就变成了O(N^2)次,一般来说,JVM栈的深度(栈调用的次数)大概在1w次左右。当JVM调用函数的次数超过了能允许的最大深度就栈溢出了。

      如何退化:极端情况,数组完全有序[1、2、3、4、5、6、7、8],先按1分区,分区后1左面没有数组,右边是剩下的全部数组,再拆[2...8],分区后2前面无数组,2右边是一个数组(只有>=v右元素)。分区后的左右区间严重不平衡,二叉树退化为单支树,即树结构退化为了链表(logN->n)

      对于这种退化的修改:(不再从最左侧开始选基准值)用下面的随机选择或取中优化

2.随机选择⭐

      随机在[l...r]区间选一个数,交换l与randomIndex位置,此时l对应的值是随机值而非第一个元素,将这个数作为基准值开始从左侧遍历

 private static final ThreadLocalRandom random=ThreadLocalRandom.current();
    private static int partition(int[] arr, int l, int r) {
        //随机在当前的数组中选一个数
        int randomIndex=random.nextInt(l,r);
        swap(arr,randomIndex,l);
        //基准值
        int v=arr[l];//此时arr[l]不一定是数组第一个元素,而是[l...r]间任意索引对应值

3.几数取中(一般三数取中)

      如三个数arr[l],arr[mid],arr[right]大小是中间的值为基准值。随机选择和取中都能避免在递归过程中,在接近有序的数组上快速排序的分区严重不平衡的问题

3.快排的退化

1.接近有序的数组上快排退化(上面优化中提到了) 

2.在大量重复元素数组上快排退化:

      极端情况,当100w个元素全都是一个值,分区后,<v的元素没有,全都在>=v的集合中,二叉树又退化成了单支树。这种退化的解决思路:二路/三路快排

4.快排非递归写法

    /**
     * 借助栈/队列来实现非递归分治的快排
     */
    public static void quickSortNonRecursion(int[] arr){
        Deque<Integer> stack=new ArrayDeque<>();
        //栈中保存当前集合的开始位置和终止位置
        int l=0;
        int r=arr.length-1;
        //先入后出
        stack.push(r);
        stack.push(l);
        while(!stack.isEmpty()){
            //栈不为空,说明子区间还未处理完毕
            int left=stack.pop();//left是区间左端点
            int right=stack.pop();
            if(left>=right){
                //区间只有一个元素,不需要再分区了
                continue;
            }
            int p=partition(arr,left,right);
            //依次将右区间的开始和结束位置压入栈中//先右后左压,取出来是先左后右
            stack.push(right);
            stack.push(p+1);
            //再将左侧区间开始和结束位置入栈
            stack.push(p-1);
            stack.push(left);
        }
    }
/**
     * 在arr[l...r]上的分区函数,返回分区点的索引
     * @param arr
     * @param l
     * @param r
     * @return
     */
    private static int partition(int[] arr, int l, int r) {
        //随机在当前的数组中选一个数
        int randomIndex=random.nextInt(l,r);
        swap(arr,randomIndex,l);
        //基准值
        int v=arr[l];//此时arr[l]不一定是数组第一个元素,而是[l...r]间任意索引对应值
        //arr[l+1...j]<v
        //arr[j+1...i)>=v
        //i表示当前正在扫描的元素
        //最开始时上面两个区间没有元素,是空区间,赋值时注意
        int j=l;
        for (int i = l+1; i <= r; i++) {
            if(arr[i]<v){
                swap(arr,j+1,i);
                j++;
                //i++在循环条件里写了
            }//else arr[i]>v不需要交换只用i++
        }
        //走完循环交换基准值v和最后一个小于v的元素(j索引处)交换
        swap(arr,l,j);
        return j;//基准值对应索引
    }

三、二路快排

上述优化后的基本快排仍存在不足

      当待排序的集合中出现大量重复元素时,就会导致某个分区的元素过多(相等的元素全在一个区间中),造成递归过程中递归树严重不平衡,快排退化为O(N^2)时间复杂度。【见快排的退化第2点】

1.图片分析

二路快排:将相等的元素均分到左右两个子区间

1)使用两个变量i和j,从前向后扫描,碰到第一个arr[i]>=v的元素停止,从后向前扫描,碰到第一个<=v的元素停止,把这两个元素交换(swap(arr,i,j)),这样就可以把相等元素平均到左右两个子区间。

2)i++或j--继续移动i,j,此时,橙色区间元素<=v,紫色区间元素>=v,i,j继续向后遍历判断

3)i>=j时整个集合就扫描结束了,注意,j最后会落在最后一个<=v的元素上

4)交换l和j对应的元素即可,交换后返回j处索引,继续在橙色与紫色部分递归即可。

2.带入数组元素分析分区过程 

3.代码实现

public static void quickSort2(int[] arr){
        quickSortInterna2(arr,0,arr.length-1);
    }

    private static void quickSortInterna2(int[] arr, int l, int r) {
        if(r-l<=15){
            insertionSort(arr, l, r);
            return;
        }
        int p=partition2(arr,l,r);
        quickSortInterna2(arr,l,p-1);
        quickSortInterna2(arr,p+1,r);
    }

    /**
     * 在arr[l...r]上进行双路快排的分区处理
     * @param arr
     * @param l
     * @param r
     * @return
     */
    private static int partition2(int[] arr, int l, int r) {
        int randomIndex=random.nextInt(l,r);
        swap(arr,randomIndex,l);
        int v=arr[l];
        //arr[l+1..i)<=v
        int i=l+1;
        //arr(j...r]>=v
        int j=r;
        while(true){
            //i从前向后扫描,碰到第一个>=v的元素停止
            while(i<=j&&arr[i]<v){
                i++;
            }
            //j从后向前扫描,碰到第一个<=v的元素停止
            while(j>=i&&arr[j]>v){
                j--;
            }
            if(i>=j){
                //循环遍历交换完毕
                break;
            }
            swap(arr,i,j);
            i++;
            j--;
        }
        //整个集合扫描完毕,j落在最后一个<=v的元素上
        swap(arr,l,j);
        return j;
    }

结果:(100w个元素在0-100之间取值(即含大量重复元素时排序的结果观察))

四、挖坑法快排

注意,挖坑法也没有解决大量重复元素的情况(重复元素过多退化仍有可能栈溢出)

1.思路

1)选取分区点p=arr[l]。挖坑法必须先从后向前扫描(先从right开始),再从前向后扫描。从前向后的话由于右边j索引元素未存,直接j对应元素就丢了,而从后向前第一次覆盖的arr[i]还是在left处,即i索引的值是被保存在v的,值不会丢。

2)j先从后向前扫描,碰到第一个<v的元素停止,然后直接arr[i]=arr[j]

3)i再从前向后扫描,碰到第一个>j的元素停止,然后直接arr[j]=arr[i]

4)循环结束(挖坑结束)后只需要将分区值v给array[i]即可

2.代码实现

 public static void quickSortOfDigPit(int[] arr){
        quickSortOfDigPitInternal(arr,0,arr.length-1);
    }
    //    在arr[l...r]上进行快速排序
    private static void quickSortOfDigPitInternal(int[] arr, int l, int r) {
        if(r-l<=15){
            insertionSort(arr,l,r);//插入排序
            return;
        }
        //分区点
        int p=partitionDigPit(arr,l,r);
        //分区点左侧继续挖坑排序
        quickSortOfDigPitInternal(arr,l,p-1);
        //分区点右侧继续挖坑排序
        quickSortOfDigPitInternal(arr,p+1,r);
    }

    /**
     * 在arr[l...r]上挖坑法进行排序的分区处理
     * @param arr
     * @param l
     * @param r
     * @return
     */
    private static int partitionDigPit(int[] arr, int l, int r) {
        int randomIndex=random.nextInt(l,r);
        swap(arr,randomIndex,l);
        int i=l;
        int j=r;
        int v=arr[l];//也相当于左侧元素(分区点元素)被暂存了
        while(true){
            //必须先进行右侧向前扫描
            //j对应元素<v,循环暂停
            while(j>i&&arr[j]>=v){
                j--;
            }
            //直接把j处对应值给i
            arr[i]=arr[j];
            //i向后遍历,直到对应元素>v
            while(i<j&&arr[i]<=v){
                i++;
            }
            //i处对应元素直接覆盖j处对应元素
            arr[j]=arr[i];
            if(i>=j){
                //i>=j时,挖坑结束
                break;
            }
        }
        //此时挖坑结束,开始填坑,即把v值给arr[i]即可
        arr[i]=v;
        return i;
    }

五、三路快排(了解)

1.思路

      在一次分区函数的操作中,将所有相等的元素都放在最终位置,只需要在小于v和大于v的子区间上进行快排,所有相等的元素就不再处理

2.图片分析

1)当前扫描元素e<v:当arr[i]<v,换到橙色部分(和等于的第一个换),swap(arr,i,lt+1),lt++,i++继续处理下一个元素。

2)当前扫描元素e==v:直接i++处理下一个元素即可

3)当前扫描元素e>v:当arr[i]>v,换到紫色部分,swap(arr,i,gt-1),但是此时i不需++【因为此时换过来的gt-1对应的元素是没处理过的,你不知道它是大于小于或等于v。上面i++是因为交换的元素都是已经处理的<或=的元素只需要gt--即可,然后继续判断i对应处e的大小。

4)经过全集合全部处理结束后,i和gt就重合了,此时v只需要和lt一交换(lt指向最后一个<v的元素)即可此时左侧就全是<v的,右侧就全是>=v的。

      最终只需在所有<v(arr[l...lt-1])和所有>v(arr[gt...r])的区间再递归进行快速排序,中间所有与v相等的元素全都在本轮分区中到达了最终位置。

3.代码实现

 public static void quickSort3(int[] arr){
        quickSortInternal3(arr,0,arr.length-1);
    }
    private static void quickSortInternal3(int[] arr, int l, int r) {
        if(r-l<=15){
           insertionSort(arr,l,r);
            return;
        }
        int randomIndex=random.nextInt(l,r);
        swap(arr, l, randomIndex);
        int v=arr[l];//分区元素
        //变量的取值要满足区间定义,最开始的时候,所有区间取值得是空的
        //arr[l+1...lt]<v
        //lt指向最后一个<v的元素
        //lt是向后++的
        int lt=l;
        //arr[lt+1,i)==v
        int i=lt+1;
        //arr[gt,r]>v
        //gt是第一个>v的元素(gt是左移--的)
        int gt=r+1;
        //i从前向后扫描直到与gt重合时,所有元素就处理完毕了
        while(i<gt){
            if(arr[i]<v){
                swap(arr,i,lt+1);
                i++;
                lt++;
            }else if(arr[i]>v){
                swap(arr,i,gt-1);
                gt--;
                //此处i不++后移,因为换过来的gt-1对应元素没有处理,不知大小
            }else{
                i++;
            }
        }
        //循环结束,lt落在最后一个<v的索引处,交换lt与l索引位置的值,让v到lt索引处
        swap(arr,l,lt);
        //此时arr[l...lt-1]<v,在这个区间继续快排
        quickSortInternal3(arr,l,lt-1);
        //arr[gt...r]>v,在这个区间继续快排
        quickSortInternal3(arr,gt,r);
    }
 private static void insertionSort(int[] arr, int l, int r) {
        for (int i = l+1; i <=r ; i++) {
            for (int j = i; j >l&&arr[j]<arr[j-1]; j--) {
                swap(arr,j,j-1);
            }
            
        }
    }

 六、关于快速排序分区函数的衍生问题

leetcode215:数组中第k个最大元素

leetcode215-第k个最大元素(快速排序分区函数的衍生问题)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值