先说一个现象:很多人学C++学到STL的时候,对std::stack和std::queue的用法倒背如流,push、pop、top、size张口就来,但一旦被人问起“这两个容器为什么没有rbegin()和rend()”或者“反向迭代器到底是怎么实现出来的”,当场就会卡壳。这节加餐课正好把这两件事放在一起讲清楚——stack_queue这类容器适配器的底层设计,以及反向迭代器(reverse_iterator)这个看上去不起眼、实际上把很多人绕晕的迭代器适配器。搞懂这两个点,你再看STL源码里的很多设计,都会有种“原来如此”的感觉。这篇文章适合已经能熟练使用STL、但想进一步理解容器底层结构和迭代器原理的读者,也适合正在准备C/C++方向面试的人,因为这两个知识点在面试里出现的频率比你想的高得多。
1. 先把stack_queue的底裤扒干净:容器适配器到底适配了什么
1.1 为什么说stack不是容器,而是“集装箱规则”
很多人第一次听到“stack是容器适配器(container adapter)”这个说法时都是一头雾水:我明明在用std::stack<int>,这不就是个容器吗?为什么说它不是容器?
关键在于“适配器”这个词。容器适配器本身并不持有数据,它只是把一个已经存在的底层容器包装起来,对外只暴露一组受限制的接口。类比一下就是:std::deque<int>是一堆货物,std::stack<int>是集装箱,集装箱规定了货物的进出顺序只能是“后进先出”,但货物本身还是放在原来的仓库里。
从<stack>头文件里扒出来的实现大概是这样的:
cpp复制template <class T, class Container = std::deque<T>>
class stack {
protected:
Container c; // 底层容器,数据全存在这里
public:
bool empty() const { return c.empty(); }
size_type size() const { return c.size(); }
reference top() { return c.back(); }
void push(const value_type& x) { c.push_back(x); }
void pop() { c.pop_back(); }
};
看到没有,stack自己几乎没有任何数据成员,所有数据都存放在那个Container c里面。stack做的只是在front/back/push_front/push_back这一堆底层容器的通用操作里,挑出back、push_back、pop_back这几个来用,并且改个更贴切的名字:top、push、pop。这就是适配器模式:改变接口,但不改变底层能力。
1.2 为什么默认底层是deque,而不是vector或者list
这是我在带新人时被问得最多的问题之一。std::stack默认底层是std::deque,但std::vector明明也能提供push_back、pop_back和back操作,为什么不用vector?
答案有三个层面:
第一,deque的头尾插入删除都是常数时间。vector只有在尾部插入删除是均摊O(1),如果在头部操作就是O(n)。虽然stack只操作尾部,看起来vector够用了,但deque还有一个优势是它的头部操作也是O(1),这让deque在“既要当stack用,又要当queue用”的场景下更灵活。
第二,deque的存储结构与vector不同。vector是一块连续内存,满容量时扩容需要搬移大量元素;deque是分段连续的,通过中控器(map)管理多个缓冲块,扩容时不需要搬移已有元素,代价是内存占用略高、迭代器结构更复杂。
第三,从标准委员会的角度看,选deque而不是vector作为默认,是为了同时兼顾stack和queue。如果stack默认用vector、queue默认用list,接口虽然都能跑通,但性能特征和内存行为会很不一致,使用者还得额外关注底层类型差异。
注意一个细节:std::queue的默认底层也是deque。为什么queue不用vector?因为queue需要pop_front,也就是从头部删除元素。vector头删是O(n),deque头删是O(1),所以vector当不了queue的底层。而std::priority_queue的默认底层是vector,因为它需要随机访问来配合堆算法(push_heap/pop_heap)。
1.3 底层容器可以换,换完之后行为差异很大
模板参数Container是可以指定的,这一点很多初学者不知道。比如:
cpp复制std::stack<int, std::vector<int>> s1;
std::stack<int, std::deque<int>> s2;
std::stack<int, std::list<int>> s3;
std::queue<int, std::list<int>> q1;
std::queue<int, std::deque<int>> q2;
换个底层容器,不只是性能变了,连迭代器失效规则都会变。vector的尾部插入可能会导致迭代器失效(扩容时全部失效),而list的插入永远不失效任何已有迭代器。如果你在写代码时混用了底层容器的迭代器,这个区别是致命的。
在实践中,我见过有人为了“更快”把stack底层换成vector,结果在一段高并发代码里因为vector扩容导致迭代器失效,查了整整一天才定位到。要换底层容器可以,但一定要清楚这个容器的迭代器失效规则。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 反向迭代器:一个把我们“骗”得团团转的适配器
2.1 正向迭代器和反向迭代器的对应关系
先说结论:反向迭代器(reverse_iterator)也是一个适配器,它内部包着一个正向迭代器,然后把“自增”变成“自减”,把“自减”变成“自增”。
拿std::vector<int> v = {1, 2, 3, 4, 5}举例:
cpp复制auto it = v.begin(); // 指向1
auto rit = v.rbegin(); // 指向5
auto rit2 = v.rend(); // 指向1之前的虚拟位置
对应关系是这样的:
| 正向操作 | 反向操作 | 说明 |
|---|---|---|
begin() |
rend() |
区间的“开头” |
end() |
rbegin() |
区间的“结尾” |
++it |
++rit(内部是 --base) |
向前走 |
--it |
--rit(内部是 ++base) |
向后走 |
*it |
*rit(内部是 *(base-1)) |
取当前位置元素 |
关键点在于:反向迭代器保存的current并不是它逻辑上指向的元素,而是“当前位置的下一个正向位置”。这个设计让反向区间和正向区间一样满足左闭右开的语义,代价就是你在用反向迭代器解引用时,访问的其实是*(current - 1),不是*current。这是反向迭代器最容易被绕晕的地方。
2.2 源码级别的reverse_iterator实现
<iterator>头文件里std::reverse_iterator的核心实现逻辑,剥离掉各种typedef和constexpr后大概是下面这样:
cpp复制template <class Iterator>
class reverse_iterator {
protected:
Iterator current; // 内部保存的是一个正向迭代器
public:
using iterator_type = Iterator;
using reference = typename std::iterator_traits<Iterator>::reference;
reverse_iterator() : current() {}
explicit reverse_iterator(iterator_type it) : current(it) {}
// base()返回内部的正向迭代器
iterator_type base() const { return current; }
// 解引用:访问的是 current 的前一个位置
reference operator*() const {
Iterator tmp = current;
return *--tmp;
}
// 自增 = 内部正向迭代器自减
reverse_iterator& operator++() {
--current;
return *this;
}
// 自减 = 内部正向迭代器自增
reverse_iterator& operator--() {
++current;
return *this;
}
};
这段代码讲透了,整个反向迭代器就没有秘密了。你自己实现一个反向迭代器,核心也就是这五个函数:构造、base、解引用、自增、自减。
很多人问:为什么不直接让reverse_iterator保存“当前元素的正向位置”,这样*rit直接返回*current不更直观吗?
原因是左闭右开区间的一致性。正向区间是[begin, end),负负得正的镜像之后,反向区间如果是(rbegin, rend],代码里处理区间边界时又要单独判断“开区间”和“闭区间”,各种算法(比如std::rotate、std::reverse、二分查找)的边界条件都会变得混乱。保留“保存后一个位置”的设计,能让反向区间在算法推导时跟正向区间保持完全对称。
2.3 为什么base()偏移1,以及erase的经典陷阱
理解了解引用的偏移,就能解释一个经典坑:用反向迭代器去erase一个元素,删掉的跟你想的不是同一个。
cpp复制std::vector<int> v{1, 2, 3, 4, 5};
// 我想删除4
auto rit = v.rbegin() + 1; // 反向迭代,rbegin指向5,rbegin()+1指向4
v.erase(rit.base()); // 结果删掉了5!
为什么?因为rit.base()返回的是内部正向迭代器current,而rit逻辑上指向的是*(current - 1)。当rit指向4时,它的base()指向的是5所在的位置,erase就用正向迭代器删掉了5。
正确写法是:
cpp复制auto rit = v.rbegin() + 1; // 指向4
v.erase((rit + 1).base()); // rit+1指向3,其base()指向4,删除4
或者先转一步:
cpp复制auto it = v.rbegin() + 1; // 指向4
v.erase(std::next(it).base()); // std::next(it)指向3,base()指向4
这种偏移关系在面试里非常经典,问法一般是“v.erase(v.rbegin().base())会删除哪个元素?”答案是删除v.end()指向的元素之前那个,也就是最后一个元素,因为rbegin().base() == end()。这些细节,背十个八股文不如亲手跑一次代码记得牢。
3. 问题来了:为什么stack和queue没有反向迭代器
3.1 接口设计:stack和queue故意不给你begin()
很多人的第一反应是:既然反向迭代器这么有用,那std::stack和std::queue为什么不在内部提供一个rbegin()和rend()?
因为从数据结构语义上讲,栈和队列的核心约束就是“只能访问端点”。栈只允许访问栈顶,队列只允许访问队头和队尾。一旦对外暴露迭代器,用户就可以随便遍历中间的每一个元素,甚至可以修改中间的元素——那这个“栈”就不叫栈了,它退化成了一个可以任意读取的数组。
容器适配器的思想就是:宁可少给你功能,也要保证你不会在不经意间破坏数据结构的语义。所以std::stack和std::queue不仅没有反向迭代器,连正向迭代器都没有。它们只暴露top()、front()、back()这类端点访问接口,从设计上就把“遍历”这个操作堵死了。
3.2 如果面试官问“怎么给stack加一个反向遍历”
我曾在一个面试里被问到过这个问题,现在也经常拿来问新人。先说答案:不要通过继承std::stack来做。
因为STL容器都没有虚析构函数,继承std::stack后,如果通过基类指针删除派生类对象,行为是未定义的。这不是“可能出问题”,而是法律条文式的UB,没人知道会发生什么。
更合理的做法是组合。自己写一个类,内部持有一个底层容器,对外只暴露栈的接口,外加begin/end/rbegin/rend这几个迭代器接口。这样既保留了栈的push/pop/top语义,又能在需要时遍历所有元素。
3.3 不要滥用:遍历栈本身就说明设计可能有问题
话说回来,如果你的代码里出现了“把stack里的元素全部遍历一遍”的需求,先停下来想想:这真的是栈吗?很多情况下,使用者把一个中间结果数组当成了栈,但业务逻辑其实需要随机访问,这时候直接用std::vector或std::deque更合理。
stack和queue的定位是“受限制的接口”,而不是“高性能的容器”。如果你发现自己在频繁遍历栈、频繁从栈底部取数据,这个数据结构的选择大概率有问题,别硬用容器适配器。
4. 实操:动手写一个支持反向遍历的受限栈
4.1 基于组合的iterable_stack完整实现
下面这个类我实际在项目里用过,是在调试阶段为了快速查看栈内容写的。它复用deque作为底层容器,对外暴露迭代器接口,同时保留stack的语义:
cpp复制#include <deque>
#include <iostream>
#include <iterator>
template <typename T, typename Container = std::deque<T>>
class iterable_stack {
public:
// 类型别名,方便使用
using container_type = Container;
using value_type = typename Container::value_type;
using size_type = typename Container::size_type;
using reference = typename Container::reference;
using const_reference = typename Container::const_reference;
using iterator = typename Container::iterator;
using const_iterator = typename Container::const_iterator;
using reverse_iterator = typename Container::reverse_iterator;
using const_reverse_iterator = typename Container::const_reverse_iterator;
// 迭代器接口
iterator begin() { return c.begin(); }
iterator end() { return c.end(); }
const_iterator begin() const { return c.begin(); }
const_iterator end() const { return c.end(); }
reverse_iterator rbegin() { return c.rbegin(); }
reverse_iterator rend() { return c.rend(); }
const_reverse_iterator rbegin() const { return c.rbegin(); }
const_reverse_iterator rend() const { return c.rend(); }
// 栈的标准语义
bool empty() const { return c.empty(); }
size_type size() const { return c.size(); }
void push(const value_type& x) { c.push_back(x); }
void pop() { c.pop_back(); }
reference top() { return c.back(); }
const_reference top() const { return c.back(); }
private:
Container c;
};
int main() {
iterable_stack<int> st;
st.push(5);
st.push(2);
st.push(7);
std::cout << "反向遍历: ";
for (auto rit = st.rbegin(); rit != st.rend(); ++rit) {
std::cout << *rit << " ";
}
std::cout << "\n";
std::cout << "正向遍历: ";
for (auto it = st.begin(); it != st.end(); ++it) {
std::cout << *it << " ";
}
std::cout << "\n";
st.pop(); // 弹出7
std::cout << "栈顶: " << st.top() << "\n"; // 输出2
return 0;
}
输出结果:
text复制反向遍历: 7 2 5
正向遍历: 5 2 7
栈顶: 2
核心思路是:c作为私有成员,对外只暴露push/pop/top这些受限操作,因此外部无法用pop_front等方式破坏栈的语义;同时begin/rbegin这些只读和遍历能力被放开了。这是一个兼顾“约束”和“可控开放”的组合方案。
4.2 用反向迭代器解决“从栈顶往栈底看”的实际场景
单调栈是stack最常见的算法场景之一,经典题目比如“每日温度”“接雨水”。这类题的通用解法是维护一个单调递减(或递增)的栈,栈里存的是元素下标。
写一个“每日温度”的简版:
cpp复制#include <vector>
#include <stack>
std::vector<int> dailyTemperatures(std::vector<int>& temps) {
int n = temps.size();
std::vector<int> ans(n, 0);
std::stack<int> st;
for (int i = 0; i < n; ++i) {
while (!st.empty() && temps[i] > temps[st.top()]) {
int idx = st.top();
st.pop();
ans[idx] = i - idx;
}
st.push(i);
}
return ans;
}
这个栈在任意时刻,从栈底到栈顶的温度是严格递减的。你如果想确认“当前栈底到栈顶的温度趋势”,用std::stack是没法一眼看出来的,但如果你用的是上面这个iterable_stack,直接反向遍历就能从栈顶看到栈底,调试时特别方便:
cpp复制std::cout << "栈顶到栈底: ";
for (auto rit = st.rbegin(); rit != st.rend(); ++rit) {
std::cout << *rit << " "; // 下标对应的温度严格递增或递减
}
std::cout << "\n";
这种场景并不是要求栈支持遍历,而是在调试阶段需要“观察栈的内部状态”。给栈额外开一个遍历口子,代价低、收益明确。
4.3 const版本与const_reverse_iterator的坑
在写带迭代器的自研容器时,最容易漏掉的就是const版本。你如果不提供begin() const和rbegin() const,那么对一个const iterable_stack<int>调用rbegin()就会编译失败。
上面代码里我同时提供了普通版本和const版本,const_reverse_iterator的类型也一并搞定:
cpp复制const_reverse_iterator rbegin() const { return c.rbegin(); }
const_reverse_iterator rend() const { return c.rend(); }
有个细节值得注意:c.rbegin()在const对象上返回的是const_reverse_iterator,在非const对象上返回的是reverse_iterator。C++的重载规则会帮你自动选对版本,所以你只需要在类里写两个重载就够了。
我在实际写代码时还踩过一个坑:误以为const_reverse_iterator是“迭代器本身const”,其实它指的是“迭代器指向的元素const”,跟const iterator完全是两回事。后者是“迭代器本身不能改变指向”,基本没人这么用。
5. 常见问题与排查技巧实录
5.1 反向迭代器自增自减到底走向哪里
有个很基础的但很多人会搞混的问题:++rbegin()之后,*it指向的是哪个元素?
cpp复制std::vector<int> v{10, 20, 30, 40};
auto rit = v.rbegin(); // 指向40
++rit; // 指向30
--rit; // 回到40
因为反向迭代器的++内部是正向迭代器的--,所以方向一定是“从尾部向头部走”。这个一旦记混,写循环的时候要么死循环,要么越界。我的习惯是每次写反向遍历前都在心里默念一句:“反向的++,是正向的--。”
5.2 为什么erase反向迭代器时会崩溃
崩溃的本质是“迭代器失效”或者“erase了一个不属于正向迭代器管辖的迭代器”。
std::vector::erase只接受正向迭代器,不接受反向迭代器,你传一个reverse_iterator进去编译器就会报错。如果你调用rit.base()去转正,又往往会犯偏移1的错误,删错元素。我在第2.3节已经写了一个完整的案例,这里再补一个更隐蔽的场景:
cpp复制std::vector<int> v{1, 2, 3, 4, 5};
// 想删除所有小于5的元素
for (auto it = v.rbegin(); it != v.rend(); ) {
if (*it < 5) {
it = std::make_reverse_iterator(v.erase((it + 1).base()));
// 注意erase返回的是正向迭代器,要重新包装成反向迭代器
} else {
++it;
}
}
这个写法虽然能跑,但可读性极差。我的建议是:需要边遍历边删除时,别用反向迭代器,改用正向索引或者直接使用std::remove_if。反向迭代器适合只读遍历,不适合做修改删除的循环。
5.3 用底层容器打印stack内容的调试技巧
调试std::stack的时候,里面存的临时数据看不见,又不能直接遍历。我见过很多人的做法是:
cpp复制std::stack<int> st;
// ...
while (!st.empty()) {
std::cout << st.top() << " ";
st.pop();
}
这种方法把栈弹空了,调试完还得重新构造数据,非常浪费时间。更好的做法是:拷贝一个栈出来再弹,原栈不被破坏:
cpp复制auto tmp = st; // 拷贝构造,O(n)开销但调试无所谓
while (!tmp.empty()) {
std::cout << tmp.top() << " ";
tmp.pop();
}
std::stack的拷贝是合法的,因为底层容器deque支持拷贝。如果T类型本身可拷贝,这就是最简单的调试方式。
5.4 性能对比:反向迭代器比正向迭代器慢吗
严格来说,反向迭代器的operator*里有一次--再解引用的操作,看起来比正向迭代器多一步。但在开启编译优化(-O2)之后,这一步通常会被优化掉,最终的机器码和正向迭代器访问几乎一致。
真正有性能差异的是在循环里反复调用base()再操作,或者用反向迭代器做随机访问(rit + n需要做一次额外的位置换算)。我实测过用反向迭代器遍历一个千万级vector,跟正向遍历的耗时差距在误差范围内,所以不用担心这个性能问题。你更应该关注的是算法本身的时间复杂度,而不是纠缠迭代器方向。
5.5 开发环境里顺手排查的小技巧
如果你的开发环境是VSCode加C/C++插件,调试时可以在监视窗口里同时添加rit和rit.base(),观察它们的变化。你会发现当rit指向元素3时,rit.base()指向的元素是4,这样就能直观理解那个偏移1的设计。很多新人看完这个监视面板,比看十段文字讲解都管用。
另外,如果你用std::reverse_iterator直接包装一个自定义迭代器,记得检查iterator_traits是否完整,比如difference_type、value_type这些类型别名是否都定义好了。少了任何一个,搭配算法库使用时会编译出一长串看不懂的模板报错,这个问题我遇到过不止一次。
最后补一句实操体会
我自己的经验是,stack_queue和反向迭代器这对组合,价值不在“怎么用”,而在“为什么这么设计”。反向迭代器的偏移1是STL左闭右开思想的自然延伸,容器适配器的受限接口是数据结构语义的硬约束。这些设计原则一旦理解透,你再看priority_queue为什么没有迭代器、再看deque为什么适合当默认底层,基本都能自己做判断。今天这篇加餐课如果能把这两个点讲清楚,那这些时间就没有白花。
