请君浏览
- 前言
- 1. 序列式容器和关联式容器
- 2. set系列的使用
- 2.1 set的介绍
- 2.2 set的构造和迭代器
- 2.3 set的增删查
- 2.4 multiset和set的差异
- 3. map系列的使用
- 3.1 map的介绍
- 3.2 pair类型的介绍
- 3.3 map的构造和迭代器
- 3.4 map的增删查
- 3.5 map的数据修改(重点)
- 3.6 multimap和map的差异
- 4. 题目练习
- 4.1 set的练习
- 4.2 map的练习
- 尾声
前言
今天,我们继续踏入追寻C++的冒险历程。上一章我们简单介绍并且了解了一种数据结构–二叉搜索树,是为了接下来学习的set和map两个容器以及AVL树和红黑树的学习。那么本章将为大家讲解STL中另外的两种容器——set和map。下面让我们一起来进入set和map的学习。
由于set和map的底层数据结构是二叉搜索树的变形——红黑树,因此我们这里先了解set和map的使用,当我们学习了红黑树之后再去讲一讲如何自己去实现set和map。
1. 序列式容器和关联式容器
在C++的STL库中,容器主要分为两类:序列式容器和关联式容器。它们的主要区别在于存储和访问元素的方式不同。
- 序列式容器:前⾯我们已经接触过STL中的部分容器如:
string、vector、list、deque、array、forward_list
等,这些容器都称为序列式容器,因为逻辑结构为线性序列的数据结构,两个位置存储的值之间⼀般没有紧密的关联关系,⽐如交换⼀下,他依旧是序列式容器。顺序容器中的元素是按他们在容器中的存储位置来顺序保存和访问的。 - 关联式容器:关联式容器也是⽤来存储数据的,与序列式容器不同的是,关联式容器逻辑结构通常是⾮线性结构,两个位置有紧密的关联关系,交换⼀下,他的存储结构就被破坏了。关联式容器中的元素是按关键字来保存和访问的。关联式容器有
map/set
系列和unordered_map/unordered_set
系列。
本章节讲解的map和set底层是红⿊树,红⿊树是⼀颗平衡⼆叉搜索树。set是key搜索场景的结构, map是key/value搜索场景的结构。
2. set系列的使用
之前我们已经讲过了很多的容器了,这里不再赘述过多的东西。
set系列有两个容器,一个是set
,另一个是multiset
。后者能够支持插入相同的值,前者不支持插入相同的值。multiset
的应用场景很少,因此这里我们着重来讲解set
。
2.1 set的介绍
下面我们直接来看一下set容器的声明:
- set的声明如上,模板参数
T
就是set底层关键字的类型。 - set默认要求
T
⽀持⼩于⽐较,如果不⽀持或者想按⾃⼰的需求⾛可以⾃⾏实现仿函数传给第⼆个模版参数(例如如果想要一个降序的set便可以传入一个大于比较的仿函数,对于内置类型可以直接传入greater<T>
)。 - set底层存储数据的内存是从空间配置器申请的,如果需要可以⾃⼰实现内存池,传给第三个参数。(暂时了解即可)
- set底层是⽤红⿊树实现,增删查效率是O(log2N),迭代器遍历是⾛的搜索树的中序,所以是有序的。
⼀般情况下,我们都不需要传后两个模版参数,前⾯部分我们已经学习了vector/list等容器的使⽤,STL容器接⼝设计,⾼度相似,所以这⾥我们就不再⼀个接⼝⼀个接⼝的介绍,而是挑⽐较重要的接⼝进⾏介绍。
2.2 set的构造和迭代器
set最基本的三个构造如下所示:
- 第一个就是空的构造,可以传入仿函数来实现自己想要的排序方式。
- 第二个是范围构造,通过给定的迭代器区间去构造相应的set。
- 第三个是拷贝构造,直接传入另一个set来构造。
- 此外我们还可以通过初始化列表(initializer_list)构造,即给定一串数据进行构造。
#include<iostream>
#include<set>
#include<vector>
using namespace std;
int main()
{//1.空构造set<int> s1;//2.迭代器区间构造vector<int> v = {1, 2, 3};set<int> s2(v.beign(), v.end());//3.拷贝构造set<int> s3(s2);//4.初始化列表构造set<int> s3 = {1, 2, 3};return 0;
}
set的迭代器是双向迭代器(bidirectional iterator),⽀持正向和反向迭代遍历,遍历默认按升序顺序。因为底层是⼆叉搜索树,迭代器遍历⾛的中序;⽀持迭代器就意味着⽀持范围for,set的iterator和const_iterator都不⽀持迭代器修改数据,因为修改关键字数据,会破坏底层搜索树的结构。
#include<iostream>
#include<set>
#include<vector>
using namespace std;
int main()
{set<int> s1 = { 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 };//范围forfor (int x : s1){cout << x << ' ';}cout << endl;//迭代器遍历且降序set<int, greater<int>> s2 = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };auto it = s2.begin();while (it != s2.end()){cout << *it << ' ';it++;}return 0;
}
运行结果如下:
2.3 set的增删查
插入(insert):
-
单个数据插⼊,如果已经存在则插⼊失败
pair<iterator,bool> insert (const value_type& val);
-
列表插⼊,已经在容器中存在的值不会插⼊
void insert (initializer_list<value_type> il);
-
迭代器区间插⼊,已经在容器中存在的值不会插⼊
template <class InputIterator> void insert (InputIterator first, InputIterator last);
对于单个数据的插入的返回值是一个类模板:
它的两个参数分别被定义为:
它在这里充当的是函数的返回值,在后面的map中存储的数据都是以pair的形式存储的。
对于单个数据插入返回的pair类的第一个参数为插入数据的所在位置的迭代器,第二个参数为bool类型,若为false则代表该元素已存在,插入失败,反之则成功。这里用这样一个类来作为返回值是为这一系列特定的重载操作符[]
做准备,在下面会进行详细的讲解。接下来先来看一下insert
的使用样例:
#include<iostream>
#include<set>
using namespace std;
int main()
{// 去重+升序排序set<int> s;// 去重+降序排序(给⼀个⼤于的仿函数)//set<int, greater<int>> s;s.insert(5);s.insert(2);s.insert(7);s.insert(5);//set<int>::iterator it = s.begin();auto it = s.begin();while (it != s.end()){// error C3892: “it”: 不能给常量赋值// *it = 1;cout << *it << " ";++it;} cout << endl;// 插⼊⼀段initializer_list列表值,已经存在的值插⼊失败s.insert({ 2,8,3,9 });for (auto e : s){cout << e << " ";} cout << endl;set<string> strset = { "sort", "insert", "add" };// 遍历string⽐较ascll码⼤⼩顺序遍历的for (auto& e : strset){cout << e << " ";} cout << endl;
}
删除(erase):
-
删除⼀个迭代器位置的值
iterator erase (const_iterator position);
-
删除val,val不存在返回0,存在返回1
size_type erase (const value_type& val);
-
删除⼀段迭代器区间的值
iterator erase (const_iterator first, const_iterator last);
下面我们来看一下erase
的使用样例:
#include<iostream>
#include<set>
using namespace std;
int main()
{set<int> s = { 4,2,7,2,8,5,9 };for (auto e : s){cout << e << " ";} cout << endl;// 删除最⼩值s.erase(s.begin());for (auto e : s){cout << e << " ";}cout << endl;// 直接删除xint x;cin >> x;int num = s.erase(x);if (num == 0){cout << x << "不存在!" << endl;} for (auto e : s){cout << e << " ";} cout << endl;// 直接查找在利⽤迭代器删除xcin >> x;auto pos = s.find(x);if (pos != s.end()){s.erase(pos);} else{cout << x << "不存在!" << endl;} for (auto e : s){cout << e << " ";} cout << endl;return 0;
}
查找(find):
-
查找val,返回val所在的迭代器,没有找到返回迭代器end()
iterator find (const value_type& val);
-
查找val,返回Val的个数
size_type count (const value_type& val) const;
set除了能够查找某一个元素,还能查找某一个范围内的元素:
-
返回⼤于等于val位置的迭代器
iterator lower_bound (const value_type& val) const;
-
返回大于val位置的迭代器
iterator upper_bound (const value_type& val) const;
光看并没有多大体会,下面我们来看看使用样例:
#include<iostream>
#include<set>
using namespace std;
int main()
{std::set<int> myset;for (int i = 1; i < 10; i++)myset.insert(i * 10); // 10 20 30 40 50 60 70 80 90for (auto e : myset){cout << e << " ";}cin >> x;// 算法库的查找 O(N)auto pos1 = find(s.begin(), s.end(), x);// set⾃⾝实现的查找 O(logN)auto pos2 = s.find(x);// 利⽤count间接实现快速查找if (s.count(x)){cout << x << "在!" << endl;} else{cout << x << "不存在!" << endl;}cout << endl;// 实现查找到的[itlow,itup)包含[30, 60]区间// 返回 >= 30位置的迭代器auto itlow = myset.lower_bound(30);// 返回 > 60位置的迭代器auto itup = myset.upper_bound(60);// 删除这段区间的值myset.erase(itlow, itup);for (auto e : myset){cout << e << " ";} cout << endl;return 0;
}
2.4 multiset和set的差异
multiset和set的使⽤基本完全类似,主要区别点在于multiset⽀持值冗余,那么insert/find/count/erase都围绕着⽀持值冗余有所差异,具体参看下⾯的样例代码理解。
#include<iostream>
#include<set>
using namespace std;
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给值时会删除所有的xs.erase(x);for (auto e : s){cout << e << " ";} cout << endl;return 0;
}
3. map系列的使用
3.1 map的介绍
map系列与set系列一样,也有multimap
和map
,和set一样,multimap
支持Key
冗余,而map
不支持。
下面我们来看一下map的声明:
- map的声明如上,Key就是map底层关键字的类型,T是map底层value的类型,T在map中被命名为
mapped_type
,也就是映射类型。 - map默认要求
Key
⽀持⼩于⽐较,如果不⽀持或者想按⾃⼰的需求⾛可以⾃⾏实现仿函数传给第⼆个模版参数(例如如果想要一个降序的map便可以传入一个大于比较的仿函数,对于内置类型可以直接传入greater<Key>
)。 - map底层存储数据的内存是从空间配置器申请的,如果需要可以⾃⼰实现内存池,传给第三个参数。(暂时了解即可)
- map底层是⽤红⿊树实现,增删查效率是O(log2N),迭代器遍历是⾛的搜索树的中序,所以是有序的。。
可以看到,map的声明于set基本上是一致的,只是比set多了一个参数T,map的值是通过键值对<Key, T>
来表示,适用的场景范围要比set更大一点,不过二者都各有好处,要根据实际情况去选择更合适的容器。
3.2 pair类型的介绍
上面讲set时我们简单提了一下pair类,接下来让我们来了解一下它:
map底层的红⿊树节点中的数据,使⽤pair<Key, T>
存储键值对数据。下面是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中第一个参数被称为first,第二个参数被称为second,当我们想要调用对应的数据时可以通过对象.first/second
来操作。
这里我们只需要先了解一下pair的结构,知道map中存储的数据都是以pair类型存储的即可,通过下面的学习来深刻理解pair类和make_pair
函数模板。
3.3 map的构造和迭代器
map的常用构造与set基本相同,我们直接来看:
- 第一个就是空的构造,可以传入仿函数来实现自己想要的排序方式。
- 第二个是范围构造,通过给定的迭代器区间去构造相应的map。
- 第三个是拷贝构造,直接传入另一个map来构造。
- 此外我们还可以通过初始化列表(initializer_list)构造,即给定一串数据进行构造。
value_type
是对pair<const key_type, mapped_type>
的重命名。
map的迭代器也是双向迭代器(bidirectional iterator),⽀持正向和反向迭代遍历,遍历默认按关键字Key的升序顺序,因为底层是二叉搜索树,迭代器遍历⾛的中序;⽀持迭代器就意味着⽀持范围for,map⽀持修改value数据,即关键字Key映射的T,不⽀持修改key数据,因为修改关键字数据,破坏了底层搜索树的结构。
通过文档数据我们也可以看到,对于没有const修饰的迭代器我们是可以改变其所指的数据的,不公只能改变对应的value
值,而不能改变key
。
下面我们来看一下利用map来制作一个字典的使用样例,这也是map的一个使用场景:
#include<iostream>
#include<map>
using namespace std;
int main()
{// initializer_list构造及迭代遍历map<string, string> dict = { {"left", "左边"}, {"right", "右边"},{"insert", "插⼊"},{ "string", "字符串" } };//map<string, string>::iterator it = dict.begin();auto it = dict.begin();while (it != dict.end()){//cout << (*it).first <<":"<<(*it).second << endl;// map的迭代基本都使⽤operator->,这⾥省略了⼀个->// 第⼀个->是迭代器运算符重载,返回pair*,第⼆个箭头是结构指针解引⽤取pair数据//cout << it.operator->()->first << ":" << it.operator->()->second << endl;cout << it->first << ":" << it->second << endl;++it;} //结构化绑定:在C++17后,我们可以用另一种方法进行遍历for(const auto& [k, v] : dict){cout << k << ":" << v << endl;}cout << endl;return 0;
}
在上面的代码中,当我们在进行迭代器遍历时为什么不直接解引用it
进行打印呢?这是因为map中的数据都是以pair的形式存储的,而pair类没有重载流插入(<<)运算符,因此当我们想要打印其具体的值时需要对pair中的两个数据一一进行打印。
3.4 map的增删查
map的增删查关注以下⼏个接⼝即可: map的插入接⼝,插⼊的是pair键值对数据,跟set所有不同,但是查找和删除的接⼝只⽤关键字key跟set是完全类似的,不过查找返回iterator
,不仅仅可以确认key在不在,还找到key映射的value,同时通过迭代器还可以修改value。
插入(insert):
-
单个数据插⼊,如果已经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);
对于单个数据的插入,我们有很多种方法,如下代码所示:
// insert插⼊pair对象的4种⽅式
pair<string, string> kv1("first", "第⼀个");
dict.insert(kv1);dict.insert(pair<string, string>("second", "第⼆个"));dict.insert(make_pair("sort", "排序"));dict.insert({ "auto", "⾃动的" });// "left"已经存在,插⼊失败
dict.insert({ "left", "左边,剩余" });
因为我们要插入的是一个pair对象,因此我们可以选择有名对象或者匿名对象这种调用pair的构造的方式,除此之外,我们还可以使用函数模板make_pair
。在C++11后支持多参数隐式类型转换,也就是上面代码的最后一种方式,对比之下这种方式最为简便,因此我们可以使用这种方式来进行单个数据的插入,更为的方便。
查找(find):
-
查找k,返回k所在的迭代器,没有找到返回迭代器end()
iterator find (const key_type& k);
-
查找k,返回k的个数。由于映射容器中的所有元素都是唯一的,因此该函数只能返回 1(如果找到元素)或 0(否则)。
size_type count (const key_type& k) const;
以及范围查找:
-
返回⼤于等于k位置的迭代器
iterator lower_bound (const key_type& k);
-
返回⼤于k位置的迭代器
const_iterator upper_bound (const key_type& k) const;
下面来看一下使用样例:
#include<iostream>
#include<map>
using namespace std;
int main()
{map<string, string> dict = { {"left", "左边"}, {"right", "右边"},{"insert", "插⼊"},{ "string", "字符串" } };string str;while (cin >> str){auto ret = dict.find(str);if (ret != dict.end()){cout << "->" << ret->second << endl;} else{cout << "⽆此单词,请重新输⼊" << endl;}} // 这些接⼝跟set完全类似,这⾥就不演⽰讲解了return 0;
}
删除(erase):
-
删除⼀个迭代器位置的值
iterator erase (const_iterator position);
-
删除k,k存在返回0,存在返回1
size_type erase (const key_type& k);
-
删除⼀段迭代器区间的值
iterator erase (const_iterator first, const_iterator last);
这里的删除操作基本上跟set一致,不再详细演示。
3.5 map的数据修改(重点)
前⾯我提到map⽀持修改mapped_type
数据,不⽀持修改key
数据,因为修改关键字数据,会破坏底层搜索树的结构。
map第⼀个⽀持修改的⽅式时通过迭代器,迭代器遍历时或者使用find
函数返回key所在的迭代器修改。
map还有⼀个⾮常重要的重载运算符operator[]
,但是operator[]
不仅仅⽀持修改,还⽀持插⼊数据和查找数据,所以他是⼀个多功能复合接⼝。需要注意从内部实现⻆度,map这⾥把我们传统说的value
值,给的是T类型,typedef为mapped_type
,也就是第二个参数。⽽value_type
是红⿊树结点中存储的pair键值对的值,是key_value
的集合。⽇常使⽤我们还是习惯将这⾥的T的映射值叫做value
。
下面让我们来认识一下这个比较特别的重载运算符[]
:
⽂档中对insert返回值的说明:
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>
我们来看一下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结点的迭代器,//而在operator[]中又可以返回该迭代器指向的结点中存储mapped_type值的引⽤,所以[]具备了查找+修改的功能pair<iterator, bool> ret = insert({ k, mapped_type() });iterator it = ret.first;return it->second;
}
因此一个[]
操作符便同时具备了插入,查找,修改三个功能。下面让我们来看一看具体的使用场景:
1.在该场景中key为水果的名字,value为某个水果出现的次数:
#include<iostream>
#include<map>
#include<string>
using namespace std;
int main()
{// 利⽤[]插⼊+修改功能,巧妙实现统计⽔果出现的次数string arr[] = { "苹果", "西⽠", "苹果", "西⽠", "苹果", "苹果", "西⽠","苹果", "⾹蕉", "苹果", "⾹蕉" };map<string, int> countMap;for (const auto& str : arr){// []先查找⽔果在不在map中// 1、不在,说明⽔果第⼀次出现,则插⼊{⽔果, 0},同时返回次数的引⽤,++⼀下就变成1次了// 2、在,则返回⽔果对应的次数++countMap[str]++;} for (const auto& e : countMap){cout << e.first << ":" << e.second << endl;} cout << endl;return 0;
}
2.在该场景中key为中文,value为相应的英文:
#include<iostream>
#include<map>
#include<string>
using namespace std;
int main()
{map<string, string> dict;dict.insert({"sort", "排序"});// key不存在->插⼊ {"insert", string()}dict["insert"];// 插⼊+修改dict["left"] = "左边";// 修改dict["left"] = "左边、剩余";// key存在->查找cout << dict["left"] << endl;return 0;
}
3.6 multimap和map的差异
multimap和map的使⽤基本完全类似,主要区别点在于multimap
⽀持关键值key冗余,那么insert/find/count/erase都围绕着⽀持关键值key冗余有所差异,这⾥跟set和multiset
完全⼀样,⽐如find时,有多个key,返回中序第⼀个。其次就是multimap不⽀持[]
,因为⽀持key冗余,[]
就只能⽀持插⼊了,不能⽀持修改。
4. 题目练习
俗话说的好,光说不练假把式,下面让我们通过两道题去更加深入地了解如何使用set和map以及它们在做题中带给我们的遍历:
4.1 set的练习
先来看题目(链接):
虽然这道题非常的简单,但是使用set会使代码更加的简单,思路就是根据set的有序加上去重来使这道题从简单变得非常简单,将两个数组的值分别放入两个set中,然后依次比较两个set中的值,小的就往后遍历,相等的就是交集,都往后遍历一次。
下面来看代码:
class Solution {public:vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {set<int> s1(nums1.begin(), nums1.end());set<int> s2(nums2.begin(), nums2.end());// 因为set遍历是有序的,有序值,依次⽐较// ⼩的++,相等的就是交集vector<int> ret;auto it1 = s1.begin();auto it2 = s2.begin();while(it1 != s1.end() && it2 != s2.end()){if(*it1 < *it2){it1++;} else if(*it1 > *it2){it2++;} else{ret.push_back(*it1);it1++;it2++;}} return ret;}
};
4.2 map的练习
先来看题目(链接):
这道题也比较简单。思路就是我们利⽤map统计出次数,在对次数进行降序排序,然后取前K个值即可。不过这道题有⼀个特殊要求,如果不同的单词有相同出现频率,按字典顺序排序。当我们使用map时,这些单词的其实已经按是按照字典序排序(也就是string的排序,根据ascll码值)好了,因此我们只需要在对次数进行排序时不改变它们的相对位置即可,那么我们将数据放到vector中⽤⼀个稳定的排序就可以实现上⾯特殊要求,但是库中提供的sort排序是以快排为基础的排序,前面我们讲过,快排是不稳定的,稳定的排序算法只有冒泡排序、插入排序和归并排序。其实库里也提供了稳定排序的函数stable_sort()
这样一来,这道题就可以得到完美的解决了,更多的细节可以看代码中的注释。
下面来看代码:
class Solution {
public:struct Compare{bool operator()(const pair<string, int>& x, const pair<string, int>& y)const{return x.second > y.second;}};vector<string> topKFrequent(vector<string>& words, int k) {map<string, int> countMap;for(auto& e : words){countMap[e]++;} //因为map是双向迭代器,不支持使用库中的sort函数vector<pair<string, int>> v(countMap.begin(), countMap.end());// 仿函数控制降序stable_sort(v.begin(), v.end(), Compare());//sort(v.begin(), v.end(), Compare());// 取前k个vector<string> strV;for(int i = 0; i < k; ++i){strV.push_back(v[i].first);} return strV;}
}
关于set和map的使用讲解到这里就结束了,至于它们的具体实现等我们学习了接下来的AVL树和红黑树之后再进行讲解。
尾声
若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!