随机快排详解

目录

简介

快排1.0版本

快排2.0版本

快排3.0版本


简介

        随机快排(以下简称快排),作为基于比较的排序算法之一,不仅具备高效的性能,还能大量的节省系统空间,在工程也有许多应用,比如在Java中,集合工具类Arrays,底层的实现就有使用到快排的思想,那么快排具体是怎么实现的呢?让我们一起去一步步去体会快排的思想,感受一下快排的魅力吧。

快排1.0版本

        从一则问题谈起——荷兰国旗问题1.0:给你一个无序数组和一个给定值k,把不大于k的数放在数组的左边,大于k的数放在数组的右边。要求额外空间复杂度O(1),如果没有这个硬性要求,直接申请一个辅助数组,与原数组等长,将原数组遍历一遍,把不大于K的数从左往右依次填在辅助数组中,而大于K的数从右往左依次填在辅助数组中,即可得到正确答案。

        然而很明显,由于题目的硬性要求,上面的做法明显是不达标的。那么正确的流程是什么样的呢?我们先过流程,再过例子。

        假设数组 arr[5,1,8,3,6] k=3,首先在脑海中想象数组有两个区域, <=k 的区域在左边,用指针lessEqual表示边界,>k 的区域在右边,开始时,指针lessEqual在-1位置(数组下标)上,然后我们从左往右遍历数组,假设当前遍历到的数为cur,cur与k进行比较,则有两种情况:

        ①当前数cur <= k,cur和lessEqual区的下一个数进行交换,lessEqual区向左扩,当前数跳下一个。

        ②当前数cur > k,当前数跳下一个。

        比如当前来到0位置,在数组中arr[0] = 5, 5 > 3,命中情况②,cur直接跳下一个,来到1位置,arr[1] = 1,1 < 3,命中情况①,cur与<=K区域的下一个数进行交换,此时lessEqual在-1位置,lessEqual的下一个位置是 ++lessEqual = 0,所以 1 和 5 进行交换,1来到 0 位置,5来到 1 位置,此时 arr[1,5,8,3,6] ,重复此过程直到数组遍历结束即可。在代码实现的过程中,对题目的输入用例进行了特殊的处理,但并不违反上述流程,具体代码如下:

// 数组arr,在L到R位置上,以arr[R]作为划分值
// <=arr[R]放左边  >arr[R]放右边
// 返回 <=k区的边界
public static int partition(int[] arr,int L,int R){
    if (L > R){
        return -1;
    }
    if (L == R){
        return L;
    }
    // 小于等于区域的边界
    int lessEqual = L - 1;
    // 当前位置的数
    int index = L;
    while (index < R){
        // 如果当前数不大于划分值
        if (arr[index] <= arr[R]){
            // 当前数和小于区域的下一个位置交换,当前数跳下一个
            swap(arr,index,++lessEqual);
        }
        index++;
    }
    // 使用arr[R]位置数作为划分值
    // 当while循环结束时,只是L到R-1位置上的数归位
    // 还剩下R位置的数,要记得归位
    swap(arr, ++lessEqual, R);
    return lessEqual;
}

