STL 容器适配器详解:stack 与 queue

一. stack 和 queue 是什么

1. 基本概念

stack:只允许后进后出 (LIFO) 的数据结构,类似于一个瓶子,我们只能从瓶口存储或者拿取

queue:只允许先进先出 (FIFO) 的数据结构,类似于现实中的排队场景。新元素从队尾加入,操作只能从队头移除,不支持在中间位置插入元素

2. 为什么 stack/queue 不能遍历

我们第一次看到 stack 和 queue 的时候都会产生疑问:为什么 vector 和 list 等容器都可以遍历,而 stack/queue 不行?

本质原因:它们是 <容器适配器> 而非 <顺序容器>

stack 和 queue 并不像 vector / list 那样直接存储数据,它们只是套在其他容器外面的一层接口包装。目的是限制用户访问方式强制遵守 LIFO / FIFO 规则。因此它们故意不暴露 begin() / end()  接口,目的就是屏蔽内部结构,防止绕过规则访问数据

在 stl 中,stack/queue 通常是这样定义的:

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

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

可以看到,stack/queue 其实就是对内部的容器 (通常是 deque) 的一层封装

stack 和 queue 不是“不能遍历”,而是“故意不给你遍历”
它们就是希望你乖乖用 push/pop/front/back 这样的接口来操作数据,而不是跳过规则随意访问底层容器

二. 容器适配器

在上一小节我们提到 stack/queue 属于容器适配器。这一节我们就把适配器说清楚:它是什么、为什么需要它、以及它如何把现有容器包装成新的接口

1. 什么是容器适配器

容器适配器是一种设计模式,通过包装现有的容器类来提供特定的接口。核心思想是通过复用底层容器来限制或调整对外接口,实现特定的数据访问语义。

可以看到stack/queue/priority_queue都通过封装不同的底层容器来实现,使得这些容器可以无需关心数据存储细节,直接委托给底层适配容器即可,并且通过隐藏其他接口(如随机访问迭代器)来实现栈和队列特有的的访问语义

2. 模拟实现

stack:

template<class T, class Container = deque<T>>
class stack
{
public:
    void push(const T& val) { _con.push_back(val); }
    void pop() { _con.pop_back(); }
    T& top() const { return _con.back(); }
    bool empty() const { return _con.empty(); }
    size_t size() const { return _con.size(); }
private:
    Container _con;
}

queue:

template<class T, class Container = deque<T>>
class queue
{
public:
    void push(const T& val) { _con.push_back(val); }
    void pop() { _con.pop_front(); }
    T& front() const { return _con.front(); }
    T& back() const { return _con.back(); }
    bool empty() const { return _con.empty(); }
    size_t size() const { return _con.size(); }
private:
    Container _con;
}

3. priority_queue的模拟实现

priority_queue(优先级队列)不像stack/queue一样先进先出或先进后出,而是谁最大谁先出的设计思想,基于堆结构实现优先级调度(默认是大根堆)

priority_queue 并不直接存储数据,而是使用一个底层容器(默认 vector)+ 比较器(默认 less<T>),通过构造堆来维持元素的优先级顺序。

由于真正体现 priority_queue 特性的只有push()/pop(),因为要对堆做向上/向下调整,所以我们模拟实现时只需要聚焦在这两个核心操作即可

template<class T>
class Less
{
public:
    bool operator()(const T& x, const T& y)
    {
        return x < y;
    }
}

template<class T, class Container = vector<T>, class Compare = Less<T>>
class priority_queue
{
public:
    void push(const T& val);

    void pop();

    // 其他接口简单处理即可
    const T& top() const { return _con[0]; }
    bool empty()  const { return _con.empty(); }
    size_t size() const { return _con.size(); }
private:
    Container _con;
}

仿函数

在真正写 priority_queue 的 push/pop 之前,有个关键点必须说明清楚:它是怎么决定谁优先的?
在官方 STL 中,priority_queue 默认用的是这个定义:

template<class T, class Container = vector<T>, class Compare = less<T>>
class priority_queue;

这里的 compare 默认就是 less<T> 也就是常见的大顶堆排序规则。
很多人第一次看到 less<T> 会好奇:它到底是什么?它不是关键字,也不是宏,而是一个仿函数(函数对象)通过重载运算符 " () " 使他的对象可以像是函数一样被调用,所以叫做仿函数

template<class T>
struct less
{
    bool operator()(const T& x, const T& y) { return x < y; }
};

他做的只有一件事,就是比较 x,y 的大小

如果我们需要小根堆的话可以像这样定义一个 greater<T>:

template<class T>
struct Greater
{
    bool operator()(const T& x, const T& y) { return x > y; }
}

push/pop

在实现 push/pop 之前,先认识两个核心算法

priority_queue 本质是一个堆结构,而堆能维持谁优先的顺序,完全依赖两个操作:

  • 向上调整(push 时用)

