C++初阶(15)stack、queue、优先级队列、双端队列

1. stack的介绍和使用

1.1 stack的介绍

stack的文档介绍

【说明】

  1. stack是一种容器适配器,专门用在具有后进先出操作的上下文环境中,其删除只能从容器的一端进行元素的插入与提取操作。

  2. stack是作为容器适配器被实现的,容器适配器即是对特定类封装作为其底层的容器,并提供一组特定的成员函数来访问其元素,将特定类作为其底层的,元素特定容器的尾部(即栈顶)被压入和弹出。

  3. stack的底层容器,可以是任何标准的容器类模板,或者一些其他特定的容器类。

  4. 这些容器类应该支持以下操作:

  • empty:判空操作
  • back:获取尾部元素操作
  • push_back:尾部插入元素操作
  • pop_back:尾部删除元素操作
  1. 标准容器vector、deque、list均符合这些需求,默认情况下,如果没有为stack指定特定的底层容器,默认情况下使用deque。

1.2stack的使用

1.2.1接口说明

|-----------------------------------------------------------------------------|--------------------|
| 函数说明 | 接口说明 |
| stack() | 构造空的栈 |
| empty() | 检测stack是否为空 |
| size() | 返回stack中元素的个数 |
| top() | 返回栈顶元素的引用 |
| push() | 将元素val压入stack中 |
| pop() | 将stack中尾部的元素弹出 |

1.2.2代码测试

1.2.3OJ练习

1.2.2.1最小栈

最小栈:是一个栈,支持后进先出,但是增加了一个接口------获取最小元素。

而且要求时间复杂度O(1),不能是O(N)遍历。


思路1:用一个min记录一下最小元素------每次入栈的时候,都用入栈元素跟min比较一下,如果比min小就更新min。

问题1:删栈顶元素之后,min该如何更新------得遍历一遍,但栈不允许遍历**(没有提供迭代器)**,就算允许遍历,时间复杂度也是O(N) 不是O(1)。


【注意】

  • minst栈顶就是当前最小元素,minst栈顶之下是曾经的最小元素,栈顶弹出后下面的"曾经最小元素"才有机会重新成为"当前最小元素"。
  • 入栈元素小于等于minst栈顶元素,都得入栈minst,得保证两边的最小元素的个数一致,因为删的时候是一起删的。

用C++的代码,就不需要造轮子了,也不用写构造,全是自定义类型成员变量,默认构造就可以。

cpp 复制代码
//最小栈类
//成员变量:st、minst
//成员函数:......
class MinStack
{
public:
    
    //入数据 
    void push(int x)
    { 
         // 只要是压栈,先将元素保存到_elem中------正常栈,直接入
         _elem.push(x);
 
         // 如果x小于_min中栈顶的元素,将x再压入_min中------min栈判断一下再入
         if(_min.empty() || x <= _min.top())
             _min.push(x);
     }
 
    //出数据
    void pop()
    {
        // 如果_min栈顶的元素等于出栈的元素,_min顶的元素要移除------min栈判断一下再出
        if(_min.top() == _elem.top())
            _min.pop();

         _elem.pop();------正常栈,直接出
    }
 
    int top(){return _elem.top();}    //获取栈顶元素
    int getMin(){return _min.top();}  //获取最小元素

private:

 // 保存栈中的元素
 std::stack<int> _elem;
 // 保存栈的最小值
 std::stack<int> _min;

};

不用写构造------编译器生成的默认构造,对自定义类型stack<int>会去调它的默认构造。

1.2.2.2 栈的弹出压入序列

入栈和出栈序列匹配,之前做过选择题。一个入栈序列,会有多个与之匹配的出栈序列。


任何一个数据都是入栈之后,再出栈。这道题感觉简单,写得稍有不慎,就会写成一个复杂题。

一定记住不要直接把入栈序列、出栈序列直接拿来比较。而是任何一个数据都保证它是入栈之后,再去出栈,就没问题。

入完栈之后再判断栈顶元素出不出, 而不是还没入栈就直接判断。

直到入栈序列push_st(数组的形式),本次入栈st的元素num,恰好等于出栈序列pop_st(数组的形式)的首元素,就执行出栈,按着出栈序列pop_st的顺序进行出栈。

匹配到情况b,就执行操作1。

