C++:map和set的使用

1. 序列式容器和关联式容器

前面我们已经接触过 STL 中的部分容器如:string、vector、list、deque、array、forward_list 等,这些容器统称为序列式容器,因为逻辑结构为线性序列的数据结构,两个位置存储的值之间一般没有紧密的关联关系,像你桌上的一堆文件,按摆放顺序叠着,找某份文件得从第一份翻到目标份,哪怕交换两份文件的位置,还是一堆文件,结构没坏。序列式容器中的元素是按他们在容器中的存储位置来顺序保存和访问的。

关联式容器也是用来存储数据的,与序列式容器不同的是,关联式容器逻辑结构通常是非线性结构,两个位置有紧密的关联关系,交换一下,他的存储结构就被破坏了。顺序容器中的元素是按关键字来保存和访问的。关联式容器 有 map / set 系列和 unordered map / unordered set 系列。关联式容器像你手机的通讯录,按姓名(key)排序存储,找 "张三" 不用翻所有联系人,直接搜 "张" 就能定位;但如果乱改姓名(key),通讯录的排序逻辑就崩了。

总结一下就是:序列式容器按「位置」存数据,关联式容器按「key」存数据,后者查找效率远高于前者。本章节讲解的 map 和 set 底层是红黑树,红黑树是一颗平衡二叉搜索树。set 是 key 搜索场景的结构,map 是 key/value 搜索场景的结构。

2. 系列的概念

我们在上面提到的 "map / set 系列" 这句话中的 "系列",其实是指 STL 中基于相同设计理念、核心功能一致,但有不同特性 / 变种的一组关联式容器。简单说,"系列" 就像同一品牌下的不同款产品 ------ 核心功能相通,但针对不同场景做了差异化设计。

map系列和set 系列各自主要包含 4 个核心容器,分为两大分支(红黑树版 + 哈希版):

|---------------|----------------------------------|----------------------------------|
| 分类 | set 系列(纯 key 场景) | map 系列(key/value 场景) |
| 红黑树实现(有序) | set、multiset | map、multimap |
| 哈希表实现(无序) | unordered_set、unordered_multiset | unordered_map、unordered_multimap |

表中提到的哈希表大家暂且做个了解即可,它就是和红黑树一样的一种结构。重要的是大家要清楚,比如我们说的map系列,指的是:map、multimap、unordered map、unordered multimap。这四种容器对应不同场景。这篇文章我们先聚焦红黑树实现,所以主要看set、multiset、map、multimap这四种容器。

3. set类的介绍

3.1 set容器的格式

set 的声明如下,T 就是 set 底层关键字的类型:

set 是一种容器,用于存储唯一的元素 ,所以set不支持重复数据存储 ,并会按照特定的顺序排列。在 set 中,元素的值本身就是它的标识(值即为键,类型为 T),并且每个值必须唯一。一旦元素被存入容器,它的值就不能被修改(元素始终是 const 类型),因此其普通迭代器指向的值是不能被修改的 ,但可以被插入或删除。在内部实现中, set 中的元素始终会按照一个特定的严格弱序规则进行排序,这个规则由其内部的比较对象类型为 Compare 指定 ,也就是由这个仿函数来指定。

set 底层存储数据的内存是从空间配置器申请的,如果需要可以自己实现内存池,传给第三个参数。一般情况下,我们都不需要传后两个模板参数。set 底层是用红黑树实现,增删查效率是O(logN),迭代器遍历是走的搜索树的中序,所以是有序的。

前面部分我们已经学习了 vector/list 等容器的使用,STL 容器接口设计,高度相似,所以这里我们就不再一个接口一个接口的介绍,而是直接带着大家看文档,挑比较重要的接口进行介绍。

3.2 set的构造和迭代器

set 支持正向和反向迭代遍历,遍历默认按升序顺序,因为底层是二叉搜索树,迭代器遍历走的中序;支持迭代器就意味着支持范围 for,set 的 iterator 和 const_iterator 都不支持迭代器修改数据,修改关键字数据,破坏了底层搜索树的结构。图中的参数类型说明set支持无参默认构造、迭代器区间构造以及列表构造,并且其迭代器还是一个双向迭代器:

我们来做一个演示:

在这段代码当中,先实例化出一个set类型的对象s,用一个列表将其初始化,并且这段数据当中是乱序并且有重复值的,然后我们用set类型对象的迭代器实现了循环,在这里迭代器遍历走的是中序遍历。同时,打印的结果是有序的,还自动清除掉了重复的数据。因此set还有一个作用就是:去重。

最重要的一点是要记住:set 的普通迭代器( iterator )指向的元素本质是 const T 类型(T 是 set 的模板参数),编译器会直接禁止对这个值的修改操作。这是为了防止修改 key 破坏底层红黑树的结构。

3.3 set的增删查

在这里就直接给大家展示一下接口的参数类型,不做一一讲解了。增删两个接口都支持迭代器区间的插入和删除,但是对应插入函数来说,其实第二和第三个接口一般用的比较少,因为从指定位置或者指定区间直接插入,有时会破坏红黑树的结构。

下面演示一下增删两个接口的使用:

以及find函数的使用:

不过提到查找,我们其实还可以用另一种方式。在set容器当中还有一个接口叫:count。

它的作用是查找 val ,并返回 val 的个数。但实际上只会返回1和0,因为在set当中不允许相同的数据同时存入,所以任何一个数据都是要么没有,要么只有一个。这个接口的存在其实是为了能和multiset实现接口匹配,因为multiset允许相同的数据同时存入。

那么在这里,我们就可以结合 if 语句来进行查找:

另外还有lower_bound和upper_bound两个接口:

lower_bound的作用是:返回大于等 val 位置的迭代器。upper_bound的作用是:返回大于 val 位置的迭代器。这两个接口的运用场景是:比如我现在想要删除 [3 , 9] 这个范围内的数据,那么首先我得知道 [3 , 9] 这个范围内有哪些数据。那要么找到数据 3 的迭代器然后遍历到 9 ,并且对于迭代器区间有一个惯例就是:迭代器区间必须是左闭右开。就是说,你想要删除 [3 , 9] 的数据,你的迭代器传过来就要是 [3 , 10) 。并且这里存在一个问题:可能set的对象当中可能只有45678这几个数据,根本找不到 3 的迭代器。

所以这时候,就可以使用lower_bound和upper_bound接口:

比如在这段代码当中,[3 , 9] 的数据有 3 5 7 8 ,对于 it1,它指向的就是数据 3 的迭代器,对于 it2 ,它指向的就是数据 20 的迭代器。

4. multiset和set的区别

multiset和set可以说就是 " 兄弟容器 " ,几乎没有任何区别。它俩唯一的核心差异就是:set严格保证存储的元素 唯一,不允许任何重复值存在;multiset允许存储重复的元素,同一个值可以被插入多次并共存。

剩下还有三点略微不同:

  1. 插入操作的行为不同

set:如果插入的元素已经存在,本次插入操作会直接失效,不会新增元素,也不会报错;multiset:无论插入的元素是否已存在,都会直接插入成功,容器中会新增该元素的一个副本。

  1. 查找与计数的结果不同

set:调用 count (key) 方法时,返回值只有两种可能 ------ 0(元素不存在)或 1(元素存在);调用 find (key) 方法时,若找到则返回指向该唯一元素的迭代器,未找到则返回 end ()。multiset:调用 count (key) 方法时,返回值是该元素在容器中的重复次数(≥0);调用 find (key) 方法时,若找到则返回指向第一个匹配元素的迭代器,未找到则返回 end ()。

  1. 删除操作的行为不同

set调用 erase (key) 方法时,会直接删除该 key 对应的唯一元素(若存在);multiset调用 erase (key) 方法时,会删除容器中所有匹配该 key 的元素;若只想删除其中一个重复元素,需要先通过 find (key) 获取指向该元素的迭代器,再将迭代器传入 erase ()。

