【C++修炼】STL容器—stack与queue

stack 与 queue

1. stack 的介绍

stack 的文档介绍

前面已经介绍过 string,vector,list 等容器,其底层数据结构是顺序表或链表。本篇的栈与队列同属数据结构,但 stack 和 queue 有不同的原因和用途,C++把它们归属于配接器。

什么是 stack ?它是一种容器适配器(container adaptor),特别设计为后进先出 (LIFO)的数据结构。对于容器,类的模板参数除了代表数据类型 的 T,另一个参数一般是 allocator,即空间配置器/内存池。对于 stack 配接器,空间配置器参数变成了 Container (容器),参数默认值是 deque,这是配接器与容器的第一个不同。

相比前几个容器各种各样的接口,stack 大大简化,接口主要是栈的相关操作,例如压栈 push、出栈 pop 以及获取栈顶元素 top 等。具体成员函数包括:

2. queue 的介绍

queue 的文档介绍

什么是 queue?与 stack 一样,作为容器适配器(container adaptor),特别设计为先进先出(FIFO)的数据结构,队尾入,队头出。相比容器,同样有一个代表数据类型 的模板参数 T,且另一个参数变成了 Container (容器),默认值是 deque,与 stack 一致,可见这是适配器的共同点。

queue 的接口同样十分简单,大多是队列的相关操作,例如入队 push、出队 pop 以及获取队头 front 或队尾 back 的数据等。具体成员函数包括:

3. stack 与 queue 的使用

若需要使用栈 或队列 的数据结构,直接用对应的 stack 或 queue 即可,只需包含对应头文件 <stack> 或 <queue>。

下面用几道OJ题演示其使用。

例题 3.1

力扣(LeetCode)155------最小栈

题意解析:要求实现栈的相关操作,包括栈的初始化、入栈、出栈、获取栈顶元素。同时,此题关键是要实现常数时间内获取栈中最小元素。

实现思路:该题关键是常数时间内获取最小元素。可用两个栈实现,特别维护一个有序栈。一个栈正常使用,有序栈除了第一个元素,后续元素当且仅当小于等于栈顶才入栈。这样,有序栈的栈顶就可以存储最小元素,且维护了一个小于栈底元素的有序序列。当有序栈栈顶元素与普通栈栈顶元素相同时,就需要出栈,保证两个栈的数据一致性。

代码示例 3.1

cpp 复制代码
class MinStack 
{
private:
    // int 为栈中元素类型
    stack<int> _st;    // 普通栈
    stack<int> _minst; // 有序栈
public:
    MinStack() 
    {
        // 默认构造:初始化堆栈对象
        // 此处可删除默认构造,也可显式写但不写内容
        /* 因为,编译器默认生成默认构造,成员变量初始化一定会走构造函数的初始化列表,
        内置类型,有缺省值用缺省值初始化,没有则初始化为随机值;自定义类型,一定调用
        自己的构造函数初始化。此处使用的两个 stack 成员变量是库中实现好的类,不管写
        不写,两个自定义类型的成员变量最终都会调用自己的构造函数初始化。*/
    }
    // 压栈:普通栈正常入,有序栈为空或者 val 小于等于栈顶元素
    void push(int val) 
    {
        _st.push(val);
        if (_minst.empty() || val <= _minst.top())
            _minst.push(val);
    }
    // 出栈:普通栈正常出,有序栈栈顶元素与普通栈栈顶元素相同,就需要出栈
    void pop() 
    {
        if (_st.top() == _minst.top())
            _minst.pop();
        _st.pop();
    }
    // 获取栈顶元素:返回普通栈栈顶元素
    int top() { return _st.top(); }
    // 获取栈中最小元素:返回有序栈栈顶元素
    int getMin() { return _minst.top(); }
};

例题 3.2

牛客(nowcoder)JZ31------栈的压入序列与弹出序列

题意解析:给出两个序列,一个序列是元素的入栈顺序,一个序列是元素的出栈顺序,要求检查该出栈序列是否可能对应入栈序列。

实现思路:根据入栈序列模拟,是否能实现该出栈顺序。入栈序列的元素逐个入栈,每入一个则检查出栈序列,此时的栈顶元素是否该出栈,元素匹配则出栈,不匹配则连续入栈。可能匹配上就连续匹配,则要连续出栈。直到入栈序列遍历完,则匹配结束。此时,栈出完为空或出栈序列走到末尾,说明模拟成功;若栈没有出完,不为空或出栈序列没有走完,则说明模拟失败。

代码示例 3.2

cpp 复制代码
class Solution 
{
public:
    // pushV:入栈序列     popV:出栈序列
    bool IsPopOrder(vector<int>& pushV, vector<int>& popV) 
    {
        size_t popi = 0; // 用于遍历出栈序列
        stack<int> _st;  // 模拟用的栈
        for (auto& e : pushV) // 遍历入栈序列,逐个入栈,不断比较栈顶元素
        {
            // 先入栈
            _st.push(e);
            // 再比较栈顶元素(是否与待出栈的元素对应)
            // 1、若匹配则出栈,出栈可能是连续的,需要用循环
            // 2、若不匹配,则进入下一轮入栈比较
            while (!_st.empty() && _st.top() == popV[popi])
            {
                _st.pop(); // 出栈
                ++popi;    // 出栈序列往后走
            }
            /* 需注意:出栈过程可能是连续的,可能入栈序列遍历的中途出空,因此 while 循环
            出栈的过程中,除了不匹配,栈为空也不能出栈。例如:入栈序列[1,2,3,4,5],出栈
            序列[4,3,2,1,5]。1、2、3、4 依次入栈,4 成为栈顶元素,与待出栈的值匹配,则
            出栈。3 成为新栈顶元素,同时待出栈的值也变成 3,连续匹配则连续出栈,2、1同理
            ,while 循环中途栈连续出空。空栈是不能访问栈顶的,因此需要判空进行边界检查。
            */
        }
        // 入栈序列遍历结束则模拟结束,此时栈为空则模拟成功,不为空则模拟失败
        return _st.empty(); // 栈为空则返回 true,否则返回 false
    }
};

