【C++面试】迭代器失效是什么?vector、list、map中如何避免迭代器失效

一、什么叫迭代器失效

先看一个简单的:

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 等问题就比较容易判断了。

0voice · GitHub

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值