匹配到情况a,就执行操作2。

出完4,用3和5比较,不相等,符合情况b,下一步是操作1。入栈5。

再比较,若相等,出完继续比。一直相等一直比。

结束条件:栈st空了。

不相等就继续入,一直不等一直入。

相等出完继续比,一直相等一直比。

5入栈,相等再出栈。

不相等,要再入数据,但是入栈序列已经空了。

【结束条件】

入栈序列必然已经结束了;

  • ① 上面这种情况------匹配的时候:出栈序列也结束了,同时栈空了
    (栈空or出完,都可以验匹配)
  • ② 下面这种情况------不匹配的时候:出栈序列没结束,同时栈也没空
    (栈非空or没出完,都可以验不匹配)
    如果把入栈序列(数组)和出栈序列(数组)直接比较,不相等就++,而不是借助一个第三方的栈这样一个数据结构来辅助判断,就很麻烦了。

算法能力跟看得算法课多少没多大关系,不是看了就会了,重要的是学会这些方法和思路。

  • 逻辑梳理顺了,代码就很好写了,大框架是这样,无非是一些细节上面的控制。
  • 这些算法题,代码已经不那么重要了,重要的是思路。画图把思路理清楚。
cpp 复制代码
class Solution {
public:
    bool IsPopOrder(vector<int> pushV,vector<int> popV) 
    {
        //入栈和出栈的元素个数必须相同 
        if(pushV.size() != popV.size())
            return false;
 
        // 用s来模拟入栈与出栈的过程
        int outIdx = 0;    //当前出栈序列的下标
        int inIdx = 0;     //当前入栈序列的下标
        stack<int> s;
 
         while(outIdx < popV.size())
         {
             // 如果s是空,或者栈顶元素与出栈的元素不相等,就入栈
             while(s.empty() || s.top() != popV[outIdx])
             {
                 if(inIdx < pushV.size())
                     s.push(pushV[inIdx++]);
                 else
                     return false;
             }
 
             // 栈顶元素与出栈的元素相等,出栈
             s.pop();
             outIdx++;    //下标迭代
         }
 
         return true;
     }
};
  • 栈为空:所有数据都匹配上了。
  • 栈不为空:有数据没匹配上。
1.2.2.3**逆波兰表达式求值**
cpp 复制代码
class Solution {
public:
 int evalRPN(vector<string>& tokens) {
 stack<int> s;
 for (size_t i = 0; i < tokens.size(); ++i)
 {
 string& str = tokens[i];
 // str为数字
 if (!("+" == str || "-" == str || "*" == str || "/" == str))
 {
 s.push(atoi(str.c_str()));
 }
 else
 {
 // str为操作符
 int right = s.top();
s.pop();
 int left = s.top();
 s.pop();
 switch (str[0])
 {
 case '+':
 s.push(left + right);
 break;
 case '-':
 s.push(left - right);
 break;
 case '*':
 s.push(left * right);
 break;
 case '/':
 // 题目说明了不存在除数为0的情况
 s.push(left / right);
 break;
 }
 }
 }
 return s.top();
 }
};
1.2.2.4**两个栈实现队列**

......

1.3stack的模拟实现

从栈的接口中可以看出,栈实际是一种特殊的vector,因此使用vector完全可以模拟实现stack。

cpp 复制代码
#include<vector>
namespace bite
{
    template<class T>
    class stack
    {
    public:
        stack() {}
        void push(const T& x) {_c.push_back(x);}
        void pop() {_c.pop_back();}
        T& top() {return _c.back();}
        const T& top()const {return _c.back();}
        size_t size()const {return _c.size();}
        bool empty()const {return _c.empty();}
    private:
        std::vector<T> _c;
    };
}

适配器的作用就是用于转换的,以电力系统为例,在外传输需要转换到特高压降低损耗,接入家庭需要转换220V,各种功率电器使用需要使用电源适配器转换成对应的电压。


同理,为了满足不同场合的不同需求,栈的实现也要提高兼容性。

用vector(list)来适配,只提供尾插尾删,封装一下,就成了一个顺序栈(链栈)。

但是这样用list<T> _lt的方式,就写死了,没法灵活适配。

所以它在这增加一个模版参数------容器Container,用来提供合适的容器,适配出一个栈。

