c++ 队列相关知识总结

预备

队列,是一种先进先出(FIFO)的线性表,一般来说会使用链表或者数组来实现它。

队列被允许从后端(rear)插入(insert)新元素,称作入列(push,enqueue);而从前端(front)弹出(pop)元素,称作出列(pop,dequeue)。

学术上说它和堆栈常常被同时提起,因为堆栈与队列几乎一摸一样,除了出栈时也在后端弹出元素,从而构成了后进先出(LIFO)的数据结构。

古典的单向链表/单向队列

单向链表表示的队列,出列时必须遍历整个链表,直至链尾的前一位,然后摘除链尾来实现出列操作。

毫无疑问,这一定是代价很高的。

但这种方案的优势在于任何时候任何人都能够随手写得出来。也就是说,它是最具备可手撸性的一种实现方案,虽然出列代价较高,但对于相当多的场景来说那点代价 CPU 根本不当作是事。

双向链表/双向队列

双向链表的好处是在链表头尾的操作都是 O(1) 的,代价极低,可以视作毫无代价。但这是用额外的两个指针的空间代价来达成的。对于像 std::deque<long> 这样的双向链表来说,每个链表元素的数据部分(payload)需要 8 bytes,而附着在 payload 上的指针则需要 16 bytes(典型的 64bit 计算时),相当于说超出数据实体两倍的代价,不可謂不昂贵。幸而通常的实践场景中很少会有这样的实践需求,往往更多的是一个 payload 的实体 struct 可能需要数百 bytes 甚至更多,这时候 16 bytes 就不算太起眼了。

双向链表结构的手写,难点在于指针的同步修改,正确指向。

对于高频交易场景来说,双向链表的指针维护操作是导致锁开销的典型瓶颈,所以需要下力气研究和解决锁问题。

一般来说在现实世界中大家都是借助于 STL 来实现队列算法及其应用的。典型的例子是通过 deque:

#include <iostream>
#include <deque>

int main()
{
  // 创建容纳整数的 deque
  std::deque<int> d{};

  // 约定 push_back 为入列操作
  d.push_back(13);
  d.push_back(25);
  d.push_back(7);
  d.push_back(2);

  // 约定 pop_front 为出列操作
  auto tmp = d.pop_front();
  std::cout << "FRONT: " << tmp << '\n';

  // 迭代并打印 deque 的值
  for(int n : d) {
    std::cout << n << '\n';
  }
}

得益于 deque 的强健的基础能力,我们实际上可以得到一个工程强度的有效的队列模板类。

同时,deque 也可以被毫无代价地实现强健的 Stack 数据结构,仅仅是将出栈操作从上文的 pop_front 改为 pop_back() 即可。

所以,deque 的强大可见一斑。

不过,无论如何,我们有必要手写一份来完成自己的理解。在下面我们简单地实现了一份理论上的典型队列方案:

template<class T>
 class ti_queue {
  public:
  struct element {
   T _data;
   element *_last;
   element *_next;
  };

  public:
  ti_queue() {
   _head = new element;
   _tail = new element;
   _head->_next = _tail;
   _tail->_last = _head;
  }
  ~ti_queue() {
   auto *p = _head;
   while (p != nullptr) {
    auto *t = p;
    p = p->_next;
    delete t;
   }
  }

  void push(T const &data) {
   auto h = _tail->_last;
   auto *ptr = new element{data, h, _tail};
   _tail->_last = ptr;
   h->_next = ptr;
  }

  T pop() {
   auto p = _head->_next;
   if (p == _tail) {
    return T{};
   }
   p->_last->_next = p->_next;
   p->_next->_last = p->_last;
   T t = p->_data;
   delete p;
   return t;
  }

  bool empty() const { return _tail->_last == _head; }

  T const &peek() const {
   if (empty()) return _tail->_data;
   return _tail->_last->_data;
  }

  private:
  element *_head{};
  element *_tail{};
 };

