news 2026/10/3 3:01:05

C++ list容器从使用到模拟实现:迭代器失效与高频操作详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++ list容器从使用到模拟实现:迭代器失效与高频操作详解

坦白讲,C++的list是我见过最容易被“低估”的STL容器。平时用push_back加数据跑得很开心,可一旦面试官问起“迭代器为什么失效”“insert为什么不导致迭代器失效”,或者要求你徒手模拟一个list,很多人就卡住了。我自己就吃过这个亏:当年面试手撕list模拟实现,写了半小时还踩了typename和迭代器封装的坑;后来在项目里用list维护高频插入删除的任务队列,又踩了缓存不友好和sort用错的坑。这篇文章把list的使用细节和底层模拟实现一次性讲透,适合正在学STL的初学者,也适合准备面试和想深挖源码的进阶读者。

1. 先搞清楚list到底是什么

1.1 双向循环链表长什么样

list在STL里是一个双向循环链表,每一个节点除了存数据之外,还有两个指针:prev指向前一个节点,next指向后一个节点。为什么强调“循环”?因为容器内部有一个不存数据的哨兵头节点(也叫header节点),链表尾部节点的next指向这个哨兵,哨兵的prev指向尾部节点。这样一来,end()位置就是哨兵节点的位置,遍历到end()就说明转完了一圈。

这个结构带来的直接结果就是:begin()指向哨兵的下一个节点,end()指向哨兵本身。空链表时,哨兵的next和prev都指向自己。所以你看list的empty()其实是判断begin() == end(),也就是头节点的前后指针是否都指向自己。模拟实现的时候,只要维护好这个哨兵节点,空链表、单节点、多节点的情况都能统一处理,不用写一堆if (head == nullptr)的特殊逻辑。

1.2 list和vector、forward_list怎么选

很多人选容器就是“一拍脑袋”,其实你只需要看两个维度:随机访问频率和中间插入删除频率。

vector是连续内存,支持O(1)随机访问,[]和at()随便用。但它在中间插入删除时要搬移元素,复杂度是O(n),而且扩容时会涉及整体拷贝或移动。list正好相反:任何位置的插入删除只需要改前后指针,时间复杂度O(1),但它没有随机访问能力,你想取第5个元素,只能从头部或尾部++过去,复杂度O(n)。

forward_list是单向链表,比list省一个prev指针的内存,但插入删除只能在一个方向上操作,而且没有size()接口,push_back也别想用,只能push_front。它是C++11为了极致节省内存而设计的,实际业务代码里用得很少。如果你需要双向遍历、需要从尾部插入,老老实实用list。

我之前做过一个任务分发模块:多个生产者往队列尾部塞任务,消费者从头部取任务,同时还要支持随时删除还没执行的任务。这种情况我用list就非常合适,因为中间的删除和尾部的追加都是O(1),而且删除一个任务不影响其他任务的内存位置,迭代器还能安全保存。

1.3 list适合干什么,不适合干什么

list最适合的场景有两类:一类是需要频繁在已知位置进行插入删除,另一类是需要把节点从一个链表拼接到另一个链表,也就是splice操作。这两个场景下,vector无论如何都做不到O(1)。

但list有个很隐蔽的缺点,新手容易忽视:缓存不友好。链表节点在内存里是零散分布的,你遍历整个链表时,CPU缓存命中率极低。我实测过,同样遍历100万个int,vector比list快一个数量级都不止。所以如果是“建好链表然后只是从头到尾遍历”的场景,list反而不如vector。

另一个不适合的场景是“随机访问为主”。比如你写一个排行榜,要频繁按索引取第N名,list就完全不行。这时候要么vector,要么deque。deque是分段连续内存,两头插入删除O(1),随机访问O(1)但多一次间接跳转,属于list和vector之间的折中。

2. list接口使用要点(拿过来就能用)

2.1 构造与赋值的方式

list的构造方式有五种,我建议全部记下,面试和写代码都很常用。

#include <list> #include <iostream> using namespace std; int main() { // 1. 默认构造:空链表 list<int> l1; // 2. 构造 n 个 value list<int> l2(5, 10); // 5个10 // 3. 迭代器区间构造 int arr[] = {1, 2, 3, 4, 5}; list<int> l3(arr, arr + 5); // 4. 拷贝构造 list<int> l4(l3); // 5. 初始化列表 list<int> l5 = {1, 2, 3, 4, 5}; // 6. 移动构造(C++11) list<int> l6(std::move(l5)); for (int x : l6) cout << x << " "; return 0; }

