优化数据结构

优化数据结构

理解标准库容器

  • 序列容器

    • std::string、std::vector、std::deque、std::list、std::forward_list

    • 前三个容器能通过下标访问,而后两个容器不能

    • 除了最后一个容器,push_back具有常量时间开销

    • 只有后三个容器有高效的push_front方法

    • 前三个容器在非末尾处插入元素的时间开销是O(n),并且插入元素时这个内部数组可能会被重新分配,导致所有的迭代器和指针失效

    • 后两个容器中,只有迭代器和指针指向的元素被移除,迭代器和指针才会失效。如果有一个迭代器已经指向插入位置,那么插入元素的时间开销是O(1)

  • 关联容器

    如果要对表执行查找操作,那么关联容器非常适合。

    • 有序关联容器:std::map、std::multimap、std::set、std::multiset

    • 有序关联容器要求必须对键或是元素自身定义能够对它们进行排序的operator<()

    • 有序关联容器的实现是平衡二叉树,无需手动排序,遍历时按照顺序访问,插入或删除元素的分摊开销是O(logn)

    • 无序关联容器:std::unordered_map、std::unordered_multimap、std::unordered_set、std::unordered_multiset

    • 无序关联容器要求对键或是元素自身定义相等关系即可。

    • 无序关联容器的实现是散列表,遍历时会按照未定义的顺序访问,插入或删除元素的平均开销是O(1),最差情况是O(n)

std::vector与std::string

- 序列容器
​- 插入时间:在末尾插入元素的时间开销为O(1),在其他位置插入元素的时间开销为O(n)
- 索引时间:根据位置进行索引,时间开销为O(1)
- 排序时间:O(nlogn)
- 如果已排序,查找时间开销为O(logn),否则为O(n)
- 当内部数组被重新分配时,迭代器和引用失效
- 迭代器从前向后或是从后向前生成元素
- 合理控制分配容量,与大小无关​​​

  • 重新分配的性能影响

    • size表示有多少元素,capacity表示容量大小,当size==capacity,插入操作会触发重新分配内部存储空间,将所有元素复制新的存储空间中,并使所有指向旧存储空间的迭代器和引用失效。新的capacity会是size的若干倍

    • 通过调用reserve方法预留足够多的的capacity,可以防止发生不不要的重新分配和复制的开销

    • 即使元素被移除了,也不会自动将内存返回给内存管理器。比如clear方法。

    • shrink_to_fit可以提示vector将容量缩减至当前的大小,但并不强制进行重新分配

    • 想确保释放vector的内存,std::vector().swap(x),构造一个空的vector与x交换,并在删除这个临时vector时,内存管理器会回收属于x的内存。

  • std::vector中的插入与删除

    • 填充vector最快的方式是给它赋值;

    • 如果数据是在另外一个容器中,使用std::vector::insert();

    • std::vector::push_back(),使用下标、at()、迭代器来获取插入元素的花费时间不分伯仲。可以用reserve()进行优化。

    • 它的弱点是在前端插入元素很低效。

  • 遍历std::vector

    • 下标 > at() > 迭代器
  • 对std::vector排序

    • std::sort()和std::stable_sort()
  • 查找std::vector

std::deque

- 序列容器
​- 插入时间:在队列两端插入元素的时间开销为O(1),在其他位置插入元素的时间开销为O(n)
​- 索引时间:根据位置进行索引,时间开销为O(1)
​- 排序时间:O(nlogn)
​- 如果已排序,查找时间开销为O(logn),否则为O(n)
​- 当内部数组被重新分配时,迭代器和引用失效
​- 迭代器从前向后或是从后向前遍历元素
- 专门用于创建先进先出队列的容器

  • std::deque的典型实现方式是一个数组的数组。获取deque中元素所需的两个间接引用会降低缓存局部性,而且更加频繁地调用内存管理器所产生的性能开销也比vector的要大。

  • deque的内存分配行为更加复杂,没有提供任何类似std::vector::reserve()的用于为其内部数据结构预先分配存储空间的成员函数。

  • std::deque是一个叫std::queue的容器适配器模板默认实现的,无法保证这种用法具有优秀的内存分配性能。

std::list

- 序列容器
​- 插入时间:任意位置的插入时间开销为O(1)
​- 排序时间:O(nlogn)
- 查找时间:O(n)
​- 除非元素被移除了,否则迭代器和引用永远不会失效
​- 迭代器从前向后或是从后向前遍历元素

  • 在拼接和合并时无需复制链表元素。

  • 即使是像splice和sort这种操作也不会使迭代器失效

  • 能够以一种简单且可预测的方式与内存管理器交互。当有需要时,list中的每个元素会被分别分配存储空间。在list中不存在未使用的额外的存储空间。

  • 插入与删除:在list末尾插入元素是最快的,甚至比=运算符函数更快。

  • 遍历std::list:list没有下标运算符,遍历它的唯一方式是迭代器。

  • 对std::list排序:std::sort()的时间开销是O(n^2),内置的sort函数时间开销是O(nlogn)。

  • 查找std::list:二分查找和std::find()的时间开销都是O(n),不适合替代关联容器。

std::forward_list

