前端学数据结构与算法(十):深入理解快速排序
前言
上一章我们已经实现了快速排序,在数据理想化的情况下,上一章的快排性能确实也不错,但如果数据比较极端的,快排的O(nlogn)
就不太稳定了,本章将介绍几种快排应对极端数据下优化方案;以及介绍partition
操作延伸出来的快速选择算法在解决top K
问题时高效。
优化分区点的选择
上一章我们直接选择了数组的范围内的第一个元素作为分区点,当数据有序度很低时,这样选择确实也没问题。但是如果本身就是一个有序数组,这个时候就会出问题:
因为partition
的操作是以分区点为中心,将数组一分为二。而此时数组是有序的,也就是说每次选择的这个分区点无法将数组一分为二,会导致快排最终的复杂度退化为O(n²)
。所以此时要改变选择分区点的规则。
三数取中
不仅是从头部,无论是从数组哪个位置,只要是单单选择一个位置,都有可能出现退化的情况。所以我们可以多选几个位置从里面挑一个出来。如从范围数组中的头、中间、尾选择三个元素,比较它们的大小,选择中间大小的值作为分区点。这样就能避免遇到有序数组时的退化情况,代码如下:
const quickSort = arr => { const _quickSort = (arr, l, r) => { if (l >= r) { // 递归终止条件 return } const p = _partition(arr, l, r) // 返回分区点所在下标 _quickSort(arr, l, p - 1) // 递归进行左半部分 _quickSort(arr, p + 1, r) // 递归进行右半部分 } _quickSort(arr, 0, arr.length - 1) } function _partition(arr, l, r) { // 三数取中分区点 const mid = l + (r - l) / 2 | 0 // 中间位置 const t1 = arr[l] > arr[mid] ? l : mid const t2 = arr[t1] > arr[r] ? r : t1 swap(arr, l, t2) // 让最左侧的l和中间大小的值交换位置 const v = arr[l] // 让中间值作为分区点 let j = l for (let i = l + 1; i <= r; i++) { // 从l + 1,刨去分区点的位置 if (v > arr[i]) { // 当分区点大于当前节点时 swap(arr, j + 1, i) // 让大区间的开头与当前节点交换 j++ // 小区分范围增加 } } swap(arr, j, l) // 最后让分区点回到其所在位置 return j // 返回其下标,进行接下来的分区操作 } function swap (arr, i, j) { [arr[i], arr[j]] = [arr[j], arr[i]] }
随机选择
从被需要排序数组的区间中随机选择一个位置作为分区点,虽说不能保证每次都选到合适的值作为分区点,但从概率来说,每一次都选到数组里最大或最小的值,概率几乎为0
,理想中能保持O(nlogn)
的复杂度,这个方法也经常被采用。代码如下:
const quickSort = arr => { const _quickSort = (arr, l, r) => { if (l >= r) { // 递归终止条件 return } const p = _partition(arr, l, r) // 返回分区点所在下标 _quickSort(arr, l, p - 1) // 递归进行左半部分 _quickSort(arr, p + 1, r) // 递归进行右半部分 } _quickSort(arr, 0, arr.length - 1) } function _partition(arr, l, r) { // 随机选择分区点 const rdmIndex = Math.random() * (r - l + 1) + l | 0; swap(arr, l, rdmIndex) // 让最左侧的l与随机下标交换位置 const v = arr[l] // 让随机值作为分区点 ... }
应对重复数据过多的三路快排
上面我们解决了分区点选择的问题,但此时又有一个新的问题。假如一个数组里面有100
万条数据,但它的取值范围都是0 ~ 10
,此时再采用之前的partition
算法就不行了。因为重复数据比较多,而上面partition
里没有对值相等时的情况处理,会造成相等的数据全部堆积在分区数组其中的一边,又回到上一个问题,会导致分区极度不平衡。
此时我们可以使用三路快排,它会对相等的数据做单独的处理,不在仅仅是一分为二,而是一分为三,将相等的数据单独作为一个区间。而且再进行递归时,可以将相等的区间刨除在外,只对小区间或大区间进行partition
操作,减少操作次数。图解示例如下:
还是有两个边界下标left/right
,游走下标更换为lt/i/gt
,lt
表示为小区间的最后一位,i
表示为当前访问到的元素,也可以理解为等于区间的最后一位,gt
表示大区间的第一位。这次的partition
操作将比较分为三种情况:
- 小于分区点
我们需要将lt + 1
与i
进行交换,并将lt
和i
依次进行后移。因为lt
表示为小区间的最后一位,所以lt + 1
就表示等于区间的第一个元素,而此时i
又小于区间值,所以交换后lt
依然是小区间的最后一位,而i
继续遍历下一个元素。
- 大于分区点
我们需要将gt - 1
与i
进行交换,因为gt
表示的是当前大区间的第一位,而gt - 1
则表示最红等于区间的最后一位,交换位置之后,大区间的范围也就增加了。此时仅仅gt
前移一位即可,i
不需要移动,因为交换过去的值还不确定它的大小,正好作为当前元素即可。
- 等于分区点 那么
i + 1
即可,增加等于区间的范围及遍历下一个元素,lt/gt
坐标都不需要移动。最终i
与gt
碰上之后结束遍历过程。
代码如下:
const quickSort3Ways = arr => { const _quickSort3Ways = (arr, l, r) => { if (l >= r) { return } const rdmIndex = Math.random() * (r - l + 1) + l | 0 // 选择随机分区点 [arr[l], arr[rdmIndex]] = [arr[rdmIndex], arr[l]] const v = arr[l] let lt = l let gt = r + 1 let i = l + 1 while (i < gt) { if (arr[i] < v) { // 小于区间值 [arr[i], arr[lt + 1]] = [arr[lt + 1], arr[i]] lt++ i++ } else if (arr[i] > v) { // 大于区间值 [arr[i], arr[gt - 1]] = [arr[gt - 1], arr[i]] gt-- } else { // 等于区间值 i++ } } [arr[l], arr[lt]] = [arr[lt], arr[l]] // 交换分区点与小区间的最后一位,维持三个区间 _quickSort3Ways(arr, l, lt - 1) // [l ... lt-1]表示的就是小区间 _quickSort3Ways(arr, gt, r) // [gt ... r]表示的就是大区间 // 而直接可以舍弃等于区间,提高效率 } _quickSort3Ways(arr, 0, arr.length - 1) }
改用遍历的方式,不用担心调用栈溢出的情况,且三分之后,相等区间的范围在每次遍历时都可以直接忽略掉,因为它们已经在最终排好序的位置。
使用插入排序代替小范围排序
O(nlogn)
的排序算法的确是比O(n²)
快很多,但这描述的是随着数据规模n
的增长而增长的趋势,这里忽略了常数以及低阶的情况,而当n
小到一定常数时使用插入快排代替就是一种合理的选择,因为范围小则它有序度高的几率就大,插入排序在应对近似有序时的效率又奇高。可以这么理解,快排虽然快,但它的启动会慢一些。
const quickSort = arr => { const _quickSort = (arr, l, r) => { if (r - l < 10) { // 终止条件替换为插入排序 insertSort(arr, l, r) return } const p = _partition(arr, l, r) // 返回分区点所在下标 _quickSort(arr, l, p - 1) // 递归进行左半部分 _quickSort(arr, p + 1, r) // 递归进行右半部分 } _quickSort(arr, 0, arr.length - 1) ... }
当要待排序的数组小到一定程度时,我们改为插入排序,同时这里的插入排序也需要改一下,给它限定排序的范围:
const insertSort = (arr, l, r) => { for (let i = l + 1; i <= r; i++) { let e = arr[i] let j; for (j = i; j > l && arr[j - 1] > e; j--) { arr[j] = arr[j - 1] } arr[j] = e } }
比堆更有效率的解决top-K问题
这是之前第七章介绍堆的一个力扣题目,当时使用的堆解决,堆能在O(nlogk)
的时间复杂度里解出,这已经是合格的解法了,不过在此借鉴快排的partition
思想后,能交出O(n)
的满分答案。先来回顾下题目:
215-数组中的第K个最大元素 ↓
在未排序的数组中找到第 k 个最大的元素。 请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。 示例: 输入: [3,2,1,5,6,4] 和 k = 2 输出: 5
我们知道partition
操作会把分区点放在合适的位置,最后返回它所在的下标,而这个下标恰恰就是当数组完全排好序后,它正好所在的位置,所以我们只需要找到下标正好等于K
的元素即可。
这里又会有两种情况,返回的下标大于K
或者小于K
,因为partition
操作已经对数组进行了分区,所以只需要将返回的下标与K
进行比较即可,如果大于就去大区间查找,反之亦然。每次查找平均可以舍弃一半的查找范围,所以这个算法最差也是O(n)
的时间复杂度。代码如下:
var findKthLargest = function (nums, k) { if (nums.length === 0 || k > nums.length || k < 0) { return -1; } const _partition = (nums, l, r) => { // 返回对应下标 const rdmIndex = (Math.random() * (r - l + 1) + l) | 0; [nums[rdmIndex], nums[l]] = [nums[l], nums[rdmIndex]]; const v = nums[l]; let j = l; for (let i = l + 1; i <= r; i++) { if (nums[i] > v) { // 采用降序,正好对应第k大 [nums[i], nums[j + 1]] = [nums[j + 1], nums[i]]; j++; } } [nums[l], nums[j]] = [nums[j], nums[l]]; return j; }; let l = 0; let r = nums.length - 1; while (true) { const p = _partition(nums, l, r); if (p + 1 === k) { // 下标需要加1, 0表示第1大 return nums[p]; } else if (p < k) { // 缩小partition的范围 l = p + 1; } else { r = p - 1; } } };
严格意义上来说,partition
的思想确实在解决这个题目时是最快的解法,但其实它和用堆解法也是各有优劣。
当这个数组是动态随时会有新数据加入其中时,当前解法每次又需要O(n)
时间去查找。而堆应对这种场景就有优势了,面对动态的数组集合,每次只需要重新维护堆结构即可,在O(logn)
复杂度即可返回结果。
最后
本章我们介绍了关于快排在面对极端数据时的优化以及它延伸出来的快速选择算法,还有在面对高频面试题Top-K
问题时与堆处理的优劣(还有尾递归的优化,貌似浏览器不支持,就不列出了)
。完全理解快排也算是打通了算法的任督二脉,为更难理解的算法打好基础。作为排序里面的明星算法,快排值得一整个章节。

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
Spring Cloud? Kubernetes? Istio?服务治理方案怎么选
不同选择分别代表了两个方向: embedded component 内嵌组件 如(Ribbon,DiscoveryClient,Hystrix,Sleuth等) attached sidecar 附着挎斗 (如 kube-proxy, istio sidecar等) sidecar分离了服务治理所需的部分职能到与业务应用不同的进程中完成,也代表着: 业务应用的技术栈选择更加灵活 服务治理的配置与业务应用分离 服务治理的配置对于DevOps人员更加友好 服务治理相关功能的演进速度更快 这些好处显而易见,在happy-path下我们可能无往不利 但同时也意味着: 熔断、限流等需要DevOps人员不止关注到业务应用,还需要对不同接口进行配置管理。可能需要和开发人员对齐不同接口的设计规格(流量、负载、可用性指标等) 根据老马对熔断的描述,熔断不单单是断掉就万事大吉,需要我们关注降级处理方案(fallback)(注1),而这些istio并未提供支持(注2,3) 灰度/金丝雀发布在istio中的实现方案(注7、8)基于header(cookie),意味着可能会信息泄露或被客户端仿造,若内部组件控...
- 下一篇
关于监控的那些事,你有必要了解一下
作者 | 乔克 来源 |运维开发故事 分享 | 乔克 监控是整个运维以及产品整个生命周期最重要的一环,它旨在事前能够及时预警发现故障,事中能够结合监控数据定位问题,事后能够提供数据用于分析问题。 一、监控的目的 监控贯穿应用的整个生命周期。即从程序设计、开发、部署、下线。其主要的服务对象有: 技术 业务 技术通过监控系统可以了解技术的环境状态,可以帮助检测、诊断、解决技术环境中的故障和问题。然而监控系统的最终目标是业务,是为了更好的支持业务运行,确保业务的持续开展。 所以监控的目的可以简单归纳如下:1、能够对系统进行7*24小时的实时监控 2、能够及时反馈系统状态 3、保证平台的稳定运行 3、保证服务的安全可靠 4、保证业务的持续运行 二、监控的模式 监控由上至下可以分为: 业务监控 应用监控 操作系统 其中业务监控主要是研发提供一些业务指标、业务数据,对其增长率、错误率等进行告警或者展示,需要提前定义规范甚至埋点。应用程序的监控主要有探针和内省。其中探针主要是从外部探测应用程序的特征,比如监听端口是否有响应。内省主要是查看应用程序内部的内容,应用程序通过检测并返回其内部的状态,内部的...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- SpringBoot2编写第一个Controller,响应你的http请求并返回结果
- CentOS7编译安装Gcc9.2.0,解决mysql等软件编译问题
- CentOS8编译安装MySQL8.0.19
- CentOS8安装Docker,最新的服务器搭配容器使用
- CentOS8,CentOS7,CentOS6编译安装Redis5.0.7
- SpringBoot2整合Redis,开启缓存,提高访问速度
- Mario游戏-低调大师作品
- Docker安装Oracle12C,快速搭建Oracle学习环境
- CentOS7安装Docker,走上虚拟化容器引擎之路
- CentOS6,7,8上安装Nginx,支持https2.0的开启