要注意:list没有operator[]和at(),因为它不支持随机访问。这是它和vector最大使用差异。也不要试图用l.begin() + 3这种操作,编译直接报错。

赋值方面,除了拷贝赋值运算符operator=之外,assign有两个重载比较实用:l.assign(3, 7)将链表重置为3个7,l.assign(begin, end)用迭代器区间重置。它们会先清空原有元素再重新填充。

2.2 常用接口与迭代器

我把最常碰到的接口列出来,你直接照着用就行。

  • 迭代器相关:begin()、end()、rbegin()、rend(),以及C++11的cbegin()、cend()。反向迭代器通过++向前移动,注意rbegin()对应最后一个元素。
  • 容量相关:empty()、size()、max_size()。
  • 元素访问:front()、back()。注意没有operator[]。
  • 修改相关:push_front、pop_front、push_back、pop_back、insert、erase、swap、clear、resize、assign。

给个综合例子:

list<int> lst = {1, 2, 3}; lst.push_front(0); // 0 1 2 3 lst.push_back(4); // 0 1 2 3 4 lst.pop_front(); // 1 2 3 4 lst.pop_back(); // 1 2 3 lst.resize(5, 99); // 1 2 3 99 99 auto it = lst.begin(); ++it; lst.insert(it, 100); // 在第二个位置前插入100 lst.erase(lst.begin()); // 删除第一个元素 for (int x : lst) cout << x << " "; // 输出: 100 2 3 99 99

insert的返回值是指向新插入元素的迭代器,erase的返回值是指向被删除元素下一个位置的迭代器,这是C++11之后的规定,非常关键。尤其是erase,如果你写lst.erase(it)后还继续用it,那就是悬空指针,出问题只是时间问题。

2.3 算法型成员函数:sort、unique、merge、splice、remove、reverse

list自带一批算法成员函数,因为通用算法(比如std::sort)需要随机访问迭代器,链表用不了。这些接口高频出现,逐个说。

sort:lst.sort()默认升序,也可以传仿函数或lambda:lst.sort(greater<int>())实现降序。它内部是归并排序,稳定。不要拿std::sort直接对list用,编译会报错。也不要以为list::sort是简单的冒泡,它底层是归并,复杂度O(n log n)。

unique:lst.unique()只删除相邻且相等的元素。如果链表是{1, 1, 2, 2, 2, 1},unique()之后是{1, 2, 1},最后一个1不会被删,因为不相邻。想全局去重,先sort再unique。

merge:合并两个已排序的链表。lst1.merge(lst2)执行后,lst2会变成空链表。这个操作是O(m+n)的,比把lst2元素逐个插入lst1快很多,因为底层是链表节点指针的拼接。

splice:把一个链表的节点直接“嫁接”到另一个链表,不拷贝不移动元素,复杂度O(1)。最常用的形式是lst1.splice(pos, lst2),把lst2全部节点拼接到lst1的pos位置之前。这个函数是list相比vector的杀手锏:如果两批任务需要合并,用splice比反复insert效率高一个量级。

remove / remove_if:lst.remove(5)删除所有值为5的元素;lst.remove_if([](int x){ return x % 2 == 0; })删除所有偶数。它是真正的条件删除,和unique性质不一样。

reverse:lst.reverse()反转链表,O(n)复杂度,但只是改指针方向,不需要移动数据。

2.4 使用list的3个隐蔽坑

第一个坑:不要保存end()迭代器做插入判断。end()是一个固定的哨兵迭代器,但这个迭代器在插入后仍然有效,可它指向的位置可能不再是“最后一个元素的下一位置”了。具体说,你保存auto endIt = lst.end();然后lst.push_back(5),endIt依然指向哨兵,用起来没问题。但如果你先insert,再判断it != endIt,逻辑上可能出错。稳妥做法是每次需要end()就现场调用。

第二个坑:size()是O(1),别自己遍历算长度。STL的list在C++11之后要求size()必须是常数复杂度,实现里维护了_size字段。所以你写for (auto it = lst.begin(); it != lst.end(); ++it) ++cnt;完全是白费时间。