例题 3.3

力扣(LeetCode)232------用栈实现队列

题意解析:用两个栈实现队列的4个操作:入队 push、出队 pop、判空 empty、获取队头元素 peek。

实现思路:栈后进先出,栈顶入,栈顶出;队列先进先出,队尾入,队头出。栈顶对应队尾,但出数据的方向相反。因此,栈模拟队列的关键在于如何出队,队头对应栈底,但栈底不能出数据。可以通过两个栈辗转数据,数据从一个栈到另一个栈的过程其实相当于一次逆序,原本的栈底成了另一个栈的栈顶。相当于两个栈,一个栈顶为队尾,另一个栈顶为队头。只需要一个栈专门入,一个栈专门出即可。

代码示例 3.3

cpp 复制代码
class MyQueue 
{
private:
    stack<int> _pushST; // 专门入栈(入队),栈顶即队尾
    stack<int> _popST;  // 专门出栈(出队),栈顶即队头
    // 两种情况:
    // 1、队列为空,则两个栈都为空
    // 2、队列不为空,则数据在push栈或pop栈,取决于上一次操作将数据辗转到了哪里
public:
    MyQueue() 
    {
        
    }
    // 入队:从队尾入,入push栈
    void push(int x) 
    {
        // 先确保数据在push栈中(pop栈为空即可)
        // pop栈为空说明数据都在push栈,或者两个栈都为空(队列为空),两种情况都允许入队
        while (!_popST.empty())
        {
            _pushST.push(_popST.top());
            _popST.pop();
        }
        _pushST.push(x); // push栈栈顶即队尾
    }
    // 出队:从队头出,pop栈出
    int pop() 
    {
        // 先确保数据在pop栈中
        while (!_pushST.empty())
        {
            _popST.push(_pushST.top());
            _pushST.pop();
        }
        // 还需要防止两个栈都为空(队列为空),有数据才可以出队
        assert(_popST.empty() != true); // assert断言检查
        // 有数据,允许出队,pop栈栈顶即队头
        int ret = _popST.top();
        _popST.pop();
        return ret;
    }
    // 获取队头元素:pop栈的栈顶元素即队头元素
    int peek() 
    {
        // 先确保数据都在pop栈中
        while (!_pushST.empty())
        {
            _popST.push(_pushST.top());
            _pushST.pop();
        }
        // 队列可能为空,避免访问空栈,断言检查
        assert(_popST.empty() != true);
        return _popST.top();
    }
    // 判空:push栈与pop栈同时为空
    bool empty() 
    {
        return _pushST.empty() && _popST.empty();
    }
};

例题 3.4

力扣(LeetCode)102------二叉树的层序遍历

题意解析:给出一棵二叉树,层序遍历所有结点(逐层遍历,从左往右),并将结点值存储到一个二维数组中返回,二维数组的一行则代表一层,一行一列则代表一个结点。

实现思路:这道题的关键在于如何获取一层结点存储到二维数组中,需要区分结点的层级。

  • 方法1:借助例题 3.1 中最小栈的思路,使用两个队列。一个队列专门存储结点,一个队列专门存储结点的层级。例如根结点3入队,层级为1,同步入另一个队列。第一层入完,再入第二层,根结点出队,根结点的层级出队,根结点的左孩子、右孩子依次入队,同时,根据根结点的层级可以获取其孩子的层级,同步入另一个队列。第二层入完,再入第三层,第三层同理,根结点的左孩子为队头,先出,则其左、右孩子先入,入队顺序符合层序遍历的顺序,重复即可。

方法1用了两个队列,有一定的空间消耗。稍作改良,也可以用一个队列。

  • 方法2:逐个结点入队变成逐层入队。只需要借助1个额外变量 levelSize 即可。如果我们知道了当前层的结点个数,我们就能控制结点整层入或整层出。队列中就不会上下层结点混杂,同一时间只存在一层结点,轻松获取层数。那么,如何获取每层结点数呢?其实就是队列的元素个数。根结点整层入,队列元素个数为1,第一层结点数为1,根据结点数就能整层出,出完即可存储一层元素;再入第二层,根结点的左、右孩子,结点数为2,队列元素个数为2,同样根据结点数整层出完并存储。实际遍历过程中,队列仍然会同时存在上下层结点,出队的本质不会改变,结点还是一个个出。但知道了上层结点数,就可以控制上层结点连续出完并存储。出队的同时,上层结点的左、右孩子连续入队,上层结点出完的同时,相当于下层结点全部入完,此时队列的元素个数就相当于新一层的结点个数。对于二维数组结点是整层出即可整层存储。

代码示例 3.4

cpp 复制代码
class Solution 
{
public:
    vector<vector<int>> levelOrder(TreeNode* root) 
    {
        vector<vector<int>> vv; // 二维数组存储遍历的值
        queue<TreeNode*> q;     // 队列存储逐层结点
        int levelSize = 0;      // 当前层的结点个数
        if (root)
        {
            // 先将根结点入队
            q.push(root);
            levelSize = 1;      // 当前层结点个数为1
        }
        while (!q.empty())
        {
            vector<int> v;      // 存储当前层所有结点
            // 根据当前层结点数将结点整层出队,同时将左右孩子整层入队
            while (levelSize--)
            {
                TreeNode* front = q.front(); // 临时存储当前结点
                q.pop(); // 结点出队
                v.push_back(front->val); // 将结点值存入临时的一维数组
                // 左右孩子依次入队
                if (front->left)
                    q.push(front->left);
                if (front->right)
                    q.push(front->right);
            }
            // 此时,上层结点整层出完,下层结点整层入队,队列元素个数即下一层结点数
            // 临时一维数组中存储了上层整层的所有结点值,存入二维数组即可
            vv.push_back(v);
            levelSize = q.size();
        }
        return vv;
    }
};

