3-18-堆

215. 数组中的第K个最大元素 - 力扣(LeetCode)

方法一:基于堆排序的选择方法

思路和算法

我们也可以使用堆排序来解决这个问题——建立一个大根堆,做 k−1 次删除操作后堆顶元素就是我们要找的答案。在很多语言中,都有优先队列或者堆的的容器可以直接使用,但是在面试中,面试官更倾向于让更面试者自己实现一个堆。所以建议读者掌握这里大根堆的实现方法,在这道题中尤其要搞懂「建堆」、「调整」和「删除」的过程。

友情提醒:「堆排」在很多大公司的面试中都很常见,不了解的同学建议参考《算法导论》或者大家的数据结构教材,一定要学会这个知识点哦!_

class Solution {
public:
 void maxHeapify(vector<int>& a, int i, int heapSize) {
     int l = i * 2 + 1, r = i * 2 + 2, largest = i;
     if (l < heapSize && a[l] > a[largest]) {
         largest = l;
     } 
     if (r < heapSize && a[r] > a[largest]) {
         largest = r;
     }
     if (largest != i) {
         swap(a[i], a[largest]);
         maxHeapify(a, largest, heapSize);
     }
 }

 void buildMaxHeap(vector<int>& a, int heapSize) {
     for (int i = heapSize / 2 - 1; i >= 0; --i) {
         maxHeapify(a, i, heapSize);
     } 
 }

 int findKthLargest(vector<int>& nums, int k) {
     int heapSize = nums.size();
     buildMaxHeap(nums, heapSize);
     for (int i = nums.size() - 1; i >= nums.size() - k + 1; --i) {//去除堆顶元素k-1次,可直接把循环写为
   //for(i : 0~k-1)
         swap(nums[0], nums[i]);
         --heapSize;
         maxHeapify(nums, 0, heapSize);
     }
     return nums[0];
 }
};

复杂度分析

  • 时间复杂度:O(nlogn),建堆的时间代价是 O(n),删除的总代价是 O(klogn),因为 k<n,故渐进时间复杂为 O(n+klogn)=O(nlogn)。

  • 空间复杂度:O(logn),即递归使用栈空间的空间代价。

方法二:基于快速排序的选择方法

思路和算法

我们可以用快速排序来解决这个问题,先对原数组排序,再返回倒数第 k 个位置,这样平均时间复杂度是 O(nlogn),但其实我们可以做的更快。

首先我们来回顾一下快速排序,这是一个典型的分治算法。我们对数组 a[l⋯r] 做快速排序的过程是(参考《算法导论》):

  1. 分解: 将数组 a[l⋯r] 「划分」成两个子数组 a[l⋯q−1]、a[q+1⋯r],使得 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。其中,计算下标 q 也是「划分」过程的一部分。

  2. 解决: 通过递归调用快速排序,对子数组 a[l⋯q−1] 和 a[q+1⋯r] 进行排序。

  3. 合并: 因为子数组都是原址排序的,所以不需要进行合并操作,a[l⋯r] 已经有序。

上文中提到的 「划分」 过程是:从子数组 a[l⋯r] 中选择任意一个元素 x 作为主元,调整子数组的元素使得左边的元素都小于等于它,右边的元素都大于等于它, x 的最终位置就是 q。
由此可以发现每次经过「划分」操作后,我们一定可以确定一个元素的最终位置,即 x 的最终位置为 q,并且保证 a[l⋯q−1] 中的每个元素小于等于 a[q],且 a[q] 小于等于 a[q+1⋯r] 中的每个元素。

所以只要某次划分的 q 为倒数第 k 个下标的时候,我们就已经找到了答案。 我们只关心这一点,至于 a[l⋯q−1] 和 a[q+1⋯r] 是否是有序的,我们不关心。

因此我们可以改进快速排序算法来解决这个问题:在分解的过程当中,我们会对子数组进行划分,如果划分得到的 q 正好就是我们需要的下标,就直接返回 a[q];否则,如果 q 比目标下标小,就递归右子区间,否则递归左子区间。这样就可以把原来递归两个区间变成只递归一个区间,提高了时间效率。这就是「快速选择」算法。

我们知道快速排序的性能和「划分」出的子数组的长度密切相关。直观地理解如果每次规模为 n 的问题我们都划分成 1 和 n−1,每次递归的时候又向 n−1 的集合中递归,这种情况是最坏的,时间代价是 O(n^2 )。我们可以引入随机化来加速这个过程,它的时间代价的期望是 O(n),证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。需要注意的是,这个时间复杂度只有在 随机数据下才成立,而对于精心构造的数据则可能表现不佳。因此我们这里并没有真正地使用随机数,而是使用双指针的方法,这种方法能够较好地应对各种数据。

