STL容器大汇总

STL 容器vector ,list,deque的比较
 

 

作者:斑鸠
更新时间:2009/01/04
编译器版本:Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 15.00.21022.08 for 80x86

C++的STL 模板库中提供了3种容器 类:vector ,list,deque
对于这三种容器 ,在觉得好用的同时,经常会让我们困惑应该选择哪一种来实现我们的逻辑。
在少量数据操作的程序中随便哪一种用起来感觉差别并不是很大,
但是当数据达到一定数量后,会明显感觉性能上有很大差异。

本文就试图从介绍,以及性能比较两个方面来讨论这个问题。

 

  1. vector - 会自动增长的数组
  2. list - 擅长插入删除的链表
  3. deque - 拥有vector 和list两者优点的双端队列
  4. 性能竞技场
  5. 性能总结与使用建议
  6. 测试程序清单

vector - 会自动增长的数组

vector 又称为向量数组,他是为了解决程序中定义的数组是
不能动态改变大小这个缺点而出现的。
一般程序实现是在类创建的时候同时创建一个定长数组,
随着数据不断被写入,一旦数组被填满,则重新开辟一块更大的内存区,
把原有的数据复制到新的内存区,抛弃原有的内存,如此反复。

由于程序自动管理数组的增长,对于我们程序员来说确实轻松了不少,
只管把数据往里面插就行了,当然把物理内存和虚拟内存插爆掉了
就是操作系统来找你麻烦了:-)

vector 由于数组的增长只能向前,所以也只提供了后端插入和后端删除,
也就是push_back和pop_back。当然在前端和中间要操作数据也是可以的,
用insert和erase,但是前端和中间对数据进行操作必然会引起数据块的移动,
这对性能影响是非常大的。

对于所有数组来说,最大的优势就是随机访问的能力。
vector 中,提供了at和[]运算符这两个方法来进行随机访问。
由于每个数据大小相同,并且无间隔地排列在内存中,
所以要对某一个数据操作,只需要用一个表达式就能直接计算出地址:
address = base + index * datasize

同样,对 vector 进行内存开辟,初始化,清除都是不需要花大力气的,
从头到尾都只有一块内存。

list - 擅长插入删除的链表

有黑必有白,世界万物都是成对出现的。
链表对于数组来说就是相反的存在。
数组本身是没有动态增长能力的(程序中也必须重新开辟内存来实现),
而链表强悍的就是动态增长和删除的能力。
但对于数组强悍的随机访问能力来说的话,链表却很弱。

list是一个双向链表的实现。
为了提供双向遍历的能力,list要比一般的数据单元多出两个指向前后的指针。
这也是没办法的,毕竟现在的PC内存结构就是一个大数组,
链表要在不同的环境中实现自己的功能就需要花更多空间。

list提供了push_back,push_front,pop_back,pop_front四个方法
来方便操作list的两端数据的增加和删除,不过少了 vector 的at和[]运算符的
随机访问数据的方法。并不是不能实现,而是list的设计者
并不想让list去做那些事情,因为他们会做得非常差劲。

对于list来说,清除 容器 内所有的元素是一件苦力活,
因为所有数据单元的内存都不连续,list只有一个一个遍历来删除。

deque - 拥有vector 和list两者优点的双端队列

黑与白,处于这两个极端之间的就是令人愉悦的彩色了。
deque作为 vector 和list的结合体,确实有着不凡的实力。

STL 的deque的实现没有怎么去看过,不过根据我自己的猜测,
应该是把数组分段化,在分段的数组上添加指针来把所有段连在一起,
最终成为一个大的数组。

deque和list一样,提供了push_back,push_front,
pop_back,pop_front四个方法。可以想象,如果要对deque的两端进行操作,
也就是要对第一段和最后一段的定长数组进行重新分配内存区,
由于分过段的数组很小,重新分配的开销也就不会很大。

deque也和 vector 一样,提供了at和[]运算符的方法。
要计算出某个数据的地址的话,虽然要比 vector 麻烦一点,
但效率要比list高多了。
首先和list一样进行遍历,每次遍历的时候累积每段数组的大小,
当遍历到某个段,而且baseN <= index < baseN + baseN_length的时候,
通过address = baseN + baseN_index就能计算出地址
由于分过段的后链表的长度也不是很长,所以遍历对于
整体性能的影响就微乎其微了。

看起来deque很无敌吧,不过deque和希腊神话的阿吉里斯一样,
再怎么强大也是有自己的弱点的,之后的测试数据中就能看到了。


 

