<audio id="audio" title="12 | 三分天下的容器：恰当选择，事半功倍" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f1/3e/f1095473d788bb6d570af4ea65ba873e.mp3"></audio>

你好，我是Chrono。

今天我要讲的是标准库里的一块“重地”：容器，它也是C++泛型编程范式的基础。

不过在正式开讲之前，我先问你个问题：什么是容器？

你也许会说：**容器，就是能够“容纳”“存放”元素的一些数据结构**。

这个回答非常正确，而且说到了“点”上。

还记得计算机先驱的那句经典名言吗？“**算法 + 数据结构 = 程序。**”在C++里，容器就是这个公式里面的“数据结构”。

所以，下面我就着重从数据结构的角度，来谈谈各种容器的区别、优缺点，还有如何选择最合适的容器。

## 认识容器

所谓的数据结构，就是数据在计算机里的存储和组织形式，比如堆、数组、链表、二叉树、B+树、哈希表，等等。

在计算机的发展历史上，众多“大牛”孜孜不倦地发明创造了这么多的数据结构，为什么呢？

因为没有一种数据结构是万能的、可以应用于任何场景。毕竟，不同的数据结构存储数据的形式不一样，效率也就不一样。有的是连续存放，有的是分散存放，有的存储效率高，有的查找效率高，我们必须要依据具体的应用场合来进行取舍。

我想，你肯定已经学过这些数据结构了，也知道它们的实现原理，自己写也不是什么太难的事情。

但是，对于最基本、最经典的那些数据结构，你完全没有必要去“自己造轮子”，因为C++标准库里的容器就已经把它们给实现了。

容器，其实就是C++对数据结构的抽象和封装。而且，因为标准库开发者的功力很深，对编译器的了解程度更是远超你我，所以，容器的性能和优化水平要比我们自己写的好上几十倍，这一点你绝对不用质疑。

我们要做的，就是仔细品鉴标准容器这盘大餐，从中找出最合适自己口味的那道菜。

由于容器相关的资料已经有很多了，无论是看图书还是网站，都可以找到非常详细的接口文档，所以今天，我就不去罗列每个容器的具体操作方法了，而是把重点放在特性介绍上。掌握了这些特性，今后你在面临选择的时候，不用太纠结，就可以选出最适合你的容器。

## 容器的通用特性

你必须要知道所有容器都具有的一个基本特性：它保存元素采用的是“值”（value）语义，也就是说，**容器里存储的是元素的拷贝、副本，而不是引用**。

从这个基本特性可以得出一个推论，容器操作元素的很大一块成本就是值的拷贝。所以，如果元素比较大，或者非常多，那么操作时的拷贝开销就会很高，性能也就不会太好。

一个解决办法是，**尽量为元素实现转移构造和转移赋值函数**，在加入容器的时候使用std::move()来“转移”，减少元素复制的成本：

```
Point p;                        // 一个拷贝成本很高的对象

v.push_back(p);                // 存储对象，拷贝构造，成本很高
v.push_back(std::move(p));    // 定义转移构造后就可以转移存储，降低成本

```

你也可以使用C++11为容器新增加的emplace操作函数，它可以“就地”构造元素，免去了构造后再拷贝、转移的成本，不但高效，而且用起来也很方便：

```
v.emplace_back(...);            // 直接在容器里构造元素，不需要拷贝或者转移

```

当然，你可能还会想到在容器里存放元素的指针，来间接保存元素，但我不建议采用这种方案。

虽然指针的开销很低，但因为它是“间接”持有，就不能利用容器自动销毁元素的特性了，你必须要自己手动管理元素的生命周期，麻烦而且非常容易出错，有内存泄漏的隐患。