class Solution {
public:
    int quickselect(vector<int> &nums, int l, int r, int k) {
        if (l == r)
            return nums[k];
        int partition = nums[l], i = l - 1, j = r + 1;
        while (i < j) {
            do i++; while (nums[i] < partition);
            do j--; while (nums[j] > partition);
            if (i < j)
                swap(nums[i], nums[j]);
        }
        if (k <= j)return quickselect(nums, l, j, k);
        else return quickselect(nums, j + 1, r, k);
    }

    int findKthLargest(vector<int> &nums, int k) {
        int n = nums.size();
        return quickselect(nums, 0, n - 1, n - k);
    }
};

复杂度分析

  • 时间复杂度:O(n),如上文所述,证明过程可以参考「《算法导论》9.2:期望为线性的选择算法」。

  • 空间复杂度:O(logn),递归使用栈空间的空间代价的期望为 O(logn)。

方法三:调用函数priority_queue()

堆的前置知识

是一个完全二叉树(最后一层可以不满,上面的每一层都是满的。一个结点若只有一个孩子结点,那一定是它的左孩子。如下图)这是一个逻辑上基于完全二叉树、物理上一般基于线性数据结构(如数组、向量、链表等)的一种数据结构。

完全二叉树

完全二叉树最重要的性质:如果n个节点的完全二叉树的节点按照层次并按从左到右的顺序从0开始编号,对于每个节点都有:

  • 序号为0的节点是根
  • 对于 i > 0 ,其父节点的编号为( i − 1 ) / 2 。
  • 若 2 * i + 1 < n,其左子节点的序号为 2 ⋅ i + 1 ,否则没有左子节点。
  • 若 2 * i + 2 < n ,其右子节点的序号为 2 ⋅ i + 2 ,否则没有右子节点。
  • 另外我们下边举例子根节点为数组下标为1的位子,相应的公式也会有点变化
  • 大根堆即指在逻辑上的二叉树结构中,根结点>子结点,总是最大的,并且在堆的每一个局部都是如此大根堆的根结点在整个堆中是最大的元素。
  • 小根堆的性质与大根堆类似,只不过在二叉树的结构中,根结点<子结点。小根堆的根结点在整个堆中是最小的元素。

priority_queue 容器适配器定义了一个元素有序排列的队列。默认队列头部的元素优先级最高。因为它是一个队列,所以只能访问第一个元素,这也意味着优先级最高的元素总是第一个被处理。但是如何定义“优先级”完全取决于我们自己。

  1. 需要使用的头文件:
#include <queue>
  1. 优先队列:在C++中优先队列默认的是大根堆,如果用小根堆则加入greater.

  2. 小根堆:
    使用函数对象greater来生成小根堆

    注意:这里的大于号>规定了优先级,表示优先队列后面的元素都要大于优先队列前面的元素,因为优先队列队首的元素优先级最高,优先队列队尾元素的优先级最低,所以大于号>就规定了优先队列后面的元素都要大于优先队列前面的元素(尾部优先级小于首部优先级),也就是形成一个小根堆,升序排序,每次权值最小的会被弹出来

    priority_queue<int, vector<int>, greater<int>> test; 
    
priority_queue<int, vector<int>, less<int>>s;//less表示按照递减(从大到小)的顺序插入元素
priority_queue<int, vector<int>, greater<int>>s;//greater表示按照递增(从小到大)的顺序插入元素

