求数组中第k大的元素

4,537 阅读7分钟

题目描述

在一个未排序的数组中,求出其中第k大的数,需要注意的是,数组中可能存在重复元素,所以求解的是第k大的数,而不是第k个不同的元素。

这个题目对应LeetCode上的第215号题,链接:leetcode第215题, 在继续往下看具体的解法之前,可以先花一点时间,自己想一下如何解决:)

解法一

既然是求其中第k大的数,那么使其数组整体有序后,找到其中第k大的数就比较容易了,所以先对数组进行排序,然后再取第k大的数即可。

使用c++代码:

int findKthLargest(vector<int>& nums, int k) {
    if (k < 1 || k > nums.size()) {
        return 0;
    }
    
    // 从大到小排序
    sort(nums.begin(), nums.end(), greater<int>());
    
    return nums[k - 1];
}

首先函数开始执行,先对参数k进行检查,当k小于1,或者大于数组的个数时,我的处理方式是返回零。

然后再进行排序,这里我的代码实现中,用的是从大到小的排序,因为数组的索引是从0开始计算,所以计算第k大的数时,就应该取数组的第k-1个元素。另外,这里也可以从小到大的排序,那么,第k大的数就应该是数组的第n-k个元素。

解法二

前面一种方法中,需要对数组进行排序,所以时间复杂度为0(nlogn)。我们可以试着想一下,这里是对数组整体都进行了排序,而其实我们求的是数组中第k大的数,那么我们只需要把前k大的数排序出来即可,我们并不用关心后面的n-k个数是否是有序的。

从各种排序算法中看,选择排序可以满足我们的需求,还记得选择排序的思想吗?就是每一趟循环就会确定一个余下元素中最大或最小的数。那么我们只需要循环k次,就可以找出来了。要循环k次,所以这种解法的时间复杂度是0(n x k)。

代码如下:

    int findKthLargest(vector<int>& nums, int k) {
        if (k < 0 || k > nums.size()) {
            return 0;
        }

        // 外层只需排序k次
        for (int i = 0; i < k; i++) {
            int maxindex = i; // 内层循环时,保存寻找到的最大数的【索引】
            for (int j = i + 1; j < nums.size(); j++) {
                if (nums[j] > nums[maxindex]) {
                    maxindex = j;
                }
            }
            swap(nums[i], nums[maxindex]);
        }

        return nums[k - 1];
    }

解法三

可以使用优先队列,维护前k大的元素。

代码:

int findKthLargest(vector<int>& nums, int k) {
    if(k < 1 || k > nums.size()) {
        return 0;
    }
    
    priority_queue<int,vector<int>,greater<int>> q; // 构建最小堆
    
    for (int i = 0; i < nums.size(); i++) {
        if (q.size() != k) {
            q.push(nums[i]);
        } else if (q.top() < nums[i]) {
            q.pop();
            q.push(nums[i]);
        }
    }
    return q.top();
}

注意,这里使用优先队列创建最小堆,来维护前k大的元素。这样在堆顶的元素始终是堆中最小的,当队列中有k个元素,再入队一个新元素时,便将堆顶的元素,也就是最小的元素出队。当循环一遍数组,队列中的堆顶元素也就是第k大的数了。

这里使用了优先队列维护前k大的元素,所以空间复杂度为0(k),在时间复杂度上,循环遍历了一遍数组,并且对遍历每个元素时,都要维护前k个元素,每进队一个元素,堆中的时间复杂度是0(logk),所以整体为O(nlogk)。

解法四

可以使用二分查找结合快速排序来更高效的查找。

先简单复习下二分查找和快速排序的基本思想。

二分查找:对于一个有序的数组,搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。

快速排序:以从小到大排序为例,快速排序是选定数组中的某一个元素作为标定点,比这个元素小的全部排到这个特定元素的左边,比这个元素大的全部排到这个特定元素的右边,然后分别对这两部分继续按照上述方法进行排序,最后使整体达到有序。如果忘记快速排序的具体实现的话,可以看我之前写的关于快速排序的文章:快速排序

那如何结合二分查找和快速排序来解决当前的问题呢?按照快排的partition操作,我们可以先选定一个标定点,然后与标定点比较分成两部分区间,然后确定第k大元素所属的区间是标定点的左边部分还是右边部分,再按照二分查找的方式,只对第k大元素所属的区间继续进行partition操作,直到找到第k大的元素为止。

假设以数组[7,92,23,9,-1,0,11,6]这个数组为例,寻找其中第四大的元素。我们来对其进行具体的分析。

使用快排并不需要将整体全部排序,只是用来确定需要寻找元素的范围。

按照从小到大排序的思路使用快速排序,那么其中第k大的元素,也就是数组中第n+1-k小的元素,数组中的元素个数为8,加1, 减去k = 4,也就是求数组中第5小的元素。

开始,随机选取标定点为11,第一次partition操作后如图。为了使观察更清晰,数字下方的&代表这个数字比标定点小,#代表比标定点大。

[7,9,-1,0,6,11,92,23];
 & &  & & &     # #

可以看到,比标定点小的个数有5个,即可以确定这5个元素是数组中前5个较小的元素,那么我们要找到第5小的元素,包含在其中,只需要在这5个元素中查找,而后面的元素,我们就完全可以抛弃,不用去管它了。如图。

[7,9,-1,0,6,*,*,*]

然后继续对这一部分进行partition操作。假设随机选取的标定点为6。

[-1,0,6,7,9,*,*,*]
  & &   # #

这样我们就可以进一步确定,数组中第5小的元素位于6的右边,也就是7与9之间。而标定点左边的元素就不用管了。

[*,*,*,7,9,*,*,*]

假设随机选取的标定点为9,继续进行partition,可以发现9所处的位置正好是这个数组第5小的位置。由于是从小到大排序,我们就可以确定第5小的是9。第5小的元素,从下图从右往左看,也就是数组中第4大的元素。

[*,*,*,*,9,*,*,*]

下面是具体的代码实现。

public:
    int findKthLargest(vector<int>& nums, int k) {
        if(k < 1 || k > nums.size()) {
            return 0;
        }
        
        srand(time(NULL));
        return findKthLargest(nums, 0, nums.size() - 1, nums.size() - k);
    }
    
private:
    // 在数组[l,r]区间内寻找k大的数排序后所处的索引s。
    int findKthLargest(vector<int>& nums,int l, int r, int s){
        if (l == r) {
            return nums[l];
        }
        
        int p = partition(nums,l,r);
        if (p == s) {
            return nums[p];
        } else if (s < p){
            return findKthLargest(nums, l, p - 1, s);
        } else { // s > p
            return findKthLargest(nums, p + 1, r, s);
        }
    }
    
    int partition(vector<int>& nums, int l, int r) {
        int p = rand()%(r-l+1) + l;
        swap(nums[l], nums[p]);
        
        int j = l;
        for (int i = l + 1; i <= r; i++) {
            if (nums[i] < nums[l]) {
                swap(nums[i], nums[j + 1]);
                j++;
            }
        }
        swap(nums[l], nums[j]);
        return j;
    }

上面代码需要注意的几个地方

  • 使用随机数来选取标定点。
  • 对partition过程不理解的,可以查看:快速排序 中讲解partition的部分。

快排的时间复杂度是O(nlogn),这里使用了二分查找,舍弃了每次partiton后对另一部分处理的时间,所以时间复杂度为O(n),空间复杂度要考虑递归函数入栈时的空间,整体为0(logn)。