第三个坑:不要把迭代器当指针用。常见错误是auto it = lst.begin(); it->someMethod();这种指针式访问其实是合法的,->被重载了,没问题。但如果你写*(it + 1)这种随机偏移,编译不过。链表迭代器只支持++、--,不支持+ n。记住:std::advance(it, n)才是通用写法。

3. 迭代器失效:面试必问的list专属问题

3.1 为什么list的insert不失效,erase会失效

这是面试高频题,答案的关键在“内存连续性”。vector插入元素可能触发扩容,原来元素整体搬到了新内存,所以所有迭代器都可能失效;而list的每个节点是独立分配在堆上的,插入新节点只是改了几个指针,已经有节点的内存地址完全不变,所以除了指向被插入位置之前的那个迭代器之外,其他迭代器全部有效。注意,即使insert位置在中间,原来指向那个位置元素的迭代器依然有效,因为元素本身没动,只是多了一个前驱节点。

erase为什么失效?因为被删除的那个节点已经delete了,指向它的迭代器保存的是这个已释放内存的地址,再解引用就是悬空访问,典型UB。正确的用法是:it = lst.erase(it);,用返回值接过下一个位置。这在删除满足条件的元素时特别重要:

list<int> lst = {1, 2, 3, 4, 5, 6}; for (auto it = lst.begin(); it != lst.end();) { if (*it % 2 == 0) it = lst.erase(it); // erase返回下一个元素迭代器 else ++it; }

3.2 保存end()迭代器的后果

list的end()指向哨兵节点。哨兵节点是容器成员,不会被删除,所以end()理论上“永远有效”。但你保存它之后,事情会变得诡异。比如:

list<int> lst = {1, 2, 3}; auto endIt = lst.end(); lst.push_back(4); // 此时 endIt 仍然指向哨兵,输出*endIt没有意义,但比较 == 是和 end() 等价的

真正危险的是:如果你保存的endIt来自另一个链表,或者你在erase时把哨兵节点删了。虽然erase(end())本身也是UB,标准不允许删除end()。更常见的问题是:先保存auto it = lst.end();然后it = lst.erase(--lst.end());,因为erase返回下一个迭代器,而下一个就是end(),结果it又成了合法end(),这种没问题。但如果你保存的迭代器是指向被删除节点的,那就彻底悬空了。

我的建议是:不要保存end(),不要保存begin(),不要保存任何可能被erase的迭代器。用的时候重新拿,插入删除后再用返回值重新赋值,养成这个习惯,链表相关的悬空问题能少一大半。

3.3 vector和list迭代器失效对比

把两者的失效规则放一起,一目了然:

操作vector迭代器/引用list迭代器/引用
插入元素可能全部失效(扩容)或插入位置之后失效原有全部有效
删除元素被删位置之后全部失效仅被删元素失效
push_back扩容则全部失效不影响已有迭代器
push_front不支持(deque才支持)不影响已有迭代器
重新赋值/clear全部失效全部失效(节点被释放)

面试时这么答,基本能把“容器迭代器失效”这个专题拿下。实际写代码时,vector优先考虑“下标”,list优先考虑“迭代器”,因为链表没有随机访问能力。

4. 模拟实现list:从0开始搭建

4.1 节点结构和整体设计

模拟实现前先设计节点:

template <class T> struct ListNode { ListNode<T>* _prev; ListNode<T>* _next; T _data; ListNode(const T& data = T()) : _prev(nullptr), _next(nullptr), _data(data) {} };

这里_prev和_next是原始指针,_data直接存储数据。有人问为什么不存unique_ptr或shared_ptr?原因很简单:链表节点互相指向的指针是“借用关系”,不是所有权关系。每个节点的生命周期由链表容器统一管理,如果用shared_ptr反而容易造成循环引用,用unique_ptr又没法实现prev指针。所以裸指针在这个场景是合理的。

整体设计是三件套:ListNode<T>节点、list_iterator<T, Ref, Ptr>迭代器、list<T>容器本体。真正的STL实现还要考虑空间配置器、reverse_iterator适配、const迭代器转换、异常安全等,但我们模拟实现聚焦核心逻辑,先把主链路打通。

4.2 迭代器封装:为什么不能直接拿指针

你可能想问:vector的迭代器直接就是T*,那list的迭代器能不能直接拿ListNode<T>*?

不行。原因有两个:

第一,operator++的语义不同。原生指针++会跳到sizeof(T)之后的内存地址,但链表的下一个节点在堆上任意位置,并不在当前节点地址后面。原生指针无法提供“跳到_next指向的节点”这个行为。所以迭代器必须重载++和--,本质是返回_next和_prev。

第二,解引用语义不同。对ListNode<T>*解引用,你得到的是节点本身ListNode<T>,而不是用户数据T。用户写出*it想拿到的是T&。这必须通过重载operator*返回_data来实现。

所以迭代器必须是一个包装类,内部持有一个ListNode<T>*指针,对外模拟指针的行为。这也是STL迭代器设计中最精妙的地方:把所有容器差异隐藏在统一接口后面。

模板参数设计成三个:T是数据类型,Ref是引用类型,Ptr是指针类型。这样做是为了让iterator和const_iterator共用一份迭代器代码:

template <class T, class Ref, class Ptr> struct list_iterator { typedef ListNode<T> Node; typedef list_iterator<T, Ref, Ptr> self; Node* _node; // 指向某个链表节点 list_iterator(Node* node = nullptr) : _node(node) {} Ref operator*() { return _node->_data; } Ptr operator->() { return &_node->_data; } self& operator++() { _node = _node->_next; return *this; } self operator++(int) { self tmp(*this); _node = _node->_next; return tmp; } self& operator--() { _node = _node->_prev; return *this; } self operator--(int) { self tmp(*this); _node = _node->_prev; return tmp; } bool operator==(const self& s) const { return _node == s._node; } bool operator!=(const self& s) const { return _node != s._node; } };

注意operator->返回的是&_node->_data,也就是T*,这样it->member才能访问到数据成员的内部字段。

4.3 list类主体一:构造、析构、拷贝控制

链表本体维护一个哨兵节点_head和一个大小字段_size。构造函数最重要的是初始化哨兵并让_head->_prev = _head->_next = _head,形成自环。

template <class T> class list { public: typedef ListNode<T> Node; typedef list_iterator<T, T&, T*> iterator; typedef list_iterator<T, const T&, const T*> const_iterator; private: Node* _head; size_t _size; void create_head() { _head = new Node; _head->_prev = _head; _head->_next = _head; _size = 0; } public: list() { create_head(); } iterator begin() { return iterator(_head->_next); } iterator end() { return iterator(_head); } const_iterator begin() const { return const_iterator(_head->_next); } const_iterator end() const { return const_iterator(_head); }

再看拷贝构造和赋值。链表是深拷贝容器,必须一个节点一个节点复制。不能用memcpy,因为T可能不是POD类型。核心逻辑是遍历源链表,逐节点push_back:

list(const list<T>& lt) { create_head(); for (const auto& x : lt) push_back(x); } list<T>& operator=(const list<T>& lt) { if (this != &lt) { clear(); for (const auto& x : lt) push_back(x); } return *this; } ~list() { clear(); delete _head; _head = nullptr; }

clear()负责删除所有数据节点,保留哨兵节点:

void clear() { Node* cur = _head->_next; while (cur != _head) { Node* next = cur->_next; delete cur; cur = next; } _head->_prev = _head; _head->_next = _head; _size = 0; }

很多人在模拟实现里写while (size() > 0) pop_back();,这也行,但每次都调用erase和--_size,多一层函数开销,不如直接用clear遍历一次删除干净。

4.4 list类主体二:插入删除核心操作

插入的核心是insert,它在pos指向的节点之前插入新节点。关键是指针连接顺序不要写错,最好画个图再写:

iterator insert(iterator pos, const T& value) { Node* cur = pos._node; Node* prev = cur->_prev; Node* newNode = new Node(value); newNode->_prev = prev; newNode->_next = cur; prev->_next = newNode; cur->_prev = newNode; ++_size; return iterator(newNode); }

先让新节点建立与前后节点的连接,再修改前后节点的指针。顺序错了会导致链表断链。如果pos是end(),cur就是哨兵,prev是最后一个节点,新节点插到了链表尾部,这就是push_back的实现基础。所以:

void push_back(const T& value) { insert(end(), value); } void push_front(const T& value) { insert(begin(), value); }

删除的核心是erase:

iterator erase(iterator pos) { Node* cur = pos._node; Node* prev = cur->_prev; Node* next = cur->_next; prev->_next = next; next->_prev = prev; delete cur; --_size; return iterator(next); }

pop_back和pop_front都能用erase组合出来:

void pop_back() { erase(--end()); } void pop_front() { erase(begin()); }

这里有个细节:erase(begin())没问题,因为begin()指向第一个数据节点;erase(--end())也没问题,因为--让end()向前走一步变成最后一个数据节点。但千万不要写erase(end()),那会把哨兵删掉,整个链表直接崩。

5. 完整模拟实现代码与测试(可直接抄作业)

5.1 完整代码清单

把上面所有片段拼成一个完整可编译的头文件,去掉重复声明。这里给出可以直接抄的版本:

#include <iostream> #include <cassert> using namespace std; template <class T> struct ListNode { ListNode<T>* _prev; ListNode<T>* _next; T _data; ListNode(const T& data = T()) : _prev(nullptr), _next(nullptr), _data(data) {} }; template <class T, class Ref, class Ptr> struct list_iterator { typedef ListNode<T> Node; typedef list_iterator<T, Ref, Ptr> self; Node* _node; list_iterator(Node* node = nullptr) : _node(node) {} Ref operator*() { return _node->_data; } Ptr operator->() { return &_node->_data; } self& operator++() { _node = _node->_next; return *this; } self operator++(int) { self tmp(*this); _node = _node->_next; return tmp; } self& operator--() { _node = _node->_prev; return *this; } self operator--(int) { self tmp(*this); _node = _node->_prev; return tmp; } bool operator==(const self& s) const { return _node == s._node; } bool operator!=(const self& s) const { return _node != s._node; } }; template <class T> class list { public: typedef ListNode<T> Node; typedef list_iterator<T, T&, T*> iterator; typedef list_iterator<T, const T&, const T*> const_iterator; private: Node* _head; size_t _size; void create_head() { _head = new Node; _head->_prev = _head; _head->_next = _head; _size = 0; } public: list() { create_head(); } list(const list<T>& lt) { create_head(); for (const auto& x : lt) push_back(x); } list<T>& operator=(const list<T>& lt) { if (this != &lt) { clear(); for (const auto& x : lt) push_back(x); } return *this; } ~list() { clear(); delete _head; _head = nullptr; } iterator begin() { return iterator(_head->_next); } iterator end() { return iterator(_head); } const_iterator begin() const { return const_iterator(_head->_next); } const_iterator end() const { return const_iterator(_head); } size_t size() const { return _size; } bool empty() const { return _size == 0; } T& front() { return *begin(); } T& back() { return *(--end()); } void clear() { Node* cur = _head->_next; while (cur != _head) { Node* next = cur->_next; delete cur; cur = next; } _head->_prev = _head; _head->_next = _head; _size = 0; } iterator insert(iterator pos, const T& value) { Node* cur = pos._node; Node* prev = cur->_prev; Node* newNode = new Node(value); newNode->_prev = prev; newNode->_next = cur; prev->_next = newNode; cur->_prev = newNode; ++_size; return iterator(newNode); } iterator erase(iterator pos) { Node* cur = pos._node; Node* prev = cur->_prev; Node* next = cur->_next; prev->_next = next; next->_prev = prev; delete cur; --_size; return iterator(next); } void push_back(const T& value) { insert(end(), value); } void push_front(const T& value) { insert(begin(), value); } void pop_back() { erase(--end()); } void pop_front() { erase(begin()); } };

5.2 测试用例与运行输出

用下面这个main函数测试完整流程:

#include "list.hpp" #include <iostream> using namespace std; struct Point { int x, y; Point(int a = 0, int b = 0) : x(a), y(b) {} }; int main() { list<int> l1; l1.push_back(1); l1.push_back(2); l1.push_front(0); l1.push_back(3); cout << "正向遍历: "; for (auto it = l1.begin(); it != l1.end(); ++it) cout << *it << " "; cout << endl; cout << "反向遍历: "; for (auto it = --l1.end(); it != l1.begin(); --it) cout << *it << " "; cout << *l1.begin() << endl; // 在第二个位置插入100 auto it = l1.begin(); ++it; l1.insert(it, 100); // 删除值为2的元素 for (auto p = l1.begin(); p != l1.end();) { if (*p == 2) p = l1.erase(p); else ++p; } cout << "测试后: "; for (auto x : l1) cout << x << " "; cout << endl; list<int> l2(l1); // 拷贝构造 list<int> l3; l3 = l2; // 赋值 cout << "l3 size: " << l3.size() << endl; // 自定义类型测试 list<Point> pts; pts.push_back(Point(1, 2)); pts.push_back(Point(3, 4)); auto pit = pts.begin(); cout << "Point: " << pit->x << ", " << pit->y << endl; cout << "Point: " << (*pit).x << ", " << (*pit).y << endl; return 0; }

编译参数建议加上-Wall -g。预期输出如下:

正向遍历: 0 1 2 3 反向遍历: 3 2 1 0 测试后: 0 100 1 3 l3 size: 3 Point: 1, 2 Point: 1, 2

这里验证了几个关键点:insert插入后链表顺序正确;erase删除元素后返回合法迭代器;front/back取值正常;自定义类型的->运算符重载可用;拷贝构造和赋值深拷贝无误。

5.3 内存与异常安全说明

模拟实现里最需要注意的异常安全问题:如果push_back过程中new抛异常(内存不足),链表可能处于不一致状态。标准库会保证强异常安全或基本安全,我们的简化版做不到完整保证,但在学习阶段够用了。

另一个问题是拷贝构造里如果中途new失败,已经分配的新节点会泄漏。更严谨的做法是用try-catch包裹,失败时clear再rethrow。实际面试时提到这一点,反而能加分。

析构函数里delete _head之后把_head置空,这算防御性编程。真实STL实现还会有allocator来定制内存分配,比如用内存池减少频繁new/delete的开销。我们模拟实现直接用new/delete,道理是一样的,只是性能差点。

6. 常见问题与排查技巧实录

6.1 erase之后还能用旧迭代器吗

不能,这是链表使用最高频的UB来源。erase会立刻delete节点,旧迭代器保存的地址已经释放。读它、写它、++它,都是未定义行为。有些人说“我试过好像没事”,那是因为释放后的内存还没被系统回收或复用,但这是纯粹的运气,换个场景立刻崩溃。

正确的通用删除模式我之前已经写过:it = lst.erase(it);,返回值一定指向被删元素的下一个位置。如果你要连续删除多个元素,就持续用返回的迭代器继续判断。还有一个容易错的地方:不要在范围for循环里erase,因为范围for会缓存end(),删除后缓存失效,运行期可能崩。

6.2 clear之后内存释放了吗

clear()会把所有数据节点delete掉,所以节点内存释放了。但有两个隐含问题:第一,哨兵节点还在,链表对象仍然占着堆上的一块内存;第二,delete只是把内存还给堆管理器,操作系统层面不一定立即回收物理内存,这只是所有new/delete程序的共性,和list没关系。

另外,如果T本身是std::string或vector等容器,clear会正确调用它们的析构函数释放深层资源。我们的模拟实现里delete cur会触发Node析构,而Node的成员_data会自动调用T::~T(),所以不用手动清理_data。

6.3 list的sort和std::sort怎么选

这是新手最容易踩的坑。std::sort需要随机访问迭代器,list的迭代器是双向迭代器,所以std::sort(lst.begin(), lst.end())编译不过。必须用lst.sort()成员函数。

list::sort内部是归并排序,不是快排。归并排序稳定,而且适合链表结构,因为它只需要改指针不需要搬移数据。如果你要稳定排序,list::sort本来就很合适。还要注意:sort之后链表元素的地址会变,因为要重新连接节点指针。如果你在sort之前保存了某个元素的迭代器,sort之后这个迭代器仍然指向原来那个节点,但节点在链表中的位置变了。如果你保存的迭代器指向的元素不存在了,那就别用了。

6.4 实战:什么时候list反而不如vector

我自己实测过大流量场景:维护一个高频插入删除的缓冲区,list在插入删除上的优势非常明显;但如果是“先收集数据,再统一遍历输出”的模式,list反而慢到离谱。原因就是缓存不友好,链表节点散落在内存各处,遍历时每次都要跳到一个随机地址,CPU缓存几乎没有命中。

所以我的选择标准是:

  • 数据量大且只遍历:vector。
  • 频繁中间插入删除,且删除位置已知:list。
  • 双端操作,又要随机访问:deque。
  • 需要把整个链表嫁接到另一个链表:list+splice,别的容器替代不了。

另外,list每个节点都有两个指针,如果存的是小对象,节点指针的内存开销可能比数据本身还大。比如list<char>存1字节数据,却要额外8字节(或16字节对齐)的指针开销,内存浪费严重。这时候就用deque<char>或vector<char>。

我个人还有个小技巧:如果对list的插入删除性能不满意,先问自己“是不是用了太多new导致内存碎片”。常规办法是写一个内存池分配器,让节点从内存池分配而不是每次走new。STL的std::allocator本来就能定制,你可以在声明list<T, MyAllocator>时传入。不过这个属于进阶优化,面试能说出来是加分项,项目里如果没到瓶颈也别轻易上,复杂度会明显上升。

回到本文的模拟实现,最后再提醒一句:不管看多少遍源码,都不如手写一遍。你把节点、迭代器、插入删除、拷贝构造这四个模块写通,list基本就吃透了。写的时候遇到编译报错,优先检查是不是忘了typename——类外定义成员函数时,返回类型里的list<T>::iterator必须写成typename list<T>::iterator,告诉编译器这是个类型而不是静态成员。这个错误,几乎所有手写STL的人都会遇到至少一次。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 3:00:46

京东云老用户续费同价:算清这笔账,续费不再吃亏

先说一个很多云服务用户都遇到过的情况&#xff1a;新用户买台云服务器&#xff0c;一年价格低得离谱&#xff0c;等你用顺手了想续费&#xff0c;价格立刻“回归原价”&#xff0c;翻了一倍都不止。群里天天有人吐槽“老用户不如狗”&#xff0c;尤其是手里压着域名、备案、数…

作者头像 李华
网站建设 2026/10/3 3:00:46

谷歌云国际版 vs AWS:开发者如何做出理性选择?

谷歌云国际版和 AWS 到底怎么选&#xff1f;这个问题我这一年里被问了快二十次。我是 YDWLCloud&#xff0c;平时帮团队做技术选型&#xff0c;也喜欢用自己的账号折腾各类云服务&#xff0c;踩过的坑不算少。每次遇到这类问题&#xff0c;我都能感受到提问者真正担心的不是“哪…

作者头像 李华
网站建设 2026/10/3 3:00:33

房价预测入门:从线性回归到数据预处理的完整机器学习实战

1. 项目概述&#xff1a;为什么每个入门者都该做一次房价预测房价预测&#xff0c;这应该是机器学习圈子里被讨论最多、也最“老掉牙”的入门项目了。无论是大学课程里的期末大作业&#xff0c;还是网上各种培训班的第一节课&#xff0c;基本都绕不开它。我自己当年学机器学习时…

作者头像 李华
网站建设 2026/10/3 3:00:16

SpringBoot毕设项目实战:小说阅读平台全栈开发指南

1. 选题价值与功能设计拆解1.1 为什么是“小说阅读平台”这类选题每年到毕设季&#xff0c;Java方向的学生问得最多的就是“老师&#xff0c;SpringBoot选题选什么好”。我做了这么多年开发和带毕设的经验&#xff0c;小说阅读平台这类题目几乎是Java Web方向里的“常青树”&am…

作者头像 李华
网站建设 2026/10/3 2:59:46

手写链表全攻略:从LeetCode 707到嵌入式list_head

把链表从头实现一遍&#xff0c;是每个写代码的人都绕不过去的一道坎。无论是 LeetCode 707 这道经典的“设计链表”&#xff0c;还是数据结构实验课上的“单链表的基本操作实验”&#xff0c;底层逻辑都是一样的&#xff1a;你要自己管理节点、自己处理指针&#xff08;或者引…

作者头像 李华
网站建设 2026/10/3 2:59:12

Ubuntu 22.04中文输入法配置全攻略:Fcitx5安装、环境变量与故障排查

Ubuntu 22.04 装中文输入法&#xff0c;这个话题我估计被问过几百次了。群里隔三差五就有人发截图求助&#xff1a;要么是候选框不跟随光标&#xff0c;要么是 CtrlSpace 按了没反应&#xff0c;要么是装完 Fcitx5 重启之后又退回默认英文输入。网上的教程不少&#xff0c;但很…

作者头像 李华