// 可以考虑异或运算
public static void swap(int[] arr,int i,int j){
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

        经过上述的partition过程,给定一个数组,开始时选数组的最后一个数作为划分值,我们可以得到一个边界,如果我们把这个边界作为分界线,将数组划分为两个范围,如果把划分出来的范围看做整体(可以把它想象成一个数),那么经过partition的调整,原数组是不是在某种程度上具备一定的有序性了呢,只是划分出来的范围,其内部不是有序的,那么我们只需对划分出来的范围也同样进行partition调整,重复此过程直到数组不可再分,那么我们最终是不是就可以得到一个有序的数组了呢?这就是快排1.0的版本。具体代码如下:  

public static void quickSort1(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    process1(arr, 0, arr.length - 1);
}

public static void process1(int[] arr, int L, int R) {
    if (L >= R) {
        return;
    }
    int M = partition(arr, L, R);
    process1(arr, L, M - 1);
    process1(arr, M + 1, R);
}

快排2.0版本

        依旧是从荷兰国旗问题谈起,给定一个无序数组以及一个给定值k,请你做到数组中小于k的数放左边,等于k的数放中间,大于k的数放在右边,要求额外空间复杂度O(1),跟上面1.0版本有点像,就是做了一次小升级,废话不多说暴力方法我们就不进行讨论了,直接过流程:

        假设数组 arr[5,1,3,8,3] k=3,和上述流程类似,在脑海中想象数组有三个区域, <k 的区域在左边,用指针less表示边界,=k的区域在中间,>k 的区域在右边,用指针more表示边界,开始时,指针less在-1位置(数组下标)上,指针more在数组的最右位置(即 arr.length),然后我们从左往右遍历数组,假设当前遍历到的数为cur,cur与k进行比较,则有三种情况:

        ①当前数cur < k,当前数与less区的下一个数交换,less向左扩,当前数跳下一个。

        ②当前数cur = k,当前数直接跳下一个。

        ③当前数cur > k,当前数与more区的前一个数交换,more区向右扩,当前数不动(交换过来的数没看过)。

        比如,当前来到0位置,less指针在-1位置,more指针在5位置,arr[0] = 5,5 > 3,命中情况③,5与more区的前一个数交换,即 --more = 4,既更新大于区的边界,同时算出需要交换的位置,5与arr[4]位置的数交换,当前数不动,arr更新为[3,1,3,8,5],此时当前数仍在0位置,arr[0] = 3,3=3,命中情况②,当前数直接跳下一个,来到1位置,arr[1] = 1,1<3,命中情况①,当前数与less区的下一个交换,即 ++less = 0,1与arr[0]位置的数交换,当前数跳下一个,此时arr更新为[1,3,3,8,5],继续遍历,根据命中情况调整数组,直到当前数的指针与大于区的边界(more指针)重合,流程结束。在代码实现的过程中,依旧对题目的输入用例进行了特殊的处理,但并不违反上述流程,具体代码如下:

// 在arr[L...R]上,玩荷兰国旗问题的划分,以arr[R]做划分值
// <arr[R]放左边  =arr[R]放中间  >arr[R]放右边
// 等于区的位置以数组的形式返回
public static int[] netherlandsFlag(int[] arr, int L, int R) {
    if (L > R) {
        return new int[]{-1, -1};
    }
    if (L == R) {
        return new int[]{L, R};
    }
    // 小于区边界
    int less = L - 1;
    // 大于区边界
    int more = R;
    int index = L;
    // 当前位置,不能和大于区域的左边界重合
    while (index < more) {
        if (arr[index] < arr[R]) {
            // 命中情况①的处理
            swap(arr, index++, ++less);
        } else if (arr[index] == arr[R]) {
            // 命中情况②的处理
            index++;
        } else {
            // 命中情况③的处理
            swap(arr, index, --more);
        }
    }
    // 还剩下arr[R]位置的数未归位
    // arr[R]位置的数与当前大于区域的边界位置上的数进行交换
    swap(arr, more, R);
    return new int[]{less + 1, more};
}

        经过netherlandsFlag过程进行划分,给定一个数组,选取数组的最后一个数作为划分值,我们可以将数组划分为三个区域,跟上述partition的流程相似,此时数组内的三个区域,如果把三个区域分别看作整体,数组在某种程度上是具备一定的有序性,只是划分出来的 <区 和 >区 的内部不是有序的,我们只需要在这两个区域再次进行netherlandsFlag划分,重复此过程直到数组不可再分,那么最终我们也可以得到一个有序的数组,这就是快排2.0的版本,具体代码如下:  

public static void quickSort2(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    process2(arr, 0, arr.length - 1);
}

public static void process2(int[] arr, int L, int R) {
    if (L >= R) {
        return;
    }
    int[] equalArea = netherlandsFlag(arr, L, R);
    process2(arr, L, equalArea[0] - 1);
    process2(arr, equalArea[1] + 1, R);
}

快排3.0版本

        经过上面两个版本的快排算法代码实现,我们来讨论一下两版快排算法的时间复杂度,看到这里,相信大家会说快排的时间复杂度不是O(N *log N)吗?首先,快排的时间复杂度的确是O(N *log N),但上述两个版本的快排算法的时间复杂度是O(N^{2}),这是为什么呢?因为估计一个算法的时间复杂度,要根据最差的数据状况去估计,要怎么理解呢?以上面两个版本的快排实现为例,给定一个数组,每次都是选取数组的最后一个数作为划分值,根据上述的流程,我们是希望选取的划分值,经过调整之后,可以将数组划分为对应的三个区间,理想的情况是选取的划分值大小处在数组所有数的中间位置,每次调整之后都可以得到一个与原数组数据量相对而言,三个较小的数据范围,从而提速后续过程中的调整行为,而在相对极端的情况下,如果我们选取的划分值刚好是全部数据量中的极端值,比如在快排2.0版本中,给定数组[4,3,2,1],每次选取数组的最后一个位置的数作为划分值去进行调整,每次调整后的数据量与前一次调整的数据量对比,呈现等差数列状态,所以上述两个版本的快排算法的时间复杂度是O(N^{2})。

        那么如何做到快排的时间复杂度是O(N * log N)呢?或者说上面两个版本的算法的不足之处在哪里呢?追究根源,就是在划分值的选取上,划分值取得好,算法的时间复杂度就好,划分值取得差,算法的时间复杂度就差,而算法的时间复杂度是具备稳定性的,这种情况要怎么去评估算法的时间复杂度呢?其实做法很简单,对于划分值的选取,我们把它随机化,把好情况和差情况随机化,最终通过数学的方法证明,把好情况和差情况随机,在数学的长期期望上,算法的时间复杂度就收敛于O(N * log N),所以快排3.0,时间复杂度就是O(N * log N)。代码如下:

// 随机快排  最终版  时间复杂度:O(N *log N)
public static void quickSort3(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    process3(arr, 0, arr.length - 1);
}

public static void process3(int[] arr, int L, int R) {
    if (L >= R) {
        return;
    }
    // 在 L...R 的范围上,随机选取一位和 arr[R] 进行交换
    // 目的:把好情况和差情况随机化
    swap(arr, L + (int) (Math.random() * (R - L + 1)), R);
    int[] equalArea = netherlandsFlag(arr, L, R);
    process3(arr, L, equalArea[0] - 1);
    process3(arr, equalArea[1] + 1, R);
}
// 随机快排 非递归版本
// 辅助类
// 记录处理哪个范围的排序
public static class Op {
    int l;
    int r;

    public Op(int left, int right) {
        l = left;
        r = right;
    }
}

public static void quickSort(int[] arr) {
    if (arr == null || arr.length < 2) {
        return;
    }
    int N = arr.length;
    // 在 L...R 的范围上,随机选取一位和 arr[R] 进行交换
    swap(arr, (int) (Math.random() * N), N - 1);
    int[] equalArea = netherlandsFlag(arr, 0, N - 1);
    int el = equalArea[0];
    int er = equalArea[1];
    // 申请栈用以记录调整的边界范围
    Stack<Op> stack = new Stack<>();
    stack.push(new Op(0, el - 1));
    stack.push(new Op(er + 1, N - 1));
    while (!stack.isEmpty()) {
        Op op = stack.pop();
        if (op.l < op.r) {
            // 在 L...R 的范围上,随机选取一位和 arr[R] 进行交换
            swap(arr, op.l + (int) (Math.random() * (op.r - op.l + 1)), op.r);
            equalArea = netherlandsFlag(arr, op.l, op.r);
            el = equalArea[0];
            er = equalArea[1];
            stack.push(new Op(op.l, el - 1));
            stack.push(new Op(er + 1, op.r));
        }
    }
}

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值