插入新元素后,它先被放到容器末尾,再不断和父节点比较,如果更优,就不断往上交换最终找到正确位置

  • 向下调整(pop 时用)

删除堆顶后,用最后一个元素补上去,再和左右子节点比较,如果不够优,就往下换到正确位置

我们只需要记住一句话:push本质是尾删+向上调整,pop本质是替换+向下调整

void adjust_up(size_t child)
{
    Compare com; // 定义仿函数对象
    size_t parent = (child - 1) / 2;
    while(child > 0)
    {
        if(com(_con[parent], _con[child]))
        {
            std::swap(_con[parent], _con[child]);
            child = parent;
            parent = (child - 1) / 2;
        }
        else
        {
            break;
        }
    }
}

void push(const T& val)
{
    _con.push_back(val);
    adjust_up(_con.size() - 1); // 向上调整
}
void adjust_down(size_t parent)
{
    Compare com; // 定义仿函数对象
    size_t child = (parent * 2) + 1;
    while(child < _con.size())
    {
        if(child + 1 < _con.size() && com(_con[child], _con[child + 1]))
            ++child;

        if(com(_con[parent], _con[child]))
        {
            std::swap(_con[parent], _con[child]);
            parent = child;
            child = (parent * 2) + 1;
        }
        else
        {
            break;
        }
    }
}

void pop()
{
    std::swap(_con[0], _con[_con.size() - 1]);
    _con.pop_back();
    adjust_down(0); // 向下调整
}

三,栈和队列的实际应用OJ题

前面那些底层原理、模拟实现什么的,该讲的我们都讲完了。接下来我就不继续啰嗦原理了,我们来切两道OJ题练练手。

栈题:

栈的压入,弹出序列OJ

class Solution {
public:
    bool IsPopOrder(vector<int>& pushV, vector<int>& popV) {
        if(pushV.size() != popV.size())
            return false;

        size_t inIndex = 0;
        size_t outIndex = 0;
        stack<int> st;

        while(outIndex < popV.size())
        {
            // 如果栈顶和出序列下标值不相等或者栈为空,则入栈
            if(st.empty() || st.top() != popV[outIndex])
            {
                // 如果inIndex还没有越界,入栈
                if(inIndex < pushV.size())
                    st.push(pushV[inIndex++]);

                //如果已经越界,说明弹出序列不符合,压入序列已经没有相同的值了,返回false
                else
                    return false;
            }

            // 如果栈顶和出序列下标值相同,则出栈,outIndex往后走
            if(st.top() == popV[outIndex])
            {
                st.pop();
                outIndex++;
            }
        }
        return true;
    }
};

用栈实现队列

class MyQueue {
public:
    MyQueue() {
        
    }
    
    void push(int x) {
        instack.push(x);
    }
    
    int pop() {
        // 如果out栈是空的话就把in栈里面所有数据搬过来
        // 这样in栈的栈底数据就成了out栈的栈顶数据
        if(outstack.empty()){
            while(!instack.empty()){
                outstack.push(instack.top());
                instack.pop();
            }
        }
        int ret = outstack.top();
        outstack.pop();
        return ret;
    }
    
    int peek() {
        // 如果out栈是空的话就把in栈里面所有数据搬过来
        // 这样in栈的栈底数据就成了out栈的栈顶数据
        if(outstack.empty()){
            while(!instack.empty()){
                outstack.push(instack.top());
                instack.pop();
            }
        }
        return outstack.top();
    }
    
    bool empty() {
        return instack.empty() && outstack.empty();
    }
private:
    stack<int> instack;
    stack<int> outstack;
};

队列题:

用队列实现栈

class MyStack {
public:
    MyStack() {}

    void push(int x) {
        q2.push(x); // 保证新插进来的元素在对头

        // 将q1所有数据挪到q2里面去
        while (!q1.empty()) {
            q2.push(q1.front());
            q1.pop();
        }

        // 始终保证q1是主队列,把q2空出来存储最后一个入栈的元素
        swap(q1, q2);
    }

    int pop() {
        int ret = q1.front();
        q1.pop();
        return ret;
    }

    int top() {
        return q1.front();
    }

    bool empty() {
        return q1.empty();
    }
    queue<int> q1;
    queue<int> q2;
};

优先级队列:

数组中第K个最大元素

class Solution {
public:
    int findKthLargest(vector<int>& nums, int k) {
        priority_queue qu(nums.begin(), nums.end());
        while(k-- > 1)
            qu.pop();
        return qu.top();
    }
};

四,结语

到这里,stack、queue 和 priority_queue 的概念、实现原理以及它们为什么“看起来简单却不能遍历”,我们都已经讲清楚了。你会发现,它们本质上并不是新的容器,而是建立在其他顺序容器之上的“使用方式约束”。理解了这一点,不管是自己模拟实现、刷OJ题,还是面试手撕代码,都会轻松很多。

这一篇就到这,下一篇我们继续搞点更硬核的东西。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值