STL 序列容器

转自时习之
STL中大家最耳熟能详的可能就是容器,容器大致可以分为两类,序列型容器(SequenceContainer)和关联型容器(AssociativeContainer)这里介绍STL中的各种序列型容器和相关的容器适配器。主要内容包括

  • std::vector
  • std::array
  • std::deque
  • std::queue
  • std::stack
  • std::priority_queue
  • std::list
  • std::forward_list

std::vector

1)初始化

int initilizer[4] = { 1, 2, 3, 4 };
std::vector<int> ages(initilizer, initilizer + 4);

\(std::vector<int> ages = { 1, 2, 3, 4 }\)这种写法实际上从语法分析上来说是分成下面几个步骤的:

//{ 1, 2, 3, 4 } 被编译器构造成一个临时变量std::initializer_list<int>
//使用临时变量构造一个临时变量 std::vector<int>
//再用 std::vector<int>的拷贝构造函数构造最终的ages
std::initializer_list<int> initilizer;
std::vector<int> tmp(initilizer);
std::vector<int> ags(tmp);

当然上面的分析只是语法上的分析,绝大部分编译器都可以优化掉tmp,而且因为{1, 2, 3, 4}转换成std::initializer_list是编译器在编译器完成的事情,所以其实效率比我们想象中要高一些。

2)自动增长

std::vector 会在内存不够的时候自动增长空间,这是相对于C数组来说最大的一个优势。那么空间不够的时候怎么增长呢?每次容器满了需要扩容的时候,容量总是呈现两倍增长(VS上1.5倍),而且每次扩容,容器第一个元素所在地址都会发生改变,由此我们知道,容器的扩容时实际是另外寻找一片更大的空间。

3)缩减 std::vector

std::vector会在空间不够的时候自动分配空间,但是它并不会在空间冗余的时候自动释放空间。如果你使用C++11之后的版本,你可以使用std::vector::shrink_to_fit来回收空间,否则你需要像下面这个缩减空间。

std::vector<int> ages;
std::vector<int>(ages.begin(), ages.end()).swap(ages)

这种用法叫做copy and swap在拷贝构造函数的实现中用的也很多。这个地方需要特别注意的是临时变量和实际变量位置不能写反ages.swap(std::vector(ages.begin(), ages.end()))是语法错误,因为std::vector::swap的原型如下:

void swap( vector& other );

临时变量(前面哪个匿名对象)是右值,无法绑定到一个左值引用上面。

4)兼容 C 数组

C++很重要的一个特性就是兼容C语言,C的接口中,如果需要传入一个数组,通常的方式s是传入一个起始地址加上一个长度,如下:

void* memset( void* dest, int ch, std::size_t count );

如果你现在有一个std::vector,现在需要把它传递给C,接口你可以调用std::vector::data这个成员变量获取底层的内存空间的首地址。std::vector和其他的容器一个非常重要的区别就是它保证了底层的内存空间的连续性,也就是说,它保证了内存空间和C数组的兼容性,能用C数组的地方都可以使用std::vector,而且它还能保证内存空间的自动管理。

5)std::vector 的内存模型

我们来看下面这段代码:

std::vector small(100);
std::vector large(1000);

那么 sizeof(small) 和 sizeof(large) 哪个大呢?答案是一样大,要解答这个问题我们需要了解 std::vector 的内存模型。std::vector的实现的内存模型并不完全一样,但是基本上都大同小异,类似下面这种结构。

            stack
        +------------+
        |  begin     +----------+
        +------------+          |
        |  end       +-------------------------------+
        +------------+          |                    |
+-------+  cap       |          v                    v
|       +------------+          +--------------------+----------+
|       |   ......   |          |                    |          |   heap
|       +------------+          +--------------------+----------+
|                                                               ^
+---------------------------------------------------------------+

从上面的图中我们可以看出small和large真正的差别其实在heap不在stack,所以说sizeof(small) == sizeof(large) (= 24?)。

6)std::vector

