排序算法:快速排序

        快速排序算法由 C. A. R. Hoare 在 1960 年提出。它的时间复杂度也是 O(nlogn),但它在时间复杂度为 O(nlogn) 级的几种排序算法中,大多数情况下效率更高,所以快速排序的应用非常广泛。再加上快速排序所采用的分治思想非常实用,使得快速排序深受面试官的青睐,所以掌握快速排序的思想尤为重要。

        快速排序算法的基本思想是:

  • 从数组中取出一个数,称之为基数(pivot)
  • 遍历数组,将比基数大的数字放到它的右边,比基数小的数字放到它的左边。遍历完成后,数组被分成了左右两个区域
  • 将左右两个区域视为两个数组,重复前两个步骤,直到排序完成

        事实上,快速排序的每一次遍历,都将基数摆到了最终位置上。第一轮遍历排好 1 个基数,第二轮遍历排好 2 个基数(每个区域一个基数,但如果某个区域为空,则此轮只能排好一个基数),第三轮遍历排好 4 个基数(同理,最差的情况下,只能排好一个基数),以此类推。总遍历次数为 logn~n 次,每轮遍历的时间复杂度为 O(n),所以很容易分析出快速排序的时间复杂度为 
O(nlogn) ~ O(n^2),平均时间复杂度为 O(nlogn)。

快速排序递归框架

根据我们分析出的思路,先搭出快速排序的架子:

public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int start, int end) {// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 对左边区域快速排序quickSort(arr, start, middle - 1);// 对右边区域快速排序quickSort(arr, middle + 1, end);
}
public static int partition(int[] arr, int start, int end) {// TODO: 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
}

partition 意为“划分”,我们期望 partition 函数做的事情是:将 arr 从 start 到 end 这一区间的值分成两个区域,左边区域的每个数都比基数小,右边区域的每个数都比基数大,然后返回中间值的下标。

只要有了这个函数,我们就能写出快速排序的递归函数框架。首先调用 partition 函数得到中间值的下标 middle,然后对左边区域执行快速排序,也就是递归调用 quickSort(arr, start, middle - 1),再对右边区域执行快速排序,也就是递归调用 quickSort(arr, middle + 1, end)。

现在还有一个问题,何时退出这个递归函数呢?

退出递归的边界条件
        

        很容易想到,当某个区域只剩下一个数字的时候,自然不需要排序了,此时退出递归函数。实际上还有一种情况,就是某个区域只剩下 0 个数字时,也需要退出递归函数。当 middle 等于 start 或者 end 时,就会出现某个区域剩余数字为 0。

所以我们可以通过这种方式退出递归函数:

public static void quickSort(int[] arr, int start, int end) {// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 当左边区域中至少有 2 个数字时,对左边区域快速排序if (start != middle && start != middle - 1) quickSort(arr, start, middle - 1);// 当右边区域中至少有 2 个数字时,对右边区域快速排序if (middle != end && middle != end - 1) quickSort(arr, middle + 1, end);
}

        在递归之前,先判断此区域剩余数字是否为 0 个或者 1 个,当数字至少为 2 个时,才执行这个区域的快速排序。因为我们知道 middle >= start && middle <= end 必然成立,所以判断剩余区域的数字为 0 个或者 1 个也就是指 start 或 end 与 middle 相等或相差 1。

我们来分析一下这四个判断条件:

  • 当 start == middle 时,相当于 quickSort(arr, start, middle - 1) 中的 start == end + 1
  • 当 start == middle - 1 时,相当于 quickSort(arr, start, middle - 1) 中的 start == end
  • 当 middle == end 时,相当于 quickSort(arr, middle + 1, end) 中的 start == end + 1
  • 当 middle == end -1 时,相当于 quickSort(arr, middle + 1, end) 中的 start == end

综上,我们可以将此边界条件统一移到 quickSort 函数之前:

public static void quickSort(int[] arr, int start, int end) {// 如果区域内的数字少于 2 个,退出递归if (start == end || start == end + 1) return;// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 对左边区域快速排序quickSort(arr, start, middle - 1);// 对右边区域快速排序quickSort(arr, middle + 1, end);
}

        更进一步,由上文所说的 middle >= start && middle <= end 可以推出,除了start == end || start == end + 1这两个条件之外,其他的情况下 start 都小于 end。所以我们可以将这个判断条件再次简写为:

public static void quickSort(int[] arr, int start, int end) {// 如果区域内的数字少于 2 个,退出递归if (start >= end) return;// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 对左边区域快速排序quickSort(arr, start, middle - 1);// 对右边区域快速排序quickSort(arr, middle + 1, end);
}

        这样我们就写出了最简洁版的边界条件,我们需要知道,这里的 start >= end 实际上只有两种情况:

  • start == end: 表明区域内只有一个数字
  • start == end + 1: 表明区域内一个数字也没有

不会存在 start 比 end 大 2 或者大 3 之类的。

分区算法实现


        快速排序中最重要的便是分区算法,也就是 partition 函数。大多数人都能说出快速排序的整体思路,但实现起来却很难一次写对。主要问题就在于分区时存在的各种边界条件,需要读者亲自动手实践才能加深体会。

        上文已经说到,partition 函数需要做的事情就是将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标。那么首先我们要做的事情就是选择一个基数,基数我们一般称之为 pivot,意为“轴”。整个数组就像围绕这个轴进行旋转,小于轴的数字旋转到左边,大于轴的数字旋转到右边。(所谓的双轴快排就是一次选取两个基数,将数组分为三个区域进行旋转,关于双轴快排的内容我们将在后续章节讲解。)

基数的选择


        基数的选择没有固定标准,随意选择区间内任何一个数字做基数都可以。通常来讲有三种选择方式:

  • 选择第一个元素作为基数
  • 选择最后一个元素作为基数
  • 选择区间内一个随机元素作为基数

选择的基数不同,算法的实现也不同。实际上第三种选择方式的平均时间复杂度是最优的,待会分析时间复杂度时我们会详细说明。

本文通过第一种方式来讲解快速排序:

// 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
public static int partition(int[] arr, int start, int end) {// 取第一个数为基数int pivot = arr[start];// 从第二个数开始分区int left = start + 1;// 右边界int right = end;// TODO
}

最简单的分区算法


        分区的方式也有很多种,最简单的思路是:从 left 开始,遇到比基数大的数,就交换到数组最后,并将 right 减一,直到 left 和 right 相遇,此时数组就被分成了左右两个区域。再将基数和中间的数交换,返回中间值的下标即可。

按照这个思路,我们敲出了如下代码:

public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int start, int end) {// 如果区域内的数字少于 2 个,退出递归if (start >= end) return;// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 对左边区域快速排序quickSort(arr, start, middle - 1);// 对右边区域快速排序quickSort(arr, middle + 1, end);
}
// 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
public static int partition(int[] arr, int start, int end) {// 取第一个数为基数int pivot = arr[start];// 从第二个数开始分区int left = start + 1;// 右边界int right = end;// left、right 相遇时退出循环while (left < right) {// 找到第一个大于基数的位置while (left < right && arr[left] <= pivot) left++;// 交换这两个数,使得左边分区都小于或等于基数,右边分区大于或等于基数if (left != right) {exchange(arr, left, right);right--;}}// 如果 left 和 right 相等,单独比较 arr[right] 和 pivotif (left == right && arr[right] > pivot) right--;// 将基数和中间数交换if (right != start) exchange(arr, start, right);// 返回中间值的下标return right;
}
private static void exchange(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}

        因为我们选择了数组的第一个元素作为基数,并且分完区后,会执行将基数和中间值交换的操作,这就意味着交换后的中间值会被分到左边区域。所以我们需要保证中间值的下标是分区完成后,最后一个比基数小的值,这里我们用 right 来记录这个值。

        这段代码有一个细节。首先,在交换 left 和 right 之前,我们判断了 left != right,这是因为如果剩余的数组都比基数小,则 left 会加到 right 才停止,这时不应该发生交换。因为 right 已经指向了最后一个比基数小的值。

        但这里的拦截可能会拦截到一种错误情况,如果剩余的数组只有最后一个数比基数大,left 仍然加到 right 停止了,但我们并没有发生交换。所以我们在退出循环后,单独比较了 arr[right] 和 pivot。

        实际上,这行单独比较的代码非常巧妙,一共处理了三种情况:

  • 一是刚才提到的剩余数组中只有最后一个数比基数大的情况
  • 二是 left 和 right 区间内只有一个值,则初始状态下, left == right,所以 while (left < right) 根本不会进入,所以此时我们单独比较这个值和基数的大小关系
  • 三是剩余数组中每个数都比基数大,此时 right 会持续减小,直到和 left 相等退出循环,此时 left 所在位置的值还没有和 pivot 进行比较,所以我们单独比较 left 所在位置的值和基数的大小关系

