【优先级队列(堆)+排序】LeetCode hot100+面试高频

文章目录

    • [LeetCode 215. 数组中的第 K 个最大元素](#LeetCode 215. 数组中的第 K 个最大元素)
      • 小根堆解法
      • 快排解法
        • [一、 为什么是 O(N) 而不是 O(logN)?](#一、 为什么是 O(N) 而不是 O(logN)?)
        • [二、 为什么要随机选 Pivot?](#二、 为什么要随机选 Pivot?)
    • [手撕快排(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:

  1. 第一轮

    • Pivot 是 1
    • Partition 遍历完 100 个数,发现 1 就在最左边。
    • 左边(比1小的)是空,右边(比1大的)有 99 个。
    • 结果 :你忙活了半天,只排除了 1 这一数字。哪怕是只剩下 99 个,规模并没有变成一半!
  2. 第二轮

    • 剩下的数组是 [2, 3, ..., 100]。Pivot 选 2
    • Partition 遍历完 99 个数。
    • 结果 :又只排除了 2 这一个数字。剩下 98 个。
  3. 结局

    你需要遍历 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];
        }
    }
}
相关推荐
亭上秋和景清2 小时前
计算器回调函数
c语言·数据结构·算法
a程序小傲2 小时前
百度Java面试被问:HTTPS解决了HTTP什么问题?
java·后端·http·百度·面试
第二只羽毛2 小时前
基于Deep Web爬虫的当当网图书信息采集
大数据·开发语言·前端·爬虫·算法
Ayanami_Reii2 小时前
详解Splay平衡树
数据结构·算法·线段树·主席树·持久化线段树
前端小白在前进2 小时前
★力扣刷题:LRU缓存
spring·leetcode·缓存
JiaJZhong2 小时前
560. 和为 K 的子数组
数据结构·算法
小年糕是糕手2 小时前
【C++】模板初阶
java·开发语言·javascript·数据结构·c++·算法·leetcode
AndrewHZ3 小时前
【遥感图像入门】遥感图像专用去噪算法:核心方案与实战(PyTorch代码)
pytorch·算法·计算机视觉·cv·遥感图像·高分辨率·去噪算法
前端小L4 小时前
回溯算法专题(八):精细化切割——还原合法的「IP 地址」
数据结构·算法