不写第三个参数或者写成less都是大根堆。greater是小根堆。

  1. 支持的顺序容器:vector,queue。默认是vector。

  2. priority queue类,能按照有序的方式在底层数据结构中执行插入、删除操作。

  3. priority queue的特有操作:

    • q.pop():删除优先队列priority_queue的最高优先级元素(通过调用底层容器的pop back()实现的)
    • q.push(item):在priority_queue优先级顺序合适的位置添加创建一个值为item的元素(通过调用底层容器的push back(0操作实现)
    • q.emplace(args):在priority_queue优先级顺序合适的位置添加一个由args构造的元素(通过调用底层容器的emplace back(操作实现的)
    • q.top():返回priority queue的首元素的引用(通过调用底层容器的front()操作实现的)
    • q.empty():判断s是否为空,空返回true,否则返回false(通过调用底层容器的empty()操作实现的)
    • q.size():返回s中的元素个数(通过调用底层容器的size()操作实现的)
    • swap(q,P):交换两个优先队列priority,queue p,q的内容,p和q的底层容器类型也必须相同(通过调用底层容器的swap()操纵实现的)
class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        //小根堆中存储数组中k个最大的元素
        priority_queue<int,vector<int>,greater<int>> heap;
        int n = nums.size();
        for(int i = 0; i < n; i++){
            //堆的大小小于k,向堆内添加一个元素
            if(heap.size() < k){
                heap.push(nums[i]);
            }
            //堆的大小大于等于k
            //如果当前元素比堆内所有元素都小,就加入堆,反之,弹出堆内最小的那个元素后加入
            else{
                if(nums[i] > heap.top()){
                    heap.pop();
                    heap.push(nums[i]);
                }
            }
        }
        return heap.top();
    }
};

代码逻辑分析

  1. 小根堆维护:使用一个大小为k的小根堆,堆顶是堆中最小的元素。
  2. 遍历数组
    • 当堆大小不足k时,直接插入元素。
    • 当堆大小等于k时,若当前元素大于堆顶,则替换堆顶(保证堆中始终是较大的k个元素)。
  3. 结果返回:遍历完成后,堆顶即为第k大的元素(因为堆中存储了最大的k个元素,堆顶是其中最小的)。

示例说明

以数组 nums = [3,2,1,5,6,4]k=2 为例:

  1. 初始堆为空,依次插入3、2,堆为 [2,3],堆顶是2。
  2. 处理1:1不大于堆顶2,不操作。
  3. 处理5:5 > 2,替换堆顶,堆变为 [3,5],堆顶3。
  4. 处理6:6 > 3,替换堆顶,堆变为 [5,6],堆顶5。
  5. 处理4:4 < 5,不操作。
  6. 最终堆顶5,即第2大的元素。

时间复杂度

  • 每个元素最多进行一次堆插入(O(logk))和一次堆删除(O(logk))。
  • 总时间复杂度为 O(n logk),其中n是数组长度。

空间复杂度

  • 堆最多存储k个元素,空间复杂度为 O(k)

总结

该算法通过维护一个大小为k的小根堆,高效地筛选出最大的k个元素,堆顶即为结果。相比完全排序(O(n logn)),在k较小时更优。例如,当k=1000且n=1,000,000时,复杂度为O(n logk) ≈ 1e6 * 10,远优于O(n logn) ≈ 1e6 * 20。但对于k接近n的情况,快速选择(平均O(n))可能更高效。


347. 前 K 个高频元素 - 力扣(LeetCode)

第一思路代码:

class Solution {
  public:
      vector<int> topKFrequent(vector<int>& nums, int k) {
          vector<int> res;
          unordered_map<int , int> mp;
          for(int i : nums) mp[i] ++;

          priority_queue<int , vector<int> , greater<int>> heap;

          for(auto& p : mp){
            if(heap.size() < k) heap.push(p[1]);
            else{
              if(p[1] > heap.top()){
                heap.pop();
                heap.push(p[1]);
              }
            }
          }

          for(auto& ip : heap){
            res.push_back(ip[0]);
          }
          return res;
      }
  };

问题:

第一个问题:unordered_map的遍历。在C++中,unordered_map的迭代器解引用后的类型是std::pair<const Key, Value>。所以当用户使用auto& p : mp时,p是一个pair的引用,其中键是p.first,值是p.second。但在代码中,用户写的是p[1],这在C++中是不正确的,因为pair没有下标访问,应该用p.second

第二个问题:堆的元素类型。用户定义的堆是priority_queue<int, vector<int>, greater<int>>,即堆中存储的是int类型。但在这里,用户需要根据元素的频率来维护堆,但堆里存储的是频率值,而不是元素本身。这样会导致问题,因为最终需要返回的是元素本身,而不是它们的频率。例如,当频率相同时,如何知道对应的元素是什么?因此,堆中应该存储的是元素及其频率的组合,这样才能在最后获取元素的值。

第三个问题:堆的比较函数。当前堆的比较函数是greater<int>,即小根堆,但堆中存储的是频率的数值。如果堆中存储的是频率,那么小根堆的堆顶是当前最小的频率。当新元素的频率大于堆顶时,替换堆顶,这样可以保持堆中始终有最大的k个频率。这部分逻辑是正确的,但问题在于堆里存储的是频率,而无法对应到元素本身。例如,当堆里的元素是频率值时,用户无法知道这些频率对应的元素是哪些,所以在最后无法将元素收集到结果中。