4. stack 与 queue 的底层实现

4.1 适配器模式

适配器是设计模式的一种(设计模式共23种,迭代器模式属于其中之一)。

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

例如日常生活中,中国家庭电路的标准电压是220V,出于绝对安全和特定功能的考虑,许多设备在入户的220V电压基础上,通过专用设备转换出了更低的直流或交流电压。如:手机充电器、笔记本电脑电源适配器,将220V交流电转换为5V、12V、20V等安全的直流电;智能家居设备(如智能门锁、网络摄像头、音箱)、LED灯带、USB接口的灯具,通常使用12V或24V直流电驱动。各种++适配器在其中扮演了转换的角色++。

如图,用户现有一个两插头的实例,因此目标接口是两孔接口(支持两插头),但现有接口(被适配者接口)是三孔接口,只支持三插头,目标与现有不匹配。适配器既有目标接口(两孔接口),又持有三插头实例(可插入三孔接口),可实现现有到目标的转换。

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

我们思考一个问题:能否用其他容器封装转换一下变成栈?其实是可行的,因为数据结构之间是可以转换的。

栈需要在一端(栈顶)插入或删除,只要支持该特性就可以使用。vector 和 list 底层数据结构分别是顺序表和链表,都可以支持一端插入或删除,因此,它们天然符合栈的结构,只需要简单封装即可。

例如:

代码示例 4.2.1

cpp 复制代码
#include <iostream>
#include <vector>
#include <list>

// 通过模板参数Container,就无需指定栈底层是vector还是list
// 自由选择栈的底层结构,顺序栈(数组),或链式栈(链表),或其他数据结构
template<class T, class Container = vector<T>>
class stack
{
private:
    Container _con;
};

// 例如:
int main()
{
    stack<int> st1; // 元素类型为int,容器默认为vector
    stack<char, list<char>> st2; // 元素类型为char,指定list容器
    return 0;
}

C语言中栈的实现就是用的顺序表,只不过是以静态或动态数组的形式,vector 容器本质是相同的,只不过多了层封装,list 同理。之前C语言都使用顺序表实现栈,只是因为顺序表更优,内存连续,且无需额外的成员变量 next 指针管理结点。这并不意味着链表不可用,特殊场景下,例如单个结点占用内存较大时,用链表实现栈反而比顺序表更优,各结点内存单独分配,动态申请。

为什么说 stack 是容器适配器(container adaptor)?因为它的实现基于适配器模式,将一个类的接口转换成客户希望的另外一个接口,类内部实现的就是适配转换的过程,即顺序表/链表,或者说容器 Container 到栈的过程。

队列同理,只不过是从后进先出(LILO)的栈的数据结构变为先进先出(FIFO)的队列的数据结构而已。

4.3 stack 的模拟实现

由于成员变量 Container _con 是自定义类型,默认构造/拷贝构造/析构/赋值重载等默认成员函数都会调用 _con 容器自身实现的,例如使用 vector 容器适配转换成 stack,默认调用标准库中 vector 已实现的默认成员函数。

这也是为什么不从零实现栈,反而借助其他容器封装实现的原因,代码复用即可,大大简化了模拟过程,需要关注的则是栈的相关操作,如压栈、出栈、获取栈顶元素等。

代码示例 4.3.1

cpp 复制代码
#pragma once
#include<deque>

namespace david
{
    template<class T, class Container = std::deque<T>>
    class stack
    {
    public:
        stack() {};
        // 压栈
        void push(const T& x)
        {
            _con.push_back(x); // 统一把尾部当作栈顶
        }
        // 出栈
        void pop()
        {
            _con.pop_back();
        }
        // 获取栈顶元素
        T& top()
        {
            // 不可用方括号[],容器未必是vector,list不支持该运算符重载
            return _con.back();
        }
        const T& top() const
        {
            return _con.back();
        }
        // 返回栈中元素个数
        size_t size() const
        {
            return _con.size();
        }
        // 判空
        bool empty() const
        {
            return _con.empty();
        }
    private:
        Container _con;
    };
}

// 使用
// 可将stack类的实现放到头文件,包含头文件再使用,此处为演示直接在类下面使用
#include <iostream>
#include <vector>
#include <list>

int main()
{
    david::stack<int, std::vector<int>> st1;
    st1.push(1);
    st1.push(2);
    st1.push(3);
    while (!st1.empty())
    {
        int top = st1.top();
        st1.pop();
        std::cout << top << " ";
    }
    std::cout << "\n";

    david::stack<int, std::list<int>> st2;
    st2.push(4);
    st2.push(5);
    st2.push(6);
    while (!st2.empty())
    {
        int top = st2.top();
        st2.pop();
        std::cout << top << " ";
    }
    std::cout << "\n";

    david::stack<int> st3; // 不传容器类型则使用缺省值
    st3.push(7);
    st3.push(8);
    st3.push(9);
    while (!st3.empty())
    {
        int top = st3.top();
        st3.pop();
        std::cout << top << " ";
    }
    std::cout << "\n";
    return 0;
}

运行结果:

4.4 queue 的模拟实现

队列要求先进先出(FIFO),一端入,另一端出,使用 vector 就不太合适了,list 反而更适合。而且标准库中 vector 只支持尾插尾删,不支持头插头删,或者说只能间接支持,例如 insert 和 erase 可间接实现头插/头删。当然,顺序表头插/头删效率太低了,对于队列而言,没必要特别支持 vector。

代码示例 4.4.1

cpp 复制代码
#pragma once
#include<deque>

