本文涉及到 SGI STL 源码的文件主要是 stl_hash_set.hstl_hash_map.h 等文件。

1. hashset 和 hash_multi_set

需要说明的是,STL 标准只规范了复杂度与接口,并没有规范实现方法,但 STL 实现的版本中 set 大多以 RB-tree 为底层机制,SGI STL 在实现了以 RB-tree 为底层机制的 set 外,还实现了以 hashtable 为底层机制的 hashset。
和 set 一样,hashset 的键值(key)和实值(value)是同一个字段,不同的是 set 默认是自动排序的,而 hashset 则是无序的。除此之外,hashset 与 set 的对外接口完全相同。
这里还有一种称为 hash_multi_set 的集合,它同 multiset 类似,允许键值重复,而上面的 hashset 则不允许。下面是 hashset 的定义的主要代码:

本文涉及到 SGI STL 源码的文件主要是 stl_hashtable.hstl_hash_fun.h 等文件。

1. hashtable 简介

在数据结构中我们知道,有种数据结构的插入、删除、查找等操作的性能是常数时间,但需要比元素个数更多的空间,这种数据结构就是哈希表。哈希表的基本思想是,将数据存储在与其数值大小相关的地方,比如对该数取模,然后存储在以余数为下表的数组中。但这样会出现一个问题,就是可能会有多个数据被映射到同一个存储位置,即出现了所谓的“碰撞”。哈希表的主要内容就是解决“碰撞”问题,一般而言有以下几种方法:线性探测、二次探测、开链等。

线性探测

简单而言,就是在出现“碰撞”后,寻找当前位置以后的空档,然后存入。如果找到尾部都没有空档,则从头部重新开始找。只要空间大小比元素个数大,总能找到的。相应的,元素的查找和删除也与普通的数组不同,查找如果直接定位到相应位置并找到或是空档,就可以确定存在或不存在,而如果定位到当前位置非空且与待查找的元素不同,则要依序寻找后续位置的元素,直到找到或移到了空档。删除则是采用懒惰删除策略,即只标记删除记号,实际删除操作则待表格重新整理时再进行。

二次探测

与线性探测类似,但向后寻找的策略是探测距当前位置为平方数的位置,即 $index = H+i^{2}$ 。但这样会有一个问题,那就是能否保证每次探测的是不同的位置,即是否存在某次插入时,探测完一圈后回到自己而出现死循环。

开链

这种方法是将出现冲突的元素放在一个链表中,而哈希表中只存储这些链表的首地址。SGI STL中就是使用这种方法来解决“碰撞”的。

2. hashtable 的数据结构

由于使用开链的方法解决冲突,所以要维护两种数据结构,一个是 hash table,在 STL 中称为 buckets,用 vector 作为容器;另一个是链表,这里没有使用 list 或 slist 这些现成的数据结构,而是使用自定义 __hashtable_node ,相关定义具体如下:

本文涉及到 SGI STL 源码的文件主要是 stl_map.hstl_multimap.hstl_pair.hmap.hmultimap.hmap 等文件。

1. map 简介

map 的特性是,所有元素都是键值对,用一个 pair 表示,pair 的第一个元素是键值(key),第二个元素是实值(value),map 不允许两个元素的键值相同。
与 set 类似的,map 也不允许修改 key 的值,但不同的是可以修改 value 的值,因此 map 的迭代器既不是一种 constant iterators,也不是一种 mutable iterators。同样的,map的插入和删除操作不影响操作之前定义的迭代器的使用(被删除的那个元素除外)。
与 set 不同的是,map 没有交、并、差等运算,只有插入、删除、查找、比较等基本操作。

2. map 的实现

由于 map 的元素是键值对,用 pair 表示,下面是它的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <class _T1, class _T2>
struct pair {
  typedef _T1 first_type;
  typedef _T2 second_type;
  _T1 first; // 两个成员 first 和 second
  _T2 second;
  pair() : first(_T1()), second(_T2()) {} // 构造函数
  pair(const _T1& __a, const _T2& __b) : first(__a), second(__b) {} // 拷贝构造函数
};
template <class _T1, class _T2>
inline bool operator==(const pair<_T1, _T2>& __x, const pair<_T1, _T2>& __y) { // 相等比较
  return __x.first == __y.first && __x.second == __y.second;
}
template <class _T1, class _T2>
inline bool operator<(const pair<_T1, _T2>& __x, const pair<_T1, _T2>& __y) { // 大小比较
  return __x.first < __y.first || (!(__y.first < __x.first) && __x.second < __y.second);
}
template <class _T1, class _T2>
inline pair<_T1, _T2> make_pair(const _T1& __x, const _T2& __y) { // 创建一个 pair
  return pair<_T1, _T2>(__x, __y);
}