std::vector有一个特化版本\(std::vector<bool>\),用于实现dynamic bitset,需要注意的是,这个特化版本并不是容器,它的迭代器无法很好的适配STL中的所有算法。它的存在是为了节省空间,它的每一个元素只占用一位而不是一个字节。为了实现这种优化,operator[]返回的是一个代理类,你没有办法取单个元素的地址。通常的建议是,如果你不需要动态的bitset,你可以使用std::bitset,如果你需要dynamic bitset你可以考虑使用std::deque替代。


std::array

std::vector会自动管理使用到的内存,这是一个非常重要的特性,但是如果你的数据的大小是已知而且固定的,这个特性对于你来说是不必要的开销。因为前面提到std::vector的数据实际上放到heap上面,访问需要额外的解引用,而且它可能内部有内存空闲,空间有浪费。这种情况下你可以考虑使用std::array来替换std::vector。

1) 初始化

static const std::array<std::string, 5> kTags = {"trace", "info", "debug",   "waring", "error"};

2)为什么不直接使用C数组

std::array实际上是一个容器,它提供来迭代器可以很方便的遍历元素,它可以用过 size() 方法返回数组的大小,而且它是zero cost abstraction的绝佳体现,它的开销实际上并不比C数组要大,但是却提供来大量的方便易用的接口,可以和STL很好的整合在一起,所以如果你使用C++,你基本上可以考虑告别C数组了,变长数组你可以使用vector,定长数组可以使用std::array。


std::deque

前文提到,std::vector的内存空间是连续的,在头部插入数据需要移动所有数据是O(N)级别的操作,因为开销过于巨大,std::vector并没有提供在头部插入和删除的接口。如果我们真的有这样的需求,我们可以选择使用std::deque。它支持在头部和尾部以O(1)的开销插入和删除数据,同时可以在O(1)时间内访问任意元素。

1) push_front、front、pop_front

如果你选择使用std::deque而不是vector,十有八九你是为了用这三个函数,std::deque提供这三个函数用于在队列的头部插入和删除数据。需要注意的是下面两点:

这三个函数的复杂度都是O(1)
提供三个函数而不是两个函数是为了保证异常安全性

2)内存模型

std::deque是如何做到O(1)时间内访问任意元素又保证O(1)时间在头部和尾部操作数据内?这要从它的内存模型说起。

std::deque在逻辑上也是一个数组,只不过在物理上它的空间并不连续,它实际上由一段一段的小块儿内存拼接而成,这些小块儿的内存我们姑且叫它buffer,把这些buffer串在一起的就形成了一个逻辑上的一纬数组,用来串连这些buffer我们姑且称之为map

      +------+-------+-------+-------+--------+------+
      |      |       |       |       |        |      | map
      +------+---+---+----+--+----+--+----+---+------+
                 |        |       |       |
              +--v-+   +--v-+  +--v-+  +--v-+
              |    |   |    |  |    |  |    |  buffer
              |    |   |    |  |    |  |    |
              |    |   |    |  |    |  |    |
begin   +-->  +----+   |    |  |    |  +----+  <-+    end
              |    |   |    |  |    |  |    |
              |    |   |    |  |    |  |    |
              +----+   +----+  +----+  +----+

这个结构实际上是把一维数组变成了二维结构,本质上来说它就是通过增加一个间接层来实现的。再一次印证来那句老化,什么问题都可以通过增加间接层来解决。

3)逻辑上的数组

我们说逻辑上,std::deque也是一个数组,它支持取下标操作符,可以在O(1)时间内访问容器内部的任意元素。需要注意的是std::deque的O(1)和vector的O(1)存在常数上的差别,因为vector只需要一次解引用就可以获取元素而std::deque需要两次。
std::deque的这种逻辑和物理存储不一致的特性也从另外一个侧面反应了接口和实现直接的本质区别。编程的核心思想在于抽象,而抽象的核心在于分离接口和实现。

4)自动回收空间

和vector一样空间不足的时候会自动分配分配新的空间,大多数情况下只需要分配固定大小的buffer挂到map上,但是buffer过多的时候会导致map的重新分配。和vector不同的是std::deque会自动回收多余的空间,如果你对于运行时的内存要求非常严苛,而且会频繁的插入和删除数据可以考虑使用std::deque。