堆需要按频率排序,因此需存储 pair<频率, 元素> 并自定义比较逻辑。

第四个问题:结果的收集。用户尝试遍历堆,将ip[0]加入结果。但堆中的元素是int类型(频率),而ip在这里是一个int,所以ip[0]是错误的,会导致编译错误。此外,堆的遍历方式不正确,因为priority_queue不提供迭代器访问,用户不能直接使用范围for循环遍历堆。所以,这部分代码也是错误的。

改正后代码:

class Solution {
public:
    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> freq_map;
        for (int num : nums) freq_map[num]++; // 统计频率

        // 定义小根堆,存储 pair<频率, 元素>,按频率排序
        priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> min_heap;

        // 维护大小为 k 的堆
        for (auto& entry : freq_map) {
            int num = entry.first;
            int freq = entry.second;
            if (min_heap.size() < k) {
                min_heap.push({freq, num});
            } else if (freq > min_heap.top().first) {
                min_heap.pop();
                min_heap.push({freq, num});
            }
        }

        // 收集结果
        vector<int> res;
        while (!min_heap.empty()) {
            res.push_back(min_heap.top().second);
            min_heap.pop();
        }
        return res;
    }
};

复杂度分析

  • 时间复杂度:O(Nlogk),其中 N 为数组的长度。我们首先遍历原数组,并使用哈希表记录出现次数,每个元素需要 O(1) 的时间,共需 O(N) 的时间。随后,我们遍历「出现次数数组」,由于堆的大小至多为 k,因此每次堆操作需要 O(logk) 的时间,共需 O(Nlogk) 的时间。二者之和为 O(Nlogk)。

  • 空间复杂度:O(N)。哈希表的大小为 O(N),而堆的大小为 O(k),共计为 O(N)。


另一种写法:

#include <vector>
#include <unordered_map>
#include <queue>
using namespace std;

class Solution {
public:
    // 自定义小顶堆比较函数
    class Compare {
    public:
        bool operator()(const pair<int, int>& a, const pair<int, int>& b) {
            return a.second > b.second; // 按频率从小到大排序
        }
    };

    vector<int> topKFrequent(vector<int>& nums, int k) {
        // 统计频率
        unordered_map<int, int> freq;
        for (int num : nums) freq[num]++;

        // 定义小顶堆,存储 (元素值, 频率)
        priority_queue<pair<int, int>, vector<pair<int, int>>, Compare> min_heap;

        // 遍历频率哈希表
        for (unordered_map<int, int>::iterator it = freq.begin(); it != freq.end(); it++) {
              min_heap.push(*it);
            if (min_heap.size() > k) { // 如果堆的大小大于了K,则队列弹出,保证堆的大小一直为k
              //因为这里已经写了比较逻辑,不用再比较it.second了
                min_heap.pop();
            }
        }

        // 找出前K个高频元素,因为小顶堆先弹出的是最小的,所以倒序来输出到数组
        vector<int> res(k);
        for (int i = k - 1; i >= 0; i--) {
            res[i] = min_heap.top().first;
            min_heap.pop();
        }
        return res;
    }
};

用lambda函数对比较函数定义:

如果想用lambda函数对堆的第三个参数进行定义,如:

priority_queue<pair<int, int>, vector<pair<int, int>>, [](const pair<int , int>&l , const pair<int , int>& r)->bool{return l.second > r.second;};> pri_que;

这里第三个模板参数应该是一个类型,而用户传递了一个lambda表达式,这是不允许的。在C++中,lambda表达式不能作为模板参数的类型,只能作为对象实例。所以这里会导致编译错误。

正确的做法是使用decltype来推导lambda的类型,并将lambda作为构造函数的参数。例如:

auto comp = [](const pair<int, int>& l, const pair<int, int>& r) { return l.second > r.second; };

priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(comp)> pri_que(comp);

小顶堆为何是l.second > r.second;

优先队列的比较函数本质

priority_queue 的第三个模板参数是一个 比较类,其 operator() 需要定义元素的 优先级规则

  • 当比较函数返回 true 时,表示 第一个参数应排在第二个参数的后面
  • 这会导致堆中父节点的优先级 始终高于子节点(即堆顶是优先级最高的元素)。

为什么用 a.second > b.second 实现小顶堆?

