文章目录
-
- 一、容器适配器总述
-
- [1.1 适配器到底适配了什么](#1.1 适配器到底适配了什么)
- [1.2 整体结构:两个类,各持有一个容器](#1.2 整体结构:两个类,各持有一个容器)
- [二、stack 的实现与原理:后进先出](#二、stack 的实现与原理:后进先出)
- [三、queue 的实现与原理:先进先出](#三、queue 的实现与原理:先进先出)
- [四、priority_queue 的实现与原理:堆与仿函数](#四、priority_queue 的实现与原理:堆与仿函数)
-
- [4.1 一句话说清仿函数](#4.1 一句话说清仿函数)
- [4.2 从一个写死的比较说起](#4.2 从一个写死的比较说起)
- [4.3 手写 Less 与标准库 std::less](#4.3 手写 Less 与标准库 std::less)
- [4.4 STL 算法里的可调用对象](#4.4 STL 算法里的可调用对象)
- [4.5 概念与三个模板参数](#4.5 概念与三个模板参数)
- [4.6 堆是数组存起来的完全二叉树](#4.6 堆是数组存起来的完全二叉树)
- [4.7 用 Less 却得到大顶堆](#4.7 用 Less 却得到大顶堆)
- [4.8 Compare 传的是类型,sort 传的是对象](#4.8 Compare 传的是类型,sort 传的是对象)
- [4.9 逐行复盘](#4.9 逐行复盘)
- [4.10 边界与坑点](#4.10 边界与坑点)
- [4.11 和 stack/queue 的异同、与标准库的差异](#4.11 和 stack/queue 的异同、与标准库的差异)
- [五、deque 作为默认底层容器的原因](#五、deque 作为默认底层容器的原因)
-
- [5.1 deque 内部:中控 map 加分段 buffer](#5.1 deque 内部:中控 map 加分段 buffer)
- [5.2 迭代器背四个指针](#5.2 迭代器背四个指针)
- [5.3 push 的三种情况](#5.3 push 的三种情况)
- [5.4 operator\[\] 两步定位](#5.4 operator[] 两步定位)
- 六、替换底层容器的对比
-
- [6.1 三个候选的接口账](#6.1 三个候选的接口账)
- [6.2 实测:三种底层的数据](#6.2 实测:三种底层的数据)
- [七、逐行复盘(stack 与 queue)](#七、逐行复盘(stack 与 queue))
-
- [7.1 构造函数里那个 `_con()`](#7.1 构造函数里那个
_con()) - [7.2 拷贝构造:深拷贝是容器给的](#7.2 拷贝构造:深拷贝是容器给的)
- [7.3 赋值:`this != &s` 在防什么](#7.3 赋值:
this != &s在防什么) - [7.4 析构函数:空函数也是表态](#7.4 析构函数:空函数也是表态)
- [7.5 push 与 pop:一行转发,复杂度全在下面](#7.5 push 与 pop:一行转发,复杂度全在下面)
- [7.6 top / front / back:为什么返回引用](#7.6 top / front / back:为什么返回引用)
- [7.7 size 与 empty:也是转发,但返回类型有讲究](#7.7 size 与 empty:也是转发,但返回类型有讲究)
- [7.8 模板的声明与实现为什么必须放在一起](#7.8 模板的声明与实现为什么必须放在一起)
- [7.1 构造函数里那个 `_con()`](#7.1 构造函数里那个
- 八、难点拆解
-
- [8.1 适配器为什么不给迭代器](#8.1 适配器为什么不给迭代器)
- [8.2 空容器访问:没有异常,直接崩](#8.2 空容器访问:没有异常,直接崩)
- [8.3 换底层容器:vector 做不了 queue](#8.3 换底层容器:vector 做不了 queue)
- [8.4 引用返回值遇上 pop:悬垂引用](#8.4 引用返回值遇上 pop:悬垂引用)
- [8.5 自赋值保护:防御还是必需](#8.5 自赋值保护:防御还是必需)
- 九、高频坑点总结
- 十、和标准库对比
- 十一、测试与编译运行说明
- 十二、闭环复盘
- 十三、完整代码
-
- [13.1 stack_queue.h](#13.1 stack_queue.h)
- [13.2 stack_queue_impl.h](#13.2 stack_queue_impl.h)
- [13.3 main.cpp](#13.3 main.cpp)
- [13.4 priority_queue.h(含仿函数)](#13.4 priority_queue.h(含仿函数))
- [13.5 main_pq.cpp](#13.5 main_pq.cpp)
一、容器适配器总述
1.1 适配器到底适配了什么
先说这个词。旅行用的电源转换插头,本身不发电,也不改变电压,它只做一件事:把墙上的插孔形状换成电器能插的形状。插座没动,电器没动,中间多了一个转接层。
容器适配器是同一个意思。deque 已经能存数据、能两端增删了,stack 不重新实现这些,它只规定"对外只准从尾部进、从尾部出",然后把这三个动作翻译成 push_back、pop_back、back。数据仍然住在 deque 里,stack 只持有它。
所以适配器的准确定义是:它不提供存储,只提供一套受限的访问规则,规则通过转发调用落在底层容器上。
这里有个设计选择值得记一笔:组合和继承,这里选了组合。
假如写成 class stack : public deque<T>,stack 会连 begin()、erase()、insert()、operator[] 一起继承过来。使用者写 st.insert(st.begin() + 2, 100),编译通过,运行时也不报错,但栈的语义已经破了------中间被插了一个元素,出栈顺序全乱。继承把不该给的能力也给了,而适配器要做的恰恰是"少给"。
组合只暴露自己写出来的成员函数,_con 是私有的,底层容器的一切能力都被挡在门内。想加一个能力,得自己写一行转发;不想给的能力,天然就不存在。
1.2 整体结构:两个类,各持有一个容器
类模板长这样,两个类除了接口名字不同,骨架一模一样:
cpp
template<class T, class Container = std::deque<T> >
class stack
{
// ...
private:
Container _con;
};
Container 是第二个模板参数,默认值 std::deque<T>。使用者写 byte::stack<int> 时,等价于 byte::stack<int, std::deque<int> >;写 byte::stack<int, std::vector<int> > 时,底层就换成了 vector,上层代码一个字都不用改。
这就是模板参数做"策略"的典型用法:把"用什么装"从"怎么访问"里抽出来,两者独立变化。
接口清单摆在一起看:
| 类 | 接口 | 转发到 | 语义 |
|---|---|---|---|
| stack | push(x) |
_con.push_back(x) |
压栈 |
| stack | pop() |
_con.pop_back() |
弹栈,不返回元素 |
| stack | top() |
_con.back() |
看栈顶 |
| stack | empty() |
_con.empty() |
是否为空 |
| stack | size() |
_con.size() |
元素个数 |
| queue | push(x) |
_con.push_back(x) |
入队 |
| queue | pop() |
_con.pop_front() |
出队,不返回元素 |
| queue | front() |
_con.front() |
看队头 |
| queue | back() |
_con.back() |
看队尾 |
| queue | empty() / size() |
同名转发 | 同上 |
十行转发,就是这两个类的全部业务逻辑。剩下的构造、拷贝、赋值、析构,是让它们像个正常对象该有的样子。
配图 1|容器适配器的委托结构
二、stack 的实现与原理:后进先出
stack 的三个动作全落在同一端,所以只要底层容器支持"尾部增、尾部删、读尾部",它就能工作:
cpp
void push(const T& x) { _con.push_back(x); }
void pop() { _con.pop_back(); }
T& top() { return _con.back(); }
后进先出不是额外加的规则,是"只有一个口"这个结构推出来的结论。只有一个口,那么最后进去的必然挡在最外面,想拿里面的,得先把外面的搬走。
这也解释了为什么 stack 只需要 top,不需要 front:它只有一端,没有第二个位置可以看。
有一次我试图在栈里找第 3 个元素,写了半天 st[2],编译不过,才反应过来适配器根本没有下标访问------想拿第 3 个,只能 pop 两次,而且拿完那两个元素就没了。受限不是缺陷,是它存在的理由。
配图 2|stack 的后进先出
三、queue 的实现与原理:先进先出
queue 是两头分工:尾部负责进,头部负责出。
cpp
void push(const T& x) { _con.push_back(x); }
void pop() { _con.pop_front(); }
就这一行 pop_front,把底层容器的候选范围砍掉了一半------vector 没有 pop_front,所以 vector 当不了 queue 的底层。这一点在第八节有真实报错。
队列的两个观察口也是分开的:front() 看队头(下一个出队的),back() 看队尾(最后进来的)。两个函数返回的都是引用,也就是说 q.front() = 100 是合法的,改的是队头那个元素的值------这是标准库的行为,不是这份实现擅自加的。
配图 3|queue 的一头进一头出
四、priority_queue 的实现与原理:堆与仿函数
priority_queue 出队不按先后,按"谁优先级高谁先出"。优先级规则不写死在代码里,交给模板参数上的一个仿函数;数据结构则换成了堆。这一节先把仿函数讲透,再拆堆的两套调整。
4.1 一句话说清仿函数
cpp
template<class T>
struct Less
{
bool operator()(const T& x, const T& y) const
{
return x < y;
}
};
一个重载了 operator() 的结构体,它的对象可以像函数一样用一对圆括号调用,所以叫函数对象,也叫仿函数。byte::Less<int>()(3, 5) 等价于 3 < 5,差别只在前者是一个"对象在假装当函数"。
4.2 从一个写死的比较说起
先看一个没有比较器参数的写法。手写冒泡排序时,比较规则通常直接写死在循环里:
cpp
if (a[j + 1] < a[j])
{
std::swap(a[j], a[j + 1]);
}
这份代码只能升序。想降序,要么再抄一份把 < 改成 >,要么给比较留一个"插槽",让调用方决定谁在前谁在后。
C 语言的 qsort 走的是函数指针这条路:比较器收 const void* 参数,按字节比较,类型信息全部丢失------元素大小传错、强转错了类型,编译器一声不吭,运行时才炸。C++ 把同样的需求写成模板参数,比较规则变成一个类型:
cpp
template<class T, class Compare>
void BubbleSort(T* a, int n, Compare com)
{
for (int i = 0; i < n; ++i)
{
int exchange = 0;
for (int j = 0; j < n - i - 1; ++j)
{
if (com(a[j + 1], a[j])) // 写死的比较换成了 com
{
std::swap(a[j], a[j + 1]);
exchange = 1;
}
}
if (exchange == 0)
{
break;
}
}
}
BubbleSort(a, 7, Less<int>()); // Less 语义:升序
BubbleSort(a, 7, Greater<int>()); // Greater 语义:降序
原来写死 a[j + 1] < a[j] 的位置,换成 com(a[j + 1], a[j])。com 是一个对象,用起来像函数------"仿函数"这个名字就是在这种调用现场来的。Compare 是模板参数,编译器按传入的类型生成两份排序代码,一份升序一份降序,类型检查全程在编译期完成。
priority_queue 的 Compare 参数,和这里 BubbleSort 的 Compare 是同一个角色:把"谁更优先"从代码里抽出来,交给使用的人填。
4.3 手写 Less 与标准库 std::less
标准库 <functional> 里早就备好了 std::less<T>、std::greater<T>,内部结构和上面手写的 byte::Less 几乎一字不差,operator() 同样是 const 成员。手写一份的意义,是看清这个"比较器"说到底就三行,也看清 priority_queue 默认参数里那个 Less<T> 到底是个什么东西。operator() 写成 const 成员也不是随便写的------比较不该改动元素,const 正好把这条约束钉在类型上。
4.4 STL 算法里的可调用对象
仿函数不是 priority_queue 的私房设计,STL 算法里到处是这种"插槽"。三个最常用的:
std::find(first, last, val):按值找,比较规则固定为相等,不需要插槽;std::find_if(first, last, pred):按条件找,pred是一个收元素、返回 bool 的可调用对象;std::transform(first, last, result, op):逐个变换,op是一个收元素、返回新值的可调用对象。
配两个小仿函数,C++98 就能写:
cpp
struct IsEven
{
bool operator()(int x) const
{
return x % 2 == 0;
}
};
struct Double
{
int operator()(int x) const
{
return x * 2;
}
};
int* pos = std::find_if(a, a + 7, IsEven()); // 找第一个偶数
std::transform(v.begin(), v.end(), v.begin(), Double()); // 全部乘 2
算法只负责遍历和搬运,"怎么比、怎么变"全部留给插槽。priority_queue 的 Compare、sort 的比较器参数,走的都是这条路:把策略做成可调用对象的类型,从接口上传进来。
4.5 概念与三个模板参数
cpp
template<class T, class Container = std::vector<T>, class Compare = Less<T> >
class priority_queue
{
// ...
};
三个参数各管一件事:
T:元素的类型;Container:底层容器,默认std::vector<T>,因为优先级队列要按下标随机访问,还要在尾部追加;Compare:比较器,默认Less<T>,决定"谁更优先"。
它和 stack、queue 一样是适配器:数据住在 Container 里,自己只露出 push、pop、top、empty、size 五个口,不给迭代器,也没有 front、back------因为除了"最优先的那一个",其余元素的相对顺序没有意义,不该让外界碰到。
4.6 堆是数组存起来的完全二叉树
priority_queue 内部不是排好序的数组,而是一个堆------一棵用数组按层序存起来的完全二叉树:
text
下标: 0 1 2 3 4 5 6 7 8
元素: a b c d e f g h i
a(0)
/ \
b(1) c(2)
/ \ / \
d(3) e(4) f(5) g(6)
/ \
h(7) i(8)
三组下标换算关系,是 adjust_up / adjust_down 的全部地基:
- 根在
_con[0]; - 下标
i的父节点是(i-1)/2; - 左孩子是
2i+1,右孩子是2i+2; - 最后一个非叶节点是
(size-2)/2,从它开始往前逐个下滤,就能 O(n) 建堆。
4.7 用 Less 却得到大顶堆
这是整块最容易搞反的地方。Less<T> 的 com(a, b) 等价于 a < b,而堆的维护规则是"当 com(父, 子) 为真就交换"------父小于子就换,等于把大的那个一路换上去。于是堆顶是最大的元素,得到的是大顶堆。
配图 4|堆的两种调整:push 上滤两步,pop 三步
4.8 Compare 传的是类型,sort 传的是对象
同一个 Greater<int>,在 priority_queue 和 sort 里写法不一样:
cpp
priority_queue<int, std::vector<int>, std::greater<int> > pq; // 不带括号
std::sort(v.begin(), v.end(), std::greater<int>()); // 带括号
差别来自两种模板接收参数的方式。priority_queue 是类模板 ,尖括号里的三个位置全部是类型,Compare 这个位置要填一个类型名------std::greater<int> 本身就是类型;写成带括号的形式,是把表达式塞进了类型表,编译不过。sort 是函数模板 ,比较器参数的类型由实参推导,圆括号里要传的是一个对象 ------std::greater<int>() 用默认构造造出一个临时对象传进去;只写类型名,是把类型当表达式用,同样编译不过。
一个记法:类模板的尖括号里只能写类型,函数调用的圆括号里只能写对象。两处写法错位的报错都挺难懂,记住这条分界线,比背报错省力。
4.9 逐行复盘
两个构造加空判断。 区间构造先判 _con.empty(),再算 ((int)_con.size() - 2) / 2。这里藏着原文件的一个隐患:原代码写的是 (_con.size()-1-1)/2,size() 返回无符号的 size_t,空容器或单元素时减法会下溢成一个天文数字,转成 int 结果未定义。转成 int 再减、加空判断,才算安全。
push 与上滤。 新元素先 push_back 到末尾,再从末尾往上调整。上滤的完整逻辑:
cpp
void adjust_up(int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (_comp(_con[parent], _con[child])) // 父不如子优先
{
std::swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
循环条件 child > 0,到根就停;每轮要么交换后上浮一层,要么 break。parent 在交换之后重算一次,下一轮接着用。以配图 4 的 push 38 为例:38 挂在下标 9,先和父 35 比、交换,child 变 4 再和父 40 比,不比父优先,break------两层就停,一个元素最多上浮 O(log n) 层。
pop 与下滤。 先把堆顶和末尾元素交换、pop_back 删掉原堆顶,再从根向下调整:
cpp
void adjust_down(int parent)
{
size_t child = (size_t)parent * 2 + 1;
while (child < _con.size())
{
if (child + 1 < _con.size() && _comp(_con[child], _con[child + 1]))
{
++child; // 两个孩子里挑更优先的那个
}
if (_comp(_con[parent], _con[child]))
{
std::swap(_con[child], _con[parent]);
parent = (int)child;
child = (size_t)parent * 2 + 1;
}
else
{
break;
}
}
}
两个值得记的细节。一,下滤必须先在两个孩子里挑更优先的 再和父比:com(左, 右) 为真说明右更优先,++child 换成右;跳过这一步直接和左孩子换,堆序会坏。二,child + 1 < _con.size() 这个前置判断保证右孩子存在才去比,只有左孩子的末层节点不会越界。配图 4 的 pop 三步走完,新的根就是剩余元素里的最优先者。
比较器成员化。 这是对原文件最关键的一处改动。原代码在 adjust_up / adjust_down 里写 Compare com; 每次现造一个,带状态的仿函数(比如 NearTo)的状态会在每次调整时被清零,队列表现错乱。改成成员 _comp,构造时存进去,整个队列共用一个比较器,状态才带得住。这也是标准库的做法------标准库 priority_queue 专门有 priority_queue(const Compare&) 这个构造,就是用来把比较器作为成员保存的。
top 只给 const 引用。 堆顶不能随便改,改了堆序就坏,所以只返回 const T&,不提供非 const 版本,把"别改堆顶"写进接口。
4.10 边界与坑点
- 空堆
top():_con[0]越界,vector 的operator[]不检查,ASan 下直接 SEGV,栈帧停在main,读的是 0 地址; - 空堆
pop():_con[0]和_con[size()-1]双双越界,SEGV 崩在std::swap里,因为size()-1已经下溢成 SIZE_MAX; - 结论和 stack、queue 一样:适配器不判空,先
empty()再top/pop是调用方的责任; - 自定义比较器要满足严格弱序 。
NearTo按距离分组、距离本身是良序的,所以安全;一旦比较规则里出现"两边都不小于对方却又不是相等"的矛盾,堆序就会被破坏,出堆顺序错乱,而且不报错,是最难查的一类。
4.11 和 stack/queue 的异同、与标准库的差异
- 相同:都是适配器,都不给迭代器,数据都住在底层容器里;
- 不同:stack 一个口、queue 两个口、priority_queue 靠堆维护"最优先者先出";默认底层也不同,前两者是 deque,它是 vector;
- 与标准库对比:这份实现的核心接口和
std::priority_queue对齐(push/pop/top/empty/size、区间构造、自定义比较器),缺的是 allocator 模板参数和 C++11 的移动构造、emplace。C++98 口径下,行为一致。
五、deque 作为默认底层容器的原因
为什么默认是它,账在第六节算;这里先把它的内部结构看明白。
5.1 deque 内部:中控 map 加分段 buffer
deque 不是一根连续数组,也不是一串节点,而是两层结构:下层是一串等长的缓冲区 (buffer),每段是一块连续内存;上层是一根指针数组(map,也叫中控器),每个格子存一个 buffer 的首地址。数据全部住在 buffer 里,map 只负责记住每一段在哪。
头尾各留空闲段,是两端 O(1) 的直接来源:push_front 写头段前面的空位,push_back 写尾段后面的空位,已有元素一个都不搬。某一段写满,才新开一段并在 map 里挂上指针;map 自己也写满了,就换一根更大的 map,把旧指针整体搬过去。这两步都有代价,但摊到海量的 push 上可以忽略。
配图 5|deque 的中控 map、分段 buffer 与四指针迭代器
5.2 迭代器背四个指针
deque 的迭代器不是一个裸指针,是一个背着四个指针的小结构(仿 SGI STL 的 __deque_iterator):
cpp
template<class T>
struct __deque_iterator
{
T* cur; // 当前指向的元素
T* first; // 所在 buffer 的第一个位置
T* last; // 所在 buffer 的边界外一格
T** node; // 指向 map 里管理这个 buffer 的那一格
};
四个指针分工:cur 管当前元素,first 与 last 圈定所在段的范围,node 记住这段由 map 的哪一格管理。operator* 返回 *cur,operator== 只比 cur------比的是位置,不用管在不在同一段。
关键动作是 operator++ 里的跳段:
cpp
self& operator++()
{
++cur;
if (cur == last) // 本段走完了
{
set_node(node + 1); // node 先在 map 里挪一格
cur = first; // cur 回到新段开头
}
return *this;
}
set_node 把 node 指向 map 的下一格,再从那一格取出新 buffer 的首地址放进 first,last 等于 first + buffer_size()。先挪 node、再换 first、最后落 cur,这套顺序就是跳段三步。普通指针的 ++ 是一次加法,deque 的 ++ 最坏要走这三步------这是它迭代器比 vector 重的原因。
5.3 push 的三种情况
- 本段还有空位:写入
finish.cur指的格子,cur前移,完事; - 本段写满:新开一段 buffer,map 找个空槽挂上,
finish跳到新段开头; - map 也写满:换一根更大的 map,旧指针整体搬家,再挂新段。
push_front 同理、方向相反:先看头段前面有没有空位,有就往前写;头段用尽,就在 map 前面挂一段新的。所以两端扩展都是"就地写空位",vector 头插那种全员搬家在 deque 里不存在。
5.4 operator\[\] 两步定位
下标访问要跨过分段这一层:先 pos / buffer_size() 算出落在第几段,再 pos % buffer_size() 算出段内第几个,两次算术就拿到 map[i] 那段里的第 j 格。vector 只有一次加法,deque 多一除一取模------随机访问"能做但略慢"的来源就在这两步。
六、替换底层容器的对比
Container 这个模板参数留在那儿就是为了换。换之前把三种候选的账摆在一起算清楚。
6.1 三个候选的接口账
三个候选摆出来对比,选择理由就清楚了。
vector:尾部增删是摊还 O(1),但头部插入删除要搬整个数组,O(n)。queue 需要 pop_front,vector 连这个函数都没有。所以 vector 只能给 stack 用。
list:两端都是 O(1),接口齐全,但每个元素多背两个指针,而且每个节点单独 new 一次。前面复盘 list 时测过,节点式存储在遍历和插入上都比连续存储慢一个数量级。
deque:分段连续,接口是三个里的全集。头尾各留空闲段,两端扩展都不用搬已有元素。它同时满足"两端 O(1)"和"空间开销小、缓存友好"这两个条件,所以被选为默认值。
6.2 实测:三种底层的数据
先证分段连续是真的。 相邻元素地址一减就知道:
cpp
std::deque<int> d;
for (int i = 0; i < 3000; ++i)
{
d.push_back(i);
}
// 打印关键下标的地址差
std::cout << (char*)&d[1023] - (char*)&d[0] << std::endl;
std::cout << (char*)&d[1024] - (char*)&d[1023] << std::endl;
text
&d[0] = 0x1049b6360
&d[1023] = 0x1049b735c 与 d[0] 差 4092 字节
&d[1024] = 0xa3ac04000 与 d[1023] 差 39563087012 字节
&d[2047] = 0xa3ac04ffc 与 d[1024] 差 4092 字节
&d[2048] = 0xa3ac05000 与 d[2047] 差 4 字节
三件事一次看清。一,d[0] 到 d[1023] 地址每步加 4,4092 = 1023 × 4,段内是真连续,一段 4096 字节装 1024 个 int,这个数就是实测出来的 buffer_size() 。二,d[1023] 到 d[1024] 地址差三百九十六亿字节------跨段即天各一方,"段间不连续"不是修辞。三,d[2047] 到 d[2048] 只差 4 字节:第二段和第三段恰好被分配在内存里挨着的位置。逻辑上的分段靠 map 维护,物理上两段挨不挨着全凭 allocator 的安排,这正说明分段是逻辑概念,不是物理承诺。
再量两端的代价。 头插 2 万次:
| 操作 | 耗时 |
|---|---|
deque::push_front |
0.000053s |
list::push_front |
0.000303s |
vector::insert(begin(), x) |
0.012450s |
deque 比 list 还快约 6 倍------list 每插一个都要 new 一个节点,deque 只是往头段空位里写;vector 慢两百多倍,每次头插都要搬光已有元素。queue 必须两头 O(1),vector 在这一项上直接出局。
随机访问的账。 预生成 100 万个随机下标后逐个访问:deque 0.001888s,vector 0.001432s,deque 慢约三成,就是那"一除一取模"的价钱。对照组更触目:list 用 advance 只定位 1000 次就花了 0.63s,是 deque 定位 100 万次的三百多倍。三种容器里,deque 是唯一同时给出"两端 O(1)"和"下标可随机访问"的。
最后是 stack 场景的总账。 三种底层各压 100 万个 int 再全部弹出,-O2,本机一次测量:
| 底层容器 | 100 万次 push | 100 万次 top + pop |
|---|---|---|
std::deque<int> |
0.002459s | 0.000929s |
std::vector<int> |
0.002814s | 0.000089s |
std::list<int> |
0.019107s | 0.020261s |
数据里有两件事值得记:一是 list 慢得明显,push 慢约 8 倍、pop 慢约 200 倍,原因不在算法,在每个节点都要单独申请一次内存;二是只做栈的话,vector 反而最快 :出栈阶段 0.000089s,比 deque 快十倍,原因是它只有一段连续内存,back() 和 pop_back() 都是纯粹的下标运算,缓存全程命中。
所以"默认 deque"这个选择不是"对 stack 最优",而是"对 stack 和 queue 都够用"。deque 自己是"优点都不够极致"的那个------头尾增删不如 vector 极致地快、任意插删不如 list 极致地灵活、缓存命中不如 vector 极致地满,但每一项都不差,恰好是当"默认值"的料:一份默认值同时服务两个适配器,代价是让 stack 稍微慢一点点,收益是使用者不用为两个类记两套默认值。
配图 6|替换底层容器:vector、deque、list 三方账本
七、逐行复盘(stack 与 queue)
7.1 构造函数里那个 _con()
cpp
template<class T, class Container>
stack<T, Container>::stack()
: _con()
{}
_con 是类类型成员,即使初始化列表里什么都不写,编译器也会调它的默认构造函数。所以 : _con() 在这里没有功能上的差别。
写它的理由是习惯和一致性:初始化列表列全所有成员,读代码时不用去翻"这个成员到底有没有被初始化"。等到某天加进来一个 int _size; 这种内置类型成员,忘记写初始化列表就是未定义值,而那时候的惯性已经养成了。
还有一点:类外定义不能重复写默认参数 。默认值 = std::deque<T> 只在类内的声明处出现一次,类外定义写成 template<class T, class Container>,不能带默认值。带了会报 redefinition of default argument。
7.2 拷贝构造:深拷贝是容器给的
cpp
template<class T, class Container>
stack<T, Container>::stack(const stack<T, Container>& s)
: _con(s._con)
{}
这一行调的是容器的拷贝构造函数,容器再挨个拷贝里面的元素,所以拷完两份数据完全独立。
这里有个我一开始想错的地方:我以为"不写拷贝构造就会浅拷贝、两个栈共用一份数据",于是赶紧手写了一个。后来把这段删掉再跑,行为完全一样------因为编译器生成的默认拷贝构造就是逐个成员拷贝 ,唯一的成员是 Container,而容器的拷贝构造本来就是深拷贝。
所以准确的结论是:适配器的深拷贝由底层容器保证,手写拷贝构造是显式化,不是必需 。真正需要手写的是成员里存在裸指针的情况(比如 vector 那份实现里的 _start),那时候默认的逐成员拷贝抄的是地址,才会出问题。
判断标准很简单:成员全是有值语义的类类型,默认拷贝就用;成员里有掌管资源的裸指针,必须自己写。
7.3 赋值:this != &s 在防什么
cpp
template<class T, class Container>
stack<T, Container>& stack<T, Container>::operator=(const stack<T, Container>& s)
{
if (this != &s)
{
_con = s._con;
}
return *this;
}
自赋值就是 s = s,或者两个引用指向同一个对象时的 a = b。
先释放自己再拷贝的话,自赋值会把数据先删掉,然后从一个已经空了的对象里拷------经典的自毁操作。所以自赋值保护的对象是"先释放后申请"这种实现方式。
这份实现里其实不出问题:_con = s._con 调的是容器的赋值运算符,容器自己处理了自赋值。但保护仍然值得写,两个理由:一是自赋值时直接返回,省掉一次可能很贵的整表拷贝;二是将来把成员换成裸指针、改成先 delete[] 再 new 的写法时,这一行 if 就是唯一的救命绳。
防御性代码的价值不在当下跑得通,在未来改得动。
7.4 析构函数:空函数也是表态
cpp
template<class T, class Container>
stack<T, Container>::~stack()
{}
适配器自己没有申请过任何资源,成员 _con 的析构由编译器自动调用(析构函数体执行完,成员按逆序析构)。所以函数体空着是对的。
写出来的意义是明说"这里确实没有额外资源要释放",读的人不用怀疑是不是漏了。不写的话,编译器生成的析构也是空的,行为一致,但这份"说明"就没了。
与 vector 那份对比一下就清楚:vector 的析构必须写,因为 _start 指向的数组不归编译器管,得手动 delete[]。资源是裸的,就必须手动收;资源被类包着,就别插手。
7.5 push 与 pop:一行转发,复杂度全在下面
cpp
template<class T, class Container>
void stack<T, Container>::push(const T& x)
{
_con.push_back(x);
}
push 的复杂度完全由容器的 push_back 决定:deque 是 O(1),vector 是摊还 O(1)(偶尔扩容要搬),list 是 O(1) 但每次都要 new。适配器这一层没引入任何额外开销。
pop 返回 void,不返回被删掉的元素。这个设计和标准库一致,理由有两条:
一是异常安全。要返回值就得在删除前拷贝一份,这次拷贝可能抛异常(元素类型是 std::string 时内存不足),一旦抛了,元素已经被删、返回值也没送出去,状态就说不清了。返回 void 时 pop_back 只有析构,不抛异常。
二是效率。要返回就得拷贝,而调用方往往只关心"删掉",不关心"删了什么";真要拿值,先 top() 看一眼再 pop() 就行,多写一行而已。
7.6 top / front / back:为什么返回引用
cpp
template<class T, class Container>
T& stack<T, Container>::top()
{
return _con.back();
}
template<class T, class Container>
const T& stack<T, Container>::top() const
{
return _con.back();
}
返回引用而不是值,有两个好处:省一次拷贝(元素是 std::string 或大结构体时差别明显),以及允许修改容器里的元素(st.top() = 100)。
成对写 const 版本,是为了让 const 对象也能读。const stack<int>& 只能调 const 成员函数,如果只写了非 const 版本,传一个 const 引用进去就编译不过。这套重载决议的细节在前一篇 list 复盘里拆过,机制完全一样:*this 的 const 性参与重载决议。
queue 的 front、back 各写两个版本,一共四个函数,看起来重复,但缺一个就会在某个 const 场景下卡住。
7.7 size 与 empty:也是转发,但返回类型有讲究
cpp
template<class T, class Container>
size_t stack<T, Container>::size() const
{
return _con.size();
}
size() 返回 size_t,和容器的 size() 一致。标准库的 std::stack::size_type 其实就是底层容器的 size_type,通常在 64 位平台上是 unsigned long。
empty() 返回 bool,实现只有一行 _con.empty()。判断空表优先用它,而不是 size() == 0------对 deque 和 vector 差别不大,但对 list 这种 size() 可能是遍历计数的容器,empty() 只比较一次指针。
7.8 模板的声明与实现为什么必须放在一起
这份代码把实现单独放在 stack_queue_impl.h,然后在 stack_queue.h 末尾 #include 进来。模板类不能像普通类那样拆成 .h + .cpp。
试一次就知道了。把实现放进 stack_queue.cpp,只编译不链接是能过的,一链接就炸:
text
Undefined symbols for architecture arm64:
"byte::stack<int, std::__1::deque<int, std::__1::allocator<int>>>::pop()", referenced from:
_main in m-c09c05.o
"byte::stack<int, std::__1::deque<int, std::__1::allocator<int>>>::push(int const&)", referenced from:
_main in m-c09c05.o
"byte::stack<int, std::__1::deque<int, std::__1::allocator<int>>>::stack()", referenced from:
_main in m-c09c05.o
ld: symbol(s) not found for architecture arm64
原因:类模板不是代码,是生成代码的图纸 。编译器看到 byte::stack<int> 时,必须当场拿到 push、pop 的函数体才能生成 stack<int> 这一份机器码。而 main.cpp 只包含了声明,函数体在另一个编译单元里,编译器手上没有图纸,只能留一个"外部符号"等着链接器去找;stack_queue.cpp 那边没有任何人实例化 stack<int>,自然也什么都没生成。两边都空,于是未定义符号。
三种解法:把定义放进头文件(本实现的做法,最常见);在 .cpp 里显式实例化 template class byte::stack<int>;(只对已知类型有效);或者干脆全写在类内(内联,最省事但代码臃肿)。
顺带说一句 C++98 的书写细节:嵌套模板的两个右尖括号之间要有空格。写 stack<stack<int>> 会报:
text
error: a space is required between consecutive right angle brackets (use '> >')
八、难点拆解
8.1 适配器为什么不给迭代器
试图像遍历 vector 那样遍历 stack,编译直接拦下:
text
error: no template named 'iterator' in 'byte::stack<int>'; did you mean 'std::iterator'?
error: no member named 'begin' in 'byte::stack<int>'
这不是没实现,是不该实现。
第一,迭代器会绕开受限规则 。有了 begin(),就能从头到尾走一遍,拿到栈底的元素------那 stack 的"只能访问栈顶"就没意义了,和直接拿 deque 用没区别。
第二,迭代器会把底层结构写进接口。迭代器的类型取决于底层容器(deque 的迭代器和 list 的迭代器完全是两种东西),一旦暴露,使用者就会依赖它,之后想换底层容器就成了破坏性改动。适配器的价值恰恰是"底层可换、上层不动"。
第三,标准库也是这么做的。std::stack、std::queue 都没有 begin()/end(),这不是偷懒,是接口设计的一致性。
代价是:适配器不能配合 STL 算法,不能范围 for,想"看看里面有什么",只能破坏性遍历------pop 到临时容器里再放回去,或者干脆在调试时用底层容器。
8.2 空容器访问:没有异常,直接崩
top() 的实现是 _con.back(),back() 对空容器是未定义行为。标准库同样不检查,因为检查要给每次访问加一次判断,而调用方本来就知道自己有没有 push 过。
空栈取栈顶的真实结果:
text
AddressSanitizer:DEADLYSIGNAL
ERROR: AddressSanitizer: SEGV on unknown address 0x00700001ffff
The signal is caused by a READ memory access.
#0 std::__1::deque<int>::back() deque:1579
#1 byte::stack<int, ...>::top() stack_queue_impl.h:47
#2 main e4.cpp:7
空队列 pop() 也是崩,栈帧落在 deque::pop_front() 上:
text
ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
The signal is caused by a READ memory access.
==58819==Hint: address points to the zero page.
#0 std::__1::deque<int>::pop_front() deque:2204
#1 byte::queue<int, ...>::pop() stack_queue_impl.h:103
两次都是解引用了一个非法地址:deque 在空容器上算出了一个没有指向任何元素的指针,再拿它去取数据。这类崩溃的特点是位置在库里面,根因在调用方 ------看到 deque::back() 崩了,别去怀疑 deque,回头查谁在空容器上调了 top。
想主动防,两种写法:调用方养成 if (!st.empty()) 的习惯;或者在自己的实现里加 assert(!_con.empty()),Debug 时立刻定位,Release 时 assert 被去掉、零开销。第二条更适合自己写的容器,因为定位成本低得多。
8.3 换底层容器:vector 做不了 queue
把 queue 的第二个模板参数换成 vector,push 能过,pop 编译不过:
text
error: no member named 'pop_front' in 'std::vector<int>'
note: in instantiation of member function 'byte::queue<int, std::vector<int> >::pop' requested here
注意报错的位置:stack_queue_impl.h 里 pop 的类外定义,而不是调用处。因为 pop 只有在被调用时才实例化,编译到 q.pop() 那一刻才发现 vector 没有 pop_front。
可用组合整理成表:
| 底层容器 | 能否做 stack 的底层 | 能否做 queue 的底层 |
|---|---|---|
std::deque |
能,默认 | 能,默认 |
std::vector |
能,出栈甚至更快 | 不能,缺 pop_front |
std::list |
能,但慢 | 能,但慢 |
判断依据就一条:把接口映射表拉出来,逐个检查底层容器有没有对应的成员函数 。stack 需要 push_back/pop_back/back;queue 需要 push_back/pop_front/front/back。少一个就不行。
8.4 引用返回值遇上 pop:悬垂引用
top() 返回的是引用,指向容器内部那一格。如果先把引用存下来,再 pop 掉那个元素,引用就指向一块已经析构的内存:
cpp
const std::string& r = st.top();
st.pop();
std::cout << r.size() << std::endl;
ASan 的输出:
text
ERROR: AddressSanitizer: container-overflow on address 0x621000000117
READ of size 1 at 0x621000000117 thread T0
#0 std::__1::basic_string<...>::__is_long() const string:2153
#1 std::__1::basic_string<...>::size() const string:1301
#2 main e6.cpp:10
container-overflow 是 ASan 对"访问已归还给容器的内存"的专门报警。这里之所以没报成 use-after-free,是因为 deque 的缓冲区还挂在容器上,只是元素被析构了、位置标记成空闲------性质一样危险,只是表现形式不同。
如果元素是 int,这段代码可能什么都不报,读出来是个看着正常的垃圾值,这种"偶发正确"最坑。结论很直接:top() 的引用只在元素还活着的时候有效,要留就拷一份值,别存引用。
8.5 自赋值保护:防御还是必需
6.3 提过,这份实现里自赋值本来就安全。看起来这个 if 是多余的。
看具体实现方式:
| 赋值实现 | 自赋值是否安全 | 原因 |
|---|---|---|
_con = s._con(本实现) |
安全 | 容器的赋值自己处理了自赋值 |
先 delete 旧数据再 new 拷贝 |
危险 | 先把自己删了,再从一个已毁的对象里拷 |
| 先拷贝到临时再交换 | 安全 | 交换对自赋值无害 |
所以判断标准是"赋值过程有没有先破坏自己"。有,就必须保护;没有,保护仍有价值(省一次拷贝、留一条后路),但不是刚需。
我自己写的时候会一律加上,因为将来改成裸指针实现时,最容易忘的就是这一行。
九、高频坑点总结
| 编号 | 坑 | 现象 | 根因 | 处理 |
|---|---|---|---|---|
| 1 | 空栈调 top() |
SEGV,栈帧落在 deque::back() |
标准不检查空容器,属未定义行为 | 调用前判 empty(),或在实现里加 assert |
| 2 | 空队列调 pop() |
SEGV,栈帧落在 deque::pop_front() |
同上 | 同上 |
| 3 | 以为不写拷贝构造会浅拷贝 | 纠结半天,其实默认逐成员拷贝已经是深拷贝 | 唯一成员是容器,容器自己深拷贝 | 成员全为值语义类型时,默认拷贝即可 |
| 4 | 模板实现拆到 .cpp |
链接错误:Undefined symbols for architecture arm64 |
实例化点看不到函数体,两个编译单元都没生成代码 | 定义放头文件,或显式实例化 |
| 5 | queue 用 vector 当底层 |
no member named 'pop_front' |
vector 不支持头部删除 | 换成 deque 或 list |
| 6 | C++98 写 stack<stack<int>> |
a space is required between consecutive right angle brackets |
老标准把 >> 当右移运算符 |
写成 > > |
| 7 | 存 top() 的引用再 pop() |
ASan 报 container-overflow |
元素已析构,引用悬垂 | 需要保留就拷贝一份值 |
| 8 | 头文件漏 #include <deque> |
use of undeclared identifier 'std',随后 too few template arguments |
默认模板参数里用到了 std::deque,声明处必须可见 |
头文件自己包含所需的头,别依赖调用方 |
| 9 | 类外定义重复写默认参数 | redefinition of default argument |
默认值只能在声明处出现一次 | 类外定义只写 template<class T, class Container> |
| 10 | 想遍历 stack |
no member named 'begin' |
适配器有意不提供迭代器 | 确实要看内容就换底层容器 |
| 11 | 用 size() == 0 判空 |
语义没错,但 list 底层下是 O(n) | size() 可能是遍历计数 |
一律用 empty() |
| 12 | 以为 pop() 会返回元素 |
编译错:void 不能赋给变量 |
标准接口就是 void pop() |
先 top() 取值,再 pop() |
| 13 | sort 传 greater<int> 忘带括号,或 priority_queue 写成 greater<int>() |
各自报一个难懂的编译错 | sort 是函数模板收对象,priority_queue 是类模板收类型 | sort 带 (),priority_queue 不带 |
十、和标准库对比
| 维度 | std::stack |
byte::stack |
|---|---|---|
| 默认底层容器 | std::deque<T> |
std::deque<T> |
| 核心接口 | push/pop/top/empty/size |
同,一致 |
| 迭代器 | 不提供 | 不提供,一致 |
| 拷贝构造 | 隐式可用(逐成员拷贝) | 显式写出,行为一致 |
| 赋值 | 隐式可用 | 显式写出,带自赋值保护 |
| 关系运算符 | 有 ==/!=/< 等,直接比较底层容器 |
未实现 |
| 异常规格 | 多数接口不抛(除容器本身抛) | 依赖底层容器,未标注 |
| C++11 新增 | emplace、swap、移动构造 |
无,C++98 范围之外 |
queue 那张表除了多出 front/back 两个接口,其余完全对应。
三点差异值得单独说:
一是关系运算符。 std::stack 的 operator== 是拿两个底层容器直接比较,逐元素对比,不用 pop。没实现它,是因为实现起来需要访问对方的 _con(同类的私有成员可以互访,技术上可行),但标准要求的是"按容器比较",语义上和逐个 pop 比较等价、效率不同。这份实现没写,需要比较时得自己补。
二是异常安全。 标准库对 push 一般给强保证(失败则容器状态不变),靠的是底层容器的强保证。这份实现原样转发,保证级别跟着容器走:deque 的 push_back 成功或不变,所以这里也一样。
三是 emplace。 C++11 之后标准库多了 emplace,可以拿构造参数直接在容器里构造元素,省一次临时对象。C++98 没有可变参数模板,做不了通用版本,所以只保留 push(const T&)。
十一、测试与编译运行说明
main.cpp(六组场景:栈的基本操作与出栈顺序、队列的进出、拷贝构造后的独立性、赋值与自赋值、替换底层容器、const 对象调用 const 接口。
编译命令
bash
clang++ -std=c++98 -pedantic -Wall -Wextra main.cpp -o demo
运行结果:
text
== 1. stack ==
push 1,2 之后 top=2 size=2
pop 一次后 top=1 size=1
栈内出栈顺序: 4 3 1 | 清空后 empty=1
== 2. queue ==
push 1,2 之后 front=1 back=2
出队顺序: 2 3 4 | 清空后 size=0
== 3. 拷贝构造 ==
原栈 size=2 top=second
副本 size=2 top=second-mod
原队列 front=x 副本 front=y
== 4. 赋值 ==
赋值后 q2 front=1 size=2
自赋值后 q2 front=1 size=2
stack 赋值后 s2 top=7
== 5. 换底层容器 ==
vector 当底层的 stack: 30 20 10
list 当底层的 queue: front=1 back=3
== 6. const 接口 ==
const 引用读栈顶=6 size=2
const 引用读队列 front=1 back=2
对照输出能确认几件事:
- 栈的出栈顺序是
4 3 1(先 push 1、2,pop 掉 2,再 push 3、4),最后进的最先出,LIFO 成立; - 队列的出队顺序是
2 3 4(pop 掉 1 之后又 push 3、4),先进先出,front与back分别指向两端; - 拷贝之后改副本,原对象仍是
size=2、top=second,说明两份数据独立; - 赋值与自赋值之后
q2的内容不变,size=2; vector当底层的 stack、list当底层的 queue 都能正常增删。
sizeof 那组数据也值得记:sizeof(std::deque<int>) = 48,sizeof(byte::stack<int>) = 48,sizeof(byte::queue<int>) = 48。适配器没有塞进任何额外成员,它在内存上和裸容器一模一样。
stack 与 queue 之外,priority_queue 的测试单独一组。
编译命令:
bash
clang++ -std=c++98 -pedantic -Wall -Wextra main_pq.cpp -o demo
运行结果:
text
== 1. 默认大顶堆 ==
大顶堆 top=9 size=8
出堆顺序: 9 6 5 4 3 2 1 1
== 2. Greater 最小堆 ==
最小堆 top=1 出堆顺序: 1 1 2 3 4 5 6 9
== 3. 区间建堆 ==
区间建堆后 top=9 出堆顺序: 9 8 7 6 5 4 3 2 1
== 4. 带状态仿函数 ==
锚点=5 时,离 5 最近的 top=5 出堆顺序: 5 6 7 3 8 2 1 10
== 5. string 元素 ==
字典序大顶堆 top=pear 出堆顺序: pear orange banana apple
对照输出能确认五件事:默认大顶堆 top=9、出堆降序;Greater 最小堆 top=1、升序;区间构造一次建堆;带状态仿函数锚点取 5 时 top=5、按距离从近到远出队;string 元素默认按字典序大顶堆出队。
补一组建堆与 push 的实测:100 万 int,区间建堆 0.0033s,逐个 push 0.0122s,差约 3.7 倍,O(n) 对 O(n log n) 的差距清清楚楚落在计时器上。数据量一大、要一次性把一批元素建成堆时,优先走区间构造。
Xcode 里跑这套代码:新建 macOS 的 Command Line Tool 工程,Build Settings 把 C++ Language Dialect 设成 C++98(对应 CLANG_CXX_LANGUAGE_STANDARD = c++98),把 stack_queue.h、stack_queue_impl.h、main.cpp(连同 priority_queue.h、main_pq.cpp)拖进工程即可,头文件形式的实现不需要任何额外配置。
十二、闭环复盘
把这篇串一遍,落成六条。
第一,适配器是"接口翻译层",不是新容器。 它不申请内存、不做算法,只把底层的接口换个名字露出来。判断一个类是不是适配器,看两件事:成员里有没有一个容器、函数体里有没有 new。全都没有,就是适配器。
第二,组合优于继承,理由是"能少给"。 公有继承会把底层容器的全部能力带出来,insert、erase、begin 一旦露出去,LIFO/FIFO 的语义就守不住。私有成员加转发函数,暴露面完全由自己决定。
第三,语义由结构决定,不由注释决定。 栈只有一个口,所以后进先出;队列两个口分工,所以先进先出。这两条结论不需要在代码里写规则去维持,接口设计本身就把它们固定住了。
第四,默认容器是"两个都够用"的折中。 deque 分段连续,两端 O(1),缓存比 list 友好、空间比 list 省。单看 stack,vector 出栈更快;但一份默认值要同时服务两个适配器,deque 是那个不需要为谁特判的答案。
第五,模板的分离只能是"声明 + 实现头"的分离。 拆到 .cpp 会缺定义,链接期才报错;想在 .cpp 里写,就得显式实例化,等于把类型钉死。这是模板的编译模型决定的,不是风格问题。
第六,仿函数把"谁更优先"变成可插拔的策略。 priority_queue 的排序规则不写死在代码里,而是通过 Compare 模板参数传进来:默认 Less 得到大顶堆,传 Greater 得到小顶堆,带状态的仿函数还能定义"离锚点最近者优先"。比较器要作为成员保存,状态才带得住;堆的上滤下滤逻辑与比较规则解耦,换一个仿函数,同一套代码换一种出队顺序。
最后留一张选择表,下次动手前先看一眼:
| 需求 | 选择 |
|---|---|
| 后进先出,只在尾部增删 | stack,底层 deque 或 vector |
| 先进先出,两端分工 | queue,底层 deque 或 list |
| 需要遍历、随机访问 | 别用适配器,直接用 vector / deque |
| 需要按优先级取元素 | 用 priority_queue(堆 + vector,见第四节) |
| 元素很大、拷贝贵 | 容器里放指针或智能指针,适配器不用改 |
三个适配器的复盘到这里闭环。三个类的骨架一模一样,差别只在"从哪个口出":栈一个口、队列两个口、优先队列按堆序出。到这一步,容器适配器这条线就串完了。
十三、完整代码
全部完整代码集中在这里,正文各节只保留讲解用的片段。五个文件:stack_queue.h 放声明,stack_queue_impl.h 放类外定义,main.cpp 测 stack 与 queue,priority_queue.h 是优先队列加仿函数,main_pq.cpp 是它的测试。声明和实现分开写是为了读起来清爽,但两个文件都得被同一个编译单元看见,原因在 7.8 讲。
13.1 stack_queue.h
cpp
#ifndef __STACK_QUEUE_98_H__
#define __STACK_QUEUE_98_H__
#include <deque>
#include <stddef.h>
namespace byte
{
// 适配器不新建数据结构,只把底层容器的一段接口换个名字露出来
// stack:只在一端进出,对应 push_back / pop_back / back
template<class T, class Container = std::deque<T> >
class stack
{
public:
stack();
stack(const stack<T, Container>& s);
stack<T, Container>& operator=(const stack<T, Container>& s);
~stack();
void push(const T& x);
void pop();
T& top();
const T& top() const;
bool empty() const;
size_t size() const;
private:
Container _con;
};
// queue:一头进一头出,对应 push_back / pop_front / front / back
template<class T, class Container = std::deque<T> >
class queue
{
public:
queue();
queue(const queue<T, Container>& q);
queue<T, Container>& operator=(const queue<T, Container>& q);
~queue();
void push(const T& x);
void pop();
T& front();
const T& front() const;
T& back();
const T& back() const;
bool empty() const;
size_t size() const;
private:
Container _con;
};
}
#include "stack_queue_impl.h"
#endif
13.2 stack_queue_impl.h
cpp
#ifndef __STACK_QUEUE_IMPL_98_H__
#define __STACK_QUEUE_IMPL_98_H__
namespace byte
{
// ---------- stack ----------
template<class T, class Container>
stack<T, Container>::stack()
: _con()
{}
template<class T, class Container>
stack<T, Container>::stack(const stack<T, Container>& s)
: _con(s._con)
{}
template<class T, class Container>
stack<T, Container>& stack<T, Container>::operator=(const stack<T, Container>& s)
{
if (this != &s)
{
_con = s._con;
}
return *this;
}
template<class T, class Container>
stack<T, Container>::~stack()
{}
template<class T, class Container>
void stack<T, Container>::push(const T& x)
{
_con.push_back(x);
}
template<class T, class Container>
void stack<T, Container>::pop()
{
_con.pop_back();
}
template<class T, class Container>
T& stack<T, Container>::top()
{
return _con.back();
}
template<class T, class Container>
const T& stack<T, Container>::top() const
{
return _con.back();
}
template<class T, class Container>
bool stack<T, Container>::empty() const
{
return _con.empty();
}
template<class T, class Container>
size_t stack<T, Container>::size() const
{
return _con.size();
}
// ---------- queue ----------
template<class T, class Container>
queue<T, Container>::queue()
: _con()
{}
template<class T, class Container>
queue<T, Container>::queue(const queue<T, Container>& q)
: _con(q._con)
{}
template<class T, class Container>
queue<T, Container>& queue<T, Container>::operator=(const queue<T, Container>& q)
{
if (this != &q)
{
_con = q._con;
}
return *this;
}
template<class T, class Container>
queue<T, Container>::~queue()
{}
template<class T, class Container>
void queue<T, Container>::push(const T& x)
{
_con.push_back(x);
}
template<class T, class Container>
void queue<T, Container>::pop()
{
_con.pop_front();
}
template<class T, class Container>
T& queue<T, Container>::front()
{
return _con.front();
}
template<class T, class Container>
const T& queue<T, Container>::front() const
{
return _con.front();
}
template<class T, class Container>
T& queue<T, Container>::back()
{
return _con.back();
}
template<class T, class Container>
const T& queue<T, Container>::back() const
{
return _con.back();
}
template<class T, class Container>
bool queue<T, Container>::empty() const
{
return _con.empty();
}
template<class T, class Container>
size_t queue<T, Container>::size() const
{
return _con.size();
}
}
#endif
13.3 main.cpp
cpp
#include "stack_queue.h"
#include <iostream>
#include <string>
#include <vector>
#include <list>
// 1. stack:后进先出
static void test_stack()
{
byte::stack<int> st;
st.push(1);
st.push(2);
std::cout << "push 1,2 之后 top=" << st.top() << " size=" << st.size() << std::endl;
st.pop();
std::cout << "pop 一次后 top=" << st.top() << " size=" << st.size() << std::endl;
st.push(3);
st.push(4);
std::cout << "栈内出栈顺序: ";
while (!st.empty())
{
std::cout << st.top() << " ";
st.pop();
}
std::cout << "| 清空后 empty=" << st.empty() << std::endl;
}
// 2. queue:先进先出
static void test_queue()
{
byte::queue<int> q;
q.push(1);
q.push(2);
std::cout << "push 1,2 之后 front=" << q.front() << " back=" << q.back() << std::endl;
q.pop();
q.push(3);
q.push(4);
std::cout << "出队顺序: ";
while (!q.empty())
{
std::cout << q.front() << " ";
q.pop();
}
std::cout << "| 清空后 size=" << q.size() << std::endl;
}
// 3. 拷贝构造:验证两个对象互不影响
static void test_copy()
{
byte::stack<std::string> a;
a.push("first");
a.push("second");
byte::stack<std::string> b(a);
b.pop();
b.push("second-mod");
std::cout << "原栈 size=" << a.size() << " top=" << a.top() << std::endl;
std::cout << "副本 size=" << b.size() << " top=" << b.top() << std::endl;
byte::queue<std::string> q1;
q1.push("x");
q1.push("y");
byte::queue<std::string> q2(q1);
q2.pop();
std::cout << "原队列 front=" << q1.front() << " 副本 front=" << q2.front() << std::endl;
}
// 4. 赋值:普通赋值与自赋值
static void test_assign()
{
byte::queue<int> q1;
q1.push(1);
q1.push(2);
byte::queue<int> q2;
q2.push(9);
q2 = q1;
std::cout << "赋值后 q2 front=" << q2.front() << " size=" << q2.size() << std::endl;
byte::queue<int>& qr = q2; // 绕开编译器的自赋值警告,真正走到 if (this != &q) 那一行
q2 = qr;
std::cout << "自赋值后 q2 front=" << q2.front() << " size=" << q2.size() << std::endl;
byte::stack<int> s1;
s1.push(7);
byte::stack<int> s2;
s2 = s1;
std::cout << "stack 赋值后 s2 top=" << s2.top() << std::endl;
}
// 5. 换底层容器:stack 用 vector,queue 用 list
static void test_container()
{
byte::stack<int, std::vector<int> > sv;
for (int i = 1; i <= 3; ++i)
{
sv.push(i * 10);
}
std::cout << "vector 当底层的 stack: ";
while (!sv.empty())
{
std::cout << sv.top() << " ";
sv.pop();
}
std::cout << std::endl;
byte::queue<int, std::list<int> > ql;
ql.push(1);
ql.push(2);
ql.push(3);
std::cout << "list 当底层的 queue: front=" << ql.front() << " back=" << ql.back() << std::endl;
}
// 6. const 对象只能用 const 版本接口
static void test_const()
{
byte::stack<int> st;
st.push(5);
st.push(6);
const byte::stack<int>& cst = st;
std::cout << "const 引用读栈顶=" << cst.top() << " size=" << cst.size() << std::endl;
byte::queue<int> q;
q.push(1);
q.push(2);
const byte::queue<int>& cq = q;
std::cout << "const 引用读队列 front=" << cq.front() << " back=" << cq.back() << std::endl;
}
int main()
{
std::cout << "== 1. stack ==" << std::endl;
test_stack();
std::cout << "== 2. queue ==" << std::endl;
test_queue();
std::cout << "== 3. 拷贝构造 ==" << std::endl;
test_copy();
std::cout << "== 4. 赋值 ==" << std::endl;
test_assign();
std::cout << "== 5. 换底层容器 ==" << std::endl;
test_container();
std::cout << "== 6. const 接口 ==" << std::endl;
test_const();
return 0;
}
13.4 priority_queue.h(含仿函数)
cpp
#ifndef __PRIORITY_QUEUE_98_H__
#define __PRIORITY_QUEUE_98_H__
#include <vector>
#include <algorithm>
#include <stddef.h>
namespace byte
{
// 仿函数:重载 operator(),让对象能像函数一样被调用
// 它比函数指针多一件事------能带状态,而且能被编译器内联
template<class T>
struct Less
{
bool operator()(const T& x, const T& y) const
{
return x < y;
}
};
template<class T>
struct Greater
{
bool operator()(const T& x, const T& y) const
{
return x > y;
}
};
// priority_queue:容器适配器,默认底层 vector,默认比较 Less<T>
// 默认是大顶堆:top() 返回的是最大的那个元素
// 底层是数组存起来的完全二叉树,父子下标有固定换算关系
template<class T, class Container = std::vector<T>, class Compare = Less<T> >
class priority_queue
{
public:
priority_queue()
{
}
explicit priority_queue(const Compare& comp)
: _comp(comp)
{
}
template<class InputIterator>
priority_queue(InputIterator first, InputIterator last)
: _con(first, last)
{
if (_con.empty())
{
return;
}
// 从最后一个非叶子节点开始,逐个向下调整,O(n) 一次建堆
for (int i = ((int)_con.size() - 2) / 2; i >= 0; --i)
{
adjust_down(i);
}
}
template<class InputIterator>
priority_queue(InputIterator first, InputIterator last, const Compare& comp)
: _con(first, last), _comp(comp)
{
if (_con.empty())
{
return;
}
for (int i = ((int)_con.size() - 2) / 2; i >= 0; --i)
{
adjust_down(i);
}
}
void push(const T& x)
{
_con.push_back(x);
adjust_up((int)_con.size() - 1);
}
void pop()
{
// 堆顶与末尾交换,删掉末尾,再从根往下沉
std::swap(_con[0], _con[_con.size() - 1]);
_con.pop_back();
adjust_down(0);
}
const T& top() const
{
return _con[0];
}
bool empty() const
{
return _con.empty();
}
size_t size() const
{
return _con.size();
}
private:
// 上滤:新元素插在末尾,一路和父节点比,比父"更优先"就往上换
void adjust_up(int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (_comp(_con[parent], _con[child]))
{
std::swap(_con[child], _con[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
// 下滤:根被换成了末尾元素,一路和孩子里"更优先"的那个比,往下沉
void adjust_down(int parent)
{
size_t child = (size_t)parent * 2 + 1;
while (child < _con.size())
{
if (child + 1 < _con.size() && _comp(_con[child], _con[child + 1]))
{
++child;
}
if (_comp(_con[parent], _con[child]))
{
std::swap(_con[child], _con[parent]);
parent = (int)child;
child = (size_t)parent * 2 + 1;
}
else
{
break;
}
}
}
private:
Container _con; // 数据都住在这里,vector 连续内存、随机访问
Compare _comp; // 比较器被存成成员,整个队列共用一个,状态才带得住
};
}
#endif
13.5 main_pq.cpp
cpp
#include "priority_queue.h"
#include <iostream>
#include <string>
#include <vector>
// 带状态的仿函数:构造时定一个"锚点",之后每次比较都相对这个锚点
// 离锚点近的算"更大"(更优先),于是堆顶是离锚点最近的那个
struct NearTo
{
int anchor;
explicit NearTo(int a)
: anchor(a)
{
}
bool operator()(int x, int y) const
{
int dx = x - anchor;
if (dx < 0)
{
dx = -dx;
}
int dy = y - anchor;
if (dy < 0)
{
dy = -dy;
}
return dx > dy; // 离得远的算"更小",近的自然浮到堆顶
}
};
// 1. 默认大顶堆:top 是最大元素,出堆顺序是降序
static void test_maxheap()
{
byte::priority_queue<int> pq;
int arr[] = {3, 1, 4, 1, 5, 9, 2, 6};
for (size_t i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i)
{
pq.push(arr[i]);
}
std::cout << "大顶堆 top=" << pq.top() << " size=" << pq.size() << std::endl;
std::cout << "出堆顺序: ";
while (!pq.empty())
{
std::cout << pq.top() << " ";
pq.pop();
}
std::cout << std::endl;
}
// 2. Greater 当比较器:变成最小堆
static void test_minheap()
{
byte::priority_queue<int, std::vector<int>, byte::Greater<int> > pq;
int arr[] = {3, 1, 4, 1, 5, 9, 2, 6};
for (size_t i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i)
{
pq.push(arr[i]);
}
std::cout << "最小堆 top=" << pq.top() << " 出堆顺序: ";
while (!pq.empty())
{
std::cout << pq.top() << " ";
pq.pop();
}
std::cout << std::endl;
}
// 3. 区间构造:一次性建堆
static void test_range()
{
int arr[] = {9, 8, 7, 6, 5, 4, 3, 2, 1};
byte::priority_queue<int> pq(arr, arr + 9);
std::cout << "区间建堆后 top=" << pq.top() << " 出堆顺序: ";
while (!pq.empty())
{
std::cout << pq.top() << " ";
pq.pop();
}
std::cout << std::endl;
}
// 4. 带状态仿函数:堆顶是离锚点最近的值
static void test_stateful()
{
byte::priority_queue<int, std::vector<int>, NearTo> pq(NearTo(5));
int arr[] = {10, 7, 3, 5, 8, 1, 6, 2};
for (size_t i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i)
{
pq.push(arr[i]);
}
std::cout << "锚点=5 时,离 5 最近的 top=" << pq.top() << " 出堆顺序: ";
while (!pq.empty())
{
std::cout << pq.top() << " ";
pq.pop();
}
std::cout << std::endl;
}
// 5. string 元素:默认 Less<std::string> 依赖 operator<
static void test_string()
{
byte::priority_queue<std::string> pq;
pq.push("pear");
pq.push("apple");
pq.push("orange");
pq.push("banana");
std::cout << "字典序大顶堆 top=" << pq.top() << " 出堆顺序: ";
while (!pq.empty())
{
std::cout << pq.top() << " ";
pq.pop();
}
std::cout << std::endl;
}
int main()
{
std::cout << "== 1. 默认大顶堆 ==" << std::endl;
test_maxheap();
std::cout << "== 2. Greater 最小堆 ==" << std::endl;
test_minheap();
std::cout << "== 3. 区间建堆 ==" << std::endl;
test_range();
std::cout << "== 4. 带状态仿函数 ==" << std::endl;
test_stateful();
std::cout << "== 5. string 元素 ==" << std::endl;
test_string();
return 0;
}





