news 2026/10/2 14:11:50

C++循环队列

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++循环队列

前言

"循环队列"(circular queue,也叫环形缓冲区 ring buffer)是数据结构课上必讲的一个结构:用一个固定大小的数组当队列,front和tail走到数组末尾就绕回开头,从而复用被pop释放出来的空间。标题把它写成"C++ 循环队列",需要先说清一件事:循环队列不是 C++ 标准库里的组件。标准库提供的队列是<queue>里的std::queue,它是一个容器适配器(container adapter),底层默认用std::deque实现,它的扩容行为是"动态增长",不是"循环复用固定数组"。

这两个东西的取舍是:std::queue写起来最省事、能无限增长;循环队列容量固定、内存不增长、没有动态分配,适合嵌入式、音视频缓冲、生产者-消费者这类"缓冲区大小必须可控"的场景。

本文讲三件事:循环队列的判空判满有哪几种做法、C++ 里怎么写出一个正确可编译的版本、以及它和std::queue、std::deque的边界在哪。代码以 C++17 为基准。

一、循环队列的核心:怎么区分"空"和"满"

用数组实现队列,front指向队头元素,tail指向队尾元素的下一个位置(或者队尾元素本身,两种约定都有人用)。入队时写buf[tail]然后tail = (tail + 1) % capacity。问题来了:队列空的时候front == tail,队列满的时候如果元素占满了整个数组,front也会等于tail。同一个条件对应两种状态,没法区分。

业界有三种解决办法:

方案判空判满可用容量说明
浪费一个槽位front == tail(tail + 1) % cap == frontcap - 1实现最简单,代价是永远少存一个元素
维护计数器count == 0count == capcap空间全用上,每次操作多维护一个变量
加一个标志位front == tail && !fullfront == tail && fullcap省空间,但每个分支都要记得同步标志,容易忘

本文选计数器方案:它的不变式(invariant)最容易验证——count就是元素个数,判空判满都是直接看它,没有"少用一个格子"的心理负担,也不像标志位那样容易忘记更新。

二、用 std::vector 做底的模板实现

// C++17,单文件:g++ -std=c++17 -Wall -Wextra circular_queue.cpp #include <cstddef> #include <iostream> #include <stdexcept> #include <string> #include <utility> #include <vector> template <class T> class CircularQueue { public: explicit CircularQueue(std::size_t capacity) : buf_(capacity), head_(0), tail_(0), count_(0) { if (capacity == 0) { throw std::invalid_argument("CircularQueue: capacity must be > 0"); } } std::size_t size() const { return count_; } std::size_t capacity() const { return buf_.size(); } bool empty() const { return count_ == 0; } bool full() const { return count_ == buf_.size(); } // 入队:满了返回 false,调用方决定怎么办(丢弃 / 阻塞 / 覆盖) bool push(const T& value) { if (full()) return false; buf_[tail_] = value; tail_ = next(tail_); ++count_; return true; } // 移动版:避免 T 的拷贝构造被调用 bool push(T&& value) { if (full()) return false; buf_[tail_] = std::move(value); tail_ = next(tail_); ++count_; return true; } // 出队到自己提供的变量里,成功返回 true bool pop(T& out) { if (empty()) return false; out = std::move(buf_[head_]); head_ = next(head_); --count_; return true; } // 看一眼队头(不出队) const T& front() const { if (empty()) throw std::out_of_range("CircularQueue::front on empty queue"); return buf_[head_]; } const T& back() const { if (empty()) throw std::out_of_range("CircularQueue::back on empty queue"); // tail_ 指向"下一个要写的位置",往回退一格就是队尾,注意绕回 return buf_[(tail_ + buf_.size() - 1) % buf_.size()]; } void clear() { head_ = tail_ = count_ = 0; } private: std::size_t next(std::size_t i) const { return (i + 1 == buf_.size()) ? 0 : i + 1; // 条件加法比取模快,且无溢出 } std::vector<T> buf_; std::size_t head_; // 队头元素下标 std::size_t tail_; // 下一个可写位置(等价于队尾元素下标 +1) std::size_t count_; // 当前元素个数 }; int main() { CircularQueue<int> q(4); // 容量 4,可存 4 个(计数器方案不浪费槽位) std::cout << std::boolalpha << "empty=" << q.empty() << '\n'; for (int i = 1; i <= 5; ++i) { std::cout << "push(" << i << ") -> " << q.push(i) << '\n'; } std::cout << "size=" << q.size() << " full=" << q.full() << '\n'; std::cout << "front=" << q.front() << " back=" << q.back() << '\n'; int v = 0; while (q.pop(v)) std::cout << "pop -> " << v << '\n'; // 绕回验证:反复填满再清空,检查下标是否正常循环 for (int round = 0; round < 3; ++round) { for (int i = 0; i < 4; ++i) q.push(round * 10 + i); int x = 0; while (q.pop(x)) std::cout << x << ' '; std::cout << '\n'; } return 0; }