namespace david
{
    template<class T, class Container = std::deque<T>>
    class queue
    {
    public:
        queue() {};
        // 入队
        void push(const T& x)
        {
            _con.push_back(x); // 统一将尾部当作队尾
        }
        // 出队
        void pop()
        {
            _con.pop_front(); // 统一将头部当作队头
        }
        // 返回队尾元素
        T& back()
        {
            return _con.back();
        }
        const T& back() const
        {
            return _con.back();
        }
        // 返回队头元素
        T& front()
        {
            return _con.front();
        }
        const T& front() const
        {
            return _con.front();
        }
        // 返回队列中元素个数
        size_t size() const
        {
            return _con.size();
        }
        // 判空
        bool empty() const
        {
            return _con.empty();
        }
    private:
        Container _con;
    };
}

// 使用
#include <iostream>
#include <vector>
#include <list>

int main()
{
    david::queue<int, std::vector<int>> q;
    q.push(1);
    q.push(2);
    q.push(3);
    // q.pop();
    return 0;
}

基于 vector 问题,queue 的使用要引申一点:模板的按需实例化。

模板类的成员函数按需实例化,只在使用时实例化,并非全部实例化。例如出队操作 pop 模拟实现中仍采用 pop_front 进行头删,但 vector 并不直接支持头删,因此,实例化时 queue<int, std::vector<int>> q 使用 vector 容器是有问题的。但上述代码是可以编译通过的。

问题就在于按需实例化,没有使用就没有实例化。代码中只使用了 push 函数,push 底层使用的是尾插 push_back,vector 支持自然没有问题。但如果解除注释,pop 函数被使用就需要实例化,此时编译就会因为无法找到 pop_front 报错。

4.5 deque 的简单介绍(了解)

在 stack (栈) 和 queue (队列) 模拟实现中,都采用 deque (双端队列) 作为默认容器。值得澄清的是,deque 和 queue 虽然都是队列,但不可混为一谈。

  • queue 是容器适配器(container adaptor),deque 是容器(container);
  • queue 要求先进先出,deque 不要求。
4.5.1 vector 和 list 的优缺点

为什么会出现 deque 呢?因为不管是 vector 还是 list,它们的优缺点都太明显了。

vector 优点:

  1. 尾插/尾删效率高,支持下标随机访问
  2. 物理空间连续(高速缓存利用率高)

vector 缺点:

  1. 需要扩容,扩容有一定代价(移动数据有效率损耗,2倍扩容存在空间浪费)
  2. 头部或中间插入/删除效率低

list 优点:

  1. 按需申请/释放空间,无需扩容
  2. 支持任意位置的插入/删除,效率高

list 缺点:

  1. 不支持随机访问
4.5.2 定义与接口

定义

deque (双端队列):一种双开口的"连续"空间的数据结构,类似 vector 顺序表和 list 链表的缝合怪。双开口含义是:可在头尾两端插入/删除,且时间复杂度为 O(1)。

  • deque 解决了 vector 头插/头删效率低的问题,且不需要搬移元素,融合了 list 的特点
  • 相比 list,内存相对连续,空间利用率比较高,且支持随机访问,融合了 vector 的特点

接口

可预见,deque 容器必然也结合了 vector 和 list 的接口,尾插、尾删、头插、头删,包括方括号 \[\] 运算符重载,通过下标+方括号 \[\] 的方式随机访问。

4.5.3 原理介绍

deque 并不是真正连续的空间,而是由一段段连续的小空间(buffer缓冲数组)拼接而成,但相比 list,它接近连续,空间利用率更高。各个小的缓冲数组相当于"链表的结点",管理各个"结点"的不是 next 指针,或 prev 指针,而是中控数组。

C语言中介绍过,数组名就是指针,arr0 相当于指针运算 arr + 0。同理,buf00 相当于指针运算 *(*(buf + 0) + 0)。第一层,内层的指针运算与解引用 *(buf + 0) 获取中控数组中下标为0处的数组指针;第二层,外层的指针运算与解引用 *(*(buf + 0) + 0) 获取缓冲数组中下标为0处的元素。这便是双端队列实现随机访问的关键,效率不如顺序表,但也还不错。

缓冲数组类似链表结点,按需申请,无需扩容,缓冲数组满了就申请新的缓冲数组,只需要把新的 buf 填入中控数组即可。但双端队列同样需要扩容 ,中控数组是动态申请的,中控数组的大小决定了 buf 的个数 ,缓冲数组大小通常固定,间接决定了总元素个数。因此,随着开辟的缓冲数组的增加,中控数组就需要扩容。

双端队列优势在于扩容代价低。顺序表扩容需要移动所有元素,但++中控数组只需要拷贝指针,扩容需要的空间小,需要移动的数据量也少++。中控数组扩容1个元素(指针)只需要4字节空间(64位下为8字节),就可以多1个缓冲数组,大大减少了移动数据量和空间需求。

这里需要提及一个细节:第一个缓冲数组并非放在中控数组的开始位置,其通常位于中控数组的中间位置。头插就往前/往左走,开辟新的缓冲数组,插入元素到数组末尾,从后往前依次插入;尾插就往后/往右走,从前往后依次插入,如果放满N个元素则在后面开辟新的缓冲数组,继续插入新元素。

4.5.4 迭代器

双端队列本质仍是假象的连续空间,实际分段连续,为了维护实现"整体连续"以及随机访问,任务落在了 deque 的迭代器身上,因此 deque 的迭代器设计就非常关键,其实现也比较复杂,因为需要支持随机访问,所以 deque 的 iterator 必定是随机迭代器。

map 为中控数组,1个结点管理1个缓冲数组。deque 的迭代器封装了4个值:node 是二级指针,指向中控数组中的某个元素,即迭代器当前指向的 buffer;first 和 last 分别指向缓冲数组的开始与结束,cur 代表当前 buffer 的第i个元素。

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

deque 的成员变量有4个:map、map_size、start 和 finish。map 是指向中控数组的指针,map_size 是中控数组的大小,即元素个数,这2个变量方便扩容。重点是后2个变量,start 和 finish。start 对应 begin() 函数返回的迭代器,finish 对应 end() 函数返回的迭代器。

