平时使用C++的过程中,关联容器std::map、std::set经常被用到,本文分享几个使用方面的小技巧,虽然举例时均使用std::map,但结论同样适用于std::set。
注意迭代器失效问题
void f(std::map<int, int> &m) {
for (auto it = m.begin(); it != m.end(); ++it) {
if (it->first % 2 == 0)
m.erase(it);
}
}
上述代码有什么问题吗?有!在循环中使用erase
会导致迭代器失效,使用失效的迭代器再进行诸如++it
的操作就会导致错误,一种正确的使用方法如下
void f(std::map<int, int> &m) {
for (auto it = m.begin(); it != m.end();) {
if (it->first % 2 == 0) {
it = m.erase(it);
} else {
++it;
}
}
}
在range based for loop中谨慎地指定类型
std::size_t test_for1(std::map<int, std::string> &m) {
std::size_t total = 0;
for (const auto &v : m) {
total += v.second.size();
}
return total;
}
std::size_t test_for2(std::map<int, std::string> &m) {
std::size_t total = 0;
for (const std::pair<int, std::string> &v : m) {
total += v.second.size();
}
return total;
}
上述两段遍历std::map
的代码有哪里不同呢?原来进行遍历操作时指定的引用类型不同!一般情况下我们不会写成第二种方式,但在理论上第二种写法确实会比第一种慢一些,原因是std::map<int, std::string>
容器中保存的是std::map<int, std::string>::value_type
,即std::pair<const int, std::string>
,所以当使用const std::pair<int, std::string> &
类型用于遍历时,每个元素都会被复制一份,这份开销可能不大,但一定是不必要的。对标准库容器进行只读遍历时,一般使用const auto &
即可,但如果你的公司编码规范要求不能使用auto
,出现一个罚款100元,要记得使用正确的类型。
快速创建容器的思路
我想根据一组数据创建一个std::map
,怎么插入数据性能更好呢?本文测试了将一千万条数据放入容器时,下述几种操作方式的效率,得出了耗时(Cost)和元素比较次数(Compare)的表格,测试代码放到文章最后
- insert_range: 使用
m.insert(first, last)
的接口直接创建 - insert: 使用
m.insert(data)
逐个放入 - insert_back: 使用
m.insert(m.end(), data)
逐个放入 - sort_and_insert: 先对数据排序,再使用
m.insert(data)
逐个放入 - sort_insert_back: 先对数据排序,再使用
m.insert(m.end(), data)
逐个放入
结果如下,对于需要排序的方式,Cost包含排序占用的时间,但Compare不包含排序时的比较次数
Test Method | Cost(ms) | Compares |
---|---|---|
sort_insert_back | 1246 | 20000000 |
sort_and_insert | 2525 | 430640011 |
insert | 12116 | 242834184 |
insert_back | 12309 | 252833970 |
insert_range | 11939 | 252833970 |
sort_only | 911 | 0 |
从表中惊奇地发现,先排序再构建std::map
竟然比直接构建快十倍,这是为什么呢?
一般来说,C++标准库常用红黑树这种数据结构来实现std::map
,当添加有序的新数据时,该数据恰好应当被放到最后一个位置,因此sort_insert_back
可以减少很多次因查找带来的比较次数。sort_and_insert
没有提示应当放到哪个位置,所以比较次数增加了约20倍,但为什么耗时只增加一倍呢?这件事应当从缓存命中的方面思考,考虑逐步向一棵树上新增有序数据时,每次查找几乎都是沿着树的右边界进行,因此其大概率可以命中高速缓存,所以虽然比较次数很多但效率尚可,当然这与具体硬件性能有关,可能换一个CPU后耗时还会增加。
对于未排序的数据,向std::map
新增数据时,首先需要查找插入的位置,然后调整树的结构保持平衡,当数据量变大时,这个过程命中高速缓存的可能性就很小了,所以耗时会急剧增加。在实际应用中,需要综合考虑多种指标来确定优化方案,例如需要从文件中导入数据到某个数据结构中,瓶颈很可能在文件IO上面,与缓存未命中相比,读文件的耗时那就高太多了。
并发访问时通过延迟销毁降低锁粒度
假设你正在写一个支持多线程访问的高性能内存数据库,在处理flushdb
指令时写下了下述代码
std::map<std::string, std::string> db;
std::mutex mtx;
void flushdb() {
std::lock_guard<std::mutex> lg(mtx);
db.clear();
}
这份代码没有任何错误,且格式规范、逻辑清晰,但当需要清理一个数据量很大的数据库时,整个数据库服务仿佛被冻结了,数百毫秒无法提供服务!原因是清理整个std::map
可能非常耗时,导致清理过程中新的读写操作都无法进行。此时若通过swap
将清理操作推迟到锁外进行,则可以立刻开始处理新的请求。注意:清理操作被推迟,大量新的数据被写入后,可能有短时间内存占用超出预期的情况,请谨慎斟酌。
void flushdb_delay() {
std::map<std::string, std::string> tmp;
mtx.lock();
tmp.swap(db);
mtx.unlock();
// 即使不调用clear也可被析构函数释放
tmp.clear();
}
同理,删除元素也可以将销毁操作推迟,一般情况下释放一条数据不会有太多的耗时,除非数据本身非常大(比如长度1G的字符串)或者结构复杂(比如包含数万条数据的std::list,数据不多但逐个销毁时会因无法命中高速缓存而进度缓慢)等,在实际应用中应酌情考虑。
void del(const std::string &key) {
std::lock_guard<std::mutex> lg(mtx);
db.erase(key);
}
void del_delay(const std::string &key) {
std::map<std::string, std::string>::node_type node;
mtx.lock();
auto it = db.find(key);
if (it != db.end())
node = db.extract(it);
mtx.unlock();
}
结语
在学习和工作中,无需进行过早的优化,不然可能会花费过多的精力在用处不大的地方,但当问题发生时,我们应当有足够的知识储备,能快速地发现问题并提出解决方案,这是目前的我比较期待的生活状态。
附录1
#include <vector>
#include <map>
#include <chrono>
#include <iostream>
#include <random>
#include <algorithm>
#include <iomanip>
#include <string>
using namespace std;
long compare_count{0};
template<typename T>
struct CountCompare {
bool operator()(const T &l, const T &r) const {
++compare_count;
return l < r;
}
};
using MapType = std::map<int, int, CountCompare<int>>;
using ValueType = std::pair<int, int>;
long now() {
using namespace std::chrono;
auto d = steady_clock::now().time_since_epoch();
return duration_cast<milliseconds>(d).count();
}
void insert_back(std::vector<ValueType> &data, MapType &m) {
for (const auto &d : data)
m.insert(m.end(), d);
}
void sort_insert_back(std::vector<ValueType> &data, MapType &m) {
std::sort(data.begin(), data.end());
for (const auto &d : data)
m.insert(m.end(), d);
}
void insert(std::vector<ValueType> &data, MapType &m) {
for (const auto &d : data)
m.insert(d);
}
void sort_and_insert(std::vector<ValueType> &data, MapType &m) {
std::sort(data.begin(), data.end());
for (const auto &d : data)
m.insert(d);
}
void insert_range(std::vector<ValueType> &data, MapType &m) {
m.insert(data.begin(), data.end());
}
void sort_only(std::vector<ValueType> &data, MapType &) {
std::sort(data.begin(), data.end());
}
using test_func_t = void(*)(std::vector<ValueType> &, MapType &);
void do_test(const char *test_type, test_func_t func, const std::vector<ValueType> &data) {
std::vector<ValueType> v = data;
MapType m;
long start = now();
compare_count = 0;
func(v, m);
long cost = now() - start;
std::cout << "| " << std::setw(18) << test_type <<
" | " << std::setw(16) << cost << " | "
<< std::setw(16) << compare_count << " |\n";
}
void test_header() {
std::cout << "| " << std::setw(18) << "Test Method" <<
" | " << std::setw(16) << "Cost(ms)" << " | "
<< std::setw(16) << "Compares" << " |\n";
std::cout << "| " << std::string(18, '-') <<
" | " << std::string(16, '-') << " | "
<< std::string(16, '-') << " |\n";
}
#define TEST_MAP(func, data) do_test(#func, func, data)
int test_create_map() {
constexpr int SIZE = 10000001;
std::vector<ValueType> data;
data.reserve(SIZE);
for (int i = 0; i < SIZE; i++)
data.push_back({i, i});
std::mt19937_64 mt(12345);
std::shuffle(data.begin(), data.end(), mt);
test_header();
// 第一次不计入统计,以避免page fault影响
TEST_MAP(sort_insert_back, data);
TEST_MAP(sort_insert_back, data);
TEST_MAP(sort_and_insert, data);
TEST_MAP(insert, data);
TEST_MAP(insert_back, data);
TEST_MAP(insert_range, data);
TEST_MAP(sort_only, data);
return 0;
}
int main() {
return test_create_map();
}
本文由kedixa发表于个人博客,
转载请注明作者及出处。
本文链接:https://blog.kedixa.top/2023/std-map-tips/