前言

本文主要是介绍关于快速排序的三种优化思路,所以是基于读者已经掌握快速排序算法思想以及最基本的实现的前提,遂有关于快速排序原理方面,这里就不多赘述了。

下面是快速排序最简单的实现版本,即每次选取待排序序列中最左侧的元素作为枢纽元。

package SortPractice;import java.util.Random;public class QuickSortTest {// 辅助函数,用来交换数组中两个位置上的元素private static void swap(int[] arr,int i,int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;}// 快速排序外部入口public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1);}private static void quickSort(int[] arr,int l,int r) {if (l >= r) {return;}int p = partition(arr, l, r);quickSort(arr, l, p - 1);quickSort(arr, p + 1, r);}private static int partition(int[] arr,int l,int r) {int val = arr[l];int j = l;for(int i = l + 1;i <= r; i++) {if (arr[i] < val) {swap(arr, i, ++j);}}swap(arr, l, j);return j;} }

优化一:结合插入排序

插入排序有两个优点,如下:

  • 在数组几乎有序的情况下,插入排序效率极高!考虑一种极端情况,在数组完全有序的情况下,插入排序的时间复杂度将是 O(n)!
  • 在数据量较小的时候,插入排序的效率很高!

对于这两个优点,本文简单的解释一下,首先给出插入排序的实现,如下

    // 对于数组arr,从下标 l 开始到 r 部分执行插入排序public static void insertSort(int[] arr,int l,int r) {for(int i = l + 1; i <= r; i++) {int val = arr[i];int j;for(j = i; j > l && arr[j - 1] > val; j--) {arr[j] = arr[j - 1];}arr[j] = val;}}

1) 对于插入排序第一个优点

在数组近乎有序的情况下,我们不难发现,在插入排序的内层循环将会提前终止,而更极端的情况下,如果待排序数组就是一个单调递增的升序序列,那内层循环根本就不会执行了!所以此时插入排序将是 O(n) 时间复杂度的!

我们可以做一个简单的测试,排序一个完全有序的数组(这里设置大小为 10000 个),我们对比一下快速排序和插入排序的效率。测试代码如下

   public static void main(String[] args) {//生成从 0 到 9999 共 10000 个元素的递增数组int[] arr = new int[10000];for(int i = 0; i < 10000; i++) {arr[i] = i;}long s1 = System.currentTimeMillis();quickSort(arr);long e1 = System.currentTimeMillis();System.out.println("对于10000个元素的单调递增序列,快速排序耗时:" + (e1 - s1) + " ms");long s2 = System.currentTimeMillis();insertSort(arr, 0, arr.length - 1);long e2 = System.currentTimeMillis();System.out.println("对于10000个元素的单调递增序列,插入排序耗时:" + (e2 - s2) + " ms");}

控制台打印结果

可以看出来,在数组有序的情况下,二者差距还是相当大的!

而且,对于数组完全有序的情况下,是快速排序最不想遇到的最最最极端的情况!此时,快速排序时间复杂度将退化成 O(n^2) ,原因就在于每次选取的枢纽元都是最小的那个,所以无法将待排序的序列 “ 一分为二 ”,将其递归树画出来,就可以很明显的看出来此时递归树完全倾斜于一个方向。


此时,递归树的高度就是 n,所以此时快速排序的时间复杂度是 O(n^2),而快速排序最理想的情况就是每次选取的枢纽元都能够将待排序的序列正正好好的一分为 2,此时不难推导出递归树的高度为 logn,所以此时快速排序的时间复杂度是 O(nlogn)。

2) 对于插入排序第二个优点

最容易想到的原因便是减少了递归栈的开销。