这是一份不采用智能指针的实现。因为智能指针在这里只有坏处没有好处。然而这就会使得这份实现显得低级,很有趣吧,现代 C++ 在很多时候其实是扭曲了的,它虽然很现代,很时髦,但也很脱了裤子放屁。

在这份实现中有一个重要的约定:我们认为你在提供 T 的具现类型时,你会隐含地约束零值初始化的 T 示例代表着空元素。所以当你 pop/peek 得到一个 T 实例时,如果它是零值将代表着队列为空。

所以我们的测试片段为:

int main() {
 std::cout << '\n' << "queue: ";
 bet::ti_queue<int> tiq;
 tiq.push(31);
 tiq.push(17);
 tiq.push(19);
 tiq.push(73);

 auto tmp = tiq.pop();
 std::cout << tmp;

 while (!tiq.empty()) {
  tmp = tiq.pop();
  std::cout << ',' << tmp;
 }

 std::cout << '\n';
}

其结果应该形如这样:

queue: 31,17,19,73

OK,继续我们的思考线路:

检测队列有否为空很重要,你不可能无代价地避免空访问,因为这是数据结构本身的特性决定的。而在具体实现方面,上面之所以要做出那样的约定,是因为我们没有使用指针方案来存储 T,所以你需要检查 pop/peek 得到的 T 实例是否为空元素。或者,你在 pop/peek() 之前首先采用 empty() 进行队列非空判断。

如果采用指针存储方案的话,你可以顺理成章地检查 pop/peek 返回值是否 nullptr,这其实是 C/C++98 时代的典型实现方案。

如果是在 C++11 之后,尽管我们的实现有上述约定,但你也可以通过给 T 实例化为 std::shared_ptr<Real> 的方式来建立一个智能指针的缓冲层,因此你可以判断该指针的非空性来鉴别返回值的非空性。

借助于共享指针的强力特性,很多时候我们无需在做容器实现时考虑太多的元素如何存储的问题,因为模板的一个重要能力就是透明地引入中间层来提供额外的供给能力。这种时候,在这个例子里,中间层的智能指针包装实际上提供了一种装饰器的设计模式运用机制。

所以,

我们认为,在容器等数据结构等实现部分,如果有可能,你应该负起责来,自行处理 C++/C 指针的各种运算,保证你的实现边界之内没有泄漏问题。这样做的好处在于你的实现将会非常可读可维护,因为它通常能够和理论上的数据结构的实现近似于等价,如同我们上面的实现一样。

然而,如果你的目标是在面向两个目标时则例外了:第一个目标是工程性的高性能线程安全性要求,此时应该加上的锁、优化则取决于具体实现;第二个目标是用户的使用简便性,很显然让用户直接具现他们的 T 而不是 shared_ptr<T> 是绝对有助于用户的使用简便性的,那么你就可能要在精打细算和借助智能指针方面做个选择了。

环形队列

此前,我其实在 Golang 针对高频交易提出了一种无锁环形队列的实现方案,在那里使用了一种程序员直觉化的代码编写方法及直觉化的算法设计思路。事实上我个人偏好于那种直觉化的算法设计思路,我讨厌搞委托交易,像 Spring framework 这样的框架,在做无论什么的集成的时候如同脱了裤子放屁一般的老奶奶裹脚布似的做法,我鄙视之,所以我现在根本没兴趣讨论 Java 的任何事情,这是被我判定了不值得的一种技术体系。

不过,RxJava 的思路是符合我的审美观的。这才是对世界做抽象的正确态度。

那种顽固地拿着 XML 搞出一个庞大的 shit 堆的方法,我只能是表示敬而远之。不但是 Java 的多数玩意如此地垃圾,.NET 画界面 XAML,macOS XCode 画界面的 Storyboard 统统都是毫无疑问的垃圾。任何和 XML 紧密捆绑的东西,没说的,都是在创造垃圾。

那是一种将任何事情复杂化的思维模式,我拒绝的正是如此这般明显愚蠢的模式。

