【经典面试题】无序数组中,求第K大的数(堆、荷兰国旗问题、bfprt算法)

今天来看一到算法题!经典面试题了,将从时间复杂度一般的解法,再到最优解!!!

img

题目:查找一个无序数组中,第K大的数。LeetCode链接

image-20211230194300686

解法一、堆

分析:既然是求第K大的数,那么很显然,可以使用TOPK问题的角度来解题。只需建一个小根堆,堆的大小就是K,遍历一遍数组:

  • 如果此时堆的大小<K,直接往里面放数据。
  • 如果此时堆的大小>=K,那么只需比较堆顶与arr[i]的大小,如果堆顶的元素小于arr[i],就弹出堆顶,再放入arr[i]即可。

image-20211230200419392

public int findKthLargest(int[] nums, int k) {
    if (nums == null || nums.length == 0 || k <= 0 || k > nums.length) {
        return -1;
    }
    PriorityQueue<Integer>  minHeap = new PriorityQueue<>();
    int size = 0; //堆的大小
    for (int i = 0; i < nums.length; i++) {
        if (size < k) { //堆的大小 < K 
            size++;
            minHeap.add(nums[i]);
        } else {//堆的大小 >= K 
            if (minHeap.peek() < nums[i]) {
                minHeap.poll();
                minHeap.add(nums[i]); //放入比较大的元素
            }
        }
    }
    return minHeap.poll();
}

以上这种解法,因为整体遍历了一遍数组,是O(N),而遍历的每一个数,插入堆中最坏的情况就是O(logK),整体时间复杂度O(N*logK),空间复杂度O(K)这样的效率,还是达不到面试官的要求。我们接着看下一解法。

解法二、改进“荷兰国旗问题”

我们在之前学快速排序的时候,说到过一个优化,就是荷兰国旗问题优化。当时说的是根据一个基准值,将整个区域划分为大于区、小于区和等于区,此时这里也是一样的。我们既然要找第K大的数,对应在数组中,那就是下标为K-1的数据。

说到这里,我想你可能就理解我的意思了。

那就是,根据划分后的区域,小于区、等于区和大于区,判断K-1,是在这三个区域的哪一块区域内:

  • K-1在等于区范围内,那么直接返回等于区的数据即可。
  • K-1在大于区范围内,那么递归调用函数,再次进行划分即可。
  • K-1在小于区范围内,也是递归调用小于区的范围即可。

如图:

image-20211230201631195

//主方法
public int findKthLargest(int[] nums, int k) {
    if (nums == null || nums.length == 0 || k <= 0 || k > nums.length) {
        return -1;
    }
    //因为process函数求的是第K小的数,而题目是要求第K大的数
    //所以此时调用的是计算第length-k小的数
    return process(nums, 0, nums.length - 1, nums.length - k);
}

private int process(int[] nums, int L, int R, int index) {
    if (L == R) {
        return nums[L];
    }
    int pivot = L + (int)(Math.random() * (R - L)); //生成随机值
    int[] mid = partition(nums, L, R, nums[pivot]); //划分范围
    if (index >= mid[0] && index <= mid[1]) {//index在等于区
        return nums[index];
    } else if (index < mid[0]) { //index在小于区
        return process(nums, L, mid[0] - 1, index); 
    } else {  //index在大于区
        return process(nums, mid[1] + 1, R, index);
    }
}

//荷兰国旗问题划分
private int[] partition(int[] arr, int L, int R, int pivot) {
    int less = L - 1;
    int more = R + 1;
    int index = L;
    while (index < more) {
        if(arr[index] == pivot) {
            index++;
        } else if (arr[index] > pivot) {
            swap(arr, index, --more);
        } else {
            swap(arr, index++, ++less);
        }
    }
    return new int[]{less + 1, more - 1};
}
//交换数据
private void swap(int[] arr, int left, int right) {
    int tmp = arr[left];
    arr[left] = arr[right];
    arr[right] = tmp;
}

特别需要注意的就是在调用process函数的时候,因为这个函数是计算的是求第K小的数,而题目是要求第K大的数。这里在调用的时候,其实要转个弯,求的是第length-K小的数

调用一次partition函数,时间复杂度是O(N),而在基准值的选取时,是随机产生的,因为基准值的选取很重要,只有基准值选在数组的中间位置,才能使这个算法达到最优的效果。但是在数学证明中,这个算法还是收敛于O(N)的水平。时间复杂度O(N),空间复杂度可以做到O(1)。将递归调用,改为迭代就可以做到空间复杂度O(1)。