- 序列容器
​- 插入时间:任意位置的插入时间开销为O(1)
​- 排序时间:O(nlogn)
​- 查找时间:O(n)
​- 除非元素被移除了,否则迭代器和引用永远不会失效
​ - 迭代器从前向后遍历元素
- 与list的区别就是前向链表(单向链表),只提供前向迭代器。

  • std::forward_list中的插入和删除:没有insert(),而是insert_after()和push_front()

  • 遍历std::forward_list:与std::list类似

  • 对std::forward_list排序:与std::list类似

  • 查找std::forward_list与std::list类似

std::map与std::multimap

- 有序关联容器
​- 插入时间:O(logn)
​- 索引时间:通过键进行索引的时间开销为O(logn)
​- 除非元素被移除了,否则迭代器和引用永远不会失效
​- 利用迭代器对元素进行正向排序或是反向排序
- map会根据键的值对节点排序
- map的内部实现是一棵带有便于使用迭代器遍历的额外链接的平衡二叉树​,但它并不是树,没有办法得到节点之间的链接,也无法进行广度优先搜索以及其他可以在树上进行的操作。

  • std::map中的插入和删除:忧郁需要遍历map的内部树来找到插入位置,向map中插入一个元素的时间开销通常是O(nlogn)。map提供了另外一个版本的insert函数,该函数接收一个额外的map迭代器作为参数,如果这个提示是最优的,插入的均摊时间开销会降低为O(1)。

  • 优化“检查并更新”惯用法:一种常用的编程惯用法是先检查某个键是否存在于map中,然后根据结果进行相应的处理(插入或 是更新键对应的值)。那么可以进行性能优化:find()和insert()的时间开销都是O(nlogn),都会遍历map。有两种方法,insert()返回find()结果:std::pair<value_t, bool> result = table.insert(key, value); find()的返回结果作为提示传给insert():iterator it = table.lower_bound(key);

  • 遍历std::map:用迭代器遍历,非常耗时。

  • 对std::map排序:map本来就是有序的。

  • 查找std::map:查找也非常耗时,跟遍历相似。

std::set与std::multiset

- 有序关联容器 ​
​- 插入时间:O(logn)
​- 索引时间:通过键进行索引的时间开销为O(logn)
​- 除非元素被移除了,否则迭代器和引用永远不会失效
​- 利用迭代器对元素进行正向或是反向遍历

std::unordered_map与std::unordered_multimap

- 无序关联容器
​- 插入时间:平均时间开销为O(1),最差情况为O(n)
​- 索引时间:通过键进行索引的平均时间开销为O(1),最差情况为O(n)
​- 再计算散列值时迭代器会失效;只有元素被移除引用才会失效
​- 可以独立于大小(size)扩大或缩小它们的容量(capacity)
- 采用了动态分配内存的骨干数组,在其中保存指向动态分配内存的节点组成的链表的桶,因此构造非常昂贵。

  • 计算出size/buckets比例称为负载系数。负载系数大于1.0表示有些桶有一条多个元素链接而成的元素链,降低了查询这些键的性能(非完美散列)。在实际的散列表中,即使负载系数小于1.0,键之间的冲突也会导致形成元素链。负载系数小于1表示存在未被使用的桶(非最小散列),1-负载系数是空桶数量的下界,因为散列函数可能非完美。负载系数是一个因变量,无法直接设置或是预测它的值。如果负载系数超过了程序制定的最大负载系数值,那么桶数组会被重新分配,所有元素都会被重新计算散列值,这个值会被保存在新数组的桶中。由于桶数量的增长总是因负载系数大于1引起的,因此插入操作的均摊时间开销是O(1)。当最大负载系数大于1时,插入和查找操作的性能会显著降低。通过将最大负载系数降低到1以下能够适度地提高程序性能。我们能够通过构造函数指定桶的初始数量,可以通过调用rehash()增加桶的数量,可以通过调用rehash(n)将桶的数量的最小值设置为n。

  • 调用reserve(n)等价于调用rehash(ceil(n/max_load_factor()))

  • clear()会清除所有元素并释放内存。

  • 与其他C++标准库容器不同的是,std::unordered_map通过为遍历桶和未遍历桶中元素提供一个接口,暴露自己的实现结构。计算每个桶中的元素的长度能够帮助我们发现散列函数的问题。

  • std::unordered_map中的插入和删除:插入操作的时间开销并不理想。

  • 遍历std::unordered_map:使用迭代器,无法被排序

  • 查找std::unordered_map:查找才是它存在的理由。查找性能高效,但是缺点在于空间开销大。

其他数据结构

Boost库提供了一组类似标准库容器的数据结构。

小结

  • 斯特潘诺夫的标准模板库是第一个可复用的高效容器和算法库。

  • 各容器类的大O标记性能并不能反映真实情况。

  • 在进行插入、删除、遍历和排序操作时std::vector都是最快的容器。

  • 使用std::lower_bound查找有序vector的速度可以与查找std::map的速度相匹敌。

  • std::deque只比std::list稍快一点。

  • std::forward_list并不必std::list快。

  • 散列表std::unordered_map比std::map更快,但是相比所受到的开发人员的器重程度,它并没有比std::map快上一个数量级。

  • 互联网上有丰富的类似标准库容器的容器资源。

posted @ 2021-03-13 10:43  睿阳  阅读(319)  评论(0编辑  收藏  举报