尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++容器适配器底层机制与实战:stack、queue、priority_queue完全解读

C++容器适配器底层机制与实战:stack、queue、priority_queue完全解读 最近在群里带新人学C发现一个很有意思的现象很多人对vector、map、unordered_map如数家珍但一提到stack、queue、priority_queue就只会“哦那是STL里的容器”。再细问一句“它们底层是什么”基本上就沉默了。这个问题其实不只是新手很多写了几年C的人也不一定真把容器适配器这层窗户纸捅破过。这篇我就打算把容器适配器从底层机制到实际踩坑完整聊一遍尤其是底层容器的选择逻辑、priority_queue的比较器方向这类高频考点和实战误区一次说透。如果你是正在准备C面试的开发者或者已经用stack、queue写过不少算法题、却总觉得这些容器像“黑盒子”的人这篇文章会很对口。内容偏底层、偏机制但我会尽量用实际代码和场景讲明白“为什么是这样”而不是让你死记结论。1. 容器适配器到底是什么它不是容器是一个“转换层”先搞清楚一个最基础的问题容器适配器到底是个什么东西名字里带“容器”两个字但它在STL里的定位和vector、deque这些真正的容器完全不是一回事。学习这个区分比记住几个API重要得多。1.1 理解“适配器”三个字一个插座转换头的故事如果你用过那种“国标转美标”的电源转换插座你就能一秒理解容器适配器。插座本身不会发电也不会改变电流它只是把一种标准的插口转换成另一种形态让你原本插不进去的电器能正常用。容器适配器干的就是这件事。它内部不自己管理内存不自己存数据而是包裹一个现成的底层容器然后对外只暴露一个受限的、专门化的接口。你想要的“后进先出”也好“先进先出”也好它本质都是在一个普通容器默认是deque的基础上把不需要的操作藏起来把关键的操作重新起个名包装成栈或队列的语义。所以在源码里std::stack的定义大致长这样templateclass T, class Container std::dequeT class stack { public: // 典型接口 void push(const T value) { c.push_back(value); } void pop() { c.pop_back(); } T top() { return c.back(); } bool empty() const { return c.empty(); } size_t size() const { return c.size(); } protected: Container c; // 底层容器就这么挂着 };注意看protected这个c成员它就是一个普通的deque对象。stack的push就是调用deque.push_backpop就是调用deque.pop_backtop就是deque.back。所有的规矩和限制都是这一层包装自己定的底层容器本身并没有变。1.2 为什么称“适配器”而不是“容器”接口约束才是灵魂stack、queue、priority_queue这三个之所以被单独分成一类关键是它们的接口设计逻辑和普通容器完全不同。普通容器如vector、list追求的是“全功能”你能访问第一个元素也能访问最后一个还能用迭代器从头到尾遍历支持插入、删除、随机访问、扩容。这些容器的接口是多而全的牺牲的是语义上的“纯净”。而容器适配器追求的是“单一职责”stack只允许你在尾部操作queue只允许你在尾部进、头部出priority_queue只允许你访问和删除当前优先级最高的元素。你看不到迭代器不支持随机访问甚至std::stack连遍历能力都没有。这不是功能缺失而是刻意为之。一旦允许stack像vector那样随机访问你写代码时就很容易绕过“后进先出”的约束栈语义就被破坏。适配器通过接口屏蔽掉了底层容器的所有“多余能力”让使用者只能按照设计者预期的方向使用。从这个角度说适配器的核心价值不是数据存储而是接口约束。1.3 三个适配器的默认底层容器为什么是这样下面是三个适配器与底层容器的对应关系这个表建议直接背下来也建议理解背后的原因适配器默认底层容器可用底层容器原因简述stackdequevector、list、deque只在尾部操作deque尾部操作极高效queuedequelist、deque需要尾部进、头部出deque两头都能高效操作priority_queuevectorvector、deque堆算法需要随机访问同时要支持尾部插入删除stack和queue默认选deque而不是vector最直观的原因是deque支持头部插入删除且尾部操作同样高效。vector在尾部push_back当然也快但queue需要头部pop如果底层是vector每次pop_front都会把后面所有元素往前搬复杂度直接变成O(n)这不能忍。priority_queue为什么默认vector因为堆算法make_heap、push_heap、pop_heap在STL里是基于随机访问迭代器实现的vector完美满足这个条件而且比deque更紧凑、缓存更友好。用deque当然也可以但大多数场景下vector是最优解。提示网上经常有人问“为什么stack默认不用vector反而用deque”实际上这是历史选择和工程权衡的共同结果。stack用vector做底层完全没问题但deque对stack而言在分配大对象时可以减少元素复制次数deque是分段连续的又有push_front的能力属于兼顾了灵活性的默认值。2. 三大容器适配器把“接口”和“结构”拆开看这一章进入实操层面。我会把stack、queue、priority_queue三个适配器逐个拆解核心是它的操作语义、底层调用关系以及使用时的注意事项。2.1 stack一套严格的“后进先出”门禁std::stack的接口非常少好记也好用。它的模型就是一堆盘子你从最上面放盘子也只能从最上面拿盘子。#include stack #include iostream int main() { std::stackint st; st.push(1); st.push(2); st.push(3); std::cout size st.size() \n; // 3 std::cout top st.top() \n; // 3 st.pop(); // 弹出3没有返回值 std::cout top st.top() \n; // 2 return 0; }几个容易踩坑的点单独拎出来说第一pop没有返回值。新手最容易犯的错是int x st.pop()这是不能编译的。你要先int x st.top(); st.pop();两步走。这么设计的原因之一是为了异常安全如果pop既要移除元素又要返回元素返回值就涉及复制和移动一旦复制过程抛异常元素已经被移除了数据就丢了。分离top和pop让操作的语义更干净。第二stack不支持迭代器。std::stack没有begin()、end()也不可能用它来做范围for循环。如果你确实有遍历栈内容的需求要么另存副本要么手动用一个底层容器然后自己按栈规则操作。第三stack的底层容器是可以换的。比如写std::stackint, std::vectorint st;就明确要求用vector作为底层。这样栈的top仍然对应vector.back()只是代码里少了deque的头部开销在个别性能敏感场景下有差别。stack最常见的应用是表达式求值、括号匹配、函数调用栈模拟、深度优先搜索DFS。做DFS时如果你不想用递归stack就是天然的替代品。2.2 queue一条严格的“先进先出”流水线queue的语义是排队新元素从队尾进来老元素从队头出去。区别于stack的是它同时操作两端所以默认底层用deque用vector会很尴尬。#include queue #include iostream int main() { std::queueint q; q.push(1); q.push(2); q.push(3); std::cout front q.front() \n; // 1 std::cout back q.back() \n; // 3 q.pop(); // 弹出1 std::cout front q.front() \n; // 2 return 0; }这里也注意queue的pop同样没有返回值。先q.front()拿值再q.pop()移除。front返回的是队头元素的引用back返回的是队尾元素的引用两者都可以通过引用修改队列里的元素内容但不能改变“队列顺序”。关于queue的底层有一个细节值得提deque的头部插入删除看起来是O(1)的但它并不是一个连续的地址空间缓存友好度比vector稍差。如果你只是做简单的BFS且队列长度非常大可以观察一下性能。如果你自己决定换成std::list做底层要注意list的节点是分散的遍历访问的局部性更差反而可能更慢。不要想当然地以为链表就比deque快。queue在算法题中最经典的应用是广度优先搜索BFS特别是树的层序遍历、图的最短路径无权图、拓扑排序这些场景。消息队列、任务调度这类业务场景也天然适合用它表达。2.3 priority_queue带优先级的“插队”机制priority_queue是三者里最麻烦、也最容易被面试官问出水平的一个。它和stack、queue最大的区别是它不是一个纯粹的队列模型而是一个堆。#include queue #include iostream int main() { std::priority_queueint pq; pq.push(5); pq.push(1); pq.push(9); pq.push(3); std::cout top pq.top() \n; // 9默认是最大值优先 pq.pop(); std::cout top pq.top() \n; // 5 return 0; }priority_queue默认是一个“大根堆”每次top()拿到的是整个堆里最大的元素。你可以通过给模板传递不同的比较器来变成“小根堆”比如std::priority_queueint, std::vectorint, std::greaterint min_pq;这一行代码在面试里考得特别勤而且很多人栽在greater上。原因是我们平时写排序std::sort(v.begin(), v.end(), std::greaterint())是降序排列也就是大的在前而在priority_queue里std::greaterint反而让最小的元素在堆顶。这个方向感的问题我在后面专门开一节讲。priority_queue没有front()、back()只有一个top()。你不能直接修改堆里的元素如果确实要改需要把元素拿出来、改完再push回去或者用“懒删除”技巧。它也不能遍历因为堆结构只保证父子节点间的大小关系不保证兄弟节点间有任何顺序。性能上push插入一个元素的时间复杂度是O(log n)pop弹出堆顶也是O(log n)top是O(1)。底层调用的是STL的堆算法push_heap和pop_heap。所以如果你需要频繁获取当前数据流中的最大值或最小值priority_queue几乎是首选。典型应用场景包括TopK问题、合并K个有序链表、数据流中的中位数、任务调度优先级高的先执行、Dijkstra最短路径算法中的优先队列优化。这些都是面试算法题里的常客。3. 容器适配器的进阶操作自定义底层容器和比较器说完三个适配器的基本用法很多人会觉得这不就是一个API问题吗背一背就会了。但如果只是停留在“会用”那“吃透”两个字就谈不上。真正拉开差距的是你能不能按需定制它。3.1 手动指定底层容器什么时候有必要三个适配器的第二个模板参数都是底容器类型可以在实例化时显式传进去。比如#include stack #include vector #include queue #include list std::stackint, std::vectorint st_vec; // 栈使用vector std::queueint, std::listint q_list; // 队列使用list std::priority_queueint, std::dequeint pq_deque; // 优先队列使用deque但底层容器不是随便选的每个适配器对底层容器有明确的“接口要求”。以stack为例它要求底层容器支持empty()、size()、back()、push_back()、pop_back()。这些操作std::vector、std::list、std::deque全都支持所以都能用。queue要求底层容器支持empty()、size()、front()、back()、push_back()、pop_front()。这就把vector排除掉了因为vector没有pop_front()效率也不行。用std::list、std::deque都会合法。priority_queue要求底层容器支持empty()、size()、front()对应top、push_back()、pop_back()且需要随机访问迭代器RandomAccessIterator。这就把list彻底排除在外。所以priority_queue的合法底层容器只有vector和deque。注意priority_queue没有front()接口但底层要求有front()因为top()实现时访问的是底层容器的front()也就是堆顶元素。实际开发中什么情况下值得手动指定底层容器第一如果你明确知道数据总量非常大且对尾部操作的性能有更高要求可以把stack的底层从默认的deque换成vector。vector在内存连续性、缓存命中率上通常优于deque代价是扩容时可能整体搬迁元素。但stack只在尾部操作vector的尾部操作摊销O(1)并不会出问题。第二当你需要和某些自定义容器对接时只要你的自定义容器提供了适配器要求的接口理论上它就能作为底层容器。比如一个静态数组构成的stack你可以自己写一个轻量容器然后塞给std::stack当底层。这种用法在嵌入式开发和内存受限环境里很实用。不过我的建议是**如果没有明确理由保持默认就好。**标准库作者选择的默认值是根据大多数场景的平均表现权衡出来的。过早优化是最常见的自我感动。3.2 自定义比较器priority_queue里的方向陷阱priority_queue的完整模板参数是template class T, class Container std::vectorT, class Compare std::lessT class priority_queue;第三个参数Compare是用来决定“谁在堆顶”的。这里有一个特别反直觉的设定Compare传给priority_queue的语义和传给std::sort的语义是反的。我们写std::sort(v.begin(), v.end(), std::lessint())得到的是升序也就是less表示“小的在前”。但是在priority_queue里std::lessint构建出来的是大根堆也就是“大的在前”。为什么因为底层堆算法在判断两个节点是否需要交换时使用的规则是如果比较器返回true说明“后者优先级更高”还是“前者优先”我翻了很多资料最直接的解释是STL的堆算法内部在比较父子节点时调用方式大致等价于comp(parent, child)。对于std::less当parent child时返回true说明parent应该下沉、child应该上浮。于是大的节点不断上浮到堆顶最终形成大根堆。用代码验证一下#include iostream #include queue #include vector #include functional int main() { // 默认less 大根堆 std::priority_queueint max_heap; max_heap.push(10); max_heap.push(30); max_heap.push(20); std::cout max_heap.top() \n; // 30 // 显式传 greater 小根堆 std::priority_queueint, std::vectorint, std::greaterint min_heap; min_heap.push(10); min_heap.push(30); min_heap.push(20); std::cout min_heap.top() \n; // 10 return 0; }所以记住一个口诀**想要最大值在堆顶用less想要最小值在堆顶用greater。**如果你是自己定义仿函数return a b的语义就是less对应大根堆return a b就是greater对应小根堆。3.3 自定义类型作为元素不仅要有operator还得注意方向如果你要在priority_queue里存自定义类型默认情况下得给这个类型定义operator因为默认比较器std::lessT靠它来工作。#include queue #include vector #include string struct Task { int priority; std::string name; }; // 注意这里定义的是 优先级越高越应该排在堆顶 的规则 // operator 返回 true 时表示当前对象优先级更低 bool operator(const Task a, const Task b) { return a.priority b.priority; // 数字大 优先级高 排在前 } int main() { std::priority_queueTask tasks; tasks.push({3, low}); tasks.push({5, high}); tasks.push({4, medium}); std::cout tasks.top().name \n; // high因为priority最大 return 0; }这里有个细节operator的语义直接影响堆顶是谁。如果你希望priority小的排前面那operator就应该写成return a.priority b.priority;。看起来逻辑绕但因为priority_queue默认把less当作比较器而堆又要求比较结果与“元素位置”之间的映射关系所以你看到的直观结论就是operator 的方向决定了堆顶的方向。如果不想重载全局operator你也可以直接给priority_queue传一个自定义比较器struct TaskCompare { bool operator()(const Task a, const Task b) const { return a.priority b.priority; } }; std::priority_queueTask, std::vectorTask, TaskCompare tasks;这种写法更清晰也避免污染全局命名空间工程上更推荐。4. 实战用容器适配器解决高频算法题理论说太多容易飘下面我用几个经典的算法题演示容器适配器具体怎么用以及用的时候有哪些容易卡壳的细节。4.1 括号匹配stack的经典题题目是给定一个只包含()、[]、{}的字符串判断括号是否合法。解法就是用栈。#include stack #include string #include unordered_map bool isValid(const std::string s) { if (s.size() % 2 1) return false; std::stackchar st; std::unordered_mapchar, char pairs { {), (}, {], [}, {}, {} }; for (char ch : s) { if (pairs.count(ch)) { // 是右括号栈空说明没有左括号配对直接失败 if (st.empty() || st.top() ! pairs[ch]) { return false; } st.pop(); } else { // 是左括号入栈 st.push(ch); } } return st.empty(); }这里的两个细节值得说第一先判断st.empty()再访问st.top()很多人会漏掉这个判断直接st.top()在字符串是)]这种纯右括号时直接未定义行为。第二最后返回st.empty()而不是return true因为可能左括号多出来比如(()这种情况栈不为空说明匹配失败。4.2 用两个栈实现队列面试题里的“明星题”这道题本身问的是如何利用stack的LIFO特性模拟queue的FIFO语义。可以把stack当进队容器出队时把栈底元素翻出来就需要把元素全部倒到另一个栈再弹顶。#include stack class MyQueue { public: void push(int x) { input.push(x); } int pop() { if (output.empty()) { transfer(); } int top output.top(); output.pop(); return top; } int peek() { if (output.empty()) { transfer(); } return output.top(); } bool empty() const { return input.empty() output.empty(); } private: void transfer() { while (!input.empty()) { output.push(input.top()); input.pop(); } } std::stackint input; std::stackint output; };这个题的核心在transfer的时机只有output为空时才倒数据。否则如果频繁push、pop交错执行每次都倒来倒去会把O(1)的均摊复杂度变成O(n)的无效拷贝。用两个栈实现队列每个元素最多入栈两次、出栈两次整体均摊O(1)代价是常数项变大。这也是面试官很喜欢追问的点为什么均摊是O(1)4.3 TopK问题priority_queue小根堆的妙处给定一个整数数组找出其中第K大的元素。最容易想到的是排序但用priority_queue可以在O(n log k)内解决且只需要维护大小为K的小根堆。#include queue #include vector int findKthLargest(std::vectorint nums, int k) { // 小根堆堆顶是当前堆里最小的元素 std::priority_queueint, std::vectorint, std::greaterint min_heap; for (int num : nums) { min_heap.push(num); if (min_heap.size() k) { min_heap.pop(); // 弹出当前堆顶也就是K个元素里的最小值 } } return min_heap.top(); }思路解释维持一个大小为K的小根堆。当堆大小超过K时堆顶就是这K1个元素里最小的那个弹掉它剩下的依然是当前最大的K个。遍历完整数组后堆里的K个元素就是全部数据中最大的K个而堆顶就是这K个里最小的也就是第K大。如果不加比较器默认大根堆在这里就不好使因为一旦堆顶弹出的是最大的你就丢失了最大的那个候选答案。4.4 BFS层序遍历queue做广度优先树的层序遍历是queue的经典使用场景。核心在于用队列层级地保存节点并通过记录当前队列大小来控制每一层的边界。#include queue #include vector struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} }; std::vectorstd::vectorint levelOrder(TreeNode* root) { std::vectorstd::vectorint res; if (!root) return res; std::queueTreeNode* q; q.push(root); while (!q.empty()) { int level_size q.size(); std::vectorint level; for (int i 0; i level_size; i) { TreeNode* node q.front(); q.pop(); level.push_back(node-val); if (node-left) q.push(node-left); if (node-right) q.push(node-right); } res.push_back(level); } return res; }层序遍历里最容易犯的错是把for循环条件写成for (int i 0; i q.size(); i)因为q.size()在循环中会不断变化。所以一定要一开始就把当前层的节点数存进level_size里再根据这个固定值遍历。这个既是代码习惯问题也考查了队列大小随出队入队动态变化的理解。5. 底层原理与易错点这些才是“吃透”和“用过”的分水岭前四章解决了“怎么用”这一章来解决“为什么会这样”。面试里被追问、实际项目里踩坑基本都集中在这些底层机制上。5.1 为什么容器适配器不支持遍历和迭代器这是面试八股的高频题。你直接回答“它就是没有”会被认为没理解你要回答“因为它设计上就不是用来遍历的”才到位。容器适配器的设计目标是提供一个严格的、受限的数据结构接口。比如stack它的本质是LIFO如果你可以随意访问中间任意元素或者在中间插入、删除那栈的语义就失效了。适配器通过不暴露迭代器从编译层面就禁止了这类误用。另一个层面是效率priority_queue内部是一个堆堆本身就不是按序排列的遍历它没有意义queue内部虽然可能是deque可以遍历但如果你真的需要遍历应该直接用deque而不是给queue包一层壳。设计者希望你在选择数据结构时就想清楚要遍历选原始容器要约束语义选适配器。5.2 priority_queue不能修改内部元素的真相这问题源于std::priority_queue对元素访问的限制。它只提供const的top()所以你不能通过top()修改元素值。比如你想把堆内某个元素从5改成10是做不到的因为一旦修改会破坏堆的有序性。实际操作中有两种替代方案方案一修改后重新入堆。先把旧元素存到一个临时位置pop掉再重新push进去代价是O(n)。方案二懒删除。用priority_queue配合unordered_set做一个“延迟删除优先队列”。当你要删除某个元素时不直接操作堆而是记录在“待删除集合”里。每次取top时判断当前堆顶是否在集合里如果在就弹掉同时从集合里移除直到堆顶是真正需要处理的元素。这个技巧在Dijkstra等需要频繁“更新”节点权值的算法中非常常见。#include queue #include unordered_set #include functional class LazyPriorityQueue { public: void push(int value) { pq.push(value); } void erase(int value) { lazy.insert(value); } int top() { while (!pq.empty() lazy.count(pq.top())) { lazy.erase(pq.top()); pq.pop(); } return pq.top(); } void pop() { top(); // 先清掉延迟删除的堆顶 pq.pop(); } private: std::priority_queueint pq; std::unordered_setint lazy; };懒删除的优点是避免O(n)的重建缺点是如果你有大量“无效”元素积压在堆顶附近实际取出真正元素的代价会增加。但通常它比一遍遍重建堆要快得多。5.3 底层容器的“迭代器失效”问题会影响适配器吗vector、deque在扩容或插入元素时会发生迭代器失效这是原始容器的问题。容器适配器没有暴露迭代器所以你在使用适配器时根本不会直接操作迭代器于是这个问题被“隔离”了。但注意如果你修改了适配器底层的容器比如你拿stack继承类访问了c成员或者你直接把底层容器替换掉那vector扩容导致的引用失效问题依然存在。比如下面这种写法就不推荐class MyStack : public std::stackint { public: void removeMiddle() { // 通过继承访问底层容器c // 操作c可能会导致迭代器失效 } };标准库里没有规定派生类可以安全地操作底层容器而且stack的析构函数也不是virtual。永远不要继承标准库容器去修改内部数据这是很多C项目里的红线。5.4 容器适配器线程安全吗容器适配器本身不提供任何线程安全保证。push和pop如果被多个线程并发调用结果未定义。这里建议不要只依赖容器本身要自己加锁或者用更高层的并发数据结构。有一种错误认知是“deque是分段存储的所以并发访问不同段是安全的”这种说法非常危险。标准库容器只保证“不同的对象实例”之间互不干扰同一个对象没有任何多线程安全承诺。你如果做生产者-消费者模型最简单的做法是在queue外面包一层std::mutex和std::condition_variable或者直接用std::deque加锁而不是硬套适配器。5.5 高频易错点自查表我把平时开发、面试里遇到的常见问题做成了一张表方便直接翻阅参考问题现象原因解决调用stack.pop()后拿不到返回值编译报错或拿到的是被移除前的值pop只负责移除不返回先top()后pop()queue默认底层为什么不是vector用vector当底层会编译失败vector没有pop_front()用deque或listpriority_queue默认是大根堆还是小根堆top()返回最大值默认比较器是less符合堆算法语义想要小根堆传greater自定义类型放进priority_queue报错缺少operatorstd::lessT需要operator重载operator或提供自定义比较器for循环里动态判断q.size()导致层序出错遍历层数不对混层size()随push/pop变化先保存level_size快照想要遍历栈/队列/堆没有迭代器设计上不允许遍历换用原始容器或复制到vector修改priority_queue里的元素值编译不通过或结果不对top()返回const引用懒删除或重插入stack默认底层是deque自己改成vector后不习惯代码性能未必更好内存布局不同根据场景压测后再决定并发地对容器适配器进行读写数据错乱程序崩溃标准容器非线程安全自行加锁或用并发数据结构5.6 这些小技巧能让你用得更顺手再讲几个我实际工作中觉得有用的点普通教程里很少提。第一容器适配器之间可以互相嵌套。比如std::stackstd::queueint在表达某种“任务栈”时很好用每个栈元素是一个子队列。这种组合在业务逻辑分支比较多的时候比写一堆分支判断要清晰。第二利用vector的operator和字典序。如果你想在priority_queue里按“多个字段”排序可以让元素类型是自己写的结构体然后在operator里按多个字段依次比较。这种办法比用std::pair加自定义比较器更容易读代码。第三在 LeetCode 或 ACM 场景中priority_queue配合int类型时你甚至可以避免定义比较器直接用std::pairint, int因为pair本身自带字典序比较规则。比如求“某个权重最大且编号最小”的元素pair的默认比较就很方便。第四queue的front()和back()返回的都是引用你可以原地修改元素但不改变队列顺序。这个特性在做 BFS 时如果需要对当前层数据做聚合统计可以很自然地用引用减少拷贝。结语与一点个人体会容器适配器这几个类从API数量上看少得可怜好像很快就能学完。但真要“吃透”关键在于你愿不愿意往底层多走几步去看一眼stack的成员变量到底是什么去亲手把priority_queue的底层堆算法跑一遍用std::greater和std::less来回试试看见堆顶的变化。踩过这些坑之后再回头看你会觉得它们不是记忆负担而是顺理成章的设计。我做C这些年最大的感受之一是很多时候写代码慢、出bug多不是因为语言语法不够熟而是对标准库每个组件背后的“设计意图”理解不够深。容器适配器就是其中一个典型缩影——它把“接口”和“实现”分离到了极致也正是这个设计让栈、队列、优先队列这三个看似简单的数据结构在工程里变得异常稳固。如果你现在还在死记API我建议你打开编译器照着这篇文章里的例子把底层容器换来换去试几遍。试完之后你对接下来的面试题和工程问题都会有种豁然开朗的感觉。
返回列表