性能竞技场

为了能更好地进行比较,我们让静态数组(程序中写死的)和
动态数组(程序中new出来的)也参加了部分竞技。

竞技项目:
  • 初始化:对于静态和动态数组,逐一赋值,对于容器 ,push_back插入
  • 前向遍历:从0到n-1,每个数据自加1
  • 后向遍历:从n-1到0,每个数据自减1
  • 随机访问:在0到n-1中,随机抽取一定数量的数据进行读取
  • 后端插入:用push_back在后端插入一定数量的数据
  • 后端移除:用pop_back在后端移除一定数量的数据
  • 前端插入:用push_front在前端插入一定数量的数据
  • 前端移除:用pop_front在前端移除一定数量的数据
  • 中间插入:用insert在中间插入一定数量的数据
  • 中间移除:用erase在中间移除一定数量的数据
  • 反初始化:对于静态和动态数组,ZeroMemory删除所有数据,对于容器 ,调用clear方法
规则:
  • vector ,list,deque都调用默认的构造函数来创建
  • 数组和容器 的数据项都是1,000,000个
  • 前端和后端插入的数据项是10,000个
  • 前端和后端删除的数据项是10,000个
  • 随机访问的数据项是10,000个
  • 数据类型采用int型
  • 计时采用RDTSC高精度计时器来计时
  • 随机访问的数据的位置序列在测试前随机生成,所有数组和容器 都采用这个序列
  • 测试采用Debug版(Release版会对代码进行优化,可能会对测试产生一定的影响)
  • 测试3次,取平均值
测试机配置:
Intel(R) Core(TM)2 CPU T7400 2.16GHz 2.16GHz
2.00GB内存

测试结果:(单位 秒)
测试项目 静态数组 动态数组 vector list deque 备注
 初始化 0.00551 0.00461 0.207 1.30 0.352 list每个数据项都有附加数据,速度稍慢了一些
前向遍历 0.00381 0.00549 0.0796 0.0756 0.0713  
后向遍历 0.00422 0.00478 0.885 0.879 0.690  
随机访问 0.000334 0.000342 0.00119 148 0.0115 list把时间都耗在了找寻相应数据上
后端插入 N/A N/A 0.00192 0.0128 0.00260  
后端移除 N/A N/A 0.00131 0.0293 0.00194  
前端插入 N/A N/A 10.2 0.0128 0.00547 vector 对前端操作很苦手啊
前端移除 N/A N/A 10.3 0.0297 0.00135 同上
中间插入 N/A N/A 195 187 764 看似万能的deque的最大弱点,因为复杂的结构导致中间数据操作带来的复杂性大大增加,体现在操作时间是其他两个的几倍
中间移除 N/A N/A 195 209 753 同上
反初始化 0.00139 0.00290 0.0000106 0.693 0.305 vector 貌似是直接抛弃内存的,其他两个就没那么简单了

 

性能总结与使用建议

测试项目 静态数组 动态数组 vector list deque
 初始化 ★★★★★ ★★★★★ ★★★★☆ ★★★☆☆ ★★★★☆
前向遍历 ★★★★★ ★★★★★ ★★★★☆ ★★★★☆ ★★★★☆
后向遍历 ★★★★★ ★★★★★ ★★★★☆ ★★★★☆ ★★★★☆
随机访问 ★★★★★ ★★★★★ ★★★★☆ ★☆☆☆☆ ★★★☆☆
后端插入 N/A N/A ★★★★★ ★★★★☆ ★★★★★
后端移除 N/A N/A ★★★★★ ★★★★☆ ★★★★★
前端插入 N/A N/A ★★☆☆☆ ★★★★☆ ★★★★★
前端移除 N/A N/A ★★☆☆☆ ★★★★☆ ★★★★★
中间插入 N/A N/A ★★☆☆☆ ★★☆☆☆ ★☆☆☆☆
中间移除 N/A N/A ★★☆☆☆ ★★☆☆☆ ★☆☆☆☆
反初始化 ★★★★★ ★★★★★ ★★★★★ ★★★★☆ ★★★★☆

一些使用上的建议:
Level 1 - 仅仅作为Map使用:采用静态数组
Level 2 - 保存定长数据,使用时也是全部遍历:采用动态数组(长度一开始就固定的话静态数组也行)
Level 3 - 保存不定长数组,需要动态增加的能力,侧重于寻找数据的速度:采用 vector
Level 3 - 保存不定长数组,需要动态增加的能力,侧重于增加删除数据的速度:采用list
Level 4 - 对数据有复杂操作,即需要前后增删数据的能力,又要良好的数据访问速度:采用deque
Level 5 - 对数据中间的增删操作比较多:采用list,建议在排序的基础上,批量进行增删可以对运行效率提供最大的保证
Level 6 - 上述中找不到适合的:组合 STL 容器 或者自己建立特殊的数据结构来实现


