朴素的寻找第 $K$ 大元素可以排序,再找到第 $k$ 个,时间复杂度为 $O(n \times \log{n})$,但由于只需要第 $k$ 大,因此这样会造成很大的时间上浪费。 维护一个 $k$ 大的堆的时间复杂度为 $O(n \times \log{k})$,有所改进,但时间复杂度仍不够优,且实现复杂。而基于分治思想的算法可以在 $O(n)$的时间内找到 第 $k$ 大的数。

原理

利用分治法寻找第 $k$ 大的数的原理与快速排序类似,每次选取一个基准元素,利用与快速排序相同的操作,找到基准元素在最终的数列中的位置,如果等于 $k$, 就直接return,如果大于,在第k大的元素一定在右半部分,否则一定在左半部分。接着递归进行上述过程。由此,便可以有效地避免不必要的比较和排序操作, 降低算法的时间复杂度。

具体实现

int k_find(int num[], int l, int r, int k)
{
    int i = l, j = r, m = (l+r)>>1; // binary improved.
    int x = num[m];
    while (i != j) {
        while (j > m && num[j] > x) {
            j--;
        }
        num[m] = num[j]; m = j;
        while (i < m && num[i] < x) {
            i++;
        }
        num[m] = num[i]; m = i;
    }
    num[i] = x;
    if (i < k) {
        return k_find(i+1, r, k);
    } else if (i > k) {
        return k_find(l, i-1, k);
    } else {
        return num[i];
    }
}

算法复杂度分析

基于分治的查找第 $K$ 大的数的算法期望时间度为 $O(n)$, 其中,$n$ 为元素的个数。由于无额外空间占用,因此,空间复杂度为 $O(n)$。

扩展

多次查询

对于查找序列中的第 $K$ 大数的问题,如果需要多次查询,那么先排序在查找或者划分树的算法更为合适。

多个序列

LeetCode OJ上有这样一道题目:

There are two sorted arrays nums1 and nums2 of size m and n respectively. Find the median of the two sorted arrays. The overall run time complexity should be $O(\log{m+n})$.

题目链接:004. Median of Two Sorted Arrays

拿到这个题目,不难想到可以在O(m+n)的时间内归并这两个有序数组,然后二分在 $O(\log{m+n})$ 的时间复杂度内找出中位数,但这显然不符合题目的复杂度要求。 这便要求我们设计出更加高效的算法。

将寻找中位数扩展至寻找两个有序序列的第 $K$ 大,又该如何做呢?中位数是第 $\frac{m+n}{2}$ 小的数,因此,可以认为这两个问题等价。

首先假设数组 $A$ 和 $B$ 的元素个数都大于 $\frac{k}{2}$,我们比较 $A[\frac{k}{2}-1]$ 和 $B[\frac{k}{2}-1]$ 两个元素,这两个元素分别表示 $A$ 的第 $\frac{k}{2}$ 小的元素和B的第 $\frac{k}{2}$ 小的元素。这两个元素比较共有三种情况:$>$、$<$和$=$。如果 $A[\frac{k}{2}-1] < B[\frac{k}{2}-1]$,这表示 $A[0]$ 到 $A[\frac{k}{2}-1]$ 的元素都在 $A$ 和 $B$ 合并之后的前 $k$ 小的元素中。换句话说,$A[\frac{k}{2}-1]$ 不可能大于两数组合并之后的第 $k$ 小值,所以 可以将其舍弃

证明也很简单,可以采用反证法。

  • 假设 $A[\frac{k}{2}-1]$ 大于合并之后的第 $k$ 小值,我们不妨假定其为第 $(k+1)$ 小值。由于 $A[\frac{k}{2}-1]$ 小于 $B[\frac{k}{2}-1]$, 所以 $B[\frac{k}{2}-1]$ 至少是第 $(k+2)$ 小值。但实际上,在 $A$ 中至多存在 $\frac{k}{2}-1$ 个元素小于 $A[\frac{k}{2}-1]$, $B$ 中也至多存在 $\frac{k}{2}-1$ 个元素小于 $A[\frac{k}{2}-1]$,所以小于 $A[\frac{k}{2}-1]$ 的元素个数至多有 $\frac{k}{2}+\frac{k}{2}-2 = k-2$,小于 $k$, 这与 $A[\frac{k}{2}-1]$ 是第 $(k+1)$ 的数矛盾。

  • 当 $A[\frac{k}{2}-1] > B[\frac{k}{2}-1]$ 时存在类似的结论。

  • 当 $A[\frac{k}{2}-1] = B[\frac{k}{2}-1]$ 时,我们已经找到了第 $k$ 小的数,也即这个相等的元素,我们将其记为 $m$。由于在 $A$ 和 $B$ 中分别 有 $\frac{k}{2}-1$ 个元素小于 $m$,所以 $m$ 即是第 $k$ 小的数(需要注意的是,如果k为奇数,则m不是中位数。这里是进行了理想化考虑,在实际 代码中略有不同,是先求 $\frac{k}{2}$,然后利用 $k-\frac{k}{2}$ 获得另一个数)。

通过上面的分析,我们即可以采用递归的方式实现寻找第 $k$ 小的数。此外我们还需要考虑几个边界条件:

  • 如果 $A$ 或者 $B$ 为空,则直接返回 $B[k-1]$ 或者 $A[k-1]$;
  • 如果 $k$ 为 $1$,我们只需要返回 $A[0]$ 和 $B[0]$ 中的较小值;
  • 如果 $A[\frac{k}{2}-1] = B[\frac{k}{2}-1]$,返回其中一个。

给出这一算法的Python实现:

def findMedianSortedArrays(nums1, nums2):

    def findKth(a, m, b, n, k):
        if m > n:
            return findKth(b, n, a, m, k)
        if m == 0:
            return b[k-1]
        if k == 1:
            return min(a[0], b[0])
        pa = min(k//2, m)
        pb = k - pa
        if a[pa-1] < b[pb-1]:
            return findKth(a[pa:], m-pa, b, n, k-pa)
        elif (a[pa-1] > b[pb-1]):
            return findKth(a, m, b[pb:], n-pb, k-pb)
        else:
            return a[pa - 1];

    m, n = len(nums1), len(nums2)
    if (m+n) % 2 == 0:
        return (findKth(nums1, m, nums2, n, (m+n)//2) +
                findKth(nums1, m, nums2, n, (m+n)//2+1)) / 2.0
    else:
        return findKth(nums1, m, nums2, n, (m+n)//2+1)

寻找前 $k$ 个数

上面提到的算法仅仅要求找到第 $k$ 个数,那么,如果变换一下问题,要求找到前 $k$ 个数,又该怎么做呢?

  1. 如果不要求前 $k$ 个数有序,那么复杂度等同于找到第 $k$ 个数,因为在找到第 $k$ 个数的时候,前面的数都比第 $k$ 个数小。
  2. 如果要求前 $k$ 个数有序,那么在找到之后进行一次排序即可。复杂度 $O(n \times \log{k})$。

参考

  1. Median of Two Sorted Arrays
  2. leetcode之 median of two sorted arrays
  3. Leetcode 4 Median of Two Sorted Arrays
  4. Time Bounds for Selection