5)首尾插入和删除数据可以保证其他迭代器的合法性

std::deque还有一个特性就是如果你只是在头部或者尾部操作数据,你之前持有的迭代器不会失效,这一点我们会放到后面迭代器相关的文章中重点的讨论。


std::queue

传统的数据结构课程中,提到同时操作头部和尾部,我们首先想到的应该是队列,它是一种FIFO的结构,广泛的使用在各种程序中。STL中提供来std::queue这个模板类来实现这一结构,但是需要特别注意的是它不是一个容器,它是容器适配器。
std::queue不是容器,因为它不满足容器的concept,比如它没有定义iterator这个成员类型,它也不提供begin、end这样的成员方法,也就谁说你没有办法它不提供迭代器,没有迭代器你就不能使用STL中的算法,你也就失去了STL中的半壁江山。

1) 什么是容器适配器

我们说std::queue是一个容器适配器,所谓的适配器从设计模式的角度考虑就是把一个类的接口适配成另外一种接口。std::queue实际上就是拿着容器的接口,适配成队列的所需要的接口。默认情况下,它用来适配的容器是std::deque,这是为什么我们在讲完std::deque之后接着就讲std::queue的原因。我们来看一下标准库中std::queue的定义:

template<
    class T,
    class Container = std::deque<T>
> class queue;

std::queue默认使用的容器是std::deque。也就是说如果你觉得不合适,你完全可以换掉它,只要你提供的类型满足 Container 这个模板参数需要的条件:

The type of the underlying container to use to store the elements. The container must satisfy the requirements of SequenceContainer. Additionally, it must provide the following functions with the usual semantics:

  • back()
  • front()
  • push_back()
  • pop_front()

在标准库中除了std::deque之外std::list也满足这个条件。

例子

#include <queue>
#include <list>
#include <cassert>

int main(int argc, char *argv[])
{
    std::queue<int, std::list<int>> numbers;

    numbers.push(1);
    numbers.push(2);
    numbers.push(3);

    assert(numbers.front() == 1);
    assert(numbers.back() == 3);

    numbers.pop();
    assert(numbers.front() == 2);

    return 0;
}

请注意上面std::queue的定义,我提供了第二个参数,也请注意前面的写法是std::list而不是std::list因为list不是类型,list才是。


std::stack

说完FIFO的队列,我们顺便说一下FILO的栈,在STL中提供了std::stack用来实现栈的功能,它和std::queue一样是容器适配器而不是容器,而且它同样默认使用std::deque作为默认的容器,和std::queue不同的是,std::stack只需要操作容器的尾部,所以你可以用vector当作来适配std::stack。
std::stack的接口比较直观这里不再赘述,有需要的同学可以自行查看devdocs或者cppreference上面的文档。


std::priority_queue

在STL中,优先队列也是一个容器适配器,每次获取的数据都是优先级最大值的值(如何定义优先级可以通过模板参数来控制)。和前面两个容器适配器不同的是,它默认适配的容器是std::vector(std::deque也可以用于适配优先队列)。

1)堆

优先队列和前面两个容器适配器一个重要的区别就是它不仅仅是用底层的容器来存取数据,它会调整存储的数据的顺序,构建一个堆来达到优先队列每次都在常量时间取得优先级最大的数据的功能。

这里说的堆不是堆空间而是一种特殊的数据结构,它是基于数组实现的一颗完全二叉树,有大堆和小堆之分,默认情况下,std::priority_queue是基于大堆实现的,它的特点是父节点比子节点都要大(相反小堆是指它的父节点比子节点都要小)。正是因为堆的这种特点,所以它获取最高优先级的数据可以在常量时间内完成。

堆STL中也是一个非常独特的存在,在传统的数据结构和算法课程中,它属于数据结构部分,经常和队列和栈一起讲。但是在STL中它是放在算法库而不是容器或者容器适配器中实现的,和堆相关的算法有下面这些:

std::make_heap
std::pop_heap
std::push_heap
之所以这样设计是因为堆只需要底层是一个逻辑数组就可以了,把它设计成算法可以让它适用于各种逻辑数组的实现(std::vector,std::deque,std::array,c array)。

