终于轮到我们排序算法中的王牌登场了。

快速排序由于排序效率在同为 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 里面一探究竟。

面试 12:玩转 Java 快速排序的更多相关文章

  1. 玩转算法系列--图论精讲 面试升职必备(Java版)

    第1章 和bobo老师一起,玩转图论算法欢迎大家来到我的新课程:<玩转图论算法>.在这个课程中,我们将一起完整学习图论领域的经典算法,培养大家的图论建模能力.通过这个课程的学习,你将能够真 ...

  2. 记录面试龙腾简合-java开发工程师经历

    /** * ############ * 变强是会掉光头发的!现在的头发还是很茂盛,是该开心还是难过呢.. * ############ * / 总结下近期面试龙腾简合-java开发岗的经历.附上笔试 ...

  3. 高端面试必备:一个Java对象占用多大内存

    这个问题一般会出现在稍微高端一点的 Java 面试环节.要求面试者不仅对 Java 基础知识熟悉,更重要的是要了解内存模型. Java 对象模型 HotSpot JVM 使用名为 oops (Ordi ...

  4. 开涛spring3(12.4) - 零配置 之 12.4 基于Java类定义Bean配置元数据

    12.4  基于Java类定义Bean配置元数据 12.4.1  概述 基于Java类定义Bean配置元数据,其实就是通过Java类定义Spring配置元数据,且直接消除XML配置文件. 基于Java ...

  5. Java面试中笔试题——Java代码真题,这些题会做,笔试完全可拿下!

    大家好,我是上海尚学堂Java培训老师,以下这些Java笔试真题是上海尚学堂Java学员在找工作中笔试遇到的真题.现在分享出来,也写了参考答案,供大家学习借鉴.想要更多学习资料和视频请留言联系或者上海 ...

  6. 【JAVA秒会技术之秒杀面试官】秒杀Java面试官——集合篇(一)

    [JAVA秒会技术之秒杀面试官]秒杀Java面试官——集合篇(一) [JAVA秒会技术之秒杀面试官]JavaEE常见面试题(三) http://blog.csdn.net/qq296398300/ar ...

  7. 剑指offer 面试12题

    面试12题: 题目:矩阵中的路径 题:请设计一个函数,用来判断在一个矩阵中是否存在一条包含某字符串所有字符的路径.路径可以从矩阵中的任意一个格子开始,每一步可以在矩阵中向左,向右,向上,向下移动一个格 ...

  8. 玩转java多线程(wait和notifyAll的正确使用姿势)

    转载请标明博客的地址 本人博客和github账号,如果对你有帮助请在本人github项目AioSocket上点个star,激励作者对社区贡献 个人博客:https://www.cnblogs.com/ ...

  9. 8年经验面试官详解 Java 面试秘诀

      作者 | 胡书敏 责编 | 刘静 出品 | CSDN(ID:CSDNnews) 本人目前在一家知名外企担任架构师,而且最近八年来,在多家外企和互联网公司担任Java技术面试官,前后累计面试了有两三 ...

随机推荐

  1. git 入门教程之变基合并

    git 鼓励大量使用分支---"早建分支!多用分支!",这是因为即便创建再多的分支也不会造成存储或内存开销,并且分支的作用有助于我们分解逻辑工作,这样一样其实比维护单一臃肿分支要简 ...

  2. linux(centos)无中文输入,如何解决

    1.终端执行安装命令 yum install "@Chinese Support" 2.如下图,多出Input method 3.点击进行配置 4.reboot重启系统,新建一个文 ...

  3. 智能POS正餐主副机模式FAQ(无桌台或桌台模块)

    1.无桌台 (1).如果是初次使用,首先检查是否是新建的机器号,新建的机器是默认关闭桌台的,需要到模块管理中开启. 2.无桌台模块 (1).是否在主副机开启连接上后重启主机与副机,且同步数据.

  4. spark查看DF的partition数目及每个partition中的数据量【集群模式】

    println("--------------------"+data.rdd.getNumPartitions) // 获取DF中partition的数目 val partiti ...

  5. jsp笔记----jsp常用的的获取项目的根路径

    <% String path = request.getContextPath(); String basePath = request.getScheme() + "://" ...

  6. Scrapy (网络爬虫框架)入门

    一.Scrapy 简介: Scrapy是用纯Python实现一个为了爬取网站数据.提取结构性数据而编写的应用框架,Scrapy 使用了 Twisted['twɪstɪd](其主要对手是Tornado) ...

  7. February 27th, 2018 Week 9th Tuesday

    Great minds think alike. 英雄所见略同. If great minds really did think alike, then we would live in an unr ...

  8. 11.scrapy框架持久化存储

    今日概要 基于终端指令的持久化存储 基于管道的持久化存储 今日详情 1.基于终端指令的持久化存储 保证爬虫文件的parse方法中有可迭代类型对象(通常为列表or字典)的返回,该返回值可以通过终端指令的 ...

  9. MySQL高级知识(五)——索引分析

    前言:前面已经学习了explain(执行计划)的相关知识,这里利用explain对索引进行优化分析. 0.准备 首先创建三张表:tb_emp(职工表).tb_dept(部门表)和tb_desc(描述表 ...

  10. WPF设计の自定义窗体

    效果图如下: 实现思路: 1.继承Window类 2.为自定义的CustomWindow类设计窗体样式(使用Blend很方便!) 3.为窗体增加最大最小化和关闭按钮,并实现鼠标拖拽改变窗体大小(使用D ...