STL学习笔记

前言

很久很久之前,有一个OIer叫KsCla。

他自称数据结构选手,熟读Heap,Treap等数据结构,代码正确率也不低。做题的时候他一看见数据结构题就兴奋,尤其是LCT(虽然他的LCT经常因为一些细节挂掉,比如没有判连通性等等)。相对地,他不会用STL,也没想过学。

由于他平衡树只会Treap和Splay,所以他经常动不动就要用Treap,代码常常几百行。他觉得无所谓,反正他码完之后基本不用调就能A。

直到有一天,他参加了Code+12月月赛,改变了他对STL的看法。

那场比赛的T2,是一道平衡树+Heap。他码了280行,花费了很多时间,这在时长只有3h的比赛中明显代价巨大。然而别人用一个STL的set+优先队列就解决了,代码不过短短几十行,A得飞快。

于是,在颓完一个元旦假期之后,他果断地踏上了STL之旅。


本文主要介绍OI比赛中比较实用的四类容器:vector,set,map和priority_queue(优先队列)及其相关用法。主要目的是给以后的自己复习或对现役OIer,ACMer的学习作参考。其中大部分资料来源于网上dalao的blog或相关书籍,我只是取出其中自认为比较实用的地方进行汇总。

考虑到实际比赛中如果不开O2优化,有可能因为STL的巨大常数而失分,而大部分资料又没有讲时间复杂度和常数问题,只是笼统地概括“效率很高,效率较低”之类的,所以我会在本篇中插入一些代码,并附上它在我校机房老人机的运行时间,各位自行体会。

路过的dalao如有发现本篇错误,或者对某些地方有自己的见解,欢迎提出。


Part0:一些基础知识

①pair:
使用pair需要调用头文件<utility>。不过我在实际写代码的过程中并没有单独调用这个库,而是调用了以下这12个库,就完成了pair的所有操作。可能当中有些库包含了<utility>

#include<iostream>
#include<string>
#include<cstring>
#include<cmath>
#include<cstdio>
#include<cstdlib>
#include<stdio.h>
#include<algorithm>
#include<vector>
#include<set>
#include<map>
#include<queue>

pair可以理解为两个类型打包在一起,这两个类型可以是自定义的结构体。
声明pair:pair <type1,type2> a(val1,val2);
其中type1,type2分别是pair的两个类型,val1,val2作为a的两个初始值。
而使用make_pair函数可以将两个类型打包成一个pair返回,如:a=make_pair(val1,val2);
语句a.first返回一个type1类型,a.second返回type2类型,分别表示a中的两个值。
pair的使用比较常见,如map里数据的存储就是用pair的。


②迭代器:
迭代器可以看成一个指针,不过它指向的不是某个变量或数组中的某个元素,而是容器中的某个元素,迭代器本身就是该元素的地址。而且对于不同的容器,要用和它类型相同的迭代器才可以成功访问其元素。如:

map <int,int> a;
for (map <int,int> :: iterator p=a.begin(); p!=a.end(); p++)
    printf("%d %d\n",p->first,p->second);

上面的语句也展示了迭代器的声明方式。
使用*p可以取出p所指向的元素,而p->first相当于(*p).first,这一点和指针一样。
关于for语句中p++的意义,我将在下面解释。


③其它:

STL中的很多参数都是左闭右开的,即包含头不包含尾。如sort函数:sort(a+1,a+n+1)(其中a是一个int类型的数组),它会让a[1]~a[n]从小到大排序。同时本篇中四种容器的begin(头指针)都指向其第一个元素,而end(尾指针)则指向最后一个元素的下一个位置

使用greater<int>可以将容器内的小于号重载为实际的大于号,从而改变一些存储int类型的容器的排序法则(其它类型也类似)。


Part1:vector

使用vector需调用头文件<vector>

vector的意思是向量,但它的的本质是动态数组。vector在内存中是连续的一段,它有两个属性,一个是当前容量(capacity),一个是当前数组的长度(size),实际容量总是大于等于数组长度。当数组变长,大于了实际容量的时候,vector就会在内存中找更长的连续的一段来存储元素(会把先前的所有元素cpoy过去),当前实际容量也随之扩大。(如果暂时对这段话不理解,可以先跳过,后面会有详细解释)

虽然我之前采用手写链表的形式来模拟只在尾端进行操作的vector,但这样并不能做到 O(1) 地取任意一元素的值,因此vector在某些情况下还是很有用的。

vector的声明方式:

vector <int> a; //声明一个int类型的vector a,一开始为空。
vector <int> a(10); //a的初始大小为10(即a[0]~a[9]已经开好了)。
vector <int> a(10,1); //a的初始大小为10,并且a[0]~a[9]初始值全部为1。
vector <int> a(b); //将vector b作为a的初始值,即a=b。
vector <int> a( b.begin() , b.begin()+3 ); //将b[0]~b[2]作为a的初始元素,其中b是一个vector(注意左闭右开)。
vector <int> a( b+1 , &b[3] );//将b[1]~b[2]作为a的初始元素,其中b是一个数组(注意左闭右开)。

vector的基本操作:

vector <int> a;
a.push_back(x); //在a的末尾插入元素x,a的长度(size)+1。
a.pop_back(); //弹出a末尾的元素,a的长度-1。
a.size(); //返回当前数组长度(即元素个数)。
a.empty(); //bool类型,a无元素时返回true。
a.clear(); //清除a中的所有元素。
a.at(i); //相当于a[i],即取出a[i]的值。
a.begin(); //返回指向a的首个元素的迭代器。
a.end(); //返回指向a最后一个元素后一位的迭代器。
a.front(); //返回a[0]。
a.back(); //返回a[size-1]。

关于调用a[i]的时间,一开始我的很多队友都不太清楚,有的说 O(1) ,有的说 O(log(size)) ,还有的说 O(size) 。然而我认为这是 O(1) 的,因为既然vector是动态数组,那么它调用a[i]的时间应该和在数组中调用a[i]的时间一样。于是我写了个小程序:

vector <int> a;
for (int i=1; i<=100000000; i++) a.push_back(i);
int x;
for (int i=1; i<=100000000; i++) x=a[50000000];

上述代码在本机的运行时间约为2.4s,所以很明显调用a[i]是 O(1) 的。
用同样的方法,可以说明调用a.size()swap(a,b)也是 O(1) 的。交换两个vector只需要将其begin交换即可,所以是瞬间完成的,这一点对于set和map等也一样。

关于vector的比较:
vector的比较采用字符串比较方式,支持==,!=,>,>=,<,<=。vector还支持赋值,即a=b。这些操作的时间均为线性。

顺便说一句,vector允许用a.begin()+x获得指向a[x]的迭代器,这或许是因为vector在内存中是连续一段的。而在set或map中不允许这样。
另外,执行完a.pop_back()之后,空下来的那一位内存将不会被回收,再次调用这个位置的值或者对这个位置赋值也是可以的。a.pop_back()只是相当于将当前尾指针-1。
你还可以试一下在一个vector为空的时候强行pop_back(),我的电脑是没有RE的,并且它会帮你将size减成负数,一直到- 106 都没出现问题。
还有,debug的时候是无法查看vector内的值的,我的电脑一这么做就卡死……


接下来讲一下insert()函数。它是OI比赛中不太可能被调用的函数之一。

a.insert(pos,x); //pos是一个迭代器,表示将x插入pos位置之前。
a.insert(pos,y,x); //在pos前插入y个x。

由于vector是动态数组,所以在某个位置插入,会导致该位置后面的元素整体右移一位,因此所需时间和该位置到a.end()的距离成正比。如果真的要这样做,还是用set或map更好。

但事实上insert也没有想象中的这么慢,我在本机执行以下语句,时间仅需2.8s:

vector <int> a;
for (int i=1; i<=100000; i++) a.insert( a.begin() , i );

然而如果将100000变得更大,所需时间将迅速增长。


接下来的部分将涉及vector的4个不太常用的函数:resize(),capacity(),reserve(),max_size()。虽然它们不太常用,但通过它们我们可以略微看出vector的实际工作原理,从而得到一些启发。

a.reserve(x):它的作用是为当前vector开大小为x的空间。使用完该语句之后a[0]~a[x-1]都可以进行赋值或调用。如果当前vector的大小已经大于等于x,该语句不会起任何效果。如果已经知道了a的最终大小,那么预先申请内存,比一个一个push_back动态开内存要快一些。理论上来说,对于数字y,如果a[y]还没有被开出来,那么赋值或调用a[y]将会RE。

a.capacity():返回a当前的容量,即a已经申请的内存大小。

a.resize(x):将a的数组长度(size)设为x,即截断x-1之后的部分。这条语句相当于将尾指针移到a.begin()+x处,下次push_back()pop_back()都会从x-1开始操作。注意,这条语句并没有改变a的容量大小!

