文章目录
- set和map的使用
- set系列
- 声名和定义
- 默认成员函数
- 迭代器
- set的增删查
- lower_bound和upper_bound
- Insert接口
- pair类
- 对于查找的另一种使用
- set和multiset的区别
- map系列
- 声名和定义
- pair类的进一步介绍
- 默认成员函数
- map的增删查
- map的数据修改
- map和multimap的差异
set和map的使用
本篇文章将讲解一些STL库中的两个新的容器,即map系列和set系列。这两个看着很高深,其实就是我们在上一篇文章中实现的二叉搜索树。只不过底层是使用红黑树来实现的。红黑树就是对二叉搜索树进行了一些规定,使其查找效率会更高。但是本质也是搜索树。所以我们可以先直接学习使用。
学习完使用后我们再来一步步的引入底层实现的学习。
set系列
set系列对应的二叉搜索树其实就是key模式,即没有映射。
由于我们以前已经有了对序列式容器string、vector、list等的学习,我们已经初步掌握了STL库中接口的使用,现在我们通过查阅文档并且结合一些使用来看:
文档:
https://legacy.cplusplus.com/reference/set/
声名和定义
这里的T其实就是key,这里写的确实会令人误解。因为本质上二叉树对应的这些关联式容器都是以key作为关键字来进行查找、删除等操作的。这里我们跟着看一下文档就好了。如果到后期自行实现了改过来就好。
这个Compare模板参数类型给了一个less< T >,这是干什么用的呢?这个是用来规定搜索树的规则的。我们默认的规则是对于一个搜索树,右子树的key应该比根节点的大,左子树的key比根节点要小。默认传入的是小于的比较逻辑。但是如果想要反过来,需要传入大于的比较逻辑。比较逻辑本质是仿函数。
还有一个模板参数就是空间配置器了,这个知道就好,当前先不作了解。
默认成员函数
构造函数有很多个,甚至在c++不同的标准下有不同版本的。我们虽然学习的主要是c++98的语法,但是这个太古老了,有时候使用起来也不太方便。对于这些接口的使用可以查看一下c++11的标准:
//empty (1) 默认构造函数
explicit set (const key_compare& comp = key_compare(),const allocator_type& alloc = allocator_type());
explicit set (const allocator_type& alloc);//range (2) 使用迭代器区间构造
template <class InputIterator>set (InputIterator first, InputIterator last,const key_compare& comp = key_compare(),const allocator_type& = allocator_type());//copy (3) 拷贝构造
set (const set& x);
set (const set& x, const allocator_type& alloc);//initializer list (5) 列表构造
set (initializer_list<value_type> il,const key_compare& comp = key_compare(),const allocator_type& alloc = allocator_type());//析构函数
~set();//copy (1) 赋值重载 set& operator= (const set& x);
这几个构造函数是我们需要重点掌握的。其实这些在之前都见过,所以就不细讲了。
迭代器
迭代器这种用法其实也是用过很多次。只不过之前我们学习的都是序列式容器,迭代器的遍历时很好理解的。但是现在学的时关联式容器,迭代器是如何遍历内部的数据的呢?
我们在实现BinaryTree的时候就说过,如果按照默认的搜索逻辑,那么对这个树进行中序遍历,得到的序列一定是升序(没有重复数据的前提下,也就是这里的set)。所以迭代器的遍历出来的结果就是升序的。只不过这个底层实现可能会有一些麻烦,但今天这篇文章只是介绍使用,还不需要对底层原理进行了解。
验证一下结果确实如此。因为c++的封装性,我们可以不用关注底层而直接调用这些接口来完成一些相同的功能。这就是学习STL的必要性。
至于其它的迭代器我就不再赘述了,因为这里的用法和以往学习的并没有太大的差别。
set的增删查
一般来讲,搜索树中的key是不允许被修改的。这是很好理解的,如果轻易被修改很容易破坏这个二叉搜索树的一些性质(如左小右大、又或是不能有重复的值)。所以在set系列中,是没有提供修改内容的接口的。我们重点看看增删查这三个功能即可。
Member types
key_type -> The first template parameter (T)
value_type -> The first template parameter (T)// 单个数据插⼊,如果已经存在则插⼊失败
pair<iterator,bool> insert (const value_type& val);
// 列表插⼊,已经在容器中存在的值不会插⼊
void insert (initializer_list<value_type> il);
// 迭代器区间插⼊,已经在容器中存在的值不会插⼊
template <class InputIterator>
void insert (InputIterator first, InputIterator last);
// 查找val,返回val所在的迭代器,没有找到返回end()
iterator find (const value_type& val);
// 查找val,返回Val的个数
size_type count (const value_type& val) const;
// 删除⼀个迭代器位置的值
iterator erase (const_iterator position);
// 删除val,val不存在返回0,存在返回1
size_type erase (const value_type& val);
// 删除⼀段迭代器区间的值
iterator erase (const_iterator first, const_iterator last);
// 返回⼤于等于val位置的迭代器
iterator lower_bound (const value_type& val) const;
// 返回⼤于val位置的迭代器
iterator upper_bound (const value_type& val) const;
里面大部分都是非常熟悉的,只需要注意的就是使用迭代器位置或者区间进行一些修改容量的操作的时候,是很容易出现迭代器失效的问题的。在vs2022下是会直接触发警告并终止程序的。所以使用的后应当及时更新迭代器。
还有就是对于set系列和map系列,我们不要去使用算法库中包含的find接口。因为那个接口是按照迭代器去暴力查找的,时间复杂度是O(N),但是对于搜索树来讲,在比较接近完全二叉树的时候,查找效率非常高,基本上就是对数级别,效率是天差地别。这点注意一下。
重点要讲的是Insert这个接口和最后写的两个接口。
lower_bound和upper_bound
这两个接口是用来查找在指定位置数据的,举个例子:
比如有一个以{ 8, 3, 1, 10, 1, 6, 4, 7, 14, 13 }
进行构造的set,那么中序遍历就是:
1 3 4 6 7 8 10 13 14
。假设我现在就想要找出6到12之间的数据的位置,并且只对在这一段区间内的数据进行遍历,应当怎么办呢?
那就需要确定这段区间的上限和下限。也就是使用 lower_bound和upper_bound这两个接口。
lower_bound是找出第一个大于等于某个值的位置的。所以我们直接使用 lower_bound(6),返回的迭代器位置就是6那个位置的。
upper_bound是找出第一个大于传入值位置的迭代器,使用upper_bound(12),虽然这个序列中并不存在有12这个数据,但第一个大于12的位置是14,所以返回的迭代器是指向14的。
我们又知道,在c++STL库中,对于迭代器的使用都是左闭右开的,那这个时候不就刚好的将要找到的区间包含在了下限和上限中吗?
就算有相同的值,比如序列:1 3 3 3 4 4 5 6 7 8 8 10 11 13 14 18
找出2到17之间的数据,那么我们使用这两个接口:lower_bound找出第一个大于等于2的位置,即第一个3的位置,upper_bound找出第一个大于17的位置,即18。这不就刚好左闭右开找到了指定的区间吗?
这里需要注意的是,要什么区间,就传入对应的值。这两个接口必须这么实现。如果不这样实现会出问题。因为在使用这两个接口的时候,我们很可能不太知道里面的数据。那么在不知道的情况下,我们要找出2到17的数据是很困难的。如果一定要指定明确的数据开头(3和18),我们又不知道内部数据,那是很麻烦的,总不能每使用一次前都打印出来看一次。
当然我们查阅文档后会发现,还有一个equal_range的接口,这其实是找出指定数据的迭代器区间的。但是对于set来讲,没有太大的意义,因为内部的key不会重复,所以找到了迭代器区间也是多此一举,直接使用Find接口更好。这个接口我们放在后面map系列中讲解。
Insert接口
来看Insert接口。其实正常来讲,插入逻辑就是先按照二叉搜索树的插入逻辑插入后再按照红黑树的逻辑对树进行调整就可以了。我们当前也只关注使用,所以在底层目前不需要了解的情况下我们是会使用这些接口的。但是为什么,对于插入单个值的时候,返回值出现了一个pair的类呢?
pair类
这就要介绍一下这个类是什么了。我们以前写程序经常会遇到的一个问题,就是返回值不能一次性返回两个值。如果要返回多个值,需要通过外界传地址进来进行修改,实现间接的返回。但是这样比较麻烦。c++程序专门针对这一点进行改进,如果要返回的值是两个,就将这两个返回值分别作为pair类的第一个和第二个成员变量,同时进行一些函数的重载或者接口的实现。这样子就可以很轻松的将两个值同时带出来了。
所以对于第一个插入一个值的Insert接口,其实就是将插入后的位置和是否插入成功返回来而已。如果失败了那就让迭代器为默认迭代器,第二个bool类型的返回值给false即可。
当然库中提供了一个函数叫make_pair,可以很方便的将两个值构造成一个pair类。这些我们目前再使用set的时候不太需要关注,正常使用即可。具体的内容需要在map系列中进行讲解。
对于查找的另一种使用
在set中有一个接口叫count,这个接口正常来说是返回容器中指定数据的个数有多少的。但是set中元素的个数只可能是0或者1,所以这就很天然的能作为是否存在这个元素的返回值。如果是正常使用find接口,找到了返回指定元素位置的迭代器,找不到返回的是其end迭代器。这二者原理有差别。但是写法上用count更简单一些。如果只是想判断一下某个元素在不在,直接调用count接口就可以了:
这点注意一下即可。
set和multiset的区别
multiset其实和set差不多,只不过其支持有重复的值。其一些接口的用法相较于set有所改动。
对于其查找操作find,找到的数据就是中序遍历的第一个。这个要实现就需要修改一下查找逻辑。我们可以大致介绍一下:
假设已经在某个节点位置找到3这个数据了,但是我们并不能知道是否后续还有3的出现。要找的必须是中序遍历的第一个3。此时就得往下找。因为中序遍历排的是升序(默认规则为左小于等于 右大于),左子树必然先比根节点打印出来。所以找到3后应该继续往下找,也就是往左子树找,如果后续不存在3了,就说明只有一个3,那第一个3自然就是原来那个位置的。但是如果后续在左子树部分又找到了一个3,那就需要将中序遍历的第一个3的位置进行更新一下,然后继续执行上述操作,知道找不到为止。
其lower_bound和upper_bound的使用在前面也讲过了,也是可以正常使用的。
对于其删除操作,就需要注意的是传入什么值,就会把容器中所有的这些值删除。比如multiset中有5个3,现在调用erase接口删除3,那么全部3都会被删除,返回的是被删除的最后一个元素位置的下一个位置的迭代器。
所以set和mulitiset最大的区别就是,前者可以进行排序和去重,但是multiset只能进行排序。
map系列
讲完了set系列,再来看map系列:
map系列其实就是key_value形式,实现方式其实差不多,我们只挑出一些重点来讲一下。
文档:
https://legacy.cplusplus.com/reference/map/
声名和定义
map的声明如下,Key就是map底层关键字的类型,T是map底层value的类型,set默认要求Key支持小于比较,如果不支持或者需要的话可以自行实现仿函数传给第二个模版参数,map底层存储数据的内存是从空间配置器申请的。⼀般情况下,我们都不需要传后两个模版参数。map底层是用红黑树实现,增删查改效率是 O(logN) ,迭代器遍历是走的中序,所以是按key有序顺序遍历的。
template < class Key, // map::key_type
class T, // map::mapped_type
class Compare = less<Key>, // map::key_compare
class Alloc = allocator<pair<const Key,T> //map::allocator_type
> class map;
这一次文档内又把关键字写成Key了,但是值没有写成value,变成了T。这里注意一下。
pair类的进一步介绍
map系列底层对于key和value的存储就不是像我们实现的那样进行分开存储了。而是把两个数据放在一个pair类里面:
typedef pair<const Key, T> value_type;
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){}template<class U, class V>pair (const pair<U,V>& pr): first(pr.first), second(pr.second){}
};
template <class T1,class T2>
inline pair<T1,T2> make_pair (T1 x, T2 y){
return ( pair<T1,T2>(x,y) );
}
pair本质也是类,所以也是会有其默认成员函数。但是现在面临一个问题,如果说每个节点存储的数据域是一个pair类,我们怎么样访问到内部的数据呢?
这个时候有两种方法,直接使用操作符.进行访问,又或是可以重载->,就像实现list的时候那样,这样子就可以进行访问了。这一点我们等下结合迭代器仔细讲解。
默认成员函数
typedef pair<const Key, T> value_type;// 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());//析构函数
~map();//copy (1) 赋值重载
map& operator= (const map& x);
构造还是那么些构造方法,只不过存储的数据变成了一个pair类:
在使用的时候就需要特别注意了。传入的数据必须是pair才可以,可以像图中使用列表构造,将里面的每一个括号内的内容走隐式类型转换给一个pair。也可以自行使用make_pair进行构造出一个pair,又或是调用pair的构造函数也可以。
不转换为pair类再存储是会报错的。
map的增删查
map的增删查关注以下几个接口即可:
map增接口,插入的pair键值对数据,跟set所有不同,但是查和删的接口只用关键字key跟set是完全类似的,不过find返回iterator,不仅仅可以确认key在不在,还找到key映射的value,同时通过迭代还可以修改value。
现在我们来看一下:
Member types
key_type -> The first template parameter (Key)
mapped_type -> The second template parameter (T)
value_type -> pair<const key_type,mapped_type>// 单个数据插⼊,如果已经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);
// 返回⼤于等k位置的迭代器
iterator lower_bound (const key_type& k);
// 返回⼤于k位置的迭代器
const_iterator lower_bound (const key_type& k) const;
需要注意的是,这里的逻辑都是依靠key在操作的,也就是只能操作key。
用法其实和set也差不了太多,只是需要注意,插入一个数据的时候是传入一个pair类,可以是匿名对象,可以是实例化的对象,也可以是走隐式类型转换。
其他的接口用法其实和set差不了太多,在这里就不讲了。
map的数据修改
这个部分我们需要重点讲解,即映射关系value的修改。
前面提到map⽀持修改mapped_type 数据,不支持修改key数据,修改关键字数据,破坏了底层搜索树的性质。
map第一个支持持修改的方式时通过迭代器,迭代器遍历时或者find返回key所在的iterator修改,map还有⼀个非常重要的修改接口operator[],但是operator[]不仅仅支持修改,还支持插入数据和查找数据,所以这是⼀个多功能复合接口。
需要注意从内部实现角度,map这里把我们传统说的value值,给的是T类型,typedef为
mapped_type。而value_type是红黑树结点中存储的pair键值对值。日常使用我们还是习惯将这里的T映射值叫做value。
Member types
key_type -> The first template parameter (Key)
mapped_type -> The second template parameter (T)
value_type -> pair<const key_type,mapped_type>//查找k,返回k所在的迭代器,没有找到返回end()
//如果找到了通过iterator可以修改key对应的mapped_type值
iterator find (const key_type& k);
// ⽂档中对insert返回值的说明
// The single element versions (1) return a pair, with its member pair::first
//set to an iterator pointing to either the newly inserted element or to the
//element with an equivalent key in the map. The pair::second element in the
//pair is set to true if a new element was inserted or false if an equivalent
//key already existed.// insert插⼊⼀个pair<key, T>对象
// 1、如果key已经在map中,插⼊失败,则返回⼀个pair<iterator,bool>对象,返回pair对象
//first是key所在结点的迭代器,second是false// 2、如果key不在在map中,插⼊成功,则返回⼀个pair<iterator,bool>对象,返回pair对象
//first是新插⼊key所在结点的迭代器,second是true//也就是说无论插⼊成功还是失败,返回pair<iterator,bool>对象的first都会指向key所在的迭代器
//那么也就意味着insert插⼊失败时充当了查找的功能,正是因为这⼀点,insert可以⽤来实现operator[]// 需要注意的是这⾥有两个pair,不要混淆了,⼀个是map底层红⿊树节点中存的pair<key, T>,
//另⼀个是insert返回值pair<iterator,bool>pair<iterator,bool> insert (const value_type& val);mapped_type& operator[] (const key_type& k);// operator的内部实现
mapped_type& operator[] (const key_type& k){
// 1、如果k不在map中,insert会插⼊k和mapped_type默认值,同时[]返回结点中存储
//mapped_type值的引⽤,那么我们可以通过引⽤修改返映射值。所以[]具备了插⼊+修改功能
// 2、如果k在map中,insert会插⼊失败,但是insert返回pair对象的first是指向key结点的
//迭代器,返回值同时[]返回结点中存储mapped_type值的引⽤,所以[]具备了查找+修改的功能pair<iterator, bool> ret = insert({ k, mapped_type() });iterator it = ret.first;return it->second;
}
我们重点关注的就是operator[]的重载,说白点就是,如果以key查找,存在就返回这个位置的迭代器和插入失败。不存在就插入进去,然后返回其节点的value的引用。
那这样子来统计出现次数就很简单了:
int main()
{string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
"苹果", "香蕉", "苹果", "香蕉" };map<string, int> countMap;for (const auto& str : arr){// 先查找⽔果在不在map中// 1、不在,说明⽔果第⼀次出现,则插⼊{⽔果, 1}// 2、在,则查找到的节点中⽔果对应的次数++auto ret = countMap.find(str);if (ret == countMap.end()){countMap.insert({ str, 1 });}else{ret->second++;}}for (const auto& e : countMap){cout << e.first << ":" << e.second << endl;}cout << endl;return 0;
}
这是我们之前实现的统计次数代码,我们还可以这样写:
即使用operator[],当传入给方括号内的str存在,就返回这个迭代器位置value的引用。所以直接让其自增就可以表达出此时有的个数。
如果不存在也不怕,因为operator[]调用了insert接口,会直接插入。而int的默认构造是0,所以再对其自增一次变为1。逻辑完美闭环。
既然是返回引用,我们也可以直接对value进行赋值修改:
int main()
{map<string, string> dict;dict.insert(make_pair("sort", "排序"));// key不存在->插⼊ {"insert", string()}dict["insert"];// 插⼊+修改dict["left"] = "左边";// 修改dict["left"] = "左边、剩余";// key存在->查找cout << dict["left"] << endl;return 0;
}
最后的答案很明显是符合预期的。
map和multimap的差异
multimap和map的使用基本完全类似,主要区别点在于multimap支持关键值key冗余,那么
insert/find/count/erase都围绕着⽀持关键值key冗余有所差异,这里跟set和multiset完全⼀样,比如find时,有多个key,返回中序第⼀个。其次就是multimap不支持[],因为支持key冗余,[]就只能支持插入了,不能支持修改。