前端不懂算法(四)--快速排序

本篇文章参考借鉴了以下文章和极客时间的数据结构与算法一文,若涉及侵权,请联系我删除。

前言

  上一篇博客我们一块学习了, 归并排序,它的时间复杂度是 O(nlogn),是一种排序效率很高的算法,本篇文章,我们一块来学习一种效率高,并且名气很大的排序算法。快速排序

没看过之前文章的小伙伴可以先了解一下

核心思想

  快速排序的核心思想也是 分治思想 ,我们习惯叫它“快排”。乍看起来快排很像归并排序,但是实现原理完全不同。稍后我们讨论一下它们之间的区别

快速排序原理

  如果要排序数组中下标从 pr 之间的一组数据,我们选择 pr 之间的任意一个数据作为 pivot (区分点)。

  我们遍历 pr 之间的数据,把大于 pivot 的放到右边,小于 pivot 的放到左边,将 pivot 放到中间。经过这一步之后,数组 pr 之间的数据就被分成了三部分,前面的 pq - 1 之间的都是小于 pivot ,后面的 q + 1r 之间的都是大于 pivot 的。
在这里插入图片描述

  根据分治、递归的思想,我们可以用递归排序下标从 pq - 1 之间的数据和下标从 q + 1r 之间的数据,直到区间缩小为 1 ,就说明所有的数据都有序了。

  用递推公式写出上面的过程如下:

  递推公式:

  quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q + 1…r)

  终止条件:

  p >= r

  将递推公式转化成代码如下:

//快速排序
function quickSort(arr) {
    quickSortFn(arr, 0, arr.length-1)
}

//快速排序递归函数,p r为下标
function quickSortFn(arr, p, r) {
    if (p >= r) return;
    
    //获得区分点    
    q = partition(arr, p, r)
    quickSortFn(arr, p, q-1);
    quickSortFn(arr, q+1, r);
}

  归并排序有一个 merge() 合并函数,我们这里有一个 partition() 分区函数。partition()分区函数实际上我们已经讲过原理了,就是随机选择一个元素作为 pivot (一般情况下可以选择 p 到 r 的最后一个元素),然后对 arr[p…r] 分区,函数返回 pivot 的下标。

  如果不考虑空间消耗的话,partition() 分区函数可以写的非常简单。我们申请两个临时数组 XY ,遍历 arr[p…r] 将小于 pivot 的元素拷贝到临时数组 X ,将大于 pivot 的元素拷贝到临沭数组 Y ,最后再将数组 X,Y 中的数据按顺序拷贝到 arr[p…r]
在这里插入图片描述
  但是如果按照这种思路实现的话, partition() 函数就需要很多额外的内存空间,这样快排就不是原地排序算法了。如果我们希望快排是原地排序算法,那它的空间复杂度就得是O(1) ,那 partition() 函数就不能占用太多空间,我们就需要在 arr[p…r] 的原地完成分区操作。

  原地分区函数的实现非常巧妙,代码如下:

//快速排序
function quickSort(arr) {
    quickSortFn(arr, 0, arr.length-1)
}

//快速排序递归函数,p r为下标
function quickSortFn(arr, p, r) {
    if (p >= r) return;
    
    //获得区分点    
    q = partition(arr, p, r)
    
    quickSortFn(arr, p, q-1);
    quickSortFn(arr, q+1, r);
}

function partition(arr, p, r) {
    let pivot = arr[r],
        i = p,
        tmp = '';
    for (let j = p; j <= r-1; j++) {
        if (arr[j] < pivot) {
            tmp = arr[i];
            arr[i] = arr[j];
            arr[j] = tmp;
            i++
        }
    }
    tmp = arr[i];
    arr[i] = arr[r];
    arr[r] = tmp;
    debugger
    return i;
}
let arr = [10, 8, 6, 4, 9, 7, 5];
quickSort(arr);
console.log(arr) //[4, 5, 6, 7, 8, 9, 10]

  这里处理有点类似选择排序。我们通过游标 iarr[p…r-1] 分成两部分。arr[p…i-1] 的元素都是小于 pivot 的,我们暂且叫它“已处理区”,arr[i…r-1] 是“未处理区”。我们每次都从“未处理区间” arr[i…r-1] 中取一个元素 arr[j]pivot 对比,如果小于 pivot ,则将其加入到已处理区间,也就是 arr[i] 的位置。

  数组的插入操作还记得吗?在数组某个位置插入元素,需要搬移数据,非常耗时。这时需要一种处理技巧,就是交换,在 O[1] 的时间复杂度内完成插入操作。借助这个思想,只需要将 arr[i]arr[j] 的数据交换,就可以在 O[1] 的时间复杂度内将 arr[j] 放入到下标为 i 的位置。

  文字不如图直观,上图来看