模版参数平时没有具体含义的时候,就叫T(Type),这里有具体含义,就可以使用它的含义。

这样构造就不用写了,直接使用自定义类型的默认构造。

栈的设计打开了一种新的设计模式:

  • 之前设计一个数据结构,就要自己一笔一画去写,一点一点去搓。
  • 而现在只需要对其他合适的数据结构封装一下,转换一下就完成了我需要的数据结构,而且依靠模版参数的泛型编程,灵活适配不同容器来作底层架构,从而适配不同的应用场景。

代码测试。

在表层,使用的stack都是一个后进先出的特性的容器。

但是底层数据结构却很不一样。


库里面的stack的使用,可以不用传第2个参数,stack<int> st就可以了,所以我们也可以给到一个缺省参数。

  • 模版参数和函数参数很类似,都是实参传递给形参。
  • 模版参数是<>尖括号,函数参数是圆括号()。
  • 模版参数传的是类型,函数参数传的是对象。
  • 模版参数和函数参数一样,都可以给缺省值,只是这个地方给的缺省值是一个类型。

队列也是类似的,也可以这样实现。

  • vector支持尾插尾删,不支持头插头删(虽然有insert、erase但慎用)
  • list支持尾插尾删头插头删,但是不支持[ ]访问。

deque支持头插头删尾插尾删,和[ ]访问。

2.queue的介绍和使用

2.1queue的介绍

queue的文档介绍

【说明】

  1. 队列是一种容器适配器,专门用于在FIFO上下文(先进先出)中操作,其中从容器一端插入元素,另一端提取元素。

  2. 队列作为容器适配器实现,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从队尾入队列,从队头出队列。

  3. 底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操作:

  • empty:检测队列是否为空
  • size:返回队列中有效元素的个数
  • front:返回队头元素的引用
  • back:返回队尾元素的引用
  • push_back:在队列尾部入队列
  • pop_front:在队列头部出队列
  1. 标准容器类deque和list满足了这些要求。默认情况下,如果没有为queue实例化指定容器类,则使用标准容器deque。

2.2queue的使用

2.2.1接口说明

|-----------------------------------------------------------------------------|--------------------------------|
| 函数声明 | 接口说明 |
| queue() | 构造空的队列 |
| empty() | 检测队列是否为空,是返回true,否则返回false |
| size() | 返回队列中有效元素的个数 |
| front() | 返回队头元素的引用 |
| back() | 返回队尾元素的引用 |
| push() | 在队尾将元素val入队列 |
| pop() | 将队头元素出队列 |

2.2.2OJ练习

2.2.2.1**队列实现栈**

......

2.2.2.2**二叉树的层序遍历**

思路:先进先出,上一层的父结点出去的时候,带入下一层它的子结点。

例如:先入3,3出的时候带入9、20,9出的时候带入9的子树(NULL),20出的时候带入15、7


这道题是进阶版:要求每一层都存到一个数组里面,总的存成一个数组,即二维数组。

这样子的话用C语言写起来就很麻烦了

动态返回二维数组,上次vector<vector<......>>已经说过这个问题了。

问题来了:什么时候一行就走完了呢???

先来实现层序遍历入队列。

问题:怎么一层一层地取。怎么知道一个数据是那一层的呢???


思路1:再搞一个队列存层数------队列内每一个元素,都在另一个队列中对应的位置存放着它对应的层数。

常规队列存完3,再搞一个队列存它的层数1。

3出去的时候,1也就跟着出。同时带入下一层的数据,同时跟着入层数2。

元素9和层数2出去,就带入元素15和层数3。

评价:这种思路可行,但是这种思路在操控上可能略复杂。因为每一层的数据你知道它是第几层以后,你还要把它放到vector里面去,并且你是第1层的要放到第0行的vector。还得把vector提前resize好,不然的话,放数据的时候,用插入数据就会频繁扩容。
思路2:直接在这个地方用一个levelsize(每一层的数据个数)变量控制,用while循环控制一层一层出。

第一层一个数据,levelsize为1,用一个while循环控制,出完就减到0。

2.3queue的模拟实现

因为queue的接口中存在头删和尾插,因此使用vector来封装效率太低,故可以借助list来模拟实现queue,具体如下:

cpp 复制代码
#include <list>
namespace bite
{
    template<class T>
    class queue
    {
    public:
        queue() {}
        void push(const T& x) {_c.push_back(x);}
        void pop() {_c.pop_front();}
        T& back() {return _c.back();}
        const T& back()const {return _c.back();}
        T& front() {return _c.front();}
        const T& front()const {return _c.front();}
        size_t size()const {return _c.size();}
        bool empty()const {return _c.empty();}
    private:
        std::list<T> _c;
    };
}

queue的适配容器可以选list、deque,默认是deque。

使用vector来适配可能会报错------先进先出,需要头删,vector不(直接)支持头删------效率低。

2.4测试

注意queue提供的接口是front,而不是top。

而vector没有支持pop_front,用vector来适配会报错------避免使用低效的方式来适配队列。

3. 容器适配器

3.1什么是适配器

适配器:适配器是一种设计模式。


什么是设计模式?


以兵法为例,第一个比较著名的兵法就是《孙子兵法》。

最开始打仗就是拼谁的人多,后面发现打仗也可以有一定的套路,不是说人多一定能赢。

设计模式类似于兵法,最开始大家写代码的时候都是随意写,后面语言趋于成熟后,就形成了一定特定的套路------编程模式。

后面大家在写程序的时候,除了要把功能实现,还讲究一个代码的可维护性

日常的代码写好了之后,后面就不会再去管了,工作中的代码要持续地去维护,修复用户深度使用过程中出现的各种各样的bug、优化效率、开发新的需求、......等等。

这个时候如果代码的可维护性不高,就会导致后续在维护的时候,按下葫芦浮起瓢,各种问题冒出来,后面就出现了软件工程这门学科,提出了低耦合、高内聚、设计模式等等这样的概念。

现在已经学过的有:

  • 迭代器模式**;**
  • 适配器模式

while的条件是不等于,而不是小于,因为数组、字符串可以说前面的元素的指针小于最后的指针,但链表没有这样的规律。

  • 第一层封装:想给你访问的设置成公有,不想给你访问的设置成私有,把数据和方法都封装到类里面。
  • 第二层封装:不管底层结构的差异巨大,在里面封装一个统一的访问方式(各种容器的自己的实现可能是原生指针,可能是自定义类型)------typedef ...... iterator,在外层提供统一的访问接口------begin()、end()、*(原生指针本来就可以,自定义类型需要重载运算符)。

C语言也可以实现迭代器,只是++不支持运算符重载------*重载,但是支持typedef++。

故而实现起来没那么方便,可读性也不是很高。

C语言是面向过程的语言,*重载是面向对象的思维,面向过程的话可以使用函数的方式:ListIterEqual(it,end())、getData()、next())。

面向对象支持运算符重载,可以把迭代器封装得更类似于指针,用法上几乎和指针一致。

C语言也可以模拟出成员函数的概念------用函数指针的方式。

结构体可以用函数指针模拟实现成员函数,函数指针可以直接加上()填入参数就调用的。

上面的C语言迭代器就可以用it.get()、it.next()。


迭代器模式,为各种容器提供了统一的访问模式。

像Python这样的其他语言,可能没有迭代器的概念,只有范围for,其底层还是迭代器,在迭代器上又加了一层封装。


  • 迭代器模式的核心思想:封装以后提供统一的访问方式。
  • (所有容器的访问都可以使用这种方式)
  • 适配器模式的核心思想:封装转换------一个容器的底层结构是什么,不是写死的,是由第二个模版参数决定的,但其表现出来的功能是不变的。

适配器模式:用现成的容器来封装出一个新的具有特定功能的容器,这个具有特定功能的容器可以用不同的基础容器来适配,从而产生在基础功能之外的特点。

用不同的容器适配出来的栈,底层完全不同,而表现的功能类似------后进先出。


设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结,该种模式是将一个类的接口转换成客户希望的另外一个接口。

3.2STL标准库中stackqueue的底层结构

虽然stack和queue中也可以存放元素,但在STL中并没有将其划分在容器的行列。

而是将其称为容器适配器,这是因为stack和队列只是对其他容器的接口进行了包装,STL中stack和queue默认使用deque,比如:

  • 容器适配器都没有提供迭代器**;**
  • 因为不支持使用迭代器访问;
  • 容器适配器对其元素的访问是有严格限制的;
  • 从哪里插入、从哪里弹出都是有严格限制的。

