数组本身结构简单,但是对数组进行操作会涉及到很多经典算法,这使得数组成为了面试中最热门的考点之一。这篇文章就总结一下求数组的第k
小/第k
大元素,或者中位数的问题。
一、求数组中第k小/第k大元素
问题:输入一个数组以及一个整数k
,返回数组中第k
大的元素。
看到这个问题,我们的第一反应可能是先将输入的数组排序,这样一来,算法的时间复杂度就至少是O(nlogn)
。其实,解决这类问题有一种更快的方法,它是基于快速排序的partition
函数实现的。
1.1 partition
函数概述
partition
函数的作用是选择一个标记元素pivot
(一般取数组第一个元素为标记元素),将原数组划分为两部分,左边一部分元素比pivot
小,右边一部分元素比pivot
大,pivot
位于中间。该函数的大致流程如下:
1. 初始:记录两个下标p1
和p2
。p1
记录了比pivot
小的部分的最后一个元素的下标,初始值为0
;p2
记录了当前正在遍历的元素的下标,初始值为1
。
2. 迭代:每当p2
遍历到一个比pivot
小的元素,则将p1++
,然后将arr[p1]
的值和arr[p2]
的值进行交换。
3. 结束:在遍历完毕后,arr[1..p1]
存放的就是所有小于pivot
的元素,arr[p1+1..p2]
存放的就是所有的大于pivot
的元素。我们将arr[0]
和arr[p1]
交换,这样标记元素pivot
就位于下标为p1
的位置,它左侧的元素小于它,它右侧的元素大于它。
1.2 基于partition
函数的求第k
大元素的算法
首先对整个数组进行一次划分,观察大于pivot
的元素数量size
。如果size == k-1
,说明pivot
正好是我们要找的第k
大的元素,直接返回pivot
即可;如果size > k-1
,说明第k
大的元素位于右半部分,我们需要对右半部分递归调用partition
函数,找右半部分第k
大的元素;如果size < k-1
,说明第k
大的元素位于左半部分,我们需要对左半部分递归调用partition
函数,找左半部分第k-1-size
大的元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
|
该算法的平均时间复杂度为O(n)
,与快速排序一样,最坏情况下,时间复杂度为O(n2)
。
二、数组前k小/前k大的元素
问题:输入一个数组以及一个整数k
,返回数组中前k
小的元素。
2.1 方法一:基于partition
函数的算法
定义partition
函数,其返回值为pivot
在划分之后所处的下标。首先对整个数组进行一次划分,并观察函数的返回值。
如果返回值等于k
,我们知道arr[0..k-1]
肯定都比pivot
小,arr[k+1..n-1]
肯定都比pivot
大,换句话说,arr[0..k-1]
就是数组的前k
小的元素;如果返回值小于k
,我们对右半部分进行递归调用,直到返回值等于k
为止;如果返回值大于k
,我们对左半部分进行递归调用,直到返回值等于k
为止。
与上一个问题相同,该算法的平均时间复杂度为O(n)
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
|
上述方法尽管效率较高,但是也存在一个致命的问题:运行时需要将所有数据载入内存。这就决定了该算法不适合海量数据的场合,比如从一亿个数中找出前一百个数。为了解决这个问题,我们便引入了一种基于堆的方法。
2.2 方法二:基于堆的算法
建立一个大根堆,然后遍历数组中的所有元素,在遍历过程中:
如果堆的大小小于k
,则直接将该元素进堆。如果堆的大小等于k
,则比较该元素与堆顶元素的大小。由于是大根堆,堆顶元素肯定是堆中最大的元素。如果该元素比堆顶元素还要大,那么该元素不可能是前k
小的元素之一(因为堆中已经有k
个比它小的元素了),我们可以直接将该元素抛弃;如果该元素比堆顶元素小,我们知道堆顶元素已经不再属于前k
小的元素之一了,我们将堆顶元素弹出,然后将该元素进堆。
在算法执行的过程中,保证堆的大小不大于k
。这样算法执行完毕后,堆中的元素就是原数组前k
小的元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
该方法不需要将所有数据载入内存,只需要在内存中保存一个大小为k
的堆,所以更适合处理海量数据。因为每次调整堆的时间复杂度为O(logk)
,并且最坏情况下,每遍历到一个元素就需要对堆进行一次调整,所以该方法的时间复杂度为O(nlogk)
。总体来讲,该方法适用于n
较大但是k
非常小的情况。
三、求长度可变数组的中位数
问题:有一个数组支持两种操作:一是向该数组中加入一个元素(int
类型);二是求该数组中所有元素的中位数。请实现该数组。
通过本文的第一节,我们已经知道了计算大小为n
的数组中位数的方法:如果n
是奇数,那么中位数就是第n/2 + 1
大的元素;如果n
是偶数,中位数则是第n/2
和n/2 + 1
大的元素的平均值。我们可以直接利用上面提到的基于partition
的方法进行递归求解。
但是本问题中,数组的长度是逐渐增加的,我们不可能在每次往数组中添加元素之后,都利用O(n)
的时间来求新的中位数。我们需要找到更快的方法。
3.1 最大最小堆法更新中位数
我们保存一个大根堆和一个小根堆,并且在往数组中添加元素之后,保证以下三个条件:
1、大根堆中的元素是整个数组中较小的一半元素;
2、小根堆中的元素是整个数组中较大的一半元素;
3、大根堆和小根堆的大小之差的绝对值不超过1。
具体来说,在添加一个元素num
时:
如果num
小于大根堆堆顶,说明num
位于数组前半部分,将其放入大根堆中;
如果num
大于小根堆堆顶,说明num
位于数组后半部分,将其放入小根堆中;
如果num
介于二者之间,则放入哪个堆中都是可以的。
然后观察两个堆大小之差的绝对值是否超过1
,如果是,我们需要调节两个堆的平衡,具体做法为:从元素数量较多的堆中取出一个元素,放入另一个堆中。
3.2 最大最小堆法获取中位数
如果大根堆与小根堆的大小相差1
,说明原数组大小为奇数。大小较大的那个堆的堆顶元素即为中位数。如果大根堆与小根堆的大小相等,说明原数组大小为偶数。两个堆堆顶元素的平均值即为中位数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
|
该算法每次添加元素的时间复杂度为O(logn)
,获取中位数的时间复杂度为O(1)
。