STL Container Usage Tips3. std::vector object model
关于vector这种容器,需要澄清的几个概念或者认识:
1. 对于vector来说,一个元素是否属于某个给定的vector,并不是以该元素的值为评判标准的,而是以
它的地址为标准。这就是为什么vector没有find函数的原因;
2. 迭代器失效的真正含义有这样几种情况:(1) 它指向的对象被彻底销毁了,它成了野指针;(2) 它虽然
不是野指针,但是它指向的对象已经不是原来的那个对象了;(3) 它虽然不是野指针,但是它指向的对
象已经不属于原来的那个容器了,这个就是归属问题;
3. 在调用insert、erase、assign、resize、swap等函数之后都可能使先前获得的迭代器、指针、引用
失效。例如,它们都可能使先前获得的迭代器end()失效,即不再指向现在的end()。特别是当发生内
存重分配的情况下以及调用swap()函数以后,所有已经获得的迭代器等都将失效;
4. 具体地,凡是增加元素的操作都可能使先前获得的迭代器等失效,但是站在我们开发人员的角度,我
们并不清楚哪次增加会导致内存重分配,所以为保险起见还是重新获取迭代器为妙。但是,删除元素
操作不会导致容量缩减,因此不需要内存重分配,先前获得的迭代器、指针、引用等可能并不真的失
效,不过它们未必仍然指向原来的元素对象(如果是删除末尾元素,则原来指向它的迭代器失效)。
T1 T2 T3 T4 T5 T6
start
finish
end_of_storage
std::vector<T>
3
5. std::vector
这样呢?
std::vector<int> V; // 假设有一个vector容器
………… // 添加很多元素
for (std::vector<int>::iterator itFirst = V.begin();
itFirst != V.end(); ) // 每次循环都重新获取end()
{
if (*itFirst == 100)
V.erase(itFirst++); // 先指向下一个元素位置,然后删除当前元素
else
++itFirst;
}
5
6. std::vector
正确的方法:
std::vector<int> V; // 假设有一个vector容器
………… // 添加很多元素
for (std::vector<int>::iterator itFirst = V.begin();
itFirst != V.end(); ) // 每次循环都重新获取end()
{
if (*itFirst == 100)
itFirst = V.erase(itFirst); // 返回逻辑上的下一个元素位置
else
++itFirst;
}
6
8. typedef std::vector<int> IntVector;
IntVector A(10, 5), B(5, 10);
IntVector::iterator ix = A.begin();
B.erase(ix); // ix并不属于B!
B.insert(ix, 20); // ix并不属于B!
IntVector::iterator it = B.end();
it += 2;
B.erase(it); // it不在有效范围内!
B.insert(it, 20); // it不在有效范围内!
int orphan = 30;
IntVector::iterator io(&orphan);
B.erase(io); // io不属于B,更不在有效范围内!
B.insert(io, 20); // io不属于B,更不在有效范围内!
这个问题编译器无能为力,只能在runtime时进行检查。SafeSTL以及较新的MSVC++提供的
STL都具备这个能力,并且只在Debug版本中有效。Release build时自动去掉检查代码。
std::vector
5 5 5 5 5 5 5 5 5 5A:
10B: 10 10 10 10
it
ix
8
9. std::list object model
关于list这种顺序容器,它很多方面都和vector是一样的。不同的方面有:
1. List在任何位置的插入、删除元素动作的复杂度是O(1)的;
2. List的增加元素的操作不会使任何迭代器失效;删除元素的操作除了使指向被删除元素的
迭代器、指针、引用失效外,其他元素的迭代器等都不会失效;特别地,end()始终不会失
效,而且只要list对象没有销毁它的end()就是不变的,因此我们在循环处理list中的元素时
就不需要反复重新计算end();
list<T> lst1;
_head ●
size
allocator
存储分配器
空白
●
●
data
●
●
data
●
●
data
●
●...
...
lst1.end() lst1.begin() list::Node
list容器中的有效元素
●
next
●
prev
Heap
可静态创建也
可动态创建
9
有些实现版本将空白结点直接包含在list对象内部,
并且没有data域,如SGI的实现;而有的是用一个
指针指向空白结点,如MSVC++的实现。这都是实
现细节,并不影响list的接口语义和性能要求。
10. std::list
在遍历list的过程中删除特定的元素,下面的做法都是可以的:
std::list<int> L; // 假设有一个list容器
………… // 添加很多元素
std::list<int>::iterator itFirst = L.begin(); // 只需一次
std::list<int>::iterator const itLast = L.end(); // 只需一次
while (itFirst != itLast)
{
if (*itFirst == 100)
itFirst = L.erase(itFirst); // 返回逻辑上的下一个元素位置
else
++itFirst;
}
10
11. std::list
std::list<int> L; // 假设有一个list容器
………… // 添加很多元素
std::list<int>::iterator itFirst = L.begin();
std::list<int>::iterator const itLast = L.end();
while (itFirst != itLast)
{
if (*itFirst == 100)
L.erase(itFirst++);
else
++itFirst;
}
11
12. std::list
std::list<int> L; // 假设有一个list容器
………… // 添加很多元素
for (std::list<int>::iterator itFirst = L.begin();
itFirst != V.end(); )
{
if (*itFirst == 100)
L.erase(itFirst++);
else
++itFirst;
}
12
13. std::list
std::list<int> L; // 假设有一个list容器
………… // 添加很多元素
L.remove(100);
3. List实现了一个双向环状链表;
4. Header结点的作用:方便了begin()/end()/rbegin()/rend()的实现,并且保证了它们的复杂
度为O(1);
5. List专门提供 的 函数:sort(), reverse(), merge(), unique(), remove_if(), remove(),
splice();
13
15. 关于set/map这种关联式容器,需要澄清的几个概念或者认识:
1. Set其实对应的是数学上的集合概念,而我们知道集合的元素是无所谓顺序的。例如任意
一个整数集合S={5, 20, 30, -2, 6, -100},元素是无序的,你不能说元素20排在元素30的
前面,也不能说元素-100排在元素6的后面。因此,erase()函数的标准形式是返回void,
而不是所谓的“指向下一个元素的iterator”;
2. map就是一张‘n行2列’的表格,或者说是pair<key, value>的一个集合,它的pair同样
没有先后顺序;
3. 基于二叉平衡搜索树的实现,按照用户指定的比较函数对元素排序(比如std::less<T>或
者std::greater<T>),这纯粹是为了提高查找性能,要不然树的空间开销如此之大又是何
必呢?计算当前节点的下一个节点即iterator++的时间复杂度并不是O(1)的,而是
O(log2N)的(即接近于二叉树的深度,这一点与顺序容器不同);iterator--也是一样。
4. 此处,‘下一个’‘上一个’均是指元素自动排序后的逻辑顺序,不是在Tree上的位置顺
序(实际上,Tree中的结点也没有什么所谓的位置顺序),这是实现需要,与set/map的概
念并不矛盾;
5. Set/map并非一定要用rb-tree实现,其实也可以用list实现;
6. 只要set/map对象还在,其end()就是不变的,begin()虽然会变但是复杂度总是O(1)的。
因此,就像list那样,在遍历时也不需要反复计算end();
std::set/std::map
15
17. 在遍历set的过程中删除特定的元素:
std::set<int> S; // 假设有一个set容器
………… // 添加很多元素
std::set<int>::iterator itFirst = S.begin(); // 只需一次
std::set<int>::iterator const itLast = S.end(); // 只需一次
while (itFirst != itLast)
{
if (*itFirst == 100)
S.erase(itFirst++); // 返回排序逻辑上的下一个元素位置
else
++itFirst;
}
std::set/std::map
17
19. std::set/std::map
建议的做法:
typedef std::list<BigObject> BigObjectList;
typedef boost::shared_ptr<BigObjectList> BigObjectListSmartPtr;
std::map<std::string/*name*/, BigObjectListSmartPtr> M;
BigObjectListSmartPtr pL(new BigObjectList);
BigObject a, b, c, d;
pL->push_back(a); // 添加很多元素
pL->push_back(b);
pL->push_back(c);
pL->push_back(d);
M[“张三”] = pL;
BigObject A[10];
BigObjectListSmartPtr pT(new BigObjectList);
pT->assign(A, A+10);
M[“李四”] = pT;
……
M[“张三”]->sort();
19
20. Hashtable/unordered_set/unordered_map object model
1. SGI STL hashtable的实现使用开链法来解决hash函数的结果冲突问题;
2. Hashtable没有reverse_iterator,因此不支持反向遍历;
3. begin()的复杂度是O(N);end()为O(1);iterator++的复杂度接近O(1);
4. Hashtable对元素不进行自动排序,因此hashset/hashmap在C++最新标准中被命名
为unordered_set/unordered_map;
20
21. 5. Hash函数的质量好坏直接影响到find()/insert()/erase()函数的性能;
6. 好的Hash函数能使得hashtable的find()/insert()/erase()等函数的性能接近
O(1),也就是说任何一个单链表的长度都接近1,而且在buckets中的分布比较均匀;
最坏的Hash函数使得任何key的hash值都相等即落到同一个bucket里,因此使整个
hashtable退化为一个单链表;
7. 为了避免任何一个单链表过长,hashtable实现采取了自动扩张策略,即当hashtable
中包含的元素总数即将超过当前bucket的数量时,就会重建buckets vector(仅需重建
vector即可)。因此最坏情况下,任何一个单链表的长度都不会超过当前bucket的数量;
8. Hashtable初始化时,取下表中不小于用户给定的size的最小素数作为buckets的实际
大小(不同的实现使用不同的素数集合):
static const unsigned long __stl_prime_list[28] =
{
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
};
9. 当Hashtable需要重建buckets vector时,有些实现直接按照2倍于原来的size的策
略来扩展(如MSVC++),而SGI实现按照上表取下一个素数为新的size。
Hashtable/unordered_set/unordered_map
22. 10. 当重建buckets vector后,由于‘模(%)运算’的base变大了,所以必须对所有元素
重新hash并计算新的bucket编号,这样的话原来的单链表就会被打散重新排列;
11. 在元素是大对象以及相同规模的元素数量并且规模达到一定数量级的情况下 比较
hashtable、std::vector和binary_tree的性能(仅针对最常用的三类操作):
12. Example:ndm_db_conn.hh,_SQL_STMT_MAP_
Hashtable/unordered_set/unordered_map
operations hashtable std::vector binary_tree
insert(V) 如果不需要重建buckets,则接近
O(1);
如果需要扩展和重建buckets,则所
有元素对象需要重新hash并连接到
新的bucket上,但是没有拷贝每个
元素对象的开销。所以,这种情况
下的开销仍然比vector要好很多
如果不需要重建vector,那么
只有在末尾插入(push_back)
时不需要搬移元素对象(拷贝元
素);
如果需要重建vector,那么搬
移元素的开销巨大;
平均为O(log2N),
但是有时需要重
新平衡树结构,
因此会伴随子树
旋转和红黑结点
转换等复杂操作
find(key) 平均接近O(1); 不直接提供find,但平均O(N);O(log2N)
erase(key) 平均接近O(1); 只有在末尾删除元素时不需要
搬移后面的元素;
同insert()操作
23. Quiz
相同元素规模的情况下,上述各个容器类型中,哪种的空间开销最大?其次
是谁?
如果不考虑std::map/std::set实现中的排序要求,它们是否可以用hash
table来实现?
在结构上,std::set<T> rb_tree<T>, 那么std::map<key, value>
rb_tree< ??? > ?
同样道理,hash_set<T> hashtable<T>, hash_map<key, value>
hashtable< ??? > ?
对于给定的hash_map/hash_set(hash函数已经给定),具有相同hash值的
不同key或不同元素一定会落入同一个bucket里。那么具有不同hash值的不
同key或不同元素是否一定落入不同的bucket里?
对于hash_multiset/hash_multimap,当insert()操作导致buckets需要重建
时,原来具有相同value/key的那些元素在新的hashtable里面是否仍然位于
同一个bucket里面并且挨在一起?[不一定,因为mod的base变了]
使用开链法实现Hashtable时,为什么不使用双向链表?
23