>
Home

登龙(DLonng)

选择大于努力

STL 常用容器


版权声明:本文为 DLonng 原创文章,可以随意转载,但必须在明确位置注明出处!

STL 简介

STL 是 Standard Template Library 的简称,中文名标准模板库,是用 C++ 模板机制来表示泛型的库。STL 现在是 C++ 标准库的一部分(大约 80 %),在工作中也非常常用,非常值得我们学习,其实 STL 就是下面 6 个组件的集合:

  • 容器 Container
  • 算法 Algorithm
  • 迭代器 Iterator
  • 仿函数 Function object
  • 适配器 Adaptor
  • 空间配置器 Allocator

本次我们来学习常用的 STL 容器,主要包括:

  • 序列式容器:vector,queue,deque,priority_queue,list,stack
  • 关联式容器:set,map,multiset,multimap

什么是容器?

普通概念上容器就是用来存放一组元素,在 STL 中也是如此:STL 中的容器用来管理一组元素。因为存储的元素的属性不同,使用的场合也不同,于是就开发了多种容器,比如 vector 其实就是一个动态数组,deque 就是一个队列,list 是一个链表,set 是元素的集合,map 是键值对。

可以看出容器的设计跟数据结构有很大的关系,因为我们处理的都是数据,在不同的场合选择最佳的数据结构(STL 容器)可以更方便的解决问题,这其实就是学习数据结构的意义,实际工作中是不需要你自己封装数据结构的,基本都是使用稳定的库(例如 STL),但是我们必须知道在何种场合使用何种数据结构(STL 容器)。

知道并不等于会用,只有真正动手用一个数据结构解决一个问题才能说明你掌握了它。

序列式容器 - Vector

来看看最简单,最常用的序列式容器 - vector,它实际上是一个动态数组,它将元素置于一个动态数组中管理,它的优缺点和数组相同:

  • 优点:随机访问元素快,直接用索引访问
  • 缺点:在中部和头部添加和删除元素比较费时,需要移动大量元素

下面是 vector 的基本用法,这里只介绍常用的 API,全部的 STL 容器相关的用法可以参考 cppreference 网站。

这个例子中我们创建了一个包含了 4 个整型元素的 vector,你也可以换成 double 或其他类型,最常用的函数是 push_back 添加元素,其他的 API(size, pop_back) 参考前面的网站(我不可能把所有的函数都列出来,要是这样的话自己看手册不就行了,方法很重要!要学会举一反三):

#include <iostream>

// 需要包含 vector 头文件
#include <vector>

int main(void) {
  std::vector<int> v = { 1, 2, 3, 4 };
  // 在尾部添加元素
  v.push_back(5);
  v.push_back(6);
  for (auto n : v)
    std::cout << n << std::endl;
  return 0;
}

既然谈到动态数组,我们又想到静态数组,你可能会想到使用 int a[10]; 来定义一个静态数组,但是在 C++ 中有更好的 array 可以使用:

// 使用 array 必须包含该头文件
#include <array>

// 创建一个含有 5 个元素的静态数组
std::array<int, 5> a = { 1, 2, 3, 4, 5 };

array 和 vector 的不同点如下:

  • 静态数组 array 是在栈上分配内存,容量较小,并且不需要变长
  • 动态数组 vector 是在堆上分配的内存,容量较大,需要变长

在合适的场合要选择合适的数据结构来解决问题,没有哪个好哪个不好,只有合适和不合适。

序列式容器 - queue,deque,priority_queue

queue 是先进先出的队列,而 deque 是一个双端队列(队列的两端都可以操作),关于队列概念的介绍不是这里的重点(相信你学习 STL 应该了解些常用的数据结构)。

普通队列 queue

queue 与数据结构中队列的定义相同,队列中的元素先进先出,常用的 API 有 2 个:

#include <iostream>
// 队列头文件
#include <queue>

int main(void) {
  // 创建一个队列
  std::queue<int> q;
  // 在尾部添加元素
  q.push(1);
  q.push(2);
  q.push(3);
  // 弹出元素
  while (!q.empty()) {
    std::cout << q.front() << std::endl;
    q.pop();
  }
  return 0;
}

要注意:queue 没有迭代器,所以输出元素会有点麻烦。实际使用时,当你的数据需要从一端进入,从另一端输出时可以考虑队列容器。

双端队列 deque