解法三、bfprt算法

这个算法呢,是由五位大佬想出来的,也就是这五位大佬姓名的首字母组成的这个算法名。

这个算法,也是用于解决这个数组中第K大的数值的。也是能够做到时间复杂度O(N)的水平。因为上文中解法二,是一个概念性的基准值,在运气不好的情况下,那个算法的效率可能不是很理想。

bfprt算法,解决的就是如何选取基准值。其余的部分,还是跟解法二一样,基准值选出来之后,还是调用partition函数即可。

那么到底是如何选取基准值的呢?我先将步骤说出来,再一一讲解:

  1. 将整个数组,从左到右划分小组,5个数为一组。如果数组末尾不够5个数的,单独为一组;
  2. 将划分出来的小组,进行排序;(切记,是这小组中的5个数排序,不是整个数组)
  3. 挑选出排好序的小组中的中位数,组成一个新的数组;
  4. 基准值就是,新数组的排序后的中位数。

可能有点饶,看图就清楚了:

image-20211230204807960

如果数组末尾不够5个数的,中间值就是上中位数,比如只有4个数时,返回第2个数,这就是上中位数。

最下面的红圆圈就是挑选出来的基准值。那么有人肯定会说,费这么大的劲挑选这个基准值,怎么说明他的重要性呢???来看如下分析:

QQ截图20211230213228

通过上图的分析,我们就可以得出挑选出来的基准值d,d肯定是<=3N/10的。

因为整个代码会划分为三个区域,小于区、等于区和大于区。我们现在需要估计,小于区的数据最多有多少个?反推就是计算 大于区和等于区的数据最少有多少个?根据上图的分析,大于区和等于区最少有3N/10

再者,每5个数据为一个小组,进行排序的时候,选择任意的排序方法都无所谓,应该一组是有5个数据,所以一个组排序的时间复杂度是O(1),而总共有N/5个小组。

再加上partition函数的时间复杂度是O(N)。

所以整体的时间复杂度如下:

image-20211230212636943

//主方法
public int findKthLargest(int[] nums, int k) {
    if (nums == null || nums.length == 0 || k <= 0 || k > nums.length) {
        return -1;
    }
    //bfprt求的是第K小的数,而题目求的是第K大的数
    return bfprt(nums, 0, nums.length - 1, nums.length - k );
}

private int bfprt(int[] arr, int L, int R, int index) {
    if (L == R) {//只有一个数的时候
        return arr[L];
    }

    //获取基准值-小组内排序
    int pivot = medianOfMedians(arr, L, R);
    int[] mid = partition(arr, L, R, pivot); //划分区间
    if (index >= mid[0] && index <= mid[1]) {//index在等于区内
        return arr[index];
    } else if (index < mid[0]) { //进入小于区
        return bfprt(arr, L, mid[0] - 1, index);
    } else {//进入大于区
        return bfprt(arr, mid[1] + 1, R, index);
    }
}

//5个数一组,并且排序,挑选出中位数,组成新数组
private int medianOfMedians(int[] arr, int L, int R) {
    int size = R - L + 1;
    int offset = size % 5 == 0? 0 : 1; //数组末尾够不够5个数
    int[] tmp = new int[size / 5 + offset];
    for (int i = 0; i < tmp.length; i++) {
        int start = L + i * 5; //每个小组的开始下标
        tmp[i] = getMedianNum(arr, start, Math.min(start + 4, R));//闭区间
    }
    //求新数组的中位数,再次调用bfprt函数即可
    return bfprt(tmp, 0, tmp.length - 1, tmp.length / 2);//求tmp的中位数
}

private int getMedianNum(int[] arr, int L, int R) {
    selectSort(arr, L, R); //闭区间。小组内排序
    return arr[(R + L) / 2]; //返回中间值
}
//选择排序
private void selectSort(int[] arr, int left, int right) {
    for (int i = left; i <= right - 1; i++) {
        int min = i;
        for (int j = i + 1; j <= right; j++) {
            if (arr[j] < arr[min]) {
                min = j;
            }
        }
        if (min != i) {
            swap(arr, i, min);
        }
    }
}

private void swap(int[] arr, int left, int right) {
    int tmp = arr[left];
    arr[left] = arr[right];
    arr[right] = tmp;
}

解法二,已经是足够优秀的了。但是现在的算法难度越来越高,可能解法二不能够满足面试官的胃口了,那么此时你就可以说一说,这道题还有一个bfprt算法可以解。

好啦,以上全部就是本期文章的全部内容啦!我们下期见吧!!!

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>