[技术] OIer的C++标准库 : STL入门

注: 本文主要摘取STL在OI中的常用技巧应用, 所以可能会重点说明容器部分和算法部分, 且不会讨论所有支持的函数/操作并主要讨论 C++11 前支持的特性. 如果需要详细完整的介绍请自行查阅标准文档.

原始资料源于各大C++参考信息网站/C++标准文档和Wikipedia.

博主可能会写一个系列的博文来阐述C++标准库在OI中的应用, 本文为第一篇.

(表示打这个好累的说OwO博主表示手打了好几天才码完这么多字)

1.概述

首先, 什么是STL?

STL, 即标准模板库, 全称Standard Template Library , 主要包含4个组件, 即算法, 函数, 容器, 迭代器. 这里的函数似乎主要指函数式编程(FP)中的函数而不是平常概念中的函数...标准模板库具有强大的可扩展性, 包含很多常用算法/数据结构/常用操作, 极大地增加了代码的重用, 几年前在NOI系列赛中解禁后为C++语言选手提供了很多便利.

但是需要注意的是, STL并不等同于C++标准库. C++标准库除了STL中的容器/迭代器/算法之外还包含语言支持/输入输出/字符串/诊断/一般工具/数值操作/本地化等内容, 虽然其中很多也会用到模板, 但是它们并不属于STL. STL应为C++标准库的子集.

为了与用户的命名区分, STL中的内容都在  namespace std 中, 可能许多人会偷懒使用 using namespace std; , 但是博主个人不建议大家使用这种做法. 因为这样可能会造成潜在的访问元素歧义. 因为在使用该语句后, 如果在全局也有声明同名变量/函数且调用时未显式指定命名空间为全局或 std 的话就可以同时解释为调用标准库或访问自己定义的标识符. 容易出现欧洲合格认证(CE)的情况w

(顺便说一句其实C++里的模板是图灵完全的, 据说有dalao可以用这个在编译期可以打出表来)

2.内容

STL的逻辑是"将待操作的数据与要进行的操作分开", 于是便有了如下的三个概念:

1.容器: 用于存放数据的

2.迭代器: 用于指出数据的位置, 或成对使用来划定一个区间 (注: STL区间一般都是左闭右开的半开区间.)

3.算法: 要执行的操作.

2.1 容器

STL中, 容器是"包含/放置数据的地方". STL中的容器使用的都是堆内存, 而且大多数情况下支持动态分配(不会写指针和动态内存的选手の福利)所以内存利用率多数都很高.

多说几句, 对于STL容器的对象数组坚决不能memset.....STL 容器所存储的静态内存内部数据都是指针或者其他标志位, memset之后直接乱掉然后没法用了w

容器又分以下几类:

2.1.1 顺序容器

顺序容器中保存的是一个有序的数据集合, 实现能按顺序访问的数据结构.

也就是说你放进顺序容器的数据是保持原来放进去时的顺序的. 关联容器内部实现是平衡树, 只能按元素从大到小访问.(当然也有时候我们确实也希望这么做w所以要分两类嘛(雾))

STL中的顺序容器包含以下5个:

 std::vector  std::list  std::forward_list  std::deque  std::array 

由于 std::list 和 std::forward_list 就是链表平常手写一个也不费事功能过于简单而且时间复杂度常数偏大OI中一般不使用所以略(其实是博主懒癌发作)

然后 std::array 是C++11起支持的特性, 提供一个更加安全的C风格数组(然而这个是定长的...除了比较安全以及可以快速获取长度/剩余空间等等信息之外和普通数组似乎没卵区别)所以也略(逃)

2.1.1.1 std::vector

 std::vector 定义于头文件  , 在OI中一般用于作为变长数组. 元素在其中连续存储, 也就是说不仅按下标随机访问是严格 $O(1)$ 时间复杂度, 而且除了迭代器外, 常规指针也可以用于访问它的内容. 最重要的是在尾部插入/删除元素的均摊时间复杂度为 $O(1)$ ,在OI中无需担心时间复杂度问题(虽然可能有丧病出题人卡你常数233).

其实它也支持在首部和中间插入数据, 但是时间复杂度 $O(n)$ ...

vector似乎是用了一些启发式的奇技淫巧内存管理方式, 每次预分配一定的空间, 当预分配的这部分被用完时开一块新的连续内存并将所有原来的元素copy过去. 当预分配大小经过仔细计算后就可以做到尾部插入元素操作均摊 $O(1)$ 的时间复杂度.

模板参数如下

1 template<
2     class T,
3     class Allocator = std::allocator
4 > class vector;

第一个参数 class T 指定元素类型, 第二个参数 class Allocator 指定内存分配方式(这个参数有默认值所以平常一般不用管, 除非手写了个内存池出来让STL用)

例:声明一个成员为 int , 内存分配方式默认的 std::vector 的代码如下:

std::vector<int> v;

然后是 std::vector 所支持的操作(成员函数):

1. std::vector::operator=  $O(n)$

