C++ 标准库中的 set 容器
一、set 容器的本质与底层数据结构
C++ 标准库中的std::set是一种关联式容器,其核心特性是自动排序和唯一性。与序列式容器(如 vector、list)不同,set 中的元素是按照特定的排序规则进行存储的,这使得 set 具有高效的查找、插入和删除操作。
std::set的底层实现采用了红黑树(Red-Black Tree) 这一自平衡二叉搜索树数据结构。红黑树通过一系列规则保证了树的平衡性,从而确保了基本操作(插入、删除、查找)的时间复杂度均为 O (logN),其中 N 为树中元素的数量。
在 C++ 标准库中,红黑树的实现通常被封装在_Rb_tree类模板中,而 set 则是该类模板的一个特化应用,专门用于存储键值对中的键(在 set 中,键和值是同一个概念)
二、红黑树的结构解析
2.1 红黑树节点的构成
红黑树的每个节点通常包含以下几个部分:
键(key):即存储的元素值,用于节点间的比较
左孩子指针(left child):指向当前节点的左子节点
右孩子指针(right child):指向当前节点的右子节点
父节点指针(parent):指向当前节点的父节点
颜色标志(color):标识节点是红色还是黑色
在 C++ 标准库的实现中,节点的结构大致如下(简化版):
1 | template <typename Value> |
2.2 红黑树的特性
红黑树之所以能够保持平衡,是因为它遵循以下五条核心规则:
- 每个节点要么是红色,要么是黑色
- 根节点必须是黑色
- 所有叶子节点(NIL 节点,即空节点)都是黑色
- 如果一个节点是红色,则它的两个子节点必须是黑色
- 从任意一个节点到其所有后代叶子节点的路径上,包含相同数量的黑色节点
这些规则确保了红黑树的平衡性。即使在最坏情况下,树的高度也不会超过 2log (N+1),从而保证了高效的操作性能。
三、set 容器的核心操作实现
3.1 插入操作
set 的插入操作本质上是红黑树的插入操作,主要分为以下几个步骤:
查找插入位置:按照二叉搜索树的规则,找到新元素的合适插入位置
创建新节点:为新元素创建一个红色的节点(新节点默认为红色可以减少重平衡操作)
插入节点:将新节点插入到红黑树中
修复红黑树性质:通过旋转和重新着色操作,确保插入后红黑树仍然满足上述五条规则
修复操作主要处理以下几种情况:
情况 1:新节点的叔叔节点是红色
情况 2:新节点的叔叔节点是黑色,且新节点是右孩子
情况 3:新节点的叔叔节点是黑色,且新节点是左孩子
以下代码示例展示了std::set的插入操作及其特性:
1 | #include <iostream> |
在上述代码中,插入元素5, 3, 7, 2, 4, 6, 8后,std::set会自动对元素进行排序;尝试再次插入已存在的元素5时,由于set的唯一性,该操作不会产生重复元素。
3.2 删除操作
删除操作是红黑树中最复杂的操作,主要步骤如下:
查找目标节点:找到要删除的节点
处理删除节点的子节点:根据删除节点的子节点数量(0 个、1 个或 2 个)进行不同处理
若删除节点有 0 个或 1 个子节点,直接用子节点替换删除节点
若删除节点有 2 个子节点,则找到其前驱(或后继)节点,将前驱节点的值复制到删除节点,然后删除前驱节点
修复红黑树性质:删除节点后,可能破坏红黑树的规则,需要通过旋转和重新着色来恢复平衡
删除操作的修复过程比插入操作更为复杂,需要考虑多种不同的场景,涉及双重黑色节点的处理等概念。
以下代码展示了std::set的删除操作:
1 | #include <iostream> |
上述代码从std::set中删除元素6,删除后重新遍历集合,可验证元素已被成功移除,且剩余元素仍保持有序。
3.3 查找操作
set 的查找操作利用了二叉搜索树的特性,其过程如下:
从根节点开始查找
若当前节点的值等于目标值,则查找成功
若当前节点的值大于目标值,则进入左子树继续查找
若当前节点的值小于目标值,则进入右子树继续查找
若到达叶子节点仍未找到,则查找失败
由于红黑树的平衡性,查找操作的时间复杂度为 O (logN)。
下面是使用std::set进行查找操作的代码示例:
1 | #include <iostream> |
上述代码通过find方法分别查找存在和不存在的元素,根据返回结果判断元素是否存在于std::set中。
3.4 遍历操作
set 容器提供了多种遍历方式,包括前序遍历、中序遍历和后序遍历。在实际应用中,中序遍历最为常用,因为它可以按照元素的排序规则依次访问所有元素。
set 的迭代器实现与红黑树的结构密切相关。set 的迭代器是双向迭代器,它能够通过节点的指针在树中移动。迭代器的++操作会找到当前节点的中序后继,而--操作则会找到当前节点的中序前驱。
以下代码展示了使用迭代器遍历std::set的过程:
1 | #include <iostream> |
上述代码分别使用正向迭代器和反向迭代器对std::set进行遍历,展示了迭代器在set遍历中的应用。
四、set 与相关容器的比较
4.1 set 与 multiset 的区别
std::set和std::multiset都基于红黑树实现,它们的主要区别在于:
set:不允许存在重复元素,插入已存在的元素会失败
multiset:允许存在重复元素,插入已存在的元素会成功,并且在树中保留多个相同的值
这种差异反映在红黑树的插入操作上:set 在插入前会检查元素是否已存在,而 multiset 则直接插入,不进行检查。
4.2 set 与 unordered_set 的区别
std::set和std::unordered_set的主要区别在于底层数据结构和特性:
底层数据结构:set 基于红黑树,unordered_set 基于哈希表
排序性:set 中的元素是有序的,unordered_set 中的元素是无序的
查找效率:在平均情况下,unordered_set 的查找效率更高,时间复杂度为 O (1),但在最坏情况下可能退化为 O (N);而 set 的查找效率稳定为 O (logN)
迭代器稳定性:当进行插入或删除操作时,set 的迭代器(除了被删除元素的迭代器)不会失效;而 unordered_set 在进行插入操作时,可能会因为哈希表扩容而导致所有迭代器失效
4.3 set 与 map 的关联
std::set和std::map都基于红黑树实现,它们的区别在于存储的元素类型:
set:存储的是单一的值(键)
map:存储的是键值对(key-value pair)
实际上,set 可以看作是 map 的一种特殊情况,即键和值相同的 map。在 C++ 标准库中,set 通常是通过 map 的特化实现的,即set
五、内存管理机制
set 容器的内存管理由其底层的红黑树实现负责,主要涉及节点的分配和释放:
节点分配:当插入新元素时,set 会通过分配器(allocator)为新节点分配内存
节点释放:当删除元素或销毁 set 时,set 会释放相应节点所占用的内存
在 C++ 标准库中,默认的分配器是std::allocator,它使用operator new和operator delete进行内存管理。用户也可以通过模板参数指定自定义的分配器。
set 的内存布局是分散的,每个节点都是单独分配的内存块,通过指针连接形成树结构。这种布局与 vector 等连续存储的容器不同,它不会造成内存的整体移动,但可能会导致更多的内存碎片。
六、性能分析
6.1 时间复杂度
set 容器的主要操作的时间复杂度如下:
插入操作:O (logN)
删除操作:O (logN)
查找操作:O (logN)
遍历操作:O (N)
这些时间复杂度均基于红黑树的平衡性保证,在实际应用中具有很好的稳定性。
6.2 空间复杂度
set 容器的空间复杂度为 O (N),其中 N 为元素的数量。每个元素需要一个红黑树节点来存储,每个节点除了存储元素值外,还需要存储三个指针和一个颜色标志,因此额外的空间开销相对固定。
七、线程安全特性
C++ 标准库并没有为 set 容器提供线程安全保证,具体来说:
多个线程同时读取 set 是安全的
若有一个线程正在修改 set,则其他线程既不能读取也不能修改该 set
为了在多线程环境中安全地使用 set,用户需要自己添加同步机制(如互斥锁)来保护对 set 的访问。
不同的实现可能会有一些细微的差别,但总体而言,set 的线程安全特性遵循 C++ 标准库的通用规则。
八、总结
std::set是 C++ 标准库中一种非常实用的关联式容器,其底层基于红黑树实现,具有以下特点:
元素自动排序且唯一
插入、删除、查找操作的时间复杂度均为 O (logN)
提供双向迭代器,支持多种遍历方式
与 multiset、unordered_set、map 等容器既有联系又有区别