所以我这样的人,一边不爽 Golang 的简陋,一边不也还是投入其怀抱了吗——就因为它能够极低代价的 ELF 化啊。程序员要干的事情就是简化这个世界。创造垃圾这样的事是干不得的。

所以,环形队列在理论研究层面看,实际上就是一个数组,它是固定大小的,而后我们将其首尾相接(通过 put-pointer 和 get-pointer 自动回绕的方式),形成了抽象层面上的环形状态。它的特点在于,当排除了分配每个元素的代价之后,它可以通过自己的固定缓冲区的特点来去除对象入列和出列时的潜在可能的复制开销。

关于这一点,即使是充分利用 C++11 甚至 C++17 的现代语法,在借用 STL 来实现队列的时候,你可能也往往无法避免出列时的复制开销。如果考虑到高频交易场景下想要避免锁代价的影响的话,往往你可能连入列时的额外开销也无法避免——只不过,很多时候锁的代价更高过内存分配已经内存复制的总线级代价,所以我们不得不两害相权取其轻罢了。

为了解决这种特定的问题,一种方案是通过环形队列来避免内存分配问题;另一种则是自行构造自己的队列模板,通过专门的设计和实现来取得内存分配、内存复制与读写访问、CPU Cache 等层面的锁的问题等等的平衡。

再有一种方案,那就简单了:用指针吧。鉴于 C++11 之后用裸指针被认为是跟不上时代的表现,所以:用智能指针吧。借助智能指针的移动语义或共享计数值技术,通常可以较低代价地避免内存反复频繁分配以及内存复制带来的额外开销问题。这种方案是有一点投机取巧,但是语言的基础设施本就应该提供给程序员以这样的能力。反过来思考,如果一个程序员根本经受不了 C 指针的蹂躏的话,他也不配被称作程序员。话说今天这话也很没意思,阿猫阿狗都是程序员的——劣币正在驱逐良币。

应用

由于环形队列是固定大小的,所以它极其适合这样的场景:数据采样工具,或者遥测场景。在数据采样的场景,常常需要的是最最近几十条记录进行数学分析。当使用环形队列来做相应的数据缓冲时,老的数据不断随着插入指针的推进而被覆盖掉,所以在取出指针一侧总是最新的 N 条记录,N 是环形队列的预设大小。于是分析器可以面对这个环形队列顺利地提取到最新 N 条记录用于计算和分析。

除此而外,我们还可以让环形队列的插入行为是非覆盖的,于是当队列满时,插入操作就会在队尾被阻塞,这是另一种环形队列的应用方式。

下几期我将可能会考虑实现一份 C++ 版本的环形队列,但这次可能不会做高频版了,大约只会做适合毫秒级例如典型工业 TCP 交易的版本。

Conclusion

队列这种数据结构,总体上来说非常简单,也不会牵涉到高深的数学证明,所以实现一个队列往往会是初学者上手编程的好题目。

以上就是c++ 队列相关知识总结的详细内容,更多关于c++ 队列的资料请关注我们其它相关文章!

(0)