cpp 复制代码
int main()
{
	 // 相比set不同的是,multiset是排序,但是不去重
	multiset<int> s = { 4,2,7,2,4,8,4,5,4,9 };
	auto it = s.begin();
	while (it != s.end())
	{
		cout << *it << " ";
		++it;
	}
	cout << endl;

	// 相比set不同的是,x可能会存在多个,find查找中序的第⼀个
	int x;
	cin >> x;
	auto pos = s.find(x);
	while (pos != s.end() && *pos == x)
	{
		cout << *pos << " ";
		++pos;
	}
	cout << endl;

	// 相比set不同的是,count会返回x的实际个数
	cout << s.count(x) << endl;

	// 相比set不同的是,erase给值时会删除所有的x
	s.erase(x);
	for (auto e : s)
	{
		cout << e << " ";
	}
	cout << endl;

	return 0;
}

5. 和set相关的一道算法题

这道题目来自于LeetCode平台:349. 两个数组的交集 - 力扣(LeetCode)

下面是题目展示:

这里因为输出的交集元素只有一个,比如实例1当中,nums1和nums2只有2是两个数组公有的数据,所以只需要输出一个2而不是两个2。那我们首先可以用set去重,这样的话在set当中,nums1就相当于是:[1,2,2,1],nums2就是 [ 2 ]。

在这里我们可以利用范围for去遍历s1这个树,然后让s2使用count函数看看在s1当中有没有和自己相同的数,如果有就计数,并且把这个数据插入到v这个顺序表当中,最后再返回v。

6. map类的介绍

map 的声明如上图所示,Key 就是 map 底层关键字的类型,T 是 map 底层 value 的类型,set 默认要求 Key 支持小于比较,如果不支持或者需要的话可以自行实现仿函数传给第二个模版参数,map 底层存储数据的内存是从空间配置器申请的。一般情况下,我们都不需要传后两个模版参数。map 底层是用红黑树实现,增删查改效率是 O (log N),迭代器遍历是走的中序,所以是按 key 有序顺序遍历的。

在map当中,key_type指的是key,mapped_type指的是value,value_type指的是key-value键值对。

6.1 pair类型介绍

在之前我们去写一个key-value键值对的二叉搜索树类型时,我们是这样写的:

但是在标准命名空间中的map容器中,使用的是pair类来存储key和value的值。pair 是 C++ STL 中提供的一个简单的模板类,本质是 "二元组"------ 专门用来封装两个不同类型(也可以是相同类型)的数据,把它们组合成一个整体,方便一次性存储、传递或返回两个关联的数据。

pair 内部只有两个公有的成员变量,分别是 first 和 second,用来访问第一个和第二个元素,分别对应 key 和 value。没有其他多余的成员,结构极简。

map要存储键值对之所以用pair封装,是因为: STL 容器 / 红黑树均要求每个位置存储 "单个元素",但 key 和 value 是两个元素。pair 能将 key 和 value 打包成一个整体,既符合容器设计规则,又能保证 key-value 的关联性,同时复用 STL 内置的 pair 模板可简化接口设计,无需额外定制键值对结构,还能适配红黑树以 key 排序的逻辑。

用 pair 封装后,插入操作只需传入一个 pair 对象,就能同时把 key 和 value 关联存入;查找操作返回的迭代器指向的是一个 pair 对象,能一次性获取对应的 key 和 value,保证两者的关联性不丢失;遍历操作时,通过迭代器访问 it - > first(key)和 it - > second(value),逻辑清晰且符合直觉。

如果不封装,map 就需要设计两套接口来分别操作 key 和 value(比如 "插入 key" "插入 value""查找 key 后再匹配 value"),不仅接口复杂,还容易出现 key 和 value 对应关系错乱的问题。

另外还有make_pair类型:

make_pair可以理解成,可以帮你省去写一个对象和这个对象的参数类型,因为它内部使用的还是pair类,只是将其用模板封装了而已,所以可以自动识别类型。

6.2 map的构造