本文涉及到 SGI STL 源码的文件主要有 stl_set.hstl_multiset.hset.hmultiset.hset 等文件。

1. set 简介

set 即集合,相比于其他容器有些特别。首先是它的每个元素是唯一的,即不允许有相同的值出现。其次,作为一种关联容器,set 的元素不像 map 那样可以同时拥有实值(value)和键值(key),set 元素的键值就是实值,实值就是键值。
由于 set 的实质和键值相同,共用同一个内存空间,而 set 的底层容器为红黑树(中序遍历有序),因此不能对其键值进行修改,否则会破坏其有序特性。为避免非法修改操作,在SGI STL的实现中,set<T>::iterator 被定义为 RB-tree 底层的 const_iterator,_杜绝写入操作。set 与 list 有一个相似的地方是,元素插入、删除后,之前的迭代器依然有效(被删除的那个元素的迭代器除外)。
我们知道集合有一些特殊的操作,诸如并、交、差等,在STL的 set 中,默认也提供了这些操作,如交集 set_intersection 、联集 set_union 、差集 set_difference 和对称差集 set_symmetric_difference 等。与之前那些线性容器不同的是,这些 set 的操作并不是在 set 内部实现的,而是放在了算法模块(algorithm)中,其具体实现在后面的算法章节中会具体介绍。

2. set 的实现

前面多次提到 set 的底层采用 RB-tree 容器,这是因为 RB-tree 是一种比较高效的平衡二叉搜索树,能够很好的满足元素值唯一的条件,而且查找效率高。由于 RB-tree 已实现了很多操作,因此 set 基本上只是对 RB-tree 进行了一层简单的封装。下面是其实现的主要代码:

本文涉及到 SGI STL 源码的文件主要是 stl_tree.h 这个文件。

0. 关联式容器

之前几篇文章详细介绍了SGI STL中序列式容器的实现,并提到过STL中还有一类关联式的容器。标准的STL管理师容器分为 set(集合)和map(映射表)两大类,以及这两大类的衍生体multiset(多键集合)和multimap(多键映射表),这些容器的底层机制均以RB-Tree(红黑树)完成。RB-Tree是一种非常高效的数据结构,它本质上是一种平衡的二叉搜索树,因而其查找的平均时间复杂度为元素总个数的对数(即logN)。在STL中RB-Tree是一个独立的容器,但并没有对用户的公开接口,仅提供给STL的set和map使用。
SGI STL在标准STL之外,还提供了一类关联式容器——hash table(哈希表),以及以此为低层机制的hash set(散列集合)、hash map(散列映射表)、hash multiset(散列多键集合)和hash multimap(散列多键映射表)。相比于RB-Tree,hash table的时间效率更高,插入、删除、查找的时间复杂度均为常数时间,但需要比元素总个数多得多的空间。
本文接下来主要介绍树及RB-Tree相关的内容,后续文章将具体介绍SGI STL中set、map、hash table的实现。

1. 树与二叉搜索树

树是一种非常常见而且实用的数据结构,几乎所有的操作系统都将文件存放在树状结构里,几乎所有编译器需要实现一个表达式树(expression tree),文件压缩所用的哈夫曼算法也需要用到树状结构,数据库所使用的B-tree则是一种相当复杂的树状结构。
关于树的一些基本概念相信大家都比较熟悉,这里就不赘述了,如果需要可以google或看wikipedia,这里重点重温一下数据结构里的二叉搜索树、平衡二叉搜索树、AVL树。
二叉搜索树:任何节点的键值大于其左子树每一个节点的键值,并小于其右子树中的每一个节点的键值。根据二叉搜索树的定义可知,按照中序遍历该树可以得到一个有序的序列。平均情况下,二叉搜索树可以提供对数时间的插入和访问。其插入和查找的算法也很简单,每次与根节点的键值进行比较,小于根节点的键值则往根节点的左子树插入或查找,大于则往右子树插入或查找,无论是递归实现还是非递归实现都很简单。
平衡二叉搜索树:上面提到二叉搜索数的平均性能为对数时间,这是因为二叉搜索树的深度与数据插入的顺序有关,如果插入的数据本身就比较有序,那么就会产生一个深度过大的树,甚至会退化为一个链表的结构,这中情况下,其查找的效率就是线性时间了。平衡二叉搜索树就是为了解决这个问题而产生的,“平衡”的意义是,没有任何一个节点过深。不同的平衡条件造就出不同的效率表现,以及不同的实现复杂度,如 AVL-TreeRB-TreeAA-Tree _等。他们都比简单的二叉搜索树要复杂,特别是插入和删除操作,但他们可以避免高度不平衡的情况,因而查找时间较快。

本文涉及到 SGI STL 源码的文件有heapstl_heap.hheap.hstl_queue.hqueue 等几个文件。

1. 概述

