序列式容器和关联性容器
首先序列式容器和我们之前学的线性表很相似,序列式容器的功能就只是单纯的储存数据。序列式容器例如:vactor/list/deque等等
而关联式容器则并不单纯的储存数据,数据之间式存在关联关系的,有了这个关联关系我们才能更好地去做查找。关联式容器由map/set等等。
两者的的区别,我要往map中插入一个数据,我能不能保证这个节点一定插在根节点的左子树上呢?很明显是不能的,而我想往vector的中部,头部,尾部任意一个位置插入数据都是可行的。
所以我们要知道关联式容器,并不是单纯的为了储存数据。
set接口简介
set是包含在头文件set中的
#include<set>
首先我们来看一下set的原型:
可以看到set的原型是一个类模板,而这里的第一个模板参数给了一个T,这就是我们在二叉搜索树那里学的key。
然后Compare是一个仿函数,因为set的底层是一个搜索树,而搜索树是需要去比较key的大小的。然后最后一个是空间配置器。空间配置器是用于给容器申请内存的(我们暂时不用管),而仿函数内部已经默认有了一个所以我们也不需要管。所以我们在使用set的时候,只用给第一个即可。下面我们来看set的接口。
首先就是默认成员函数,构造析构,和拷贝构造。
详细的构造
可以看到构造也是支持无参,迭代器,和传另一个set构造的。
至于拷贝和析构自然也是一样的。
这里我们需要知道的一点就是,因为set的底层是一颗树,所以拷贝和析构这颗树的代价是很大的。树的节点越多影响也就越大。
下面是迭代器
对于迭代器,和我们之前学的容器一样和正常的去使用就可以了。这里因为set是支持迭代器的,自然也就支持范围for了。
下面是关于set容量相关的东西。
然后就是set对节点修改的接口了
可以看到和其它容器不同的是set是没有头插,尾插和任意位置插入的概念的,因为set是一个关联式容器,而关联式容器各个数据之间是存在联系的。
然后下面就是查找
总结一下:set是一个key搜索模型的容器,是用于判断在不在的。所以不存在修改节点的概念,所以set具有增删查的功能但不具备改的功能。
set接口的简单使用
insert
我们先来看insert使用需要传递的参数
可以看到insert支持三种模式第一种直接插入一个值,第二个在某个位置插入一个值(尽量不要使用这种方式,使用可能会出现很多的坑,然后回让你的set搞出问题)第三个支持插入一个迭代器区间。
在日常使用中我们基本上使用的是第一种插入方式,第三个偶尔会使用后。
我们来使用第一种方式插入5个数据:
从遍历出来的结果就可以看到set的特点,那就是set遍历走完是一个升序。这说明set底层二叉搜索树走的是一个中序遍历。
set还有一个特点
当我们插入一个之间已经存在于树中的值时。
会发生什么呢?
可以看到我在下面重复插入的5,3和1是没有进入到set中的。
虽然set的底层是一棵红黑树,但是即使是红黑树,那也存在一个前提就是它是一棵搜索树既然是搜索树那么对于重复的值在key模型下是不会插入的(会返回false)。
但我们去去上面的那张图片上看insert返回的是一个pair,在pair中确实存在一个bool类型的参数
那么这个pair是个什么东西呢?
pair类
可以看到这个pair是一个类,这个类里面存在两个值,具有两个值。
下面是在sgi中对于pair的定义
所以这里的pair就只是一个模板类,它具有两个成员变量(first,secend)。而这两个成员的具体类型则由我们自己传递。
那么我们现在去看insert的返回值就能够理解了,可以看到insert返回了一个pair对象,而这个pair对象的first是一个迭代器,而第二个类型是一个bool值。那么如果我想在上面接收insert的返回值要怎么写呢?
那么这里就存在了一个问题那就是为什么在上面的pair那里能够直接使用iterator,而我们这里需要指定类域呢?
首先这个insert实现的时候,肯定是在set的内部实现的,那么此时这里的iterator,本身便在类域中了,此时这个insert函数是不会受访问限定符的限制的(这个iterator它知道是该类域中的iterator),但是此时我们在这里使用的时候,肯定是在类外的。所以这里需要指定类域。
那么为什么要这么做呢?为什么不直接返回一个false,这个在下面使用map的时候会解释。
这里如果你不想使用这一大串类型声明,也可以使用一个auto,但是这里也可以看到auto的一个坏处,这里我们因为看来insert函数的声明,已经pair类型的定义,所以我们在使用a的时候知道,a中的第二个参数才是bool值,而第一个是一个迭代器,如果使用auto,但是却不去看insert函数的声明的话,这里大概率是无法得到bool值得。
这也正是auto得坏处,虽然能够让代码变得简洁,但是如果不熟悉接口的话容易出错。
至于范围for肯定是可以使用的,因为范围for的底层是一个迭代器,而栈就没有范围for,因为栈没有迭代器。
对于迭代器的接口没有什么好说的,直接使用即可。
erase
下面我们简单的看一下erase函数的使用
也是支持三种方式位置,值和迭代器区间删除,那么元素的迭代器从哪里来呢?自然是find来的。
find返回的就是一个迭代器。
那么如果find没有找到这个元素会返回什么呢?
可以看到会返回end。
所以可以这么使用:
那么这里我们思考一个问题,如果erase的时候我们传递的不是一个有效的迭代器,erase这里会发生什么呢?
可以看到这里就直接崩溃了。
所以如果传递的是一个迭代器,一定要判断是否找到了。
在上面我们看到除了迭代器之外也可以直接使用值来删除,那么如果这里删除一个不存在的值会有什么影响吗?
可以看到没有任何的影响。
总结一下对于erase来说,如果传递的是迭代器,那么必须要保证这个迭代器的值是有效的,而如果传递的是一个值,那无所谓存在就删除,不存在那也没有影响。那么这里如果传递的是一个值,我们如何知道他是删除成功了的呢?
我们也是可以知道的。
可以看到这个erase会返回一个size_type,而这个size_type就是一个无符号整型的重命名,所以这里如果删除成功返回的是一个1,不然返回的是0。那么这里为什么是size_type而不是bool呢?这和下面的一个容器有关。
至于emplace系列,因为我暂时还没有学习到这里,所以就暂时不写了。
lower_bound
这个函数是给一个值然后返回一个迭代器。
至于实际意义:
我们直接看示例:
上面的示例lower_bound返回的是30的迭代器,而upper_bound返回的是70的迭代器,那么这是为什么呢?
因为凡是迭代器区间都是左闭右开的,只有返回的是70的迭代器,才能把我们输入的60的迭代器,包含进去。erase函数也是这样,不会删除70,因为左闭右开。
所以我们才能在下面的erase那里直接将30到60的数据全部删除。
那么如果是下面的这样呢?
这里lower_bound中的值是一个25,这里会返回什么呢?
可以看到这里的结果是不变的所以这里能够得出结论:
所以这里如果你在upper那里写一个65返回的依旧是70的迭代器。如果你给一个70,那么自然返回的就是80的迭代器了。
所以如果你想在set中寻找某一个范围内的值就可以使用lower_bound和upper_bound。
equal_range
那么这个接口的作用又是什么呢?可以看到这里返回的是一个pair。
我们来看下面的示例:
这里如果值是30那么这里返回的就是30和40的迭代器。
那么如果我们在equal_range中放的值是一个35,那么这里又会返回什么呢?
可以看到这里返回的是40
总结这里的first是返回大于等于val,而secend返回的是大于value的值。
这个函数对set其实没有太大的作用,但是对于另外一个容器是很有用的。
下面是swap函数,以及clear函数。swap函数自然就是在底层交换两颗树的根了,至于clear自然是将树中的值销毁。
那么最后还有一个函数count
count
这个函数会返回在set中value的个数,在set中value的个数就只有两种1个或者是0个。
而这里就可以使用count来判断在不在来了。
最后还有一个注意的点就是set是不支持修改的。
因为改了就可能不是搜索树了,key模式不支持更改,但是keyvalue模式是支持的。
multiset容器
我们在之前的set容器的时候,看到set容器时不支持重复值插入修改的,但是multiset是支持的。
这里你可以认为multiset是一棵变异的搜索树。
这里我们就可以总结一下set的功能就是排序+去重。
而multiset的作用就是排序。
那么这里一个相同的值来了以后multiset是怎么插入的呢?
这个相同的值是插入在了父节点的左边还是右边呢?
这个问题的答案是无所谓,你插入在左边还是右边都是一样的。
例如下图:
上面的这个树结构是会进行旋转(不平衡)的。
所以你插入在右边是可能旋转到左边,你插入在左边是可能旋转到右边,所以这里你插入在左边还是右边是无所谓的。
至于为何会旋转的,以及平衡的概念之后,我还没有学习。就不写了。
至于如果再插入一个5如何插入呢?可以直接就是大于走右边小于走左边,这样就会变成下图。
这样中序去走这两个5也是在一起的。
那么这里有一个问题,现在我在我上面的代码修改了一下让其拥有了好几个2,那么下面如果我使用find来找2返回的是哪一个2呢?修改后的插入。
可以看到返回的是中序的第一个2.所以总结就是:在multiset中如果存在多个值返回的是中序的第一个值。
所以我们就能够知道这个函数对于multiset的意义了
例如这里如果你想要删除multiset中所有的2.
那么这个函数就可以帮助你,因为这个函数的pair中first储存大于等于val的迭代器,刚好就能够找到最左端的2,而secend储存的是大于val的迭代器,自然就能找到最右端的2。最后自然就能够删除全部的2了。
所以这个接口是具有这个意义的。同理count接口最后返回的是一个无符号整型(返回val的个数),对于multiset也就具有了意义。
但是其实用这个去做erase也没有太大的意义,因为你直接使用erase(2),也能删除全部的2。删除了几个2也能从n中读出
总结所以如果你想要在multiset中找到一个值的存在的范围那么使用equal_range是可以的。
这也是为什么erase函数等等的返回值是这样的。
其余的接口这两个容器是差不多的
map的使用
map是包含在头文件map中的
#include<map>
首先map和set一样也是分成了两个一个是普通的map,一个就是multimap。
首先map是一个kv型的结构。
至于接口函数
和set是差不多的,但是多了一个operator[](这个很重要需要重点关注)。
下面我们来使用通过map的实现声明那里我们知道我们要传入两个类型一个是key,一个是T。
这里我们就写两个string。
insert
那么这里插入值,要怎么插入呢?
可以看到这里要我们输入的是一个value_type的类型值,那么这个又是什么呢?还有为什么两个值变成一个值了呢?
可以看到这里的value_type不是一个简单的key,而是一个pair,而这个pair的第一个关键字就是Key,第二个关键字就是T(也就是value)。
所以我们这里需要这么插入:
从上面的图我们可以看到在pair中,key被一个const修饰,代表着在map中key是不能被修改的,但是,value没有const修饰所以这里的value是可以被修改的。而我们在插入写pair的时候,不加const也没有关系,因为在内部会转化。
那么这里内部是如何转化的呢?
这里需要先明确一点那就是类模板的参数不同,那么就是不同的类型。
例如vector<int>和vector<char>不是同一个类型。
我的pair传过去的模板参数明明是string和string,而这里需要的是const string和string这里是怎么转化的呢?这里的实参的pair和形参的pair很明显也就是不同的类型。
那么这里能够运行是因为一个很灵活的实现。
这个灵活就在于这个拷贝构造,这个拷贝构造不是写死的,这也就意味着你可以传入自己的pair,即使不是完全和库中需要的类型一样,例如这里的const string和string,但是只要这是一个符合条件的初始化,string可以初始化给const string,那么这里就可以构造出一个pair<const stroing,string>对象。
甚至于可以这么写:
const char* 自然也是可以初始化const string的,同理const char* 也可以初始化给string。
而这里的函数可以是拷贝构造,也可以是一个拷贝构造。
所以这里只要你传入的参数是可以初始化first和secend那么这里都是可以的,例如上面的strin和string,可以初始化给const string和string,const char* 和const char* 自然也是可以初始化给const string和string的。
即这里的pair的构造既可以是构造也可以是拷贝构造。
而我们在平时一般也不和上面一样去给map插入值,而是使用下面的make_pair函数
这是一个函数模板。而函数模板的特点就是可以自己推演模板参数。
所以我们平时可以这么写:
这里的make_pair构造出的pair类型是下面的这样
虽然这里两个pair的参数基本都是不符合map的插入要求的(const string和string),但是在传参给insert的时候,可以直接使用着两个pair构造出两个符合insert插入条件的pair。
而在使用插入的时候,一般都是使用make_pair函数。
从这里可以看到模板是很厉害的。
下面我们来看map的迭代器。
这里我们就能够理解了,因为在map中储存的是一个pair,而不是我们在模拟kv模式的二叉树那里直接放两个值。在pair中first是一个key,而secend就是value。而这里在底层通过调用重载的operator*。获得pair的对象,再从派人对象中打印first和secend的值。这个在之后的map和set的模拟实现那里会有更加详细的说明。
那么我们知道除了operator之外其实还存在一个操作符重载,那就是operator->。那么什么时候使用operator,什么时候使用operator->呢?
这个在模拟实现list的时候说过:
当容器中储存的数据类型是一个自定义类型时就使用箭头去访问。而在上图中的pair就是一个自定义类型,所以上面的代码还可以这么写。
他的本质调用其实是下面这样调用的:
那么这里是怎么调用的呢?首先operator()返回的是一个自定义类型的指针,而自定义类型的指针在去使用->访问first或者是访问second。
而这里省略一个箭头就是简洁的写法了。
这里自然也是可以使用范围for的,因为存在迭代器自然就可以使用范围for.
那么这里为什么要使用&呢?
因为如果不使用引用,那么在底层就相当于是将一个pair对象赋值给了kv,这个代价是很大的,所以这里使用引用,如果你不想修改这个值的化,加一个const即可。
其次这里再将const去掉之后可以做到下面的事情:
这里就是pair的first可以修改,而second是可以修改的。那么这里是怎么做到的呢?
原理就是因为在底层的pair那里
first是被const修饰的,而seconde则没有。
下面则是map的erase函数
可以看到的就是map的erase函数是只需要key就可以完成删除的。
而也可以使用迭代器删除,而迭代器自然也是可以使用find找到的。
map统计次数
如何使用map去统计水果出现的次数呢?
就是这样输入的这个字符串如果在map中没有找到,那就插入,而如果找到了,而find返回的正是那个key的迭代器,而使用迭代器自然能够完成修改。
下面打印一下
其实除了上面的方法外还可以使用一个operator[]来完成。
以前的[]都是随机访问,你输入的是数字几,最后就会返回的就是容器中的第几个数据。而这里的[]则是你给一个key,[]返回一个key对应的value的引用。所以这里能够改变value。
那么这个[]是如何实现的呢?
下面我们来看这个函数的底层实现:
而要弄懂这个函数,我们需要回忆一下insert的返回值。
从中可以看到如果插入失败了,insert返回的pair中会把第二个bool值设置为false,而第一个则是已经存在的key所在节点的iterator,而插入成功了,insert返回的pair中会把第二个bool值设置为true,然后返回的是新插入key所在节点的iterator.
那么上面的那个mapped_type是什么呢?这个就是value()默认构造的匿名对象,如果这个value是一个int那么就是0,如果这个value是一个自定义类型就会调用自定义类型的默认构造。如果是指针那就是一个nullptr。
就拿上面的例子,如果我们现在插入这个水果(key),是之前没有存在的那么这里就会调用int的默认构造(自然就是0)。
如何理解上面的这张图呢?首先insert里面是使用了我们之前输入的key去构造了一个pair<string,int>插入到了map中,如果插入成功了那么在insert返回的那个pair中的第一个参数(iterator)就会储存新插入key所在节点的iterator,否则储存的就是已经存在的key所在节点的iterator。
然后在返回insert返回了第一个pair之后,我们通过.first拿到,已经插入/没有插入已经存在节点的iterator。然后通过这个iterator(这里的itearator就已经是map中某个节点的迭代器了)去访问value(因为value就储存在second中)。
这里还是要知道insert是具有查找和插入两个功能的,无论你是插入成功还是失败,无非成功返回的是新插入的key对应的迭代器。而失败返回的是已经存在的key所对的迭代器。
通过这个key的迭代器就能够修改value了。这里调用second的时候不能是点(.),因为这个iterator是自定义类型的迭代器,所以需要使用->去访问。
下面我们就能够总结[]的功能了
而在用的角度map的重点就是[]。
而multimap其它方面和map是一样的,但是不支持[]
因为在multimap中运行多个key,而在insert那里就不知道要返回哪一个key的迭代器,所以multimap是不支持[]的。其余函数基本和map是一样的。
写的不好,请见谅。
希望这篇博客能对你有所帮助,如果发现了任何错误,欢迎指出。