前言
「遍历容器,删除满足条件的元素」是 C++ 里最常见的操作之一,也是迭代器失效(iterator invalidation)事故最集中的地方。典型错误写法是:
// 错误示例
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it % 2 == 0) v.erase(it);
}erase之后,it已经失效,循环末尾的++it作用在失效迭代器上,属于未定义行为(undefined behavior, UB)。它可能崩溃,可能跳过元素,也可能「看起来正常」——这正是 UB 最难排查的地方。
另一个常见误解是「所有容器的erase行为都一样」。事实上,vector、deque、list、map、unordered_map的失效规则差别很大:vector的erase会让删除点及其后的迭代器全部失效,list只让被删元素失效,unordered_map的rehash会让所有迭代器失效但引用仍有效。用同一套循环模板套所有容器,迟早会出问题。
本文以 C++17 为准,先给出各容器的失效规则总表,再讲三种正确的删除写法,最后说明 C++20 的std::erase_if如何简化这类代码。
一、各容器迭代器失效规则
| 容器 | erase | insert/push_back | 备注 |
|---|
vector | 删除点及之后的迭代器、引用失效 | 扩容时全部失效;否则插入点之后失效 | 连续内存 |
deque | 删除首尾只失效被删元素;删除中间全部失效 | 首尾插入:迭代器全部失效,引用仍有效;中间插入:全部失效 | 分段连续 |
list | 只失效被删元素 | 不失效 | 节点独立 |
forward_list | 只失效被删元素 | 不失效 | 单向 |
map/set | 只失效被删元素 | 不失效 | 红黑树 |
unordered_map/unordered_set | 只失效被删元素 | 触发 rehash 时迭代器全部失效,引用/指针仍有效 | 哈希表 |
几条容易记混的细节:
vector的erase返回被删元素之后的迭代器(C++11 起)。这正是it = v.erase(it);能工作的原因。deque在中间删除会重排元素,导致所有迭代器失效;在头部或尾部删除只影响被删元素。不要因为「deque是分段连续」就以为中间删除只影响局部。list、map、set的节点不会移动,删除只影响被删节点,其他迭代器、引用、指针都保持有效。unordered_*的rehash只影响迭代器,不影响指向元素的引用和指针;这与vector扩容「引用也失效」不同。
二、循环中删除的三种正确写法
写法一:用 erase 的返回值推进
// 适用于 C++17
#include <vector>
void removeEven(std::vector<int>& v) {
for (auto it = v.begin(); it != v.end(); ) {
if (*it % 2 == 0) {
it = v.erase(it); // erase 返回下一个位置
} else {
++it;
}
}
}关键点是循环头不写++it,而是在「保留」分支手动自增。erase返回的迭代器指向被删元素的后一个位置,直接赋给it,下一轮从正确位置继续。这个写法对所有标准容器都适用,是最通用的模板。
写法二:it = c.erase(it++)(仅限节点型容器)
// 适用于 C++17
#include <list>
void removeEven(std::list<int>& l) {
for (auto it = l.begin(); it != l.end(); ) {
if (*it % 2 == 0) {
l.erase(it++); // 先复制旧迭代器并自增,再删除旧位置
} else {
++it;
}
}
}it++返回旧值的副本,同时把it推进到下一个节点;erase收到的是旧副本。对list、map、set、unordered_*这类「只失效被删元素」的容器,推进后的it仍然有效。
但这个写法不能用于vector和deque。vector的erase会让删除点之后的迭代器全部失效,it++得到的新迭代器也在失效范围内;deque中间删除更是全部失效。对这两个容器,老老实实用写法一。
写法三:erase-remove 惯用法
// 适用于 C++17
#include <algorithm>
#include <vector>
void removeEven(std::vector<int>& v) {
v.erase(std::remove_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; }),
v.end());
}std::remove_if把保留的元素依次前移,返回新的逻辑结尾;它不改变容器大小,只是把要删除的元素「挤」到尾部。随后erase(newEnd, end())一次性删掉尾部区间。这个写法对vector、deque、string都很高效:每个元素最多移动一次,比逐个erase的O(n^2)好得多。
注意std::remove_if需要<algorithm>,并且只对「删除后元素前移」语义成立的序列容器适用。list虽然也能用,但成员函数remove_if更合适,它不需要移动元素。
三、为什么范围 for 里不能删除
范围 for 循环在语义上等价于用begin()/end()的迭代器循环:
// 适用于 C++17
for (int x : v) {
// 这里拿不到当前迭代器
// 若通过其他方式 erase,当前迭代器和 end() 都可能失效
}即使你在循环体里用下标或指针删除了元素,范围 for 内部保存的迭代器也已经失效,下一轮解引用就是 UB。要删除,必须显式管理迭代器,用写法一或写法二。
四、C++20 的 erase / erase_if 与下标删除
C++20 为标准容器增加了统一接口:
// 需要 C++20
#include <vector>
void removeEven(std::vector<int>& v) {
std::erase_if(v, [](int x) { return x % 2 == 0; });
}std::erase_if(c, pred)对所有满足条件的元素执行删除,返回删除的个数。还有std::erase(c, value)按值删除。它们对vector、deque、list、forward_list、string、map、set、unordered_*等都有重载,内部实现会选用适合该容器的方式,调用方不需要关心迭代器失效。
使用前要确认标准库已经实现这些重载。C++20 的库特性支持进度因工具链而异,具体以所用编译器的支持表为准。不能因为语言标准选了 C++20,就假设所有 C++20 库设施都可用。
下标删除与引用失效
对vector,还有一种常见写法是用下标:
// 适用于 C++17
for (std::size_t i = 0; i < v.size(); ) {
if (v[i] % 2 == 0) {
v.erase(v.begin() + static_cast<std::ptrdiff_t>(i));
// 不递增 i:后面的元素前移到了 i
} else {
++i;
}
}删除后不递增i,因为原来的i+1元素已经移到了i。如果递增,会跳过一个元素。这种写法可读性不如迭代器版本,而且每次erase都是O(n),整体O(n^2);数据量大时应改用 erase-remove。
引用失效同样危险:
// 适用于 C++17
std::vector<int> v{1, 2, 3};
int& first = v[0];
v.push_back(4); // 可能扩容
// first 可能已经悬垂,继续读写是 UBpush_back是否扩容取决于当前容量,标准不保证。只要可能扩容,就不应长期持有元素的引用或指针;需要稳定引用时,用std::deque、std::list,或先reserve足够容量(但reserve之后仍不能超过容量)。
常见坑点
1. 循环头自增 + 循环体 erase
❌for (auto it = v.begin(); it != v.end(); ++it) if (cond) v.erase(it);✅ 循环头不写++it,删除时it = v.erase(it);,否则++it。
2. erase 后继续用旧迭代器
❌v.erase(it); ++it;,it已失效。 ✅it = v.erase(it);,使用返回值。
3. 对 vector 用erase(it++)
❌ 以为和 list 一样安全,实际上it已被失效。 ✅ 对vector/deque用返回值写法或 erase-remove。
4. 只调用std::remove不 erase
❌std::remove(v.begin(), v.end(), x);,容器大小不变,尾部残留无效元素。 ✅v.erase(std::remove(...), v.end());。
5. 在范围 for 中删除
❌for (int x : v) { if (cond) v.erase(...); },内部迭代器失效。 ✅ 改用显式迭代器循环,或用std::erase_if。
6. 下标删除后仍递增
❌v.erase(v.begin() + i); ++i;,跳过后移上来的元素。 ✅ 删除后保持i不变,保留分支才++i。
7. 持有引用跨越插入
❌int& r = v[0]; v.push_back(x); use(r);,扩容后引用悬垂。 ✅ 插入后重新取引用,或改用稳定引用的容器。
8. 认为 unordered 容器 rehash 后引用也失效
❌ 因担心 rehash,每次插入后重新获取所有元素的指针。 ✅unordered_*的 rehash 只失效迭代器,引用和指针仍有效;但迭代器要重新获取。
总结
| 场景 | 推荐写法 | 禁止写法 |
|---|
vector/deque条件删除 | it = c.erase(it)或 erase-remove | erase(it++) |
list/map/set条件删除 | it = c.erase(it)或erase(it++) | 删除后继续用旧迭代器 |
| 批量按值/条件删除 | std::erase/std::erase_if(C++20) | 循环中逐个 erase |
| 范围 for | 只读或修改元素 | 删除元素 |
| 引用/指针 | 插入后重新获取 | 跨越可能扩容的插入长期持有 |
迭代器失效的本质是「容器底层存储发生了变化」。连续存储的vector、deque在删除或扩容时会搬移元素,节点式的list、map只影响被删节点,哈希容器则在 rehash 时重排桶。记住这条底层差异,再记住「用erase的返回值推进」这一通用模板,绝大多数删除循环都能写对。