Set 和 Map 的模拟实现
1、引言
在数据结构与算法的学习与实践中,关联容器(associative containers)是不可忽视的重要工具。作为高效管理数据的一类容器,C++
标准库中的 set
和 map
在现代软件开发中扮演着关键角色。这两个容器通过平衡二叉搜索树(如红黑树)的底层实现,能够在 O(log n)
的时间复杂度下完成插入、查找、删除等操作,因此被广泛应用于需要有序存储、快速检索和动态调整的数据集场景中。
set
容器作为一种不允许重复元素的集合类型,在许多涉及集合操作的应用中具有优势,比如对数据的去重处理、集合的并集和交集等。由于它保证元素的唯一性和自动排序,set
是实现有序集合、去重列表等场景下不可或缺的工具。同时,set
容器具有多种变体,如 multiset
,允许重复元素的存储,提供了更大的灵活性。
相比之下,map
容器则是以键值对的形式存储数据的关联容器。通过基于键的快速查找,map
在需要频繁检索、插入或更新数据的场景下表现出色。其键值对的存储结构不仅确保键的唯一性,同时自动按键的顺序进行排序,因此在实现字典、哈希表、数据库索引等应用时尤为高效。此外,C++
标准库中还提供了 multimap
,允许键重复,以满足不同场景下的需求。
这两种容器的底层实现通常依赖于平衡二叉搜索树,尤其是红黑树。红黑树是一种能够在插入、删除过程中始终保持平衡的自平衡二叉搜索树,这使得 set
和 map
能够在任何操作中维持较低的时间复杂度。理解红黑树的结构与性质,是深入理解 set
和 map
内部实现机制的关键。
本博客的目的在于通过技术深度的分析与代码实现,深入讲解 C++ 中 set
和 map
容器的理论与实际应用。在接下来的章节中,我们将首先介绍 set
和 map
的数据结构与性质,分析其基本操作的实现原理。然后,我们会结合具体代码,**讲解这些容器的底层实现方式,重点探讨红黑树在 set
和 map
中的核心作用。**最后,我们还将探讨 set
和 map
在实际项目中的应用场景及优化策略,为读者提供实践中的参考依据。
通过这篇博客,您将不仅能够掌握 set
和 map
的基本用法,还能对其内部工作原理有深入理解,并且学会在不同场景下灵活运用这些容器,以实现更高效、可扩展的数据结构解决方案。
如果你对 二叉搜索树 还不够了解,我的这篇关于 二叉搜索树 的博客请一定不要错过:《 C++ 修炼全景指南:九 》打破编程瓶颈!掌握二叉搜索树的高效实现与技巧
如果你对 **AVL 树 **还不够了解,我的这篇关于 AVL 树 的博客请一定不要错过:《 C++ 修炼全景指南:十 》自平衡的艺术:深入了解 AVL 树的核心原理与实现
如果你对 红黑树 还不够了解,我的这篇关于 红黑树 的博客请一定不要错过:《 C++ 修炼全景指南:十一 》穿越数据的红与黑:掌握数据平衡的极致艺术
2、容器的基础概念与使用
C++
标准库中的 set
和 map
是两种常见的关联容器,均使用自平衡二叉搜索树——通常是**红黑树(Red-Black Tree)**作为其底层数据结构。红黑树的平衡性保证了这些容器的操作时间复杂度始终维持在 O(log n)
,无论是插入、删除还是查找操作。因此,set
和 map
容器在大量数据场景下具备良好的性能表现。接下来,我们将通过详细的代码示例,展示 set
和 map
的常见操作,并结合技术深度探讨其背后的实现原理和应用场景。
2.1、容器
在讨论 set
和 map
之前,有必要先了解 C++ 标准库中的容器分类。这些容器分为 序列式容器(Sequence Containers) 和 关联式容器(Associative Containers),它们各自有不同的结构、实现原理以及适用场景。通过理解它们的特性,能够帮助我们更好地选择合适的容器来解决特定问题。
2.1.1、序列式容器(Sequence Containers)
序列式容器是一类用于存储和管理数据元素的线性数据结构,它们主要提供了按照顺序存储元素的功能。在这些容器中,元素的排列顺序与其插入顺序保持一致。序列式容器支持随机访问,允许我们根据元素的存储位置进行高效的插入、删除、修改和访问。
序列式容器主要包括以下几类:
-
string
:std::string
是C++
中处理字符串的标准容器,基于动态数组实现,并提供了一系列字符串相关的操作。- 时间复杂度:
- 随机访问:O(1)
- 尾部插入/删除:摊还复杂度 O(1)
- 中间插入/删除:O(n)
- 使用场景:与传统的
C
风格字符串(使用char
数组和空字符\0
作为结束符)相比,std::string
提供了更方便、安全的接口,并具备动态扩展能力,能够自动管理内存。 - 《 C++ 修炼全景指南:二 》告别平庸!实现一个比标准库更强的 C++ String 类
- 时间复杂度:
-
vector
:
vector
是一种动态数组容器,能够根据需要动态扩展或缩小容量。它提供了高效的随机访问和尾部插入、删除操作,但在数组中间或开头插入和删除元素的效率较低(因为会涉及元素的移动)。- 时间复杂度:
- 随机访问:O(1)
- 尾部插入/删除:摊还复杂度 O(1)
- 中间插入/删除:O(n)
- 使用场景:适用于需要频繁随机访问或尾部操作的场景,如动态数组管理、临时存储等。
- 《 C++ 修炼全景指南:三 》如何实现标准库般强大的 C++ Vector?:从动态扩容到移动语义到迭代器全覆盖
- 时间复杂度:
-
list
:
list
是双向链表,支持高效地在任何位置进行插入和删除操作,但不支持随机访问。由于链表的元素不连续存储,访问元素的时间复杂度较高,因此不适用于需要频繁随机访问的场景。- 时间复杂度:
- 插入/删除:O(1)
- 随机访问:O(n)
- 使用场景:适用于需要频繁插入和删除元素,但不需要随机访问的场景,如任务队列、事件调度等。
- 《 C++ 修炼全景指南:四 》揭秘 C++ List 容器背后的实现原理,带你构建自己的双向链表
- 时间复杂度:
-
deque
:
deque
是双端队列,支持在头部和尾部高效地插入和删除元素。与vector
相比,它在两端插入和删除元素的性能要更好,但在中间插入或删除元素时,效率与vector
类似。-
时间复杂度:
- 随机访问:O(1)
- 头部/尾部插入/删除:O(1)
- 中间插入/删除:O(n)
-
使用场景:适用于双端队列操作,如需要频繁从头部和尾部进行插入和删除操作的场景。
-
《 C++ 修炼全景指南:五 》实现媲美 C++ 标准库的 stack 和 queue 容器 —— 模板、动态扩容、迭代器与线程安全详解
《 C++ 修炼全景指南:六 》深入探索 C++ 标准库中的 stack 与 queue 容器适配器
-
序列式容器的特点:
- 支持元素按照插入顺序存储,保证顺序一致。
- 提供灵活的内存管理和高效的线性操作。
string
、vector
和deque
支持随机访问,而list
不支持。
2.1.2、关联式容器(Associative Containers)
与序列式容器不同,关联式容器使用的是基于特定规则进行元素存储和管理的树结构或哈希表结构。关联式容器通过键值对来管理数据,自动为插入的元素进行排序或管理,支持高效的查找、插入和删除操作。
关联式容器主要包括两类:
- 有序关联式容器:
使用 红黑树 作为底层数据结构,确保所有元素按照某种规则排序。在有序关联式容器中,元素之间有明确的顺序,查找和操作的时间复杂度通常为O(log n)
。包括:set
:元素唯一且自动排序的集合容器。map
:以键值对存储数据,键是唯一的,并且按键排序。multiset
:允许重复元素的集合,其他特性与set
类似。multimap
:允许重复键的键值对容器,其他特性与map
类似。
- 无序关联式容器:
使用 哈希表 作为底层数据结构,通过哈希函数来管理数据。无序关联式容器不保证元素的顺序,但在大多数情况下,查找、插入和删除操作的平均时间复杂度为O(1)
。包括:unordered_set
:基于哈希表实现的集合,不保证顺序,元素唯一。unordered_map
:基于哈希表实现的键值对容器,键是唯一的。unordered_multiset
:基于哈希表实现的集合,允许重复元素。unordered_multimap
:基于哈希表实现的键值对容器,允许重复键。
2.1.3、键值对
在 C++
标准库中,键值对数据结构的代表性容器主要是 std::map
和 std::unordered_map
。这两种容器广泛用于需要存储和管理键值对的场景。键值对**用来表示具有一一对应关系的一种结构,该结构中一般只包含两个成员变量 key
和 value
,key
代表键值, value
表示与 key
对应的信息。**键值对的数据结构通常用于需要根据某个键快速查找到对应值的场景。C++ 标准库中的 std::map
和 std::unordered_map
正是为这一需求而设计的。
- 键(Key):通常是一个唯一的标识符,用来访问关联的值。在一个键值对容器中,键不能重复。
- 值(Value):与键相关联的数据,可以是任何类型,包括基础数据类型或自定义类型。
键值对容器的目标是提供高效的键查找、插入和删除操作,且在查找键时能够保证键的唯一性。比如:现在要建立一个英汉互译的字典,那该字典中必然有英文单词与其对应的中文含义,而且,英文单词与其中文含义是一一对应的关系,即通过该单词,在词典中就可以找到与其对应的中文含义。
SGI-STL
中关于键值对的定义:
template <class T1, class T2>
struct pair
{typedef T1 first_type;typedef T2 second_type;T1 _first;T2 _second;pair(): _first(T1()), _second(T2()){}pair(const T1& a, const T2& b): _first(a), _second(b){}
};
pair 文档
2.2、set 的定义与实现原理
2.2.1、set 的定义
set
是一种无重复元素的集合容器,通常用于存储需要保证唯一性的元素。它通过维护一个内部有序的结构,确保元素插入时的顺序是基于元素的排序规则的。C++
中的 set
默认基于 <
运算符的排序规则排列元素,因此在迭代访问时,set
中的元素总是按照升序排列。
set
的底层实现基于红黑树,利用红黑树的平衡性来保证查找、插入、删除操作的时间复杂度始终为 O(log n)。每次插入新元素时,set
会根据其排序规则判断该元素在树中的位置,如果发现重复元素,插入操作会被拒绝,从而确保元素的唯一性。
三万字详解,真的值得一看喔!《 C++ 修炼全景指南:十一 》穿越数据的红与黑:掌握数据平衡的极致艺术
2.2.2、set 的常用操作
set 文档
红黑树作为 set
容器的底层数据结构,其插入、删除、查找等操作均是通过红黑树的基本操作来实现的。
1、set 的模板参数列表
template < class T, // set::key_type/value_type class Compare = less<T>, // set::key_compare/value_compare class Alloc = allocator<T> // set::allocator_type > class set;
T:set
中存放元素的类型,实际在底层存储<value, value>
的键值对
Compare:set
中元素默认按照小于来比较
Alloc:set
中元素空间的管理方式,使用STL
提供的空间配置器管理
2、set 的构造
// 构造空的 set
explicit set (const key_compare& comp = key_compare(), const allocator_type& alloc = allocator_type());// 用 (first, last) 区间中的元素构造 set
template <class InputIterator>
set (InputIterator first, InputIterator last,const key_compare& comp = key_compare(),const allocator_type& alloc = allocator_type());// 拷贝构造
set (const set& x);
3、set 的迭代器
函数声明 功能介绍
iterator begin() // 返回 set 中起始位置元素的迭代器
iterator end() // 返回 set 中最后一个元素后面的选代器
const iterator cbegin() const // 返回 set 中起始位置元素的 const 迭代器
const iterator cend() const // 返回 set 中最后一个元素后面的 const 迭代器
reverse_iterator rbegin() // 返回 set 第一个元素的反向选代器,即 end
reverse iterator rend() // 返回 set 最后一个元素下一个位置的反向选代器, 即 begin
const reverse iterator crbegin() const // 返回 set 第一个元素的反向const选代器, 即 cend
const reverse iterator crend() const // 返回 set 最后一个元素下一个位置的反向 const 选代器, 即 cbegin
4、set 的容量
函数声明 功能介绍
bool empty( ) const // 检测 set 是否为空, 空返回 true, 否则返回 true
size_type size() const // 返回 set 中有效元素的个数
5、set 的修改操作
函数声明 功能介绍
// 在 set 中插入元素 x, 实际插入的是 <x, x> 构成的键值对, 如果插入成功, 返回 <该元素在 set 中的位置, true>, 如果插入失败, 说明 x 在 set 中已经存在, 返回 <x 在 set 中的位置, false>
pair<iterator,bool> insert(const value_type& x);// 删除 set 中 position 位置上的元素
void erase (iterator position );// 删除 set 中值为 x 的元素, 返回删除的元素的个数
size_type erase (const key_type& x);// 删除 set 中 [first, last) 区间中的元素
void erase (iterator first, iterator last);// 交换 set 中的元素
void swap(set<Key, Compare, Allocator>& st);// 将 set 中的元素清空
void clear ( );// 返回 set 中值为 x 的元素的位置
iterator find (const key_type& x)const// 返回 set 中值为 x 的元素的个数
size _type count (const key_type& x)const
- 插入操作
在
set
中插入元素时,系统会首先通过比较元素的大小来确定其在红黑树中的插入位置。如果待插入的元素与树中的某个节点相等,插入操作会终止,因为set
容器要求所有元素唯一。若插入成功,红黑树的平衡性可能会被打破,因此插入操作完成后可能需要进行颜色调整和旋转操作,以维持树的平衡。
- 删除操作
删除操作与插入类似,首先需要找到要删除的元素,随后将其从红黑树中移除。由于删除某个节点可能会破坏红黑树的平衡,删除操作完成后通常需要重新调整树的颜色,或者通过左旋、右旋操作恢复平衡。
- 查找操作
在
set
中查找某个元素时,系统会根据红黑树的性质,沿树路径依次进行元素比较。如果找到与目标元素相等的节点,则查找成功。由于红黑树的高度始终保持在 O(log n),因此查找操作的时间复杂度同样为 O(log n)。
2.2.3、set 使用举例
void test_set1()
{set<int> s1; // set默认排序为从小到大 set底层是搜索树s1.insert(10);s1.insert(20);s1.insert(5);s1.insert(2);s1.insert(1);s1.insert(8);s1.insert(14);s1.insert(44);s1.insert(23);s1.insert(0);s1.insert(2);s1.insert(1);s1.insert(8);s1.insert(14);// 排序 + 去重set<int>::iterator it = s1.begin();while (it != s1.end()){//*it = 100; // set中的元素是只读的,不能修改cout << *it << " ";it++;}cout << endl;// 范围forfor (auto e : s1){cout << e << " ";}cout << endl;// 拷贝构造 底层默认是一个深拷贝set<int> s2(s1);for (auto e : s2){cout << e << " ";}cout << endl;// 删除// find()是成员函数,只能用于set/multiset/map/multimap// set<int>::iterator pos = s2.find(5);auto pos = s2.find(5);// find()是泛型算法,可以用于所有容器// s.find() O(logN) s.begin() O(1) s.end() O(1)// find(s.begin(), s.end(), 5) O(N) 效率会低不少// auto pos = find(s2.begin(), s2.end(), 5);if (pos != s2.end()){s2.erase(pos);}for (auto e : s2){cout << e << " ";}cout << endl;// 或者直接删s2.erase(8);s2.erase(80); // 和迭代器的区别在于:删除不存在的元素,不会报错for (auto e : s2){cout << e << " ";}cout << endl;
}
保证唯一性的机制
set
容器的独特性质在于它保证集合中的每个元素都是唯一的。为了实现这一点,set
在插入元素时总是会首先查找该元素是否已存在。如果待插入的元素已存在于树中,插入操作将被拒绝。红黑树的这种有序存储结构在维持树的平衡性的同时,能够在 O(log n)
的时间内完成重复元素的检测和插入决策。
以下是 set
容器的插入操作示例代码:
#include <set>
#include <iostream>int main() {std::set<int> s;// 插入元素s.insert(10);s.insert(5);s.insert(15);// 插入重复元素,不会成功auto result = s.insert(10);if (!result.second) {std::cout << "元素 10 已存在" << std::endl;}return 0;
}
在这个示例中,set
容器成功插入了 10、5、15 三个元素,但在尝试插入第二个 10 时,插入操作被拒绝,保证了集合的唯一性。
2.3、map 的定义与实现原理
2.3.1、map 的定义
map
是一种键值对(key-value pair)
容器,其每个元素都是由唯一的**键(key)和与其对应的值(value)**组成的。与 set
类似,map
中的键是有序存储的,但不同之处在于,map
允许通过键查找其对应的值。C++
标准库中的 map
默认也基于红黑树实现,因此其查找、插入和删除操作的时间复杂度为 O(log n)
。
map
的核心工作机制是通过键来管理数据,键的唯一性是 map
容器的关键属性。这意味着在 map
中,不能有两个元素具有相同的键。当向 map
插入新元素时,如果该键已存在,则新元素不会插入,但其值会被更新。
2.3.2、map 的常用操作
map
文档
与 set
相同,map
容器的插入、删除和查找操作均依赖于红黑树的平衡性来保持高效性。每个操作的本质与 set
类似,只不过 map
的每个节点不仅存储键,还存储与之相关联的值。
1、map 的模板参数说明
template < class Key, // map::key_typeclass T, // map::mapped_typeclass Compare = less<Key>, // map::key_compareclass Alloc = allocator<pair<const Key,T> > // map::allocator_type> class map;
key
:键值对中 key 的类型
T
:键值对中 value 的类型
Compare
:比较器的类型,map
中的元素是按照key
来比较的,缺省情况下按照小于来比较,一般情况下(内置类型元素)该参数不需要传递,如果无法比较时(自定义类型),需要用户自己显式传递比较规则(一般情况下按照函数指针或者仿函数来传递)
Alloc
:通过空间配置器来申请底层空间,不需要用户传递,除非用户不想使用标准库提供的空间配置器注意:在使用
map
时,需要包含头文件
2、map 的构造
函数声明 功能介绍
map() // 构造一个空的 map
3、map 的迭代器
函数声明 功能介绍
begin() 和 end() // begin: 首元素的位置, end 最后一个元素的下一个位置
cbegin() 和 cend() // 与 begin 和 end 意义相同, 但 cbegin 和 cend 所指向的元素不能修改
rbegin() 和 rend() // // 反向迭代器, rbegin 在 end 位置, rend 在 begin 位置,其 ++ 和 -- 操作与 begin 和 end 操作移动相反
crbegin() 和 crend() // 与 rbegin 和 rend 位置相同, 操作相同, 但 crbegin 和 crend 所指向的元素不能修改
4、map 的容量与元素访问
函数声明 功能简介
bool empty( ) const // 检测 map 中的元素是否为空, 是返回 true, 否则返回 false
size_type size() const // 返回 map 中有效元素的个数
mapped type& operator[](const key_type& k) // 返回去 key 对应的 value
问题:当 key
不在 map
中时,通过 operator
获取对应 value
时会发生什么问题?
注意:在元素访问时,有一个与 operator[]
类似的操作 at()
(该函数不常用)函数,都是通过 key
找到与 key
对应的 value
然后返回其引用,不同的是:当 key
不存在时,operator[]
用默认 value
与 key
构建键值对然后插入,返回该默认 value
,at()
函数直接抛异常。
// map/multimap的下标运算符, 返回值是mapped_type&, 如果key不存在, 就插入一个新的节点, value初始化为0
mapped_type& operator[](const key_type& key)
{return (*((this->insert(make_pair(key, mapped_type()))).first)).second;
}
5、map 的修改操作
// 函数声明 功能简介
// 在 map 中插入键值对 x, 注意 x 是一个键值对, 返回值也是键值对: iterator 代表新插入元素的位置, bool代表释放插入成功
pair<iterator,bool> insert(const value_type& x);// 删除 position 位置上的元索
void erase (iterator position);// 删除键值为 x 的元素
size_type erase( const key_type& x);// 删除 [first, last) 区间中的元素
void erase(iterator first, iterator last);// 交换两个 map 中的元素
void swap(map<Key,T,Compare, Allocator>& mp);// 将 map 中的元素清空
void clear();// 在 map 中插入 key 为 x 的元素, 找到返回该元素的位置的迭代器, 否则返回 end
iterator find(const key_type& x);// 在 map 中插入 key 为 x 的元素, 找到返回该元素的位置的 const 迭代器, 否则返回 cend
const iterator find (const key_type& x) const// 返回 key 为 x 的键值在 map 中的个数, 注意 map 中 key 是唯一的, 因此该函数的返回值要么为 0, 要么为 1, 因此也可以用该函数来检测一个 key 是否在 map 中
size_type count(const key_type& x) const
1. 插入操作
当
map
中插入新的键值对时,系统首先会查找该键是否已存在。如果键存在,则插入失败,但会更新与该键相关联的值。若键不存在,则红黑树根据键的大小来确定新键值对的插入位置,并按照红黑树的规则进行颜色调整和旋转操作,以确保树的平衡性。2. 删除操作
删除操作与
set
类似,首先需要找到对应的键,然后删除对应的键值对。删除完成后,红黑树的平衡性可能会被破坏,因此系统需要通过调整颜色或旋转操作来恢复平衡。3. 查找操作
map
中的查找操作是通过键进行的。由于map
是基于红黑树实现的,查找过程遵循二叉搜索树的查找方式,能够在 O(log n) 的时间复杂度下找到与目标键匹配的键值对。
2.3.3、map 使用举例
void test_map1()
{map<int, int> m1;m1.insert(pair<int, int>(1, 1)); // pair是一个模板类,可以用来创建pair对象m1.insert(pair<int, int>(3, 3));m1.insert(pair<int, int>(2, 2));m1.insert(pair<int, int>(5, 5));m1.insert(pair<int, int>(6, 6));m1.insert(pair<int, int>(4, 4)); // pair构造函数,构造一个匿名对象,然后插入到map中// 日常大家喜欢用make_pair()来构造pair对象,因为他不用声明模板参数,编译器自动推m1.insert(make_pair(7, 7)); // 函数模板构造一个pair对象map<int, int>::iterator it = m1.begin();while (it != m1.end()){// cout << *it << " "; // C++不支持返回两个数cout << (*it).first << " " << (*it).second << endl; // operator*()返回的是节点中值的引用cout << it->first << " " << it->second << endl; // operator->()返回的是节点的指针 也就是pair<k, v>的指针it++;}cout << endl;// 范围forfor (auto &e : m1){cout << e.first << " " << e.second << endl;}cout << endl;
}void test_map2()
{std::map<std::string, std::string> dirt;dirt.insert(pair<std::string, std::string>("sort", "排序"));dirt.insert(make_pair("string", "字符串"));dirt.insert(make_pair("left", "左边")); // 按照ASCII码排序std::map<std::string, std::string>::iterator it = dirt.begin();while (it != dirt.end()){cout << it->first << " " << it->second << endl;it++;}cout << endl;
}void test_map3()
{// 统计单词出现的次数string strArr[] = {"西瓜", "苹果", "西瓜", "苹果", "西瓜", "香蕉", "西瓜", "樱桃", "西瓜", "苹果","西瓜", "苹果", "西瓜", "香蕉", "西瓜", "樱桃", "西瓜", "苹果", "西瓜", "苹果", "西瓜", "香蕉","西瓜", "樱桃", "西瓜", "苹果", "西瓜", "苹果", "西瓜", "香蕉", "西瓜", "樱桃", "西瓜", "苹果","西瓜", "苹果", "西瓜", "香蕉", "西瓜", "樱桃", "西瓜", "苹果", "西瓜", "苹果", "西瓜", "香蕉"};map<string, int> countMap;// 第一种方法for (auto &str : strArr){map<string, int>::iterator pos = countMap.find(str);if (pos != countMap.end()){pos->second++;}else{countMap.insert(make_pair(str, 1));}}for (auto &e : countMap){cout << e.first << " " << e.second << endl;}cout << endl;// 第二种方法for (auto &str : strArr){pair<map<string, int>::iterator, bool> ret = countMap.insert(make_pair(str, 1));if (ret.second == false) // 插入失败,说明已经存在{ret.first->second++; // 迭代器指向的是插入的元素}}for (auto &e : countMap){cout << e.first << " " << e.second << endl;}cout << endl;// 第三种方法for (auto &str : strArr){// 1、如果水果不在map中,则[]会插入pair<str, 0>, 返回映射对象(次数)的引用进行++// 2、如果水果在map中,则[]会返回水果对应的映射对象(次数)的引用,对它进行++countMap[str]++;}countMap["葡萄"]; // 插入 一般不这么用countMap["葡萄"] = 100; // 修改cout << countMap["葡萄"] << endl; // 查找countMap["哈密瓜"] = 5; // 插入 + 修改// 一般使用operator[]去// 1、插入 + 修改// 2、修改// 一般不会用他去做查找,因为如果key不在,会插入数据。for (auto &e : countMap){cout << e.first << " " << e.second << endl;}cout << endl;
}int main()
{// test_set1();test_map1();// test_map2();// test_map3();return 0;
}
2.4、multiset && multimap 的介绍
multimap
文档
在 C++ 标准库中,除了 set
和 map
,还有两种关联容器——multiset
和 multimap
,它们允许存储多个相同的键。与 set
和 map
不同的是,multiset
和 multimap
允许键重复,因此在特定应用场景中,如处理不唯一的集合或键值对,它们非常有用。接下来,我们将详细探讨 multiset
和 multimap
的理论基础、常见操作以及实际应用。
multimap
是关联式容器,它是按照特定顺序,存储右key
和value
映射成的键值对<key, value>
,其中多个键值对之间的key
是可以重复的。在
multimap
中,通常按照key
排序和唯一地标识元素,而映射的value
存储与key
关联的内容。key
和value
的类型可能不同,通过multimap
内部成员类型value_type
组合在一起,value_type
是组合key
和value
的键值对:
typedef pair<const Key, T> value_type;
在内部,
multimap
中的元素总是通过其内部比较对象,按照指定的特定严格弱排序标准对key
进行排序的
multimap
通过key
访问单个元素的速度通常比unordered_multimap
容器慢,但是使用迭代器直接遍历multimap
中的元素可以得到关于key
有序的序列
multimap
在底层用二叉搜索树(红黑树)来实现注意:
multimap
和map
的唯一不同就是:map
中的key
是唯一的,而multimap
中key
是可以重复的
multimap 中的接口可以参考 map ,功能都是类似的
注意:
- multimap 中的 key 是可以重复的
- multimap 中的元素默认将 key 按照小于来比较
- multimap 中没有重载 operator[] 操作
- 使用时与 map 包含的头文件相同
multiset
&& multimap
使用举例
void test_multi()
{// multiset根set的区别就是允许键值冗余multiset<int> s1; // 使用和set一样,只是multiset可以存放重复的元素s1.insert(10);s1.insert(20);s1.insert(5);s1.insert(2);s1.insert(1);s1.insert(8);s1.insert(8);s1.insert(14);s1.insert(44);s1.insert(23);s1.insert(0);s1.insert(2);s1.insert(1);s1.insert(8);s1.insert(8);s1.insert(14);for (auto e : s1){cout << e << " ";}cout << endl;auto pos = s1.find(8); // 查找到的是重复元素的第一个元素地址cout << *pos << endl;pos++;cout << *pos << endl;pos++;cout << *pos << endl;pos++;cout << *pos << endl;pos++;cout << *pos << endl;// multimap和map的区别和上面是一样的multimap<string, int> mm;mm.insert(make_pair("苹果", 1));mm.insert(make_pair("苹果", 1));mm.insert(make_pair("苹果", 3));mm.insert(make_pair("西瓜", 2));mm.insert(make_pair("西瓜", 1));for (auto &e : mm){cout << e.first << " " << e.second << endl;}cout << endl;
}
3、红黑树的封装
在这章中,我们将从底层的红黑树设计开始,逐步深入到 set
和 map
的封装与实现。但是本章不会对红黑树讲解的那么细致,如果你对红黑树还不够细致,这篇三万字详解红黑树的博客请一定不要错过:《 C++ 修炼全景指南:十一 》穿越数据的红与黑:掌握数据平衡的极致艺术
我们的目标是通过代码和理论结合,详尽介绍红黑树的结构、迭代器的设计、平衡树的旋转与修正、键值对的存储及其复杂度分析,最终展现如何通过这些基础实现 C++ 标准库中重要的 set
和 map
容器。
3.1、红黑树节点的设计
红黑树的每个节点不仅要存储数据,还必须包含颜色信息(红或黑),并通过指针链接至父节点、左子节点和右子节点。这些额外的信息使红黑树能够在插入和删除操作后保持平衡,并保证操作的时间复杂度为 O(log n)
。
3.1.1、节点结构
我们为红黑树设计的节点结构如下:
namespace Lenyiin
{enum Colour{RED,BLACK};template <class T>struct RBTreeNode{RBTreeNode<T> *_left; // 左子节点RBTreeNode<T> *_right; // 右子节点RBTreeNode<T> *_parent; // 父节点T _data; // 节点存储的数据Colour _colour; // 节点的颜色(红色或黑色)// 节点的构造函数,默认为红色节点RBTreeNode(const T &data): _left(nullptr), _right(nullptr), _parent(nullptr), _data(data), _colour(RED){}};
}
3.1.2、节点结构详解
在这个节点设计中,我们使用模板参数 T
来支持不同的数据类型。每个节点包含五个成员变量:
- _left: 指向左子节点的指针。
- _right: 指向右子节点的指针。
- _parent: 指向父节点的指针。这允许我们在树中进行向上回溯操作。
- _data: 存储的数据,可以是任意类型。
- _colour: 表示节点的颜色,枚举类型
Colour
包含红色和黑色两个值。
在红黑树中,根节点的颜色始终是黑色,新插入的节点初始为红色。当红黑树进行插入或删除操作时,节点颜色可能会发生变化,以维持红黑树的平衡性。
3.1.3、节点颜色的重要性
红黑树的平衡特性依赖于节点的颜色以及颜色的相关规则:
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色。
- 所有叶子节点(NIL 节点,通常用空节点表示)必须是黑色。
- 如果一个节点是红色的,那么它的两个子节点必须是黑色的(即不能有两个连续的红色节点)。
- 从任一节点到其所有叶子节点的路径上,必须包含相同数量的黑色节点。
这些规则的目的在于保持树的近似平衡,即红黑树的高度不会超过 O(log n),从而保证其高效性。
3.1.4、节点内存管理
在实际实现中,红黑树的节点通常是动态分配的。每次插入新元素时,会创建一个新的节点对象并插入到树中。而在删除节点时,则需要负责释放相应的内存空间。
为避免内存泄漏,我们需要在红黑树的析构函数中处理节点的删除和内存释放操作。为了简化操作,可以设计一个递归的删除函数:
// 删除整个红黑树
void DeleteTree(Node *root)
{if (root == nullptr){return;}// 递归删除左子树DeleteTree(root->_left);// 递归删除右子树DeleteTree(root->_right);// 删除当前节点delete root;
}
这样,当红黑树析构时,我们只需调用 DeleteTree(_root)
即可销毁整棵树。
3.2、红黑树的迭代器设计
在 STL
标准库中,容器类的迭代器被设计为一种通用接口,允许用户以统一的方式访问容器的元素。C++
标准库中 set
和 map
容器都提供了迭代器接口,允许用户像操作数组一样方便地遍历容器中的元素。因此,我们的红黑树封装也需要提供类似的迭代器。
设计原则:
- 双向遍历:迭代器需要支持从当前节点向前或向后移动,确保可以顺序遍历容器中的元素。
- 中序遍历顺序:对于有序关联容器如
set
和map
,迭代器应确保以中序遍历的顺序访问元素,使得节点的遍历顺序与键值的升序或降序一致。 - 稳定性与高效性:迭代器操作应尽可能高效,避免无谓的计算和内存占用,并保持稳定的时间复杂度。
- 内存安全:在迭代器使用过程中应避免悬空指针等错误。
3.2.1、迭代器的基本结构
迭代器的设计通常是基于指针的,它持有一个指向树节点的指针,并通过重载运算符来实现对树节点的遍历操作。为了实现双向迭代器,我们需要重载增量(++
)和减量(--
)运算符,保证用户能够顺利进行正向和反向的遍历。首先,我们定义迭代器的结构如下:
namespace Lenyiin
{template <class T, class Ref, class Ptr>struct __TreeIterator{typedef RBTreeNode<T> Node;typedef __TreeIterator<T, Ref, Ptr> Self;Node *_node; // 当前迭代器指向的节点// 构造函数__TreeIterator(Node *node): _node(node){}// 解引用操作符Ref operator*(){return _node->data;}// 访问成员操作符Ptr operator->(){return &_node->_data;}// 前置递增运算符Self &operator++(){// 1. 如果右不为空, 中序的下一个就是右子树的最左节点if (_node->_right){Node *subLeft = _node->_right;while (subLeft->_left){subLeft = subLeft->_left;}_node = subLeft;}// 2. 如果右为空, 表示 _node 所在的子树已经完成, 下一个节点在他祖先中去找, 沿着路径往上找孩子使它的左的那个祖先else{Node *cur = _node;Node *parent = cur->_parent;while (parent && cur == parent->_right){cur = parent;parent = cur->_parent;}_node = parent;}return *this;}// 后置递增运算符Self operator++(int){Node *tmp = _node;++(*this);return Self(tmp);}// 前置递减运算符Self &operator--(){// 1. 如果左不为空, 中序的上一个就是左树的最右节点if (_node->_left){Node *subRight = _node->left;while (subRight->_right){subRight = subRight->_right;}_node = subRight;}// 2. 如果左为空, 表示 _node 所在的子树已经完成, 上一个节点在他祖先中去找, 沿着路径往上找孩子是它的右的那个祖先else{Node *cur = _node;Node *parent = cur->_parent;while (parent && cur == parent->_left){cur = parent;parent = cur->_parent;}_node = parent;}return *this;}// 后置递减运算符Self operator--(int){Node *tmp = _node;--(*this);return Self(tmp);}bool operator!=(const Self &n){return _node != n._node;}bool operator==(const Self &n){return _node == n._node;}};
}
3.2.2、迭代器 ++ – 详解
红黑树是一种二叉搜索树,迭代器的遍历应该遵循二叉搜索树的中序遍历规则。中序遍历的顺序为左子树 -> 当前节点 -> 右子树,这确保了迭代器访问节点时的顺序是按键值升序排列的。
例如,给定如下结构的红黑树:
20/ \10 30/ \ \5 15 40
中序遍历的结果应该是:5 -> 10 -> 15 -> 20 -> 30 -> 40
。
在迭代器的实现中,++
运算符用于实现正向的中序遍历,而 --
运算符用于实现逆向遍历。我们分别介绍这两个操作的细节。
1、前置递增运算符 (operator++
)
为了从当前节点移动到树中的下一个节点(即中序遍历中的下一个节点),我们需要找到中序后继。如果当前节点有右子节点,那么下一个节点应该是其右子树的最左节点;否则,我们需要向上移动,直到找到一个节点,它是其父节点的左子树。
// 前置递增运算符
Self &operator++()
{// 1. 如果右不为空, 中序的下一个就是右子树的最左节点if (_node->_right){Node *subLeft = _node->_right;while (subLeft->_left){subLeft = subLeft->_left;}_node = subLeft;}// 2. 如果右为空, 表示 _node 所在的子树已经完成, 下一个节点在他祖先中去找, 沿着路径往上找孩子使它的左的那个祖先else{Node *cur = _node;Node *parent = cur->_parent;while (parent && cur == parent->_right){cur = parent;parent = cur->_parent;}_node = parent;}return *this;
}
这个函数逻辑可以分为两种情况:
- 如果当前节点有右子节点,则它的后继节点是右子树中最左的那个节点。
- 如果当前节点没有右子节点,则需要沿着父指针向上移动,直到找到一个节点,它是其父节点的左子节点,那么这个父节点就是当前节点的后继。
2、前置递减运算符 (operator--
)
类似地,前置递减运算符需要找到中序前驱。如果当前节点有左子节点,则前驱节点是左子树中的最右节点;否则,沿着父指针向上移动,直到找到一个节点,它是其父节点的右子节点。
// 前置递减运算符
Self &operator--()
{// 1. 如果左不为空, 中序的上一个就是左树的最右节点if (_node->_left){Node *subRight = _node->left;while (subRight->_right){subRight = subRight->_right;}_node = subRight;}// 2. 如果左为空, 表示 _node 所在的子树已经完成, 上一个节点在他祖先中去找, 沿着路径往上找孩子是它的右的那个祖先else{Node *cur = _node;Node *parent = cur->_parent;while (parent && cur == parent->_left){cur = parent;parent = cur->_parent;}_node = parent;}return *this;
}
类似地,这个函数逻辑也可以分为两种情况:
- 如果当前节点有左子节点,则它的前驱节点是左子树中最右的那个节点。
- 如果当前节点没有左子节点,则需要沿着父指针向上移动,直到找到一个节点,它是其父节点的右子节点,那么这个父节点就是当前节点的前驱。
3.2.3、迭代器的边界处理
为了确保迭代器的健壮性,特别是在处理树的边界情况时(例如到达叶子节点的末尾),我们需要额外处理end()
和begin()
的位置:
end()
:当迭代器超出红黑树的最后一个节点时,迭代器应该指向树中的“虚拟末尾”位置,即nullptr
。这意味着如果调用++
运算符使迭代器越过树的最后一个节点,_node
应该被设置为nullptr
。begin()
:同理,调用--
运算符使迭代器超出第一个节点时,也应当触发相应的边界处理,确保访问第一个节点之前的位置时指向nullptr
。
3.3、旋转操作
在红黑树中,旋转操作是保持红黑树性质的核心步骤之一。在执行插入或删除操作时,红黑树可能会违反其性质,这时候通过旋转操作可以重新调整树的结构,使其满足红黑树的平衡要求。旋转操作主要分为两种:左旋和右旋。通过这两种操作,我们可以在局部调整树的形状,使得其符合红黑树的性质。
3.3.1、左旋
左旋操作是将节点向左旋转,使其右子节点上升为其父节点,而原节点下降为其右子节点的左子节点。这种旋转主要用于调整在右孩子方向上失衡的情况。
场景:当新节点插入到某个节点的右子树的右子节点时,可能导致该节点失衡。此时,需要进行左旋操作来恢复平衡。
旋转过程:
- 假设不平衡节点为
A
,其右子节点为B
。 B
的左子树BL
将成为A
的右子树。B
成为新的子树根节点,而A
成为B
的左子节点。
示例图:
插入导致的结构:
A\B\C
单左旋后的结构:
B/ \A C
代码实现:
// 左旋
void RotateL(Node* parent)
{Node* ppNode = parent->_parent;Node* subR = parent->_right;Node* subRL = subR->_left;parent->_right = subRL;if (subRL){subRL->_parent = parent;}subR->_left = parent;parent->_parent = subR;// 1. 原来 parent 是这棵树的根, 现在 subR 是根if (parent == _root){_root = subR;subR->_parent = nullptr;}// 2. parent 为根的树只是整棵树中的子树, 改变链接关系, 那么 subR 要顶替他的位置else{if (ppNode->_left == parent){ppNode->_left = subR;}else{ppNode->_right = subR;}subR->_parent = ppNode;}
}
解释:
subR
指向A
的右子节点B
。B
的左子树subRL
被挂接到A
的右子树。B
成为新的根节点,A
成为B
的左子节点。
3.3.2、右旋
右旋操作是左旋的镜像操作。它将节点向右旋转,使其左子节点上升为其父节点,而原节点下降为其左子节点的右子节点。右旋主要用于调整在左孩子方向上失衡的情况。
场景:当新节点插入到某个节点的左子树的左子节点时,可能导致该节点失衡。此时,需要进行右单旋操作来恢复平衡。
旋转过程:
- 假设不平衡节点为
A
,其左子节点为B
。 B
的右子树BR
将成为A
的左子树。B
成为新的子树根节点,而A
成为B
的右子节点。
示例图:
插入导致的结构:
A/ B /
C
单右旋后的结构:
B/ \C A
代码实现
// 右旋
void RotateR(Node* parent)
{Node* ppNode = parent->_parent;Node* subL = parent->_left;Node* subLR = subL->_right;parent->_left = subLR;if (subLR){subLR->_parent = parent;}subL->_right = parent;parent->_parent = subL;if (parent == _root){_root = subL;subL->_parent = nullptr;}else{if (ppNode->_left == parent){ppNode->_left = subL;}else{ppNode->_right = subL;}subL->_parent = ppNode;}
}
详细解释:
parent
是节点A
subL
指向A
的左子节点B
。B
的右子树subLR
被挂接到A
的左子树。B
成为新的根节点,A
成为B
的右子节点。
3.3.3、旋转操作的技术细节
在红黑树的插入和删除操作中,我们使用旋转操作来调整树的平衡。具体来说,旋转操作的目的是减少树的高度,或者说是重新分配子树的高度,使得整个树的结构更加平衡。在红黑树中,执行旋转操作不会破坏二叉搜索树的性质,即中序遍历的顺序仍然保持不变。
- 左旋与右旋的对称性:左旋和右旋是对称操作,左旋将节点的右子树移至父节点,而右旋将节点的左子树移至父节点。两者的实现过程是对称的,这也使得在红黑树的调整中可以灵活运用两者来维护树的平衡。
- 复杂度:单次旋转操作的时间复杂度为
O(1)
。旋转操作只涉及指针的改变,不需要遍历树的节点,因此是一个非常高效的操作。 - 调整树的高度:通过左旋或右旋,红黑树可以减少某个子树的高度,或者在某个方向上增加子树的高度。这样就能在插入或删除节点后,通过旋转调整,保持红黑树的高度平衡,进而保证操作的时间复杂度为
O(logn)
。
3.3.4、旋转操作在插入和删除中的作用
在红黑树的插入和删除操作中,我们主要通过旋转操作来维护红黑树的性质:
- 插入调整:在插入操作中,可能会出现连续的红色节点违背红黑树的性质。通过旋转和重新着色,可以确保红黑树的平衡。例如,在插入一个节点后,如果出现 “红-红” 冲突,则根据叔叔节点的颜色不同,我们需要执行左旋或者右旋,或者双旋操作来调整树的结构。
- 删除调整:删除操作可能会破坏红黑树的平衡,特别是当删除一个黑色节点时,可能导致黑高不平衡。这时候通过旋转和重新着色,可以重新平衡红黑树。例如,在删除节点后,如果当前节点的兄弟节点是黑色的且其子节点中有一个是红色,则我们可以通过旋转和重新着色,使得整棵树重新满足红黑树的性质。
通过上述两种旋转操作和调整逻辑,红黑树能够在插入和删除操作后,依然保持其性质,从而确保查找、插入、删除操作的时间复杂度始终保持在 O(logn)
。这就是红黑树高效性和实用性的重要原因之一。
3.4、插入操作
为了实现更好的封装,并且适应迭代器,我们可以将红黑树的插入操作做一些改进,并且为后续的 set
和 map
容器提供接口。
3.4.1、插入节点的基本逻辑
首先,插入的第一步仍然是按照二叉搜索树的方式进行,找到合适的位置插入节点。为了更好地封装,我们需要在插入时检查是否已经存在相同的键(对于 set
和 map
而言,键必须是唯一的)。
std::pair<iterator, bool> Insert(const T &data)
{// 按照搜索树的规则进行插入// 如果树为空,新节点直接作为根节点if (_root == nullptr){_root = new Node(data);_root->_colour = BLACK; // 根节点是黑色的return std::make_pair(iterator(_root), true);}// 插入时使用比较器来比较键的大小,以支持不同的数据类型KOfT koft;Node *parent = nullptr;Node *cur = _root;while (cur){if (koft(cur->_data) > koft(data)){parent = cur;cur = cur->_left;}else if (koft(cur->_data) < koft(data)){parent = cur;cur = cur->_right;}else{// 如果键已经存在, 插入无效, 返回 false, 并且返回该键的迭代器return std::make_pair(iterator(cur), false);}}// 找到位置, 根据父节点插入新节点cur = new Node(data);Node *newnode = cur;if (koft(parent->_data) > koft(cur->_data)){parent->_left = cur;cur->_parent = parent;}else{parent->_right = cur;cur->_parent = parent;}// 插入后需要修复红黑树的性质InsertFixUp(parent, cur);return std::make_pair(iterator(newnode), true);
}
解释:
- 插入逻辑:和之前的插入逻辑类似,首先找到合适的父节点,然后将新节点插入到父节点的左子树或右子树中。
- 唯一性检查:在插入时,我们使用
KOfT
仿函数来获取键值大小大小,以作比较。如果键已经存在,则返回false
表示插入无效。 - 插入后修复:插入完成后,需要调用
InsertFixUp
函数来修复红黑树的平衡性。
3.4.2、插入后修复红黑树的性质
插入后,我们需要对红黑树进行调整,以确保其仍然满足红黑树的五个性质。我们继续使用颜色翻转和旋转操作来修复红黑树的平衡。
void InsertFixUp(Node *parent, Node *cur)
{// 调整结点颜色// 新结点给红的还是黑的? 红色// 1. 空树。插入结点做根, 把他变黑。// 2. 插入红色节点, 他的父亲是黑色的, 结束。// 3. 插入红色节点, 他的父亲是红色的, 可以推断他的祖父存在且一定为黑色。关键看叔叔// a. 如果叔叔存在且为红, 把父亲和叔叔变黑, 祖父变红, 继续往上处理。// b. 如果叔叔存在且为黑, 或者不存在。旋转(单旋 or 双旋)+ 变色while (parent && parent->_colour == RED){// 关键看叔叔Node *grandfather = parent->_parent;// 父节点是祖父节点的左子节点if (grandfather->_left == parent){Node *uncle = grandfather->_right;// 情况1: uncle 存在, 且为红if (uncle && uncle->_colour == RED){parent->_colour = uncle->_colour = BLACK;grandfather->_colour = RED;// 继续向上处理cur = grandfather;parent = cur->_parent;}// 情况 2 or 情况 3 : uncle 不存在 or uncle 存在且为黑else{// 情况 3 : 双旋 -> 变单旋if (cur == parent->_right){RotateL(parent);std::swap(parent, cur);}// 第二种情况 (ps: 有可能是第三种情况变过来的)RotateR(grandfather);grandfather->_colour = RED;parent->_colour = BLACK;break;}}// 父节点是祖父节点的右子节点else{Node *uncle = grandfather->_left;// 情况1: uncle 存在, 且为红if (uncle && uncle->_colour == RED){parent->_colour = uncle->_colour = BLACK;grandfather->_colour = RED;// 继续向上调整cur = grandfather;parent = cur->_parent;}// 情况 2 or 情况 3 : uncle 不存在 or uncle 存在且为黑else{// 情况 3 : 双旋 -> 变单旋if (cur == parent->_left){RotateR(parent);std::swap(parent, cur);}// 第二种情况 (ps: 有可能是第三种情况变过来的)RotateL(grandfather);grandfather->_colour = RED;parent->_colour = BLACK;break;}}}// 保证根节点为黑_root->_colour = BLACK;
}
3.4.3、插入调整的细节分析
红黑树插入的调整过程,包含颜色变换和旋转。每当插入一个新节点时,需要仔细判断红黑树的结构,并通过相应的调整来保持其平衡性。我们可以将插入调整过程进一步分为多个情况,并通过每个情况的详细分析,理解红黑树的自平衡过程。
1、情况1: 父节点和叔叔节点都是红色
当插入一个新节点时,如果该节点的父节点和叔叔节点都是红色,那么根据红黑树的性质,需要将父节点和叔叔节点变为黑色,而祖父节点则变为红色。此时,由于祖父节点可能与更高层的祖先发生冲突,需要继续向上调整。
if (uncle && uncle->_colour == RED)
{parent->_colour = uncle->_colour = BLACK;grandfather->_colour = RED;// 继续向上处理cur = grandfather;parent = cur->_parent;
}
在这种情况下,整个子树的黑色节点数保持不变,因此不会影响路径的黑色节点数。但是需要注意,祖父节点变为红色可能会破坏红黑树的性质,因此需要递归调整祖父节点。
2、情况2: 父节点是红色,叔叔节点是黑色,且当前节点是父节点的右子节点
当父节点是红色而叔叔节点是黑色时,如果新插入的节点是父节点的右子节点,此时会形成“Z”形的结构。为了使树保持平衡,我们需要先对父节点进行左旋,使当前节点成为父节点,再通过右旋调整整体结构。
if (cur == parent->_right)
{RotateL(parent);std::swap(parent, cur);
}
3、情况3: 父节点是红色,叔叔节点是黑色,且当前节点是父节点的左子节点
如果新插入的节点是父节点的左子节点,此时会形成“直线”形结构。此时,我们需要对祖父节点进行右旋,同时交换父节点和祖父节点的颜色,以保持红黑树的平衡。
RotateR(grandfather);
grandfather->_colour = RED;
parent->_colour = BLACK;
在右旋后,父节点成为新的根节点,而祖父节点则变为父节点的右子节点,红黑树的性质得到修复。
4、特殊情况:根节点的颜色调整
无论是哪种情况,在插入调整完成后,都需要确保红黑树的根节点始终为黑色。这是红黑树的一个基本性质,保证了从根节点到叶子节点的路径中至少包含一个黑色节点。
_root->_color = BLACK;
这样可以避免根节点成为红色,从而破坏红黑树的平衡性。
3.5、删除操作
红黑树删除操作相较于插入更加复杂,原因在于:删除节点后可能会破坏红黑树的平衡性和性质(即根节点是黑色、红节点不能有红色子节点、每个路径黑节点数相同等)。因此在删除节点后,必须进行相应的调整来修复红黑树的性质。
红黑树删除通常分为以下几步:
- 寻找要删除的节点:通过红黑树的搜索性质,找到要删除的节点。
- 删除节点并调整树结构:根据删除节点的子节点情况,调整树的链接。
- 修复红黑树的性质:若删除的是黑色节点,可能会破坏红黑树的性质,因此需要通过
DeleteFixUp
函数进行颜色调整和旋转操作。
3.5.1、常规BST删除
首先,我们按照 BST
的规则找到并删除指定的节点。红黑树的删除操作可以分为以下几种情况:
- 节点没有子节点:直接删除该节点。
- 节点只有一个子节点:删除该节点,并将其子节点与其父节点连接。
- 节点有两个子节点:找到该节点的中序后继,用中序后继的值替换该节点,然后删除中序后继。
// 删除操作
bool Erase(const K &key)
{Node *nodeToDelete = _root;KOfT koft;// 1. 寻找要删除的节点while (nodeToDelete){if (koft(nodeToDelete->_data) > key){nodeToDelete = nodeToDelete->_left;}else if (koft(nodeToDelete->_data) < key){nodeToDelete = nodeToDelete->_right;}else{break; // 找到了要删除的节点}}// 节点若不存在, 直接返回 falseif (nodeToDelete == nullptr){return false;}// 执行删除操作Node *parent, *child;// 保存原节点的颜色,以便后续调整Colour originalColour = nodeToDelete->_colour;// 2. 处理删除节点的各种情况if (nodeToDelete->_left == nullptr){// 情况 1:没有左子节点child = nodeToDelete->_right;parent = nodeToDelete->_parent;Transplant(nodeToDelete, nodeToDelete->_right);}else if (nodeToDelete->_right == nullptr){// 情况 2:没有右子节点child = nodeToDelete->_left;parent = nodeToDelete->_parent;Transplant(nodeToDelete, nodeToDelete->_left);}else{// 情况 3:有两个子节点// 找到右子树中的最小节点(后继节点)Node *successor = Minimum(nodeToDelete->_right);originalColour = successor->_colour;child = successor->_right;if (successor->_parent == nodeToDelete){parent = successor;}else{// 后继节点有右子节点,用它的右子节点替换它Transplant(successor, successor->_right);successor->_right = nodeToDelete->_right;successor->_right->_parent = successor;parent = successor->_parent;}// 用后继节点替换删除节点Transplant(nodeToDelete, successor);successor->_left = nodeToDelete->_left;successor->_left->_parent = successor;successor->_colour = nodeToDelete->_colour; // 保持颜色不变}// 删除节点delete nodeToDelete;// 3. 修复红黑树的性质// 如果删除的节点是黑色, 需要进行调整if (originalColour == BLACK){EraseFixUp(child, parent);}return true;
}
代码详解
- 寻找要删除的节点:
- 使用红黑树的搜索性质,依次比较要删除的键值
key
与当前节点的数据做对比,找到对应的节点。 - 如果节点不存在,则返回
false
。
- 使用红黑树的搜索性质,依次比较要删除的键值
- 处理删除节点的情况:
- 若要删除的节点没有左子节点,直接用右子节点替换删除的节点。
- 若没有右子节点,则用左子节点替换删除的节点。
- 如果有两个子节点,找到右子树中的最小节点(即后继节点),用后继节点替换要删除的节点。
- 调用
Transplant
函数来实现节点的替换。
- 修复红黑树的性质:
- 如果删除的节点是黑色,可能会破坏红黑树的性质,因此需要调用
EraseFixUp
函数来进行颜色调整和旋转,恢复红黑树的平衡。
- 如果删除的节点是黑色,可能会破坏红黑树的性质,因此需要调用
3.5.4、辅助函数 Minimum
该函数用于找到指定节点的子树中的最小节点,即左子树中的最左节点。
Node *Minimum(Node *node)
{while (node->_left != nullptr){node = node->_left; // 一直向左走,直到找到最左节点}return node;
}
3.5.3、辅助函数 Transplant
Transplant
函数用于在删除节点时,将一个子树替换为另一个子树。
void Transplant(Node *u, Node *v)
{// 如果u是根节点,v成为新的根节点if (u->_parent == nullptr){_root = v;}// u是左子节点,用v替换它else if (u == u->_parent->_left){u->_parent->_left = v;}// u是右子节点,用v替换它else{u->_parent->_right = v;}// 连接v与u的父节点if (v != nullptr){v->_parent = u->_parent;}
}
代码详解
Transplant
函数用于将节点u
替换为节点v
,并且将v
与u
的父节点连接起来。- 若
u
是根节点,则直接将v
设为根节点,否则根据u
是左子节点还是右子节点,分别替换其位置。
3.5.4、删除调整函数 EraseFixUp
void EraseFixUp(Node *child, Node *parent)
{while (child != _root && (child == nullptr || child->_colour == BLACK)){if (child == parent->_left){Node *brother = parent->_right;// 情况1: child 的兄弟节点 brother 是红色的if (brother->_colour == RED){brother->_colour = BLACK;parent->_colour = RED;RotateL(parent);brother = parent->_right;}// 情况2: child 的兄弟节点 brother 是黑色的, 且 brother 的两个节点都是黑色的if ((brother->_left == nullptr || brother->_left->_colour == BLACK) &&(brother->_right == nullptr || brother->_right->_colour == BLACK)){brother->_colour = RED;child = parent;parent = child->_parent;}else{// 情况3: brother 是黑色的, 并且 brother 的左子节点是红色, 右子节点是黑色if (brother->_right == nullptr || brother->_right->_colour == BLACK){if (brother->_left){brother->_left->_colour = BLACK;}brother->_colour = RED;RotateR(brother);brother = parent->_right;}// 情况4: brother 是黑色的, 并且 brother 的右子节点是红色if (brother){brother->_colour = parent->_colour;parent->_colour = BLACK;if (brother->_right){brother->_right->_colour = BLACK;}RotateL(parent);}child = _root;break;}}else{Node *brother = parent->_left;// 情况1: child 的兄弟节点 brother 是红色的if (brother->_colour == RED){brother->_colour = BLACK;parent->_colour = RED;RotateR(parent);brother = parent->_left;}// 情况2: child 的兄弟节点 parent 是黑色的, 且 brother 的两个节点都是黑色的if ((brother->_left == nullptr || brother->_left->_colour == BLACK) &&(brother->_right == nullptr || brother->_right->_colour == BLACK)){brother->_colour = RED;child = parent;parent = child->_parent;}else{// 情况3: brother 是黑色的, 并且 brother 的右子节点是红色, 左子节点是黑色if (brother->_left == nullptr || brother->_left->_colour == BLACK){if (brother->_right){brother->_right->_colour = BLACK;}brother->_colour = RED;RotateL(brother);brother = parent->_left;}// 情况4: brother 是黑色的, 并且 brother 的左子节点是红色if (brother){brother->_colour = parent->_colour;parent->_colour = BLACK;if (brother->_left){brother->_left->_colour = BLACK;}RotateR(parent);}child = _root;break;}}}if (child){child->_colour = BLACK;}
}
EraseFixUp
代码解析
- 该函数通过旋转和颜色调整来修复红黑树的平衡。
- 关键在于处理被删除节点的兄弟节点,根据兄弟节点的颜色及其子节点的情况,决定是进行旋转还是颜色调整,确保红黑树
3.6、查找操作
红黑树不仅在插入和删除操作上表现优异,其查找操作同样高效。查找的复杂度为 O(log n)
,这得益于红黑树的平衡特性。接下来,我们将详细介绍红黑树的查找过程,包括基本步骤、实现代码以及相关的性质分析。
在红黑树中查找一个键值的过程与在普通二叉搜索树中查找相似。查找时,我们从根节点开始,按照以下步骤进行:
- 比较键值:首先将待查找的键值与当前节点的键值进行比较。
- 如果相等,则找到该节点,返回该节点的迭代器。
- 如果待查找的键值小于当前节点的键值,则向左子树查找。
- 如果待查找的键值大于当前节点的键值,则向右子树查找。
- 继续查找:根据比较结果,继续在相应的子树中查找,直到找到该键值或者遍历到叶子节点(即指向空指针)。
以下是红黑树查找操作的实现代码:
// 查找节点
iterator &Find(const K &key)
{KOfT koft;Node *cur = _root;while (cur){if (koft(cur->_data) > key){cur = cur->_left;}else if (koft(cur->_data) < key){cur = cur->_right;}else{return iterator(cur);}}return iterator(nullptr);
}
代码解析
- 起始节点:我们从根节点开始查找。
- 循环条件:只要当前节点不为空,就持续进行比较。
- 条件判断:通过判断键值的大小关系来决定下一步的查找方向。
- 返回结果:找到节点后返回其对应的迭代器,如果未找到则返回树的结束迭代器(通常为
nullptr
)。
查找操作的时间复杂度为 O(log n)
。这是因为红黑树的高度是对数级别的,插入和删除操作后,红黑树仍然保持相对平衡,因此查找过程中经过的节点数量是相对较少的。此外,红黑树的性质确保了节点的分布是较为均匀的,避免了出现链式结构的情况,从而保持了高效的查找性能。
4、set 和 map 容器的实现
4.1、Set 容器的实现
set 的底层为红黑树,因此只需在 set 内部直接封装一颗红黑树,即可将该容器实现出来。
#pragma once#include "RBTree.hpp"namespace Lenyiin
{template <class K>class Set{struct SetKeyOfT{const K &operator()(const K &k){return k;}};public:// 这里RBTree<K, K, SetKeyOfT>::iterator还没有实例化, 系统找不到, typename告诉编译器现在先不着急找typedef typename RBTree<K, K, SetKeyOfT>::iterator iterator;typedef typename RBTree<K, K, SetKeyOfT>::const_iterator const_iterator;iterator begin(){return _tree.begin();}iterator end(){return _tree.end();}const_iterator begin() const{return _tree.begin();}const_iterator end() const{return _tree.end();}std::pair<iterator, bool> Insert(const K &key){return _tree.Insert(key);}bool Erase(const K &key){return _tree.Erase(key);}iterator &Find(const K &key){return _tree.Find(key);}bool IsRBTree(){return _tree.IsRBTree();}private:RBTree<K, K, SetKeyOfT> _tree;};
}
4.2、Map 容器的实现
map 的底层为红黑树,因此只需在 map 内部直接封装一颗红黑树,即可将该容器实现出来。
#pragma once#include "RBTree.hpp"namespace Lenyiin
{template <class K, class V>class Map{struct MapKeyOfT{const K &operator()(const std::pair<K, V> &kv){return kv.first;}};public:typedef typename RBTree<K, std::pair<K, V>, MapKeyOfT>::iterator iterator;typedef typename RBTree<K, std::pair<K, V>, MapKeyOfT>::const_iterator const_iterator;iterator begin(){return _tree.begin();}iterator end(){return _tree.end();}const_iterator begin() const{return _tree.begin();}const_iterator end() const{return _tree.end();}std::pair<iterator, bool> Insert(const std::pair<K, V> &kv){return _tree.Insert(kv);}bool Erase(const K &key){return _tree.Erase(key);}V &operator[](const K &key){std::pair<iterator, bool> ret = _tree.Insert(std::make_pair(key, V()));return ret.first->second;}bool IsRBTree(){return _tree.IsRBTree();}private:RBTree<K, std::pair<K, V>, MapKeyOfT> _tree;};
}