双端队列可以说是队列的升级版本,允许在头部和尾部分别进行添加和删除操作,常用的 API 如下:

  • push_back:在队列尾部添加一个元素
  • pop_back:删除队列尾部的元素
  • push_front:在队列头部插入一个元素
  • pop_front:删除队列头部的元素

简单的使用方法如下:

// 必须包含这个头文件
#include <queue>

// 创建一个存储 int 类型的队列
std::deque<int> d;
d.push_back(1);
d.push_front(2);
// d: 2 1
d.pop_front();
d.pop_back();
// d:

当你需要同时操作队列头和尾部的元素时考虑 deque。

优先级队列 - priority_queue

向优先级队列(堆 heap)中添加元素,默认会进行从小到大的排序,我们也可以手动指定排序规则:

#include <queue>

// 创建一个优先级队列
std::priority_queue<int> q;

// q: 0 1 2 3 4 5 6 7 8 9
for (int n : {1, 4, 3, 2, 6, 5, 8, 7, 9, 0})
  q.push(n);

std::prority_queue<int, std::vector<int>, std::greater<int>> q2;
// q2: 9 8 7 6 5 4 3 2 1 0
for (int n : {1, 4, 3, 2, 6, 5, 8, 7, 9, 0})
  q2.push(n);

q.pop() 操作每次删除队列中最顶部(可能最大,可能最小,与排序规则有关)的元素。

序列式容器 - list

list 又可以称为链表,其实跟数据结构中的链表的思想相同,也具有链表的优缺点:

  • 优点:插入删除速度快
  • 缺点:访问元素速度慢,需要依次遍历

简单的使用方法如下:

// list 头文件
#include <list>

// 创建一个包含 int 类型的 list
std::list<int> list = { 1, 2, 3, 4 };

// 尾部添加元素
list.push_back(5);

// 头部添加元素
list.push_front(0);

// 在元素 3 前插入元素 33
auto it = std::find(list.begin(), list.end(), 3);
if (it != list.end)
  list.insert(it, 33);

// 错误:list 不可以随机访问,没有 [] 操作,要用迭代器进行遍历
int item = list[1];

// 删除一个元素后,指向该元素的迭代器会失效
list.erase(list.begin());

当你的数据需要频繁的增加和删除时,可以考虑链表容器,它具有 O(1) 的插入和删除的时间复杂度。

序列式容器 - stack

栈平常用的不是很多,但是它的后进先出思想却用在很多地方,比如函数调用需要栈的帮助,还是有必要了解下的:

#include <stack>

std::stack<int> mystack;

// mystack(top -> end): 3 2 1
mystack.push(1);
mystack.push(2);
mystack.push(3);
// pop: 3
mystack.pop();
// pop: 2
mystack.pop();
// pop: 1
mystack.pop();

我们入栈的顺序是 1 2 3,出栈的顺序是 3 2 1,符合后进先出的原则。栈的实现比较简单,可以使用 C 语言的数组来实现,其实就是设置栈顶指针来限制数组中元素的个数,并封装 push 和 pop 的逻辑。

关联式容器 - set

std::set 是一个包含有序的唯一对象(key)的容器,意思是 set 容器不包含重复的元素,并且里面的元素是有序的,排序方式可以通过比较器 Compare 来指定,set 底层的实现方式是红黑树,有兴趣可以手动实现 set,下面来看看基本的用法。

#include <iostream>
#include <set>
#include <cstring>

struct strless {
  bool operator()(const char *str1, const char *str2) {
    return strcmp(str1, str2)  <  0;
  }
};


int main() {
  const char *str[] = { "a", "b", "c", "d","e","f" };
  // 可以自定义 set 的排序函数
  std::set<const char *, strless> myset(str, str + 6, strless());
  // insert 的返回值是 pair 类型,如果元素已经存在,则插入失败
  std::pair<std::set<const char *>::iterator, bool> res = myset.insert("g");
  if (res.second)
    std::cout << "insert " << *(res.first) << " ok" << std::endl;

  for (auto ib = myset.begin(); ib != myset.end(); ib++)
    std::cout << *ib << " ";
  std::cout << std::endl;
  return 0;
}

其中 insert 函数需要注意,它的返回值是 std::pair<std::set<const char*>::iterator, bool> 类型,第一个参数 first 是指向插入元素的迭代器,第二个元素 second 表示该元素是否插入成功,成功返回 1,失败返回 0,所以可以用 res.second 来判断元素是否插入成功。关于 set 的其他操作,例如删除,清空,查找等都与其他容器差不多,可以自己写 demo 测试下。

