【C++】一篇吃透容器适配器三件套:从stack/queue/priority_queue到deque底层

很多人第一次看到 priority_queue<int, vector<int>, greater<int>> 这种类型声明时,脑子里只剩下一个问号:这东西到底包了几层?再加上 deque 这个"伪连续空间"一起登场,更是容易看了就忘。这篇文章会一步步把接口、例题、模拟实现和底层结构串在一起,让你下次再写这行代码时,不只是能编译通过,而是真正知道自己在用什么。

目录

[1. stack的介绍和使用](#1. stack的介绍和使用)

[1.1 stack的介绍](#1.1 stack的介绍)

[1.2 stack的使用](#1.2 stack的使用)

[1.2.1 最小栈(MinStack)](#1.2.1 最小栈(MinStack))

[1.2.2 栈的弹出压入序列](#1.2.2 栈的弹出压入序列)

[1.2.3 逆波兰表达式求值](#1.2.3 逆波兰表达式求值)

[1.3 stack的模拟实现](#1.3 stack的模拟实现)

[2. queue的介绍和使用](#2. queue的介绍和使用)

[2.1 queue的介绍](#2.1 queue的介绍)

[2.2 queue的使用](#2.2 queue的使用)

[2.3 queue的模拟实现](#2.3 queue的模拟实现)

[3. priority_queue的介绍和使用](#3. priority_queue的介绍和使用)

[3.1 priority_queue的介绍](#3.1 priority_queue的介绍)

[3.2 priority_queue的使用](#3.2 priority_queue的使用)

[3.2.1 基本类型:大堆和小堆](#3.2.1 基本类型:大堆和小堆)

[3.2.2 自定义类型](#3.2.2 自定义类型)

[3.3 在OJ中的使用](#3.3 在OJ中的使用)

[3.3.1 数组中第K个大的元素。](#3.3.1 数组中第K个大的元素。)

[3.4 priority_queue的模拟实现](#3.4 priority_queue的模拟实现)

[4. 容器适配器](#4. 容器适配器)

[4.1 什么是适配器](#4.1 什么是适配器)

[4.2 STL标准库中stack和queue的底层结构](#4.2 STL标准库中stack和queue的底层结构)

[4.3 deque的介绍](#4.3 deque的介绍)

[4.3.1 deque的原理](#4.3.1 deque的原理)

[4.3.2 deque的缺陷](#4.3.2 deque的缺陷)

[4.4 为什么选择deque作为stack和queue的底层默认容器](#4.4 为什么选择deque作为stack和queue的底层默认容器)

[4.5 STL标准库中对于stack和queue的模拟实现](#4.5 STL标准库中对于stack和queue的模拟实现)

[4.5.1 stack的模拟实现](#4.5.1 stack的模拟实现)

[4.5.2 queue的模拟实现](#4.5.2 queue的模拟实现)


1. stack的介绍和使用

1.1 stack的介绍

函数说明 接口说明
stack() 构造一个空栈
empty() 判断栈是否为空
size() 返回栈中元素个数
top() 返回栈顶元素的引用
push() 把元素val压入栈顶
pop() 把栈顶元素弹出(移除,不返回值)

std::stack是一个后进先出(LIFO)的容器适配器

  • 只允许在一端(栈顶)进行插入和删除;

  • 不支持遍历(没有迭代器),只有push/pop/top这几个固定动作;

  • 底层可以用vectorlistdeque等线性结构来实现。


1.2 stack的使用

例题:

  1. 最小栈;

  2. 判断一个出栈序列是否可能;

  3. 逆波兰表达式求值。


1.2.1 最小栈(MinStack)

题目大意:

实现一个栈,除了正常的push/pop/top以外,还要在O(1) 时间拿到当前栈内的最小值getMin()

思路:

  • 用一个正常栈_elem保存所有元素;

  • 再用一个辅助栈_min保存"到当前位置为止的最小值链":

    • 每次压入新元素x时:

      • _elem.push(x)

      • _min为空或x <= _min.top(),就_min.push(x)

    • 每次弹出时:

      • 如果_elem.top()等于_min.top(),辅助栈也要一起pop
  • 这样_min.top()始终就是当前栈内最小值。

代码示例:

cpp 复制代码
#include <stack>

class MinStack
{
public:
    void push(int x)
    {
        //只要压栈,先入_elem
        _elem.push(x);

        //如果_min为空或者x<=当前最小值,同步压入_min
        if (_min.empty() || x <= _min.top())
        {
            _min.push(x);
        }
    }

    void pop()
    {
        //如果当前出栈的元素正好等于当前最小值,辅助栈也要弹出
        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;
};

1.2.2 栈的弹出压入序列

题目大意:

给定一个入栈序列pushV和一个出栈序列popV,判断popV是否可能是pushV在某种合法入栈/出栈顺序下产生的弹出序列。

思路:

  • 准备一个辅助栈s,用它模拟真实入栈出栈过程

  • 维护两个下标:

    • inIdx:当前入栈序列pushV用到了哪里;

    • outIdx:当前要匹配的出栈序列popV位置;

  • 对每个popV[outIdx]

    1. 如果栈为空或栈顶不等于popV[outIdx],就从pushV中继续push入栈;

      • 如果入栈序列已经用完还对不上,直接返回false
    2. 一旦栈顶等于popV[outIdx],就pop一次,outIdx++

  • 最后如果能顺利匹配完整个popV,说明出栈序列合法。

代码示例:

cpp 复制代码
#include <vector>
#include <stack>
using namespace std;

class Solution
{
public:
    bool IsPopOrder(vector<int> pushV, vector<int> popV)
    {
        //入栈和出栈的元素个数必须相同
        if (pushV.size() != popV.size())
        {
            return false;
        }

        int outIdx = 0;
        int inIdx = 0;
        stack<int> s;

        while (outIdx < (int)popV.size())
        {
            //如果栈空或者栈顶!=当前要弹出的值,就继续入栈
            while (s.empty() || s.top() != popV[outIdx])
            {
                if (inIdx < (int)pushV.size())
                {
                    s.push(pushV[inIdx++]);
                }
                else
                {
                    //入栈序列已经用完还对不上,说明不可能
                    return false;
                }
            }

            //栈顶和当前要弹出的值相等,弹出
            s.pop();
            ++outIdx;
        }

        return true;
    }
};

1.2.3 逆波兰表达式求值

逆波兰表达式(RPN,Reverse Polish Notation)是一种不需要括号的表达式形式,例如:

  • 中缀表达式:(2 + 1) * 3

  • 后缀表达式:["2", "1", "+", "3", "*"]

求值规则:

  1. 从左到右扫描令牌;

  2. 如果遇到数字,就压栈;

  3. 如果遇到操作符:

    • 从栈中弹出两个数rightleft

    • 按操作符计算left op right

    • 把结果再压回栈;

  4. 扫描结束后,栈顶就是最终结果。

代码示例:

cpp 复制代码
#include <vector>
#include <stack>
#include <string>
#include <cstdlib>
using namespace std;

class Solution
{
public:
    int evalRPN(vector<string>& tokens)
    {
        stack<int> s;

        for (size_t i = 0; i < tokens.size(); ++i)
        {
            string& str = tokens[i];

            //如果是数字
            if (!("+" == str || "-" == str || "*" == str || "/" == str))
            {
                s.push(atoi(str.c_str()));
            }
            else
            {
                //如果是操作符
                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.3 stack的模拟实现

观察stack的接口可以发现:它只需要在一端尾插/尾删 ,并能访问栈顶即可。因此任何支持push_back/pop_back/back的容器 ,都可以拿来当stack的底层,比如vector

下面是一个用std::vector封装的简化版stack

代码示例:

cpp 复制代码
#include <vector>

namespace my
{
    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;
    };
}

2. queue的介绍和使用

2.1 queue的介绍

函数说明 接口说明
queue() 构造空队列
empty() 检测队列是否为空
size() 返回队列中有效元素个数
front() 返回队头元素的引用
back() 返回队尾元素的引用
push() 在队尾插入元素val(入队)
pop() 删除队头元素(出队,不返回值)

std::queue是一个先进先出(FIFO)的容器适配器:从队尾入,从队头出。

概念:

  1. 队列是一种容器适配器,用于在**先进先出(FIFO)**场景中操作元素:一端插入,另一端提取;

  2. 自己不直接存元素,而是把某个底层容器包起来,对外暴露push/pop/front/back等接口;

  3. 底层容器需要支持以下操作:

    • empty():检测是否为空;

    • size():返回有效元素个数;

    • front():访问队头;

    • back():访问队尾;

    • push_back():尾部插入;

    • pop_front():头部删除;

  4. 标准容器dequelist都满足这些要求;

  5. 默认底层容器是deque ,如果不指定,queue<int>就等价于queue<int, deque<int>>


2.2 queue的使用

代码示例:

cpp 复制代码
#include <iostream>
#include <queue>
using namespace std;

void TestQueueUse()
{
    queue<int> q;

    q.push(1);
    q.push(2);
    q.push(3);

    cout << "front:" << q.front() << endl;
    cout << "back:" << q.back() << endl;

    while (!q.empty())
    {
        cout << q.front() << " ";
        q.pop();
    }
    cout << endl;
}

2.3 queue的模拟实现

队列的接口需要头删+尾插 ,如果用vector来封装,每次pop()都要把前面的元素搬一遍,效率很低;用listdeque就比较合适。

下面是一个用std::list做底层的简化queue

代码示例:

cpp 复制代码
#include <list>

namespace my
{
    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;
    };
}

3. priority_queue的介绍和使用

3.1 priority_queue的介绍

priority_queue也是一个容器适配器(堆):

  • 它保证top()永远是内部元素里"最大(或最小)"的那个;

  • 插入元素可以随时做,但取元素只能从top()取;

  • 底层会用make_heap/push_heap/pop_heap等算法维护堆结构。

函数声明 接口说明
priority_queue() / (first,last) 构造一个空的优先级队列或用区间元素构造一个优先级队列
empty() 判断是否为空
size() 返回有效元素个数
top() 返回队列中"优先级最高"的元素引用(默认是最大值)
push(x) 插入元素x
pop() 删除"优先级最高"的元素(堆顶元素)

总结:

  1. priority_queue保证第一个元素始终是最大元素(默认);

  2. 底层容器可以是任意支持随机访问迭代器 的容器(如vector/deque);

  3. 默认底层容器是vector

  4. 优先级队列在内部会自动调用make_heap/push_heap/pop_heap维护堆;

  5. 默认比较方式是less<T>,所以默认是大根堆(最大值优先)。


3.2 priority_queue的使用

3.2.1 基本类型:大堆和小堆

代码示例:

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include <functional>
using namespace std;

void TestPriorityQueueInt()
{
    vector<int> v{ 3, 2, 7, 6, 0, 4, 1, 9, 8, 5 };

    //默认是大堆,底层用less比较
    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;   //最小元素
}
  • 第一种写法:priority_queue<int>,默认大根堆;

  • 第二种写法:priority_queue<int, vector<int>, greater<int>>,改成小根堆。


3.2.2 自定义类型

对于自定义类型,需要提供比较规则,默认使用less<T>,即需要operator<。如果要用greater<T>,则需要operator>

代码示例:

cpp 复制代码
#include <iostream>
#include <queue>
#include <vector>
#include <functional>
using namespace std;

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 TestPriorityQueueDate()
{
    //大堆,需要提供<的重载
    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;
}

3.3 在OJ中的使用

3.3.1 数组中第K个大的元素

思路:

  • 把所有元素丢进一个大根堆

  • 然后弹出前k-1个最大值;

  • 此时堆顶就是第k大的元素。

代码示例:

cpp 复制代码
#include <vector>
#include <queue>
using namespace std;

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();
    }
};

3.4 priority_queue的模拟实现

从结构上看,优先级队列底层就是一个堆 + 底层容器 + 比较器的组合:

  • 容器负责存放元素;

  • 堆算法负责维护堆序性;

  • 比较器负责决定谁的优先级更高。


4. 容器适配器

4.1 什么是适配器

"适配器"是一种设计模式:把一个类的接口转换成客户希望的另一种接口。这样原来的类可以继续复用,只是对外长得不一样了。

STL里:

  • stack/queue/priority_queue都不直接管理底层数据结构;

  • 它们只是把vector/list/deque这些容器"包装一下",对外提供一个更符合使用场景的接口:

    • stack对外:push/pop/top,隐藏遍历;

    • queue对外:push/pop/front/back

    • priority_queue对外:push/pop/top,但内部是堆结构。


4.2 STL标准库中stack和queue的底层结构

虽然stack/queue也可以存元素,但在STL的分类里,它们不叫"容器",而叫"容器适配器"

原因就是:它们只是对其他容器的接口进行包装,把某些操作"隐藏掉",只保留特定场景需要的那些。

标准库里的声明:

cpp 复制代码
//stack
template<class T, class Container = std::deque<T>>
class stack;

//queue
template<class T, class Container = std::deque<T>>
class queue;

总结:

  • stack<int>其实是stack<int, deque<int>>

  • queue<int>其实是queue<int, deque<int>>

  • 它们默认都是基于deque实现的;

  • 可以改成vectorlist,只要满足接口要求即可。


4.3 deque的介绍

4.3.1 deque的原理

deque(double-ended queue,双端队列)是一种双开口的伪连续空间数据结构

  • 支持在头尾两端进行插入和删除,时间复杂度O(1);

  • vector相比:头插效率高,不需要整体搬移元素;

  • list相比:空间利用率高,因为底层使用的是"分段连续空间"。

底层不是一整段连续内存,而是类似"动态二维数组":

  • 上层有一个map(控制数组),存放指向"缓冲区(buffer)"的指针;

  • 每个缓冲区是一小段连续空间;

  • 整个deque就是多个缓冲区拼接起来形成的"逻辑连续空间"。

迭代器为了维持伪连续假象,会记录:

  • 当前所在缓冲区指针node

  • 缓冲区的first/last边界;

  • 当前元素位置cur


4.3.2 deque的缺陷

优点:

  • 与vector比较:头部插入/删除不需要搬全体元素,扩容也不需要整体搬移;

  • 与list比较:整体上仍然是"分段连续",空间利用率更高,不用额外存prev/next指针。

致命缺陷:

不适合频繁遍历。

原因在于:

  • 迭代器每次++都要检查是否越过当前缓冲区边界;

  • 一旦跨界,就要切换到下一块缓冲区;

  • 这套检查逻辑会让遍历比vector慢不少。

在很多实际场景中,线性结构往往需要频繁遍历,因此:

  • 需要高效遍历+随机访问时,优先用vector

  • 需要频繁插入删除时,优先考虑list

  • deque使用场景相对少一些,比较典型的用途就是作为stackqueue的底层容器


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

  1. 不需要遍历

    • stackqueue本身就没有迭代器,对外只提供固定几个接口;

    • 它们的使用场景只在栈顶/队头/队尾操作,不会整段遍历;

    • 因此deque遍历慢的缺点在这里完全不怕。

  2. 扩容和两端操作更高效

    • stack只需要在一端不断push_back/pop_back

    • queue既要push_back又要pop_front

    • 对于这两类操作,dequevector更省成本,比list更节省空间;

    • 尤其是元素大量增长时,deque扩容不需要整体搬移既有数据。

stack/queue默认使用deque,刚好利用了deque的优点,又完美避开了"遍历慢"的缺点。


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

4.5.1 stack的模拟实现

代码示例:

cpp 复制代码
#include <deque>

namespace my
{
    template<class T, class Con = std::deque<T>>
    //template<class T, class Con = std::vector<T>>
    //template<class T, class Con = std::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;
    };
}

思路:

  • 只要Con提供push_back/pop_back/back/size/empty这些接口,就能拿来当stack的底层;

  • 默认用deque,也可以把模板注释放开换成vectorlist来实验。


4.5.2 queue的模拟实现

代码示例:

cpp 复制代码
#include <deque>
//#include <list>

namespace my
{
    template<class T, class Con = std::deque<T>>
    //template<class T, class Con = std::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;
    };
}

相关推荐
梓䈑2 小时前
【C++】C++11(右值引用和移动语义、可变参数模板 和 包装器)
java·开发语言·c++
测试人社区—小叶子2 小时前
Rust会取代C++吗?系统编程语言的新较量
运维·开发语言·网络·c++·人工智能·测试工具·rust
进击的荆棘2 小时前
C++起始之路——类和对象(中)
开发语言·c++
oioihoii2 小时前
现代C++系统编程中类型重解释的内存安全范式
java·c++·安全
dvlinker2 小时前
如何让C++程序生成dump文件?生成dump文件的方式有哪些?如何使用Windbg分析dump文件?
c++·dump文件·windbg命令·异常处理回调·writedump·windbg版本·windbg分析
小画家~2 小时前
第三十七:类型断言
开发语言·c++·算法·golang
Hard but lovely2 小时前
C++ 11--》初始化
开发语言·c++
昇腾CANN3 小时前
自定义算子开发系列:TilingKey模板化编程介绍
c++·mfc
oioihoii3 小时前
在MFC桌面应用中嵌入现代浏览器内核:原理、选型与实践全解析
c++·mfc