3.3 deque的简单介绍**(了解)**

  • deque在功能上是vector和list的结合体,所以很多适配器的默认容器都是deque。
    • vector没有头删(插);
    • list没有[]重载;
  • deque设计来替代vector和list,但是没有成功,数据结构的基础学习还是"顺序表"、"链表"。(类似于骡子:驴+马)

vector和list除了功能上的差异,它们在结构上也是两种极致的结构------极致的连续 VS 极致的离散

3.3.1deque的原理介绍

deque(Double-End Queue,双端队列)****:是一种++双开口++ 的++"连续"空间++的数据结构。

  • 双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为O(1)。
    • 与vector比较,头插效率高,不需要搬移元素;
    • 与list比较,空间利用率比较高。


  • deque在结构上类似于vector<vector>。

第一段buff的地址不是存储在中控数组的首元素,而是中间,这样头插元素(头插buff数组)之后可以把头插的buff数组地址放到之前第一段buff数组地址的前面。


deque的缺陷:

  • \]访问不够极致,比vector慢;

这就是说想:

  • 保持insert和erase的效率就需要牺牲[]的效率;(扩容)
  • 保持[]的效率就需要牺牲erase和insert的效率;(挪动)

STL库选择了"数据挪动"的方式,保持buff大小一致。

不过总的来说,[]的效率不如vector,erase/insert的效率不如list。


算法和数据都一样,不同的是容器的结构,可以看到效率上的差别还是比较大的。

可以看到deque的[]访问速度不够极致。

头尾插删的效率不错,因为不需要扩容,直接再开buff数组就可以了。

中控数组有可能会扩容------代价很低,只需要拷贝指针就可以了。


不是高频访问,而是偶尔访问,那么deque的[]也还行。

但是大量访问------比如排序(访问数据来比大小),deque相比于vector就很慢了。


【结论】

  • 经常在头尾插入删除,偶尔下标访问,就可以使用deque。
  • stack和queue使用deque作默认容器就比较合适。
    • deque相比于vector不需要扩容;
    • deque相比于list有更好的CPU高速缓存效率优势;
  • 主流的数据存储还是使用顺序表、链表。

deque并不是真正连续的空间,而是由一段段连续的小空间拼接而成的,实际deque类似于一个动态的二维****数组,其底层结构如下图所示:

**双端队列,底层是一段假象的连续空间,实际是分段连续的,为了维护其"整体连续"以及随机访问的假象,落****在了deque的迭代器身上,**因此deque的迭代器设计就比较复杂,如下图所示:


deque的成员变量主要就是两个迭代器,此外还有一个中控数组成员和size成员。


那deque是如何借助其迭代器维护其假想连续的结构呢?



cur:指向当前位置;

first:指向当前buff的第一个有效空间

last:指向当前buff的最后一个有效空间的下一个位置;

size:每个buff数组的统一大小。


start的cur:指向第一个有效数据

finish的cur:指向最后一个有效数据的下一个位置;


来看看start和finish的工作原理:

start的左边不一定有数据,finish的右边不一定有数据。


如果没有头插过,那么cur-first==0,偏移量直接就是n,直接让cur+=n。


如果头插过2次(buff大小10),那么cur-first==8:

  • 加数n小于2,为0或1,就在首buff让cur+=n。
  • 加数n大于2,则偏移量offset大于buff的大小,需要用"除+模"计算准确的位置。

3.3.2 deque的缺陷

【优势】

  • 与vector比较: deque在头部插入和删除时,不需要搬移元素,效率特别高 ,而且在扩容时,也不****需要搬移大量的元素,因此其效率是必vector高的。
  • 与list比较: 其底层是连续空间,空间利用率比较高,不需要存储额外字段。

但是,deque有一个致命缺陷:不适合遍历,因为在遍历时,deque的迭代器要频繁的去检测其是否移动到****某段小空间的边界,导致效率低下,而序列式场景中,可能需要经常遍历。

  • 因此在实际中,需要线性结构****时,大多数情况下优先考虑vector和list,deque的应用并不多。
  • 目前能看到的一个应用就是,STL用其作****为stack和queue的底层数据结构

3.4 为什么选择deque作为stack和queue的底层默认容器