从图中可以看到,start 代表整个双端队列的开始,finish 代表整个双端队列的结束。start.node 指向中控数组的第一个 buffer,start.first 和 start.last 分别指向 buffer 的开始与结束,start.cur 指向 buffer 的第一个元素;finish.node 指向中控数组的最后一个 buffer,finish.first 和 finish.last 分别指向 buffer 的开始与结束,finish.cur 指向 buffer 的最后一个元素的下一位置。

那么 deque 容器的迭代器如何借助这两个成员变量进行遍历呢?

代码示例 4.5.1

cpp 复制代码
iterator it = begin(); // 将begin返回的迭代器拷贝给it
while (it != end()) // 如果it等于end,说明it走到结尾
{
    cout << *it << " ";
    it++;
}
/* 
1、begin()的实现返回start迭代器即可;end()的实现返回finish迭代器即可。
2、!=不等于的实现只需要比较cur是否相等即可。cur代表的是某个元素,元素在整个中控数组所有缓冲
数组中都是唯一的。
3、*解引用的实现只需要解引用cur即可,*cur,返回cur指向的元素。
4、前置++或者后置++的实现需要考虑:cur是否走到当前缓冲数组的末尾。
    a. 若cur没有走到末尾,当前缓冲数组还有元素没有遍历,只需要++cur指向下一元素即可;
    b. 若cur走到数组末尾,当前缓冲数组已经遍历完毕,则需要先更新node指向下一个缓冲数组,即
       ++node,然后更新first和last,指向下一个buffer的开始与结束,即first=*node,
       last=*(node+N),cur指向下一个buffer的首元素,即cur=*node。继续++则回到①中情况
5、当it走到结尾,it和finish相等,则遍历结束
*/

基于上述结构,头插、尾插如何实现也很清楚了。

  • 尾插:找到 finish,根据 cur 是否等于 last 判断当前 buffer 满没满。cur != last 说明没满,直接插入元素并 ++cur 即可;cur == last 说明满了,需要开辟新的缓冲数组,将指向新 buffer 的 node 从前往后插入中控数组并更新 finish(first/last分别指向新buffer的开始与结束,并++node),再插入新元素并更新 cur。当然,开辟缓冲数组前,需要先判断中控数组是否有多余空间存储新结点,中控数组满了需要扩容,先申请新空间,然后拷贝各个结点,最后更新 map 和 map_size。
  • 头插:找到 start,判断 cur 是否等于 first。cur == first 说明头插不了,需要开辟新的缓冲数组,将 node 从后往前插入到中控数组并更新 start(first/last分别指向新buffer的开始与结束,并--node),再插入新元素并更新 cur(头插需注意,新元素是从buffer尾部依次向前插入);cur != first 说明可以直接在当前 buffer 插入,直接插入元素并 ++cur 即可。当然,头插同样需要考虑中控数组的扩容问题。

deque 的迭代器是随机迭代器,因此还需要支持 operator\[\]。

上面是 deque 的源码,可知其迭代器的 operator\[\] 实际复用了 operator+,operator+ 实际复用了 operator+=。相当于 arr2 本质是指针运算 *(arr + 2)。至于 + 是先拷贝临时对象,对临时对象进行 += 并返回,并不影响原本的对象。因此这3个运算符重载是可以互相复用的。

重点是 += 的逻辑。需要注意,start 和 finish 指向的首尾两个 buffer 并不确定是满的,头插/尾插可能只用了部分空间。但 start 和 finish 中间的 buffer 一定是满的,而且 buffer 的大小通常是固定的。因此 += 的实现,有一个关键的计算式 offset = n + (cur - first)。

list 内存不连续,结点按需申请,频繁申请/释放结点容易导致效率损耗,就像扩容十次1字节和扩容一次10字节,扩容涉及系统调用,系统调用越频繁开销越。而 deque 单个结点一次申请一段连续空间,相同数据量扩容次数少的多。

vector 不直接支持头插,需要搬移数据,效率很低。而且数据量越大,扩容一次的开销越大。deque 虽然也要扩容,但中控数组扩容代价小得多,不需要直接拷贝数据,只需要拷贝若干指针即可。

deque 通过中控数组(map)和迭代器(iterator)维护了"整体连续"的假象,支持O(1)的随机访问,尽管在海量数据遍历和随机访问的极致性能上略逊于 vector。

deque 的缺点也很明显:

  1. 迭代器设计略显复杂,随机访问效率不及原生的顺序表;
  2. 中间位置的插入/删除效率很低。为了保证缓冲数组大小固定,需要搬移数据;
  3. 不适合遍历(致命缺陷)。遍历时,deque 迭代器要频繁检测是否遍历到 buf 边界,导致效率低下。

实践中,需要线性结构通常优先考虑 vector 和 list,deque 的应用并不多,而目前能看到的一个应用就是,STL用其作为 stack 和 queue 的底层数据结构:

  • 栈和队列没有迭代器,无需遍历;
  • 相比 vector,deque 支持头部插入/删除,时间复杂度为O(1),支持队列两端插入/删除。且扩容代价更小,无需搬移拷贝数据,对于大型栈更友好;
  • 相比 list,deque 空间相对连续,结点管理代价更低(存储更少的额外字段/指针),空间利用率更高。

下面通过代码实践验证 deque 相比 vector 的随机访问效率。sort 底层是快排,需要大量随机访问元素。先对 deque 和 vector 中的相同数据各自排序,对比耗时。再对比 deque 与相同数据从 deque 拷贝到 vector 中排序再拷贝回来的效率。

代码示例 4.5.2

cpp 复制代码
#include <iostream>
#include <vector>
#include <deque>
#include <algorithm>
using namespace std;
#include <cstdlib> // srand/rand
#include <ctime> // time