前面分别介绍了三种各具特色的序列式容器 —— vector、list和deque,他们几乎可以涵盖所有类型的序列式容器了,但本文要介绍的heap则是一种比较特殊的容器。其实,在STL中heap并没有被定义为一个容器,而只是一组算法,提供给priority queue(优先队列)。故名思议,priority queue 允许用户以任何次序将元素放入容器内,但取出时一定是从优先权最高的元素开始取,binary max heap(二元大根堆)即具有这样的特性,因此如果学过max-heap再看STL中heap的算法和priority queue 的实现就会比较简单。

2. priority queue 的数据结构

要实现priority queue的功能,binary search tree(BST)也可以作为其底层机制,但这样的话元素的插入就需要O(logN)的平均复杂度,而且要求元素的大小比较随机,才能使树比较平衡。而binary heap是一种完全二叉树的结构,而且可以使用vector来存储:

1
2
3
4
5
6
7
template <class _Tp, class _Sequence __STL_DEPENDENT_DEFAULT_TMPL(vector<_Tp>),
          class _Compare __STL_DEPENDENT_DEFAULT_TMPL(less<typename _Sequence::value_type>) >
class priority_queue { // in stl_queue.h 文件中
protected:
  _Sequence c; // 使用vector作为数据存储的容器
  _Compare comp;
};

另外只需要提供一组heap算法,即元素插入和删除、获取堆顶元素等操作即可。

本文涉及到 SGI STL 源码的文件有dequestl_deque.hdeque.hstackstl_stack.hqueuestl_queue.h 等几个文件。

1. deque 概述

前面分别介绍了连续式存储的序列容器vector和以节点为单位链接起来的非连续存储的序列容器list,这两者各有优缺点,而且刚好是优缺互补的,那么何不将二者结合利用对方的优点来弥补己方的不足呢,于是这就有了强大的deque。

没错,与我们在数据结构中学到的固定连续空间的双端队列不同,STL中的deque是分段连续的空间通过list链接而成的序列容器,它结合了vector与list的存储特性,但与vector和list都不同的是deque只能在首部或尾部进行插入和删除操作,这个限制在一定程度上简化了deque实现的难度。由于使用分段连续空间链接的方式,所以deque不存在vector那样“因旧空间不足而重新配置新的更大的空间,然后复制元素,再释放原空间”的情形,也不会有list那样每次都只配置一个元素的空间而导致时间性能和空间的利用率低下。

2. deque 的数据结构

deque由一段一段连续空间串接而成,一旦有必要在deque的头部或尾端增加新的空间,便配置一段定量连续的空间,串接在deque的头部或尾端。deque的最大任务,就是在这些分段连续的空间上维护其整体连续的假象,并提供随机存取的接口。deque采用一块所谓的map(注意:不是STL中map容器,而是类似于vector)作为主控(为什么不使用list呢?),这块map是一个连续空间,其中每个元素都是一个指针,指向一段连续的空间,称为缓冲区,它才是deque的真正存储空间。SGI中允许指定缓冲区的大小,默认是512字节。除此之外,还有start和finish两个指针,分别指向第一个缓冲区的第一个元素和最后一个缓冲区的最后一个元素。其数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
inline size_t __deque_buf_size(size_t __size) { // 计算缓冲区的大小
  return __size < 512 ? size_t(512 / __size) : size_t(1);
}
template <class _Tp, class _Alloc> class _Deque_base {
protected:
  _Tp** _M_map; // 指向缓冲区的指针数组首地址
  size_t _M_map_size;  // 指向缓冲区的指针数组的大小
  iterator _M_start; // 指向第一个缓冲区的第一个元素
  iterator _M_finish; // 指向最后一个缓冲区的最后一个元素
};
class deque : protected _Deque_base<_Tp, _Alloc> {
protected:  // Internal typedefs
  typedef pointer* _Map_pointer;
  static size_t _S_buffer_size() { return __deque_buf_size(sizeof(_Tp)); }
};

本文涉及到 SGI STL 源码的文件有liststl_list.hlist.h 等几个文件。

1. list 和 slist

STL中也实现了链表这种数据结构,list是STL标准的双向链表,而slit是SGI的单链表。相比于vector的连续线性空间而言,list即有有点也有缺点:优点是空间分配更灵活,对任何位置的插入删除操作都是常数时间;缺点是排序不方便。list和vector是比较常用的线性容器,那么什么时候用哪一种容器呢,需要视元素的多少、元素构造的复杂度(是否为POD数据)以及元素存取行为的特性而定。限于篇幅,本文主要介绍list的内容,关于单链表slist可以参见源码和侯捷的书。

2. list 的数据结构