stack是一种后进先出的特殊线性数据结构,因此只要具有push_back()和pop_back()操作的线性结构,都可以作为stack的底层容器,比如vector和list都可以;queue是先进先出的特殊线性数据结构,只要具有push_back和pop_front操作的线性结构,都可以作为queue的底层容器,比如list。但是STL中对stack和queue默认选择deque作为其底层容器,主要是因为:

  1. stack和queue不需要遍历(因此stack和queue没有迭代器),只需要在固定的一端或者两端进行操作。

  2. 在stack中元素增长时,deque比vector的效率高(扩容时不需要搬移大量数据);queue中的元素增长时,deque不仅效率高,而且内存使用率高。

结合了deque的优点,而完美的避开了其缺陷。

3.5STL标准库中对于stack和queue的模拟实现

3.5.1stack的模拟实现

cpp 复制代码
#include<deque>
namespace bite
{
template<class T, class Con = deque<T>>
 //template<class T, class Con = vector<T>>
 //template<class T, class Con = list<T>>
 class stack
 {
 public:
 stack() {}
 void push(const T& x) {_c.push_back(x);}
 void pop() {_c.pop_back();}
 T& top() {return _c.back();}
 const T& top()const {return _c.back();}
 size_t size()const {return _c.size();}
 bool empty()const {return _c.empty();}
 private:
 Con _c;
 };
}

3.5.2queue的模拟实现

cpp 复制代码
#include<deque>
#include <list>
namespace bite
{
 template<class T, class Con = deque<T>>
 //template<class T, class Con = list<T>>
 class queue
 {
 public:
 queue() {}
 void push(const T& x) {_c.push_back(x);}
 void pop() {_c.pop_front();}
 T& back() {return _c.back();}
 const T& back()const {return _c.back();}
 T& front() {return _c.front();}
 const T& front()const {return _c.front();}
 size_t size()const {return _c.size();}
 bool empty()const {return _c.empty();}
 private:
 Con _c;
 };
}

4.priority_queue的介绍和使用

4.1priority_queue的介绍

priority_queue文档介绍

**priority_queue:**有一个container参数,需要传一个容器去适配,所以优先级队列是一个容器适配器。

priority_queue默认使用vector进行适配。
【问】为什么stack和queue默认用deque去适配,而priority_queue默认用vector?

【答】因为priority_queue需要大量使用[]访问------底层是(二叉)堆。

  • 最大元素:大堆;
  • 最小元素:小堆;
  • STL库没有现成的堆,有堆算法;
  • 要使用堆就直接使用优先级队列------优先级队列就是堆;

【说明】

  1. 优先队列是一种容器适配器,根据严格的弱排序标准,它的第一个元素总是它所包含的元素中最大的。

  2. 此上下文类似于堆,在堆中可以随时插入元素,并且只能检索最大堆元素(优先队列中位于顶部的元素)。

  3. 优先队列被实现为容器适配器,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从特定容器的"尾部"弹出,其称为优先队列的顶部。

  4. 底层容器可以是任何标准容器类模板,也可以是其他特定设计的容器类。容器应该可以通过随机访问迭代器访问,并支持以下操作:

  • empty():检测容器是否为空
  • size():返回容器中有效元素个数
  • front():返回容器中第一个元素的引用
  • push_back():在容器尾部插入元素
  • pop_back():删除容器尾部元素
  1. 标准容器类vector和deque满足这些需求。默认情况下,如果没有为特定的priority_queue类实例化指定容器类,则使用vector。

  2. 需要支持随机访问迭代器,以便始终在内部保持堆结构。容器适配器通过在需要时自动调用算法函数make_heap、push_heap和pop_heap来自动完成此操作。

4.2 priority_queue的使用

  • 优先级队列默认使用vector作为其底层存储数据的容器;
  • 在vector上又使用了堆算法将vector中元素构造成堆的结构;
  • 因此priority_queue就是堆,所有需要用到堆的位置,都可以考虑使用priority_queue。
  • 堆的核心操作:取堆顶;插入堆顶;删除堆顶。
    注意:默认情况下priority_queue是大堆。

|------------------------------------------------------|----------------------------------------|
| 函数声明 | 接口说明 |
| priority_queue()/priority_queue(first, last) | 构造一个空的优先级队列 |
| empty() | 检测优先级队列是否为空,是返回true,否则返回 false |
| top() | 返回优先级队列中最大(最小元素),即堆顶元素 |
| push() | 在优先级队列中插入元素x |
| pop () | 删除优先级队列中最大(最小)元素,即堆顶元素 |

