👦个人主页:@Weraphael
✍🏻作者简介:目前学习C++和算法
✈️专栏:C++航路
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注✨
目录
- 一、关联式容器
- 二、键值对
- 三、set容器
- 3.1 概念
- 3.2 set的使用
- 3.2.1 构造
- 3.2.2 insert + 迭代器
- 3.2.3 find
- 3.2.4 erase
- 3.2.5 count
- 四、multiset容器
- 五、map容器
- 5.1 概念
- 5.2 insert
- 5.3 访问容器数据 - 迭代器
- 5.4 operator[]
- 六、multimap
- 七、交集与差集
- 7.1 如何查找交集
- 7.2 如何查找差集
- 八、map和set的总结
一、关联式容器
- 我们已经接触过
STL
中的部分容器,例如:vector
、list
、deque
等,这些容器统称为序列式容器(其底层为线性序列的数据结构)
那关联式容器与序列式容器有什么区别?
- 所谓关联式容器,就是每个元素都有一个键值(
key
)和一个实值(value
)。当元素被插入到关联式容器中时,容器内部结构(可能是红黑树,也可能是哈希表)便依照其键值大小,以某种特点规则将这个元素放在一个合适的位置(类似于二叉树搜索树)。注意:关联式容器没有所谓头尾(只有最大元素和最小元素),所以不会有所谓push_back
、push_front
、pop_back
、pop_front
这样的操作行为
一般而言,关联式容器的内部结构是一个平衡二叉树,以便获得良好的搜索效率
二、键值对
在以上我们提到了键值,那么键值是什么呢?
- 键值对是 一种用来表示具有一一对应关系的结构,该结构中一般只包含两个成员变量:
key
和value
。其中,key
表示键值,value
表示与key
对应的信息
关联式容器的实现离不开键值对,因此在标准库中,专门提供了这种pair
。
pair
就不在这里细说了,可以参考往期博客:点击跳转
三、set容器
3.1 概念
【文档介绍】
set
其实就是之前在二叉搜索树说的key
的模型。但是注意,set
并不是完全是二叉搜索树,它的底层其实是一颗红黑树(一颗搜索树)
*set
不像map
那样可以同时拥有实值(value
)和键值(key
),set
元素的键值就是实值,实值就是键值。set
不允许有两个元素有相同的键值。
3.2 set的使用
3.2.1 构造
- 默认构造
- 迭代区间构造
- 拷贝构造
和以往学习的STL容器类似,就不一一演示了
3.2.2 insert + 迭代器
#include <iostream>
#include <set>using namespace std;int main()
{int a[] = { 2,1,5,6,3,4,8,10,7 };set<int> s;for (auto x : a){s.insert(x);}set<int>::iterator sit = s.begin();while (sit != s.end()){cout << *sit << " ";++sit;}cout << endl;return 0;
}
【程序结果】
以上代码的逻辑是将数组a
中元素全部插入到set
容器中,可是有一个问题:为什么迭代器遍历出来的结果为升序呢?我们可以联想【二叉搜索树】,它的中序遍历是一个升序。因此,set
容器底层迭代器的实现方式就是中序。
那如果我在set
容器中插入重复元素,结果又会是如何呢?
我们发现:当插入的数出现冗余,它就有去重的效果。其实它的底层其实是:遇到键值冗余的数据就不插入。
那么接下来又牵扯到一个知识点:如果一个容器支持迭代器,那么它必定支持范围for
,因为其底层就是靠迭代器实现的
#include <iostream>
#include <set>using namespace std;int main()
{int a[] = { 2,1,5,6,3,4,8,10,7 };set<int> s;for (auto x : a){s.insert(x);}for (auto x : s){cout << x << " ";}cout << endl;return 0;
}
【程序结果】
3.2.3 find
#include <set>
#include <iostream>
using namespace std;int main()
{set<int> s1;s1.insert(2);s1.insert(5);s1.insert(1);s1.insert(4);s1.insert(3);// 查找3set<int>::iterator pos = s1.find(3);if (pos != s1.end())cout << "找到了3" << endl;elsecout << "没找到3" << endl;return 0;
}
【输出结果】
- 问题1:似乎算法库
algorithm
中也有一个find
函数,那么为什么set
容器还要再设计find
函数呢?
它们除了功能是相同的以为,在效率方面不是一样,set
实现的find
函数查找的时间复杂度是O(logN)
,大的往右子树查找,小的往左子树查找,因此只需要查找高度次;而算法库中的find
是利用迭代器来遍历整颗树(暴力查找),时间复杂度为O(N)
。因此,实际中有运用到find
函数,建议使用内置的。
- 问题2:我们可以通过
set
的迭代器改变set
的元素值吗?
#include <set>
#include <iostream>
using namespace std;int main()
{set<int> s1;s1.insert(2);s1.insert(5);s1.insert(1);s1.insert(4);s1.insert(3);// 查找4,找到了修改成100set<int>::iterator pos = s1.find(4);if (pos != s1.end()){*pos = 100;}else{cout << "没找到3" << endl;}return 0;
}
【程序结果】
答案已经很明显了!不能修改!因为set元素值就是其键值,修改了会关系到set元素的排列规则。如果任意改变set元素值,会严重破坏set组织。通过查阅文档,我们发现普通迭代器被定义为const
迭代器
3.2.4 erase
大家看上图,迭代器方式删除和给值删除方式有没有什么差异? 第给值删除不是更加方便吗?直接给个值删除就行,为什么要提供第一种?
大家可以认为第二种就是依靠第一种实现的。这是因为在大多情况,还是要依靠查找来删除值。
除此之外,给值删除方式有个特点:删除除容器以外的值不会报错
#include <iostream>
#include <set>using namespace std;int main()
{int a[] = { 2,1,5,6,3,4,8,10,7 };set<int> s;for (auto x : a){s.insert(x);}// 删除100s.erase(100);for (auto x : s){cout << x << " ";}cout << endl;return 0;
}
【程序结果】
如果使用迭代删除方式,没有对find的返回值进行特判,就会发生断言错误
#include <iostream>
#include <set>using namespace std;int main()
{int a[] = { 2,1,5,6,3,4,8,10,7 };set<int> s;for (auto x : a){s.insert(x);}// 删除100set<int>::iterator sit = s.find(100);s.erase(sit);for (auto x : s){cout << x << " ";}cout << endl;return 0;
}
3.2.5 count
除了find
可以查找,count
也可以。count
作用是:传一个值,就会返回这个值出现了几次。因此,它也可以用来查找。
但是有了find
函数,再设计count
函数是不是有点冗余。其实count
在后面另有用处…
四、multiset容器
set
还有一个亲兄弟:multiset
,它和set
容器最大的区别是:允许出现数据冗余,即插入冗余的数据一定是成功的。
除此之外,multiset
和set
的操作没什么区别,都是一模一样。大家可以通过文档自行查阅:点击跳转
这里单独演示一下允许数据冗余的效果
所以,multiset
才是真正的排序,可以出现数据冗余的情况,set
则是去重 + 排序
除此之外,刚刚说的 count
函数其实就是为multiset
准备的
那么问题来了,如果我想查找的数据恰好出现冗余的情况,请问返回的数据是第几次出现的数据呢?
可以打印出它们的地址来看
#include <iostream>
#include <vector>
#include <set>
using namespace std;int main()
{vector<int> v = { 1,3,5,7,5,9,2,3,3,3 };multiset<int> ms1(v.begin(), v.end());auto pos = ms1.begin();while (pos != ms1.end()){cout << *pos << ":" << &(*(pos)) << endl;pos++;}cout << endl;// 查找5cout << "5:" << &(*(ms1.find(5))) << endl;return 0;
}
【输出结果】
在实际中,multiset
用的比较少,重点掌握set
即可
五、map容器
5.1 概念
map
是二叉搜索树改造后的key/value
模型,是一个真正意义上的 键值对map
的特性是:所有元素都会根据元素的键值自动被排序。map
的所有元素都是用pair
结构存储的,同时拥有实值(value
)和键值(key
)。pair
的第一个元素(first
)被视为键值,第二个元素(second
)被视为实值。- 注意:
map
不允许两个元素拥有相同的键值
5.2 insert
我们首先可以看看插入接口,注意看其参数类型:value_type
,它是key
的类型还是value
的类型呢?可以查阅文档:
value_type
是一个pair
结构,并且key_type
(也就是键值key
)是由const
修饰的,表明不能被修改!
想补充pair
知识可以参考往期博客:点击跳转
接下来举一个代码样例:字典
插入的方式不能向上面这样写,这是很多初学者犯的错误。刚刚说过,键值和实值需要用pair
结构来存
int main()
{map<string, string> dict;// 写法一:创建pair的结构对象pair<string, string> p("插入", "insert");dict.insert(p);// 写法二:pair的匿名对象dict.insert(pair<string, string>("插入", "insert"));// 写法三:以上的写法非常繁琐,如果没有展开命名空间的话// 因此可以使用make_pair简化代码dict.insert(make_pair("删除", "erase"));// 写法四:用花括号括起来// 这种方式非常简洁,但是有一个限制// 编译器必须支持C++11:多参数构造函数支持隐式类型转化// C++98:单参数构造函数支持隐式类型转换dict.insert({"查找", "find"});return 0;
}
5.3 访问容器数据 - 迭代器
map
同样支持迭代器
#include <iostream>
#include <map>
#include <string>
#include <utility>using namespace std;int main()
{map<string, string> dict;pair<string, string> p("插入", "insert");dict.insert(p);dict.insert(pair<string, string>("插入", "insert"));dict.insert(make_pair("删除", "erase"));dict.insert({ "查找", "find" });// 迭代器遍历map<string, string>::iterator mit = dict.begin();while (mit != dict.end()){cout << *mit << " ";++mit;}cout << endl;return 0;
}
很多初学者大概率会写出以上代码,但是编译不通过!从提示可以看出:pair
不支持流插入<<
再回过头来分析:数据是存在pair
结构里的,那么像访问结构体内的元素,是不是可以用->
或者*
操作符来访问数据。并且在【map
概念部分】说过了:pair
的第一个元素(first
)被视为键值,第二个元素(second
)被视为实值
#include <iostream>
#include <map>
#include <string>
#include <utility>using namespace std;int main()
{map<string, string> dict;pair<string, string> p("插入", "insert");dict.insert(p);dict.insert(pair<string, string>("插入", "insert"));dict.insert(make_pair("删除", "erase"));dict.insert({ "查找", "find" });// 迭代器遍历// ->访问方式map<string, string>::iterator mit = dict.begin();while (mit != dict.end()){cout << mit->first << ":" << mit->second << endl;++mit;}cout << endl;// *访问方式mit = dict.begin();while (mit != dict.end()){cout << (*mit).first << ":" << (*mit).second << endl;++mit;}cout << endl;return 0;
}
【运行结果】
同理地,既然map
也支持迭代器,那么就必定支持范围for
#include <iostream>
#include <map>
#include <string>
#include <utility>using namespace std;int main()
{map<string, string> dict;pair<string, string> p("插入", "insert");dict.insert(p);dict.insert(pair<string, string>("插入", "insert"));dict.insert(make_pair("删除", "erase"));dict.insert({"查找", "find"});// 访问forfor (const auto &x : dict){cout << x.first << ":" << x.second << endl;}return 0;
}
【运行结果】
- 那么现在就有一个问题:可以通过map的迭代器改变map的元素内容吗?
如果是想修改键值(key
)是不行的。还是和set
一样的原因:map
元素的键值关系到map
元素的排列规则。任意改变map
元素的key
将会严重破坏map
组织。
但如果想要修改元素的实值(value
),那么是可以的。因为map
元素的实值并不影响map
的排列规则
官方文档同样也给出了答案:键值key
被const
修饰,表示不能被修改。
5.4 operator[]
大家可能会感到奇怪,map
底层是一个树形结构,按理来说是不支持随机访问的,可是为什么map支持operator[]
呢?
map
常见的使用场景是:统计出现过的次数
假设我要写一个统计水果出现的次数,大部分人都可以写出以下代码
#include <iostream>
#include <map>
#include <string>
#include <utility>using namespace std;// ### 5.3 operator[]
// 大家可能会感到奇怪,map底层是一个树形结构,按理来说是不支持随机访问的,可是为什么map支持`operator[]`呢?int main()
{string s[] = {"西瓜", "西瓜", "香蕉", "苹果", "桃子", "香蕉", "香蕉", "香蕉"};map<string, int> dict;// 将数组内的元素放进map中来统计次数for (auto x : s){// 如果水果刚刚出现第一次出现,就将记为1map<string, int>::iterator pos = dict.find(x);if (pos == dict.end()){dict.insert(make_pair(x, 1));}// 否则就是出现过两次以上else{pos->second++;}}for (const auto &x : dict){cout << x.first << ":" << x.second << endl;}return 0;
}
【运行结果】
但是以上代码还可以优化:使用operator[]
#include <iostream>
#include <map>
#include <string>
#include <utility>using namespace std;// ### 5.3 operator[]
// 大家可能会感到奇怪,map底层是一个树形结构,按理来说是不支持随机访问的,可是为什么map支持`operator[]`呢?int main()
{string s[] = { "西瓜", "西瓜", "香蕉", "苹果", "桃子", "香蕉", "香蕉", "香蕉" };map<string, int> dict;// 将数组内的元素放进map中来统计次数for (auto x : s){dict[x]++;}for (const auto& x : dict){cout << x.first << ":" << x.second << endl;}return 0;
}
【运行结果】
代码改进后结果也是正确的,并且比第一种简洁多了!那么,operator[]
到底是何方神圣呢?我们一起来研究一下
我们可以通过文档来分析
注意看它的参数和返回类型,这里的operator[]
并不是以往我们所认识的下标访问。它是通过键值key
来返回实值value
的引用!
那么这里有个问题,它是如何找到实值,然后进行修改的呢?
文档同样给出了答案,operator[]
等价于调用以下这么个长东西
面对这么长的代码一定要耐下心来从内向外剖析
因此,就要研究insert
的返回值
简单翻译一下:
insert
会返回一个pair
结构,其中这个pair
的first
被设置成了一个迭代器,而迭代器的指向分两种情况:
- 键值
key
已经在树里,那么就返回树里面key
所在结点的迭代器 - 键值
key
不在树里,那么就返回新插入key
所在结点的迭代器
以上的长代码可以分解如下:
V &operator[](const K &key)
{pair<iterator, bool> res = insert(make_pair(key, V()));return res.first->second;
}
通过以上解释,会发现operator[]
有插入、修改、插入+修改、查找
#include <iostream>
#include <map>
#include <string>
#include <utility>using namespace std;// ### 5.3 operator[]
// 大家可能会感到奇怪,map底层是一个树形结构,按理来说是不支持随机访问的,可是为什么map支持`operator[]`呢?int main()
{// 有插入、修改、插入+修改、查找map<string, string> m1;// 插入m1["hello"] = "你好";m1["sort"] = "插入";for (const auto& x : m1){cout << x.first << ":" << x.second << endl;}// 修改m1["sort"] = "排序";for (const auto& x : m1){cout << x.first << ":" << x.second << endl;}return 0;
}
【运行结果】
六、multimap
multimap
中允许出现多个重复的键值。因此,operator[]
就无法确认调用者的意图,也就是不知道要返回哪个 键值对应的结点。所以multimap
中没有提供operator[]
,当然其他操作都是和map
相同
还有的是:查找find
时,返回的是中序遍历中第一次出现元素的迭代器;另外计数count
返回的则是当前键值的数量
七、交集与差集
7.1 如何查找交集
交集,指两个数组中相同的元素所构成的集合
求交集的步骤如下:
- 先将两个数组 排序 + 去重
- 遍历两个数组
- 如果不相等,小的 ++
- 相等就是交集,记录下来
- 其中一方走完,所有交集就查找完了
排序 + 去重,就可以使用set
相关题目:点击跳转
//349. 两个数组的交集
//https://leetcode.cn/problems/intersection-of-two-arrays/description/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());//查找交集vector<int> v;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{v.push_back(*it1);++it1;++it2;}}return v;}
};
7.2 如何查找差集
至于差集的查找,思路和交集差不多
求差集的步骤如下:
- 先将两个数组 排序 + 去重
- 遍历两个数组
- 如果相等,同时 ++
- 不相等,小的一方记录后,再 ++
- 其中一方走完,再遍历另一方,此时其中的所有元素都是差集
八、map和set的总结
set
- 只有键值,其键值就是实值,所以传递参数时,只需要传递实值
- 自带去重机制,不允许出现数据冗余
- 使用迭代器遍历容器时,结果为有序,并且默认为升序
- 即使是普通迭代器,也不运行修改键值
map
- 既包含键值,也包含实值。插入时,需要传
pair
对象 - 自带去重机制,不允许出现数据冗余
- 使用迭代器遍历容器时,结果为有序,并且默认为升序。是靠键值进行排序的
- 普通迭代器不运行修改键值,但运行修改实值