如果真的有这种需求，可以考虑使用智能指针unique_ptr/shared_ptr，让它们帮你自动管理元素。建议你再仔细复习一下[第8讲](https://time.geekbang.org/column/article/239580)的内容，弄清楚这两个智能指针之间的差异，区分“独占语义”和“共享语义”。

一般情况下，shared_ptr是一个更好的选择，它的共享语义与容器的值语义基本一致。使用unique_ptr就要当心，它不能被拷贝，只能被转移，用起来就比较“微妙”。

## 容器的具体特性

上面讲的是所有容器的“共性”，接下来我们再来看看具体容器的“个性”。

C++里的容器很多，但可以按照不同的标准进行分类，常见的一种分类是依据元素的访问方式，分成**顺序容器、有序容器和无序容器**三大类别，先看一下最容易使用的顺序容器。

### 顺序容器

顺序容器就是数据结构里的线性表，一共有5种：array、vector、deque、list、forward_list。

按照存储结构，这5种容器又可以再细分成两组。

- 连续存储的数组：array、vector和deque。
- 指针结构的链表：list和forward_list。

**array和vector直接对应C的内置数组，内存布局与C完全兼容，所以是开销最低、速度最快的容器**。

**它们两个的区别在于容量能否动态增长**。array是静态数组，大小在初始化的时候就固定了，不能再容纳更多的元素。而vector是动态数组，虽然初始化的时候设定了大小，但可以在后面随需增长，容纳任意数量的元素。

```
array&lt;int, 2&gt; arr;                // 初始一个array，长度是2
assert(arr.size() == 2);        // 静态数组的长度总是2

vector&lt;int&gt; v(2);              // 初始一个vector，长度是2
for(int i = 0; i &lt; 10; i++) {
    v.emplace_back(i);          // 追加多个元素
}
assert(v.size() == 12);          // 长度动态增长到12

```

deque也是一种可以动态增长的数组，它和vector的区别是，它可以在两端高效地插入删除元素，这也是它的名字double-end queue的来历，而vector则只能用push_back在末端追加元素。

```
deque&lt;int&gt; d;                  // 初始化一个deque，长度是0
d.emplace_back(9);              // 末端添加一个元素
d.emplace_front(1);              // 前端添加一个元素
assert(d.size() == 2);          // 长度动态增长到2

```

vector和deque里的元素因为是连续存储的，所以在中间的插入删除效率就很低，而list和forward_list是链表结构，插入删除操作只需要调整指针，所以在任意位置的操作都很高效。

链表的缺点是查找效率低，只能沿着指针顺序访问，这方面不如vector随机访问的效率高。list是双向链表，可以向前或者向后遍历，而forward_list，顾名思义，是单向链表，只能向前遍历，查找效率就更低了。

链表结构比起数组结构还有一个缺点，就是存储成本略高，因为必须要为每个元素附加一个或者两个的指针，指向链表的前后节点。

vector/deque和list/forward_list都可以动态增长来容纳更多的元素，但它们的内部扩容机制却是不一样的。

当vector的容量到达上限的时候（capacity），它会再分配一块两倍大小的新内存，然后把旧元素拷贝或者移动过去。这个操作的成本是非常大的，所以，你在使用vector的时候最好能够“预估”容量，使用reserve提前分配足够的空间，减少动态扩容的拷贝代价。

vector的做法太“激进”，而deque、list的的扩容策略就“保守”多了，只会按照固定的“步长”（例如N个字节、一个节点）去增加容量。但在短时间内插入大量数据的时候就会频繁分配内存，效果反而不如vector一次分配来得好。

说完了这5个容器的优缺点，你该怎么选择呢？

我的看法是，如果没有什么特殊需求，首选的容器就是array和vector，它们的速度最快、开销最低，数组的形式也令它们最容易使用，搭配算法也可以实现快速的排序和查找。

剩下的deque、list和forward_list则适合对插入删除性能比较敏感的场合，如果还很在意空间开销，那就只能选择非链表的deque了。

<img src="https://static001.geekbang.org/resource/image/6a/24/6ac671f2c8523c09343a34811ad7e324.jpg" alt="">

### 有序容器

顺序容器的特点是，元素的次序是由它插入的次序而决定的，访问元素也就按照最初插入的顺序。而有序容器则不同，它的元素在插入容器后就被按照某种规则自动排序，所以是“有序”的。

C++的有序容器使用的是树结构，通常是红黑树——有着最好查找性能的二叉树。

标准库里一共有四种有序容器：set/multiset和map/multimap。set是集合，map是关联数组（在其他语言里也叫“字典”）。

有multi前缀的容器表示可以容纳重复的key，内部结构与无前缀的相同，所以也可以认为只有两种有序容器。

因为有序容器的数量很少，所以使用的关键就是要理解它的“有序”概念，也就是说，**容器是如何判断两个元素的“先后次序”，知道了这一点，才能正确地排序**。

这就导致了有序容器与顺序容器的另一个根本区别，**在定义容器的时候必须要指定key的比较函数**。只不过这个函数通常是默认的less，表示小于关系，不用特意写出来：

```
template&lt;
    class T                          // 模板参数只有一个元素类型
&gt; class vector;                      // vector

template&lt;
    class Key,                      // 模板参数是key类型，即元素类型
    class Compare = std::less&lt;Key&gt;  // 比较函数
&gt; class set;                        // 集合

template&lt;
    class Key,                      // 第一个模板参数是key类型
    class T,                        // 第二个模板参数是元素类型
    class Compare = std::less&lt;Key&gt;  // 比较函数
&gt; class map;                        // 关联数组

```

C++里的int、string等基本类型都支持比较排序，放进有序容器里毫无问题。但很多自定义类型没有默认的比较函数，要作为容器的key就有点麻烦。虽然这种情况不多见，但有的时候还真是个“刚性需求”。

**解决这个问题有两种办法：一个是重载“&lt;”，另一个是自定义模板参数**。

比如说我们有一个Point类，它是没有大小概念的，但只要给它重载“&lt;”操作符，就可以放进有序容器里了：

```
bool operator&lt;(const Point&amp; a, const Point&amp; b)
{
    return a.x &lt; b.x;            // 自定义比较运算
}

set&lt;Point&gt; s;                    // 现在就可以正确地放入有序容器
s.emplace(7);
s.emplace(3);

```

另一种方式是编写专门的函数对象或者lambda表达式，然后在容器的模板参数里指定。这种方式更灵活，而且可以实现任意的排序准则：

```
set&lt;int&gt; s = {7, 3, 9};           // 定义集合并初始化3个元素

for(auto&amp; x : s) {                // 范围循环输出元素
    cout &lt;&lt; x &lt;&lt; &quot;,&quot;;              // 从小到大排序，3,7,9
}   

auto comp = [](auto a, auto b)  // 定义一个lambda，用来比较大小
{   
    return a &gt; b;                // 定义大于关系
};  

set&lt;int, decltype(comp)&gt; gs(comp)  // 使用decltype得到lambda的类型

std::copy(begin(s), end(s),          // 拷贝算法，拷贝数据
          inserter(gs, gs.end()));  // 使用插入迭代器

for(auto&amp; x : gs) {                // 范围循环输出元素
    cout &lt;&lt; x &lt;&lt; &quot;,&quot;;                // 从大到小排序，9,7,3
}  

```

除了**比较函数**这点，有序容器其实没有什么太多好说的，因为就这两个，选择起来很简单：**集合关系就用set，关联数组就用map**。

不过还是要再提醒你一点，因为有序容器在插入的时候会自动排序，所以就有隐含的插入排序成本，当数据量很大的时候，内部的位置查找、树旋转成本可能会比较高。

还有，如果你需要实时插入排序，那么选择set/map是没问题的。如果是非实时，那么最好还是用vector，全部数据插入完成后再一次性排序，效果肯定会更好。

### 无序容器

有“有序容器”，那自然会有对应的“无序容器”了。这两类容器不仅在字面上，在其他方面也真的是完全对应。

无序容器也有四种，名字里也有set和map，只是加上了unordered（无序）前缀，分别是unordered_set/unordered_multiset、unordered_map/unordered_multimap。

无序容器同样也是集合和关联数组，用法上与有序容器几乎是一样的，区别在于内部数据结构：**它不是红黑树，而是散列表**（也叫哈希表，hash table）。

因为它采用散列表存储数据，元素的位置取决于计算的散列值，没有规律可言，所以就是“无序”的，你也可以把它理解为“乱序”容器。

下面的代码简单示范了无序容器的操作，虽然接口与有序容器一样，但输出元素的顺序是不确定的乱序：

```
using map_type =                    // 类型别名
    unordered_map&lt;int, string&gt;;      // 使用无序关联数组

map_type dict;                      // 定义一个无序关联数组

dict[1] = &quot;one&quot;;                      // 添加三个元素
dict.emplace(2, &quot;two&quot;);
dict[10] = &quot;ten&quot;;

for(auto&amp; x : dict) {                // 遍历输出
    cout &lt;&lt; x.first &lt;&lt; &quot;=&gt;&quot;           // 顺序不确定
         &lt;&lt; x.second &lt;&lt; &quot;,&quot;;          // 既不是插入顺序，也不是大小序
} 

```

无序容器虽然不要求顺序，但是对key的要求反而比有序容器更“苛刻”一些，拿unordered_map的声明来看一下：

```
template&lt;
    class Key,                          // 第一个模板参数是key类型
    class T,                            // 第二个模板参数是元素类型
    class Hash = std::hash&lt;Key&gt;,        // 计算散列值的函数对象
    class KeyEqual = std::equal_to&lt;Key&gt; // 相等比较函数
&gt; class unordered_map; 

```

它要求key具备两个条件，一是**可以计算hash值**，二是**能够执行相等比较操作**。第一个是因为散列表的要求，只有计算hash值才能放入散列表，第二个则是因为hash值可能会冲突，所以当hash值相同时，就要比较真正的key值。

与有序容器一样，要把自定义类型作为key放入无序容器，必须要实现这两个函数。

“==”函数比较简单，可以用与“&lt;”函数类似的方式，通过重载操作符来实现：

```
bool operator==(const Point&amp; a, const Point&amp; b)
{
    return a.x == b.x;              // 自定义相等比较运算
}

```

散列函数就略麻烦一点，你可以用函数对象或者lambda表达式实现，内部最好调用标准的std::hash函数对象，而不要自己直接计算，否则很容易造成hash冲突：

```
auto hasher = [](const auto&amp; p)    // 定义一个lambda表达式
{
    return std::hash&lt;int&gt;()(p.x);  // 调用标准hash函数对象计算
};


```

有了相等函数和散列函数，自定义类型也就可以放进无序容器了：

```
unordered_set&lt;Point, decltype(hasher)&gt; s(10, hasher);

s.emplace(7);
s.emplace(3);


```

有序容器和无序容器的接口基本一样，这两者该如何选择呢？

其实看数据结构就清楚了，**如果只想要单纯的集合、字典，没有排序需求，就应该用无序容器，没有比较排序的成本，它的速度就会非常快**。

## 小结

好了，今天我从数据结构的角度全面介绍了C++标准库里的各种容器，只要你了解这些容器的基本特性，知道内部结构上的优缺点，今后在写程序的时候，也就不会再犯“选择困难症”了。

判断容器是否合适的基本依据是“**不要有多余的操作**”，也就是说，不要为不需要的功能付出代价。比如，只在末尾添加元素，就不要用deque/list；只想快速查找元素，不用排序，就应该选unordered_set。

再简单小结一下今天的内容：

1. 标准容器可以分为三大类，即顺序容器、有序容器和无序容器；
1. 所有容器中最优先选择的应该是array和vector，它们的速度最快，开销最低；
1. list是链表结构，插入删除的效率高，但查找效率低；
1. 有序容器是红黑树结构，对key自动排序，查找效率高，但有插入成本；
1. 无序容器是散列表结构，由hash值计算存储位置，查找和插入的成本都很低；
1. 有序容器和无序容器都属于关联容器，元素有key的概念，操作元素实际上是在操作key，所以要定义对key的比较函数或者散列函数。

<img src="https://static001.geekbang.org/resource/image/8e/85/8e935b3e8573ab5a6eb417c314cea285.jpg" alt="">

我再教你一个使用这些容器的小技巧，就是**多利用类型别名，而不要“写死”容器定义**。因为容器的大部分接口是相同的，所以只要变动别名定义，就能够随意改换不同的容器，对于开发、测试都非常方便。

## 课下作业

最后是课下作业时间，给你留两个思考题：

1. 试着用自己的语言说一下这些容器的优点、缺点和区别。
1. 你最喜欢、最常用的是哪种容器？为什么？

欢迎在留言区写下你的思考和答案，如果觉得今天的内容对你有所帮助，也欢迎分享给你的朋友。我们下节课见。

<img src="https://static001.geekbang.org/resource/image/18/54/1802953e56e91e6a06e1d601e6f8c854.jpg" alt="">