在数据结构中,我们知道链表的节点node和链表list本身是不同的数据结构,以下分别是node和list的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct _List_node_base {
  _List_node_base* _M_next;
  _List_node_base* _M_prev;
};
template <class _Tp>
struct _List_node : public _List_node_base {  // node 的定义
  _Tp _M_data;
};
template <class _Tp, class _Alloc>
class _List_base {
protected:
  _List_node<_Tp>* _M_node; // 只要一个指针就可以表示整个双向链表
};
template <class _Tp, class _Alloc = __STL_DEFAULT_ALLOCATOR(_Tp) >
class list : protected _List_base<_Tp, _Alloc> {
public:
  typedef _List_node<_Tp> _Node;
};

本文涉及到 SGI STL 源码的文件有vectorstl_vector.hvector.h 等几个文件。

1. 容器

在数据结构的课程中,我们主要研究数据的特定排列方式,以利于搜索、排序等算法,几乎可以说,任何特定的数据结构都是为了实现某种特定的算法。STL 容器即由一个个特定的数据结构组成,例如向量(vector),链表(list),堆栈(stack),队列(queue),树(tree),哈希表(hash table),集合(set),映射(map)等,根据数据在容器中的排列特性,这些数据接口分为序列式容器(sequence container)和关联式容器(association container)两种,本文主要解读SGI STL中的序列式容器。

所谓序列式容器,其中的元素可序(ordered),但未必有序(sorted)。C++ 本身提供了一个序列式容器——数组(array),STL中还提供了向量(vector),链表(list),堆栈(stack),队列(queue),优先队列(priority queue)等,其中stack和queue只是将deque(双端队列)设限而得到的,技术上可以被归为一种配接器(adaptor)。本系列文章将依次解读SGI STL各容器的关键实现细节。

2. vector 及其数据结构

在STL中,vector的空间在物理上就是连续的,而且是可以动态扩展的,这里的动态扩展,不需要用户去处理溢出的问题,而只需要关心上层逻辑。vector连续物理空间的动态扩展技术是该容器的关键,它主要分为三个步骤:配置新空间,数据移动,释放旧空间。这三个步骤执行的次数以及每次执行时的效率是影响最终 vector 效率的关键因素。为了减少执行的次数,就需要未雨绸缪,每次扩充空间时,成倍增长。而每次执行的效率,就主要是数据移动的效率了。下面,我们依次介绍vector的数据结构,使用的空间配置器和迭代器,以及常用操作。
vector 的数据结构
vector的数据结构很简单,就是一段连续的物理空间,包含起止地址以及已用到的空间的末尾地址这三个成员:

1
2
3
4
5
6
7
8
9
template <class _Tp, class _Alloc>
class _Vector_base {
protected:
  _Tp* _M_start;
  _Tp* _M_finish;
  _Tp* _M_end_of_storage;
};
class vector : protected _Vector_base<_Tp, _Alloc>{
};

本文涉及到 SGI STL 源码的文件有 iterator.h, stl_iterator_base.h, concept_checks.h, stl_iterator.h, type_traits.h, stl_construct.h, stl_raw_storage_iter.h 等7个文件。

1. 迭代器的设计思维

迭代器(iterators)是一种抽象的设计概念,显示程序中并没有直接对应于这个概念的实体。在 Design Patterns 一书中,对 iterators 模式的定义如下:提供一种方法,使之能够依序遍历某个聚合物(容器)所包含的各个元素,而又无需暴露该聚合物内部的表述方式。

在STL中迭代器扮演着重要的角色。STL的中心思想在于:将数据容器(container)和算法(algorithm)分开,彼此独立设计,最后再通过某种方式将他们衔接在一起。容器和算法的泛型化,从技术的角度来看并不困难,C++ 的 class template 和 function template 可以分别达到目标,难点在于如何设计二者之间的衔接器。

在STL中,起者这种衔接作用的是迭代器,它是一种行为类似指针的对象。指针的各种行为中最常见也最重要的便是内容获取(dereference)和成员访问(member access),因此迭代器最重要的工作就是对 operator*operator-> 进行重载。然而要对这两个操作符进行重载,就需要对容器内部的对象的数据类型和存储结构有所了解,于是在 STL 中迭代器的最终实现都是由容器本身来实现的,每种容器都有自己的迭代器实现,例如我们使用vector容器的迭代器的时候是这样用的 vector<int>::iterator it; 。而本文所讨论的迭代器是不依存于特定容器的迭代器,它在STL中主要有以下两个方面的作用(我自己的理解和总结):

  • 规定容器中需要实现的迭代器的类型及每种迭代器的标准接口
  • 通过Traits编程技巧实现迭代器相应型别的获取,弥补 C++ 模板参数推导的不足,为配置器提供可以获取容器中对象型别的接口

其中前一个没啥好解释的。关于第二个,后面第3节会详细介绍,那就是Traits编程技巧。