几个实现上的选择值得说明:

next()用条件加法而不是取模。(i + 1) % N语义正确,但整数取模在多数硬件上是十几到几十个周期的除法指令,而i + 1 == N ? 0 : i + 1只是一个比较加一个加法。当 N 是 2 的幂时,编译器通常会把% N优化成位与,但如果 N 是运行期决定的(比如构造函数传入),它就不会做这个优化。这里只是说清原理,具体快多少请以你在目标平台上的实测为准。另外要注意:只有条件写法能彻底避免i + 1的溢出问题——i最大是buf_.size() - 1,i + 1最多等于buf_.size(),不会溢出;而如果换成一个接近SIZE_MAX的下标,(i + 1) % N里的i + 1就可能回绕。在这个类里i永不超过buf_.size(),所以两种写法都安全。

push返回bool而不是抛异常。队列满是一个预期内的正常状况,不是异常情况。返回bool把决策权交给调用方:视频流可以丢弃旧的帧,网络协议栈可以让发送方重试,实时系统可以计数丢弃率。这也和标准库的std::queue::push不同——后者永远成功(底层容器会扩容)。

push(T&&)与push(const T&)两个重载。这是 C++11 之后写容器的标准做法:右值走移动,左值走拷贝。注意buf_[tail_] = std::move(value)要求T可移动赋值;对只有拷贝的类型,std::move会退回到拷贝,不会编译失败(前提是拷贝赋值存在)。

back()里的(tail_ + buf_.size() - 1) % buf_.size()。不能写成(tail_ - 1) % size:tail_是无符号的std::size_t,当tail_ == 0时tail_ - 1会下溢成SIZE_MAX,SIZE_MAX % 4得到的不是 3。先加size再减 1 就不会下溢。无符号下溢本身不是 UB(标准规定按 2 的幂取模),但结果完全不是你要的。

三、和 std::queue 的对比

std::queue是容器适配器,声明形式(以标准为准)是template<class T, class Container = std::deque<T>> class queue。它的接口是:

成员函数语义
push(const T&)/push(T&&)在队尾追加
emplace(args...)在队尾原地构造
pop()移除队头元素,返回 void,不返回被移除的值
front()/back()访问队头 / 队尾元素(引用),空队列上调用是 UB
empty()/size()判空 / 元素个数

和本文的循环队列对比:

维度std::queue(默认基于std::deque)循环队列(固定数组)
容量动态增长,理论上不限固定,构造时确定
内存分配随着增长会有新的分配(具体策略由实现决定)构造时一次分配,此后不再分配
满时行为不会满,push总是成功由你决定:丢弃、拒绝、覆盖
复杂度push/pop均摊 O(1)严格 O(1),没有均摊的尾巴
迭代适配器不提供迭代器同样不提供(要遍历得自己暴露接口)
典型场景一般业务队列嵌入式、音频缓冲、无锁队列的前身

