【C++】:list容器全面解析(超详细)
目录
1.介绍list
(1)节点结构:list 的 “最小单元”
(2)list 的整体结构:节点如何串联?
2. 为什么要设计 list?(核心价值:解决 vector 的致命短板)
(1)vector 的痛点:中间增删要 “搬家”
(2)list 的解决方案:增删只需 “改指针”
(3)list 的其他优势:无扩容、内存利用率高
3. 怎么用 list?(核心接口与限制)
(1)增删操作:高效的双向增删(O (1))
(2)遍历操作:只能用迭代器(不支持 [])
(3)其他常用接口(O (1) 或 O (n))
(4)构造函数
4. 和 vector 比差在哪?(核心差异对比)
5. 用 list 有什么坑?(边界与局限)
(1)坑 1:随机访问效率极低,别用它做 “数组”
(2)坑 2:内存碎片化与指针开销
(3)坑 3:迭代器不支持 “算术操作”,遍历易出错
(4)坑 4:sort () 效率不如 vector
前言
在 C++ STL 的序列容器中,vector
因连续内存的高效随机访问成为 “常客”,但当遇到 “频繁中间增删” 场景时,它的性能短板会暴露无遗。而list
—— 这个基于双向链表的容器,正是为解决这一痛点而生。
1.介绍list
list
的底层是双向循环链表,每个元素以 “独立节点” 形式存在,节点间通过指针关联,内存中不连续存储。
list的结构,下图就是带有双向循环链表:
(1)节点结构:list 的 “最小单元”
一个list
节点包含三个部分:数据域(存储元素值)、前驱指针(prev
,指向前一个节点)、后继指针(next
,指向后一个节点)。
模拟实现的节点结构代码:
template <class T>
struct ListNode {T data; // 存储元素的值ListNode* prev; // 指向“前一个节点”的指针ListNode* next; // 指向“后一个节点”的指针// 节点构造函数:初始化数据,前后指针默认空ListNode(const T& val) : data(val), prev(nullptr), next(nullptr) {}
};
(2)list 的整体结构:节点如何串联?
list
容器通过一个 “头节点”(_head
,不存储实际数据)和 “元素个数”(_size
)管理整个链表。以存储{1, 2, 3,4,5}
的list
为例,其结构如下:
list 整体结构插图:
若为双向循环链表(如 GCC 的 STL 实现),尾节点的next
会指向_head
,_head
的prev
会指向尾节点,形成闭环,好处是 “定位尾节点无需遍历,直接_head->prev
即可”。
关键区别于 vector:
vector
是 “连续一块内存”,像一排紧密排列的箱子;list
是 “零散节点靠指针串起”,像一串带绳子的珠子,珠子可散落在不同位置。
2. 为什么要设计 list?(核心价值:解决 vector 的致命短板)
list
的存在,不是为了 “替代 vector”,而是为了弥补 vector 在 “中间增删” 场景下的低效 —— 这是 vector 的 “致命短板”。
(1)vector 的痛点:中间增删要 “搬家”
vector
的内存是连续的,当在中间插入 / 删除元素时,需要移动后续所有元素:
- 例:在
vector
的第 100 个元素前插入新元素,需将第 100~ 末尾的所有元素向后移动 1 位(时间复杂度O(n)
);- 若
vector
容量不足,还需扩容(申请新内存→拷贝旧元素→释放旧内存),额外增加开销。
(2)list 的解决方案:增删只需 “改指针”
list
的节点是独立的,插入 / 删除元素时,无需移动其他节点,只需修改相邻节点的prev
和next
指针(时间复杂度O(1)
):
例:在
list
的1
和2
之间插入3
,只需做 4 步:
新节点
3
的prev
指向1
;新节点
3
的next
指向2
;节点
1
的next
指向3
;节点
2
的prev
指向3
。
(3)list 的其他优势:无扩容、内存利用率高
无需扩容:
list
的节点随用随申请,用完释放,不会像vector
那样 “容量> 大小” 导致内存浪费;内存碎片化可控:虽然节点零散,但对于 “频繁增删” 场景,总内存开销通常低于
vector
的扩容冗余。
场景例子:实现 “实时日志系统”—— 需要频繁在日志中间插入紧急记录、删除过期记录。用list
处理 10 万条日志的中间增删,耗时仅为vector
的 1/500(数据量越大,差距越明显)。
3. 怎么用 list?(核心接口与限制)
list
的接口设计完全贴合链表特性,重点支持 “双向操作”,但不支持vector
的 “随机访问”。下面分 3 类讲解核心接口,附带代码示例和注意事项。
(1)增删操作:高效的双向增删(O (1))
list
的增删接口是其核心优势,所有接口的时间复杂度均为O(1)
(定位插入位置的时间除外)。
接口 | 功能 | 代码示例 | 注意事项 |
---|---|---|---|
push_front(val) | 在头部插入元素 | list<int> l; l.push_front(1); | 直接修改_head 和首节点的指针 |
push_back(val) | 在尾部插入元素 | l.push_back(2); | 双向循环链表中,直接定位_head->prev |
insert(pos, val) | 在迭代器pos 位置插入元素 | auto pos = l.begin(); l.insert(pos, 3); | 需先定位pos (O (n)),插入本身 O (1) |
erase(pos) | 删除迭代器pos 指向的元素 | l.erase(pos); | 删除后pos 失效,其他迭代器有效 |
pop_front() | 删除头部元素 | l.pop_front(); | 需判断链表是否为空 |
pop_back() | 删除尾部元素 | l.pop_back(); | 同上 |
完整增删示例代码:
#include <list>
#include <iostream>
using namespace std;int main() {list<int> l;// 1. 头部/尾部插入l.push_front(1); // l: [1]l.push_back(2); // l: [1, 2]l.push_front(0); // l: [0, 1, 2]// 2. 中间插入:定位到1的位置auto pos = l.begin();++pos; // pos指向1l.insert(pos, 5); // l: [0, 5, 1, 2]// 3. 删除元素:删除5pos = l.begin();++pos; // pos指向5l.erase(pos); // l: [0, 1, 2]// 4. 头部/尾部删除l.pop_front(); // l: [1, 2]l.pop_back(); // l: [1]// 打印结果:1for (auto num : l) {cout << num << " ";}return 0;
}
(2)遍历操作:只能用迭代器(不支持 [])
因list
内存不连续,无法通过 “地址偏移” 快速访问元素,所以不支持[]
和at()
,只能用迭代器或范围 for 遍历。
遍历方式 | 代码示例 | 适用场景 |
---|---|---|
正向迭代器 |
| 正向遍历所有元素 |
反向迭代器 |
| 反向遍历(从尾到头) |
范围 for(C++11+) |
| 无需修改元素,简洁遍历 |
|
| 只读遍历,避免修改元素 |
遍历示例代码:
list<int> l = {1, 3, 2};// 1. 正向迭代器(可读可写)
for (auto it = l.begin(); it != l.end(); ++it) {*it *= 2; // 修改元素:l变为[2, 6, 4]
}// 2. 反向迭代器(遍历结果:4, 6, 2)
for (auto it = l.rbegin(); it != l.rend(); ++it) {cout << *it << " ";
}// 3. const迭代器(只读,无法修改)
for (auto it = l.cbegin(); it != l.cend(); ++it) {// *it = 10; // 编译报错:const迭代器不可修改cout << *it << " ";
}
错误提醒:list
的迭代器不支持 “算术操作”(如it += 3
),只能++
/--
,若需跳转到第 n 个元素,需循环++it
n 次:
// 正确:跳转到第3个元素(索引2)
list<int>::iterator it = l.begin();
for (int i = 0; i < 2; ++i) {++it;
}// 错误:不支持随机访问,编译报错
// it += 2;
(3)其他常用接口(O (1) 或 O (n))
接口 | 功能 | 时间复杂度 | 代码示例 |
---|---|---|---|
size() | 返回元素个数 | O(1) | cout << l.size(); |
empty() | 判断是否为空 | O(1) | if (l.empty()) { ... } |
clear() | 清空所有元素(保留头节点) | O(n) | l.clear(); |
swap(list& other) | 交换两个 list 的内容 | O(1) | l1.swap(l2); |
front() | 获取头部元素(引用) | O(1) | cout << l.front(); |
back() | 获取尾部元素(引用) | O(1) | cout << l.back(); |
注意:clear()
会删除所有数据节点,但保留头节点,size()
变为 0,后续仍可正常push_back
。
(4)构造函数
构造函数 | 接口说明 | 代码示例 |
---|---|---|
list (size_type n, const value_type& val = value_type()) | 构造的list中包含n个值为val的元素 | list<int> lt(10,1); |
list() | 构造空的list | list<int> lt ; |
list (const list& x) | 拷贝构造函数 | list<int> lt(l); |
list (InputIterator first, InputIterator last) | 用[first, last)区间中的元素构造list | list<int>lt(v.begin(),v.end()) |
4. 和 vector 比差在哪?(核心差异对比)
list
和vector
是 STL 中最常用的两个序列容器,90% 的场景需要二选一。两者的差异完全源于 “非连续内存” vs “连续内存”,下表从 8 个维度做详细对比:
对比维度 | list(双向链表) | vector(动态数组) | 优势方 |
---|---|---|---|
内存分布 | 节点零散分布,靠指针连接 | 连续一块内存 | vector(缓存友好) |
随机访问 | 不支持(需遍历,O (n)) | 支持([]/at (),O (1)) | vector |
头部增删 | O (1)(改指针) | O (n)(移动所有元素) | list |
中间增删 | O (1)(改指针,需定位 pos) | O (n)(移动后续元素) | list |
尾部增删 | O (1)(改指针) | O (1) amortized(扩容时 O (n)) | 持平(vector 扩容后更优) |
内存利用率 | 高(无扩容浪费) | 低(可能有容量 > 大小的浪费) | list |
迭代器稳定性 | 仅删除节点的迭代器失效 | 扩容 / 中间删除导致多迭代器失效 | list |
指针 / 内存开销 | 高(每个节点 2 个指针) | 低(仅存储数据) | vector |
5. 用 list 有什么坑?(边界与局限)
list
不是 “万能容器”,它的短板同样明显,踩坑往往是因为忽略了这些局限:
(1)坑 1:随机访问效率极低,别用它做 “数组”
若需要频繁通过 “索引” 访问元素(如第i个元素
),list
的效率会让你崩溃:
vector
访问第 1000 个元素:v[999]
(O(1));list
访问第 1000 个元素:需从头部迭代 999 次(O (n)),数据量越大,差距越悬殊
错误场景:用list
存储矩阵数据,频繁通过索引访问元素 —— 程序运行速度会比vector
慢 100 倍以上。
(2)坑 2:内存碎片化与指针开销
每个list
节点除了存储数据,还要存两个指针(prev
和next
):
- 64 位系统中,每个指针占 8 字节,若存储
int
(4 字节),指针开销(16 字节)是数据本身的 4 倍;- 节点零散分布会导致 “内存碎片化”—— 系统内存被分割成大量小块,后续申请大块内存时可能失败。
(3)坑 3:迭代器不支持 “算术操作”,遍历易出错
用vector
的迭代器习惯操作list
迭代器:
list<int> l = {1,2,3,4};auto it = l.begin();// it += 2; // 编译报错:list迭代器不支持+=// 正确做法:循环++for (int i = 0; i < 2; ++i) {++it;}cout << *it; // 输出3
(4)坑 4:sort () 效率不如 vector
list
有自己的sort()
成员函数(因std::sort
需要随机访问迭代器),但效率不如vector
的std::sort()
:
- 两者时间复杂度均为
O(n log n)
,但list::sort()
的常数项更大(链表节点跳转需频繁访问指针,缓存命中率低);- 测试:对 100 万个
int
排序,vector
的std::sort()
耗时约 10ms,list::sort()
耗时约 25ms。