map是C++ STL中的关联容器,存储的是键值对(key-value),可以通过key快速索引到value。map容器中的数据是自动排序的,其排序方式是严格的弱排序(stick weak ordering),即在判断Key1和Key2的大小时,使用“<”而不是“<=”。map 使用二叉搜索树实现,STL map的底层实现是红黑树。
map有几个值得注意的地方:map的赋值运算是深拷贝,即调用map_a = map_b后,map_a中的元素拥有独立的内存空间。map的[]运算比较有意思,当元素不存在的时候会插入新的元素;在map中查找key是否存在时可以用find或count方法,find查找成功返回的是迭代器,查找失败则返回mymap.end(),说明该key不存在;map中的key不允许重复,其count方法只能返回0或1。
map定义
map的所有元素都是pair,同时具备key和value,其中pair的第一个元素作为key,第二个元素作为value。map不允许相同key出现,并且所有pair根据key来自动排序,其中pair的定义在如下:
template
struct pair {
typedef T1 first_type;
typedef T2 second_type;
T1 first;
T2 second;
pair() : first(T1()), second(T2()) { }
pair(const T1& a, const T2& b) : first(a), second(b) { }
};
从定义中看到pair使用模板化的struct来实现的,成员变量默认都是public类型。map的key不能被修改,但是value可以被修改,STL中的map是基于红黑树来实现的,因此可以认为map是基于红黑树封装了一层map的接口,底层的操作都是借助于RB-Tree的特性来实现的。
template
class map {
public:
typedef Key key_type; //key类型
typedef T data_type; //value类型
typedef T mapped_type;
typedef pair
typedef Compare key_compare; //用于key比较的函数
private:
//内部采用RBTree作为底层容器
typedef rb_tree
rep_type t; //t为内部RBTree容器
public:
//iterator_traits相关
typedef typename rep_type::const_pointer pointer;
typedef typename rep_type::const_pointer const_pointer;
typedef typename rep_type::const_reference reference;
typedef typename rep_type::const_reference const_reference;
typedef typename rep_type::difference_type difference_type;
//迭代器相关
typedef typename rep_type::iterator iterator;
typedef typename rep_type::const_iterator const_iterator;
typedef typename rep_type::const_reverse_iterator reverse_iterator;
typedef typename rep_type::const_reverse_iterator const_reverse_iterator;
typedef typename rep_type::size_type size_type;
//迭代器函数
iterator begin() { return t.begin(); }
const_iterator begin() const { return t.begin(); }
iterator end() { return t.end(); }
const_iterator end() const { return t.end(); }
reverse_iterator rbegin() { return t.rbegin(); }
const_reverse_iterator rbegin() const { return t.rbegin(); }
reverse_iterator rend() { return t.rend(); }
const_reverse_iterator rend() const { return t.rend(); }
//容量函数
bool empty() const { return t.empty(); }
size_type size() const { return t.size(); }
size_type max_size() const { return t.max_size(); }
//key和value比较函数
key_compare key_comp() const { return t.key_comp(); }
value_compare value_comp() const { return value_compare(t.key_comp()); }
//运算符
T& operator[](const key_type& k)
{
return (*((insert(value_type(k, T()))).first)).second;
}
friend bool operator== __STL_NULL_TMPL_ARGS (const map&, const map&);
friend bool operator< __STL_NULL_TMPL_ARGS (const map&, const map&);
}
红黑树特点
性质1. 结点是红色或黑色。
性质2. 根结点是黑色。
性质3. 所有叶子都是黑色。(叶子是NIL结点)
性质4. 每个红色结点的两个子结点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色结点)
性质5. 从任一节结点到其每个叶子的所有路径都包含相同数目的黑色结点。
这些约束强制了红黑树的关键性质: 从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。结果是这个树大致上是平衡的。因为操作比如插入、删除和查找某个值的最坏情况时间都要求与树的高度成比例,这个在高度上的理论上限允许红黑树在最坏情况下都是高效的,而不同于普通的二叉查找树。
是性质4导致路径上不能有两个连续的红色结点确保了这个结果。最短的可能路径都是黑色结点,最长的可能路径有交替的红色和黑色结点。因为根据性质5所有最长的路径都有相同数目的黑色结点,这就表明了没有路径能多于任何其他路径的两倍长。
因为红黑树是一种特化的二叉查找树,所以红黑树上的只读操作与普通二叉查找树相同。
Node 有 5 个成员,除了 left、right、data,还有 color 和 parent。
C++实现,位于bits/stl_tree.h /** * Non-template code **/ enum rb_tree_color { kRed, kBlack }; struct rb_tree_node_base { rb_tree_color color_; rb_tree_node_base* parent_; rb_tree_node_base* left_; rb_tree_node_base* right_; }; /** * template code **/ templatestruct rb_tree_node : public rb_tree_node_base { Value value_field_; };
见下图。
color 的存在很好理解,红黑树每个节点非红即黑,需要保存其颜色(颜色只需要 1-bit 数据,一种节省内存的优化措施是把颜色嵌入到某个指针的最高位或最低位,Linux 内核里的 rbtree 是嵌入到 parent 的最低位);parent 的存在使得非递归遍历成为可能,后面还将再谈到这一点。
Tree 有更多的成员,它包含一个完整的 rb_tree_node_base(color/parent/left/right),还有 node_count 和 key_compare 这两个额外的成员。
这里省略了一些默认模板参数,如 key_compare 和 allocator。 template// key_compare and allocator class rb_tree { public: typedef std::less key_compare; typedef rb_tree_iterator iterator; protected: struct rb_tree_impl // : public node_allocator { key_compare key_compare_; rb_tree_node_base header_; size_t node_count_; }; rb_tree_impl impl_; }; template // key_compare and allocator class map { public: typedef std::pair value_type; private: typedef rb_tree rep_type; rep_type tree_; };
见下图。这是一颗空树,其中阴影部分是 padding bytes,因为 key_compare 通常是 empty class。(allocator 在哪里?)
rb_tree 中的 header 不是 rb_tree_node 类型,而是 rb_tree_node_base,因此 rb_tree 的 size 是 6 * sizeof(void*),与模板类型参数无关。在 32-bit 上是 24 字节,在 64-bit 上是 48 字节,很容易用代码验证这一点。另外容易验证 std::set 和 std::map 的 sizeof() 是一样的。
注意 rb_tree 中的 header 不是 root 节点,其 left 和 right 成员也不是指向左右子节点,而是指向最左边节点(left_most)和最右边节点(right_most),后面将会介绍原因,是为了满足时间复杂度。header.parent 指向 root 节点,root.parent 指向 header,header 固定是红色,root 固定是黑色。在插入一个节点后,数据结构如下图。
继续插入两个节点,假设分别位于 root 的左右两侧,那么得到的数据结构如下图所示(parent 指针没有全画出来,因为其指向很明显),注意 header.left 指向最左侧节点,header.right 指向最右侧节点。
rb_tree 的 iterator 的数据结构很简单,只包含一个 rb_tree_node_base 指针,但是其++/--操作却不见得简单(具体实现函数不在头文件中,而在 libstdc++ 库文件中)。
// defined in library, not in header rb_tree_node_base* rb_tree_increment(rb_tree_node_base* node); // others: decrement, reblance, etc. templatestruct rb_tree_node : public rb_tree_node_base { Value value_field_; }; template struct rb_tree_iterator { Value& operator*() const { return static_cast *>(node_)->value_field_; } rb_tree_iterator& operator++() { node_ = rb_tree_increment(node_); return *this; } rb_tree_node_base* node_; };
end() 始终指向 header 节点,begin() 指向第一个节点(如果有的话)。因此对于空树,begin() 和 end() 都指向 header 节点。对于 1 个元素的树,迭代器的指向如下。
end() 始终指向 header 节点,begin() 指向第一个节点(如果有的话)。因此对于空树,begin() 和 end() 都指向 header 节点。对于 1 个元素的树,迭代器的指向如下。
对于前面 3 个元素的树,迭代器的指向如下。
思考,对 std::set
rb_tree 的 iterator 的递增递减操作并不简单。考虑下面这棵树,假设迭代器 iter 指向绿色节点3,那么 ++iter 之后它应该指向灰色节点 4,再 ++iter 之后,它应该指向黄色节点 5,这两步递增都各走过了 2 个指针。
对于一颗更大的树(下图),假设迭代器 iter 指向绿色节点7,那么 ++iter 之后它应该指向灰色节点 8,再 ++iter 之后,它应该指向黄色节点 9,这两步递增都各走过了 3 个指针。
由此可见,rb_tree 的迭代器的每次递增或递减不能保证是常数时间,最坏情况下可能是对数时间(即与树的深度成正比)。那么用 begin()/end() 迭代遍历一棵树还是不是 O(N)?换言之,迭代器的递增或递减是否是分摊后的(amortized)常数时间?
另外注意到,当 iter 指向最右边节点的时候(7 或 15),++iter 应该指向 header 节点,即 end(),这一步是 O(log N)。同理,对 end() 做--,复杂度也是 O(log N),后面会用到这一事实。
对于 rb_tree 的数据结构,我们有几个问题:
1. 为什么 rb_tree 没有包含 allocator 成员?
2. 为什么 iterator 可以 pass-by-value?
3. 为什么 header 要有 left 成员,并指向 left most 节点?
4. 为什么 header 要有 right 成员,并指向 right most 节点?
5. 为什么 header 要有 color 成员,并且固定是红色?
6. 为什么要分为 rb_tree_node 和 rb_tree_node_base 两层结构,引入 node 基类的目的是什么?
7. 为什么 iterator 的递增递减是分摊(amortized)常数时间?
8. 为什么 muduo 网络库的 Poller 要用 std::map
我认为,在面试的时候能把上面前 7 个问题答得头头是道,足以说明对 STL 的 map/set 掌握得很好。下面一一解答。
因为默认的 allocator 是 empty class (没有数据成员,也没有虚表指针vptr),为了节约 rb_tree 对象的大小,STL 在实现中用了 empty base class optimization。具体做法是 std::map 以 rb_tree 为成员,rb_tree 以 rb_tree_impl 为成员,而 rb_tree_impl 继承自 allocator,这样如果 allocator 是 empty class,那么 rb_tree_impl 的大小就跟没有基类时一样。其他 STL 容器也使用了相同的优化措施,因此 std::vector 对象是 3 个 words,std::list 对象是 2 个 words。boost 的 compressed_pair 也使用了相同的优化。
我认为,对于默认的 key_compare,应该也可以实施同样的优化,这样每个 rb_tree 只需要 5 个 words,而不是 6 个。
读过《Effective C++》的想必记得其中有个条款是 Prefer pass-by-reference-to-const to pass-by-value,即对象尽量以 const reference 方式传参。这个条款同时指出,对于内置类型、STL 迭代器和 STL 仿函数,pass-by-value 也是可以的,一般没有性能损失。
在 x86-64 上,对于 rb_tree iterator 这种只有一个 pointer member 且没有自定义 copy-ctor 的 class,pass-by-value 是可以通过寄存器进行的(例如函数的头 4 个参数,by-value 返回对象算一个参数),就像传递普通 int 和指针那样。因此实际上可能比 pass-by-const-reference 略快,因为callee 减少了一次 deference。
同样的道理,muduo 中的 Date class 和 Timestamp class 也是明确设计来 pass-by-value 的,它们都只有一个 int/long 成员,按值拷贝不比 pass reference 慢。如果不分青红皂白一律对 object 使用 pass-by-const-reference,固然算不上什么错误,不免稍微显得知其然不知其所以然罢了。
原因很简单,让 begin() 函数是 O(1)。假如 header 中只有 parent 没有 left,begin() 将会是 O(log N),因为要从 root 出发,走 log N 步,才能到达 left most。现在 begin() 只需要用 header.left 构造 iterator 并返回即可。
这个问题不是那么显而易见。end() 是 O(1),因为直接用 header 的地址构造 iterator 即可,不必使用 right most 节点。在源码中有这么一段注释:
bits/stl_tree.h // Red-black tree class, designed for use in implementing STL // associative containers (set, multiset, map, and multimap). The // insertion and deletion algorithms are based on those in Cormen, // Leiserson, and Rivest, Introduction to Algorithms (MIT Press, // 1990), except that // // (1) the header cell is maintained with links not only to the root // but also to the leftmost node of the tree, to enable constant // time begin(), and to the rightmost node of the tree, to enable // linear time performance when used with the generic set algorithms // (set_union, etc.) // // (2) when a node being deleted has two children its successor node // is relinked into its place, rather than copied, so that the only // iterators invalidated are those referring to the deleted node.
这句话的意思是说,如果按大小顺序插入元素,那么将会是线性时间,而非 O(N log N)。即下面这段代码的运行时间与 N 成正比:
// 在 end() 按大小顺序插入元素 std::sets; const int N = 1000 * 1000 for (int i = 0; i < N; ++i) s.insert(s.end(), i);
在 rb_tree 的实现中,insert(value) 一个元素通常的复杂度是 O(log N)。不过,insert(hint, value) 除了可以直接传 value_type,还可以再传一个 iterator 作为 hint,如果实际的插入点恰好位于 hint 左右,那么分摊后的复杂度是 O(1)。对于这里的情况,既然每次都在 end() 插入,而且插入的元素又都比 *(end()-1) 大,那么 insert() 是 O(1)。在具体实现中,如果 hint 等于 end(),而且 value 比 right most 元素大,那么直接在 right most 的右子节点插入新元素即可。这里 header.right 的意义在于让我们能在常数时间取到 right most 节点的元素,从而保证 insert() 的复杂度(而不需要从 root 出发走 log N 步到达 right most)。具体的运行时间测试见 https://gist.github.com/4574621#file-tree-bench-cc ,测试结果如下,纵坐标是每个元素的耗时(微秒),其中最上面的红线是普通 insert(value),下面的蓝线和黑线是 insert(end(), value),确实可以大致看出 O(log N) 和 O(1) 关系。具体的证明见《算法导论(第 2 版》第 17 章中的思考题 17-4。
但是,根据测试结果,前面引用的那段注释其实是错的,std::inserter() 与 set_union() 配合并不能实现 O(N) 复杂度。原因是 std::inserter_iterator 会在每次插入之后做一次 ++iter,而这时 iter 正好指向 right most 节点,其++操作是 O(log N) 复杂度(前面提到过 end() 的递减是 O(log N),这里反过来也是一样)。于是把整个算法拖慢到了 O(N log N)。要想 set_union() 是线性复杂度,我们需要自己写 inserter,见上面代码中的 end_inserter 和 at_inserter,此处不再赘言。
这是一个实现技巧,对 iterator 做递减时,如果此刻 iterator 指向 end(),那么应该走到 right most 节点。既然 iterator 只有一个数据成员,要想判断当前是否指向 end(),就只好判断 (node_->color_ == kRed && node_->parent_->parent_ == node_) 了。
这是为了把迭代器的递增递减、树的重新平衡等复杂函数从头文件移到库文件中,减少模板引起的代码膨胀(set
严格的证明需要用到分摊分析(amortized analysis),一来我不会,二来写出来也没多少人看,这里我用一点归纳的办法来说明这一点。考虑一种特殊情况,对前面图中的满二叉树(perfect binary tree)从头到尾遍历,计算迭代器一共走过多少步(即 follow 多少次指针),然后除以节点数 N,就能得到平均每次递增需要走多少步。既然红黑树是平衡的,那么这个数字跟实际的步数也相差不远。
对于深度为 1 的满二叉树,有 1 个元素,从 begin() 到 end() 需要走 1 步,即从 root 到 header。
对于深度为 2 的满二叉树,有 3 个元素,从 begin() 到 end() 需要走 4 步,即 1->2->3->header,其中从 3 到 header 是两步
对于深度为 3 的满二叉树,有 7 个元素,从 begin() 到 end() 需要走 11 步,即先遍历左子树(4 步)、走 2 步到达右子树的最左节点,遍历右子树(4 步),最后走 1 步到达 end(),4 + 2 + 4 + 1 = 11。
对于深度为 4 的满二叉树,有 15 个元素,从 begin() 到 end() 需要走 26 步。即先遍历左子树(11 步)、走 3 步到达右子树的最左节点,遍历右子树(11 步),最后走 1 步到达 end(),11 + 3 + 11 + 1 = 26。
后面的几个数字依次是 57、120、247
对于深度为 n 的满二叉树,有 2^n - 1 个元素,从 begin() 到 end() 需要走 f(n) 步。那么 f(n) = 2*f(n-1) + n。
然后,用递推关系求出 f(n) = sum(i * 2 ^ (n-i)) = 2^(n+1) - n - 2(这个等式可以用归纳法证明)。即对于深度为 n 的满二叉树,从头到尾遍历的步数小于 2^(n+1) - 2,而元素个数是 2^n - 1,二者一除,得到平均每个元素需要 2 步。因此可以说 rb_tree 的迭代器的递增递减是分摊后的常数时间。
似乎还有更简单的证明方法,在从头到尾遍历的过程中,每条边(edge)最多来回各走一遍,一棵树有 N 个节点,那么有 N-1 条边,最多走 2*(N-1)+1 步,也就是说平均每个节点需要 2 步,跟上面的结果相同。