堆Heap和优先队列(Priority Queue)学习小结

本文详细介绍了堆数据结构的特点及应用,包括最大堆与最小堆的区别,如何使用堆进行排序,以及堆与优先队列的关系。同时探讨了在不同编程语言中实现堆的具体方法,并分析了它们的时间与空间复杂度。

Heap是一种数据结构,能保证取max/min是O(1)时间。通常如果查最小值/最大值,我们可以用Heap。如果是查是不是存在(Contain()),就用HashMap。如果又要查最小/大值,又要查是不是存在,就用Heap+HashMap.

Max-heap/Min-heap保证父节点都比子节点大/小,但兄弟节点之间没有大小关系。

Heap虽然是一个(满)二叉树,但通常用数组实现(因为容易实现)。一个节点i的左子节点编号是2*i,右子节点是2*i+1。Heap通常是完全二叉树(因为效率高),但没有要求一定是完全二叉树。

Heap Sort就是把所有元素放到Max Heap或Min Heap里面,再一个一个pop出来。时间复杂度最坏/最好/平均 都是O(nlogn),空间复杂度为O(1)。注意Heap Sort是不稳定排序,因为它不能保证重复元素排序后还保留前后一致的顺序。注意Heap Sort实际应用不多,为什么呢?因为Heap的维护很麻烦。实际场景中的数据是频繁发生变动的,而对于待排序序列的每次更新(增,删,改),我们都要重新做一遍堆的维护,以保证其特性(父节点大/小于其子节点),这在大多数情况下都是没有必要的。
另一个原因是Heap的cache locality不好,即如果在较短时间内访问多个元素,这多个元素在Heap中的位置可能较远,所以不能用cache一次读出来。
当数据更新不很频繁的时候,堆排序可能会比较实用。

Priority Queue也是一种数据结构。其中每个元素都有一个关键字key,元素之间的比较都是通过key来比较的。优先队列包括最大优先队列和最小优先队列,优先队列的应用比较广泛,比如作业系统中的调度程序,当一个作业完成后,需要在所有等待调度的作业中选择一个优先级最高的作业来执行,并且也可以添加一个新的作业到作业的优先队列中。一个典型的例子是实时输出最近的一个小时内访问频率最高的10个IP 。注意这里是实时输出,也就是说Priority Queue/Heap可以用于在线算法,不需要读完全部的数据,可以real time输出到当前为止的结果。

Priority Queue可以用Heap实现,也可以用其他方式,比如说直接对数组插入实现,但是效率不高。注意Priority Queue不是Queue,那为什么它的名字里面有queue呢?可能是因为它能提供queue的接口,比如remove(),pop(), insert(), top()等等。

Priority Queue和Heap有什么区别吗?如果是JAVA自带的Priority Queue的话,答案是有的(C++的Priority Queue可能也类似,具体要确认一下)。
具体的区别是在remove操作中,Priority Queue的时间复杂度是O(n),而Heap是O(logn)。因为Priority Queue需要找到这个数据,需要O(n)的时间,而Heap借助了HashMap,所以只需要O(1)的时间就可以找到。那为什么Priority Queue不用HashMap呢?这个就是JAVA提供的函数里面没有加上这一块而已,如果自己编程就也可以是O(logn)。

C++的priority_queue的定义是:
priority_queue<Type, Container, Functional>
Type为数据类型, Container为保存数据的容器,Functional为元素比较方式。

如果不写后两个参数,那么容器默认用的是vector,比较方式默认用operator<,也就是优先队列是大顶堆,队头元素最大。

下面的内容参考了链接
https://blog.csdn.net/nisxiya/article/details/45725857

另外,需要注意的是,C++ STL默认的priority_queue是将优先级最大的放在队列最前面,也即是最大堆。
假设有如下一个struct:

struct Node {
    int value;
    int idx;
    Node (int v, int i): value(v), idx(i) {}
    friend bool operator < (const struct Node &n1, const struct Node &n2) ; 
};

inline bool operator < (const struct Node &n1, const struct Node &n2) {
    return n1.value < n2.value;
}

priority_queue<Node> pq; // 此时pq为最大堆

如果需要最小堆,则需要如下实现:

struct Node {
    int value;
    int idx;
    Node (int v, int i): value(v), idx(i) {}
//  friend bool operator < (const struct Node &n1, const struct Node &n2) ;
    friend bool operator > (const struct Node &n1, const struct Node &n2) ;
}; 

inline bool operator > (const struct Node &n1, const struct Node &n2) {
    return n1.value > n2.value;
}
priority_queue< Node, vector< Node >, greater< Node > > pq; // 此时greater会调用 > 方法来确认Node的顺序,此时pq是最小堆

注意为什么greater是最小堆呢,而less是最大堆呢?因为如果是greater的时候,说明大的会沉下去,所以是最小堆。如果是less
priority_queue<int, vector<int>, greater<int>> minHeap;
priority_queue<int, vector<int>, less<int>> maxHeap;

也可以用以下办法定义最小堆:

