快速排序由于排序效率在同为 O(nlogn) 的几种排序方法中效率最高,因此经常被采用。再加上快速排序思想——分治法也确实非常实用,所以 在各大厂的面试习题中,快排总是最耀眼的那个。要是你会的排序算法中没有快速排序,我想你还是偷偷去学好它,再去向大厂砸简历。

事实上,在我们的诸多高级语言中,都能找到它的某种实现版本,那我们 Java 自然不能在此缺席。

总的来说,默写排序代码是南尘非常不推荐的,撇开快排的代码不是那么容易默写,即使你能默写快排代码,也总会因为面试官稍微的变种面试导致你惶恐不安。

基本思想

快速排序使用分治法策略来把一个序列分为两个子序列,基本步骤为:

  1. 先从序列中取出一个数作为基准数;
  2. 分区过程:将把这个数大的数全部放到它的右边,小于或者等于它的数全放到它的左边;
  3. 递归地对左右子序列进行不走2,直到各区间只有一个数。
 
图片来自于网络

虽然快排算法的策略是分治法,但分治法这三个字显然无法很好的概括快排的全部不走,因此借用 CSDN 神人 MoreWindows 的定义说明为:挖坑填数 + 分治法。

似乎还是不太好理解,我们这里就直接借用 MoreWindows 大佬的例子说明。

以一个数组作为示例,取区间第一个数为基准数。

0 1 2 3 4 5 6 7 8 9
72 6 57 88 60 42 83 73 48 85

初始时,i = 0; j = 9; temp = a[i] = 72

由于已经将 a[0] 中的数保存到 temp 中,可以理解成在数组 a[0] 上挖了个坑,可以将其它数据填充到这来。

从 j 开始向前找一个比 temp 小或等于 temp 的数。当 j = 8,符合条件,将 a[8] 挖出再填到上一个坑 a[0] 中。

a[0] = a[8]; i++; 这样一个坑 a[0] 就被搞定了,但又形成了一个新坑 a[8],这怎么办了?简单,再找数字来填 a[8] 这个坑。这次从i开始向后找一个大于 temp 的数,当 i = 3,符合条件,将 a[3] 挖出再填到上一个坑中 a[8] = a[3]; j--;

数组变为:

0 1 2 3 4 5 6 7 8 9
48 6 57 88 60 42 83 73 88 85

i = 3; j = 7; temp = 72

再重复上面的步骤,先从后向前找,再从前向后找。

从 j 开始向前找,当 j = 5,符合条件,将 a[5] 挖出填到上一个坑中,a[3] = a[5]; i++;

从i开始向后找,当 i = 5 时,由于 i==j 退出。

此时,i = j = 5,而a[5]刚好又是上次挖的坑,因此将 temp 填入 a[5]。

数组变为:

0 1 2 3 4 5 6 7 8 9
48 6 57 42 60 72 83 73 88 85

可以看出 a[5] 前面的数字都小于它,a[5] 后面的数字都大于它。因此再对 a[0…4] 和 a[6…9] 这二个子区间重复上述步骤就可以了。

对挖坑填数进行总结

1.i = L; j = R; 将基准数挖出形成第一个坑 a[i]。

2.j-- 由后向前找比它小的数,找到后挖出此数填前一个坑 a[i] 中。

3.i++ 由前向后找比它大的数,找到后也挖出此数填到前一个坑 a[j] 中。

4.再重复执行 2,3 二步,直到 i==j,将基准数填入 a[i] 中。

有了这样的分析,我们明显能写出下面的代码:

public class Test09 {

    private static void printArr(int[] arr) {
for (int anArr : arr) {
System.out.print(anArr + " ");
}
} private static int partition(int[] arr, int left, int right) {
int temp = arr[left];
while (right > left) {
// 先判断基准数和后面的数依次比较
while (temp <= arr[right] && left < right) {
--right;
}
// 当基准数大于了 arr[right],则填坑
if (left < right) {
arr[left] = arr[right];
++left;
}
// 现在是 arr[right] 需要填坑了
while (temp >= arr[left] && left < right) {
++left;
}
if (left < right) {
arr[right] = arr[left];
--right;
}
}
arr[left] = temp;
return left;
} private static void quickSort(int[] arr, int left, int right) {
if (arr == null || left >= right || arr.length <= 1)
return;
int mid = partition(arr, left, right);
quickSort(arr, left, mid);
quickSort(arr, mid + 1, right);
} public static void main(String[] args) {
int[] arr = {6, 4, 3, 2, 7, 9, 1, 8, 5};
quickSort(arr, 0, arr.length - 1);
printArr(arr);
}
}