将另一个vector中的所有值copy进该vector(平常似乎没卵用)

2. std::vector::at  $O(1)$

这个函数用来按下标访问vector中的值(返回引用), 参数为一个下标. 与用方括号的效果基本上是等价的. 但是用这个函数来访问vector中的值之前会先检查一下下标是否越界, 如果越界则抛出异常 std::out_of_range , 控制台也会输出相关信息比如所在行号所在文件等等. 如果用方括号的话就可能出现各种玄学错误比如修改了其他变量的值或者破坏内存结构影响以后对内存的访问, 再或者直接崩溃RE... 

3. std::vector::operator[]  $O(1)$

最常用的操作, 按下标访问vector中的值. 当做数组来用233  复杂度$O(1)$

4. std::vector::front  $O(1)$

返回vector首部元素的引用. 用于对首部元素进行修改/读取. 注意如果是空vector的话访问结果是未定义行为(也就是天晓得会发生什么, 因平台和编译器而异)

5. std::vector::back  $O(1)$

返回vector的尾部元素的引用, 用于对尾部元素进行修改/读取. 注意这次STL没有日常左闭右开.

6. std::vector::data  $O(1)$

这个函数返回的是指向vector首部元素的指针(不是迭代器. 不是迭代器. 重要的事情说三遍, 不是迭代器) , 比如vector内部元素的类型为 $T$ , 则此函数的返回值类型为 $T*$.

7. std::vector::begin  $O(1)$

返回指向vector首部元素的迭代器. 迭代器类型为随机访问迭代器(具体参见下文).

8. std::vector::end  $O(1)$

返回指向vector尾部元素的后一个元素. STL的日常左闭右开区间的结果=w=

9. std::vector::rbegin  $O(1)$

返回指向vector尾部元素的反向迭代器. 倒序枚举专用(雾) (其实貌似等于一个自增时实际下标自减的向前迭代器?)

10. std::vector::empty  $O(1)$

如果vector为空的话返回true, 否则为false. 常用于各种while循环里 (诶好像stack/queue/priority_queue的empty操作更常用在while里吧)

11. std::vector::size  $O(1)$

返回vector中包含的元素个数.

12. std::vector::resize $O(n)$

这个函数参数为一个整数, 可以调整vector的大小使其可以容纳参数指定的元素.

13. std::vector::clear $O(n)$

将vector清空.

14. std::vector::push_back  均摊 $O(1)$

向vector尾部插入一个值. 除了下标访问操作之外最常用的233

15. std::vector::pop_back  均摊 $O(1)$

删除vector尾部的值.

16. std::vector::push_front  $O(n)$

向vector首部插入一个值. 因为要移动整个vector中的元素所以复杂度为 $O(n)$. 很少使用. 对应的 std::vector::pop_front 也是同一个时间复杂度, 原因一样.

17.六种大小比较运算符 != == > >= < <=  $O(n)$

按字典序比较两个vector中的内容.

2.1.1.2 std::deque

 std::deque , 定义于头文件  , 相当于一个普通的双端队列. 但是与  std::queue 不同的是, 它不仅仅可以访问两端的元素, 内部的元素也可以访问与修改. 而与 std::vector 的不同之处在于, deque内部的存储是不连续的. 这给了deque在两端插入/删除元素时的严格 $O(1)$ 时间复杂度. 但是非连续的存储结构也使得deque中的随机访问与插入删除的复杂度猛增至 $O(n)$ . 所以deque在OI中基本上只是用于作为 std::queue 的一个扩充(毕竟这个可以在不弹出所有元素的情况下遍历内部的元素而且两端都可以插入/删除).

1. std::deque::front  $O(1)$

返回deque首部元素的引用, 用于对首部元素进行修改/读取. 同vector.

2. std::deque::back  $O(1)$

返回尾部元素的引用, 同vector.

3. std::deque::operator[]  $O(n)$

(港真一直以为deque和queue很像的我第一次看见这个操作表示震惊) 按下标访问元素, 返回引用, 同vector.

(实际上deque也支持通过 std::deque::at 来访问内部元素, 复杂度 $O(n)$, 用法和特性同vector)

4. std::deque::begin  $O(1)$

返回指向首部元素的迭代器, 同vector.

5. std::deque::end  $O(1)$

返回指向尾部元素后一个元素的迭代器. 同vector.

6. std::deque::rbegin  $O(1)$

(博主突然发现到目前为止一直都和vector一样诶...)

(突然懒癌发作) 同vector.

7. std::deque::insert  $O(n)$

插入若干值. 第一个参数为下标, 可以是代表下标的整数也可以是迭代器. 后面的参数是要插入的信息. 可以是一个值, 也可以是一个区间.

(vector其实也支持, 复杂度一样)

8. std::deque::erase  $O(n)$

删除若干值. 可以通过一对下标/迭代器指定区间, 也可以通过一个下标/迭代器和长度来指定一个区间.

(vector其实也支持, 复杂度也是一样=w=但是估计并没人用)