另外,STL 也提供了 std::unordered_set 来存储默认不排序(根据 key 的 hash 值来确定 key 的位置)的元素,用法与 set 基本相同。

关联式容器 - multiset

multiset 与 set 的唯一区别是:multiset 允许出现多个相同的元素(key)。在底层实现中,set 的每个节点就是单一的节点,而 multiset 的每个节点是一个链表,所以一个键可以对应多个不同的值。

#include <iostream>
#include <set>
#include <string>
#include <cstring>

struct student {
	int score;
	char name[30];
};

// 根据学生分数来排序
struct stuless {
	bool operator()(const student &s1, const student &s2) {
		return s1.score < s2.score;
	}
};

int main() {
	student sarray[3] = { { 81, "Jack" }, { 90, "Mary" }, { 95, "Tom" } };
	std::multiset<student, stuless> myset(sarray, sarray + 3, stuless());

	student stu;
	// 分数为 89 对应了 2 个名字相同的学生
	stu.score = 89;
	strcpy(stu.name, "Joa");
	myset.insert(stu);

	strcpy(stu.name, "Joa");
	myset.insert(stu);

	for (auto ib = myset.begin(); ib != myset.end(); ib++)
		std::cout << (*ib).score << " " << (*ib).name << std::endl;
	return 0;
}

一定要注意 multiset 允许存在相同的 key 元素!

关联式容器 - map

map 是一个包含唯一键值对的关联容器,key 通过比较器来排序(默认按照 key 从小到大排序),map 中不能出现相同的键值对。通常用 std::pair 来遍历 map,其中 pair.first 代表键,pair.second 代表值,例如下面这个例子遍历一个 map:

#include <map>

std::map<int, char> counts { {5, 'a'}, {3, 'b'}, {4, 'c'} };
// 从小到大排序输出
for (const auto &pair : counts)
  std::cout << pair.first << ": " << pair.second << std::endl;
// 3: b
// 4: c
// 5: a

另外在使用 map 时,要注意 [] 的一个使用技巧,来看一个例子:

#include <map>

// 单词 -> 出现次数的 map
std::map<std::string, size_t> words_map;

// 统计每个单词出现的次数
for (const auto &w : { "this", "is", "not", "a", "this", "is", "a"})
  ++words_map[w];

for (const auto &pair : words_map)
  std::cout << pair.first << ": " << pair.second << "times." << std::endl;

当一个单词不在 words_map 中时,++words_map[w] 就会将该单词插入,并将次数设置为 1,如果单词已经存在则不会再次插入,而仅仅将出现的次数(值)加 1,实现了统计单词出现次数的功能。

同样,STL 也提供了默认不排序(根据 key 的 hash 值来确定 key 的位置)的 std::unordered_map,用法与 map 基本相同。

关联式容器 - multimap

multimap 允许出现多个重复的键值对(包括完全相同的键值对),例如一个分数可能对应多个学生:

#include <iostream>
#include <map>

int main() {
	std::multimap<int, const char *> m;
	m.insert(std::pair<int, const char *>(80, "Tom"));
	m.insert(std::pair<int, const char *>(80, "Jack"));
	m.insert(std::pair<int, const char *>(85, "Tark"));
	m.insert(std::pair<int, const char *>(90, "Mary"));

	for (auto ib = m.begin(); ib != m.end(); ib++)
		std::cout << (*ib).first << "   " << (*ib).second << std::endl;
  return 0;
}

例子中 Tom 和 Jack 都是 80 分。

结语

STL 的使用非常广泛,本次介绍的都是非常常用的容器,例子中大多都是介绍 API 为主,但是在实际项目中,容器中存储的时候通常都是复杂的数据结构,例如 std::vector<std::vector> 这中嵌套的类型。但是不管类型多么复杂,只要理解基本的用法,我们自己也可以设计出复杂的数据结构,一切都在基础之上,勿以浮沙筑高台!

下篇文章介绍 STL 的常用算法,有兴趣可以关注,谢谢你的阅读 :)

本文原创首发于微信公号「登龙」,分享机器学习、算法编程、Python、机器人技术等原创文章,扫码即可关注

DLonng at 09/17/17