云计算百科
云计算领域专业知识百科平台

stack、queue 与 priority_queue(优先级队列):从底层访问规则理解容器适配器

文章目录

    • 1. 相同的数据,可以有三种取出规则
    • 2. stack 与 queue:接口简单,但使用习惯不能含糊
      • 2.1 先用一个小程序观察
      • 2.2 用一张表把共同点与区别放在一起
      • 2.3 为什么没有 begin 和 end
    • 3. 容器适配器:复用存储,改变使用方式
      • 3.1 “再包一层”不是重新写一个容器
      • 3.2 与设计模式的关系
    • 4. 可替换底层容器,先看它提供什么
      • 4.1 第二个模板参数
      • 4.2 deque:逻辑上是一列,典型实现中分段存储
      • 4.3 为什么默认选 deque
    • 5. 两个适配器的简短实现
      • 5.1 栈:把尾部当作唯一入口和出口
      • 5.2 队列:只把删除操作换到头部
      • 5.3 为什么没有再次手写析构、深拷贝与赋值
    • 6. priority_queue:每次给出当前最高优先级
      • 6.1 默认大堆与小堆的用法
      • 6.2 用数组表示树,不等于把数组排好序
    • 7. 比较器与堆调整:规则如何变成算法
      • 7.1 less 为什么对应大堆
      • 7.2 插入:新元素只可能破坏祖先路径
      • 7.3 删除:先用末尾元素填根,再向下调整
      • 7.4 把存储、比较和调整组合起来
      • 7.5 复杂度:把堆算法与容器操作分开看
    • 8. 扩展练习
      • 8.1 用两个栈实现最小栈
      • 8.2 用两个栈实现先进先出队列
    • 9. 总结

代码仓库:《stack queue的模拟实现》

1. 相同的数据,可以有三种取出规则


当我们依次在容器中放入 1、3、2、5、4、6,接着再把元素全部取出:

对象取出顺序决定下一次取出谁的规则
stack 6 4 5 2 3 1 最后放进去的先出,LIFO
queue 1 3 2 5 4 6 最先放进去的先出,FIFO
默认的 priority_queue<int> 6 5 4 3 2 1 当前最大值先出来

需要注意的是,这里看的是“每次访问指定位置,然后删除该元素之后继续”的结果,不是遍历底层存储。

在这里插入图片描述

栈可以放在数组上,也可以放在链表上;队列可以用链表,也可以用双端队列。

一个有效的理解顺序是:先想想下一次应该处理谁,然后再找合适的接口,而不是看到“存一组数据”就直接选择一个可以任意访问的容器。

  • 需要撤销最近一步操作,适合后进先出的规则。
  • 需要按到达顺序处理任务,适合先进先出的规则。
  • 需要不断处理当前优先级最高的任务,适合优先级队列。

2. stack 与 queue:接口简单,但使用习惯不能含糊


2.1 先用一个小程序观察

stack 的头文件是 <stack>,queue 和 priority_queue 的头文件都是 <queue>。

#include <iostream>
#include <stack>
#include <queue>

int main()
{
std::stack<int> stack;
//压栈1 2 3 4 5
for (int value = 1; value <= 5; ++value) stack.push(value);
//输出栈当前元素个数
std::cout << stack.size() << std::endl;
//逐个打印栈元素
while (!stack.empty()) {
std::cout << stack.top() << (stack.size() == 1 ? '\\n' : ' ');
stack.pop();
}
std::cout << std::endl;

std::queue<int> queue;
//入队列1 2 3 4 5 6
for (int value = 1; value <= 5; ++value) queue.push(value);
//输出队列当前元素个数
std::cout << queue.size() << std::endl;
//按顺序打印栈元素
while (!queue.empty()) {
std::cout << queue.front() << (queue.size() == 1 ? '\\n' : ' ');
queue.pop();
}
std::cout << std::endl;

return 0;
}

运行示例:

在这里插入图片描述

入栈和入队都是 push,区别在取出位置。栈查看 top(),队列查看 front();两者都用 pop() 删除当前应取出的元素。

2.2 用一张表把共同点与区别放在一起

操作stack<T>queue<T>要注意什么
默认构造 空栈 空队列 此时只能查询是否为空和数量,不能访问元素
push(x) 压入栈顶 加入队尾 保存的是元素副本
pop() 删除栈顶 删除队头 返回 void,不返回被删除的值
访问 top() front()、back() 对象必须非空;非 const 对象可以通过引用修改值
empty() 是否为空 是否为空 安全地决定能否进行下一步访问或删除
size() 元素数量 元素数量 返回无符号数量类型,不用它表达负数

