C++之容器


重点:容器其实就是一种数据结构;用来存储数据的

容器,其实就是 C++ 对数据结构的抽象和封装。

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

容器的通用特性

你必须要知道所有容器都具有的一个基本特性:它保存元素采用的是“值”(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,让它们帮你自动管理元素。

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

容器的具体特性

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

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

顺序容器

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

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

  1. 连续存储的数组:array、vector 和 deque。
  2. 指针结构的链表:list 和 forward_list。

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

它们两个的区别在于容量能否动态增长。

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


array<int, 2> arr;                // 初始一个array,长度是2
assert(arr.size() == 2);        // 静态数组的长度总是2

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

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

deque<int> 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 了。

在这里插入图片描述

有序容器

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

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

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

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

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

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


template<
    class T                          // 模板参数只有一个元素类型
> class vector;                      // vector

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

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

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

解决这个问题有两种办法:一个是重载“<”,另一个是自定义模板参数。

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


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

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

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

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

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

无序容器

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

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

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

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

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


using map_type =                    // 类型别名
    unordered_map<int, string>;      // 使用无序关联数组

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

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

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

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


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

它要求 key 具备两个条件,一是可以计算 hash 值,二是能够执行相等比较操作。

第一个是因为散列表的要求,只有计算 hash 值才能放入散列表,第二个则是因为 hash 值可能会冲突,所以当 hash 值相同时,就要比较真正的 key 值。与有序容器一样,要把自定义类型作为 key 放入无序容器,必须要实现这两个函数

。“==”函数比较简单,可以用与“<”函数类似的方式,通过重载操作符来实现:


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

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


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

使用hash函数的使用 头文件要加上

#include <functional>

functional 头文件中定义了无序关联容器使用的特例化 hash 模板。

hash 模板定义了可以从 K 类型的对象生成哈希值的函数对象的类型。

hash 实例的成员函数 operator()() 接受 K 类型的单个参数,然后返回 size_t 类型的哈希值。对于基本类型和指针类型,也定义了特例化的 hash 模板。

struct HashFunc
{
	std::size_t operator()(const KEY &key) const 
	{
		using std::size_t;
		using std::hash;
 
		return ((hash<int>()(key.first)
			^ (hash<int>()(key.second) << 1)) >> 1)
			^ (hash<int>()(key.third) << 1);
	}
};

写的时候注意:

  1. 返回类型是size_t的
  2. 要写到自定义的类中,并且要写个struct类中写这个函数
  3. hash()(key),一共两个括号,第一个括号空着,第二个写要hash的成员变量
  4. const写在函数的后面,表示这个函数不会也不能改变成员变量的值,在这个函数中成员变量只有只读属性
  5. operator()(key)同样是两个括号,第一个括号空着,第二个是key,const离KEY近,因此是引用的值不会变 也不能给变

有序容器和无序容器的接口基本一样,这两者该如何选择呢?其实看数据结构就清楚了,如果只想要单纯的集合、字典,没有排序需求,就应该用无序容器,没有比较排序的成本,它的速度就会非常快。

在这里插入图片描述

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值