9. std::deque::push_back  $O(1)$

在尾部插入一个值. 同vector

10. std::deque::push_front  $O(1)$

在首部插入一个值. 同vector.

11. std::deque::pop_front  $O(1)$

删除首部的值. 同vector.

12. std::deque::pop_back  $O(1)$

删除尾部的值. 同vector.

13.六种大小比较运算符 != == > >= < <=  $O(n)$

按字典序比较两个deque的内容或者判断二者中的数据是否相同.

2.1.2 关联容器

关联容器的内部实现一般都是一个平衡树. 可以实现 $O(log(n))$ 时间复杂度的查找操作. 包括 std::set  std::map  std::multiset  std::multimap . 其中前面两个容器自带去重buff, 后面的是给那些可能有重复元素或者要计数的应用场所设计的.

关联容器的组合有时候可以得到非常强的效果. 毕竟内部有一个封装好的平衡树(

 std::set / std::map 分别和 std::multiset / std::multimap 对应, 支持的操作完全相同, 只是内部存储时的区别而已. 故省略multiset和multimap (懒癌再次发作)

2.1.2.1 std::set

STL提供的集合容器. 具有自动去重/自动排序等buff性质, 内部实现为平衡树所以可以拿来当树套树的次级树来偷懒(雾), 自动去重与排序后的结果可以使用迭代器在 $O(nlog(n))$ 的总时间复杂度内遍历. 配合迭代器与算法库食用可滋磁的操作还有查前驱/后继/K大等等操作.

常见操作如下:

1. std::set::begin  $O(1)$

返回指向首部元素的迭代器. 与多数STL容器相同.

2. std::set::end  $O(1)$

返回指向尾部元素的下一个元素的迭代器. STL日常左闭右开区间.

3. std::set::rbegin  $O(1)$

返回指向翻转后的区间的首部元素的迭代器. 可以用于快速访问尾部元素.

4. std::set::empty  $O(1)$

返回set是否为空

5. std::set::size  $O(1)$

返回set中的元素个数.

6. std::set::clear  $O(n)$

清空set中的数据.

7. std::set::insert  插入一个值为$O(log(n))$, 传入位置参考迭代器且位置参考有效则为均摊 $O(1)$ , 插入区间为 $O(dis*log(dis+n))$ (dis为区间长度)

向set中插入数据. 可以是一个值或者一个区间. 对于插入一个值还可以传入一个位置参考迭代器. 若插入刚好发生在位置参考迭代器的左侧或右侧则可以将复杂度优化至均摊 $O(1)$. 

8. std::set::erase  删除指定值为$O(log(n))$ , 删除指定迭代器指向的结点为均摊 $O(1)$ , 删除区间为 $O(log(n)+dis)$ (dis为区间长度)

从set中删除数据. 可以通过值/迭代器来定位一个元素. 或者通过两个迭代器来指定一个区间. 需要注意的是对于multiset, erase一个值代表的是erase掉所有等于这个值的数据而不是只删除一个. 对于multiset若要删除一个元素只能选择传入迭代器.

9. std::set::count  $O(log(n))$

在set中查找传入的值并返回该值在set中的个数. 对于set来说可以判某值是否存在于set中.

10. std::set::find  $O(log(n))$

在set中查找传入的值病返回指向查找到的值的迭代器. 若未找到则返回该set的 end() 

11. std::set::lower_bound  $O(log(n))$

返回指向第一个不小于给定值的迭代器. 或者说指向第一个"可插入位置".

这里要记住不要用形如 std::lower_bound(s.begin(), s.end(), value); 来调用算法库里的相应函数. 因为迭代器移动需要 $O(log(n))$ 的时间复杂度, 而二分查找又要 $O(log(n))$ 的复杂度, 然而set的迭代器不是随机访问迭代器所以二分的时候变成了 $O(n)$ 的...然后你的复杂度就多乘了一大坨东西w目测搜索一次的时间复杂度是 $O(nlog^2(n))$  ...(还记得某学长胡策的时候就被这个坑了OwO欢声笑语中打出GG)

12. std::set::upper_bound  $O(log(n))$

返回指向第一个大于给定值的迭代器. 或者说指向最后一个"可插入位置".

注意事项同lower_bound

13. 六种大小比较运算符 != == > >= < <=  $O(n)$

按字典序比较两个set中的内容.

2.1.2.2 std::map

STL映射.必须指定的模板参数有两个: 下标的类型与数据的类型. 由于重载了 operator[] 所以在代码字面上可以当做一个下标不连续/下标可以是任何可比较类型数组来使用(下标是 std::string 都没人管你), 有时用于离散化.

内部实现是 std::pair 加上一个平衡树. 下标为第一关键字, 值为第二关键字扔在平衡树里. 因为底层元素是pair用迭代器查值时要用形如  (*it).first   it->first 这样的语句获取该结点对应的下标,   (*it).second  it->second 来获取该结点存储的值.

1. std::map::operator[]  $O(log(n))$

按下标访问map中的元素. 若该下标不存在则自动新建结点保存数据, 若存在则返回对对应数据的引用供读取/修改.

2. std::map::begin  $O(1)$

标准容器函数(雾) 返回指向首元素的迭代器

3. std::map::end  $O(1)$

返回指向尾元素下一位置的迭代器

4. std::map::rbegin  $O(1)$

返回指向翻转后区间的首元素(即原区间的尾元素) 的迭代器

5. std::map::empty  $O(1)$

返回容器是否为空.

6. std::map::size  $O(1)$

返回容器中元素的个数.

7. std::map::clear  $O(n)$

清空map中的所有数据.

8. std::map::insert  插入一个值为$O(log(n))$, 传入位置参考迭代器且位置参考有效则为均摊 $O(1)$ , 插入区间为 $O(dis*log(dis+n))$ (dis为区间长度)

插入一个值. 这个值为下标, 整个insert的意义可以解释为"为参数所提供的下标分配映射所需空间". 支持单点/区间.

9. std::map::erase  删除指定值为$O(log(n))$ , 删除指定迭代器指向的结点为均摊 $O(1)$ , 删除区间为 $O(log(n)+dis)$ (dis为区间长度)

删除一个值与它对应的映射值. 可以删除单点/区间.

10. std::map::count  $O(log(n))$

11. std::map::find  $O(log(n))$

12.六种大小比较运算符  != == > >= < <=  $O(n)$

2.1.3 无序关联容器

包括 std::unordered_set  std::unordered_map  std::unordered_multiset  std::unordered_multimap 四个, 具体用法和与之对应的关联容器基本相同. 内部基于Hash来进行元素查找, 均摊时间复杂度 $O(1)$ , 最坏时间复杂度 $O(n)$ , C++11起可用. C++11前G++的STL实现了 std::hash_set  std::hash_map 这样的非标准规定的容器, 当数据随机时可以用这些来将查找优化到均摊 $O(1)$ (但愿不会有丧病出题人专卡STL的Hash算法吧qwq)

2.1.4 容器适配器

包括栈/队列/优先队列这样的数据结构, 提供顺序容器的不同接口(也就是说基于顺序容器构建). 这三种数据结构分别对应于 std::stack  std::queue  std::priority_queue .

和手写栈/队列相比有自动管理内存的优势. 优先队列基本上就拿来当堆用了w

容器适配器都基于顺序容器作为底层容器, 所以各种操作的时间复杂度与底层容器的对应操作相等. 以下未特殊说明的时间复杂度均为底层容器默认时的时间复杂度

 std::stack 和 std::queue 默认使用 std::deque 作为底层容器. 而 std::priority_queue 则默认使用 std::vector 作为底层容器.

然而这三类容器适配器都丧心病狂地不支持 clear (清空操作) 

2.1.4.1 std::stack

字面意思, 就是个栈. 除了各种基本操作外还非常"良心"地支持了两个栈之间的赋值操作=w=. 

话不多说, 直接上成员好了w

1. std::stack::top  $O(1)$

用于访问栈顶元素. 返回的是引用所以理论上也可以用于修改栈顶元素.

2. std::stack::empty  $O(1)$

STL容器常见用法, 返回该栈是否为空.

3. std::stack::size  $O(1)$

STL容器常见用法, 返回该栈中所存储的元素个数.

4. std::stack::push  $O(1)$

将一个作为参数的值压入栈顶.

5. std::stack::pop  $O(1)$

将栈顶元素弹出(不返回栈顶元素的值)

6. std::stack::swap  与交换底层容器一致, 当容器是 std::array 时 $O(n)$, 否则 $O(1)$

将该stack中的内容与另一个stack交换.

7.六种大小比较运算符( != == > >= < <= ) $O(n)$

按照字典序比较两个栈中的内容

2.1.4.2 std::queue

字面意思, 就是个队列. 同样除了push/pop这些基本操作外"良心"地支持了两个queue间的赋值与字典序比较.

1. std::queue::front  $O(1)$

访问队首元素. 返回引用, 一般也可用于修改.

2. std::queue::back  $O(1)$

访问队尾元素(这次也不是左闭右开), 日常引用.

3. std::queue::empty  $O(1)$

熟悉的操作, 判定队列是否为空.

4. std::queue::size  $O(1)$

依然是熟悉的操作, 返回队列中元素的个数.

5. std::queue::push  $O(1)$

向队尾插入一个元素.

6. std::queue::pop  $O(1)$

删除队首元素(不返回队首的元素值)

7. std::queue::swap  <同stack> (博主懒癌再次发作)

交换两个队列中的所有信息.

8.六种大小比较运算符 != == > >= < <=  $O(n)$

按字典序比较两个队列中的信息.

2.1.4.3 std::priority_queue

字面意思, 优先队列. OI里一般当做一个封装好的堆来用. 模板参数按顺序是:元素类型/底层容器类型(默认std::vector)/比较方式 , 在这时可以自定义比较函数, 但是要先指定底层容器w. 对于如何扩展它的功能请看最下面的STL使用技巧. (突然发现优先队列原生滋磁的操作是容器适配器里最少的OwO)

1. std::priority_queue::top  $O(1)$

访问堆顶元素(优先队列默认为大端堆, 即最大的元素在队首), 不过要注意是top不是front...

2. std::priority_queue::empty  $O(1)$

熟悉操作++, 判断队列是否为空.

3. std::priority_queue::size  $O(1)$

返回队列中的元素数量.

4. std::priority_queue::push  $O(log(n))$

将一个值插入优先队列中.

5. std::priority_queue::pop  $O(log(n))$

将优先队列队首元素弹出(同样不返回队首元素的值/迭代器)

6. std::priority_queue::swap  <同stack>

交换两个优先队列中的信息.

 

2.2 迭代器

迭代器是用于指向容器中的数据的类, (虽然平常的操作似乎和指针没啥区别, 也支持 operator* 和 operator-> w) 不同的容器根据其内部结构与实现原理的不同也会提供功能不同的迭代器. 迭代器有五种类型(C++17标准及以后是六种, 增加了一个连续迭代器的概念), 分别是输入迭代器 输出迭代器 向前迭代器 双向迭代器 随机访问迭代器. 这些迭代器按照支持的操作的不同来分类.

STL中某种容器对应的迭代器类型可以直接在容器类型名(带有模板参数)后加 ::iterator 来使用. 比如对于 std::set<int> , 则对应的迭代器类型名为 std::set<int>::iterator .

同时满足输出迭代器和四种迭代器之一二者的定义的迭代器也被称为可变迭代器. 不满足输出迭代器定义的也被称为不可变迭代器常迭代器.

似乎可以认为迭代器是个容器中专用的指针...?

毕竟容器中的存储结构不一定连续, 所以当指针的位置发生移动时实际的下一个元素所在的内存可能并非刚好在上一个元素之后, 而是经过一定的算法计算出来的. 这也就造成普通指针在容器中无法使用. 这时我们可以选择使用运算符重载特性来在偏移迭代器时执行对应的算法, 保证指向内存的合法性.

注:迭代器都可以支持形如这样的操作:  *it  ++it 

至于分类可以理解为不同类型的迭代器一般有不同的定位自由度. 下面我们从限制最多的一直到最自由的开始说.

2.2.1 输入迭代器

输入迭代器只能一直自增, 并通过这样的方式遍历容器. 不过输入迭代器比迭代器的基础定义多了一个功能: 判等. 但是有些丧病的是在这个迭代器自增之后, 原来指向的元素可能失效. 这种情况可能发生在你试图复制一个迭代器作为备份, 并将原来的迭代器自增, 则自增后这个备份所指向的内容不保证合法性. 而且不保证可以根据这个迭代器合法地向这个迭代器所指向的内容进行写入.

2.2.2 向前迭代器

向前迭代器和输入迭代器一样也只能进行自增且可以判等, 但是向前迭代器比输入迭代器多了一层保证: 迭代器自增后原位置依然合法.

严格来说这个东西叫做多趟保证, 具体定义摘一下:

多趟保证

给定 a 和 b ,类型 It 的可解引用迭代器

  • 若 a 与 b 比较相等( a == b 可语境转换到 true ),则要么都是不可解引用,要么 *a 与 *b 是绑定到同一对象的引用
  • 通过可变 ForwardIterator 迭代器赋值不能非法化该迭代器(隐含地因为 reference 定义为真引用)
  • 自增 a 的副本不改变从 a 读取的值(正式而言,要么 It 是无修饰指针类型,要么表达式 (void)++It(a), *a 等价于表达式 *a )
  • a == b 隐含 ++a == ++b

2.2.3 双向迭代器

双向迭代器首先满足向前迭代器作为前置条件, 然后如字面意思所言, 双向迭代器不仅可以支持单方向的自增操作, 还支持反方向定位的自减操作. 上面所述的三种迭代器都是一次只能定位一个元素的距离.

2.2.4 随机访问迭代器

随机访问迭代器是自由度最大的, 可以指定整数偏移量来任意移动定位迭代器并访问元素. 而且对于两个随机访问迭代器还可以相减求二者之间相距的元素个数, 判断两个迭代器的前后位置关系(大于/小于/等于/不大于/不小于等运算符)

2.2.5 输出迭代器

输出迭代器的概念其实只要满足是迭代器而且这个迭代器所指向的内容可以写入数据即可.

2.2.6 迭代器综述

虽然不同类型的迭代器有不同的限制, 但是这些限制并不是没有理由的, 比如对于 std::set , 它的内部实现是一棵平衡树, 而且没有实现名次树的功能, 所以能保证时间复杂度为 $O(log(n))$ 的操作只有查询该迭代器的前驱与后继. 所以它的迭代器是一个双向迭代器. 而 std::vector 由于它连续存储的特性所以可以支持随机定位, 比如位置 $+5$ 或者 $-7$ .

其实与其说不同种类的迭代器有不同的限制, 不如说不同种类的迭代器提供了自己对某种操作是否支持的保证. 比如需要用到二分查找的函数需要随机访问迭代器作为参数, 因为只有这样才能取到最中间的元素位置, 当传入的迭代器不满足随机访问迭代器的要求时查找就会退化为线性时间复杂度; 而像 std::for_each 这样的函数则只需要一个满足前向迭代器的输出迭代器就行了. (不过二分查找好像不必是输出迭代器233)

附:迭代器使用示例:

 1 #include 
 2 #include 
 3 
 4 int main(){
 5     std::vector<int> v={
     1,9,2,4,7,5,6,8};
 6     for(std::vector<int>::iterator i=v.begin();i!=v.end();++i){
 7         std::cout<<*i<<std::endl;
 8     }
 9     return 0;
10 }

 

2.3 算法库

C++算法库中提供大量用途的函数(例如查找、排序、计数、操作),它们在元素范围上操作. 虽然算法库中的很多函数都可以比较快地手写出来, 但是有时候正确使用C++算法库可以大幅精简OI代码并减少在这些基础部分的无意义低错(而这些错误常常需要很长时间的debug才能发现.), 而且算法库中的算法往往进行过一些特殊优化使得库函数常常比手写快(STL和编译器可能有不为人知的py交易). 注意范围定义为左闭右开区间 [first, last) , 其中 last 指向要查询或修改的最后元素的后一个元素.

库函数大多都能吊打手写, 就算你自以为用了一些卡常的奇技淫巧也依然没有任何卵用. (毕竟人家有交易嘛(大雾))

多数涉及比较或者判定的库函数都支持自定义比较函数或者判定函数, 一般应该选择传const引用或者传值. 传值因为要复制所以一般有额外开销, 建议使用const引用.

下面仅介绍算法库中的部分OI常用函数(不然太多根本没法写QAQ)

1. std::for_each 

接受三个参数: 两个迭代器用于指定操作区间, 最后一个函数对象用于执行操作. FP专属操作的味道

2. std::count  std::count_if 

分别接受三个参数, 前两个都是两个迭代器用于指定区间, 前者的第三个参数为一个值, 返回该值在指定区间内出现的次数; 后者的第三个参数为一个函数对象, 接受一个对应的值并返回 bool 值. count_if 对于指定区间内令指定函数返回 true 的值进行计数.

3. std::fill 

三个参数, 前两个迭代器划分左闭右开区间, 第三个为一个类型对应的值. 作用为将指定区间内的对象全部设置为指定值. 据说内部有多路优化所以比自己写循环要快而且似乎不会像memset一样破坏STL及其他类的数据结构?

4. std::fill_n 

三个参数, 第一个为一个迭代器指定区间左端, 第二个为要修改的元素数目, 第三个为要修改的值. 与 std::fill 功能类似.

5. std::transform 

将一个或两个区间内的值分别传给一个指定的函数并将计算结果保存在另一个区间内的对应位置.

两个重载, 分别对应一元函数与二元函数. 先是两个迭代器指定参数区间, 二元函数还要再加一个迭代器指定第二个参数的区间左端. 然后是一个迭代器指定要保存结果的区间的左端, 最后是函数对象.

6. std::generate 

用一个函数的返回值填充一个区间. 先是两个迭代器划分区间, 第三个参数为一个函数. 函数的返回值将被用于一个一个地写入指定区间内.

7. std::generate_n 

与 std::generate 功能类似, 不同的是由两个迭代器划分区间改为由一个迭代器和一个计数器划分区间.

8. std::remove  std::remove_if 

分别接受三个参数, 先是照例两个迭代器划分区间, 第三个参数前者是一个值, 后者是一个函数. 前者将值与参数相等的元素删除掉, 后者将使函数返回 true 的元素删除掉. 由于删除后区间会变短所以返回删除部分元素后新的右端点(依然遵循STL的左闭右开原则).

9. std::swap 

接受两个类型相同的参数, 并将它们的值交换. 这个库函数跑得比谁都快, 不要想着自己搞个奇技淫巧就能卡常.

10. std::iter_swap 

和 std::swap 功能类似, 不同的是本函数接受的参数是指向待交换变量的迭代器.

11. std::reverse 

接受一对迭代器指定的区间然后暴力翻转w

12. std::rotate 

以轮转的方式旋转指定区间. (即滑动该区间, 并将滑出去的元素补到开头OwO) 第三个参数为一个迭代器, 指定要旋转到左端的元素位置.

13. std::random_shuffle 

接受一对迭代器指定的区间, 对区间里的值进行随机打乱, 防Hack&随机化&造数据时的好帮手OwO

14. std::unique 

给区间去重. 但是只去重连续的重复元素. 即区间 8 2 5 4 4 4 6 3 中该函数会将三个 $4$ 去重为 $1$ 个, 但是区间 8 2 4 5 4 6 4 中的三个 $4$ 不会被去重.

可以先 std::sort 排个序把相等的元素集中起来再去重.

15. std::partition 

重排区间. 接受一对指定区间的迭代器与一个函数. 区间内所有使一元函数返回 true 的值都排在返回 false 的值之后从而将其分为两组. 此重排过程不稳定.

16. std::stable_partition 

 std::partition 的稳定版本, 保证同一组内的元素的相对次序保持不变.

17. std::sort 

STL里极其强劲的函数... 可以根据数据自我调整排序方式...

指定一个区间即可. 要求区间内的元素重载了 <  运算符或者传入一个自定义比较函数.

数据量小, 快排优势体现不出来? 没事, 我们用插入排序.

数据量大而且比较随机? 快排走起.

数据量过大递归过多? 上堆排序

数据原始位置不好结果把快排卡到了接近 $O(n^2)$ ? 接着堆排上.

不可能有哪个手写排序能打败这个库函数的.

此排序不稳定.

18. std::stable_sort 

 std::sort 的稳定版本, 内部在内存足够的情况下使用 $O(nlog(n))$ 的多路归并排序, 内存不足时使用 $O(nlog^2(n))$ 的次优算法.

保证相等的元素相对次序不变.

19. std::lower_bound 

只能在有序区间上操作.

指定一个区间和一个待查找的值, 可选参数为自定义比较函数, 返回指向第一个不小于给定值的元素的迭代器. 或者说指向"第一个可插入位置".

20. std::upper_bound 

只能在有序区间上操作.

指定一个区间和一个待查找的值, 可选参数为自定义比较函数, 返回指向第一个大于给定值的元素的迭代器. 或者说指向"最后一个可插入位置".

21. std::nth_element 

指定一个区间和值 $n$ , 该函数将会以第 $n$ 大的值为分割点, 将所有大于第 $n$ 个值的元素放在第 $n$ 个元素之前, 所有小于第 $n$ 个值的元素放在它之后, 第 $n$ 大的元素将刚好在第 $n$ 个值的位置上.

可传入自定义比较函数.

22. std::merge 

合并两个有序区间.

前四个参数为指定操作区间的两对迭代器, 第五个参数为输出区间的左端迭代器. 可传入自定义比较函数.

23. std::is_heap 

判断一个区间里的元素是否满足堆性质.(大端堆)

STL对区间大端堆的定义:

对于区间 $[f,l)$, 设 $N=l-f$, 则:

\[\forall \: i \in (0,N) , f[\left \lfloor \frac{i-1}{2} \right \rfloor] \geq f[i]\]

可传入自定义比较函数.

24. std::make_heap 

在 $O(n)$ 时间复杂度内构造一个大端堆. 参数为指定操作区间的一对迭代器. 可传入自定义比较函数.

25. std::push_heap 

在 $O(log(n))$ 时间内插入一个数到建好的堆中. 待插入的值应该置于区间尾部, 原来的堆应位于 $[l,r-1)$

26. std::pop_heap 

在 $O(log(n))$ 时间内将堆顶元素弹出并放到原来堆的尾部.

27. std::sort_heap 

在 $O(n)$ 时间内建立一个堆并通过反复调用 $pop_heap$ 实现堆排序. 总时间复杂度 $O(nlog(n))$

28. std::max 

参数为两个类型相同的变量, 返回其中的较大值.

可传入自定义比较函数.

比自己用三目运算符手写再inline快.

29. std::max_element 

参数为一对迭代器指定的区间, 返回区间中最大元素的值.

可传入自定义比较函数.

30. std::min 

参数为两个类型相同的变量, 返回其中的较小值.

可传入自定义比较函数.

比手写快系列.

31. std::min_element

参数为指定操作区间的一对迭代器,  返回区间中最小元素的值.

可传入自定义比较函数.

32. std::next_permutation 

参数为指定操作区间的一对迭代器, 这个函数会计算区间内数据的下一个排列.

若有下一个排列返回 true , 否则返回 false 

33. std::prev_permutation 

基本同 std::next_permutation , 不同的是这个函数计算上一个排列并返回上一个排列是否存在.

3. 技巧

STL中的各种元素按照一定的方式配合起来使用功能可以变得十分强大, 下面介绍一些博主见到或者使用过的奇技淫巧使用方式.

3.1 离散化

我们可以使用 std::sort  std::unique 和 std::lower_bound 配合使用来实现离散化. 首先复制一份待离散化的数据到另一个数组, 然后对该数组进行排序并去重, 然后可以选择 $O(nlog(n))$ 预处理至一个数组保存并在 $O(1)$ 时间内相应查询, 或者不预处理每次直接 $O(log(n))$ 查询. 查询与预处理的过程都是拿数据在排序并去重后的数组中二分查找, 然后将返回的指针减去数组起始位置的指针来获得下标. 这个下标的值即为离散化后的值.

3.2 快速清空容器适配器

如上文所述, 容器适配器都**丧病地不支持清空操作, 这时很多OIer为了清空就会写出酱婶的袋马:

while(!q.empty()){
    q.pop();
}

看起来就很低效的样子(虽然对于栈和队列来说时间复杂度达到了理论下界, 但是常数显然比直接清空要大, 至于优先队列就直接从 $O(n)$ 挂成 $O(nlog(n))$ 了...)

这种时候就应该上我们的万能STL容器清空函数(雾)

template<class T>
void Clear(T& x){
    T tmp;
    std::swap(tmp,x);
}

简洁, 清晰(大雾)

可以看到我们在函数内声明了一个局部对象 $tmp$, 该对象类型与参数相同. 这个新建的容器必定为空, 所以我们交换了两个对象的值. (注:由于STL容器都特化了 std::swap 的实现所以不用担心时间复杂度问题) 然后巧妙的地方在于, $tmp$ 在函数执行结束后自动析构, 也就是带着 $x$ 原来的值自动析构了...

然后由于我们传的是引用所以我们华丽丽地获得了一个新的空的容器 $x$

3.3 快速删除堆/优先队列队中的元素

有时候我们会使用堆或者优先队列来进行一些扫描/贪心/优化等过程, 然而有时候我们可能会需要删除堆中的元素而非堆顶. 然而STL的堆并不提供这些函数, 而手写堆似乎也不好实现. 这时我们可以采取一些奇怪的思路: 建立一个 "垃圾堆" , 每次要删除堆中的某个元素时直接压入垃圾堆顶, 而从原堆堆顶取元素时判断一下:

1.如果两堆堆顶相等则直接同时弹出;

2.如果原堆堆顶小于垃圾堆堆顶则说明曾经有一个试图删除的值在原堆中不存在, 弹掉垃圾堆堆顶;

3.如果原堆堆顶大于垃圾堆堆顶, 原堆堆顶保证存在, 该值即为真正的堆顶.

以上算法正确性应该不难理解. 此处不再给出详细证明(其实就是懒)

3.4 STL性能相关

可能许多OIer都有一个错误的观念: STL慢出翔, 除了一个 std::sort 可以吊打手写之外经常被卡常

卡常出题人本就该被挂起来裱, 神特么有理有据的底层优化(大雾)

然而真实结果是: 不.

为什么STL平常看起来那么慢?

STL容器,只要会给你迭代器用的,都会在debug模式下内置一个线程安全的链表,对内置一个链表,用来保存送出去的迭代器,每次你的迭代器操作都会进这个链表里线性扫描一遍,检查下你的迭代器是否合法,线程安全的哦,有锁的哦,这个链表还自带allocator的哦

STL算法,只要用了比较器的,debug运行时,每次调用之前,都会对你的比较器进行几次余先验证,验证比较器对于这一组数据是不是真满足偏序关系/等价关系

STL容器本身用了各种traits技术和函数重载进行转发,高度依赖编译内联优化

作者:匿名用户
链接:https://www.zhihu.com/question/51650118/answer/126855487
来源:知乎

(摊手)

关掉Debug并开-O2之后基本不可能有谁的手写代码能做到STL的速度.

还好, 现在各路OI比赛都开始向前看并滋磁O2优化了而且优化之后跑得比港记快到不知哪里去了

配合HE省超神评测姬基本无敌的存在233333 ( $10^{10}$ 循环只跑 $4s$ 的六代i5就问你怂不怂....)

以及一些必定吊打手写的库函数:

 std::sort  std::swap  std::min  std::max 

上面几个简单函数不要想着手写, 直接调库...

对于 std::swap , 可能有的OIer学习 lrj dalao使用这样的写法:

void Swap(int& a,int& b){
    a^=b^=a^=b;
}

然后你发现你UB了...非常容易炸的写法.

就算你拆开每一行让它不UB:

void Swap(int& a,int& b){
    a^=b;
    b^=a;
    a^=b;
}

然而只是凭空拖慢速度罢了...

为啥呢? 正是因为多了那三次看起来很奇技淫巧的位运算...因为库函数优化后直接在寄存器里mov然后扔RAM完事w

而且还会存在一个严重的问题: Swap同一个变量的时候会把它Swap成 $0$ ......(突然滑稽.png)

 

4.结语

STL是个非常有用的C++特性, 只要正确使用, 不仅可以提高代码精简程度, 还可以将你的精力聚焦到核心算法而不是一些其他的细节, 并由此提高做题速度与算法能力. 本文仅仅做了很简单的介绍就已经长到了这样的地步, 可想而知STL还有更深的水. 比如库函数的多个重载/自定义内存分配器/自定义执行模式/迭代器和容器的高级用法与嵌套等等. 如果想要深入了解STL的用法与高级特性, 可以参考 cplusplus.com zh.cppreference.com wikipedia 或者直接参考标准文档. 如果为了防止版权争议可以查阅C++14标准发布前的最后一个Working Draft (n4140.pdf)

本文依然不定期更新.

转载于:https://www.cnblogs.com/rvalue/p/7276883.html

你可能感兴趣的:(c/c++,数据结构与算法,内存管理)