在编写动态规划时,发现了一个现象:
- C++ 中的
unordered_map
和map
可以一边遍历一边添加数据 - Java 中的
HashMap
却不能,Java 只能通过ConcurrentSkipListMap
实现在遍历中添加数据。
问了 grok,原来是两个编程语言在 map 数据结构底层设计的不同,总结如下。
文章目录
- 1. `map` 和 `unordered_map` 的迭代器行为
- (1) `std::map`(红黑树实现)
- (2) `std::unordered_map`(哈希表实现)
- 2. 代码示例
- `std::map` 示例
- `std::unordered_map` 示例
- 3. 与 Java `HashMap` 的对比
- 关键差异
- 4. 注意事项
- 5. 为什么与 Java `ConcurrentSkipListMap` 不同?
- 6. 总结
在 C++ 中,unordered_map
和 map
在遍历时一边添加数据(即插入新元素)之所以是可行的,是因为它们的迭代器设计和底层数据结构的特性允许这种操作,而不会像 Java 的 HashMap
那样抛出类似 ConcurrentModificationException
的异常。以下是对这一现象的详细解释,以及为什么它们在遍历中添加数据时表现不同的原因。
1. map
和 unordered_map
的迭代器行为
C++ 标准库中的容器(如 std::map
和 std::unordered_map
)遵循明确的迭代器失效规则。这些规则决定了在修改容器时,现有迭代器是否会变为无效(invalid)。关键点在于:插入操作不会使已有迭代器失效。
(1) std::map
(红黑树实现)
std::map
是一个有序关联容器,底层基于红黑树(一种自平衡二叉搜索树)。- 迭代器失效规则:
- 插入(
insert
)或删除(erase
)操作不会使指向已有元素的迭代器失效。 - 只有当某个具体元素被删除时,指向该元素的迭代器才会失效。
- 插入(
- 为什么可以一边遍历一边添加:
- 在遍历
map
时(例如使用for
循环或迭代器),插入新元素只会调整红黑树的结构,但不会影响已有节点的内存地址或迭代器的有效性。 - 插入操作的时间复杂度是 O(log n),但不会破坏正在进行的遍历。
- 在遍历
(2) std::unordered_map
(哈希表实现)
std::unordered_map
是一个无序关联容器,底层基于哈希表(通常是链地址法)。- 迭代器失效规则:
- 插入(
insert
)操作不会使已有迭代器失效,除非触发了哈希表的重新散列(rehash)。 - 如果插入导致哈希表容量不足(负载因子超过阈值),会触发 rehash,此时所有迭代器都会失效。
- 插入(
- 为什么可以一边遍历一边添加:
- 在不触发 rehash 的情况下,插入新元素只会修改某个桶的链表(或类似结构),而不影响其他桶中已有元素的迭代器。
- 如果遍历时小心避免 rehash(例如预先用
reserve
设置足够容量),就可以安全添加数据。
2. 代码示例
以下是 C++ 中 map
和 unordered_map
在遍历时添加数据的例子:
std::map
示例
#include <iostream>
#include <map>
int main() {
std::map<int, int> m = {{1, 10}, {2, 20}};
for (auto it = m.begin(); it != m.end(); ++it) {
if (it->first == 2) {
m.insert({3, 30}); // 在遍历时插入新元素
}
std::cout << it->first << ": " << it->second << "\n";
}
// 输出完整 map
for (auto [key, value] : m) {
std::cout << key << ": " << value << "\n";
}
return 0;
}
输出:
1: 10
2: 20
1: 10
2: 20
3: 30
- 插入
3: 30
不会影响已有迭代器,遍历继续正常进行。
std::unordered_map
示例
#include <iostream>
#include <unordered_map>
int main() {
std::unordered_map<int, int> um = {{1, 10}, {2, 20}};
um.reserve(10); // 预留空间,避免 rehash
for (auto it = um.begin(); it != um.end(); ++it) {
if (it->first == 2) {
um.insert({3, 30}); // 在遍历时插入新元素
}
std::cout << it->first << ": " << it->second << "\n";
}
// 输出完整 unordered_map
for (auto [key, value] : um) {
std::cout << key << ": " << value << "\n";
}
return 0;
}
输出(顺序可能不同,因为是无序的):
1: 10
2: 20
1: 10
2: 20
3: 30
- 只要不触发 rehash(通过
reserve
预留空间),插入不会使迭代器失效。
3. 与 Java HashMap
的对比
Java 的 HashMap
在遍历时不允许修改(添加或删除),否则会抛出 ConcurrentModificationException
。这是因为:
- Java 的
HashMap
使用**快速失败(fail-fast)**迭代器设计。一旦检测到结构修改(通过修改计数器modCount
),迭代器会立即抛出异常。 - C++ 的
map
和unordered_map
没有类似的机制,它们的迭代器是基于指针的惰性设计,只要内存地址有效,迭代器就可用。
关键差异
- Java: 检查结构性修改(
put
、remove
等改变大小的操作),主动抛出异常。 - C++: 只关心迭代器指向的元素是否仍然有效,插入操作通常不会影响已有元素。
4. 注意事项
尽管 C++ 的 map
和 unordered_map
允许一边遍历一边添加数据,但仍需注意以下问题:
map
:- 如果在遍历中插入大量元素,可能会导致性能下降(每次插入 O(log n))。
- 如果删除元素,需小心处理当前迭代器(例如使用
erase
返回的新迭代器)。
unordered_map
:- 如果插入触发 rehash,所有迭代器都会失效,导致未定义行为(undefined behavior)。可以用
reserve
提前分配空间来避免。 - 示例(触发 rehash 的错误情况):
std::unordered_map<int, int> um; for (auto it = um.begin(); it != um.end(); ++it) { um.insert({um.size() + 1, 0}); // 可能触发 rehash,迭代器失效 }
- 如果插入触发 rehash,所有迭代器都会失效,导致未定义行为(undefined behavior)。可以用
5. 为什么与 Java ConcurrentSkipListMap
不同?
你提到 Java 的 ConcurrentSkipListMap
在递归中不出错,而 HashMap
出问题。C++ 的 map
和 unordered_map
与 ConcurrentSkipListMap
类似的一点是,它们在设计上都支持一定的修改容忍度:
ConcurrentSkipListMap
是线程安全的,支持并发修改,迭代器是弱一致性的。- C++ 的
map
和unordered_map
虽不是为多线程设计的,但在单线程下,插入操作不会破坏迭代器的有效性。
但与 Java 的 HashMap
不同,C++ 不强制快速失败,而是将行为交给开发者控制。
6. 总结
std::map
:基于红黑树,插入不会使迭代器失效,因此可以一边遍历一边添加。std::unordered_map
:基于哈希表,只要不触发 rehash,也可以一边遍历一边添加。- 与 Java 的区别:C++ 的迭代器设计更宽松,没有快速失败机制,开发者需自己保证正确性。
另外:
- Java 的 HashMap 在容量不足时也会触发类似
rehash
的扩容(resize),但它的快速失败机制会在遍历时检测到修改并抛出异常。 - C++ 的 unordered_map 没有这种保护机制,
rehash
后迭代器失效是程序员的责任。