【注意1】默认大堆

  1. 默认情况下,priority_queue是大堆。

priority_queue支持迭代器区间初始化。

优点:直接建堆,不用挨着挨着push。


【注意】如果数据不在vector里面,而是在数组int arr [10]里面,也可以用迭代器构造,同时数组也可以用sort排序。

【注意】

  • 数组------连续的物理空间,可以直接传原生指针给迭代器参数。
  • 实现vector的迭代器就可以直接使用原生指针。

【注意】建小堆需要传仿函数。

【注意】要显式给第3个模版参数,就要先把前2个模版参数给出来。

cpp 复制代码
#include <vector>
#include <queue>
#include <functional> // greater算法的头文件
void TestPriorityQueue()
{
    // 默认情况下,创建的是大堆,其底层按照小于号比较
    vector<int> v{3,2,7,6,0,4,1,9,8,5};
    priority_queue<int> q1;
    for (auto& e : v)
        q1.push(e);
     cout << q1.top() << endl;

     // 如果要创建小堆,将第三个模板参数换成greater比较方式
     priority_queue<int, vector<int>, greater<int>> q2(v.begin(), v.end());
     cout << q2.top() << endl;
}


【注意2】存自定义类型Date

  1. 如果在priority_queue中放自定义类型的数据,用户需要在自定义类型中提供>或者<的重载。
cpp 复制代码
class Date
{
public:
    Date(int year = 1900, int month = 1, int day = 1)
        : _year(year)
        , _month(month)
        , _day(day)
    {}
 
    bool operator<(const Date& d)const
    {
         return (_year < d._year) ||
         (_year == d._year && _month < d._month) ||
         (_year == d._year && _month == d._month && _day < d._day);
     }
 
    bool operator>(const Date& d)const
    {
         return (_year > d._year) ||
         (_year == d._year && _month > d._month) ||
         (_year == d._year && _month == d._month && _day > d._day);
    }
 
    friend ostream& operator<<(ostream& _cout, const Date& d)
    {
        _cout << d._year << "-" << d._month << "-" << d._day;
        return _cout;
    }

private:
    int _year;
    int _month;
    int _day;
};

void TestPriorityQueue()
{
    // 大堆,需要用户在自定义类型中提供<的重载
    priority_queue<Date> q1;
    q1.push(Date(2018, 10, 29));
    q1.push(Date(2018, 10, 28));
    q1.push(Date(2018, 10, 30));
    cout << q1.top() << endl;

    // 如果要创建小堆,需要用户提供>的重载
    priority_queue<Date, vector<Date>, greater<Date>> q2;
    q2.push(Date(2018, 10, 29));
    q2.push(Date(2018, 10, 28));
    q2.push(Date(2018, 10, 30));
    cout << q2.top() << endl;
}

4.3 在OJ****中的使用

数组中第K个大的元素

cpp 复制代码
class Solution {
public:
 int findKthLargest(vector<int>& nums, int k) {
 // 将数组中的元素先放入优先级队列中
 priority_queue<int> p(nums.begin(), nums.end());
 
 // 将优先级队列中前k-1个元素删除掉
 for(int i= 0; i < k-1; ++i)
 {
 p.pop();
 }
 
 return p.top();
 }
};

4.4priority_queue的模拟实现

priority_queue的底层结构就是堆,因此此处只需对堆进行通用的封装即可。


(1)基本框架

(2)成员函数

① push插入

逻辑上是二叉树,物理上是数组。

【堆的插入】------"两步走"

  • 数据尾插
  • 数据向上调整
cpp 复制代码
//向上调整------需要支持下标访问(计算父节点)	
void adjust_up(int child)
{
	int parent = (child - 1) / 2;
		
    while (child > 0)
	{
		//用小于比较实现大堆------如果父亲小于孩子,就需要向上调整孩子
		if (_con[parent] < _con[child])
		{
			swap(_con[parent], _con[child]);

			//迭代------更新孩子坐标,重新计算孩子的父亲
			child = parent;
			parent = (child - 1) / 2;
		}
		//否则就结束循环
		else
			break;
	}
}