在这里插入图片描述
  因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6, 8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变,所以快排并不是一个稳定排序算法。

快排与归并的区别

  现在明白了快排的原理,我们分析一下快排与上篇文章的归并有什么区别,这两个都是用的分治思想,递推公式和递推代码也很相似,那他们的区别在哪?
在这里插入图片描述

  由图中可以看出,归并排序的处理过程是 由下到上 的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是 由上到下 的,先分区然后再处理子问题。归并排序虽然是稳定的,时间复杂度为 O(nlogn) 的排序算法,但它是非原地排序算法。上篇文章讲过,非原地排序算法,主要原因是合并函数无法在原地执行。 快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。

快排性能分析

  上面已经说明了稳定性和原地排序,接下来重点分析一下时间复杂度。

  快排是递归实现的,对于递归代码的时间复杂度,前面总结的公式依然适用。不记得的小伙伴可以查看以下文章。

  如果每次分区操作都能把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式和归并是相同的。所以时间复杂度也是 O(nlogn)

  • T(1) = C; n = 1 时,只需要常量级的执行时间。
  • T(n) = 2*T(n) + n; n > 1

  但是公式成立的前提条件是每次分区操作,我们选择的 pivot 都很合适,正好能将大区间对等的一分为二。但实际上这种情况很难实现。

  举一个极端的栗子,如果排序一个已经有序的数组,[1,3,5,6,8]。如果每次选择最后一个元素作为 pivot ,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区,才能完成快排整个过程。每次分区平均要扫描 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化到了 O(n2)

  上面是两个极端例子,分别对应最好时间复杂度和最坏时间复杂度,那平均时间复杂度是多少呢?

  假设每次分区操作都将区间分成 9 : 1 两个小区间。我们继续套用递归时间复杂度的递推公式,就会变成下面这样。

  • T(1) = C; n = 1 时,只需要常量级的执行时间。
  • T(n) = T(n/10) + T(9*n/10) + n; n > 1

  这个过程比较复杂,不推荐使用,还有一种方法求解递归时间复杂度, 递归树 这个请关注我后续的博客内容,有兴趣的小伙伴可以自己推导下。

  接下来我们直接给出结果, T(n) 在大部分情况下都可以做到 O(nlogn) ,只有在极端情况下,才会出现 O(n2) 也有很多方法降低这个概率,请关注我的后续博客。

解答开篇

   快排核心思想是 分治 分区 ,我们可以利用分区的思想,解答开篇的问题:

  • O(n) 时间复杂度内求无序数组中的第 K 大元素。比如[4, 2, 5, 12, 3] 这样一组数据,第 3 大元素就是 4 。

   我们选择数组区间 A [0…n-1] 的最后一个元素 A [n-1] 作为 pivot ,对数组 A [0…n-1] 原地分区,这样数组就分成了三个部分, A [0…p-1] A [p] A [p+1…n-1]

   如果 p + 1 = K, 那 A [p] 就是要求解的元素;如果 K > p + 1 说明 K 大元素在 A [p+1…n-1] 区间上,在按照上面思路递归的在 A [p+1…n-1] 区间上查找。

内容小结

   归并排序和快速排序是两种稍微复杂的排序算法,用的都是分治的思想,代码都是通过递归实现的,过程很相似。理解归并排序的重点是理解 递推公式merge()函数 ,同理理解快速排序的中点是 递推公式partition()分区函数

   归并排序算法是一种在任何情况下时间复杂度都稳定的算法,但是空间复杂度较高,这个致命缺点导致它没有快排应用广泛。

   快速排序最坏的情况下时间复杂度是 O(n2) ,而且可以通过合理的选择 pivot 来避免这种情况。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值