std::queue::pop()不返回元素,这是初学者最常抱怨的一点:必须front()和pop()分两步,中间还可能因为异常导致元素丢失。循环队列可以顺手提供"出队并返回"的接口,这也是它在实际工程里更顺手的原因之一。但要注意,pop()里的out = std::move(buf_[head_])如果T的移动赋值抛异常,队列状态就会不一致——真实工程里要么要求T的移动构造/赋值是noexcept(这也是std::vector扩容时的判断标准),要么把这一步改成"先移动构造到临时对象,成功后再推进head_"。

四、要不要"满时覆盖"的变体

环形缓冲在音视频和日志场景里常见的变体是"满了就覆盖最旧的":

// C++17 // 满时覆盖最旧元素,永远不失败 void push_overwrite(const T& value) { if (full()) { head_ = next(head_); // 挤掉队头,count_ 不变 } else { ++count_; } buf_[tail_] = value; tail_ = next(tail_); }

这个变体的语义是"只保留最近 N 个"。它比push更容易写错,因为head_、tail_、count_三者的更新顺序在"满"和"不满"两条路径里不一样。写完一定要用"反复填满再清空、检查输出顺序"的循环测一遍——本文main里的 round 循环就是这个用途。

常见坑点

坑 1:用size()当"元素个数",但它其实是容量。

❌ 写了buf_.size()来表示队内元素个数,于是"队列有多少元素"和"最多能装多少"混在一起。

✅ 分成两个名字:capacity()返回buf_.size(),size()返回count_。这是标准库容器的命名约定(std::vector也是如此),照抄过来不容易错。

坑 2:无符号下标减 1。

❌buf_[(tail_ - 1) % buf_.size()]——tail_ == 0时tail_ - 1下溢成SIZE_MAX。

✅buf_[(tail_ + buf_.size() - 1) % buf_.size()],先加后减。

坑 3:std::queue的pop()以为返回值。

❌int v = q.pop();——std::queue::pop()返回void,这是编译错误。

✅

int v = q.front(); q.pop();

坑 4:在空队列上调用front()/back()。

❌std::queue::front()在空队列上是 UB,标准不保证任何行为(不是抛异常)。

✅ 先if (!q.empty())。自定义的循环队列则可以在front()里抛std::out_of_range,把 UB 变成可捕获的错误。

坑 5:以为循环队列天然线程安全。

❌ 一个线程push、另一个线程pop,不加锁,认为"下标操作是原子的所以没问题"。

✅ 不加同步的生产者-消费者循环队列需要仔细的内存序设计(std::atomic的 acquire/release 语义,或者std::memory_order_acquire/release配对)。朴素的std::size_t读写在这个场景下是数据竞争,是 UB。先用std::mutex把它们包起来是对的起点。

坑 6:把"满"当成异常往上抛。

❌ 在push里对满队列throw std::runtime_error("queue full")——在高频路径上抛异常代价高,而且让调用方必须写 try/catch 才能处理一个常规状况。

✅ 返回bool,或者用"覆盖最旧"的策略,把控制流交还给调用方。

坑 7:容量传 0。

❌CircularQueue<int> q(0);然后next()里做% 0——除零是 UB(整数除零在多数平台上直接触发硬件异常)。

✅ 构造函数里检查capacity == 0并抛std::invalid_argument;或者规定容量最小为 1。

坑 8:以为可以用std::queue的底层容器换成固定数组来得到循环队列。

❌std::queue<int, std::array<int, 8>> q;——std::array没有push_back/pop_front,编译不过。

✅std::queue的底层容器必须满足序列容器的要求(有back、push_back、pop_front等),std::array不是。要固定容量且不分配,就得自己写环形缓冲,或者用第三方库的boost::circular_buffer(那是 Boost,不是标准库)。

总结

