这道题属于一道中等题,我记得我面试中有面试官曾让我写过这道题,当时觉得直接用快速排序排好序就可以了,然后面试官提示了一句“是否需要把数组中所有数字都排好序呢?”,我这才想到直接排序肯定不是最优解,于是就想到了堆排序的思想,当时是没有写出来,今天正好把它搞懂,直接看题解
有两种解题方法,第一种是利用快速排序思想,直接上代码,代码注释有详细讲解每一行代码
class Solution {
private int quickSelect(List<Integer> nums, int k) {
// 随机选择基准数
Random rand = new Random();
int pivot = nums.get(rand.nextInt(nums.size()));
// 将大于、小于、等于 pivot 的元素划分至 big, small, equal 中
List<Integer> big = new ArrayList<>();
List<Integer> equal = new ArrayList<>();
List<Integer> small = new ArrayList<>();
for (int num : nums) {
if (num > pivot) {
big.add(num);
} else if (num < pivot) {
small.add(num);
} else {
equal.add(num);
}
}
// 示例:
// nums:1 2 5 3 6 9 7 2
// 下标: 0 1 2 3 4 5 6 7
// k = 3
// pivot = 3
// big: 5 6 9 7
// small: 1 2 2
// equal: 3
// 我们可以按从大到小排列为 big > equal > small
// k表示第几大,如果k <= big.size(),说明这个第k大的数一定在big中,继续对big进行划分
if (k <= big.size()) {
return quickSelect(big, k);
}
// 同理,如果k > big.size() + equal.size(),说明这个第k大的数只能在small中,继续对small进行划分
if (big.size() + equal.size() < k) {
// k的原本含义是在整个数组中第k大的数,big和equal已经排除在外,此时是在small中,数组长度变小了,自然k也要变小
return quickSelect(small, k - (big.size() + equal.size()));
}
// 第k大的数在equal中,直接返回 pivot
return pivot;
}
public int findKthLargest(int[] nums, int k) {
List<Integer> list = new ArrayList<>();
for (int num : nums) {
list.add(num);
}
return quickSelect(list, k);
}
}
第二种是堆排序
堆 是一种特殊的 完全二叉树,其存储结构类似于完全二叉树,可以用数组实现,如下图所示
数组中的数字并未按照升序或降序排列,但其实这棵树是已经有序的状态了,至于为什么?这就与大根堆、小根堆有关了
大根堆: 父结点的值 大于或等于 其子结点的值
小根堆: 父结点的值 小于或等于 其子结点的值
它能以 O(logN)的时间复杂度完成插入、删除和查找操作,通过调整数组中元素的顺序,维护堆的结构,下面我们以 大根堆 为例,对堆的一个重要操作: 下调 ( heapfiy ) 进行说明
这个公式是比较重要的,若结点数组下标为 i ,则:
父结点数组下标:( i - 1 ) / 2;
左孩子数组下标:2 * i + 1 ;
右孩子数组下标:2 * i + 2 ;
下调 heapfiy
还是上面图示里那个数组,调整成为一个大根堆,从 最后一个元素开始 向前遍历,比较自己与左右孩子结点的大小,如果小于孩子结点就交换(即:下调 ),下调之后继续与新的左右孩子结点进行比较,如果小于孩子结点就交换,直到不能下调为止。向前继续移动,直到所有结点均遍历一遍,当所有父结点均大于其孩子结点时,那么这颗二叉树就是一棵大根堆
第一步,从后往前遍历,8,9,6节点都是叶子节点,不需要进行下调,接着遍历到7这个节点,我们会发现 7 < 8,8节点是7节点的孩子,那么需要进行交换,交换之后因为7节点是叶子节点了,所以无需继续下调,如下图所示
第二步,接着遍历到了4这个节点,我们会发现4节点比其左右孩子节点都小,我们需要与其中更大的那个孩子节点进行交换,即4节点与9节点交换,交换之后因为4节点是叶子节点了,所以无需继续下调,如下图所示
第三步,接着遍历到1这个节点,我们又发现1节点比其左右孩子节点都小,同上,我们需要与其中更大的那个孩子节点进行交换,即1节点与9节点交换,交换之后因为1节点是非叶子节点,所以需要判断是否继续下调,如下图所示
第四步,上面由于1节点的下调需要继续判断是否符合下调条件,我们会发现1节点比其左右孩子都小,所以需要进行下调,即6节点与1节点交换,交换之后因为6节点是叶子节点,所以无需继续下调,如下图所示
至此,整个遍历结束,将一个无序数组调整成为了大根堆,具体代码实现如下
class Solution {
// 根据已知数组调整为大根堆
public void heapify(int[] arr, int index, int heapSize) {
// 利用公式找到当前节点的左孩子索引
int left = index * 2 + 1;
// 如果left >= heapSize说明是叶子节点,则不需要进行处理
while (left < heapSize) {
// 记录当前节点的左右孩子value最大的下标
int largest = left + 1 < heapSize && arr[left + 1] > arr[left] ? left + 1 : left;
// 如果孩子的value小于等于父节点则不用进行交换
if (arr[largest] <= arr[index]) {
break;
} else {
// 交换
swap(arr, largest, index);
// 当前节点索引往下调
index = largest;
// 更新当前节点的左孩子索引
left = index * 2 + 1;
}
}
}
}
然后回到本题,采用建大根堆的方式进行寻找第k大的元素,代码如下,代码注释有详细讲解每一行代码
class Solution {
public int findKthLargest(int[] nums, int k) {
if (nums == null || (nums != null && nums.length == 0)) {
return -1;
}
// 从后往前遍历调整为大根堆
for (int i = nums.length - 1; i >= 0; i--) {
heapify(nums, i, nums.length);
}
int heapSize = nums.length;
// 寻找第k大的元素,比如数组长度为6,k为3,则需要删除大根堆根节点两次
for (int i = nums.length - 1; i >= nums.length - k + 1; i--) {
// 把根节点与最后一个节点交换
swap(nums, 0, i);
// 堆大小减一,相当于把最后一个节点排除在堆排序之外了
heapSize--;
// 再次调整为大根堆
heapify(nums, 0, heapSize);
}
// 经过k - 1次根节点的删除和k次大根堆的调整,当前根节点就是第k大的元素
return nums[0];
}
// 交换
public void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
// 根据已知数组调整为大根堆
public void heapify(int[] arr, int index, int heapSize) {
// 利用公式找到当前节点的左孩子索引
int left = index * 2 + 1;
// 如果left >= heapSize说明是叶子节点,则不需要进行处理
while (left < heapSize) {
// 记录当前节点的左右孩子value最大的下标
int largest = left + 1 < heapSize && arr[left + 1] > arr[left] ? left + 1 : left;
// 如果孩子的value小于等于父节点则不用进行交换
if (arr[largest] <= arr[index]) {
break;
} else {
// 交换
swap(arr, largest, index);
// 当前节点索引往下调
index = largest;
// 更新当前节点的左孩子索引
left = index * 2 + 1;
}
}
}
}