前段时间团队招C++工程师,我习惯让候选人手写一个简化版Vector。结果十个人里有八个写成了class里面包一个std::vector,剩下两个能写出三指针模型的,基本都能把扩容和析构的细节聊明白。后来我复盘发现,手写STL这件事,真的不只是面试八股,它对理解C++内存模型、模板泛型和容器适配的底层逻辑,帮助远比你想象中大。这篇文章我想从一个面试题切入,带你把Vector和优先队列(Heap)这两块硬骨头从底层啃透,全程手写代码,不依赖任何黑盒。
如果你已经能熟练用std::vector做业务开发,却说不清它扩容时发生了什么、为什么priority_queue用less反而得到大顶堆,这篇文章就是为你准备的。我会从最底层的三指针模型讲起,一步一步实现一个迷你Vector,再用它配合二叉堆算法,组装出一个可用的优先队列。整个过程不涉及复杂工程框架,只需要C++基础语法,跟着代码走一遍,你对STL的认知会有明显提升。
1. 手写STL的真正价值:不只是为了面试
1.1 面试官手写题目的背后逻辑
很多刚入门的朋友对手写容器这件事抱有抵触情绪,觉得"STL都写得那么完美了,我再写一遍不是重复造轮子吗"。但面试官让你手写Vector,重点从来不是要你在半小时内超越std::vector,而是想借着这个题目把一串底层知识点一次问透:内存是哪里来的、元素在哪里构造、异常发生时数据是否安全、迭代器为什么失效、扩容为什么是均摊常数时间。这些问题背答案背不出来,只有真正动手写过,才能在追问下答得出来。
实际上,std::vector看起来只是一块连续内存,但它同时踩中了C++最核心的几个语法机制:placement new就地构造、显式调用析构函数、operator new与operator delete配对、模板偏特化、移动语义与异常安全。一个写不出完整Vector的候选人,基本可以说对C++的底层内存机制缺少手感。
优先队列也是同理。表面上是"取最大值的容器",底层却是一个二叉堆。二叉堆用数组存储完全二叉树,涉及父子下标的数学推导、上浮与下沉两种调整操作,以及比较器的语义反转。把这两件事连起来,就是STL中priority_queue作为"容器适配器"的核心设计思路。
1.2 一条从Vector到优先队列的进阶路线
我推荐的学习顺序是:先吃透Vector,再攻堆算法,最后适配组装。为什么是这个顺序?
Vector解决的是"内存和元素生命周期"问题。堆算法解决的是"在连续内存上维护堆序"问题。优先队列解决的则是"如何把底层容器和算法封装成一个好用的类"问题。三者层层递进:没有连续内存的支撑,堆的下标运算就是空中楼阁;没有上浮下沉的算法,Vector扩容得再漂亮也无法做优先队列;没有适配器的封装思路,每次想用堆还得手动调用四个算法函数,体验太差。
这条路径还有一个额外收获:它串起了C++泛型编程的几个关键姿势,包括容器、算法、迭代器、函数对象四者的协作关系。理解了这套协作机制,你再去看sort、set、unordered_map的源码,会发现很多设计是相通的。所以别嫌麻烦,把基础砸牢,后面越走越快。
2. Vector底层黑科技:三指针模型与扩容的艺术
2.1 三指针结构:size和capacity的本质
先来一个关键认知:std::vector内部管理的并不是一块"恰好装下所有元素"的内存,而是一个容量可能大于实际元素数的缓冲区。这个设计是一切性能优势的来源。
迷你版Vector的数据结构很简单:
template <typename T> class MyVector { public: using value_type = T; using iterator = T*; using const_iterator = const T*; private: T* start_; // 指向首元素 T* finish_; // 指向最后一个元素的下一个位置 T* end_of_storage_; // 指向已分配内存的末尾 };三个成员全都是裸指针,这本身就是STL设计的一个精华:迭代器的本质就是指针(对Vector这种连续存储容器而言)。size()和capacity()不是存了两个整数,而是通过指针相减得到的:
size_t size() const { return finish_ - start_; } size_t capacity() const { return end_of_storage_ - start_; }为什么指针相减能得到元素个数?因为T*是两个T对象,编译器会按sizeof(T)换算两个地址间的间隔。这种写法省掉了两个size_t成员,更重要的是,它让"大小"这个信息与内存区域本身绑定,指针移动一步,大小自然同步更新。
我见过不少初学者把这个结构理解成"start、finish、end三点夹出的三个区间",这是对的:[start_, finish_)是已构造元素区间,[finish_, end_of_storage_)是空闲内存区间。空区间,恰恰是Vector与原生数组的本质区别——原生数组容量固定,Vector则通过"预留更多内存"获得动态扩容的能力。
2.2 扩容策略:2倍还是1.5倍的数学博弈
先明确一点:当finish_ == end_of_storage_时再做push_back,就必须重新找一块更大的内存,把所有元素搬过去,释放旧内存。这个过程叫扩容。
行业里有两种主流策略:每次翻倍(2倍),每次乘1.5(约1.5倍)。它们看起来只是倍数差异,背后却涉及时间与内存的取舍。
假设当前容量为n,扩容到2n,新内存大小恰好是旧内存的两倍。旧内存被释放后,系统空闲块是n大小;再下一次扩容需要2n大小,需要重新申请,旧内存始终无法拼出恰好能用的块。这导致旧内存块通常无法在新扩容时被复用,内存频繁向系统"重新要地",碎片化更明显。
1.5倍就不一样了。假设某次容量是n,扩容到1.5n,旧内存加上之前释放的若干块,有机会凑出新的1.5n块。举个例子,连续几次扩容的容量序列:1、1.5、2.25、3.375,前面2.25+1.5+N其实有机会被内存分配器合并成3.375附近的大小,复用概率明显更高。当然这依赖具体分配器行为,但理论上1.5倍在内存复用上确实更有优势。
时间维度上,2倍扩容的均摊代价更低。均摊分析:假设从容量1开始,每扩容一次都要把所有已有元素拷贝一次,2倍策略累计拷贝次数约2n;1.5倍策略约3n到4n。所以2倍在时间上略优,1.5倍在内存上更友好。
我整理了一张对比表:
| 策略 | 均摊拷贝次数 | 内存复用潜力 | 典型实现 |
|---|---|---|---|
| 2倍扩容 | 约2n | 较低 | GCC libstdc++ |
| 1.5倍扩容 | 约3.5n | 较高 | MSVC |
| 固定增量 | O(n^2) | 高 | 不建议 |
从表中能看出来,固定增量扩容(比如每次只加10个)均摊代价是O(n),这种写法在真实工程里千万别用。面试时如果能主动说出"VS实现采用约1.5倍(实际是1.5倍取整),GCC采用2倍,分别侧重复用与速度",会是明显加分项。我的建议是手写练习时用2倍,容易算清楚,讨论时再补充1.5倍的原因即可。
2.3 push_back的完整实现与异常安全
现在实现最核心的push_back。我的迷你版本支持const T&一个重载,区分左值右值属于锦上添花,我们先保证逻辑正确:
void push_back(const T& value) { if (finish_ != end_of_storage_) { ::new (finish_) T(value); // 有空位,就地构造 ++finish_; } else { size_t old_cap = capacity(); size_t new_cap = old_cap == 0 ? 1 : old_cap * 2; T* new_start = static_cast<T*>(::operator new(new_cap * sizeof(T))); size_t idx = 0; try { for (; idx < size(); ++idx) { ::new (new_start + idx) T(start_[idx]); } ::new (new_start + idx) T(value); } catch (...) { for (size_t i = 0; i <= idx; ++i) { (new_start + i)->~T(); } ::operator delete(new_start); throw; } for (iterator it = start_; it != finish_; ++it) { it->~T(); } ::operator delete(start_); start_ = new_start; finish_ = start_ + idx + 1; end_of_storage_ = start_ + new_cap; } }这段代码里最关键的有三处。
第一,::new (finish_) T(value)是placement new,它在已分配但尚未构造的内存上直接调用构造函数。为什么不用普通的*finish_ = value;?因为finish_指向的地址虽然已属于这个Vector,但那段内存里还没有活着的T对象,直接赋值相当于对未构造对象调用赋值运算符,对内部有资源管理的类型(比如std::string)会直接崩。placement new把"分配内存"和"构造对象"拆开了,这是STL容器能预分配容量的根基。
第二,扩容时先构造新块,析构旧元素放在最后。如果某个元素的拷贝构造函数抛出异常,catch块会析构已构造的新元素并释放新内存,然后重新抛出。此时旧Vector的start_、finish_都没动过,数据完好无损。这就是异常安全里的strong guarantee:操作失败,状态不变。
第三,释放内存用的是::operator delete而不是delete。因为整块内存是::operator new分配的,而每个元素对象的析构已经显式调用了。如果盲目写成delete[] start_,T的析构会被频繁重复调用,非POD类型的资源会二次释放。
2.4 迭代器失效:最容易踩的坑
Vector的迭代器本质上就是指针,所以"迭代器失效"翻译成人话就是"指针指向了无效位置"。
| 操作 | 对迭代器/引用的影响 |
|---|---|
| push_back导致扩容 | 所有迭代器、引用、指针全部失效 |
| push_back不扩容 | 不影响已有迭代器,但end()改变 |
| insert中间位置 | 插入位置及其后的迭代器全部失效 |
| erase中间位置 | 被删项及其后的迭代器全部失效 |
| pop_back | 被删元素迭代器失效,其他不受影响 |
| reserve触发扩容 | 与push_back扩容相同,全部失效 |
失效的根本原因是内存地址变了,或者元素相对位置变了。一旦内存重新分配,旧地址上的对象已被析构销毁,再通过旧迭代器访问就是未定义行为,表现可能是段错误,也可能"碰巧还读到了旧数据",这种随机性最坑。
工程上的建议很简单:如果预计要大量插入,先用reserve分配好容量,让扩容在可控时机发生;遍历删除时不要手写循环erase,用erase(std::remove_if(...), end())惯用法;被外部持有的元素指针要在扩容后重新获取。后面第五部分我还会详细讲一个我实际踩过的迭代器失效案例。
3. 优先队列底层黑科技:二叉堆与上浮下沉
3.1 为什么优先队列用堆而不是有序数组
先问一个问题:要实现一个"每次都能拿到当前最大元素,且支持动态插入"的容器,你会选什么数据结构?
如果选有序数组,插入一个元素需要把后面的元素全部后移,最坏O(n);删除最大元素O(1)但为了保持有序性会付出额外代价。如果选链表,插入虽然O(1)但取最大元素要遍历O(n)。如果维护一个平衡二叉搜索树,插入和删除都是O(log n),但实现复杂度高,红黑树代码量可不是几百行能搞定的。
二叉堆则在这个需求上达到了一个很好的平衡:插入O(log n),取堆顶O(1),删除堆顶O(log n)。它不需要使用额外的指针存储结构,完全用数组就能表达一棵完全二叉树,内存连续性还特别优秀,cache命中率极高。这就是priority_queue底层用它的大背景。
实际应用里,Dijkstra最短路径算法、各类定时器、Top K问题、任务调度系统,背后全是堆。可以说优先队列是图算法和操作系统里的"基础设施"。
3.2 完全二叉树的数组存储:下标推导
二叉堆首先是一棵完全二叉树。完全二叉树的定义是:除了最后一层,每一层都是满的,最后一层从左到右连续填充。这个约束带来一个巨大红利:节点天生可以在数组中按层序排列,不用存左右孩子指针。
这里给出关键下标推导(数组从0开始计数,这也是C++数组的默认姿势):
- 下标为i的节点,其左孩子下标为
2 * i + 1 - 下标为i的节点,其右孩子下标为
2 * i + 2 - 下标为i的节点,其父节点下标为
(i - 1) / 2(整数除法)
为什么是这样?因为完全二叉树的第k层最多有2^k个节点,而第0层到第k-1层的节点总数为2^k - 1。以根节点下标0起步,第k层第j个节点的下标就是2^k - 1 + j,代入左右孩子的关系,就能推出上面的公式。不用死记,每次推导一遍就记住了。
还有个特别有用的结论:在长度为n的堆数组中,最后一个非叶子节点的下标是n / 2 - 1。因为最后一个下标是n - 1,它的父节点是(n - 1 - 1) / 2 = n / 2 - 1。这个节点是make_heap执行向下调整时的起点,记住它有很大价值。
对比一下从1开始计数的写法(父节点为i/2,左孩子2i),代码里普遍采用0-based数组下标,直接用C++原生下标,省去转化步骤。面试时两种写法都能说清即可,但如果你写的是0-based,务必在纸上先画一个小堆验证几遍下标。
3.3 sift_up与sift_down的实现
堆的核心操作只有两个:上浮与下沉。我的实现基于一个大顶堆关系,即父节点值不小于子节点值。
先看下沉:
// arr为堆数组,n为堆的有效元素个数,i为待下沉节点下标 template <typename T> void sift_down(T* arr, size_t n, size_t i) { while (true) { size_t largest = i; size_t left = 2 * i + 1; size_t right = 2 * i + 2; if (left < n && arr[left] > arr[largest]) { largest = left; } if (right < n && arr[right] > arr[largest]) { largest = right; } if (largest == i) { break; } std::swap(arr[i], arr[largest]); i = largest; } }largest记录三元组(当前节点、左孩子、右孩子)中的最大值下标,如果最大值不是当前节点,就交换,然后沿着交换方向继续下沉。这个循环最坏会走到叶子节点,完全二叉树高度为⌊log2n⌋,所以时间复杂度O(log n)。注意largest == i时直接break,说明当前节点已经比两个子节点都大,堆序恢复。
再看上浮:
// i为待上浮节点下标,通常传最后一个元素的位置 template <typename T> void sift_up(T* arr, size_t i) { while (i > 0) { size_t parent = (i - 1) / 2; if (arr[i] <= arr[parent]) { break; } std::swap(arr[i], arr[parent]); i = parent; } }上浮的思路很直观:只要当前节点比父节点大,就交换,继续向上。一旦发现父节点不小于当前节点,就立即停止。新元素插入到数组末尾后,堆序只可能在"从末尾到根"这条路径上被破坏,因此只沿这条路径向上调整即可。
3.4 push与pop的完整流程拆解
有了上浮下沉两个基础操作,堆的两个经典算法就顺理成章了。
push_heap算法:把新元素放到数组末尾,然后执行sift_up(arr, n - 1)。这时的数组不一定是堆,新元素可能比父节点大,但它不影响其他节点的堆序,因为其他部分本来就满足堆序。上浮结束后,新堆恢复。
pop_heap算法:先把堆顶元素和数组末尾元素交换,然后对堆顶位置(下标0)执行一次sift_down(arr, n - 1, 0)。注意这里的重点:交换后最大元素到了末尾,而末尾元素到了堆顶。此时"堆的有效长度"应该是n - 1,但我们不是立刻删除末尾,而是先对前n - 1个元素做下沉调整。调整完毕后,前n - 1个元素恢复堆序,末尾元素就是那个被弹出的"牺牲品",等着随后的pop_back真正删除。
为什么要交换而不是直接把堆顶拿走?因为堆底层是连续数组,直接删除下标0的元素意味着后面所有元素都要前移,完全二叉树的结构就被破坏了。交换到末尾再下沉,保持了完全二叉树形态的前提下完成删除,这个技巧是堆设计中非常精妙的一环。
make_heap算法也基于下沉:
template <typename T> void make_heap(T* arr, size_t n) { // 从最后一个非叶子节点开始,向前逐个做下沉 for (long long i = (long long)n / 2 - 1; i >= 0; --i) { sift_down(arr, n, (size_t)i); } }为什么从n / 2 - 1开始?因为所有叶子节点本身没有孩子,天然满足堆序,无需调整。从最后一个非叶子节点往前逐个下沉,每个节点下沉到合适位置,最后整棵树就是堆。这个算法的时间复杂度是O(n),不是看起来的O(n log n),因为越靠近根节点的下沉工作量虽然大,但这样的节点很少,总工作量按等比级数求和收敛到O(n)。注释里记得n强制转为long long,因为n / 2 - 1在n等于0时下溢成极大的正整数,这个边界很多人会漏。
4. 从Vector到优先队列的组装:容器适配器设计
4.1 模板参数与适配器思想
现在到了标题里"从Vector到优先队列"这一步。官方priority_queue不是一个独立的数据结构,而是容器适配器:它内部持有一个底层容器对象(默认就是vector),所有操作都通过调用底层容器的接口加堆算法完成。这种"包装已有容器并提供专用接口"的设计模式,就叫适配器。
模板参数如下:
template < class T, class Container = std::vector<T>, class Compare = std::less<typename Container::value_type> > class MyPriorityQueue;三个参数含义明确:T是元素类型;Container是底层容器,必须有push_back、pop_back、front、operator[]、size等接口,默认用vector;Compare是比较器,默认std::less。如果哪天你不想用vector,也能换一个支持随机访问的容器进去,只要满足接口约定就行。
使用适配器的最大收益是封装。用户面对的是一个语义清晰的优先队列,不需要知道底层是堆、不需要手动调用四个堆算法,也不容易写错下标。把复杂细节藏起来,暴露简洁接口,这正是STL一贯的设计哲学。
4.2 比较器反直觉点:less为什么是大顶堆
这是面试出现率极高的一个坑。很多人以为std::less对应"小顶堆",因为less的字面含义是"小于"。实际上,在priority_queue里指定std::less<T>得到的是大顶堆,即top()返回最大值。
为什么?要回到堆维护比较规则。拿sift_down来说,STL的实现大概逻辑是:如果comp(arr[largest], arr[left])为true,说明arr[largest]按比较器定义"小于"arr[left],那么arr[left]优先级更高,需要换上来。这里comp决定的是"谁优先级更高",而不是"谁值更小"。
当comp是std::less<T>时,comp(a, b)返回a < b。于是"a的优先级比b更高"等价于"a > b"。换句话说,更大的数值被判定为更高优先级,堆顶自然就是最大值。反之,用std::greater<T>时,堆顶是最小值。
这个语义可以总结成一句话:priority_queue的比较器描述的是"什么条件下第一个参数比第二个参数更差",或者说"什么条件下需要把后者调到更靠前的位置"。自定义比较器时,想清楚这一点才不会写反。
比较器还必须满足严格弱序(strict weak ordering),即comp(a, a)必须为false、comp(a, b)为true时comp(b, a)必须为false。如果比较器对两个等价元素返回true,堆的排序就崩了。
4.3 完整手写代码与测试
先给之前的MyVector补三个方法,让它能被优先队列使用:
T& operator[](size_t idx) { return start_[idx]; } const T& operator[](size_t idx) const { return start_[idx]; } T* data() { return start_; } size_t size() const { return finish_ - start_; }然后组装我的优先队列:
template <typename T, typename Compare = std::less<T>> class MyPriorityQueue { public: MyPriorityQueue() = default; explicit MyPriorityQueue(const Compare& cmp) : comp_(cmp) {} void push(const T& value) { c_.push_back(value); sift_up(c_.data(), c_.size() - 1, comp_); } void pop() { std::swap(c_[0], c_[c_.size() - 1]); c_.pop_back(); sift_down(c_.data(), c_.size(), 0, comp_); } const T& top() const { return c_[0]; } bool empty() const { return c_.empty(); } size_t size() const { return c_.size(); } private: std::vector<T> c_; Compare comp_; };等一下,上面的c_我用了std::vector,但标题说手写STL,最好用我们自己的MyVector来组装,这样整条链路全是我们自己实现的。修改如下:
template <typename T, typename Compare = std::less<T>> class MyPriorityQueue { public: MyPriorityQueue() = default; explicit MyPriorityQueue(const Compare& cmp) : comp_(cmp) {} void push(const T& value) { c_.push_back(value); sift_up(c_.data(), c_.size() - 1, comp_); } void pop() { std::swap(c_[0], c_[c_.size() - 1]); c_.pop_back(); sift_down(c_.data(), c_.size(), 0, comp_); } const T& top() const { return c_[0]; } bool empty() const { return c_.empty(); } size_t size() const { return c_.size(); } private: MyVector<T> c_; Compare comp_; };注意sift_up和sift_down需要支持传入比较器。我把上一节的签名改成这样:
template <typename T, typename Compare> void sift_up(T* arr, size_t i, Compare comp) { while (i > 0) { size_t parent = (i - 1) / 2; if (!comp(arr[i], arr[parent])) { // 当前节点不比父节点"差"时停止 break; } std::swap(arr[i], arr[parent]); i = parent; } } template <typename T, typename Compare> void sift_down(T* arr, size_t n, size_t i, Compare comp) { while (true) { size_t largest = i; size_t left = 2 * i + 1; size_t right = 2 * i + 2; if (left < n && comp(arr[largest], arr[left])) { largest = left; } if (right < n && comp(arr[largest], arr[right])) { largest = right; } if (largest == i) break; std::swap(arr[i], arr[largest]); i = largest; } }这里sift_up的条件从"如果arr[i] > arr[parent]就交换"改成了"如果!comp(arr[i], arr[parent])就停止",实际逻辑保持一致:当比较器认为父节点不比当前节点差时,就不需要上浮。sift_down则是把"取较大子节点"的硬编码改成用比较器判断,这样同一套算法既能组成大顶堆也能组成小顶堆。
最后写一个验证程序:
#include <iostream> #include "MyPriorityQueue.h" int main() { MyPriorityQueue<int> pq; // 默认 std::less,大顶堆 pq.push(5); pq.push(1); pq.push(9); pq.push(3); while (!pq.empty()) { std::cout << pq.top() << " "; pq.pop(); } std::cout << std::endl; return 0; }输出结果:9 5 3 1。把比较器换成std::greater<int>,输出:1 3 5 9。到这里,从Vector到优先队列的完整链路已经跑通。
5. 常见问题与排查技巧实录
5.1 手写Vector时的高频错误
第一类是内存模型错误,很多人把operator new、placement new、delete混着用。正确姿势是:operator new负责拿裸内存、placement new负责构造对象、显式析构负责销毁对象、operator delete负责还内存。我把这四件事分开记,写代码就不容易乱。
第二类是忽略异常安全。前面代码里的try-catch不是摆设,当T的拷贝构造函数可能抛异常时,必须先处理新块再动旧块。如果你在处理线上写的是"先释放旧数据再拷贝新数据",一旦中途抛出异常,整个vector的数据就丢光了。很多生产环境的内存崩溃,根子就在这个微小的顺序问题上。
第三类是手写析构函数时直接free(start_)。free不调用T的析构函数,如果元素是std::string这类带指针的类型,内存就泄漏了。对STL容器来说,元素的生命周期管理是容器自身的责任,这块必须用显式析构完成。C++ Primer里有一句话说得很好:如果sizeof(T)很大或者T有非平凡析构,你更应该敬畏容器底层代码。
5.2 堆结构错乱的排查
优先队列常见的问题就是堆序被破坏,表现是top()返回的不是最大值(或最小值),或者某个元素丢了。我总结了三个排查方向。
一是比较器写反。最容易发生的场景:你想用小顶堆,结果在priority_queue里传了std::less,得到大顶堆,取到的是最大值。排查方法是打印前三个元素的顺序,并确认比较器语义。记住:想要小顶堆就传std::greater,这是坑,记住它比记住原理更实在。
二是比较器不满足严格弱序。如果你自定义的比较器内部用了<=而不是<,两个等价元素会被判断成"一个比另一个更优先",堆调整时可能出现死循环或者不稳定排序。排查时先构造一个只包含几个相同元素的堆,看程序是否异常。一般把<=改成<、>=改成>就正常了。
三是扩容后忘了重新让堆算法感知新地址。如果你不是用完整的MyPriorityQueue类,而是手动维护一块缓冲区并调用堆算法,扩容后继续用旧的data()指针去操作,会使堆结构错乱。正确做法是每次push后都从容器重新获取data()和size(),而不要缓存这些值。
5.3 手写版本与标准库版本的性能对比
很多人对手写容器有个迷思:觉得自己写的比标准库快。实际上,标准库经过了十多年的优化迭代,std::vector和std::priority_queue在多数情况下都比我这个教学版快,差距主要体现在:标准库针对不同类型做了特化分支、使用了更激进的移动策略、错误处理和分配器缓存也更精细。
我做过一个简单benchmark,往priority_queue里push随机数10万次再pop 10万次,本地机器上标准库大约比教学版快10%到15%。如果元素是int这种POD类型,差距会缩小,因为瓶颈主要在内存分配和函数调用开销。但手写版本的意义本来就不是替代标准库,而是理解机制、在面试中展示能力、在自定义特殊场景(例如固定容量堆、数组原地建堆)中提供基础代码。
工程上的建议是:除非你能明确说出"标准库实现无法满足我的需求",否则生产环境请直接用std::priority_queue和std::vector。手写的价值在"懂了之后用得更准",比如知道预分配、知道不能用错迭代器、知道比较器的坑,这些知识能让标准库在你手里发挥更高性能。
5.4 面试追问的延伸思路
手写完这两块之后,我建议你再往三个方向延伸一下:第一,std::vector的erase和insert为什么要O(n),为什么list可以O(1)插入,这是帮后续学链表做铺垫。第二,priority_queue的push_heap和pop_heap组合起来其实就是堆排序,你试着手写一个heap_sort,排序时把堆顶交换到数组末尾,反复pop_heap即可。第三,std::set的底层是红黑树,它和堆的最大差异在于"不修改可以找任意元素"而堆只能快速拿极值,理解这个差异,你在选型时就不会乱用。
这三个延伸,本质上是把"连续内存分配"和"堆序维护"两个知识点,扩展成对C++标准库全局设计的认知。等你把vector、heap、红黑树都吃透了,再看任何容器的源码,脑子里都会自动浮现出"这是什么结构、每个操作复杂度多少、迭代器会不会失效"三个问题。这种内化的能力,才是手写STL真正送给你的东西。
我个人在实际操作中的体会是:手写STL是最适合用来建立C++底层体感的方式,没有之一。第一次把push_back跑起来的时候,你可能只觉得"不过如此";但当你把异常安全、迭代器失效、比较器语义一个个踩过一遍,再回头用标准库,你会发现以前背过但没感觉的很多规则突然串起来了。如果你也想按这个路线练习,建议从这次的MiniVector和MiniPriorityQueue起步,慢慢往上加功能,比如reserve、insert、emplace、支持右值引用。每一步都能看到自己的代码离"工业级"又近了一点,这种成就感,远比背一百道面试题来得扎实。