要点结论
判空判满三种方案:浪费一个槽、计数器、标志位。计数器最易懂,容量也不浪费
next的写法i + 1 == N ? 0 : i + 1,比取模少一条除法,且天然无溢出
下标减法无符号下标的减法必须"先加后减",否则下溢
满队列的语义返回bool或覆盖最旧,别抛异常;决策权交给调用方
与std::queue的分工要"无限增长、写得省事"用std::queue;要"容量固定、零分配、严格 O(1)"用循环队列
线程安全朴素实现不是线程安全的,并发访问是数据竞争(UB);用锁或std::atomic明确内存序


循环队列本身的算法只值二十行代码,真正花时间的是那些边界:无符号下溢、容量为零、满队列的策略选择、以及"到底哪个下标指向有效元素"这个必须写进注释的约定。把不变式(count_恒等于有效元素个数,tail_恒指下一个可写位置)写在类的注释里,后面加接口(比如"满时覆盖"的变体)时就不容易把三条不变式拆散。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 14:11:50

MZGantt 1.0.18实战:轻量原生JS甘特图如何赋能生产排程

直接开工做前端的时间长了&#xff0c;你会发现一个尴尬的现状&#xff1a;一说起甘特图&#xff0c;大家条件反射就是jQuery时代的旧插件&#xff0c;或者一上来就拽着你引入React、Vue全家桶&#xff0c;眉头都不皱一下。但真要落到具体业务——比如我最近在搞的生产排程看板…

作者头像 李华
网站建设 2026/10/2 14:11:27

OpenCV三子棋视觉检测:棋盘网格透视校正与HSV棋子识别实战

简介&#xff1a;面向2024年全国大学生电子设计竞赛E题的OpenCV视觉检测源码包&#xff0c;专注于三子棋棋盘与棋子的高鲁棒性识别&#xff0c;适合参赛学生、计算机相关专业学习者用于赛题复现、课程设计或毕业设计。压缩包共31个文件&#xff0c;以15个Python脚本为核心&…

作者头像 李华
网站建设 2026/10/2 14:11:16

SWE智能体训练:从静态基准到环境生成的闭环突破

如果你也在做 SWE&#xff08;Software Engineering&#xff09;智能体研发&#xff0c;一定经历过这种尴尬&#xff1a;模型在 SWE-bench 验证集上明明刷到了不错的分数&#xff0c;换个真实仓库、换个框架版本&#xff0c;立刻原形毕露。你第一反应是模型不行&#xff0c;但调…

作者头像 李华
网站建设 2026/10/2 14:10:35

MATLAB图像与音频隐写系统实战:LSB嵌入、密钥恢复与工程化实现

做图像处理相关课题时&#xff0c;我经常遇到一个理解上的偏差&#xff1a;很多人把“信息隐藏”直接等同于加密。但加密和隐写本质上是两码事——加密让秘密信息变得不可读&#xff0c;旁观者一眼就能看出“这里有密文”&#xff1b;隐写则恰恰相反&#xff0c;它要让秘密信息…

作者头像 李华
网站建设 2026/10/2 14:10:35

BOLT-LMM:十万级样本GWAS高效关联分析的原理与实战

如果你手头的数据已经大到需要为“跑完一次GWAS要几天”发愁&#xff0c;BOLT-LMM就是那种能把时间压缩到几小时的工具。它由Broad Institute团队开发&#xff0c;专门面向几十万样本规模的混合模型关联分析。我最早是在一个约35万样本的队列里遇到性能问题的&#xff0c;当时对…

作者头像 李华
网站建设 2026/10/2 14:09:41

Redis启动与停止全攻略:从Windows到Linux再到Docker的实操避坑指南

前阵子有个刚入行的朋友问我&#xff1a;Redis装好了&#xff0c;点了启动&#xff0c;窗口一闪就没了&#xff0c;到底怎么才算启动成功&#xff1f;说实话&#xff0c;这个问题听起来特别基础&#xff0c;但我在各种群里、社区里看到问的人真不少。启动和停止这两个动作&…

作者头像 李华