void test_op1()
{
	srand(time(0));
	const int N = 1000000; // 一百万个数据
	deque<int> dq;
	vector<int> v;
	for (int i = 0; i < N; ++i)
	{
		// rand 最多生成3万多随机数,+i避免重复数据
		auto e = rand() + i;
		// 相同数据分别存入vector和deque中
		v.push_back(e);
		dq.push_back(e);
	}
	// clock返回从程序开始执行到函数被调用时的处理器时钟打点数
	// 要将其转换为秒,需要除以宏CLOCKS_PER_SEC
	// Windows上,CLOCKS_PER_SEC通常是1000,所以clock()的返回值单位相当于毫秒
	// 排序vector中的百万数据,计算耗时
	int begin1 = clock();
	sort(v.begin(), v.end());
	int end1 = clock();
	// 排序deque中相同的百万数据,计算耗时
	int begin2 = clock();
	sort(dq.begin(), dq.end());
	int end2 = clock();

	printf("vector:%d\n", end1 - begin1); // 单位毫秒
	printf("deque:%d\n", end2 - begin2);
}

void test_op2()
{
	srand(time(0));
	const int N = 1000000;
	deque<int> dq1;
	deque<int> dq2;
	for (int i = 0; i < N; ++i)
	{
		auto e = rand() + i;
		dq1.push_back(e);
		dq2.push_back(e);
	}
	// 排序deque中相同的百万数据,计算耗时
	int begin1 = clock();
	sort(dq1.begin(), dq1.end());
	int end1 = clock();
	// 将相同百万数据从deque拷贝到vector再排序,排序后再拷贝回去,计算耗时
	int begin2 = clock();
	vector<int> v(dq2.begin(), dq2.end()); // 拷贝到vector
	sort(v.begin(), v.end()); // 排序
	dq2.assign(v.begin(), v.end()); // 拷贝回deque
	int end2 = clock();

	printf("deque sort:%d\n", end1 - begin1);
	printf("deque copy vector sort, copy back deque:%d\n", end2 - begin2);
}

int main()
{
	test_op1();
	test_op2();
	return 0;
}

Debug下运行结果(4倍到5倍多):

Release下运行结果(1.5倍到2倍多):

5. priority_queue

5.1 priority_queue 的介绍

priority_queue(优先队列)文档介绍

priority_queue,即优先队列 ,同样是容器适配器。优先队列其实就是**堆**,只不过一个是从应用的角度,一个是从数据结构的角度。priority_queue 头文件与 queue 相同。其成员函数和栈类似,值得注意的是 pop 和 top,取优先级高的元素进行删除或获取。默认大的值优先级高,若想改为小的值优先级高,则需要用到仿函数 Compare。

priority_queue 默认容器采用 vector。堆是一棵完全二叉树,大堆每一棵子树的根结点大于孩子结点,小堆每一棵子树的根结点小于孩子结点。priority_queue 插入/删除结点需要频繁调用 AdjustUp/AdjustDown 向上/向下调整,恢复堆结构。AdjustUp/AdjustDown 中又要频繁使用下标计算获取父子结点,随机访问频率高,用 deque不如 vector。

5.2 priority_queue 的使用

代码示例 5.2.1

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

int main()
{
    // 默认大的值优先级高(建大堆,排降序)
	priority_queue<int> pq;
	pq.push(4);
	pq.push(1);
	pq.push(5);
	pq.push(8);
	pq.push(2);
	pq.push(6);
	pq.push(7);
	pq.push(3);
	while (!pq.empty())
	{
		cout << pq.top() << " ";
		pq.pop();
	}
	cout << endl;
	return 0;
}

运行结果:

力扣(LeetCode)215------数组中第K个最大元素

解题思路:利用优先队列存储数据,然后选取堆中前k-1个最大元素并删除,此时堆顶元素就是第k个最大元素。

代码示例 5.2.2

cpp 复制代码
class Solution 
{
public:
    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();
    }
};

5.3 priority_queue 的模拟实现

5.3.1 仿函数

仿函数 (Functor),全称函数对象 (Function Object)。简单来说,仿函数就是一个**"像函数"的对象**。通过重载函数调用运算符 () 使这个类的对象可以像函数一样使用。只要一个类 (class) 或结构体 (struct) 重载了 operator(),它就是一个仿函数。

仿函数一般下为空类,没有成员变量,但也可以携带参数。普通函数是全局的,不使用全局变量或者用临时变量保存结果重新传参,下一次函数调用就无法记住上一次的调用结果。仿函数作为对象,可以通过构造函数传入参数,保存在成员变量携带,可记录调用状态或调用结果。

仿函数可作为模板参数,编译器在编译期就能确切知道调用的是哪个类的 operator(),因此极大概率会被内联展开,消除函数调用的栈帧开销。部分情况下需要使用函数指针调用函数,指针跳转会影响编译器判断普通函数能否内联展开。

代码示例 5.3.1.1

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

// 普通函数
bool myfunction(int i, int j)
{
	return (i < j); // 返回bool值,i<j的比较结果
}
// 仿函数
class myclass
{
public:
	bool operator() (int i, int j)
	{
		return (i < j); // 返回bool值,i<j的比较结果
	}
};
// 仿函数也可以携带参数
struct Adder
{
	// 成员变量
	int base; // 记录上次调用结果/状态
	// 成员函数
	Adder(int b) // 构造函数
		: base(b)
	{
		// 通过初始化列表初始化成员变量,保存状态/配置参数
	}
	int operator() (int x) const // ()运算符重载
	{
		return x + base; // 返回整数加base后的结果
	}
};