class Node {
public:
    Node(int r, int c, int v) : row(r), col(c), val(v) {}
    bool operator < (const Node & obj) const {
        return val > obj.val;
    }
    int row, col, val;
};
priority_queue<Node> pq;

注意:我们什么时候需要定义operator<和operator>呢?这里要注意最小堆需要跟top比,比top大的才能进堆,所以需要定义operator>,同理,最大堆需要定义operator<。但如果这里我们不用自定义类型(比如Node)的话,我们就不需要定义operator<和operator>了,因为C++可以直接对内部保留类型(如int, char)来比大小。

也可以用decltype来定义最大堆和最小堆。以最小堆为例:

  map<int, int> freqs; //<num, freq>
 int n = nums.size();
 for (int i = 0; i < n; i++) {
     freqs[nums[i]]++;
 }
 auto comp = [&](int a, int b){return freqs[a] > freqs[b];};
 priority_queue<int, vector<int>, decltype(comp)> minHeap(comp);

记忆捷径:把 return a > b 翻译成“排序”如果你觉得底层的堆推导很绕,可以用 std::sort 的逆向思维来记忆:在 std::sort 中,如果你传入 a > b,代码会按从大到小降序排列(大元素在前面)。std::priority_queue 的堆顶,对应的是底层数组的“末尾”(因为为了效率,pop 操作是从数组末尾移除元素的)。如果数组是从大到小排列(大…中…小),那么末尾就是最小的元素。既然末尾是最小的元素,而堆顶对应的就是末尾,那么堆顶自然就是最小的元素(小顶堆)。
末尾是最小的元素,而堆顶对应的就是末尾。为什么? 这是一个非常硬核且底层的 C++ 标准库(STL)实现细节。简单直接的答案是:为了实现 (O(1)) 时间复杂度的删除(Pop)操作。如果堆顶对应数组的开头(索引 0),删除堆顶时就需要移动数组中的所有其他元素,这会导致性能暴跌。为了避免这个性能问题,C++ STL 的 std::priority_queue 巧妙地利用了 std::vector 的末尾。:

注意:sort()和priority_queue<> 的第3个参数不一样!sort不用加decltyp,priority_queue要加。
它们两个的第三个参数本质完全不同:std::sort 是一个函数(Function),它的第三个参数需要传入一个对象 / 实例(可调用对象)。std::priority_queue 是一个类模板(Class Template),它的第三个参数需要传入一个类型(Type)。这就是为什么一个不需要 decltype,而另一个必须要加(或者传入结构体)的原因。

另外,一个诀窍关于记住第3个参数到底是类型type还是实例。

  1. container(e.g., map, set, queue, priority_queue, vector, …) 的第3个参数是type; function(e.g., sort)的第3个参数是function。
    注意:仿函数是一个类,仿函数的实例相当于一个function。Lambada函数也相当于一个function。

//这是一个仿函数,因为重载了operator()
//注意对unordered_map和unordered_set,这里opeator()要返回size_t。对map,set,priority_queue,sort,这里operator()要返回bool
struct Compare {
bool operator()(const Node& a, const Node& b) const {
return a.dist < b.dist;
}
};
//这是仿函数的实例, 另外Compare()或者Compare{}也是仿函数的实例
Compare compare;
sort(nodes.begin(), nodes.end(), compare); //注意,如果是priority_queue,第3个参数就用Compare就行了.
临时仿函数实例
sort(nodes.begin(), nodes.end(), Compare{});
Lambda 实例
sort(nodes.begin(), nodes.end(),
[](const Node& a, const Node& b) {
return a.dist < b.dist;
});
Lambda 表达式产生的也是一个匿名类的对象,该类重载了 operator()。

  1. 看符号:
    尖括号 < >(如 map<…>, unordered_map<…>, priority_queue<…>),里面一律填 类型(Type)。
    圆括号 ( )(如 sort(…), find_if(…)),里面一律填 对象/值(Object/Value)。

如果硬要用 Lambda:
传给尖括号 < >:必须写 decltype(your_lambda)(C++20 之前)。
传给圆括号 ( ):直接写 your_lambda 名字或者把 { … } 丢进去即可。

/////////////////////

  1. std::priority_queue(类模板:传类型)当你声明一个优先队列时,你是在定义一个变量(创建对象)。在 C++ 中,定义变量时尖括号 < > 里面填写的必须是编译期就能确定的“类型”。
    正确写法:第三个参数是仿函数的“类型”
priority_queue<int, vector<int>, greater<int>> pq; 

priority_queue 内部会根据你传入的类型 greater,在自己类里面实例化出一个该类型的成员变量。在后续调用 push 等操作时,它会在底层自己调用这个成员变量。
如果你想用 Lambda 函数,也必须像 unordered_map 一样使用 decltype 来获取其类型,并且还要把 Lambda 对象传给构造函数:

auto cmp = [](int a, int b) { return a > b; };

// 尖括号内填类型,小括号内填对象
priority_queue<int, vector, decltype(cmp)> pq(cmp); //cmp是Lambda函数,类型是函数。这里第3个参数要是类型,所以要加decltype.

  1. std::sort(函数模板:传对象)当你使用 std::sort 时,你是在执行一个具体的操作(调用函数)。函数的圆括号 ( ) 里面填写的必须是运行期的“对象、变量或临时值”。