不过,更更关键的,当我们使用大O来描述算法的复杂度的时候,是忽略常数项的。大O刻画的是当数据规模无穷大的时候,算法性能的趋势。他只是一个趋势,不是精确的时间。我们说 O(nlogn) 的算法比 O(n^2) 的算法快,是因为当 n 无穷大的时候,哪怕 O(nlogn) 的算法是 T = 1000000nlogn,而 O(n^2) 的算法是T = 2n^2,总有一个 n,会使得 1000000nlogn < 2n^2,并且随着 n 逐渐增大,这个差距越来越大。但是,当n比较小的时候,就不一定了。

比如 n=8 的时候,1000000nlogn = 24000000;而 2n^2 只有 128

插入排序法就是一个常数项非常小的排序算法,小于大多数排序。同时,对于有序(或者近乎有序)的数据,插入排序还可以进化成为 O(n) 的算法(因为第二层循环可以提前终止),而小数据量的数组,拥有更高的概率是有序的。

综上,可以在数据量比较低的时候,直接使用插入排序来作为一种优化手段。如下所示

private static void quickSort(int[] arr,int l,int r) {if (r - l <= 50) {insertSort(arr, l, r);return;}//if (l >= r) {//  return;//}int p = partition(arr, l, r);quickSort(arr, l, p - 1);quickSort(arr, p + 1, r);}

下面,我们来简单对比一下原版快排和结合了插入排序的快排的速度快慢。测试代码编写如下

public static void main(String[] args) {Random r = new Random();int[] arr = new int[1000000];for(int i = 0; i < 1000000; i++) {arr[i] = r.nextInt(100000);}long start = System.currentTimeMillis();quickSort(arr);long end = System.currentTimeMillis();System.out.println("对于1000000个随机元素的序列,快速排序(结合了插入排序)耗时:" + (end - start) + " ms");}

看下控制台打印结果。

接下来,再把插入排序优化注释掉,看看原版本快排的性能

虽然差的不多,但是优化效果还是有的。

注:在数据量比较小的时候直接使用插入排序这种优化方案,不仅仅适用于快速排序,而且也适用于归并排序。

优化二:枢纽元选取优化

关于快速排序的最差情况,在上文已经提到过了,那就是数组完全有序,但是归根结底,是因为每次执行 partition 都发现,要么就是所有元素都小于枢纽元,要么就是所有元素都大于枢纽元,导致递归树完全左倾或者右倾,树高达到了 n,算法时间复杂度退化成了 O(n^2)。(所以为什么归并排序能保证 O(nlogn) 的最坏时间复杂度你明白了吗?就是因为归并排序每次都会选取数组下标中点,从而将数组正好一分为二,左右两侧元素个数差最多为 1,那么反应到递归树上,树高就是 logn)

那么我们对于此,很容易想到一种方案,就是选取一个恰当的枢纽元,这个枢纽元正好能将当前序列一分为二,比如下图所示

这次选取的枢纽元就是 5,那么根据这个枢纽元,正好能将数组一分为二,如果每次都能达到这个效果那就是最好的了。

但每次都要求做到正好将数组一分为二,这种要求实在是太严苛了,所以我们不去想如何实现最好的,而是从另一个角度来考虑优化,那就是避免出现最不好的情况。

最初版本的快速排序每次都是选取最左侧的元素作为枢纽元,在数组近乎有序的情况下,效率非常不理想,基于此,我们可以简单做点手脚,主要有如下两种方式:

  • 取最左侧、中间、最右侧三个下标中元素大小中间的那个值,也被称为三数取中法,取完后与最左侧元素进行交换。
  • 在当前序列当中,随机选取一个元素作为枢纽元,将其与最左侧元素进行交换。

本文以第二种方法为例,给出简单的实现。

private static Random random = new Random();private static int partition(int[] arr,int l,int r) {// 随机挑选一个元素与最左侧元素进行交换int randonIndex = random.nextInt(r - l + 1) + l;swap(arr, l, randonIndex);int val = arr[l];int j = l;for(int i = l + 1;i <= r; i++) {if (arr[i] < val) {swap(arr, i, ++j);}}swap(arr, l, j);return j;}

现在来看一下,在完全有序的情况下,使用了随机选取枢纽元优化与未做优化的快速排序对比。

测试函数如下:

public static void main(String[] args) {int[] arr = new int[10000];for(int i = 0; i < 10000; i++) {arr[i] = i;}long start = System.currentTimeMillis();quickSort(arr);long end = System.currentTimeMillis();System.out.println("对于100000个完全有序元素的序列,快速排序(未随机选取枢纽元)耗时:" + (end - start) + " ms");}


之后,使用随机选取枢纽元对 partition 函数进行优化。

效果是非常明显的!

优化三:三路快排

在正式介绍三路快排之前,我们先来聊一聊快速排序的另外一个缺点:

  • 在重复元素过多的情况下,效率低下。

先来看一下测试用例,这里我对一个拥有10万个元素的数组进行排序,这个数组内的元素大小都落在 [0,9] 区间(10万个元素都是0~9之间的,可想而知,重复的元素是多么的多!)。

 public static void main(String[] args) {int[] arr = new int[100000];for(int i = 0; i < 100000; i++) {arr[i] = random.nextInt(10);}long start = System.currentTimeMillis();quickSort(arr);long end = System.currentTimeMillis();System.out.println("对于100000个元素(元素大小落在[0,9]区间)的序列,快速排序耗时:" + (end - start) + " ms");}

控制台打印结果

耗时还是很长的,我们来看一下 partition 的一部分代码实现。

       for(int i = l + 1;i <= r; i++) {if (arr[i] < val) {swap(arr, i, ++j);}}

请注意,内层的 if 判断条件,当前下标处元素值小于枢纽元的值时,才会将当前元素交换到左半部分,那么我们来想一下,当前元素等于枢纽元的时候岂不是都被留在了右半部分了吗?那么会出现以下两种比较糟糕的情况:

  • 对于重复元素需要重复执行 partition
  • 如果重复元素非常多,那么递归树又变得很倾斜了。


上图便是普通快排执行 partition 的一个中间状态,所有等于枢纽元的元素都被分到了右半部份。

基于此,三路快排的思想便被提出来了,顾名思义,它会将数组分为三个部分:

  • 小于枢纽元
  • 等于枢纽元
  • 大于枢纽元

如下图所示,它是使用三路快排的一个中间状态,接下来,我会根据这张图介绍三路快排的过程。

简单的解释一下(注意,最左侧和最右侧的下标分别是 l 和 r):

  • [l + 1,lt] 左闭右闭区间内都是小于枢纽元的元素。
  • [lt + 1,i) 左闭右开区间内都是等于枢纽元的元素。
  • [gt,r] 左闭右闭区间内都是大于枢纽元的元素。

现在还有三个元素没有遍历完,现在我们一起来看一下这三个元素的分配过程

(1) 当前元素等于枢纽元,则直接将指针 i 右移,此时 [lt + 1,i) 左闭右开区间内又多了一个元素吧。如下图所示

(2) 现在当前元素大于枢纽元了,那么就将当前位置元素与 gt - 1 处元素交换,之后对 gt 执行自减,让其指针左移一位,此时 [gt,r] 左闭右闭区间内又多了一个元素吧,但要注意此时 i 指针不要变化,因为交换过来的值是没被处理过的。如下图所示

(3) 现在当前元素小于枢纽元了,那么就将当前位置元素与 lt + 1 处元素交换,之后对 lt 执行自增,让其指针右移一位,此时 [l + 1,lt] 左闭右闭区间内又多了一个元素吧,但要注意此时 i 指针也要进行自增,就是往前走一步。如下图所示

OK,现在 i 指针的位置已经与 gt 重合了,说明所有元素都已经判断完毕了。

此时将 lt 处的元素与枢纽元所在的位置(即最左侧:下标为 l 处)进行交换!

如下图所示

此时,我们只需要对 [l,lt - 1] 部分与 [gt,r] 部分递归执行三路快排就可以了。因为 [lt,gt - 1] 部分都排好序了。

下面给出代码实现:

    public static void quickSort3ways(int[] arr) {quickSort3ways(arr, 0, arr.length - 1);}private static void quickSort3ways(int[] arr,int l,int r) {if (l >= r) {return;}// 随机挑选一个元素与最左侧元素进行交换int randonIndex = random.nextInt(r - l + 1) + l;swap(arr, l, randonIndex);int val = arr[l]; //枢纽元int lt = l;// [l+1,...,lt]部分为小于枢纽元的部分int gt = r + 1;// [gt,r]部分为大于枢纽元的部分int i = l + 1;// [lt + 1,i)部分为等于枢纽元的部分//注意终止条件while (i < gt) {if (arr[i] < val) {swap(arr, i, lt + 1);lt++;i++;}else if (arr[i] > val) {swap(arr, i, gt - 1);gt--;}else {i++;}}swap(arr, l, lt);quickSort(arr, l, lt - 1);quickSort(arr, gt, r);}

对于代码的实现,这里多说几句,主要有两个需要注意的点:

  • 关于 lt、i、gt 三个指针的初始值的设定,主要是保证一个原则:三个区间在最开始的时候是空区间,所以 lt 初始化为 l,那么 [l+1,…,lt] 不就是 [l+1,…,l],右侧比左侧还小,这肯定是个空区间;i 初始化为 l + 1,那么 [lt + 1,i) 不就是 [l + 1,l + 1),由于右侧是开区间,那么左右两端相等,所以该区间一定是空区间;gt 初始化为 r + 1,那么 [gt,r] 不就是 [r + 1,r] 么,右侧比左侧还小,这肯定是个空区间。
  • while 循环条件,是 i < gt 而不是 i <= r,因为 i 等于 gt 时,所有元素就都已经遍历完毕了。

现在我们来简单测试一下在重复元素多的情况下,普通快排与三路快排的性能差距。测试函数如下:

public static void main(String[] args) {int[] arr1 = new int[100000];int[] arr2 = new int[100000];for(int i = 0; i < 100000; i++) {int r = random.nextInt(10);arr1[i] = r;arr2[i] = r;}long s1 = System.currentTimeMillis();quickSort(arr1);long e1 = System.currentTimeMillis();System.out.println("对于100000个元素(元素大小落在[0,9]区间)的序列,普通快速排序耗时:" + (e1 - s1) + " ms");long s2 = System.currentTimeMillis();quickSort3ways(arr2);long e2 = System.currentTimeMillis();System.out.println("对于100000个元素(元素大小落在[0,9]区间)的序列,三路快速排序耗时:" + (e2 - s2) + " ms");}

控制台打印结果

快速排序的三个优化思路相关推荐

  1. 关于秒杀的系统架构优化思路

    一.问题的提出 秒杀或抢购活动一般会经过 预约,下单,支付 ,扛不住的地方在于下单,一般会带来2个问题: 1.高并发 比较火热的秒杀在线人数都是10w起的,如此之高的在线人数对于网站架构从前到后都是一 ...

  2. 快速排序算法的优化思路总结

    写于2016年1月11日,如有错漏,欢迎斧正. 原文 前两天在知乎上看到了一个关于快速排序算法性能的问题,我简单总结了一个优化思路,现在在自己的博客里也贴一下吧,版权都是我的. 其实里面的大部分内容在 ...

  3. 【算法】基于hoare快速排序的三种思想和非递归,基准值选取优化【快速排序的深度剖析-超级详细的注释和解释】你真的完全学会快速排序了吗?

    文章目录 前言 什么是快速排序 快速排序的递归实现 快速排序的非递归实现 单趟排序详解 hoare思想 挖坑法 前后指针法 快速排序的优化 三数取中 小区间优化 快速排序整体代码 尾声 前言 先赞后看 ...

  4. 快速排序的三种方式以及快排的优化

    一.快速排序的基本思想 关于快速排序,它的基本思想就是选取一个基准,一趟排序确定两个区间,一个区间全部比基准值小,另一个区间全部比基准值大,接着再选取一个基准值来进行排序,以此类推,最后得到一个有序的 ...

  5. C语言快速排序算法及三种优化方式

    C语言快速排序算法及三种优化方式 C语言快速排序算法及三种优化方式 原理 快速排序复杂度分析 1 时间复杂度 2 空间复杂度 快速排序代码实现 1 普通快速排序 2 快速排序优化1-三数取中优化不必要 ...

  6. 【算法笔记】一步一步推出来的同余最短路优化思路(千字长文,超详细)

    整理的算法模板合集: ACM模板 目录 同余最短路 例题1:luogu P3403 跳楼机 例题2:luogu P2371 [国家集训队]墨墨的等式 例题3:luogu P2662 牛场围栏 同余最短 ...

  7. 递增三元组蓝桥杯c语言,第九届蓝桥杯_递增三元组(枚举的优化思路)

    给定三个整数数组 A = [A1, A2, ... AN], B = [B1, B2, ... BN], C = [C1, C2, ... CN], 请你统计有多少个三元组(i, j, k) 满足: ...

  8. 完美日记的微服务实践和优化思路

    点击上方蓝色"程序猿DD",选择"设为星标" 回复"资源"获取独家整理的学习资料! 来源 | 公众号「阿里巴巴中间件」 如果你是一位程序媛, ...

  9. 秒杀系统架构优化思路

    本文曾在"架构师之路"上发布过,近期支援Qcon-AS大会,在微信群里分享了该话题,故对原文进行重新整理与发布. 架构师之路16年精选50篇 一.秒杀业务为什么难做 1)im系统, ...

最新文章

  1. MySQL数据库-笔记02【创建数据库与数据表、数据类型、约束概念与举例】
  2. 设计模式之间的关联关系和对比
  3. matlab编程数字信号,MATLAB--数字信号实验.doc
  4. python从入门到精通-终于懂得python从入门到精通教程
  5. 了解OutOfMemoryError异常 - 深入Java虚拟机读后总结
  6. android duiqi文字底部,Android中的文本/布局对齐(textAlignment,gravity)
  7. [LeetCode]Rotate List
  8. java JSONObject JSONArray对象使用小记
  9. 计算机二级c语言作弊技巧,计算机等级考试二级C语言题型分析与应试技巧
  10. 众多电子秤方案免费拿~挑一个?
  11. 英雄联盟一直连接服务器win10,win10上玩英雄联盟无法连接服务器是怎么回事
  12. 新版男神女神完整投票系统源码V5.5.21版本
  13. 计算机电脑上可以做作业吗,一起作业电脑版
  14. 苹果开发者账号可以创建多少测试证书_苹果开发者帐户能创建多少个发布证书...
  15. 三分钟解决文档编辑难题-【文档编辑命令- cat echo vi/vim tail rmdir 】
  16. React-native android App项目搭建
  17. Fujikure-FSM100P+特种光纤熔接机的那些事——第一番
  18. Marlin-1.1.3固件Configuration.h文件解析
  19. [UVa 1646] Edge Case
  20. 最简单的方式讲明白numpy.reshape()函数

热门文章

  1. 长沙理工考研2021计算机软件黑不黑,2021长沙理工大学考研历年真题复习资料
  2. discoverer连接问题
  3. 基于Aidlux的人体识别、人体追踪与人数统计获取
  4. 前端和后端分别是什么?
  5. 计算机网络技术应用 考试试题,计算机网络技术考试试题及答案
  6. 热插播 devtools
  7. 信息学奥赛一本通1182:合影效果
  8. 鸿蒙榜第一名是谁,赵本山86名徒弟收入天差地别,宋小宝仅排名第二,第一名红透半边天...
  9. jquery图片播放浏览插件prettyPhoto
  10. Android webview对接H5微信支付,ERR_UNKNOWN_URL_SCHEME引发的事故