int main()
{
	// 普通函数
	int arr[6] = { 4, 1, 3, 2, 6, 5 };
	for (int i = 0; i < 6; i++)
	{
		cout << arr[i] << " ";
	}
	cout << endl;
	// 函数名即函数指针,将普通函数myfunction作为函数指针传给sort,根据函数比较规则进行排序
	sort(arr, arr + 6, myfunction);
	for (int i = 0; i < 6; i++)
	{
		cout << arr[i] << " ";
	}
	cout << endl;

	// 仿函数
	int arr2[6] = { 4, 1, 3, 2, 6, 5 };
	for (int i = 0; i < 6; i++)
	{
		cout << arr2[i] << " ";
	}
	cout << endl;
	// 将仿函数传给sort,sort内调用myclass::operator()进行比较排序
	myclass mc; // 类名即类型名,定义1个有名对象 mc,即仿函数
	sort(arr2, arr2 + 6, mc);
	// sort(arr2, arr2 + 6, myclass()); // 或使用匿名对象作为仿函数
	for (int i = 0; i < 6; i++)
	{
		cout << arr2[i] << " ";
	}
	cout << endl;

	Adder add10(10); // 创建对象/仿函数,配置参数10保存到成员变量base中携带
	cout << add10(5) << endl; // 输出 5+base
	cout << add10.operator()(5) << endl; // 即 add10(5)
	cout << add10(20) << endl; // 再次调用,配置参数不变,输出 20+base
	return 0;
}

运行结果:

仿函数可以实现为类模板。在 <functional> 库中早已实现了部分仿函数,例如 less 和 greater,一个小于比较,一个大于比较。

代码示例 5.3.1.2

cpp 复制代码
// 仿函数less(小于)模拟实现
template<class T>
class Less
{
public:
	bool operator()(const T& x, const T& y)
	{
		return x < y;
	}
};
// 仿函数greater(大于)模拟实现
template<class T>
class Greater
{
public:
	bool operator()(const T& x, const T& y)
	{
		return x > y;
	}
};

普通函数控制比较规则

代码示例 5.3.1.3

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

bool Less(int a, int b)
{
	return (a < b);
}
bool Greater(int a, int b)
{
	return (a > b);
}
// 冒泡排序
// 使用普通函数,想要控制比较规则需要借助函数指针 com
// 函数指针影响内联展开
inline void BubbleSort(int* a, int n, bool (*com)(int, int))
{
	for (int i = 0; i < n; i++)
	{
		// 单趟
		int flag = 0;
		for (int j = 1; j < n - i; j++)
		{
			if (com(a[j], a[j - 1])) // <升序  >降序
			{
				swap(a[j], a[j - 1]);
				flag = 1;
			}
		}
		if (flag == 0) break;
	}
}

int main()
{
	int arr[6] = { 2, 4, 1, 3, 6, 5 };
	BubbleSort(arr, 6, Greater);
	for (auto e : arr)
	{
		cout << e << " ";
	}
	cout << endl;
	BubbleSort(arr, 6, Less);
	for (auto e : arr)
	{
		cout << e << " ";
	}
	cout << endl;
	return 0;
}

运行结果:

仿函数控制比较规则

代码示例 5.3.1.4

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

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

// 冒泡排序
// 使用仿函数,可直接将其设为模板参数,方便内联展开
template<class Compare>
inline void BubbleSort(int* a, int n, Compare com)
{
	for (int i = 0; i < n; i++)
	{
		// 单趟
		int flag = 0;
		for (int j = 1; j < n - i; j++)
		{
			if (com(a[j], a[j - 1])) // <升序  >降序
			{
				swap(a[j], a[j - 1]);
				flag = 1;
			}
		}
		if (flag == 0) break;
	}
}

int main()
{
	int arr[6] = { 2, 4, 1, 3, 6, 5 };
	BubbleSort(arr, 6, Less<int>()); // 使用Less的匿名对象
	for (auto e : arr)
	{
		cout << e << " ";
	}
	cout << endl;
	BubbleSort(arr, 6, Greater<int>()); // 使用Greater的匿名对象
	for (auto e : arr)
	{
		cout << e << " ";
	}
	cout << endl;
	return 0;
}

运行结果:

当然,我们也可以发散想象力,实现更多不同比较规则的仿函数。假如冒泡排序中比较的不是整数呢?如果比较的是两个日期类对象呢?比较日期需要先比年份,再比月份,最后比日份,这个比较规则比单纯的大于/小于要复杂的多,但借助仿函数可以轻松实现。

当然,我们也可以坚持用 greater 或 less,它们内部调用的是大于 > 或小于 <,日期类通过运算符重载的方式同样可以实现该比较规则。但在实践中,我们不一定能为此单独修改某个类。例如库中的 vector 或 list 等容器,我们就无法修改,如果它们没有重载对应的运算符,或者它们重载的运算符比较规则不符合需求,那我们就必须使用仿函数。

5.3.2 模拟实现

库中 priority_queue 默认是小于 less,小于建大堆,排升序。

代码示例 5.3.2.1

cpp 复制代码
#pragma once
#include<vector>
#include<algorithm>

namespace david
{
    // 仿函数 Less类(小于号) 与 Greater类(大于号)
    template<class T>
    class Less
    {
    public:
        bool operator()(const T& x, const T& y)
        {
            return x < y;
        }
    };
    template<class T>
    class Greater
    {
    public:
        bool operator()(const T& x, const T& y)
        {
            return x > y;
        }
    };