2)逆序

如果你需要实现的是每次找到最小值而不是最大值,你可以通过改变默认的模板参数来控制。std::priority_queue的原型如下:

template<
    class T,
    class Container = std::vector<T>,
    class Compare = std::less<typename Container::value_type>
> class priority_queue;

第二个参数可以替换成std::deque,最后一个参数可以替换成你想要的排序算法,比如std::greater,下面是一个具体的例子:

std::priority_queue<int, std::deque<int>, std::greater<int>> q;

std::list

STL中提供了std::list表示链表,通常它的实现是双链表(它支持双向迭代),如果你的代码中需要使用到链表结构可以选择用它做为容器,虽然它的适用场景可能会比我们想象中要低很多。

1)std::vector vs std::list

传统的数据结构的教程中,list通常都是伴随着array而来,通常书上会告诉你
list中元素的插入和删除比array要快,如果你频繁使用插入和删除你应该使用list而不是array
这个说法在学术上是可以认为是正确的,但是实际上大部分情况下,上面的说法是不靠谱的。绝大部分情况下,std::vector的效率都会比std::list要高,原因主要有下面几点:

  • 找到插入点,std::list需要O(N)的时间,而vector只需要O(1)的时间。
  • std::vector的数据是集中存储的,而std::list的数据是离散存储的,这意味着vector的cache命中率会比std::list的cache命中率要高,内存的读写效率可能会比std::list要高。
  • std::list存储一个数据需要两个指针(双链表)的额外空间,std::vector不需要,所以std::vector的内存内存使用效率会高于std::list。
  • std::vector的数据是连续的,可以使用二分查找,快速查找等算法,std::list不行,所以std::vector的查找效率可能会高于std::list。
    所以大部分情况下你实际需要的可能都是vector而不是std::list,即使你伴随着数据的删除和插入。那么什么时候应该选用std::list呢?

2)什么时候考虑用std::list

容器里面的元素比较大,这种情况下,两个指针的额外开销基本上可以接受,而且如果元素本身比较大,它自身cache的命中率会高。
容器的原始特别多,而且插入删除比较频繁(而且很多在头部插入,如果都是在头部插入可以对比一下deque)
你需要频繁的在迭代的同时删除数据,或者你需要频繁的合并容器。std::list因为本身数据是离散存储的,所以迭代中删除数据不会导致后续的迭代器的失效,做区间插入的时候也可以保证全局的异常安全性。

3)std::list 中特殊的函数

std::list中有一些特殊的成员函数值得我们在这里稍微的讨论一下:

size
这个函数比较特别的是,它的开销可能是O(N),在C++11之前,标准规定它的开销可能是O(N)也可能是O(1),所以轻易不要调用这个函数。比如

if (list.size() == 0)

最好写成

if (list.empty() == 0)

remove
这个函数之所以特殊是因为std::vector不提供这个函数,而是使用算法std::remove,而remove实际上不删除数据,需要配合std::vector::erase来删除数据。而 std::list 的提供这个成员函数而且会实际删除数据。

insert(iterator pos, InputIt first, InputIt last)
这个函数之所以特殊是因为所有的容器中的区间insert,只有std::list的这个方法保证强异常安全性(要么全部插入,要么一个都不插入)

4)std::forward_list

前面提到std::list是一个双向链表,在C++11之后,STL还提供了单链表:forward_list。单链表的开销比双链表要小一些,但是舍弃了双向迭代的功能,而且只支持在头部插入和删除数据。

在你不需要双向迭代的时候,你可以考虑使用单链表替代双链表,比如哈希表的冲突列表就可以使用单链表来实现的。

5)insert_after、erase_after

这两个函数和其实容器不太一样,其他容器是在给定的pos之前(实际上给定的位置,但是因为当前位置的数据往后挪动,相当于插入到来这个位置的元素之前)插入删除,单链表因为不支持双向迭代,只能实现在给定的位置之后插入和删除。

posted @ 2018-11-29 22:59  narjaja  阅读(347)  评论(0编辑  收藏  举报