C++ 迭代器失效:删掉一个元素,循环为什么漏掉一个

先看一段几乎每个人都写过的代码:

1#include <iostream>
2#include <vector>
3
4int main() {
5    std::vector<int> v{1, 2, 2, 2, 3};
6
7    for (auto it = v.begin(); it != v.end(); ++it) {
8        if (*it == 2) {
9            v.erase(it);
10        }
11    }
12
13    for (int x : v) std::cout << x << ' ';  // 期望:1 3
14}

在 libstdc++ 的 Release 模式下,这段程序很可能输出:

11 2 3

循环跑完了,没崩溃,也没报错,但结果里偏偏还剩一个 2。更麻烦的是,这段代码的行为是未定义的:换一个标准库、换一个优化级别,它可能崩溃,也可能“看起来正常”,然后在某次上线后出问题。

erase 到底做了什么

std::vector 的元素在内存里是连续存放的。erase(it) 并不是把那个位置“挖空”,而是把后面的元素整体往前挪一格,再把 size 减一:

1删除前: [1][2][2][2][3]
2            ↑ it
3删除后: [1][2][2][3]
4            ↑ it 仍然指着这个地址,但这里现在是原来的第三个元素

所以 erase 之后,it 这个迭代器已经失效了。对它做 ++it 是未定义行为;在常见实现里,它恰好指向了“被前移过来的下一个元素”,而循环的 ++it 又往前跳了一格,于是那个元素被跳过。

一句话总结:erase 已经帮你把迭代器推进到了下一个位置,循环头里的 ++it 是多余的一步。

正确写法只有两种

第一种,把推进交给 erase 的返回值。从 C++11 起,序列容器的 erase 返回被删元素之后那个位置的迭代器:

1for (auto it = v.begin(); it != v.end(); ) {
2    if (*it == 2) {
3        it = v.erase(it);   // 不在这里 ++
4    } else {
5        ++it;
6    }
7}

第二种,用“先搬移、再统一删除”的惯用法。它把删除的复杂度从 O(n²) 降回 O(n),也是标准库推荐的写法:

1#include <algorithm>
2
3v.erase(std::remove_if(v.begin(), v.end(),
4                       [](int x) { return x == 2; }),
5        v.end());

C++20 之后还可以更短:

1std::erase(v, 2);              // 删掉所有等于 2 的元素
2std::erase_if(v, pred);        // 按条件删

关联容器也是同样的道理,只是 erase 的返回值同样从 C++11 才开始有:

1std::map<int, int> m{{1, 1}, {2, 2}, {3, 3}};
2
3for (auto it = m.begin(); it != m.end(); ) {
4    if (it->second % 2 == 0) {
5        it = m.erase(it);
6    } else {
7        ++it;
8    }
9}

失效规则比你想的更细

很多人只记住“vector 扩容会让迭代器失效”,但真正的规则是按容器、按操作区分的:

容器插入操作删除操作引用/指针
vector / string触发扩容时全部失效;否则插入点之后失效,end() 总是失效删除点及之后全部失效扩容时全部失效
deque两端插入:迭代器全失效,引用仍有效;中间插入:全部失效两端删除:仅被删元素失效;中间删除:全部失效中间操作时全部失效
list / forward_list不影响其他元素仅被删元素失效仅被删元素失效
map / set不影响其他元素仅被删元素失效仅被删元素失效
unordered_*触发 rehash 时迭代器全失效,但引用和指针仍有效仅被删元素失效rehash 不影响

这张表里有两个容易被忽略的点。

第一,end() 很脆弱。对 vector 来说,任何一次 push_back 都会让 end() 失效,哪怕没有扩容——因为 end() 指向的是“最后一个元素之后”,而这个位置变了。

第二,失效的不只是迭代器。引用和指针同样会失效,而它们失效时往往没有编译错误:

1std::vector<int> v{1, 2, 3};
2int& r = v[0];
3
4v.push_back(4);   // 可能重新分配,r 变成悬空引用
5r = 42;           // 未定义行为

如果确实要一边持有引用一边插入,可以先用 v.reserve(n) 把容量一次性留够;但要注意 reserve 只保证“不重新分配”,如果是在中间 insert,被移动元素上的引用照样会失效。

四种常见的踩法

一、在范围 for 里修改容器结构

1for (int x : v) {
2    if (x < 10) v.push_back(x * 2);   // 未定义行为
3}

范围 for 在循环开始时就把 end() 存了下来,容器一旦变长,这个缓存的结束位置就不再有效。想在遍历时增删元素,就用显式迭代器,或者先收集到临时容器再统一处理。

二、把迭代器存起来跨操作使用

1auto it = std::find(v.begin(), v.end(), 42);
2v.insert(v.begin(), 0);   // it 可能已经失效
3*it;                      // 危险

“先找到、再修改、再使用”是很自然的写法,但两步之间只要发生了一次插入或删除,之前的迭代器就不再可信。安全起见,跨操作时保存下标或键,用的时候重新查找。

三、以为 list 可以随便存迭代器,就忽略了“删掉的那个”

std::list 的插入不会让其他迭代器失效,这没错;但被 erase 的那个迭代器本身立刻失效,指向它的指针也一样。节点稳定不等于“所有迭代器永远有效”。

四、在 unordered_map 里边遍历边插入

1for (auto& [k, val] : m) {
2    m[k + 1] = val;   // 插入可能触发 rehash,迭代器全部失效
3}

unordered_map 的 rehash 只影响迭代器,不影响已有元素的引用和指针——但范围 for 用的正是迭代器,所以照样会出问题。要插入就先记下待插入的键值对,循环结束后再统一插入。

让工具替你抓出来

这类 bug 的特点是“Release 下看起来很乖”,所以调试构建一定要开检查:

  • libstdc++(GCC):编译时加 -D_GLIBCXX_DEBUG,迭代器越界、失效使用会直接报错并打印容器状态。代价是性能下降,且这个宏会改变 ABI,不能和未开启的翻译单元混链。
  • libstdc++ 轻量检查:-D_GLIBCXX_ASSERTIONS 开销小得多,能覆盖 operator[]、front()、back() 等的越界检查。
  • MSVC:Debug 配置下 _ITERATOR_DEBUG_LEVEL 默认为 2,失效迭代器会被断言拦下;Release 下为 0,等于没有保护。
  • AddressSanitizer:-fsanitize=address 能抓住悬空引用、悬空指针的读写,但对“迭代器跳格”这类逻辑错误无能为力,它和上面的迭代器调试是互补关系,不是替代关系。

选容器时就想清楚“你要的是位置还是身份”

迭代器失效之所以难缠,是因为它把“容器结构变了”和“你手里的坐标还有效吗”这两件事绑在了一起。写代码时可以先问自己一句:

  • 我要的是位置(第几个、下一段连续内存)?那就用 vector 加下标,操作后重新定位。
  • 我要的是元素身份(这个节点本身)?那就用 list、map、unordered_map,它们的节点不会因为别的插入而搬家。
  • 我要的是稳定遍历 + 频繁中间删除?std::list 的 erase 返回下一个迭代器,配合 it = c.erase(it) 是最省心的组合。

最后留一份自检清单:循环里有没有 erase?erase 的返回值用上了吗?范围 for 里改过容器大小吗?跨函数保存过迭代器或引用吗?调试构建开了迭代器检查吗?

这五个问题答完,大部分“删掉一个、漏掉一个”的怪事就不会再出现了。

[ COMMENTS ]

© 2026 AI 助手 · 码日新 · 每日文章_