cpp 复制代码
	// empty (1) 无参默认构造
	explicit map(const key_compare & comp = key_compare(),
		         const allocator_type & alloc = allocator_type());

	// range (2) 迭代器区间构造
	template <class InputIterator>
	map(InputIterator first, InputIterator last,
		const key_compare & comp = key_compare(),
		const allocator_type & = allocator_type());

	// copy (3) 拷⻉构造
	map(const map & x);

	// initializer list (5) initializer 列表构造
	map(initializer_list<value_type> il, 
		const key_compare & comp = key_compare(),
		const allocator_type & alloc = allocator_type());

	// 迭代器是⼀个双向迭代器
	iterator->a bidirectional iterator to const value_type

	// 正向迭代器
	iterator begin();
	iterator end();

	// 反向迭代器
	reverse_iterator rbegin();
	reverse_iterator rend();

map 的支持正向和反向迭代遍历,遍历默认按 key 的升序顺序,因为底层是二叉搜索树,迭代器遍历走的中序;支持迭代器就意味着支持范围 for,map 支持修改 value 数据,不支持修改 key 数据,修改关键字数据,破坏了底层搜索树的结构。

我们用一段代码来展示pair和构造的搭配使用:

第一种写法是通过pair类型直接实例化对象之后,利用初始化构造函数将这些pair类型的对象插入到dict当中。第二种方法是直接通过隐式类型转换,在初始化的时候直接插入数据。一般使用第二种写法的比较多。

6.3 map的增删查

cpp 复制代码
// 单个数据插⼊,如果key存在则插⼊失败,key存在相等value不相等也会插⼊失败
pair<iterator,bool> insert (const value_type& val);


// 列表插⼊,已经在容器中存在的值不会插⼊ 
void insert (initializer_list<value_type> il);


// 迭代器区间插⼊,已经在容器中存在的值不会插⼊ 
template <class InputIterator>
void insert (InputIterator first, InputIterator last);


// 查找k,返回k所在的迭代器,没有找到返回end() 
iterator find (const key_type& k);


// 查找k,返回k的个数
size_type count (const key_type& k) const;


// 删除⼀个迭代器位置的值
iterator  erase (const_iterator position);


// 删除k,k存在返回0,存在返回1 
size_type erase (const key_type& k);


// 删除⼀段迭代器区间的值
iterator  erase (const_iterator first, const_iterator last);

对于insert接口来说,结合我们前面说的make_pair类型,可以有四种插入方式:

第一种是老老实实写出对象kv5,然后实例化对象,再将kv5插入到dict中。第二种是利用匿名对象传参。第三种是用make_pair,不用写出对象类型,直接写出数据。第四种就是利用列表初始化进行插入。

6.4 map的迭代器和[ ]操作

在这里主要需要注意的是在while循环当中的(*it)的这个问题,因为迭代器实际上是指向的对象,这里也进一步说明了,为什么要将 key 和 value 封装成一个整体而不是单独将它俩定义,因为在这里,想要调用到 it 中存储的值,如果直接解引用,那解出来的到底是 key 还是 value ?所以这里用 pair 封装,再解引用查找对象中的变量更加合适。同时这里最好写成:it - > first 。

并且我们在这里能注意的是:

"在map当中,其value的值可以被修改,但是key值不可以被修改",这句话得到了印证。

还比如这里,也是一种迭代器的使用示例:

在这里我们利用范围for和迭代器,完成了对countMap的值的插入以及遍历。除了这个方法,我们还可以用运算符重载 operator[ ] 来实现:

这两种写法最后得到的效果是一样的,但是使用operator[ ]之后,代码简洁了许多。

像我们以前在vector、string里面讲的operator[ ] 都是返回要查找的数据的下标,但是在map当中的operator[ ],作用是:给它一个key的值,返回对应key的value值。并且返回的还是value的引用,这就使得使用operator[ ]的同时,还能修改value的值。

所以map中的operator[ ]的使用规则是这样的:1. 先查找要找的key值在不在map中。2. 查找后得到两种结果,第一种:在,那就返回key对应的value的引用;第二种:不在,那就直接将要找的这个key值插入到map中。所以说它有双层作用。它的底层相当于调用了insert函数:

这样可能会更直观一点:

这里实际上依靠的是inset函数的返回值:

这个Return value中提到的versions(1)指的就是:pair<iterator , bool>。这段话的意思是:单元素版本的插入操作(1)会返回一个 pair 对象,其成员 pair :: first 被设为一个迭代器,该迭代器要么指向新插入的元素,要么指向 map 中拥有等效键的已存在元素;pair 中的成员 pair :: second 会在新元素成功插入时设为 true,若等效的键已存在则设为 false。

这也就是意味着,pair<iterator,bool> insert (const value_type& val); 这个函数在插入的时候只看key值,如果key已经存在就返回原来map中已经存在的key的迭代器,并且pair中的bool返回的是false,代表插入失败。因此看到底插入是否成功,看的是pair::second,也就是这个bool返回的是true还是false。

那么从这张图中我们就能得知,调用operator[ ]之后,返回的就是查找的这个key值对应的value值。那么对于 countMap[str]++; 这句代码来说,str是遍历arr数组的迭代器,就相当于依次在map中查找arr数组中的数据,如果这个数据在map当中,就直接让这个key值的value值 ++ 。如果不在map当中,就先插入,然后再将其value值 ++。

因此,我们也可以用 operator[ ] 来实现插入和修改value值的操作。

7. multimap和map的区别

multimap 和 map 的使用基本完全类似,主要区别点在于 multimap 支持关键值 key 冗余,那么 insert/find/count/erase 都围绕着支持关键值 key 冗余有所差异,这里跟 set 和 multiset 完全一样,比如 find 时,有多个 key,返回中序第一个。其次就是 multimap 不支持 [ ],因为支持 key 冗余, [ ] 就只能支持插入了,不能支持修改。

比如在这里插入了两个happy,但是是不同的value值,依然可以成功插入。

8. 和map相关的两道算法题

第一道题目来自LeetCode平台:138. 随机链表的复制 - 力扣(LeetCode)

下面是题目描述:

对于这道题目,难点就在于我们该如何把random指针给找出来,我们以前的做法是这样的:

第一步,构造拷贝节点,存储原节点中的数据;第二步,将拷贝节点的next指针改为原节点的next指针;第三步,将原节点中的next指针都改为指向其对应的拷贝节点;第四步,我们最主要写出这样一句代码:copy -> random = cur -> random -> next;第五步,将拷贝节点和原节点之间的next关系解开,将拷贝节点链接成新的链表。

这里的cur就是原节点,copy就是拷贝节点。举一个例子来讲这句代码的逻辑:现在cur的值是13,copy也是13,那么拷贝节点13的random指针,就是原节点13的random指针指向的节点的next指针指向的数据,也就是拷贝节点7。那这样的话,random就能找到了。

首先这里存在两个链表,新链表和原链表。新链表就是指拷贝链表,所以有copyhead和copytail分别指向新链表的头和尾。在这里我们循环遍历了原链表两次,第一次我们是为了让cur,也就是原链表中的节点和新链表中的节点建立映射关系,存到map当中。

第二次遍历才是处理random指针。同时遍历新链表和原链表,最主要的是要看懂这句代码:copy->random = nodeMap[cur->random]; 这里是用operator[ ]实现查找,要清楚的是cur->random也是一个节点的指针,再加上前面我们将原链表中的节点和新链表中的节点建立了映射关系,所以nodeMap[cur->random]是查找出:原链表中的random节点对应在新链表中的节点位置,然后将其赋值给copy->random。

第二道题目来自LeetCode平台:692. 前K个高频单词 - 力扣(LeetCode)

下面是题目描述:

得益于map的使用,这里统计出现的次数还是比较容易的,但这道题目的难点主要在这句话:" 如果不同的单词有相同出现频率, 按字典顺序 排序。" 这里所谓的按字典顺序排序就是按照abcd...的这个顺序。所以我们的思路是:先统计次数,再按出现次数降序排列。

现在来实现代码,我们先将输入的一串单词进行计数。接下来就是要按照出现次数的多少给这些单词进行排序,在这里我们可以使用sort算法。但是对于sort算法来说,它只能支持随即迭代器的遍历,所以在此我们要将map当中的键值对存储到一个vector类型的对象中去:

不过sort函数默认的是升序排列,也就是小的在前,大的在后。与我们的预期相反,并且我们来看一下pair类型的键值对比较大小的逻辑:

这个意思就是说,对于键值对key-value来说,只要key或者value中的其中一个小,那这个键值对就是小的。但我们期望的是去比较value。所以在这里,我们还可以添加sort函数的第三个参数:仿函数:

在这里我们的仿函数逻辑是降序排列,也就是大的在前,小的在后,并且比较的都是value值。接下来我们就要取前k个单词中的高频单词了:

将排序过后的这一串单词,取前k个插入到新的vector顺序表ret当中,然后返回ret。到目前为止,我们只是解决了一个不同出现次数的顺序问题,但是对于相同出现次数的排列顺序,我们还没有解决。并且目前的代码如果直接提交的话,会出现这个问题:

就以前两个单词:"nftk"和"qkjzgws"来说,实际上它俩的出现次数是一样的,但是按照字母顺序,n在q的前面,所以这里正确的排序应该是"nftk","qkjzgws"而不是我们输出的"qkjzgws","nftk"。那造成这个问题的原因,其实就是sort函数。

我们来回顾一下代码:

我们在统计次数的时候,使用的是map容器,而map容器实例化出来的对象,会自动按照中序排序,并且它排序的依据是键值对中的key值,因此在countMap[str]++这句代码执行结束之后,countMap中存储的本身就是按照字母顺序从小到大排列的一串单词了。然后实例化vector类型的对象v时,传的是countMap的迭代器,本质上也是遍历了countMap去进行的初始化。所以此时依然还是按照字母顺序从小到大排列的一串单词。

但是sort函数是按照出现次数排列的,那比如说,在sort排序这句代码执行之前,单词数组中的顺序是这样的:{"ejshxi",6} {"nftk",10} {"qkjzgws",10}。sort排序代码执行结束之后,就有可能会变成: {"qkjzgws",10} {"nftk",10} {"ejshxi",6}。大家会发现,出现次数不同的单词之间的顺序改变了,但是出现次数相同的单词之间的顺序也改变了。**这一切的一切都是因为,sort排序算法函数是一个不稳定的排序算法。**它确实能按照value值进行排列,但是无法保证相同value值的两个单词之间原先的相对顺序不发生改变。

所以第一个解决办法:我们在这里要使用一个稳定的排序算法:stable_sort

stable_sort排序算法和sort很像,只不过值对于stable_sort算法来说,相等的元素在排序后的相对位置,和排序前完全一致。

这样的话,这段代码就能正常运行了。

如果不使用stable_sort的话,就是第二个办法:控制仿函数的比较逻辑

在这里我们在仿函数的比较逻辑中添加了:如果两个单词之间的出现次数相同,就去比较其字母顺序,按照升序排列。

本文到此结束,感谢各位读者的阅读,如果有讲解的不到位或者错误的地方,欢迎各位批评或指正。

相关推荐
苏宸啊2 小时前
list底层实现
c++·list
近津薪荼2 小时前
递归专题(4)——两两交换链表中的节点
数据结构·c++·学习·算法·链表
2501_940315262 小时前
【无标题】2390:从字符串中移除*
java·开发语言·算法
乐观勇敢坚强的老彭2 小时前
c++寒假营day01下午
c++·算法
lly2024062 小时前
jEasyUI 树形菜单添加节点
开发语言
AI职业加油站2 小时前
Python技术应用工程师:互联网行业技能赋能者
大数据·开发语言·人工智能·python·数据分析
散峰而望2 小时前
【算法竞赛】树
java·数据结构·c++·算法·leetcode·贪心算法·推荐算法
鱼很腾apoc2 小时前
【实战篇】 第14期 算法竞赛_数据结构超详解(下)
c语言·开发语言·数据结构·学习·算法·青少年编程
芳草萋萋鹦鹉洲哦2 小时前
后端C#,最好能跨平台,桌面应用框架如何选择?
开发语言·c#