a.max_size():返回当前vector最多能开多大。(我使用的计算机是1073741824,即 230

简单来说,上述函数大概是这么个关系:

完结撒花?并不!

接下来我们执行下列语句:

vector <int> a;
for (int i=1; i<=100; i++) a.push_back(i);
printf("%d\n",a[102]);

你会惊奇地发现它没有RE!于是你将102改成200,发现RE了。
于是你赶快输出了a的容量,发现它等于128。你似乎懂得了什么。

再执行以下语句:

vector <int> a;
a.reserve(100);
printf("%d\n",a.capacity());
for (int i=1; i<=101; i++) a.push_back(i);
printf("%d\n",a.capacity());

发现它分别输出了100和200。这下你终于懂了。

使用reserve的时候,你输入多少vector就帮你开多少,而如果push_back的时候,数组大小超过容量大小,它就为你开多一倍的容量。

为什么会这样呢?
我想起之前lhxQAQ问我一个问题:如果每一次找到新的内存,都要将原来的元素copy一遍,那么时间岂不是 O(size2)
但如果每一次新的容量都是旧的容量的2倍呢?设最终大小为size,那么最终容量不会超过2倍size,而其中有一半被复制了至少一遍,这一半中又有一半至少被复制了2遍……以此类推,copy的总时间不会超过4倍size。这就保证了vector的线性时间复杂度,同时保证了vector的线性空间复杂度。(在此假设每一次找到合适内存的时间小于等于 O(capacity)
这种思想大概可以出一些线性时间的倍增毒瘤题吧,呵呵。


还有二维vector:
它的声明与一维vector类似,例如开一个初始大小为x*y,元素初值全部为z的二维vector:vector< vector<int> > a(x, vector <int> (y,z) );

关于初始化:
reserve()函数目测在此行不通,例如执行以下语句,我的电脑会RE:

vector < vector <int> > a;
a.reserve(10);
for (int i=2; i<=5; i++) a[i].push_back(20);

具体原因我也不太清楚……

如果这样就可以运行:

vector < vector <int> > a;
for (int i=1; i<=10; i++) a.push_back( vector <int> () );
for (int i=2; i<=5; i++)
    for (int j=1; j<=6; j++) a[i].push_back(20);

此时输出a的容量,为16,a[2]的容量为8。

二维vector的其它用法与一维vector基本类似,更高维的vector也一样。


最后来讲一下deque。
deque是系统自带的双端队列,它可以实现 O(1) 地取值,以及在序列的两端插入或删除元素。
虽然deque的取值非常快,但由于它使用多块内存,而且自动清空一块无用的内存,导致它的插入和删除很慢。并且作为一个OIer,为什么要记背那么多东西呢?如果我在考试时要写动态的双端队列,我就会这样写:

struct Deque
{
    vector <int> neg,pos;
    int head,tail;
} ;

即用两个vector模拟。很明显vector的总大小不会超过总吞吐量*2。


Part2:set/multiset/map/multimap

我貌似讲vector讲得太长了点,涉及了一些无关紧要的内容QAQ。接下来set和map的部分就会较为简短些。

使用set或multiset需调用头文件<set>,而使用map或multimap则需要调用头文件<map>

这四种容器本质上都是红黑树,因此它支持平衡树的很多操作。set使用红黑树维护一个集合,这个集合中的元素可以是自定义类型,但该类型必须定义大小关系。map也是维护一个集合,只不过这个集合中的元素是pair<type1,type2>,map中的元素根据type1从小到大排序,type2不参与排序,只是作为一个附加值存在。set和map的元素中参与了排序的部分称为键值。简单来说,set维护键值本身,map多维护了一个附加域type2。set和map均不允许有相同键值,而multiset和multimap则允许。

set的声明:

set <int> a; //创建一个set a。
set <int> a(op); //让a以op作为排序准则(即重定义小于号为op)
set <int> a(b); //让a的初始值等于set b。
set <int> a(L,R); //让区间[L,R)中的元素作为a的初始值,L,R可以是指针,也可以是迭代器地址。
set <int> a(L,R,op); //结合第2,4条。

map的声明和set基本类似,只不过将set <type>换成了map <type1,type2>。另外在第4,5条中,[L,R)必须是pair类型。

set的基本操作:

set <int> a;
a.insert(x); //在a中插入元素x。
a.count(x); //统计a中元素x的个数,set的话只会返回0或1,multiset则有可能返回值大于1。
a.find(x); //返回指向键值为x的元素的迭代器,不存在则返回a.end();
a.erase(x); //删除值为x的元素。
a.erase(pos); //删除pos处的元素,其中pos是个迭代器。
a.empty(); //空返回真。
a.clear(); //清空a。
a.insert(L,R); //将[L,R)处的值插入a中,不返回任何值,L,R可以是指针或迭代器。
a.erase(L,R); //将[L,R)处的值删除。
swap(a,b); //交换a,b两个set,时间为O(1)(要求他们的类型,排序准则都相同)。
a.lower_bound(x); //返回集合中第一个>=x的元素的迭代器,不存在则返回end()。
a.upper_bound(x); //返回集合中第一个>x的元素的迭代器,不存在则返回end()。

set的大小比较仍遵循字典序。

此外,当multiset中有多个x时,find(x)会返回指向第一个x的迭代器。

注意set不能用begin()+x访问第x个元素,这一点和vector不同,可能是因为set在内存中并不是连续的一段。要遍历set中的元素只有从p=a.begin()开始,每一次让p++。这里++的含义,是找到p在红黑树中的后继。这样遍历一次set,就相当于dfs了一次红黑树,时间是 O(n) 的。


关于set的效率问题,可以执行以下代码体会一下:

set <int> a;
for (int i=1; i<=100000000; i++) a.insert(100);
system("pause");
a.erase(100);

我的电脑执行到pause处时用了9s左右,而erase则只用了不到1s。在不同的数据中,erase的速度都比insert要快。


map与multimap的用法,和set基本相同,只不过map的插入和删除都是用pair类型。此外,map还有一个很方便的功能,就是提供下标运算符[]。比如你之前在map <int,int> a中插入了<x,y>。那么当你调用a[x]的时候,它就会用 O(log(n)) 的时间返回y。你还可以直接用a[x]=y来插入或赋值<x,y>(就是当成数组来使用)。不过要注意,如果map中还没有键值为x的pair,那么调用a[x]的时候,map会自动帮你插入一个pair<x,y>,其中y是其类型的初始值。比如int类型,y就为0。

如果set和map存储的是结构体,可以通过重载运算符定义其大小关系。如果要将某个set或map传入子函数(比如Print函数)中,可以使用以下代码:void Print(map <int,int> &a)。在没有存储其它附加域的时候,set和map的每个元素都占16个字节(分别为父指针,左右儿子指针,以及平衡因子)。


Part3:priority_queue(优先队列)

使用priority_queue需要调用头文件<queue>

定义:

priority_queue <int> a; //声明一个存储int类型的优先队列a。
priority_queue < int , vector<int> , greater<int> > a; //声明一个以vector<int>为容器,排序方式为greater<int>的优先队列。(一般的优先队列都以vector为容器)

priority_queue本质上是一个大根堆,它会在堆顶存储最大的元素,要改变顺序可以重载运算符<。另外,声明priority_queue的时候,不能用类型+排序法则的方式声明,否则会有问题。即不能这样写:priority_queue < int , greater<int> > a;

基本操作:

priority_queue <int> a;
a.push(x); //在a中插入元素x。
a.pop(); //弹出堆顶。
a.top(); //返回堆顶元素。
a.size();  //返回a中元素个数。
a.empty(); //a为空时返回真。
//注意,没有a.clear();

priority_queue的子函数不多,但通过一些技巧可以实现更多有用的操作。比如要删除非堆顶元素的时候,可以再开一个堆b,存储删除了的元素。每次取a的最大值和b的最大值,如果相同则一起弹出,再取。修改非堆顶元素则可认为进行了一次删除和一次插入。


Part4:后记

前些日子Ghastlcon向我案例了一个叫做pb_ds的东西(Policy-Based Data Structures,STL扩展),里面好像还有系统自带的Splay。虽然这听起来十分诱人,但我还是没有学这个。主要是因为它封装得太好了,以至于不能做一些手写的Splay的操作,或者用起来比较麻烦。之前Semiwaker也和我说过,有时候set不能做Treap能做的事,我想这大概也是封装的缘故吧。不管怎样,STL在减少代码量和降低调试难度方面还是有很大的好处(尤其是嵌套数据结构的时候),开了O2之后也能节省时间,有优势也有局限性。

可能这篇文章中讲vector的部分太长了,以至于远超其它部分。说实话,当时我只是想了解一下vector的实际工作原理大概是怎样的,没想到就入了个坑。

另外,这篇学习笔记写了我半个月,中间因为有事就停滞了,写后半部分和写前半部分的时间相差了比较久,可能这个原因导致我篇幅比较奇怪吧……

那么,完结撒花!

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值