双指针分区算法

        除了上述的分区算法外,还有一种双指针的分区算法更为常用:从 left 开始,遇到比基数大的数,记录其下标;再从 right 往前遍历,找到第一个比基数小的数,记录其下标;然后交换这两个数。继续遍历,直到 left 和 right 相遇。然后就和刚才的算法一样了,交换基数和中间值,并返回中间值的下标。

代码如下:

public static void quickSort(int[] arr) {quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int start, int end) {// 如果区域内的数字少于 2 个,退出递归if (start >= end) return;// 将数组分区,并获得中间值的下标int middle = partition(arr, start, end);// 对左边区域快速排序quickSort(arr, start, middle - 1);// 对右边区域快速排序quickSort(arr, middle + 1, end);
}
// 将 arr 从 start 到 end 分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标
public static int partition(int[] arr, int start, int end) {// 取第一个数为基数int pivot = arr[start];// 从第二个数开始分区int left = start + 1;// 右边界int right = end;while (left < right) {// 找到第一个大于基数的位置while (left < right && arr[left] <= pivot) left++;// 找到第一个小于基数的位置while (left < right && arr[right] >= pivot) right--;// 交换这两个数,使得左边分区都小于或等于基数,右边分区大于或等于基数if (left < right) {exchange(arr, left, right);left++;right--;}}// 如果 left 和 right 相等,单独比较 arr[right] 和 pivotif (left == right && arr[right] > pivot) right--;// 将基数和轴交换exchange(arr, start, right);return right;
}
private static void exchange(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}

        同样地,我们需要在退出循环后,单独比较 left 和 right 的值。

        从代码实现中可以分析出,快速排序是一种不稳定的排序算法,在分区过程中,相同数字的相对顺序可能会被修改。

时间复杂度 & 空间复杂度

        快速排序的时间复杂度上文已经提到过,平均时间复杂度为 O(nlogn),最坏的时间复杂度为 
O(n^2),空间复杂度与递归的层数有关,每层递归会生成一些临时变量,所以空间复杂度为 
O(logn) ~ O(n),平均空间复杂度为 O(logn)。

快速排序的优化思路

        第一种就是我们在前文中提到的,每轮选择基数时,从剩余的数组中随机选择一个数字作为基数。这样每轮都选到最大或最小值的概率就会变得很低了。所以我们才说用这种方式选择基数,其平均时间复杂度是最优的

        第二种解决方案是在排序之前,先用洗牌算法将数组的原有顺序打乱,以防止原数组正序或逆序。

        Java 已经将洗牌算法封装到了集合类中,即 Collections.shuffle() 函数。洗牌算法由 Ronald A.Fisher 和 Frank Yates 于 1938 年发明,思路是每次从未处理的数据中随机取出一个数字,然后把该数字放在数组中所有未处理数据的尾部。 Collections.shuffle() 函数源码如下:

