stack、queue、priority_queue、容器适配器完整梳理

文章目录

    • 一、容器适配器总述
      • [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 模板的声明与实现为什么必须放在一起)
    • 八、难点拆解
      • [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;
}

相关推荐
iCxhust1 小时前
如何实时同步自己的工作日志,在不同的设备间
前端·软件工程·1024程序员节·同步日志
kingx31 小时前
BugKu 渗透测试4 Writeup:从 eval 注入到五层内网拿满 Flag
人工智能·网络安全·网络攻击模型·安全威胁分析·1024程序员节
Jsundoku1 小时前
第七章 深度学习 -- Transformer 架构
人工智能·深度学习·transformer·1024程序员节
若亦_1 小时前
Java泛型入门:类型安全与上界约束
java·1024程序员节
FriendshipT1 小时前
Vim 完整指南:从入门到高效编辑
linux·人工智能·编辑器·vim·1024程序员节
小淮AI1 小时前
AI 能生成网页吗?从三类工具看当前的实际能力
1024程序员节
思麟呀1 小时前
嵌入式入门(四)USART 串口深度解析:单片机第一次与电脑对话
c语言·单片机·嵌入式硬件·1024程序员节
猛码象限1 小时前
【模型观察】K3.1 Pro 后训练加码,自评完成度 65.7%
人工智能·业界资讯·1024程序员节
天蒙蒙亮1 小时前
React Native 打包流程(Android 为主)
1024程序员节