为什么需要hash_map 用过map吧?map提供一个很常用的功能,那就是提供key-value的存储和查找功能。
例如,我要记录一个人名和相应的存储,而且随时增加,要快速查找和修改:
岳不群-华山派掌门人,人称君子剑
张三丰-武当掌门人,太极拳创始人
东方不败-第一高手,葵花宝典 ...
这些信息如果保存下来并不复杂,但是找起来比较麻烦。例如我要找"张三丰"的信息,最傻的方法就是取得所有的记录,然后按照名字一个一个比较。如果要速度快,就需要把这些记录按照字母顺序排列,然后按照二分法查找。但是增加记录的时候同时需要保持记录有序,因此需要插入排序。考虑到效率,这就需要用到二叉树。讲下去会没完没了,如果你使用 STL 的map容器,你可以非常方便的实现这个功能,而不用关心其细节。关于map的数据结构细节,感兴趣的朋友可以参看学习 STL map, STL set之数据结构基础。看看map的实现:
#include <map>
#include <string>
using namespace std;
...
map<string, string> namemap; //增加。。。
namemap["岳不群"]="华山派掌门人,人称君子剑";
namemap["张三丰"]="武当掌门人,太极拳创始人";
namemap["东方不败"]="第一高手,葵花宝典"; ... //查找。。
if(namemap.find("岳不群") != namemap.end()){ ... }
不觉得用起来很easy吗?而且效率很高,100万条记录,最多也只要20次的string.compare的比较,就能找到你要找的记录;200万条记录事,也只要用21次的比较。速度永远都满足不了现实的需求。如果有100万条记录,我需要频繁进行搜索时,20次比较也会成为瓶颈,要是能降到一次或者两次比较是否有可能?而且当记录数到200万的时候也是一次或者两次的比较,是否有可能?而且还需要和map一样的方便使用。答案是肯定的。这时你需要hash_map. 虽然hash_map目前并没有纳入C++标准模板库中,但几乎每个版本的 STL 都提供了相应的实现。而且应用十分广泛。在正式使用hash_map之前,先看看hash_map的原理。
1 数据结构:hash_map原理
这是一节让你深入理解hash_map的介绍,如果你只是想囫囵吞枣,不想理解其原理,你倒是可以略过这一节,但我还是建议你看看,多了解一些没有坏处。 hash_map基于hash table(哈希表)。 哈希表最大的优点,就是把数据的存储和查找消耗的时间大大降低,几乎可以看成是常数时间;而代价仅仅是消耗比较多的内存。然而在当前可利用内存越来越多的情况下,用空间换时间的做法是值得的。另外,编码比较容易也是它的特点之一。其基本原理是:使用一个下标范围比较大的数组来存储元素。可以设计一个函数(哈希函数,也叫做散列函数),使得每个元素的关键字都与一个函数值(即数组下标,hash值)相对应,于是用这个数组单元来存储这个元素;也可以简单的理解为,按照关键字为每一个元素“分类”,然后将这个元素存储在相应“类”所对应的地方,称为桶。但是,不能够保证每个元素的关键字与函数值是一一对应的,因此极有可能出现对于不同的元素,却计算出了相同的函数值,这样就产生了“冲突”,换句话说,就 是把不同的元素分在了相同的“类”之中。总的来说,“直接定址”与“解决冲突”是哈希表的两大特点。hash_map,首先分配一大片内存,形成许多桶。是利用hash函数,对key进行映射到不同区域(桶)进行保存。其插入过程是:得到key通过hash函数得到hash值得到桶号(一般都为hash值对桶数求模)存放key和value在桶内。其取值过程是:得到key通过hash函数得到hash值得到桶号(一般都为hash值对桶数求模)比较桶的内部元素是否与key相等,若都不相等,则没有找到。取出相等的记录的value。 hash_map中直接地址用hash函数生成,解决冲突,用比较函数解决。这里可以看出,如果每个桶内部只有一个元素,那么查找的时候只有一次比较。当许多桶内没有值时,许多查询就会更快了(指查不到的时候). 由此可见,要实现哈希表,和用户相关的是:hash函数和比较函数。这两个参数刚好是我们在使用hash_map时需要指定的参数。
2 hash_map 使用 2.1 一个简单实例
不要着急如何把"岳不群"用hash_map表示,我们先看一个简单的例子:随机给你一个ID号和ID号相应的信息,ID号的范围是1~2的31次方。如 何快速保存查找。
#include <hash_map>
#include <string>
using namespace std;
int main(){
hash_map<int, string> mymap;
mymap[9527]="唐伯虎点秋香";
mymap[1000000]="百万富翁的生活";
mymap[10000]="白领的工资底线"; ...
if(mymap.find(10000) != mymap.end()){ ... }
够简单,和map使用方法一样。这时你或许会问?hash函数和比较函数呢?不是要指定么?你说对了,但是在你没有指定hash函数和比较函数的时候, 你会有一个缺省的函数,看看hash_map的声明,你会更加明白。下面是SGI STL 的声明:
template <class _Key, class _Tp, class _HashFcn = hash<_Key>, class _EqualKey = equal_to<_Key>, class _Alloc = __ STL _DEFAULT_ALLOCATOR(_Tp) > class hash_map { ... }
也就是说,在上例中,有以下等同关系:
... hash_map<int, string> mymap;
//等同于: hash_map<int, string, hash<int>, equal_to<int> > mymap;
Alloc我们就不要取关注太多了(希望深入了解Allocator的朋友可以参看标准库 STL :Allocator能做什么)
2.2 hash_map 的hash函数 hash< int>到底是什么样子?
看看源码: struct hash<int> { size_t operator()(int __x) const { return __x; } };
原来是个函数对象。在SGI STL 中, 提供了以下hash函数:
struct hash<char*>
struct hash<const char*>
struct hash<char>
struct hash<unsigned char>
struct hash<signed char>
struct hash<short>
struct hash<unsigned short>
struct hash<int>
struct hash<unsigned int>
struct hash<long>
struct hash<unsigned long>
也就是说,如果你的key使用的是以上类型中的一种,你都可以使用缺省的hash函数。当然你自己也可以定义自己的hash函数。对于自定义变量,你只能 如此,例如对于string,就必须自定义hash函数。
例如:
struct str_hash{
size_t operator()(const string& str) const {
unsigned long __h = 0; for (size_t i = 0 ; i < str.size() ; i ++) __h = 5*__h + str[i];
return size_t(__h);
}
};
//如果你希望利用系统定义的字符串hash函数,你可以这样写:
struct str_hash{
size_t operator()(const string& str) const {
return return __ stl _hash_string(str.c_str());
}
};

在声明自己的哈希函数时要注意以下几点:
使用struct,然后重载operator().返回是size_t 参数是你要hash的key的类型。函数是const类型的。如果这些比较难记,最简单的方法就是照猫画虎,找一个函数改改就是了。
现在可以对开头的"岳不群"进行哈希化了.直接替换成下面的声明即可:map<string, string> namemap;//改为: hash_map<string, string, str_hash> namemap;
其他用法都不用边。
当然不要忘了吧str_hash的声明以及头文件改为hash_map。
你或许会问:比较函数呢?别着急,这里就开始介绍hash_map中的比较函数。

2.3 hash_map 的比较函数
在map中的比较函数,需要提供less函数。如果没有提供,缺省的也是less<Key>。在hash_map中,要比较桶内的数据和key是否相等,因此需要的是是否等于的函数:equal_to<Key>。先看看equal_to的源码://本代码可以从SGI STL //先看看binary_function函数声明,其实只是定义一些类型而已。
template <class _Arg1, class _Arg2, class _Result> struct binary_function {
typedef _Arg1 first_argument_type;
typedef _Arg2 second_argument_type;
typedef _Result result_type;
}; //看看equal_to的定义:
template <class _Tp> struct equal_to : public binary_function<_Tp,_Tp,bool>{
bool operator()(const _Tp& __x, const _Tp& __y) const {
return __x == __y;
}
};

如果你使用一个自定义的数据类型,如struct mystruct, 或者const char* 的字符串,如何使用比较函数?使用比较函数,有两种方法. 第一种是:重载==操作符,利用equal_to;看看下面的例子:
struct mystruct{
int iID;
int len;
bool operator==(const mystruct & my) const{
return (iID==my.iID) && (len==my.len) ;
}
};
这样,就可以使用equal_to< mystruct>作为比较函数了。另一种方法就是使用函数对象。自定义一个比较函数体:
struct compare_str{
bool operator()(const char* p1, const char*p2) const{
return strcmp(p1,p2)==0;
}
};
有了compare_str,就可以使用hash_map了。
typedef hash_map<const char*, string, hash<const char*>, compare_str> StrIntMap;
StrIntMap namemap;
namemap["岳不群"]="华山派掌门人,人称君子剑";
namemap["张三丰"]="武当掌门人,太极拳创始人";
namemap["东方不败"]="第一高手,葵花宝典";
2.4 hash_map 函数 hash_map的函数和map的函数差不多。具体函数的参数和解释,请参看: STL 编程手册:Hash_map,这里主要介绍几个常用函数。 hash_map(size_type n) 如果讲究效率,这个参数是必须要设置的。n 主要用来设置hash_map 容器中hash桶的个数。桶个数越多,hash函数发生冲突的概率就越小,重新申请内存的概率就越小。n越大,效率越高,但是内存消耗也越大。 const_iterator find(const key_type& k) const. 用查找,输入为键值,返回为迭代器。 data_type& operator[](const key_type& k) . 这是我最常用的一个函数。因为其特别方便,可像使用数组一样使用。不过需要注意的是,当你使用[key ]操作符时,如果容器中没有key元素,这就相当于自动增加了一个key元素。因此当你只是想知道容器中是否有key元素时,你可以使用find。如果你 希望插入该元素时,你可以直接使用[]操作符。 insert 函数。在容器中不包含key值时,insert函数和[]操作符的功能差不多。但是当容器中元素越来越多,每个桶中的元素会增加,为了保证效 率,hash_map会自动申请更大的内存,以生成更多的桶。因此在insert以后,以前的iterator有可能是不可用的。 erase 函数。在insert的过程中,当每个桶的元素太多时,hash_map可能会自动扩充容器的内存。但在sgi stl 中是erase并不自动回收内存。因此你调用erase后,其他元素的iterator还是可用的。
3 相关 hash容器
hash 容器除了hash_map之外,还有hash_set, hash_multimap, has_multiset, 这些容器使用起来和set, multimap, multiset的区别与hash_map和map的区别一样,我想不需要我一一细说了吧。
4 其他 这里列几个常见问题,应该对你理解和使用hash_map比较有帮助。
4.1 hash_map和map的区别在哪里? 构造函数。hash_map需要hash函数,等于函数;map只需要比较函数(小于函数). 存储结构。hash_map采用hash表存储,map一般采用红黑树(RB Tree)实现。因此其memory数据结构是不一样的。
4.2 什么时候需要用hash_map,什么时候需要用map? 总体来说,hash_map 查找速度会比map快,而且查找速度基本和数据数据量大小,属于常数级别;而map的查找速度是log(n)级别。并不一定常数就比log(n) 小,hash还有hash函数的耗时,明白了吧,如果你考虑效率,特别是在元素达到一定数量级时,考虑考虑hash_map。但若你对内存使用特别严格, 希望程序尽可能少消耗内存,那么一定要小心,hash_map可能会让你陷入尴尬,特别是当你的hash_map对象特别多时,你就更无法控制了,而且 hash_map的构造速度较慢。 现在知道如何选择了吗?权衡三个因素: 查找速度, 数据量, 内存使用。 这里还有个关于hash_map和map的小故事,看看:http://dev.csdn.net/Develop/article/14 /14019.shtm
4.3 如何在hash_map中加入自己定义的类型? 你只要做两件事, 定义hash函数,定义等于比较函数。下面的代码是一个例子: -bash-2.05b$ cat my.cpp
#include <hash_map>
#include <string>
#include <iostream>
using namespace std; //define the class
class ClassA{
public:
ClassA(int a):c_a(a){}
int getvalue()const { return c_a;}
void setvalue(int a){c_a;}
private:
int c_a;
}; //1 define the hash function
struct hash_A{
size_t operator()(const class ClassA & A)const{ //return hash<int>(classA.getvalue());
return A.getvalue();
}
}; //2 define the equal function
struct equal_A{
bool operator()(const class ClassA & a1, const class ClassA & a2)const{
return a1.getvalue() == a2.getvalue();
}
};
int main() {
hash_map<ClassA, string, hash_A, equal_A> hmap;
ClassA a1(12);
hmap[a1]="I am 12";
ClassA a2(198877);
hmap[a2]="I am 198877";
cout<<hmap[a1]<<endl;
cout<<hmap[a2]<<endl;
return 0;
}

你可能感兴趣的:(STL容器大汇总)