关联式容器
STL 把它的容器分为两种,序列式容器和关联式容器
- 序列式容器:其实就对应以前的线性表,比如:vector / list / deque,序列式容器它的数据和数据之间并没有很强的关联,它就是挨着挨着存储就可以了,想存储在哪个位置都是无所谓的
- 关联式容器:关联式容器就比如:map / set 当然还有后面的 unordered_set / unordered_map,关联式容器主要就是它的数据和数据之间有着非常强的一个关联关系,因为它底层是一个红黑树,红黑树本质再底层是一个搜索树,搜索树插入数据它要按照它的规则去插入
set 的介绍
set 的本质是个 key 的模型(就是确认在不在)
set 的定义和使用
构造一个 int 类型的 set,可以用 insert 一个个插入数据来初始化
set<int> s; //定义
s.insert(4);
s.insert(2); //插入
s.insert(1);
也可以用这个列表或者迭代器的区间来初始化
set<int> s = { 1,2,1,6,3,8,5 };
int a[] = { 1,2,1,6,3,8,5 };
set<int> s(a, a + sizeof(a) / 4); //迭代器区间
用迭代器进行遍历 ,也可以用范围 for
int a[] = { 1,2,1,6,3,8,5 };
set<int> s(a, a + sizeof(a) / 4);
set<int>::iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
//1 2 3 5 6 8
我们发现结果是 1 2 3 5 6 8 因为它的底层是一个搜索二叉树,走的是中序,所以 set 可以间接的 完成排序 + 去重,也可以用个 greater<int> 仿函数让大的向左小的向右来完成降序
#include <functional> //greater需要头文件
int a[] = { 1,2,1,6,3,8,5 };
set<int, greater<int>> s(a, a + sizeof(a) / 4);
set<int>::iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}
//8 6 5 3 2 1
再来看看 erase ,那它还有没有什么其它方式呢?
//8 6 5 3 2 1
s.erase(3);
for (auto e : s)
{
cout << e << " ";
}
//8 6 5 2 1
可以看到这个 erase 这里还有一个就是 erase 一个位置
这个位置的来源还有一个配套的 find
那我们如果用 find 试一下在这个地方
//8 6 5 2 1
set<int>::iterator pos = s.find(2);
s.erase(pos);
for (auto e : s)
{
cout << e << " ";
}
//8 6 5 1
那这两种删除方式有没有什么区别呢?从这个角度看好像觉得没啥区别,都能删而且第二个感觉更麻烦些,那怎么样才会有区别呢?首先,第一个删除一个不存在的 30 不会有什么反应
s.erase(30);
for (auto e : s)
{
cout << e << " ";
}
cout <<endl;
set<int>::iterator pos = s.find(20);
s.erase(pos);
for (auto e : s)
{
cout << e << " ";
}
但是第二个,去删一个不存在的就出问题了,因为 find 这边没有找到 20 就返回了个 end ,所以这边要判断一下是否找到了,找到了才去删
set<int>::iterator pos = s.find(20);
if (pos != s.end())
{
s.erase(pos);
}
for (auto e : s)
{
cout << e << " ";
}
count 在这边是某个值的计数,它会返回个数
count 呢可以用来简单的找它在不在,但是 count 并不是为这一块设计的,是为了保持 set 和 multiset 的接口一致,在用法上几乎一致
cout << s.count(3) << endl; //在 1
cout << s.count(30) << endl; //不在 0
再看 lower_bound 和 upper_bound ,比如说我有某个需求,我想把某个范围的值都找到,可以通过下面的例子发现,30 到 60 没了
std::set<int> myset;
std::set<int>::iterator itlow, itup;
for (int i = 1; i < 10; i++) myset.insert(i * 10); // 10 20 30 40 50 60 70 80 90
itlow = myset.lower_bound(30); // ^
itup = myset.upper_bound(60); // ^
myset.erase(itlow, itup); // 10 20 70 80 90
std::cout << "myset contains:";
for (std::set<int>::iterator it = myset.begin(); it != myset.end(); ++it)
std::cout << ' ' << *it;
std::cout << '\n';
如果我这边给的是 35 呢,这个左区间找的是谁?
itlow = myset.lower_bound(35);
itup = myset.upper_bound(60);
看结果,它找了个 40
顺便把这两个值也打印一下,通过这两个场景可以对比出来这个 lower_bound 返回的是大于等于这个值的位置,upper_bound 返回的是大于这个值的位置,因为迭代器区间是左闭右开的,
itlow = myset.lower_bound(30);
itup = myset.upper_bound(60);
cout << "[ " << *itlow << ',' << *itup << " ]" << endl; //30 70
还有 equal_range 也是去找一段区间,它还用了一个 pair
这个 pair 是库里面给的一个结构,它具有两个模板参数,有两个值,一个值是 first 还有一个 second ,pair 很喜欢在很多地方叫做键值对,就是一对一对的数据就可以用它表示,可以用模板确定他们的类型
这边给 30 ,它返回的是 30 到 40 这个区间
std::set<int> myset;
for (int i = 1; i <= 5; i++) myset.insert(i * 10); // myset: 10 20 30 40 50
std::pair<std::set<int>::const_iterator, std::set<int>::const_iterator> ret;
ret = myset.equal_range(30);
std::cout << "the lower bound points to: " << *ret.first << '\n'; // 30
std::cout << "the upper bound points to: " << *ret.second << '\n'; // 40
multiset
multiset 和 set 严格来说用法上没什么区别,唯一的区别就是允许键值冗余,所以它的特点就是单纯的排序了,其它功能都类似,但是 count 返回的是个数
它跟 set 还有一个区别就是 find ,find 对于 set 就是有和无的区别,对于 multiset 如果有多个值,返回中序的第一个,而 erase 则是删除所有的这个值,如果是给迭代器位置的话就只删那一个
map
map 也一样分有 multimap 和 map ,map的功能也几乎类似,map 这边真正的区别在 insert,map 这边 insert 的也是一个 value_type
set 那边也叫 value_type ,但是它们 value_type 的 definition 是不一样的,这边是一个 pair ,pair 的 key_type 是一个 const 的 key_type ,也就是说 map 里面它插入的时候插入的不是一个数据,而是一个结构体,这个结构体叫 pair
所以 map 我们想插入值要给一个 pair ,这边首先实例化了一个 pair 类型,如果要再插入下一个就要再写一个 kv2 会很麻烦
所以匿名对象在这样的场景下还是很有必要的
然而在实际当中,大家特别喜欢用这个东西 make_pair() 自动推导
然后再来看一下 map 的遍历,会发现这样编不过,因为 pair 不支持流插入,因为迭代器解引用是去调用 operator* ,C/C++ 不支持一个函数的调用返回两个值,所以这边一个 key 一个 value 不能返回,所以这边返回的是一个 pair
去 pair 里面找,但是 map 的遍历不喜欢这样
我们除了要重载 operator* ,operator* 是取里面数据的引用,当里面的数据是一个结构的时候还要重载 operator-> ,然后去调 operator-> ,第一个 -> 是调 operator-> 返回数据的指针,在这里就是 pair 的指针,返回的就是 pair* ,再加一个 -> 就能访问了
cout << it->->first << " " << it->->second << " ";
但是为了可读性,编译器在这会省略掉一个箭头
cout << it->first << " " << it->second << " ";
我们再来借助另一个例子来进一步了解 map ,这边先玩一个计次树,有三种方法。
下一步,要统计这个次数可以先遍历这个东西,现在来了一个水果,我们先要看它在不在,利用这个 find 去查一下,如果它在这个地方在的话就去加加它的次数,如果不在的话就去插入
再来看看第二种玩法,我们想统计次数还有一种非常简单的方式,其实真要统计次数的时候不太用前面的这种方法, 一般会使用方括号 [ ] ,它这个方括号在这有一点违背之前方括号的意思,因为之前方括号是给数组用的,然而 vector 和 string 呢是重载了这个方括号,能对这个自定义类型像数组一样的去访问得益于它们的底层空间是连续的,而现在是树形结构肯定不是下标访问,不是要访问第几个第几个,所以这一块本质要做的事情是它是给一个 key 返回 value 的引用
所以在这边这样用就可以,这个用起来就非常的便捷
但是,这个地方真正的疑问是这个水果第一次来的,什么时候进的这个 map 呢?首先,map 中有这个 key 它会返回 value 的引用,那么它可以充当两个作用,查找和修改 value ,如果 map 中没有 key 它会插入一个新元素,这个新元素就是 pair ,然后这个 pair 用的是这个 key 但是现在只有这个 key 那 value 是什么呢?也就是说它会插入一个 pair(key,V()) 去调用它的默认构造, value 是个缺省值,如果是 int 那就是 0 ,如果是 string 那就是一个空的 string ,如果是指针就是空指针,再返回 value 的引用就可以去修改
这个函数跟 at 不同,这个 at 这个功能就单纯很多,就是查找修改,如果这个 key 在,它就返回 value 的引用如果不在的话它会抛一个 out_of_range 。再来看一下方括号的实现,就调了 insert 就实现了
mapped_type& operator[] (const key_type& k)
{
return (*((this->insert(make_pair(k, mapped_type()))).first));
}
再来看一下 insert 的返回值,它返回一个 pair ,这个 pair 的 second 是个布尔值,就是说我 insert 插入一个值的时候这个值已经有了就不插入,那这个布尔值就是 false,如果没有就插入就是 true ,而这个 first 被设为一个迭代器,这个迭代器指向新插入的元素,如果这个 key 已经存在就返回跟这个 key 相等的所在节点的迭代器
所以在这个地方,自己实现的时候就可以这样走
V& operator[] (const K& key)
{
pair<iterator, bool> ret = insert(make_pair(key, V()));
}
这个时候就会有两种可能性,一种是这个 key 不在,它是插入成功的,如果这个 key 已经有了那这个时候这个 bool 就是 false ,但是不管有没有插入成功这个迭代器都是和这个 key 相等的所在节点的迭代器,所以就要返回 key 对应的那个 value,这个 ret 的 first 就是这个 key 的迭代器,再加一个 -> 就可以取到对应的 value
V& operator[] (const K& key)
{
pair<iterator, bool> ret = insert(make_pair(key, V()));
return ret.first->second;
}
再来看之前那句代码 (*((this->insert(make_pair(k, mapped_type()))).first)) 它只是把这两句代码合在了一起,这个代码这边加不加 this 都可以走,包括最后这一块不解引用直接用箭头也可以,这只是它的一种写法。所以严格来说 insert 在这返回一个 pair 是为方括号准备的
multimap
multimap 跟 map 那的区别就是没有方括号,因为它现在允许键值的冗余了,比如之前的苹果第一次来第二次来第三次来,它里面有多个苹果,现在要返回 value 它要返回哪一个苹果对应的的 value 呢?所以方括号在这没有了。所以 multimap 在这就不能去做统计次数了,其它的都一样,find 查找的是中序的第一个,multiamp 呢也有它的需求场景这边简单举个例子,对于 map 而言这个值只要有了就不会再插入了,但是它不一样,它还会再插入。
最后再来看两道题
比如说,现在给你一个单词列表要求找出出现次数最多的前 k 个单词返回的答案由高到低排序,相同频率的单词按字典序排序。首先直接统计它的次数,第二步就是 topk 想到的就是堆,可以用优先级队列建一个大堆然后依次去取,但是这里用优先级队列的话有一个问题,它不能保证次数相同的谁在前谁在后,所以这道题最重要的是字典序,然后再去遍历这个 countMap 依次插入进去,最后再返回前 K 个,这个就是用仿函数来进行这块的控制
还有其它方式,其实 countMap 里面统计完后已经默认按 string 去排序了,如果在这不用优先级队列,用一个稳定的排序算法去排这个 value 也可以解决这个问题,所以这边不能直接用 sort 去排序因为快排不稳定,不能保证相同次数的 key 的相对顺序,可以用个 stable_sort 来保证它的稳定性。这里还有一个方法,统计完次数后再将 string 和 int 颠倒一下然后用 multimap ,也要一个仿函数,只是它默认是个大于,这个时候它中序左边就是大的,右边是小的。
这边可以把 nums1 和 nums 2 的数据各放入一个 set ,然后对它们进行比较,小的++,相等就插入并 ++ 它两
底层结构
前面对 map / multimap / set / multiset 进行了简单的介绍,这几个容器有个共同点就是其底层都是按照二叉搜索树来实现的,而二叉搜索树的问题是如果是有序或者接近有序插入的话会退化的很厉害这棵树的效率就无法保证了,面对这些情况呢就要尝试去解决一下就是要去尝试控制平衡,控制平衡的核心思路是红黑树但在红黑树之前要了解一个过渡的树 AVL 树,红黑树是在 AVL 树的基础上给了另外一种方式去实现,map 和 set 最终用的还是红黑树。