vector<int> nums = {3, 1, 4};

// 正确写法 1:传入一个临时的仿函数“对象”(注意后面的小括号 {} 或 ())
sort(nums.begin(), nums.end(), greater<int>{}); 

// 正确写法 2:直接传入一个 Lambda 函数“对象”
sort(nums.begin(), nums.end(), [](int a, int b) { return a > b; });

std::sort 的第三个参数是一个可调用对象。编译器会通过自动类型推导(Template Argument Deduction),自己去识别你传进来的对象的类型,不需要你在尖括号里手动指定。

//////////////////////////////////////////////////

一个经典的用最大堆和最小堆的例子是求n个无序数中的第K大的数。该题用最大堆和最小堆都可以做 (重要!)
用最大堆做的代码如下。复杂度O(nlogn)

    // max-heap
    int findKthLargest(vector<int> &nums, int k)
    {
        priority_queue<int> pq(nums.begin(), nums.end());
        for (int i = 0; i < k - 1; i++)
        {
            pq.pop();
        }
        return pq.top();
    }

用最小堆做代码如下。复杂度O(nlogk)

    // min-heap
    int findKthLargest(vector<int> &nums, int k)
    {
        priority_queue<int, vector<int>, greater<int> > pq;

        for (int i = 0; i < nums.size(); i++)
        {
            if (pq.size() == k)
            {
                int x = pq.top();
                if (nums[i] > x)
                {
                    pq.pop();
                    pq.push(nums[i]);
                }
            }
            else
            {
                pq.push(nums[i]);
            }
        }
        return pq.top();
    }
};

当然也可以用将乘以负数的办法来构造最小堆。比如要构造一个int类型的最小堆:

priority_queue pq; //
pq.push( -1 * v1) ; //
pq.push( -1 * v2) ; //
pq.push( -1 * v3) ; // 分别是插入v1, v2, v3变量的相反数,那么pq其实也就变相成为了最小堆

下面这个链接很不错:
https://blog.csdn.net/txl199106/article/details/44588487

另外,也可以通过定义struct cmp来实现最小堆,cmp里面只需给出operator()函数。给出operator()后则不需再定义operator < 或operator >。用这种方法必须通过以下方式定义priority_queue。

    priority_queue<Node,vector<Node>,cmp>q;

注意:priority_queue第3个参数cmp必须是class/struct,而sort()的第3个参数可以是function或class/struct的实例(注意是实例,不是class/struct的定义)。

举例如下(定义最小堆)。如果要生成最大堆,只需将operator()里面的>改成<即可。

struct node{
  int idx;
  int key;
  node(int a=0, int b=0):idx(a), key(b){}
};

struct cmp{
  bool operator()(node a, node b){
    return a.key > b.key;
  }
}; 
priority_queue<node, vector<node>, cmp> q;

实例代码如下 (from https://blog.csdn.net/txl199106/article/details/44588487):

#include <iostream>
#include <queue>
using namespace std;
struct Node{
	int x, y;
}node;
struct cmp{
    bool operator()(Node a,Node b){
        if(a.x==b.x) return a.y>b.y;
        return a.x>b.x;}
};

 int main(){
    priority_queue<Node,vector<Node>,cmp>q;
    for(int i=0;i<10;i++){
    	node.x=i;
    	node.y=10-i/2;
		q.push(node);
    }
    while(!q.empty()){
        cout<<q.top().x<<' '<<q.top().y<<endl;
        q.pop();
    }
    return 0;
}

该代码输出如下:
0 10
1 10
2 9
3 9
4 8
5 8
6 7
7 7
8 6
9 6

如果将operator()里面的>改为<,则为最大堆。输出结果为:
9 6
8 6
7 7
6 7
5 8
4 8
3 9
2 9
1 10
0 10

另外,C++里面也可以用multiset实现最大堆和最小堆。代码如下:

    multiset<int, greater<int>> greaterSet;
    multiset<int, less<int>> lessSet;
    multiset<int> defaultSet;  //默认为lessSet

将元素插入堆后,greaterSet.begin()所指向的值就是最大值。lessSet.begin()所指向的值就是最小值。

一个小总结 (不一定对)
求第K大用最小堆:
求第K小用最大堆
这个好像不管是对动态数据和静态数据都实用,时间复杂度O(nlogk)。但静态数据求第k大/小用quickselect更好,时间复杂度O(n)。

注意:堆是完全二叉树,但不是平衡二叉树。为什么不是平衡二叉树呢?堆的左右两个子树的高度差的绝对值不超过1啊?因为平衡二叉树必须首先是binary search tree,堆不满足这个条件。

另外,下面这个链接解释了为什么sort和priority_queue的第三个参数不一样,sort是传入一个Compare类的实例,而priority_queue是传入一个类。这是因为本质上:
sort是一个函数,函数参数列表()中需要传入实例化后的具体对象;
priority_queue是一个模板类,模板的类型参数表中<>需要传入具体的类,而不是对象。
https://www.codenong.com/cs109745348/

评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值