我们想让堆按元素的频率从小到大排列(即堆顶是频率最小的元素),所以比较函数的设计逻辑是:

// 当 a 的频率 > b 的频率时,认为 a 的优先级更低,应该排在 b 的后面,所以b优先被踢出
bool operator()(const pair<int, int>& a, const pair<int, int>& b) {
    return a.second > b.second; 
}
  • 如果 a.second > b.second 返回 true
    意味着 a 的优先级低于 b,所以 a 会被放在 b 的后面。此时堆顶会是频率最小的元素(小顶堆)。
  • 反之,若用 a.second < b.second
    堆会按频率从大到小排列,形成大顶堆。

直观例子说明

假设堆中已有两个元素 {元素1, 3}(频率3)和 {元素2, 5}(频率5):

  • 使用 a.second > b.second 的比较规则:
    频率较小的 3 会被推到堆顶,形成小顶堆。
  • 使用 a.second < b.second 的比较规则:
    频率较大的 5 会被推到堆顶,形成大顶堆。

295. 数据流的中位数 - 力扣(LeetCode)

分析:

比如现在有 6 个数:1,5,6,2,3,4,要计算中位数,可以把这 6 个数从小到大排序,得到 1,2,3,4,5,6,中间 3 和 4 的平均值 3.5 就是中位数。

中位数把这 6 个数均分成了左右两部分,一边是 left=[1,2,3],另一边是 right=[4,5,6]。我们要计算的中位数,就来自 left 中的最大值,以及 right 中的最小值

随着 addNum 不断地添加数字,我们需要:

  • 保证 left 的大小和 right 的大小尽量相等。(规定:在有奇数个数时,left 比 right 多 1 个数。)
  • 保证 left 的所有元素都小于等于 right 的所有元素。

只要时时刻刻满足以上两个要求(满足中位数的定义),我们就可以用 left 中的最大值以及 right 中的最小值计算中位数。

分类讨论:

  1. 如果当前 left 的大小和 right 的大小相等:

    • 如果添加的数字 num 比较大,比如添加 7,那么把 7 加到 right 中。现在 left 比 right 少 1 个数,不符合前文的规定,所以必须把 right 的最小值从 right 中去掉,添加到 left 中。如此操作后,可以保证 left 的所有元素都小于等于 right 的所有元素。
    • 如果添加的数字 num 比较小,比如添加 0,那么把 0 加到 left 中。
    • 这两种情况可以合并:无论 num 是大是小,都可以先把 num 加到 right 中,然后把 right 的最小值从 right 中去掉,并添加到 left 中。
  2. 如果当前 left 比 right 多 1 个数:

    • 如果添加的数字 num 比较大,比如添加 7,那么把 7 加到 right 中。
    • 如果添加的数字 num 比较小,比如添加 0,那么把 0 加到 left 中。现在 left 比 right 多 2 个数,不符合前文的规定,所以必须把 left 的最大值从 left 中去掉,添加到 right 中。如此操作后,可以保证 left 的所有元素都小于等于 right 的所有元素。
    • 这两种情况可以合并:无论 num 是大是小,都可以先把 num 加到 left 中,然后把 left 的最大值从 left 中去掉,并添加到 right 中。
  3. 最后,我们需要什么样的数据结构?这个数据结构要能高效地执行如下操作:

  • 添加元素。
  • 找到最大(小)值。
  • 删除最大(小)值。

这个数据结构是堆。left 是最大堆,right 是最小堆。

返回中位数:

  • 如果当前有奇数个元素,中位数是 left 的堆顶。
  • 如果当前有偶数个元素,中位数是 left 的堆顶和 right 的堆顶的平均值。
class MedianFinder {  
        priority_queue<int> left;
        priority_queue<int , vector<int> , greater<>> right;
  public: 
      void addNum(int num) {
          if(left.size() == right.size()){
            right.push(num);
            left.push(right.top());
            right.pop();
          }
          else{
            left.push(num);
            right.push(left.top());
            left.pop();
          }
      }
      
      double findMedian() {
          if(left.size() > right.size()){
            return left.top();
          }
          return (left.top() + right.top()) / 2.0;
      }
  };
  
  /**
   * Your MedianFinder object will be instantiated and called as such:
   * MedianFinder* obj = new MedianFinder();
   * obj->addNum(num);
   * double param_2 = obj->findMedian();
   */
posted @ 2025-03-18 22:20  七龙猪  阅读(6)  评论(0)    收藏  举报
-->