不要误写成 int value = s.pop();。正确顺序是:先确认非空,再读取或复制当前元素,最后删除。

if (!s.empty()) {
int value = s.top();
s.pop();
// value 是独立整数,后面可以继续使用。
}

为什么这里分成两个函数呢?访问只观察或修改元素,删除则改变对象状态;分离后可以只看而不删,也可以先把值保存下来再删。这样也避免了把“复制返回值”和“删除存储”绑定成一个可能失败的动作。

2.3 为什么没有 begin 和 end

标准 stack、queue 没有普通迭代接口,也没有下标访问。

  • 设计定位:容器适配器,并不是普通容器 stack、queue本身并不主要用来存储数据,只是对底层容器(默认deque)做一层包装,用来强制约束访问规则。
    • stack:只允许访问栈顶top()
    • queue:只允许访问队头front()、队尾back()
  • 语义上就不允许随意遍历中间元素 如果提供了begin()和end()迭代器,那就可以对其遍历、修改中间位置的元素,这样就直接破坏了栈 “只能操作栈顶”、队列 “只能操作头尾” 的约束。
  • 栈 / 队列的本意:你不应该看到、修改中间的数据,只能按规则增删取边界元素。

    3. 容器适配器:复用存储,改变使用方式


    3.1 “再包一层”不是重新写一个容器

    可以看看一些直接的功能映射关系:

    对外操作栈如何调用底层容器队列如何调用底层容器
    push(x) push_back(x) push_back(x)
    pop() pop_back() pop_front()
    当前可处理元素 back() front()
    其他观察 empty()、size() back()、empty()、size()

    栈把容器末尾当作栈顶。队列把头部当队头,尾部当队尾。

    相比栈与队列来说,其底层容器拥有更多能力,但适配器不把那些能力全部交给使用者。

    在这里插入图片描述

    3.2 与设计模式的关系

    设计模式是对反复出现的设计问题与解决办法的总结,不是一组必须背下来的特殊语法。

    适配器模式的核心是:把已有接口转换为使用方需要的接口。

    一个通俗的例子帮助理解: 电源转换头:插座是已有接口,你的充电器插头规格不一样,转换器就用来做适配;就像 deque 是底层容器,stack 就是转换头,对外只提供栈的接口。

    4. 可替换底层容器,先看它提供什么


    4.1 第二个模板参数

    标准库模板参数的基本形状可以理解为:

    template<class T, class Container = std::deque<T>> class stack;
    template<class T, class Container = std::deque<T>> class queue;
    template<class T, class Container = std::vector<T>,
    class Compare = std::less<T>> class priority_queue;

    T 决定元素类型,Container 决定存储它们的容器类型。这里模板的第二参数是一个完整类型,例如 std::deque<int>,标准适配器要求 T 与底层容器的元素类型一致。

    适配器默认底层容器核心底层要求可选标准容器举例
    stack deque<T> 尾插、尾删、尾部访问,另有数量和判空 deque、vector、list
    queue deque<T> 尾插、头删、头尾访问,另有数量和判空 deque、list
    priority_queue vector<T> 随机访问迭代器、尾插尾删、首元素访问等 vector、deque

    对于 queue 来说,普通的 vector 没有 pop_front(),不能直接承担标准 queue 的头删要求。即使可以写 erase(begin()),那也不是 queue 要调用的同名接口,而且这样每次删除头部通常要同步移动后面的元素,效率较低。

    list 没有随机访问迭代器,所以其不能承担标准 priority_queue 所需的堆算法接口。这两个对照说明:容器替换取决于操作契约,不取决于“它也能存放数据”。

    4.2 deque:逻辑上是一列,典型实现中分段存储

    deque 读作双端队列,可以在头尾插入、删除,还支持下标和随机访问迭代器。

    典型实现用一个索引结构(中枢数组)管理多个缓冲块。每块内部连续,不同块不保证物理相邻。由此可以提供逻辑上第 0、1、2……个元素的访问,却不能假设所有元素像一个普通数组一样位于整块连续空间中。 在这里插入图片描述

    随机访问为什么仍然是常数时间?其底层实现可以用位置和块大小计算目标块与块内偏移,而不需要从头逐个走过去。迭代器跨块时则需要处理边界,所以它通常比一个裸指针更复杂。

    4.3 为什么默认选 deque

    stack 只用尾部操作,queue 同时用头尾操作,deque 都能够直接支持。

    deque双向队列天然高效支持头尾 O (1) 增删,所以作为通用默认底层容器。

    这让它成为合理的通用默认选择。但要补上几条边界:

    • 顺序连续存储在缓存局部性、简单遍历和预先规划容量等方面可能更有优势;不能断言 deque 做栈永远更快。
    • 分块空间仍有未用槽位和索引管理成本,不能说没有额外开销,空间利用率也不能脱离元素大小与数量来比较。
    • deque 可以遍历,复杂度仍是线性的。跨块检查和局部性是代价,但不至于说“不适合遍历”或“致命缺陷”。

    5. 两个适配器的简短实现


    5.1 栈:把尾部当作唯一入口和出口

    #pragma once
    #include <deque>

    namespace by
    {
    //Container适配转换stack
    template<class T, class Container = std::deque<T>>
    class stack
    {
    public:
    //由于成员变量是对应的容器,所以此处不需要单独写构造函数
    //只需要实现核心功能即可

    //入栈(统一把容器的尾部当栈顶)
    void push(const T& x)
    {
    _con.push_back(x);
    }

    //出栈
    void pop()
    {
    _con.pop_back();
    }

    //获取栈顶元素
    const T& top() const
    {
    return _con.back();
    }

    //获取当前数据个数
    size_t size() const
    {
    return _con.size();
    }

    //判断当前是否为空
    bool empty() const
    {
    return _con.empty();
    }
    private:
    Container _con;
    };
    }

    若写 by::stack<int>,Container 被替换为 std::deque<int>;若写 by::stack<int, std::vector<int>>,成员容器就变成 std::vector<int>。函数体内的规则并没有变化。

    5.2 队列:只把删除操作换到头部

    #pragma once

    namespace by
    {
    //Container适配转换queue
    template<class T, class Container = std::deque<T>>
    class queue
    {
    public:

    //队尾插入元素
    void push(const T& x)
    {
    _con.push_back(x);
    }

    //队头出元素
    void pop()
    {
    _con.pop_front();
    }

    //获取队头元素
    const T& front() const
    {
    return _con.front();
    }

    //获取队尾元素
    const T& back() const
    {
    return _con.back();
    }

    //获取当前元素个数
    size_t size() const
    {
    return _con.size();
    }

    //判断当前是否为空
    bool empty()
    {
    return _con.empty();
    }
    private:
    Container _con;
    };
    }

    它与栈的共同点是都把存储责任委托给了成员对象。区别只有:入队从尾部进入,出队从头部离开。

    5.3 为什么没有再次手写析构、深拷贝与赋值

    • 构造:创建适配器对象时,成员容器会自动调用底层容器的默认构造函数。
    • 析构:适配器销毁时,成员对象会自动调用它自己的析构函数,释放容器内部资源。不需要手写析构函数。
    • 拷贝构造 / 拷贝赋值:编译器合成的默认拷贝、赋值运算符,会对成员做逐成员拷贝,直接调用底层容器自己的拷贝构造、拷贝赋值,完成深拷贝。

    6. priority_queue:每次给出当前最高优先级


    6.1 默认大堆与小堆的用法

    优先级队列不是普通先进先出队列,访问函数是 top(),没有 front()/back();删除的是当前最高优先级元素。

    下面用一个简单的小程序,同时观察大堆与小堆的输出顺序。

    #include <functional>
    #include <iostream>
    #include <queue>
    #include <vector>

    int main()
    {
    int values[] = { 1, 3, 2, 5, 4, 6 };
    //默认第二模板参数less 升序建小堆
    std::priority_queue<int> large;
    //显式给greater 降序建大堆
    std::priority_queue<int, std::vector<int>, std::greater<int>> small;
    //各自入队列
    for (int value : values)
    {
    large.push(value);
    small.push(value);
    }

    //输出降序建大堆的顺序
    std::cout << "降序建大堆->";
    while (!large.empty())
    {
    std::cout << large.top() << (large.size() == 1 ? '\\n' : ' ');
    large.pop();
    }
    std::cout << std::endl;

    ////输出升序建小堆的顺序
    std::cout << "升序建小堆->";
    while (!small.empty())
    {
    std::cout << small.top() << (small.size() == 1 ? '\\n' : ' ');
    small.pop();
    }
    std::cout << std::endl;

    return 0;
    }

    运行示例: 在这里插入图片描述 注意这里的“降序减大堆,升序建小堆”需要与堆排序的输出结果加以区分:

    • 在堆排序中,小堆堆顶最小,最先与尾部元素交换,交换后整体 size 减 1 ,所以最小元素依次从数组尾部往前排列,最终形成的效果就是降序。
    • 在此处优先级队列中则刚好相反,小堆堆顶最小,是最先被输出的元素,然后再依次是剩余最小元素的输出,最终形成的效果就是升序。(大堆同理)

    6.2 用数组表示树,不等于把数组排好序

    这里的堆是一种数据结构,与 new 申请空间时所说的内存“堆区”不是同一个概念。

    具体细节可查看:从树的层次关系到堆的调整算法:二叉树开篇

    堆可以用数组按层序表示成一棵完全二叉树。下标从 0 开始时:

    位置下标关系
    左孩子 2 * parent + 1
    右孩子 2 * parent + 2
    非根节点的父亲 (child – 1) / 2,前提是 child > 0

    在这里插入图片描述

    图中数组 9 7 8 1 3 2 6 是一个大堆:每个父节点都不小于孩子。

    但注意此时数组并不是完全降序,堆只维护父子关系,就足以保证根节点是当前最大值或最小值。访问堆顶不需要再扫描所有元素。兄弟节点之间没有必须有序的约束

    7. 比较器与堆调整:规则如何变成算法


    7.1 less 为什么对应大堆

    std::less<int> 调用 a < b,std::greater<int> 调用 a > b。它们是可以像函数一样调用的对象,也叫函数对象或仿函数,核心是重载 operator()。

    在优先级队列中,比较器表达的是排序关系:compare(a, b) 为真,表示 a 在该关系中位于 b 前面。优先级队列却把这个关系中“靠后”的元素放到堆顶。因此默认 < 关系下,更大的元素在堆顶。

    比较器compare(parent, child)为真时的含义最终堆顶
    less<int> parent < child 孩子更大,应向上 最大值
    greater<int> parent > child 孩子更小,应向上 最小值

    在这里插入图片描述

    7.2 插入:新元素只可能破坏祖先路径

    原来的数组已经满足堆关系,尾部加入新元素后,完全二叉树的形状仍成立。新元素没有孩子,唯一可能出错的是它与父节点的关系。于是先比较父子,必要时交换,再沿祖先链继续。 在这里插入图片描述 模拟从 9 7 8 1 3 2 6 追加 10:

    步骤childparent判断与结果
    追加后 7 3 1 < 10,交换
    上移一层 3 1 7 < 10,交换
    再上一层 1 0 9 < 10,交换
    到根 0 不计算 没有父亲,结束

    最后数组为 10 9 8 7 3 2 6 1。不是每次都走到根;若父子关系已经成立,立即停止。

    //向上调整算法
    void Adjust(int child)
    {
    Compare com;
    size_t parent = (child – 1) / 2;
    while (child > 0)
    {
    // if (_con[child] > _con[parent])
    if (com(_con[parent], _con[child]))
    {
    std::swap(_con[child], _con[parent]);
    child = parent;
    parent = (child – 1) / 2;
    }
    else
    {
    break;
    }
    }
    }

    7.3 删除:先用末尾元素填根,再向下调整

    如果直接删除数组第一个位置并把所有元素前移,树中的父子关系会大面积改变,还要搬移元素。所以堆删除使用另一条路线:

  • 交换根与最后一个元素。
  • 删除末尾,真正移除旧根,同时保持完全二叉树形状。
  • 新根与孩子可能不符合规则,沿着孩子路径向下修复。
  • 在这里插入图片描述 模拟从 9 7 8 1 3 2 6 删除 9:

    步骤childparent判断与结果
    原大堆 不计算 不计算 根 9 与末尾 6 交换
    交换完成 不计算 不计算 删除末尾的旧根 9,得到 6 7 8 1 3 2
    形状保持完整 2 0 选较大孩子 8;6 < 8,交换,得到 8 7 6 1 3 2
    检查下一层 5 2 只有孩子 2;6 ≥ 2,停止

    为什么先选更大的孩子? 若误选 7 交换,根变成 7,却仍比右孩子 8 小,根的关系就没有修好。对小堆相反,要先选更小的孩子。

    //向下调整算法
    void AdjustDown(int parent)
    {
    Compare com;
    size_t child = parent * 2 + 1;
    while (child < _con.size())
    {
    //if (child + 1 < _con.size() && _con[child] < _con[child + 1])
    if (child + 1 < _con.size() && com(_con[child], _con[child + 1]))
    {
    child++;
    }

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

    7.4 把存储、比较和调整组合起来

    //插入数据
    void push(const T& x)
    {
    _con.push_back(x);

    Adjust(_con.size() – 1);
    }

    //删除数据
    void pop()
    {
    std::swap(_con[0], _con[_con.size() – 1]);
    _con.pop_back();

    AdjustDown(0);
    }

    const T& top()
    {
    return _con[0];
    }

    //获取当前元素个数
    size_t size() const
    {
    return _con.size();
    }

    //判断当前是否为空
    bool empty()
    {
    return _con.empty();
    }

    单元素删除时,根与末尾是同一个元素。删除后容器为空,就不再向下调整。

    与栈、队列相比,优先级队列多了一层算法责任:容器维护空间,比较器定义顺序,堆算法维护关系。

    7.5 复杂度:把堆算法与容器操作分开看

    操作核心成本边界说明
    stack/queue 的观察 通常 O(1) 使用标准底层容器
    stack 的插删 底层尾插尾删的成本 连续存储尾插可能扩容,不忽略迁移成本
    queue 的插删 底层尾插头删的成本 默认 deque 或 list 支持端部操作
    priority_queue::top() O(1) 已有堆关系,不再扫描
    优先级队列插入、删除的堆调整 O(log n) 树高度是对数级;还需加上底层操作成本
    逐个插入 n 个元素 O(n log n) 的上界 每次维护一条祖先路径
    一次对 n 个元素批量建堆 O(n) 自底向上调整,不是 n 次从叶子走到根

    为什么批量建堆不是 n log n?叶子节点不用调整,倒数第二层只需很短路径,能走接近树高的节点非常少。把各层节点数量乘以各自的最大下降距离相加,是线性数量级。

    make_heap官方说明批量建堆至多执行 3*N 次比较,也就是 O (n)

    8. 扩展练习


    8.1 用两个栈实现最小栈

    LeetCode 155:最小栈

    要求入栈、出栈、查询栈顶、查询当前最小值都为常数级操作。本题规定出栈和访问时对象非空。

    思路: 普通栈保存所有元素,辅助栈保存“当前最小值变化记录”。新值小于或等于当前最小值时,辅助栈也压入它;删除的值若等于当前最小值,两个栈一起弹出。

    参考答案

    class MinStack
    {
    public:
    void push(int value)
    {
    _values.push(value);
    if (_minimum.empty() || value <= _minimum.top())
    _minimum.push(value);
    }
    void pop()
    {
    if (_values.top() == _minimum.top())
    _minimum.pop();
    _values.pop();
    }
    int top() const { return _values.top(); }
    int getMin() const { return _minimum.top(); }
    bool empty() const { return _values.empty(); }
    private:
    std::stack<int> _values;
    std::stack<int> _minimum;
    };

    8.2 用两个栈实现先进先出队列

    LeetCode 232:用栈实现队列

    基本输入输出只需整数,不必引入更复杂的数据类型。

    思路: 输入栈接收入队元素,输出栈提供队头。当输出栈为空时,才把输入栈全部倒入输出栈。一次倒转使最早进入的元素来到输出栈顶。

    动作输入栈,按栈底到栈顶输出栈,按栈底到栈顶当前队头
    入队 1、2、3 1 2 3 空 尚未转移
    首次访问 空 3 2 1 1
    删除 1 后入队 4、5 4 5 3 2 2
    删除 2、3 后再次访问 空 5 4 4

    参考答案:

    class MyQueue {
    public:
    std::stack<int> st1;
    std::stack<int> st2;
    MyQueue() {

    }

    void push(int x) {
    st1.push(x);
    }

    int pop() {
    if(st2.empty())
    {
    while(!st1.empty())
    {
    st2.push(st1.top());
    st1.pop();
    }
    }
    int res = st2.top();
    st2.pop();
    return res;
    }

    int peek() {
    if(st2.empty())
    {
    while(!st1.empty())
    {
    st2.push(st1.top());
    st1.pop();
    }
    }
    return st2.top();
    }

    bool empty() {
    return st1.empty() && st2.empty();
    }
    };

    9. 总结

    在具体场景要选择容器时,须用需求选择接口,而不是用类名去猜实现。

    需求优先考虑核心理由
    最近加入的先处理 stack 限制在栈顶操作
    按加入顺序处理 queue 队尾进入、队头离开
    不断取当前最高或最低优先级 priority_queue 维护堆关系,堆顶直接可访问
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » stack、queue 与 priority_queue(优先级队列):从底层访问规则理解容器适配器
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!