private static final int SHUFFLE_THRESHOLD = 5;public static void shuffle(List<?> list, Random rnd) {int size = list.size();if (size < SHUFFLE_THRESHOLD || list instanceof RandomAccess) {for (int i=size; i>1; i--)swap(list, i-1, rnd.nextInt(i));} else {Object arr[] = list.toArray();// Shuffle arrayfor (int i=size; i>1; i--)swap(arr, i-1, rnd.nextInt(i));// Dump array back into list// instead of using a raw type here, it's possible to capture// the wildcard but it will require a call to a supplementary// private methodListIterator it = list.listIterator();for (int i=0; i<arr.length; i++) {it.next();it.set(arr[i]);}}
}public static void swap(List<?> list, int i, int j) {// instead of using a raw type here, it's possible to capture// the wildcard but it will require a call to a supplementary// private methodfinal List l = list;l.set(i, l.set(j, l.get(i)));
}private static void swap(Object[] arr, int i, int j) {Object tmp = arr[i];arr[i] = arr[j];arr[j] = tmp;
}

        从源码中可以看出,对于数据量较小的列表(少于 5 个值),shuffle 函数直接通过列表的 set 方法进行洗牌,否则先将 list 转换为 array,再进行洗牌,以提高交换效率,洗牌完成后再将 array 转成 list 返回。

        还有一种解决方案,既然数组重复排序的情况如此常见,那么我们可以在快速排序之前先对数组做个判断,如果已经有序则直接返回,如果是逆序则直接倒序即可。在 Java 内部封装的 Arrays.sort() 的源码中就采用了此解决方案。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/57434.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

香港服务器怎么打开SSH

​  SSH是一种远程登录协议&#xff0c;可以通过加密方式在网络上安全地传输数据。它允许用户在远程服务器上执行命令&#xff0c;管理文件和目录&#xff0c;并进行其他系统管理任务。 如何打开SSH服务? 1.确认已安装OpenSSH服务器&#xff1a; 你可以通过命令sudoapt-geti…

Excel显示此值与此单元格定义的数据验证限制不匹配怎么办?

总结&#xff1a;1、在编辑excel文档的时候&#xff0c;弹出此时预测单元格定义的数据验证&#xff0c;限制不匹配的提示。2、这是我们点击菜单来的数据菜单。3、然后点击数据工具栏的数据验证下拉按钮。4、在弹出的菜单中选择数据验证的菜单项。5、然后在打开的窗口中点击左下…

Jmeter(二十七):BeanShell PostProcessor跨线程全局变量使用

在性能测试中&#xff0c;两个相关联的接口不一定都在同一个线程组&#xff0c;遇见这种情况时&#xff0c;我们要进行跨线程组传参&#xff0c;此处用登录和查询配送单两个请求举例&#xff1b; 1、登录请求中配置json提取器&#xff0c;将接口返回的token保存在变量中&#…

Python爬虫异常处理实践:处理被封禁和网站升级问题

在这篇文章中&#xff0c;我们将一起探讨Python爬虫异常处理实践&#xff0c;特别关注处理被封禁和网站升级问题。让我们一起来看看如何解决这些问题&#xff0c;提高我们爬虫程序的稳定性和可靠性。   首先&#xff0c;我们要了解为什么会遇到这些问题。网站封禁爬虫的原因主…

从C语言到C++_34(C++11_下)可变参数+ lambda+function+bind+笔试题

目录 1. 可变参数模板 1.1 展开参数包 1.1.1 递归函数方式展开 1.1.2 逗号表达式展开 1.2 emplace相关接口 2. lambda表达式&#xff08;匿名函数&#xff09; 2.1 C11之前函数的缺陷 2.2 lambda表达式语法 2.3 函数对象与lambda表达式 3. 包装器 3.1 function包装器…

不同子网络中的通信过程

从输入www.baidu.com经历了什么 一、DNS&#xff08;网址->IP&#xff09; 二、ARP&#xff08;IP->MAC&#xff09; A->B&#xff1a;有数据发送&#xff0c;数据封装ip之后发现没有主机B的mac地址。然后ARP在本网段广播&#xff1a;检查目标地址和源地址是否在同一…

【程序猿书籍大放送:第二期】《强化学习:原理与Python实战》

&#x1f339;欢迎来到爱书不爱输的程序猿的博客, 本博客致力于知识分享&#xff0c;与更多的人进行学习交流 爱书不爱输的程序猿&#xff1a;送书第二期 一、搞懂大模型的智能基因&#xff0c;RLHF系统设计关键问答1.RLHF是什么&#xff1f;2.RLHF适用于哪些任务&#xff1f;3…

MySQL日志管理 备份和恢复

备份的主要目的是灾难恢复&#xff0c;备份还可以测试应用、回滚数据修改、查询历史数据、审计等。 而备份、恢复中&#xff0c;日志起到了很重要的作用 #######前言&#xff1a;日志⭐⭐ MySQL 的日志默认保存位置为 /usr/local/mysql/data ##配置文件 vim /etc/my.cnf [mys…

c++23中的新功能之十三Ranges的as系列

一、介绍 在前面反复提到过&#xff0c;c编程的语法也越来越向着自然语言方向发展&#xff0c;今天这个as_函数就是如此&#xff0c;一般学习过英文的都知道as是啥意思。非常容易理解&#xff0c;所以c23中用这个词来描述函数&#xff0c;其实也是非常走心了。 二、as_const …

Git仓库简介

1、工作区、暂存区、仓库 工作区&#xff1a;电脑里能看到的目录。 暂存区&#xff1a;工作区有一个隐藏目录.git&#xff0c;是Git的版本库&#xff0c;Git的版本库里存了很多东西&#xff0c;其中最重要的就是称为stage&#xff08;或者叫index&#xff09;的暂存区&#xf…

vue数字输入框

目录 1.emitter.JS function broadcast (componentName, eventName, params) {this.$children.forEach(child > {var name child.$options.componentNameif (name componentName) {child.$emit.apply(child, [eventName].concat(params))} else {broadcast.apply(child, …

基于Spring Boot的软件缺陷追踪系统的设计与实现(Java+spring boot+MySQL)

获取源码或者论文请私信博主 演示视频&#xff1a; 基于Spring Boot的软件缺陷追踪系统的设计与实现&#xff08;Javaspring bootMySQL&#xff09; 使用技术&#xff1a; 前端&#xff1a;html css javascript jQuery ajax thymeleaf 微信小程序 后端&#xff1a;Java spri…

GNS3 在 Linux 上的安装指南

文章目录 GNS3 在 Linux 上的安装指南1. 基于 Ubuntu 的发行版安装 GNS32. 基于 Debian 的安装3. 基于 ArchLinux 的安装4. 从 Pypi 安装 GNS35. 启动 GNS3 服务端GNS3 在 Linux 上的安装指南 大家好,今天我们来聊聊如何在 Linux 上安装 GNS3。GNS3 是一个非常受欢迎的网络模…

服务器数据恢复-reiserfs文件系统损坏如何恢复数据?

服务器数据恢复环境&#xff1a; 一台IBM X系列服务器&#xff0c;4块SAS硬盘组建一组RAID5阵列&#xff0c;采用的reiserfs文件系统。服务器操作系统分区结构&#xff1a;boot分区LVM卷swap分区&#xff08;按照前后顺序&#xff09;。LVM卷中直接划分了一个reiserfs文件系统&…

文心一言接入Promptulate,开发复杂LLM应用程序

简介 最近在尝试将文心一言的LLM能力接入Promptulate&#xff0c;故写了一篇博客记录一下&#xff0c;Promptulate 是 Promptulate AI 旗下的大语言模型自动化与应用开发框架&#xff0c;旨在帮助开发者通过更小的成本构建行业级的大模型应用&#xff0c;其包含了LLM领域应用层…

FusionAD:用于自动驾驶预测和规划任务的多模态融合

论文背景 自动驾驶&#xff08;AD&#xff09;任务通常分为感知、预测和规划。在传统范式中&#xff0c;AD中的每个学习模块分别使用自己的主干&#xff0c;独立地学习任务。 以前&#xff0c;基于端到端学习的方法通常基于透视视图相机和激光雷达信息直接输出控制命令或轨迹…

Spring Boot(Vue3+ElementPlus+Axios+MyBatisPlus+Spring Boot 前后端分离)【一】

&#x1f600;前言 本篇博文是关于Spring Boot(Vue3ElementPlusAxiosMyBatisPlusSpring Boot 前后端分离)【一】&#xff0c;希望你能够喜欢 &#x1f3e0;个人主页&#xff1a;晨犀主页 &#x1f9d1;个人简介&#xff1a;大家好&#xff0c;我是晨犀&#xff0c;希望我的文章…

【如何对公司网络进行限速?一个案例详解】

有不少朋友问到了关于企业网络QoS配置&#xff0c;这个确实在实际网络应用中非常多&#xff0c;基本上大部分企业或个人都用到这个功能&#xff0c;本期我们详细了解下QoS如何对宽带进行限制&#xff0c;QoS如何企业中应用。 一、什么是QoS? Qos是用来解决网络延迟和阻塞等问…

基于微信小程序的文化宣传平台的设计与实现(Java+spring boot+微信小程序+MySQL)

获取源码或者论文请私信博主 演示视频&#xff1a; 基于微信小程序的文化宣传平台的设计与实现&#xff08;Javaspring boot微信小程序MySQL&#xff09; 使用技术&#xff1a; 前端&#xff1a;html css javascript jQuery ajax thymeleaf 微信小程序 后端&#xff1a;Java…

SSM(Spring+SpringMVC+MyBatis)整合

目录 1、提出问题 2、解决问题 3、相关文件 1、提出问题 SSM&#xff08;SpringSpringMVCMyBatis&#xff09;的开发&#xff0c;MyBatis在没有与Spring和SpringMVC整合的时候&#xff0c;是单独使用&#xff0c;单独配置。 Spring和SpringMVC的整合是无缝衔接的&#xff0…