【注意】循环结束条件是child==0而不能是parent==0,因为当parent==0了还要走完下一轮比较。

② pop删除

【堆的删除】------"三步走"

  • 头尾互换
  • 删除最后一个元素
  • 向下调整
③ top、size、empty

构造函数可以不用写,优先级队列的简单实现就结束了。


(3)迭代区间初始化

cpp 复制代码
//迭代区间初始化
template <class InputIterator>
priority_queue(InputIterator first, InputIterator last)
{
	//全部插入------数据插入完,还不是堆
	while (first != last)
	{
		_con.push_back(*first);	//调用优先级队列的push_back,相当于向上调整建堆------效率略低于向下调整建堆
		++first;
	}

	//向下调整建堆------从最后一个父节点(非叶子结点)开始
	for (int i = (_con.size() - 1 - 1) / 2; i >= 0; i--)	//最后一个结点size-1的父节点就用"先减一后除二"的公式,就是(size-1-1)/2
	{
		adjust_down(i);
	}
}

(4)测试

(5)仿函数

【之前建小堆的思路】改变adjust_down/up的if括号内比较表达式的<或>符号。

C语言通过传递函数指针来解决这个问题,C++不喜欢使用函数指针(太复杂)。

C++给出了一个新概念------仿函数(函数对象)

  • 仿函数(函数对象):重载了operator()的类,用类实例化的对象可以像函数一样使用。
  • operator():参数个数和返回值根据需求确定,不固定,很灵活。

仿函数可以考虑实现成struct------默认是公有。

仿函数的作用:传值给这个对象,可以完成预定任务。

注意类型匹配:

仿函数建议实现成类模版。

operator()的特点

  • 参数个数、返回值------根据需求确定,不固定。
  • 相比于之前学过的一些运算符重载函数而言,更加灵活。
  • 这个特点决定了它的功能非常多样化。

(6)增加仿函数作为模版参数

sort排升序、降序的仿函数设计也是这样子搞的:

用这个模版参数(仿函数的类)实例化出对象,用这个对象实现比较大小。

(7)测试

【模版的思想】**用你传递的具体的模版参数类型,实例化出特定的东西。
【大堆or小堆】通过仿函数控制,仿函数控制堆里面的比较逻辑。
【仿函数vs函数指针】仿函数就是
类+运算符重载**,相比于函数指针是更简单的。


在优先级队列(堆)中存放自定义类型:


改为存储日期类的指针:

结果并不是按日期类的升序 ,因为是按日期类的指针的升序

最终打印结果每次运行都不一样,有30-28-29、28-29-30、28-30-29、29-30-28、29-28-30......。

new(malloc)是带有随机性的,并不是后new的空间的地址一定大。


此时就可以写一个仿函数------自定义建堆方式:按++插入数据的解引用的值++比较建堆。

除了控制简单的大于小于比较逻辑,我们还可以控制仿函数实现更复杂的比较逻辑,来建造特定的堆。

仿函数在这里就充当了一种回调函数的作用。

相关推荐
奥特曼_ it3 小时前
【数据分析+机器学习】基于机器学习的招聘数据分析可视化预测推荐系统(完整系统源码+数据库+开发笔记+详细部署教程)✅
笔记·数据挖掘·数据分析
雨季6664 小时前
构建 OpenHarmony 简易文字行数统计器:用字符串分割实现纯文本结构感知
开发语言·前端·javascript·flutter·ui·dart
雨季6664 小时前
Flutter 三端应用实战:OpenHarmony 简易倒序文本查看器开发指南
开发语言·javascript·flutter·ui
进击的小头4 小时前
行为型模式:策略模式的C语言实战指南
c语言·开发语言·策略模式
Aevget4 小时前
MFC扩展库BCGControlBar Pro v37.2新版亮点:控件功能进一步升级
c++·mfc·界面控件
天马37984 小时前
Canvas 倾斜矩形绘制波浪效果
开发语言·前端·javascript
四维碎片4 小时前
QSettings + INI 笔记
笔记·qt·算法
Tansmjs4 小时前
C++与GPU计算(CUDA)
开发语言·c++·算法
qx094 小时前
esm模块与commonjs模块相互调用的方法
开发语言·前端·javascript
Suchadar5 小时前
if判断语句——Python
开发语言·python