一、什么叫迭代器失效
先看一个简单的:
std::vector<int> nums = {10, 20, 30};
auto it = nums.begin();
std::cout << *it << std::endl;
此时:
it
↓
┌────┬────┬────┐
│ 10 │ 20 │ 30 │
└────┴────┴────┘
it 保存的是一个能够定位到:
nums中第一个元素
的位置。
但是如果容器发生变化:
nums.push_back(40);
并且这次 push_back() 触发了扩容,那么 vector 会:
申请新内存
↓
把10、20、30搬到新内存
↓
插入40
↓
释放旧内存
例如原来:
0x1000
┌────┬────┬────┐
│ 10 │ 20 │ 30 │
└────┴────┴────┘
扩容后:
0x5000
┌────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │ │ │
└────┴────┴────┴────┴────┴────┘
但是原来的:
it
可能还指向:
0x1000
而原来的内存已经释放。
这时再:
std::cout << *it << std::endl;
就是:
未定义行为
这就是:
迭代器失效
Iterator Invalidation
它本质上就是:
容器修改以后,原来迭代器所依赖的元素位置、节点或者底层内存已经发生变化,继续使用旧迭代器不再安全。
除了迭代器:
pointer
reference
也可能一起失效。
例如:
int *p = &nums[0];
int &ref = nums[1];
nums.push_back(40);
如果发生扩容:
iterator
pointer
reference
都可能失效。
二、vector为什么最容易出现迭代器失效
vector 底层使用:
连续内存
因此有两类特别常见的失效问题。
1. 扩容导致全部失效
例如:
std::vector<int> nums = {1, 2, 3};
auto it = nums.begin();
nums.push_back(4);
std::cout << *it << std::endl;
如果:
size == capacity
那么 push_back() 需要重新分配内存。
于是原来的:
所有迭代器
所有指针
所有引用
都会失效。
所以如果提前知道元素数量,可以:
std::vector<int> nums;
nums.reserve(1000);
提前准备容量。
然后:
for (int i = 0; i < 1000; ++i) {
nums.push_back(i);
}
只要没有超过已经预留的容量:
就不会因为扩容反复搬迁底层数组
所以:
reserve()
是减少 vector 扩容型迭代器失效的一种重要方法。
但要注意:
reserve()只能减少因为重新分配内存造成的失效,并不能解决所有 vector 迭代器失效问题。
2. erase导致当前位置以及后面的迭代器失效
例如:
std::vector<int> nums = {10, 20, 30, 40};
auto it = nums.begin() + 1;
nums.erase(it);
原来:
10 20 30 40
↑
it
删除 20 后:
10 30 40
因为:
30和40需要向前移动
所以被删除位置以及后面的迭代器都会失效。
因此下面这种写法是危险的:
for (auto it = nums.begin(); it != nums.end(); ++it) {
if (*it == 20) {
nums.erase(it);
}
}
问题在于:
nums.erase(it);
之后:
it已经失效
但是循环后面还执行:
++it
相当于继续操作一个已经失效的迭代器。
正确写法:
for (auto it = nums.begin(); it != nums.end(); ) {
if (*it == 20) {
it = nums.erase(it);
} else {
++it;
}
}
为什么?
因为:
erase(it)
会返回:
被删除元素后面的下一个有效迭代器
例如:
10 20 30 40
↑
it
erase(it)
↓
10 30 40
↑
返回新的it
所以这是删除元素时非常重要的写法:
it = container.erase(it);
三、不同STL容器的失效规则并不一样
不是所有 STL 容器都和 vector 一样。
1. vector
vector:
连续内存
所以比较容易因为元素搬迁导致失效。
常见情况:
重新分配内存
↓
全部迭代器、引用、指针失效
而:
erase
↓
删除位置以及之后的迭代器失效
2. list
std::list 底层通常是:
双向链表
例如:
Node1 ↔ Node2 ↔ Node3 ↔ Node4
每个节点相对独立。
所以插入一个节点:
Node1 ↔ Node2 ↔ New ↔ Node3
通常不会移动其他节点。
因此:
insert
↓
原来的其他迭代器通常仍然有效
删除:
erase(Node2)
也通常只会使:
指向Node2的迭代器失效
其他节点:
Node1
Node3
Node4
没有搬家。
所以其他迭代器通常仍然有效。
这也是链式容器的重要特点。
3. map / set
map、set 通常是:
树结构
插入新元素时:
通常不会使已有元素的迭代器失效
删除某个元素时:
只有指向被删除元素的迭代器失效
所以:
for (auto it = m.begin(); it != m.end(); ) {
if (it->second == 0) {
it = m.erase(it);
} else {
++it;
}
}
同样是比较安全的删除写法。
4. unordered_map / unordered_set
这一类要特别注意:
哈希表
普通插入:
不一定导致已有迭代器失效
但是如果插入触发:
rehash
哈希桶重新组织。
那么:
已有迭代器可能全部失效
例如:
std::unordered_map<int, int> table;
auto it = table.find(1);
table.insert({100, 100});
如果插入过程中触发 rehash:
bucket数量变化
↓
元素重新分布
↓
原迭代器失效
所以如果已经知道大概会存多少元素,可以:
table.reserve(10000);
减少频繁 rehash。
这里和:
vector::reserve()
思路很像。
一个是:
减少扩容
一个是:
减少rehash
四、实际开发中怎么避免迭代器失效
第一种方法:
容器修改以后,不要盲目继续使用旧迭代器。
例如:
auto it = nums.begin();
nums.push_back(100);
// 如果可能发生扩容
// 不继续使用原it
it = nums.begin();
也就是:
容器结构改变以后
↓
必要时重新获取迭代器
第二种方法:
删除元素时使用 erase 的返回值。
错误:
for (auto it = nums.begin(); it != nums.end(); ++it) {
if (*it % 2 == 0) {
nums.erase(it);
}
}
正确:
for (auto it = nums.begin(); it != nums.end(); ) {
if (*it % 2 == 0) {
it = nums.erase(it);
} else {
++it;
}
}
这个模式建议直接记住:
如果删除
↓
it = erase(it)
如果不删除
↓
++it
第三种方法:
不要在 range-for 中随便修改容器结构。
例如:
for (auto &x : nums) {
if (x == 10) {
nums.push_back(100);
}
}
range-for 底层实际上会使用类似:
begin
end
iterator
如果 push_back() 导致 vector 扩容:
循环内部使用的迭代器也会失效
所以这种修改非常危险。
如果确实需要:
遍历过程中新增/删除
最好重新设计:
第一阶段遍历
↓
记录要修改的内容
第二阶段
↓
统一修改容器
例如:
std::vector<int> toAdd;
for (int x : nums) {
if (x == 10) {
toAdd.push_back(100);
}
}
nums.insert(nums.end(), toAdd.begin(), toAdd.end());
这样不会在遍历原容器过程中直接破坏迭代器。
第四种方法:
vector已知大小时提前reserve。
例如:
std::vector<Task> tasks;
tasks.reserve(10000);
for (int i = 0; i < 10000; ++i) {
tasks.push_back(Task{});
}
减少:
扩容
↓
内存地址变化
↓
全部迭代器失效
第五种方法:
有时可以保存下标,而不是长期保存vector迭代器。
例如:
size_t index = 2;
然后:
nums.push_back(100);
std::cout << nums[index] << std::endl;
即使 vector 扩容:
底层地址变了
但第2个元素的位置语义仍然可以通过下标重新计算
相比:
auto it = nums.begin() + 2;
长期保存下标有时更加安全。
但是要注意:
如果在index前面插入或删除元素
下标对应的元素本身也会发生变化
所以:
下标不是万能解决方案
只是适合某些 vector 场景。
五、面试中迭代器失效应该怎么回答
如果面试官问:
什么叫迭代器失效?
可以回答:
迭代器失效是指容器经过插入、删除、扩容或 rehash 等操作以后,原来的迭代器不再能够安全定位原来的元素。如果继续解引用或移动这个迭代器,就可能产生未定义行为。
如果继续问:
vector什么时候会发生迭代器失效?
可以回答:
vector 如果发生重新分配内存,比如
push_back导致容量不足而扩容,那么原来的所有迭代器、指针和引用都会失效。erase不一定重新分配内存,但由于后面的元素会向前移动,所以被删除位置以及之后的迭代器会失效。
如果问:
怎么避免?
可以回答:
第一,不要在容器结构发生变化以后继续使用旧迭代器;第二,遍历删除时使用
erase的返回值更新迭代器;第三,vector 如果能够预估容量,可以提前reserve减少扩容;第四,不要在 range-for 中随意插入或删除当前容器;如果修改后仍需访问元素,可以重新获取迭代器,或者在适合的场景保存下标。
如果继续问:
为什么list插入通常不会导致其他迭代器失效?
可以回答:
因为 list 是节点式存储,每个节点独立分配,插入新节点只需要修改相邻节点的指针,不需要移动其他节点,因此已有节点的地址通常不会发生变化。删除时一般也只有指向被删除节点的迭代器失效。
如果继续问:
unordered_map为什么会迭代器失效?
可以回答:
当插入元素导致 rehash 时,哈希表会重新建立 bucket,并重新组织元素所在的桶,所以已有迭代器会失效。可以通过提前
reserve或合理设置 bucket 数量减少频繁 rehash。
最后可以把常见容器简单记成:
vector
↓
扩容:全部失效
erase:删除位置及之后失效
list
↓
插入:其他迭代器通常不失效
删除:只删除节点的迭代器失效
map / set
↓
插入:已有迭代器通常有效
删除:被删除元素失效
unordered_map / unordered_set
↓
rehash
↓
迭代器失效
实际开发中最值得记住的代码就是:
for (auto it = container.begin();
it != container.end(); ) {
if (needDelete(*it)) {
it = container.erase(it);
} else {
++it;
}
}
以及一个原则:
容器发生结构性修改
↓
先考虑旧迭代器是否还有效
↓
不确定时不要继续使用
迭代器失效本质上不是"迭代器坏掉了",而是:
它原来记录的位置
已经无法再代表原来的元素
只要把这一点理解清楚,vector 扩容、erase、unordered_map rehash 等问题就比较容易判断了。