    // 默认容器用 vector
    // 默认大的值优先级高,缺省值用小于Less,建大堆
    template<class T, class Container = std::vector<T>, class Compare = Less<T>>
    class priority_queue
    {
    public:
        priority_queue() {};
        void AdjustUp(int child) // 向上调整
        {
            Compare com; // Less<,Greater>
            int parent = (child - 1) / 2;
            while (child > 0)
            {
                // 大堆:将父结点与子结点中大的那个向上交换
                if (com(_con[parent], _con[child]))
                {
                    swap(_con[parent], _con[child]);
                    child = parent;
                    parent = (child - 1) / 2;
                }
                else break;
            }
        }
        void push(const T& x) // 入队
        {
            // 元素插入到堆尾,再向上调整
            _con.push_back(x);
            AdjustUp(_con.size() - 1);
        }
        void AdjustDown(int parent) // 向下调整
        {
            Compare com; // Less<,Greater>
            int child = parent * 2 + 1; // 默认左孩子
            while (child < _con.size())
            {
                // 大堆:选大的孩子,如果右孩子大则child++
                if (child + 1 < _con.size() &&
                    com(_con[child], _con[child+1])) child++;
                // 大堆:将父结点与子结点中小的那个向下调整
                if (com(_con[parent], _con[child]))
                {
                    swap(_con[parent], _con[child]);
                    parent = child;
                    child = parent * 2 + 1;
                }
                else break;
            }
        }
        void pop() // 出队
        {
            // 根结点与堆尾结点交换再删除,删除后向下调整新的根结点
            swap(_con[0], _con[_con.size()-1]);
            _con.pop_back();
            AdjustDown(0);
        }
        // 获取堆顶元素
        T& top()
        {
            return _con[0];
        }
        const T& top() const
        {
            return _con[0];
        }
        size_t size() const // 返回元素个数
        {
            return _con.size();
        }
        
        bool empty() const // 判空
        {
            return _con.empty();
        }
    private:
        Container _con;
    };
}

代码示例 5.3.2.2

cpp 复制代码
#include <iostream>
using namespace std;
#include "MyPriority_Queue.h"

int main()
{
	// david::priority_queue<int> pq;
    // 默认仿函数Compare为less,建大堆,排降序;传greater建小堆,排升序
	david::priority_queue<int, vector<int>, david::Greater<int>> pq;
	pq.push(4);
	pq.push(1);
	pq.push(5);
	pq.push(8);
	pq.push(2);
	pq.push(6);
	pq.push(7);
	pq.push(3);
	while (!pq.empty())
	{
		cout << pq.top() << " ";
		pq.pop();
	}
	cout << endl;
	return 0;
}

运行结果:

5.3.3 库中实现的 heap

priority_queue 其实就是上述堆算法与 Container 结合组成的适配器。我们也可以单独使用库中这些堆算法。

  • make_heap:建堆(必须支持随机访问,如普通的数组/顺序表,或者vector/deque等容器)。可将指定范围内的数据调整为堆结构,默认建大堆,可传仿函数更改;
  • is_heap:判断范围内的数据是否符合堆结构。符合则返回 true,否则返回 false;
  • push_heap:误区:该函数不插入元素,只负责向上调整算法恢复堆结构。需确保容器中的数据符合堆结构,然后在堆的末尾插入新元素,再调用该函数恢复堆结构。
  • pop_heap:误区:该函数不删除元素,只负责向下调整算法恢复堆结构。需确保容器中存在数据,且符合堆结构。该算法会将根结点交换到末尾,需要手动删除末尾元素。
  • sort_heap:堆排。需确保容器中的待排序数据符合堆结构。

代码示例 5.3.3.1

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

int main()
{
	// 定义数组
	vector<int> array = { 4, 2, 3, 1, 5, 6 };
	// 判断是否符合堆结构
	if (is_heap(array.begin(), array.end())) cout << "array is heap!\n";
	else cout << "array is not heap!\n";

	// 建堆,调整为堆结构
	make_heap(array.begin(), array.end());
	// 再次判断是否符合堆结构
	if (is_heap(array.begin(), array.end())) cout << "array is heap!\n";
	else cout << "array is not heap!\n";

	// 在堆末尾插入新数据 100
	array.push_back(100);
	// 再次判断是否符合堆结构
	if (is_heap(array.begin(), array.end())) cout << "array is heap!\n";
	else cout << "array is not heap!\n";

	// 调用向上调整算法
	push_heap(array.begin(), array.end());
	// 再次判断是否符合堆结构
	if (is_heap(array.begin(), array.end())) cout << "array is heap!\n";
	else cout << "array is not heap!\n";

	// 打印
	for (auto e : array)
	{
		cout << e << " ";
	}
	cout << endl;
	// 删除堆顶元素(默认大堆,删除最大的元素)
	pop_heap(array.begin(), array.end());
	array.pop_back();
	// 再打印
	for (auto e : array)
	{
		cout << e << " ";
	}
	cout << endl;

	// 堆排序
	sort_heap(array.begin(), array.end());
	// 再打印
	for (auto e : array)
	{
		cout << e << " ";
	}
	cout << endl;
	return 0;
}

运行结果:

相关推荐
瑶池酒剑仙1 小时前
C++变量初始化五法完全指南:默认/拷贝/直接/列表/值初始化与窄化转换陷阱(青云之志)
c语言·开发语言·数据结构·c++·算法·visual studio
系统集成-鹰翔1 小时前
ERP接口月度统计少了零单月份:SQLite分组与最近三个月均值排查
javascript·数据库·经验分享·后端
raindayinrain1 小时前
c++对象模型--指针
开发语言·c++
德思特1 小时前
弱网测试入门:为什么你的设备在电梯里会断网?
经验分享·弱网测试
凯凯姜1 小时前
Ollama 本地部署大语言模型笔记
人工智能·笔记·语言模型
流年如夢1 小时前
stack和queue_介绍、用法及模拟实现
c++·stl·queue·stack·priority_queue
ACP广源盛139246256731 小时前
选型评估笔记:面向轻量化边缘 IO 扩展的 PCIe4.0 交换方案 IX8012@ACP
笔记·硬件架构·pcie·hdmi·国产芯片
AA陈超1 小时前
CN-Language中文编程语言:第03篇-语法基础
开发语言·cn·cn中文编程语言·cn-language
熊猫钓鱼>_>1 小时前
CMP for OpenHarmony 实战:Coil 图片加载库鸿蒙化使用心得
运维·服务器·开发语言·华为·ai编程·harmonyos·openharmony