list容器:双向链表的全面解析

说实话,在STL的序列容器里,list是个特别的存在。它不像vector那样连续存储,也不像deque那样分段连续。list的本质是双向链表——每个节点都保存着指向前后节点的指针。这种结构决定了它的性格:插入删除快如闪电,随机访问慢如蜗牛。

我记得刚入行时,有个同事在项目里用vector做频繁的中间插入,结果性能惨不忍睹。后来换成list,问题迎刃而解。嗯,选对容器,有时候比优化代码更重要。

1. list的基本概念

list底层是双向循环链表。每个节点包含三个部分:数据域、前驱指针、后继指针。这意味着你可以从任意方向遍历,但无法像数组那样直接跳转到第N个元素。

核心特点:

  • 插入和删除操作不会导致迭代器失效(除非指向被删除的元素)
  • 不支持随机访问,访问第N个元素需要O(N)时间
  • 每次插入都会动态分配内存,频繁插入时会有内存碎片问题
  • 支持常数时间的头部和尾部插入删除

你想想看,为什么list的插入不会导致其他迭代器失效?因为每个节点都是独立的内存块,插入新节点只需要调整指针,其他节点的内存地址纹丝不动。这一点和vector完全不同——vector扩容时所有迭代器都会失效。

list双向链表结构示意图 prev data next prev data next prev data next 每个节点独立分配,通过指针串联 插入/删除只需修改指针,O(1)时间复杂度

2. list的构造函数

list提供了多种构造方式,我平时最常用的是前三种:

#include <list>

// 1. 默认构造
std::list<int> l1;  // 空链表

// 2. 指定初始大小
std::list<int> l2(5);  // 5个元素,默认值为0

// 3. 指定大小和初始值
std::list<int> l3(5, 100);  // 5个元素,值都是100

// 4. 拷贝构造
std::list<int> l4(l3);  // 复制l3

// 5. 迭代器范围构造
std::vector<int> vec = {1, 2, 3, 4, 5};
std::list<int> l5(vec.begin(), vec.end());

// 6. 初始化列表(C++11起)
std::list<int> l6 = {10, 20, 30, 40};

个人建议:如果你知道大概要存多少个元素,用带大小的构造函数可以避免多次内存分配。不过list每次插入都会new节点,所以这个优化效果不如vector明显。

3. list的赋值与交换

赋值操作和vector类似,但有个细节要注意——list的赋值是深拷贝,每个节点都会重新创建。

std::list<int> l1 = {1, 2, 3};
std::list<int> l2 = {4, 5, 6, 7};

// 赋值操作
l1 = l2;  // l1现在有4个元素:4,5,6,7

// assign成员函数
l1.assign(3, 100);  // l1变成:100,100,100
l1.assign({9, 8, 7});  // 初始化列表赋值

// swap交换
l1.swap(l2);  // 交换两个list的内容,O(1)时间

swap操作是list的一大亮点。因为list只交换头指针,所以是常数时间。我在项目中经常用swap来做「无异常保证」的赋值——先构造一个新list,然后swap进去,这样即使构造过程抛出异常,原数据也不会被破坏。

4. list的大小操作

大小操作很直观,但有个坑我要提醒你:

std::list<int> lst = {1, 2, 3, 4, 5};

// 大小相关
size_t sz = lst.size();      // 返回5
bool empty = lst.empty();    // 返回false
lst.resize(8);               // 扩展到8个元素,新增的默认0
lst.resize(10, 99);          // 扩展到10个,新增的用99填充
lst.resize(3);               // 缩小到3个,多余的元素被删除

我曾经踩过的坑:在C++11之前,list的size()是O(N)的!因为标准允许list不缓存大小信息。虽然现代C++里size()已经是O(1)了,但如果你在维护老代码,还是要注意一下。判断空的时候用empty()而不是size()==0,因为empty()永远是O(1)。

5. list的插入与删除

这才是list的强项。插入和删除操作都是O(1)的——前提是你已经有了指向插入位置的迭代器。

std::list<int> lst = {10, 20, 30, 40};

// 头部和尾部操作
lst.push_front(5);   // 5,10,20,30,40
lst.push_back(50);   // 5,10,20,30,40,50
lst.pop_front();     // 10,20,30,40,50
lst.pop_back();      // 10,20,30,40

// 任意位置插入
auto it = lst.begin();
++it;  // 指向20
lst.insert(it, 15);  // 在20之前插入15:10,15,20,30,40

// 插入多个
lst.insert(it, 3, 99);  // 在当前位置插入3个99

// 删除
lst.erase(it);  // 删除it指向的元素
lst.remove(30); // 删除所有值为30的元素
lst.remove_if([](int x) { return x > 20; });  // 删除所有大于20的元素

// 清空
lst.clear();

这里有个细节:insert和erase之后,除了被删除元素的迭代器,其他迭代器都依然有效。这在写复杂算法时特别省心。

6. list的数据存取

list不支持随机访问,所以存取操作很有限:

std::list<int> lst = {1, 2, 3, 4, 5};

// 访问首尾元素
int first = lst.front();  // 1
int last = lst.back();    // 5

// 没有operator[],也没有at()
// 想访问中间元素?只能遍历
auto it = lst.begin();
std::advance(it, 2);  // 前进2步,指向3
int val = *it;        // 3

重要提醒:如果你需要频繁随机访问,别用list。我见过有人用list存了上万条数据,然后循环里用std::advance找元素,性能直接爆炸。这种场景请用vector或deque。

7. list的反转与排序

list有自己的sort和reverse成员函数,和STL的通用算法不同:

std::list<int> lst = {3, 1, 4, 1, 5, 9, 2, 6};

// 反转
lst.reverse();  // 6,2,9,5,1,4,1,3

// 排序(默认升序)
lst.sort();     // 1,1,2,3,4,5,6,9

// 自定义排序(降序)
lst.sort(std::greater<int>());  // 9,6,5,4,3,2,1,1

// 合并两个有序list
std::list<int> l1 = {1, 3, 5};
std::list<int> l2 = {2, 4, 6};
l1.merge(l2);  // l1变成1,2,3,4,5,6,l2为空

为什么list不用std::sort?因为std::sort需要随机访问迭代器,list的迭代器是双向的,不满足要求。list自己的sort用的是归并排序,稳定且不需要随机访问。

我的经验:list的sort在数据量大的时候性能不错,但如果你频繁排序,建议考虑其他容器。另外,merge要求两个list都是有序的,否则结果未定义——我曾经因为这个bug排查了半天。

总结一下

list是个偏科生:插入删除是学霸,随机访问是学渣。选它的时候要想清楚场景。我个人习惯是:需要频繁在中间插入删除,且不需要随机访问时,list是首选。其他情况,vector更通用。

嗯,list的内容就这些。记住它的特性,用对地方,它就是一把利器。


公众号:蓝海资料掘金营,微信deep3321