我们不妨尝试来对这个算法进行一下时间复杂度的分析:

  • 最好情况

    在最好的情况下,每次我们进行一次分区,我们会把一个序列刚好分为几近相等的两个子序列,这个情况也我们每次递归调用的是时候也就刚好处理一半大小的子序列。这看起来其实就是一个完全二叉树,树的深度为 O(logn),所以我们需要做 O(logn) 次嵌套调用。但是在同一层次结构的两个程序调用中,不会处理为原来数列的相同部分。因此,程序调用的每一层次结构总共全部需要 O(n) 的时间。所以这个算法在最好情况下的时间复杂度为 O(nlogn)。

    事实上,我们并不需要如此精确的分区:即使我们每个基准值把元素分开为 99% 在一边和 1% 在另一边。调用的深度仍然限制在 100logn,所以全部运行时间依然是 O(nlogn)。

  • 最坏情况

    事实上,我们总不能保证上面的理想情况。试想一下,假设每次分区后都出现子序列的长度一个为 1 一个为 n-1,那真是糟糕透顶。这一定会导致我们的表达式变成:

    T(n) = O(n) + T(1) + T(n-1) = O(n) + T(n-1)

    这和插入排序和选择排序的关系式真是如出一辙,所以我们的最坏情况是 O(n²)。

找到更好的基准数

上面对时间复杂度进行了简要分析,可见我们的时间复杂度和我们的基准数的选择密不可分。基准数选好了,把序列每次都能分为几近相等的两份,我们的快排就跟着吃香喝辣;但一旦选择的基准数很差,那我们的快排也就跟着穷困潦倒。

所以大家就各显神通,出现了各种选择基准数的方式。

  • 固定基准数

    上面的那种算法,就是一种固定基准数的方式。如果输入的序列是随机的,处理时间还相对比较能接受。但如果数组已经有序,用上面的方式显然非常不好,因为每次划分都只能使待排序序列长度减一。这真是糟糕透了,快排沦为冒泡排序,时间复杂度为 O(n²)。因此,使用第一个元素作为基准数是非常糟糕的,我们应该立即放弃这种想法。

  • 随机基准数

    这是一种相对安全的策略。由于基准数的位置是随机的,那么产生的分割也不会总是出现劣质的分割。但在数组所有数字完全相等的时候,仍然会是最坏情况。实际上,随机化快速排序得到理论最坏情况的可能性仅为1/(2^n)。所以随机化快速排序可以对于绝大多数输入数据达到 O(nlogn) 的期望时间复杂度。

  • 三数取中

    虽然随机基准数方法选取方式减少了出现不好分割的几率,但是最坏情况下还是 O(n²)。为了缓解这个尴尬的气氛,就引入了「三数取中」这样的基准数选取方式。

三数取中法实现

我们不妨来分析一下「三数取中」这个方式。我们最佳的划分是将待排序的序列氛围等长的子序列,最佳的状态我们可以使用序列中间的值,也就是第 n/2 个数。可是,这很难算出来,并且会明显减慢快速排序的速度。这样的中值的估计可以通过随机选取三个元素并用它们的中值作为基准元而得到。事实上,随机性并没有多大的帮助,因此一般的做法是使用左端、右端和中心位置上的三个元素的中值作为基准元。显然使用三数中值分割法消除了预排序输入的不好情形,并且减少快排大约 5% 的比较次数。

我们来看看代码是怎么实现的。

public class Test09 {

    private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
} private static void printArr(int[] arr) {
for (int anArr : arr) {
System.out.print(anArr + " ");
}
} private static int partition(int[] arr, int left, int right) {
// 采用三数中值分割法
int mid = left + (right - left) / 2;
// 保证左端较小
if (arr[left] > arr[right])
swap(arr, left, right);
// 保证中间较小
if (arr[mid] > arr[right])
swap(arr, mid, right);
// 保证中间最小,左右最大
if (arr[mid] > arr[left])
swap(arr, left, mid);
int pivot = arr[left];
while (right > left) {
// 先判断基准数和后面的数依次比较
while (pivot <= arr[right] && left < right) {
--right;
}
// 当基准数大于了 arr[right],则填坑
if (left < right) {
arr[left] = arr[right];
++left;
}
// 现在是 arr[right] 需要填坑了
while (pivot >= arr[left] && left < right) {
++left;
}
if (left < right) {
arr[right] = arr[left];
--right;
}
}
arr[left] = pivot;
return left;
} private static void quickSort(int[] arr, int left, int right) {
if (arr == null || left >= right || arr.length <= 1)
return;
int mid = partition(arr, left, right);
quickSort(arr, left, mid);
quickSort(arr, mid + 1, right);
} public static void main(String[] args) {
int[] arr = {6, 4, 3, 2, 7, 9, 1, 8, 5};
quickSort(arr, 0, arr.length - 1);
printArr(arr);
}
}

由于篇幅关系,今天我们的讲解暂且就到这里。

