五 关联式容器
标准STL关联式容器分为 set(集合)和map(映射表)两大类,以及这两大类的衍生体 multiset,multimap。这些容器的底层机制都是以RB-tree(红黑树)完成。RB-tree 也是一个独立容器,但并不开放给外界使用。
STL还提供了不在标准规格之列的关联式容器 unordered_map ,unordered_set。
所谓关联式容器,每个元素都有一个键值(key)和一个值(value)。一般而言,关联式容器的内部结构是一个平衡二叉树,以获得良好的搜素效率。平衡二叉树有许多种类型,包括 AVL-tree、RB-tree, AA-tree, 其中最被广泛用于STL的是RB-tree。
二叉搜索树,时间复杂度是O(log n), 节点放置规则是:任何节点的键值一定大于其左子树的每一个节点的键值,小于其右子树中每一个节点的键值。
AVL tree :加上“平衡条件”的二叉搜索树。要求任何节点的左右子树高度相差最多为1。
往平衡二叉树中添加节点可能会导致二叉树失去平衡,所以我们需要在每次插入节点后进行平衡的维护操作。
插入节点破坏平衡性有如下四种情况:
插入点位于X的左子节点的左子树--左左 (二叉树进行 右旋 ) 位于X的左子节点的右子树--左右 (双旋转,先左旋再右旋)
位于X的右子节点的左子树--右左 (双旋转,先右旋再左旋) 位于X的右子节点的右子树 --右右(二叉树 左旋 ,单旋转)
左左情况:右旋
RB-tree(平衡二叉搜索树)
二叉搜索树,并且满足以下规则:
- 每个节点不是红色就是黑色。
- 根节点为黑色
- 如果节点为红,其子节点必须为黑
- 任一节点至NULL(树尾端)的任何路径,所含的黑节点数必须相同。
根据规则4,新增加节点必须为红色,根据规则3,新增节点的父节点,必须为黑
当新节点未符合上述规则时,必须调整颜色并旋转树形。
例如:
RB-tree 节点的设计
typedef bool __rb_tree_color_type;
const __rb_tree_color_type __rb_tree_red = false; //红色为0
const __rb_tree_color_type __rb_tree_black = true; //黑色为1
//RB-tree节点的基类
struct __rb_tree_node_base
{
typedef __rb_tree_color_type color_type;
typedef __rb_tree_node_base* base_ptr;
color_type color; //颜色
base_ptr parent; //指向父节点的指针
base_ptr left; //指向左子节点的指针
base_ptr right; //指向右子节点的指针
//静态函数,获取以x为根节点的RB-tree最小节点的指针
static base_ptr minimum(base_ptr x)
{
while (x->left != 0) x = x->left;
return x;
}
//静态函数,获取以x为根节点的RB-tree最大节点的指针
static base_ptr maximum(base_ptr x)
{
while (x->right != 0) x = x->right;
return x;
}
};
//RB-tree节点类
template <class Value>
struct __rb_tree_node : public __rb_tree_node_base
{
typedef __rb_tree_node<Value>* link_type;
Value value_field; //RB-tree节点的value
};
RB-tree的迭代器
SGI将RB-tree迭代器实现为两层:
RB-tree 迭代器属于双向迭代器,但不具备随机定位能力。前进操作operator++()调用了基类迭代器的increment(),后退操作operator--()调用了基类迭代器的decrement()。前进或后退的举止行为完全依据二叉搜索树的节点排列法则.
struct __rb_tree_base_iterator
{
typedef __rb_tree_node_base::base_ptr base_ptr;
typedef bidirectional_iterator_tag iterator_category;
typedef ptrdiff_t difference_type;
base_ptr node;
void increment()
{
if (node->right != 0) { //如果有右子节点
node = node->right; //就往右走
while (node->left != 0) //然后一直往左子树走到底
node = node->left;
}
else { //没有右子节点
base_ptr y = node->parent; //找出父节点
while (node == y->right) { //如果现行节点本身是个右子节点
node = y; //就一直上溯,直到“不为右子节点”止
y = y->parent;
}
if (node->right != y)
node = y;
}
}
void decrement()
{
if (node->color == __rb_tree_red &&
node->parent->parent == node)
node = node->right;
else if (node->left != 0) {
base_ptr y = node->left;
while (y->right != 0)
y = y->right;
node = y;
}
else {
base_ptr y = node->parent;
while (node == y->left) {
node = y;
y = y->parent;
}
node = y;
}
}
};
RB-tree 的数据结构
template<class Key,class Value,class KeyOfValue,class Compare, class Alloc=alloc>
class rb_tree{
protected:
typedef _rb_tree_node<Value> rb_tree_node;
.....
public:
typedef rb_tree_node* link_type;
.....
protected:
//RB-tree 只以三笔资料表现自己
size_type node_count; //rb_tree 的大小(节点数)
link_type header;
compare key_compare; //key的大小比较准则
};
void init(){
header=get_node(); //产生一个节点空间,令header指向它
color(header)=_rb_tree_red; //令header为红色,用来区分header 和 root
root()=0;
leftmost()=header; //令header的左子节点为自己
rightmost()=header; //令header的右子节点为自己
}
header 的父节点指向根节点,左子节点指向最小节点,右子节点指向最大节点。
RB-tree 的元素插入操作有两个 insert_equal() //允许节点键值重复 insert_unique() //不重复
STL为什么选择红黑树而不是AVL-tree?
红黑树的平衡性是比AVL-tree弱的,但是搜索效率几乎相等。两者的插入和删除操作都是O(logn),但是就旋转操作而言,AVL-tree是O(n),而红黑树是O(1)
set
set 的所有元素都会根据元素的键值自动被排序。元素的键值就是实值,实值就是键值、set不允许两个元素具有相同的键值。
set 元素不能改变,在set源码中,set<T>::iterator被定义为底层TB-tree的const_iterator,杜绝写入操作,也就是说,set iterator是一种constant iterators(相对于mutable iterators)。几乎set 操作都只是转调用rb_tree的操作行为而已。
template < class Key,
class Compare = less<Key>, //缺省情况下,使用递增排序
class Alloc = alloc>
class set {
public:
...
//键值和实值类型相同,比较函数也是同一个
typedef Key key_type;
typedef Key value_type;
typedef Compare key_compare;
typedef Compare value_compare;
private:
...
typedef rb_tree<key_type, value_type,
identity<value_type>, key_compare, Alloc> rep_type;
rep_type t; // 内含一棵RB-tree,使用RB-tree来表现set
public:
...
//iterator定义为RB-tree的const_iterator,表示set的迭代器无法执行写操作
typedef typename rep_type::const_iterator iterator;
...
测试用例(让set从大到小存放元素):
#include <iostream>
#include <set>
#include <functional>
using namespace std;
/// set默认是从小到大排列,以下是让set从大到小排列
template <typename T>
struct greator
{
bool operator()(const T &x, const T &y)
{
return x > y;
}
};
int main(void)
{
set<int, greator<int>> iset;
iset.insert(12);
iset.insert(1);
for (set<int>::const_iterator iter = iset.begin(); iter != iset.end(); iter++)
cout << *iter << " ";
cout << endl;
system("pause");
return 0;
}
map
map 的所有元素都会根据元素的键值自动排序。map的所有元素都是pair,同时拥有实值(value)和键值(key)。pair的第一元素为键值,第二元素为实值。map不允许有两个相同的键值。
<stl_pair.h>中的pair 定义:
template<class T1,class T2>
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) {}
};
map 架构
template <class Key, class T, class Compare = less<Key>, class Alloc = alloc>
class map {
public:
typedef Key key_type; //键值类型
typedef T data_type; //实值类型
typedef T mapped_type;
typedef pair<const Key, T> value_type; //键值对,RB-tree节点中的value类型
typedef Compare key_compare; //键值比较函数
...
private:
typedef rb_tree<key_type, value_type,
select1st<value_type>, key_compare, Alloc> rep_type;
rep_type t; // 内含一棵RB-tree,使用RB-tree来表现map
public:
...
//迭代器和set不同,允许修改实值
typedef typename rep_type::iterator iterator;
...
//下标操作
T& operator[](const key_type& k) {
return (*((insert(value_type(k, T()))).first)).second;
}
//插入操作
pair<iterator,bool> insert(const value_type& x) { return t.insert_unique(x); }
...
};
multiset
multiset的特性及用法和set完全相同,唯一的差别在于它允许键值重复,插入操作采用的是底层机制RB-tree的insert_equal()而非insert_unique()
multimap
multimap的特性及用法和map完全相同,唯一的差别在于它允许键值重复,插入操作采用的是底层机制RB-tree的insert_equal()而非insert_unique()
hash table
二叉搜索树具有对数平均时间表现,但这样的表现构造在一个假设上:输入数据有足够的随机性。hashtable这种结构在插入、删除、查找具有“常数平均时间”,而且这种表现是以统计为基础,不需依赖元素的随机性。
散列函数:使用某种映射函数,负责将某一元素映射为一个索引。但不同的元素可能会被映射到相同的位置(相同的索引),出现所谓的碰撞问题,包括:线性探测、二次探测、开链等。
线性探测: 负载系数,意指元素个数除以表格大小,负载系数永远在0~1之间。若出现相同索引,则循序往下寻找一个可用空间。
二次探测:其命名由来是因为解决碰撞问题的方程式 F(i)=i^2 ;如果hash function 计算出新元素的位置为H,而该位置实际上已被使用,那么就依序尝试H+1,H+^2,H+3^2......,而不是像线性探测那样依序尝试H+1,H+2,H+3.....
开链法:在每一个表格元素中维护一个list,hash function 为我们分配某一个list,然后在list上执行元素的增删改查。SGI STL的hash table 便是采用这种做法。
hash table 的 buckets 与node
SGI STL中以开链法实现hash table,hash table表格中的元素为桶,每个桶中包含了哈希到这个桶中的节点,节点定义如下:
template <class Value>
struct __hashtable_node
{
__hashtable_node *next;
Value val;
};
注意:bucket所维护的list,并不采用STL的list或slist,而是自行维护上述的hash table node,至于buckets聚合体,则以vector完成,以便有动态扩充能力
hashtable 的迭代器
前进操作首先尝试从目前所指的节点出发,前进一个位置(节点),由于节点被安置于list内,所以利用节点的next指针即可轻易完成。如果目前节点正好是list的尾端,就跳至下一个bucket身,它正好指向下一个list的头部节点:
template <class V, class K, class HF, class ExK, class EqK, class A>
__hashtable_iterator<V, K, HF, ExK, EqK, A>&
__hashtable_iterator<V, K, HF, ExK, EqK, A>::operator++()
{
const node* old = cur;
cur = cur->next; //如果存在,就是它。否则进入以下if流程
if (!cur) {
//根据元素值,定位出下一个bucket,其起头处就是我们的目的地
size_type bucket = ht->bkt_num(old->val);
while (!cur && ++bucket < ht->buckets.size())
cur = ht->buckets[bucket];
}
return *this;
}
template <class V, class K, class HF, class ExK, class EqK, class A>
inline __hashtable_iterator<V, K, HF, ExK, EqK, A>
__hashtable_iterator<V, K, HF, ExK, EqK, A>::operator++(int)
{
iterator tmp = *this;
++*this;
return tmp;
}
hash table的迭代器没有后退操作,hashtable也没有定义所谓的逆向迭代器。
hash table的数据结构
template <class Value, class Key, class HashFcn,
class ExtractKey, class EqualKey, class Alloc = alloc>
class hashtable;
...
template <class Value, class Key, class HashFcn,
class ExtractKey, class EqualKey,
class Alloc> //先前声明时,已给出Alloc默认值alloc
class hashtable {
public:
typedef HashFcn hasher;
typedef EqualKey key_equal;
...
private:
//以下3者都是function objects
hasher hash;
key_equal equals;
ExtractKey get_key;
typedef __hashtable_node<Value> node; //hashtable节点类型
typedef simple_alloc<node, Alloc> node_allocator;
vector<node*,Alloc> buckets; //hashtable的桶数组,以vector完成
size_type num_elements; //元素个数
...
};
SGI STL以质数来设计表格大小,并且先将28个质数(逐渐呈现大约2倍的关系)计算好,以备随时访问,同时提供一个函数,用来查询在这28个质数中,“最接近某数并大于某数”的质数:
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473ul, 4294967291ul
};
一开始保留50个节点,由于最接近的STL质数为53,所以buckets vector 保留的是53个buckets,每个bukcets(指针,指向一个hash table 节点)的初值为0,接下来,循序加入6个元素,如图5-26所示。再插入48个元素,总元素达到54个,超过当时的bucket vector 大小,hash table 重建,变成右图所示
判断元素落在哪个bucket;
SGI 把这个任务包装了一层,先交给 bkt_num() 函数,再由此函数调用 hash function,取得一个可以执行modulus(取模)运算的数值。
//版本1 接受实值(value)和buckets 个数
size_type bkt_num (const value_type& obj, size_t n)const{
return bkt_num_key( get_key(obj),n); //调用版本4
}
//版本2 只接受实值(value)
size_type bkt_num(const value_type& obj) const{
return bkt_num_key(get_key(obj)); //调用版本3
}
//版本3:只接受键值
size_type bkt_num_key(const key_type& key) const{
return bkt_num_key(key,buckets.size()); //调用版本4
}
//版本4: 接受键值和 buckets 个数
size_type bkt_num_key(const key_type& key,size_t n)cosnt{
return hash(key) % n; //
}
hash functions
hash function是计算元素位置的函数,SGI将这项任务赋予了bkt_num(),再由它来调用这里提供的hash function,取得一个可以对hashtable进行模运算的值。针对char,int,long等整数类型,大部分的hash functions什么也没做,只是忠实返回原值
但对于字符字符串(const char*),就设计了一个转换函数
其余类型用特化版本
hash table 无法处理上述所列各项型别以外的元素,例如 string,double,float ,欲处理这些型别,用户必须自行为它们定义 hash function
hash_set
hash_set 以 hashtable 为底层机制,由于 hash_set 所供应的操作接口 hashtable 都提供了,所以几乎所有的hash_set操作行为,都只是转调用hashtable的操作行为而已
hash_multiset
hash_multiset 和 hash_set 实现上的唯一差别在于,前者的元素插入操作采用底层机制hashtable的insert_equal(),后者则是采用insert_unique()