文章目录
-
- [LeetCode 215. 数组中的第 K 个最大元素](#LeetCode 215. 数组中的第 K 个最大元素)
- [手撕快排(LeetCode 912为例)](#手撕快排(LeetCode 912为例))
-
- [1. 为什么是 O ( N log N ) O(N \log N) O(NlogN)?(矩形法则)](#1. 为什么是 O ( N log N ) O(N \log N) O(NlogN)?(矩形法则))
- [2. 为什么二分查找是 O ( log N ) O(\log N) O(logN)?(下楼梯法则)](#2. 为什么二分查找是 O ( log N ) O(\log N) O(logN)?(下楼梯法则))
- [3. 为什么快速选择是 O ( N ) O(N) O(N)?(三角形法则)](#3. 为什么快速选择是 O ( N ) O(N) O(N)?(三角形法则))
- 终极对比表
- [手撕堆排序(LeetCode 912为例)](#手撕堆排序(LeetCode 912为例))
- [手撕归并排序(LeetCode 912为例)](#手撕归并排序(LeetCode 912为例))
LeetCode 215. 数组中的第 K 个最大元素

小根堆解法
java
class Solution {
public int findKthLargest(int[] nums, int k) {
// 小顶堆,堆顶是最小元素
PriorityQueue<Integer> pq = new PriorityQueue<>();
for(int i :nums){
// 每个元素都要过一遍二叉堆
pq.offer(i);
// 堆中元素多于 k 个时,删除堆顶元素(把小的都筛出去了,留下的最上面的就是剩下k个数中最小的那个!!!!也就是第k大的!!!)
if(pq.size() > k){
pq.poll();
}
}
// pq 中剩下的是 nums 中 k 个最大元素,
// 堆顶是最小的那个,即第 k 个最大元素
return pq.peek();
}
}
注意这里用的是小根堆,堆中元素多于 k 个时,删除堆顶元素,现在的堆顶就是剩下K个数中最小的那个!!!!也就是第k大的!!!
快排解法
java
class Solution {
// 1. 定义 Random 对象
private static final Random rand = new Random();
public int findKthLargest(int[] nums, int k) {
int n = nums.length;
int targetIndex = n - k; //题目要"第 k 大",在一个升序数组里,这就等于下标 n - k。比如 [1, 2, 3, 4, 5],第 2 大是 4,下标是 3 (5 - 2)。
int left = 0;
int right = n - 1;
while(true){
// 这一步会选一个 Pivot,把它放到它该去的位置,并返回那个位置下标 i。
int i = partition(nums,left,right);
if(i == targetIndex){
// 找到第 k 大元素
return nums[i];
}
if(i > targetIndex){
// 第 k 大元素在 [left, i - 1] 中
right = i - 1;
} else {
left = i + 1;
}
}
}
private int partition(int[] nums, int left, int right){
// 1. 在子数组 [left, right] 中随机选择一个基准元素 pivot
int i = left + rand.nextInt(right - left + 1);
int pivot = nums[i];
// 把 pivot 与子数组第一个元素交换,避免 pivot 干扰后续划分,从而简化实现逻辑
swap(nums, i , left);
// 2. 相向双指针遍历子数组 [left + 1, right]
// 循环不变量:在循环过程中,子数组的数据分布始终如下图
// [ pivot | <=pivot | 尚未遍历 | >=pivot ]
// ^ ^ ^ ^
// left i j right
i = left + 1;
int j = right;
while(true){
while(i <= j && nums[i] < pivot){
i++;
}
// 此时 nums[i] >= pivot
while(i<=j && nums[j] > pivot){
j--;
}
// 此时 nums[j] <= pivot
if(i >= j){
break;
}
swap(nums, i, j);
i++;
j--;
}
// 循环结束后
// [ pivot | <=pivot | >=pivot ]
// ^ ^ ^ ^
// left j i right
// 3. 把 pivot 与 nums[j] 交换,完成划分(partition)
// 为什么与 j 交换?
// 如果与 i 交换,可能会出现 i = right + 1 的情况,已经下标越界了,无法交换
// 另一个原因是如果 nums[i] > pivot,交换会导致一个大于 pivot 的数出现在子数组最左边,不是有效划分【主要是如果 nums[i] > pivot,交换会导致一个大于 pivot 的数出现在子数组最左边,不是有效划分】
// 与 j 交换,即使 j = left,交换也不会出错
swap(nums, left, j);
// 交换后
// [ <=pivot | pivot | >=pivot ]
// ^
// j
// 返回 pivot 的下标
return j;
}
private void swap(int[] nums, int i, int j){
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
一、 为什么是 O(N) 而不是 O(logN)?
你说得对,二分查找 (Binary Search) 每次也是丢掉一半,它的复杂度确实是 O ( log N ) O(\log N) O(logN)。
快速选择 (Quick Select) 每次也是丢掉一半(理想情况),为什么它是 O ( N ) O(N) O(N)?
核心区别在于:在这个"丢掉一半"的动作发生之前,你花了多大力气?
1. 二分查找的账单
- 动作 :看一眼中间的数字
mid,和目标值比一下。 - 花费 :O(1) 。你只需要看这一个数字,不需要看剩下那一半。
- 总账单 :
O ( 1 ) + O ( 1 ) + O ( 1 ) + ⋯ + O ( 1 ) = O ( log N ) O(1) + O(1) + O(1) + \dots + O(1) = O(\log N) O(1)+O(1)+O(1)+⋯+O(1)=O(logN)
(就像你切一块蛋糕,每一刀只需要 1 秒钟,切 log N \log N logN 刀就完了。)
2. 快速选择的账单
- 动作 :我要决定扔哪一半,我必须先做
partition。 - 花费 :O(N) 。
partition需要遍历当前留下的所有元素,把它们和 Pivot 比较并交换。你必须看清每一个人,才能决定谁留谁走。 - 总账单 :
- 第一轮:你需要看 N N N 个人(花费 N N N)。
- 第二轮:剩下 N / 2 N/2 N/2 个人,你需要把这 N / 2 N/2 N/2 个人都看一遍(花费 N / 2 N/2 N/2)。
- 第三轮:花费 N / 4 N/4 N/4。
- ...
N + N 2 + N 4 + N 8 + ⋯ ≈ 2 N = O ( N ) N + \frac{N}{2} + \frac{N}{4} + \frac{N}{8} + \dots \approx 2N = O(N) N+2N+4N+8N+⋯≈2N=O(N)
总结:
- 二分查找是 "看一眼,扔一半"。
- 快速选择是 "全看完,扔一半"。
- 全看完的代价是昂贵的,虽然人越来越少,但总工作量是按比例累加的。
二、 为什么要随机选 Pivot?
如果不随机选(比如固定每次都选第一个元素做 Pivot),会发生什么灾难?
想象一下,你运气很差,拿到的数组恰好是已经排好序的 :
nums = [1, 2, 3, 4, 5, ..., 100],你要找第 100 大的数。
如果每次都死板地选第一个数(Index 0)作为 Pivot:
-
第一轮:
- Pivot 是
1。 - Partition 遍历完 100 个数,发现
1就在最左边。 - 左边(比1小的)是空,右边(比1大的)有 99 个。
- 结果 :你忙活了半天,只排除了
1这一个数字。哪怕是只剩下 99 个,规模并没有变成一半!
- Pivot 是
-
第二轮:
- 剩下的数组是
[2, 3, ..., 100]。Pivot 选2。 - Partition 遍历完 99 个数。
- 结果 :又只排除了
2这一个数字。剩下 98 个。
- 剩下的数组是
-
结局 :
你需要遍历 100 + 99 + 98 + ⋯ + 1 100 + 99 + 98 + \dots + 1 100+99+98+⋯+1 次。
这是一个等差数列求和,结果是 N 2 2 \frac{N^2}{2} 2N2,也就是 O ( N 2 ) O(N^2) O(N2)。
这就从"跑得飞快"的法拉利( O ( N ) O(N) O(N))变成了"老牛拉车"( O ( N 2 ) O(N^2) O(N2))。LeetCode 上稍微大一点的用例直接就**超时(Time Limit Exceeded)**了。
如果加上随机化 (Random):
不管原数组是有序的、倒序的、还是什么奇怪形状。
我随机 选一个,选到那个"倒霉的最小值的概率"只有 1 / N 1/N 1/N。
从概率论上讲,我大概率能选到一个"不好不坏"的中间值,从而保证每次大概能切掉一半左右的数据。
一句话总结 :
随机化是为了防止"虽然数据有序,但对你的算法来说是最坏情况"这种倒霉事的发生。
手撕快排(LeetCode 912为例)
java
class Solution {
/**
在子数组 [left, right] 中随机选择一个基准元素 pivot
根据 pivot 重新排列子数组 [left, right]
重新排列后,<= pivot 的元素都在 pivot 的左侧,>= pivot 的元素都在 pivot 的右侧
返回 pivot 在重新排列后的 nums 中的下标
特别地,如果子数组的所有元素都等于 pivot,我们会返回子数组的中心下标,避免退化
*/
private static final Random rand = new Random();
public int[] sortArray(int[] nums) {
quickSort(nums, 0, nums.length - 1);
return nums;
}
private void quickSort(int[] nums, int left, int right){
boolean ordered = true;
for(int i = left; i < right; i++){
if(nums[i] > nums[i+1]){
ordered = false;
break;
}
}
if(ordered){
return;
}
int i = partition(nums,left,right); // 划分子数组
quickSort(nums, left, i-1); // 排序在 pivot 左侧的元素
quickSort(nums, i+1, right); // 排序在 pivot 右侧的元素
}
private int partition(int[] nums, int left, int right){
// 1. 在子数组 [left, right] 中随机选择一个基准元素 pivot
// 1.1right - left + 1 算出这个区间里一共有多少个元素,必须 +1
// 1.2生成随机偏移量:rand.nextInt(...)生成一个范围在 [0, 区间长度) 之间的随机数。
int i = left + rand.nextInt(right - left + 1);
int pivot = nums[i];
// 把 pivot 与子数组第一个元素交换,避免 pivot 干扰后续划分,从而简化实现逻辑
swap(nums, i , left);
// 2. 相向双指针遍历子数组 [left + 1, right]
// 循环不变量:在循环过程中,子数组的数据分布始终如下图
// [ pivot | <=pivot | 尚未遍历 | >=pivot ]
// ^ ^ ^ ^
// left i j right
i = left + 1;
int j = right;
while(true){
while(i <= j && nums[i] < pivot){
i++;
}
// 此时 nums[i] >= pivot
while(i<=j && nums[j] > pivot){
j--;
}
// 此时 nums[j] <= pivot
if(i >= j){
break;
}
swap(nums, i, j);
i++;
j--;
}
// 循环结束后
// [ pivot | <=pivot | >=pivot ]
// ^ ^ ^ ^
// left j i right
// 3. 把 pivot 与 nums[j] 交换,完成划分(partition)
// 为什么与 j 交换?
// 如果与 i 交换,可能会出现 i = right + 1 的情况,已经下标越界了,无法交换
// 另一个原因是如果 nums[i] > pivot,交换会导致一个大于 pivot 的数出现在子数组最左边,不是有效划分【主要是如果 nums[i] > pivot,交换会导致一个大于 pivot 的数出现在子数组最左边,不是有效划分】
// 与 j 交换,即使 j = left,交换也不会出错
swap(nums, left, j);
// 交换后
// [ <=pivot | pivot | >=pivot ]
// ^
// j
// 返回 pivot 的下标
return j;
}
private void swap(int[] nums, int i, int j){
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
/**
时间复杂度:O(nlogn),其中 n=length(nums),即数组的长度。
空间复杂度:O(logn),主要为递归调用产生;原地(inplace)排序可认为无额外空间开销。
*/
}
这是一个非常经典的误解!很多人都会混淆二分查找 (Binary Search) 和 快速排序 (Quick Sort) 的复杂度。
简单直接的答案是:
- 二分查找 ( O ( log N ) O(\log N) O(logN)) :虽然每次切一半,但我只选一边走,另一边直接扔掉不要了。
- 快速排序 ( O ( N log N ) O(N \log N) O(NlogN)) :虽然每次切一半,但我两边都要处理,哪边都不能扔。
为了讲清楚,我们用一张图来对比它们的"工作量"。
1. 为什么是 O ( N log N ) O(N \log N) O(NlogN)?(矩形法则)
想象一下快速排序的整个过程,它像一棵树。
-
每一层的总工作量是 N N N:
- 第 1 层 :处理 1 个长度为 N N N 的数组。为了切分它(Partition),我们要遍历这 N N N 个数。耗时 N N N。
- 第 2 层 :切成了 2 个长度为 N / 2 N/2 N/2 的数组。虽然变短了,但有两个。总遍历次数是 N / 2 + N / 2 = N N/2 + N/2 = N N/2+N/2=N。
- 第 3 层 :切成了 4 个长度为 N / 4 N/4 N/4 的数组。总遍历次数 N / 4 × 4 = N N/4 \times 4 = N N/4×4=N。
- ...
- 不管切到多细,每一层所有小数组加起来,总元素个数还是 N N N,所以每一层都要把这 N N N 个数看一遍。
-
一共有多少层?
- 每次切一半,切多少次能变成 1?答案是 log N \log N logN 次(这就是树的高度)。
-
总账单 :
总耗时 = 每一层的工作量 × 层数 = N × log N \text{总耗时} = \text{每一层的工作量} \times \text{层数} = N \times \log N 总耗时=每一层的工作量×层数=N×logN
你可以把这看作一个宽为 N N N,高为 log N \log N logN 的矩形,面积就是总复杂度。
2. 为什么二分查找是 O ( log N ) O(\log N) O(logN)?(下楼梯法则)
二分查找是完全不同的逻辑。
- 第 1 层:我看一眼中间的数(耗时 1),不对?扔掉一半。
- 第 2 层:我在剩下的一半里,再看一眼中间的数(耗时 1),又扔掉一半。
- 第 3 层:再看一眼(耗时 1)。
它不需要 遍历数组,每次操作的代价只是 O ( 1 ) O(1) O(1)。它一共走了 log N \log N logN 步(层数)。
- 总账单 :
总耗时 = 每一步耗时 × 步数 = 1 × log N = O ( log N ) \text{总耗时} = \text{每一步耗时} \times \text{步数} = 1 \times \log N = O(\log N) 总耗时=每一步耗时×步数=1×logN=O(logN)
3. 为什么快速选择是 O ( N ) O(N) O(N)?(三角形法则)
回到刚才讲的快速选择 (Quick Select),它介于两者之间。
它也切分,但是它只走一边 (像二分查找),可是它每一步都要遍历(像快排)。
- 第 1 层 :遍历 N N N 个数。扔掉一半。
- 第 2 层 :遍历 N / 2 N/2 N/2 个数。扔掉一半。
- 第 3 层 :遍历 N / 4 N/4 N/4 个数。
- ...
这是一个收敛的过程:
N + N 2 + N 4 + ⋯ = 2 N = O ( N ) N + \frac{N}{2} + \frac{N}{4} + \dots = 2N = O(N) N+2N+4N+⋯=2N=O(N)
你可以把它看作一个倒三角形,面积是收敛的。
终极对比表
| 算法 | 每次怎么分? | 另一半要不要? | 这一步要花多少力气? | 形象比喻 | 总复杂度 |
|---|---|---|---|---|---|
| 二分查找 | 切一半 | 不要 (扔掉) | O ( 1 ) O(1) O(1) (看一眼) | 下楼梯 (一步一个脚印) | O ( log N ) O(\log N) O(logN) |
| 快速选择 | 切一半 | 不要 (扔掉) | O ( N ) O(N) O(N) (全都要挪) | 倒金字塔 (越干活越少) | O ( N ) O(N) O(N) |
| 快速排序 | 切一半 | 全都要 | O ( N ) O(N) O(N) (全都要挪) | 盖长方形大楼 (每层都一样大) | O ( N log N ) O(N \log N) O(NlogN) |
一句话总结:
排序不可能做到 O ( log N ) O(\log N) O(logN),因为你连 N N N 个数都没看完,怎么可能把它们排好序呢?光是把 N N N 个数读一遍就需要 O ( N ) O(N) O(N) 了。
手撕堆排序(LeetCode 912为例)
java
class Solution {
public int[] sortArray(int[] nums) {
int n = nums.length;
// 1. 建堆 (Build Max Heap)
// 从最后一个非叶子节点开始,自底向上 heapify
// 最后一个非叶子节点下标 = n / 2 - 1
for(int i = n / 2 - 1; i>=0; i--){
heapify(nums, n, i);
}
// 2. 排序 (Extract Max)
//目标:每次把第一名踢出去(放到数组末尾),然后在剩下的人里选新的第一名。
// 每次把堆顶(最大值)交换到数组末尾
// 然后堆的大小减 1,剩下的部分再次 heapify
for(int i = n - 1; i > 0; i--){
// 把最大的放到最后
swap(nums, 0 ,i);
// 剩下的元素重新调整为大顶堆
// 注意:此时堆的大小变成了 i(比如第一轮就是大小为n-1,去掉了最后一个),同时依旧是0需要下沉
heapify(nums, i, 0);
}
return nums;
}
// 下沉操作:维护大顶堆性质
//假设当前节点 i 的两个子树已经是堆了,但 i 可能是个捣乱的小数。我们要把它"沉"下去,把下面的大数"浮"上来,维持大顶堆(父节点 >= 子节点)的性质。
/**
逻辑流程:
找出 当前节点、左孩子、右孩子 三者中的最大值。
如果最大值不是当前节点(说明孩子比爸爸大),那就交换。
交换后,可能会破坏被换下去的那个子树的稳定性,所以要递归继续搞那个子树。
*/
// n: 当前堆的大小(注意:不是数组总长度,是还没排好序的那部分长度)
// i: 当前需要下沉的节点下标
private void heapify(int[] nums, int n, int i){
while(true){
int largest = i;
int left = 2 * i + 1; // 左孩子下标
int right = 2 * i + 2; // 右孩子下标
//一定要判断 left < n 和 right < n。如果不判断,计算出的 2*i + 1 可能会越界,抛出IndexOutOfBoundsException
// 1. 和左孩子比
if(left < n && nums[left] > nums[largest]){
largest = left;
}
// 2. 和右孩子比
if (right < n && nums[right] > nums[largest]) {
largest = right;
}
// 3. 判断是否需要交换
if(largest == i){
// 如果最大值就是当前节点,说明位置已经对上了,不用再下沉了
break;
}
//走到这里就是需要交换
swap(nums, i, largest);
// 【关键点】:手动移动指针
// 不用递归 heapify(nums, n, largest)
// 而是直接把 i 变成 largest,让循环在下一轮处理下一层
i = largest;
}
}
private void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
手撕归并排序(LeetCode 912为例)
java
class Solution {
private int[] temp;
public int[] sortArray(int[] nums) {
// 1. 初始化辅助数组,大小和原数组一样
temp = new int[nums.length];
// 2. 开启递归
mergeSort(nums, 0, nums.length-1);
return nums;
}
private void mergeSort(int[] nums, int left,int right){
// 终止条件:如果不构成区间(只剩一个元素或非法),直接返回
if (left >= right) {
return;
}
// 1. 找中点,防止整数溢出
int mid = left + (right - left) / 2;
// 2. 分治:分别排序左边和右边
mergeSort(nums, left, mid); // 排好左半边 [left, mid]
mergeSort(nums, mid + 1, right); // 排好右半边 [mid+1, right]
merge(nums, left, mid, right);
}
// 合并函数:合并两个有序数组 [left, mid] 和 [mid+1, right]
private void merge(int[] nums, int left, int mid, int right){
// 左半区的指针 i,右半区的指针 j
int i = left;
int j = mid + 1;
// 临时数组的指针 t (注意:这里我们复用全局 temp)
// 既然是复用,我们可以直接从 left 开始覆盖,或者从 0 开始存都可以。
// 为了方便,这里我们从 0 开始存到 temp 里,最后再拷回去。
int t = 0;
// --- 双指针比大小 ---
while(i <= mid && j <= right){
if(nums[i] <= nums[j]){
temp[t] = nums[i];
i++;
}else{
temp[t] = nums[j];
j++;
}
t++;
}
// --- 处理剩余元素 ---
// 左边如果还没走完,全部接过去
while(i <= mid){
temp[t] = nums[i];
i++;
t++;
}
while(j <= right){
temp[t] = nums[j];
j++;
t++;
}
// --- 搬运回原数组 ---
// 注意:temp 里的数据是排好序的,但它是从下标 0 开始存的。
// 我们要把它搬回到 nums 的 [left, right] 这段位置去。
for(int k = 0; k < t; k++){
nums[left + k] = temp[k];
}
}
}