话说 Java 官方是怎么实现的呢?我们明天不妨直接到 JDK 里面一探究竟。

Java 快速排序讲解的更多相关文章

  1. java集合讲解

    java集合讲解 1.概述 集合类的顶级接口是Iterable,Collection继承了Iterable接口 常用的集合主要有 3 类,Set,List,Queue,他们都是接口,都继于Collec ...

  2. 面试 12:玩转 Java 快速排序

    终于轮到我们排序算法中的王牌登场了. 快速排序由于排序效率在同为 O(nlogn) 的几种排序方法中效率最高,因此经常被采用.再加上快速排序思想——分治法也确实非常实用,所以 在各大厂的面试习题中,快 ...

  3. Java 快速排序两种实现

    快速排序,只要学习过编程的人肯定都听说过这个名词,但是有时候写的时候还真蒙住了,网上搜罗了下以及查阅了"introduction to algorithm",暂时找到两种实现快排的 ...

  4. Java权限讲解

    Java访问权限就如同类和对象一样,在Java程序中随处可见. Java的访问权限,根据权限范围从大到小为:public > protected > package > privat ...

  5. java快速排序1000万无序数组JVM-Xmx=256M 耗时2s

    自己动手写排序算法,快速排序是比较不好写的了~ import java.util.*; class Test{ public void quickSort(int[] arr,int low,int ...

  6. java快速排序引起的StackOverflowError异常

    写在前面:这篇随笔主要记录一下递归调用引起的虚拟机栈溢出的情况以及通过参数配置了虚拟机栈大小来使递归调用可以顺利执行.并没有对涉及到的一些概念进行详细的解释(因为我自己目前对这些概念并不是特别清楚), ...

  7. java泛型讲解

    原文: https://blog.csdn.net/briblue/article/details/76736356 泛型,一个孤独的守门者. 大家可能会有疑问,我为什么叫做泛型是一个守门者.这其实是 ...

  8. Java 基础讲解

    Hello,老同学们,又见面啦,新同学们,你们好哦! 在看完本人的<数据结构与算法>专栏的博文的老同学,恭喜你们在学习本专栏时,你们将会发现好多知识点都讲解过,都易于理解,那么,没看过的同 ...

  9. java context 讲解

    在 java 中, 常见的 Context 有很多, 像: ServletContext, ActionContext, ServletActionContext, ApplicationContex ...

随机推荐

  1. dropout总结

    1.伯努利分布:伯努利分布亦称“零一分布”.“两点分布”.称随机变量X有伯努利分布, 参数为p(0<p<1),如果它分别以概率p和1-p取1和0为值.EX= p,DX=p(1-p). 2. ...

  2. [转]解決 IE10 瀏覽器無法使用 ASP.NET 表單驗證登入的問題

    今天凌晨在客戶端上線,當程式佈署到正式機後發現我們的網站唯獨只有 IE10 瀏覽器無法成功登入,任何其他瀏覽器版本或使用較低的 IE 版本都可以正常登入,使用 IE 相容性檢視也都可以正常登入,想說會 ...

  3. 教你使用 Reflexil 反编译.NET 转

    转自:http://www.wxzzz.com/711.html http://sebastien.lebreton.free.fr/reflexil/ http://www.aneasystone. ...

  4. 使用BSRR和BRR寄存器直接操作STM32的I/O端口

    STM32的每个GPIO端口都有两个特别的寄存器,GPIOx_BSRR和GPIOx_BRR寄存器,通过这两个寄存器可以直接对对应的GPIOx端口置'1'或置'0'. GPIOx_BSRR的高16位中每 ...

  5. 微信小程序跳H5页面

    主页面:index.wxml 主页面:index.js ↑跳转到另一个wxml页面→recharge.wxml recharge.wxml web-view中设置跳转h5的链接,可以加上需要的参数: ...

  6. JAVA格式化解析日期

  7. 闲谈Hybrid

    前言 当经常需要更换样式,产品迭代,那么我们应该考虑hybrid混合开发,上层使用Html&Css&JS做业务开发,底层透明化.上层多多样化,这种场景非常有利于前端介入,非常适合业务快 ...

  8. Jquery之倒计时计算

      setInterval(); function setDate(setTime){ var date = new Date();//获取系统当前时间 )+)+"-"+date. ...

  9. angularjs中 $watch 和$on 2种监听的区别?

    1.$watch简单使用 $watch是一个scope函数,用于监听模型变化,当你的模型部分发生变化时它会通知你. $watch(watchExpression, listener, objectEq ...

  10. 使用百度编辑器--ueditor,后台接收提交编辑的内容,HTML不见了, 赋值不了,赋值之后,html暴露出来了??

    1.提交编辑好的内容, 后台post 接收发现 html 不见了,这个时候也许就是转义的问题, 既可以试试 $content = htmlspecialchars(stripslashes(input ...