相关推荐

  • C++用两个栈实现一个队列(面试官的小结)

    前言 两年前从网上看到一道面试题:用两个栈(Stack)实现一个队列(Queue).觉得不错,就经常拿来面试,几年下来,做此题的应该有几十人了.通过对面试者的表现和反应,有一些统计和感受,在此做个小结. 用C++描述,题目大致是这样的: 已知下面Stack类及其3个方法Push.Pop和 Count,请用2个Stack实现Queue类的入队(Enqueue)出队(Dequeue)方法. class Stack { - public: void Push(int x); // Push an el

  • c++11 多线程编程——如何实现线程安全队列

    线程安全队列的接口文件如下: #include <memory> template<typename T> class threadsafe_queue { public: threadsafe_queue(); threadsafe_queue(const threadsafe_queue&); threadsafe_queue& operator=(const threadsafe_queue&) = delete; void push(T new_va

  • c++优先队列(priority_queue)用法详解

    普通的队列是一种先进先出的数据结构,元素在队列尾追加,而从队列头删除. 在优先队列中,元素被赋予优先级.当访问元素时,具有最高优先级的元素最先删除.优先队列具有最高级先出 (first in, largest out)的行为特征. 首先要包含头文件#include<queue>, 他和queue不同的就在于我们可以自定义其中数据的优先级, 让优先级高的排在队列前面,优先出队. 优先队列具有队列的所有特性,包括队列的基本操作,只是在这基础上添加了内部的一个排序,它本质是一个堆实现的. 和队列基本

  • C++实现循环队列

    本文实例为大家分享了C++实现循环队列的具体代码,供大家参考,具体内容如下 circularQueue.h #pragma once #pragma once #ifndef CIRCULARQUEUE_H #define CIRCULARQUEUE_H #include<iostream> #include<ostream> using std::cout; using std::cin; using std::endl; using std::ostream; template

  • 详解c++ libuv工作队列

    1.说明 libuv 提供了一个线程池,可用于运行用户代码,libuv 中的工作队列中的任务会在线程池中执行 libuv 中的线程池在内部用于运行所有文件系统操作以及 getaddrinfo() 和 getnameinfo() 请求 libuv 中的线程池的默认数量为4,可以在启动时修改环境变量 UV_THREADPOOL_SIZE 来修改,最大值为 1024(1.30.0版本之前是128) libuv 中的线程池是全局的,并在所有事件循环之间共享,当特定的函数利用 uv_queue_work(

  • 利用C++如何实现一个阻塞队列详解

    阻塞队列是多线程中常用的数据结构,对于实现多线程之间的数据交换.同步等有很大作用. 阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程.简而言之,阻塞队列是生产者用来存放元素.消费者获取元素的容器. 考虑下,这样一个多线程模型,程序有一个主线程 master 和一些 worker 线程,master 线程负责接收到数据,给 worker 线程分配数据,worker 线程取得一个任务后便可以开始工作,如果没有任务便阻塞住,节约 cpu 资源. mast

  • C++基于消息队列的多线程实现示例代码

    前言 实现消息队列的关键因素是考量不同线程访问消息队列的同步问题.本实现涉及到几个知识点 std::lock_guard 介绍 std::lock_gurad 是 C++11 中定义的模板类.定义如下: template <class Mutex> class lock_guard; lock_guard 对象通常用于管理某个锁(Lock)对象,因此与 Mutex RAII 相关,方便线程对互斥量上锁,即在某个 lock_guard 对象的声明周期内,它所管理的锁对象会一直保持上锁状态:而 l

  • C++基础学习之利用两个栈实现一个队列

    1 .给出类类型如下:有两个成员变量,分别是两个stack容器,存放的元素类型是 int:stack的特点是:先进后出:而队列queue的特点是先进先出:现在用两个 stack容器来实现队列: 实现代码: ------------------------------------- ------------- queue.h --------------- #pragma once #include <iostream> #include <stdlib.h> #include &l

  • C++ 实现优先队列的简单实例

    C++ 实现优先队列的简单实例 优先队列类模版实现: BuildMaxHeap.h头文件: #include<iostream> using namespace std; #define Left(i) i*2+1 #define Right(i) i*2+2 #define Parent(i) (i-1)/2 void Max_Heapify(int a[],int length,int i) { int left,right; left=Left(i); right=Right(i); i

  • c++优先队列用法知识点总结

    c++优先队列用法详解 优先队列也是队列这种数据结构的一种.它的操作不仅局限于队列的先进先出,可以按逻辑(按最大值或者最小值等出队列). 普通的队列是一种先进先出的数据结构,元素在队列尾追加,而从队列头删除. 在优先队列中,元素被赋予优先级.当访问元素时,具有最高优先级的元素最先删除.优先队列具有最高级先出 (first in, largest out)的行为特征. 首先要包含头文件#include<queue>, 他和queue不同的就在于我们可以自定义其中数据的优先级, 让优先级高的排在队

随机推荐