stack、queue 与 deque:适配器模式、中控数组的分段连续,与仿函数
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
一、stack/queue:连迭代器都不给
栈和队列的接口一眼看过去就知道它们很"专制":不能用迭代器遍历 。原因不玄学------栈和队列是有明确顺序的 ,栈只能从顶上进出、队列只能头进尾出,要是开放迭代器随便跳着访问,这两个结构的语义就被破坏了。所以它们连 begin()/end() 都没有,只有 push/pop/top(front)/empty/size 这几个"守规矩"的接口。
手搓的时候也轻:栈的底层直接复用顺序表或链表,push 就是尾插、pop 就是尾删;队列保证队尾是 nullptr (若底层用链表,则做成循环链表,head 尾相接)------初始化列表是成员定义的地方,这些原则在手搓容器时全部复用。
二、适配器模式:不写数据结构,只写"包装"
这里出现了 STL 六大组件(容器、算法、迭代器、适配器 、仿函数、空间配置器)里还没见过的一位:适配器也是类,但它不管理数据,只把已有容器的接口"翻译"成自己的语义:
cpp
template <class T, class container = std::deque<int>>
class stack {
public:
void push(T x) { _con.push_back(x); }
void pop() { _con.pop_back(); }
...
};
stack 和 queue 的底层是有一个数据结构的 ,而且这个底层容器是模板参数------你可以自由选择想用的底层数据结构 :想要 vector 的就传 vector,想要 list 的就传 list。container 的缺省值一般是双端队列 deque。
这就是适配器模式的精髓:stack/queue 不实现任何数据结构,只定义"怎么用" ;vector 管存、list 管存、deque 管存,stack 只规定"后进先出"。一句话总结:将 vector 和 list 的两个 container 用 deque 代替------了解双端队列,成了这一章的正事。
三、deque:vector 和 list 的结合
先看两个极致(笔记里的原表):
| 优点 | 缺点 | |
|---|---|---|
| 数组(vector) | 下标随机访问快、尾插尾删效率高、CPU 高速缓存命中率高 | 头部/中间插入删除低下;扩容有性能消耗、倍数扩容可能空间浪费 |
| 链表(list) | 任意位置 O(1) 插入删除、按需申请释放 | 不支持下标随机访问;缓存命中率低,还存在缓存污染 |
从功能看,deque 是 vector 和 list 的结合:它有 list 友好的头尾插入删除(push_front/push_back/pop_front/pop_back 一应俱全),又有 vector 的快速下标访问(operator\[\]/at/front/back)。对照接口表能看得很清楚------deque 的成员函数一半画线连向 vector、一半连向 list。
但中间插入的大量数据挪移它解决不了------这是 deque 天生的折衷。
四、deque 底层:中控数组 + 分段连续
deque 的底层结构是本章最精彩的部分:
- 一段段**定长缓冲区(buff 数组)**存真正的数据;
- 一个中控数组 map 统一管理这些缓冲区------map 存的是数组首元素地址的指针,所以 map 本身是二级指针数组,node 是指向一级指针的二级指针;
- map 满载了,就再找一块更大的空间做 map(
reallocate_map),把旧的一级指针搬过去------和 vector 扩容一个思路,只不过搬的是指针不是数据。
双端队列底层是一段假象的连续空间,实际是分段连续的。为了维护"整体连续"以及随机访问的假象,重担全落在 deque 的迭代器身上------每个迭代器四个指针:
cur --------- 当前指向的位置(核心!迭代器现在指向的就是 cur)
first --------- 当前 buff 数组的首空间
last --------- 当前 buff 数组的末空间(无数据)
node --------- 指向中控数组里的那个一级指针
跨缓冲区时 ++ 就是"cur 撞到 last → node 跳到 map 里下一个一级指针 → cur/first/last 重新锚定到新 buff"。所以 deque 的迭代器是 STL 里最复杂的迭代器之一------复杂度没有消失,只是从用户转移到了迭代器类里。
定位一句话:deque 很平均 ------头尾 O(1)、随机访问 O(1)(但常数大),中间插入不行。哪里都不极致,所以它适合做默认参数:给 stack/queue 当底层(只用头尾操作,它的"平均"毫无短板),而真正排序遍历的场合大家还是用 vector/list。
五、仿函数:为了不再用 C 的函数指针
优先级队列(priority_queue,本质就是堆)带来了新需求:默认大根堆,但有时想要小根堆------比较逻辑要能被用户换掉。C 语言的做法是 qsort 那种函数指针:
c
void qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*));
C++ 的答案是仿函数/函数对象 :本质是类,重载了 operator(),所以能像函数一样加括号调用,还能保存状态(普通函数做不到):
cpp
class Less {
public:
bool operator()(int x, int y) { return x < y; }
};
Less lessFunc;
cout << lessFunc(1, 2) << endl; // 对象当函数用
仿函数传入类型或对象,有了实例,实例() 就是你要的需求 。在 priority_queue 里,Compare 是一个类参数、Container 是底层容器参数------仿函数本质是类,不是函数 ,所以它作为模板参数传的是类型,用的时候再实例化。
于是有了那个经典的反直觉写法:
cpp
priority_queue<int> pq1; // 默认 less → 大根堆
priority_queue<int, vector<int>, greater<int>> pq2; // greater → 小根堆
为什么一个带括号一个不带? 一个是传类型 (greater 是实例化类型,不带括号),一个是传匿名对象 (某些场景传对象,带括号)------类型参数和对象参数是两个世界。默认传 less 得到大根堆:less 意为"父 < 子"成立时上浮,大的自然浮到顶上。
六、建堆收口:向上/向下调整的前提
优先队列的 push/pop 就是堆的向上/向下调整,笔记里两条结论值得原样保留:
- 使用向上或向下调整的前提:除一个点以外,其余部分已经是合法堆;
- 向上调整建堆是"边插边建" (while 条件是 child > 0,只和父节点比,不用和兄弟节点比);向下调整建堆是"查完了再建" (从最后一个非叶子节点往前,while 条件和容量挂钩,向下调整要先和兄弟节点比较选出子节点再和父节点比)。
两种建堆方式相互独立又相互映射,选哪种取决于你的数据是"一次全给"还是"一个个来"------优先队列的 push 走向上、pop(top 换尾再向下)走向下,正好各用各的。
收个尾
这一章的链路是:stack/queue 有明确顺序 → 不给迭代器 → 只做容器语义的包装 → 适配器模式 → 底层缺省 deque → deque 用中控数组伪造连续 → 复杂度全压给迭代器 ;另一条线是比较逻辑要可换 → 函数指针不够优雅 → 仿函数(重载 operator() 的类)→ priority_queue 默认大根堆 less、greater 换小根堆。
六大组件至此集齐:容器管存、算法管算、迭代器当胶水、适配器管翻译、仿函数管策略。