《STL 源码剖析》第五章 关联式容器学习笔记

关联式容器

1. 红黑树

红黑树不仅是一个二叉搜索树,还必须满足如下五条规则:

  1. 每个结点要么是红色,要么是黑色。
  2. 根结点是黑色的。
  3. 每个叶结点是黑色的。
  4. 如果一个结点是红色的,那么它的两个子结点必然是黑色的。
  5. 对于每个结点,从该结点到其所有后代叶结点的简单路径上,均包含相同数目的黑色结点。

1. 插入节点

  由于必须满足规则5,所以新插入的节点必定是红色的。那么插入时唯一可能破坏的就是第4个规则(如果父节点为红,那么插入一个红色节点就违反规则了)。先为某些特殊节点定义一些代名。以下讨论都将沿用这些代名。假设新节点为X,其父节点为P,祖父节点为G,叔节点为S,曾祖父节点为GG。有了以下四种考虑:
  在分类之前,我们可以考虑到一点:当需要旋转的时候,父节点必定是红色的(黑色的就不用旋转了),那么唯二需要考虑的就是插入点在父节点的内侧还是外侧,叔节点是红的还是黑的。

  1. 叔节点为黑,X为外侧插入,则将父节点置为黑(取代原本为黑的爷节点),爷节点置为红,进行一次单旋后,变为一黑带两红。黑高不变,第5条规则得到保证。
    在这里插入图片描述
  2. 叔节点为黑,X为内侧插入,先做一次单旋变成第一种情况,然后按第一种情况处理即可。在这里插入图片描述
  3. 如果新增节点的父叔都为红(那么爷节点一定是黑的),那么就将父和叔置为黑,爷置为红,如果此时爷的父节点也为红,那么就将爷节点作为新增节点持续向上做,直到不再有父子连续为红的情况。在这里插入图片描述
  4. 注意!每一轮迭代结束(while()),都会将根置为黑,所以第三点可能爷节点为根节点,被置为黑之后就结束了。
while (z->parent != NIL && z->parent->color == Color->RED) {
    if (z->parent == z->parent->parent->left) {
        Node y = z->parent->parent->right; // y即叔节点
        if (y->color == Color->RED) { //情况3
            y->color = Color->BLACK;
            z->parent->color = Color->BLACK;
            z->parent->parent->color = Color->RED;
            z = z->parent->parent;
        } else if (z == z->parent->right) { //情况2
            z = z->parent;
            leftRotate(z);
        } else { //情况1
            z->parent->color = Color->BLACK;
            z->parent->parent->color = Color->RED;
            rightRatate(z->parent->parent);
        }
    } else {
        // 父节点为祖父节点右节点时,一样,只是处理时左右反过来。
    }
}
root->color = Color->BLACK; //满足红黑树性质2 
}

  为了避免状况3“父子节点皆为红色”的情况持续向红黑树上层结构发展,形成处理时效上的瓶颈,我们可以施行一个由上而下的程序:假设新增节点为A,那么就延着从根到A的路径,只要看到有某节点X的两个子节点皆为红色,就把X改为红色,并把两个子节点改为黑色。当然如果X的父节点也是红,则需要按上述情况1或2做处理,处理完成之后,A直接插入或插入后做一次单旋就可以了。

2. 迭代器

  在将迭代器之前,有必要说明一下红黑树节点之间的关系。首先,每个节点有三个指针,分别指向其父节点、左子节点、右子节点。然后STL专门为根节点创造了一个父节点header,在树中还没有节点的时候,header的左右指针都指向自己,父节点指向一个空的节点空间,当书中加入节点而有了根节点的时候,header的左指针指向树中最小的节点,右指针指向最大的节点,header和根节点互为对方的父节点。所以在插入节点的过程中,header的左右指针和父节点需要不断的维护。

1. increment()

寻找某个节点 node 的后继节点。

  1. 如果有右子节点,那么就是右节点
  2. 如果没有,就沿路径向上遍历node和其父节点 y,直到node不是父节点 y 的右节点为止。
    1. 如果 node 的右节点不为其父节点,这就分两种情况,但结果都是取node的父节点。
      1. node是父节点的左节点,那么最初的node的下一个节点自然就是现在的父节点 y。
      2. 这种情况下,node是 y 的父节点(根节点和header互为父节点)。node 本最开始不是根节点,而是最后一个节点,那么现在node在根节点,y 在header,那么最后一个节点的下一个节点就是 header。
    2. 如果 node 的右节点为其父节点,则说明 node 最开始就是根节点,且无右子节点(即为最后一个节点),那么再遍历的时候,node 会遍历到header上,y 则遍历到根节点,直接返回这个 node 即可。
2. decrement()

寻找某个节点 node 的前驱节点

  1. 如果是红色节点,且父节点的父节点则其自己,则说明此节点为 header (根是黑的,所以只能是header),则返回右子节点(即最后一个节点),因为最后一个节点++的时候会到header。
  2. 如果有左子树,则找到左子树的最后一个节点。
  3. 如果没有左子树,则向上找到第一个不是父节点的左子节点的节点,返回他的父节点。
    1. 如果 node 最开始就是这棵树的第一个节点,那么想上找第一个不是父节点的左子节点的节点只能是根节点,那么返回根的父节点 header。
    2. 如果向上找到了一个是父节点(称为 y)的右子节点的,那么 node 就是 y 右子树的第一个最左面的节点,y 就是最开始的 node 的前一个节点。

3. 元素操作

1. insert_unique()

insert_unique不允许键值重复,若重复则插入无效

  1. 通过循环比较找到插入位置 x 及其父节点 y,令一个迭代器 j 指向 y。
  2. 如果插在 y 的左子节点
    1. 如果 j 是最左的节点,那么就调用__insert()插进去就可以了。
    2. 如果不是,那么找到 j 的前驱节点--j(防止插入重复的值)
  3. 如果(和第二点是可以先后运行的,而不是只能运行一个)节点要插在 j 的右侧,那么就调用__insert()插在 x 处。
    1. 如果 j 刚才有 j-- 的操作,那么v的值大于现在 j 的值,小于刚才 j 的值,必然不会重复;
    2. 如果刚才没 j-- ,那么这个值一定是比 j 后面节点的值都要小的,再加上比现在的 j 大,则没有重复。
  4. 到现在还没插进去,则说明新值与树中的键值重复,则不插入。

注意了,再第一步比较的时候,是key_compare(v, key(x)),再第三步的时候,是key_compare(key(j.node), v),v 位置的不同防止了v 与节点值相等的情况。相等时会到第四步。

2. insert_equal()

insert_equal允许键值重复。

  1. 通过循环比较找到插入位置 x 及其父节点 y,令一个迭代器指向 y。
  2. 直接调用 __insert插就可以了
3. __insert()

参数分别为新值插入点 x,x 的父节点 y,新值 v。

  1. 如果 y 是 header ,或者 x 不为0,或 v 的值比 y 的 value 小,那么:
    1. 产生一个新节点 z ,赋予 v 的值,并设为 y 的左节点。
    2. 如果 y 是 header(原本没有节点),将 z 设为根,且 rightmost 设为z。
    3. 如果 y 是 leftmost ,则将 leftmost 更新为 z。
  2. 其他情况(v 的值比 y 的 value 大)
    1. 新建一个节点,设为 y 的右节点,如果 y 原本是rightmost,则将rightmost更新为此节点。
  3. 设定新节点的父节点和左右节点,旋转并改变红黑树的部分节点的颜色,节点数+1,返回指向新增节点的迭代器。
4. 单旋
// x为旋转点,root为根节点
inline void __rb_tree_rotate_left(__rb_tree_node_base *x, __rb_tree_node_base*& root){
    __rb_tree_node_base* y = x->right;
    x->right = y->left;
    if(y->left != 0)
        y->left->parent = x;	// y的左变为x的右
    y->parent = x->parent;
    if(x == root)				// y继承x原本的地位
        root = y;
    else if(x == x->parent->left)
        x->parent->left = y;
    else
        x->parent->right = y;
    y->left = x;				// 修改x、y之间的关系
    x->parent = y;
}

右旋同理

2. set 与 multiset

std::set<int> set;

set是通过RB Tree来实现的,其实也是一种适配器。所有set的操作行为,都是转调用RB Tree的操作。

set的特性是:所有元素都会根据元素的键值自动被排序,但setkeyvalue是一个值,set不允许两个元素有相同的键值。所以set的插入操作的底层机制为RB Treeinsert_unique(),而multiset允许两个元素有相同的键值。所以multiset的插入操作的底层机制为RB Treeinsert_equal()

3. map 与 multimap

# include <map>
std::map<char,int> mymap;

map是通过RB Tree来实现的,其实也是一种适配器。所有map的操作行为,都是转调用RB Tree的操作。

map 的特性是,所有元素都会根据元素的键值自动被排序。map 的所有元素都是pair,同时拥有实值( value)和键值(key) ,pair的第一元素被视为键值, pair第二元素被视为实值。map不允许两个元素拥有相同的键值

我们不可以通过迭代器修改map的键值,但可以通过迭代器修改map的实值。

同样的,map的插入操作的底层机制为RB Treeinsert_unique(),而multimap允许两个元素有相同的键值。所以multiset的插入操作的底层机制为RB Treeinsert_equal()

4. hashtable

哈希表可以对任何有名项进行存取和删除的操作,但通常情况下的利用array或其他方式来进行操作,要么会带来极大地空间消耗或者时间消耗,为了避免使用一个极大地array,哈下表使用散列函数来将一个大数映射为小数,这样X%Tablesize就会是一个0~TableSize-1的整数,可以作为array的索引。

为了解决不同的元素被映射到同一位置的问题,哈希表有三种解决方法:

  • 线性探测:如果计算出的位置已经被其他元素占用,则一一向下寻找,直到找到条件吻合的位置,至于元素的删除,则采用惰性删除的方式:只删除记号,实际删除操作则等到表格重新整理时进行。但是线性探测会带来主集团的问题。

  • 二次探测:相对于线性检测的H+1,H+2,...H+n的探寻方式,二次检测则是H+1^2,H+2^2...H+i^2的方式来依次尝试,其中迭代关系为
    H i = H i − 1 + 2 i − 1 ( M O D ( M ) ) H_i=H_{i-1}+2i-1 (MOD(M)) Hi=Hi1+2i1(MOD(M))
    虽然二次探测可以消除主集团,但是同时可能会造成次集团。

  • 开链法:每一个表格元素维护一个List,然后在这个List上进行元素的插入、搜索、删除等动作。使用开链法,表格的负载系数将大于1,以下的所有hashtable操作都是基于开链法实现的。

哈希表的定义如下:

template<class Value, class Key, class HashFcn, class ExtractKey, 
		 class EqualKey, class Alloc = alloc>
class hashtable;

这些模板参数的意义如下:

  • Value:实值型别
  • Key:键值型别
  • HashFcnhash function 的函数型别
  • ExtractKey:从节点中取出键值的方法
  • EqualKey:判断键值是否相同的方法
  • Alloc:空间适配器,缺省为alloc

1. insert_unique

  1. 调用resize判断是否需要重建表格,需要就扩充
  2. 调用insert_unique_noresize插入节点。

2. resize

比较元素的个数和bucket vector的大小,如果元素个数更多,就重建表格。

  1. 利用next_size找到元素个数的下一个质数 n

  2. 首先新建一个bucket vector,大小为 n,命名为tmp

  3. 对于原bucket vector的每一个bucket,依次遍历它的List的每一个节点

    1. 调用bkt_num计算这个节点的值应该在tmp的哪一个位置
    2. 利用指针操作将此节点移动到tmp的顶端
    // 这个first最开始是bucket每一个序列的第一个指针
    while(first){
        size_type new_bucket = bkt_num(first->value, n);
        buckets[bucket] = first->next; // 原来的bucket向下更新
        first->next = tmp[new_bucket]; // first插入tmp对应的序列中
        tmp[new_bucket] = first;
        first = buckets[bucket];	   // 继续操作
    }
    
  4. 交换这两个bucket vector(现在bucket是空的,其中的指针都转移到tmp了,把tmp和bucket对调,tmp这时会变小,bucket会变大),交换完毕后tmp为空,释放tmp的内存。

3. insert_unique_noresize

  1. 先利用bkt_num计算这个节点的值应该在哪一个位置,找到这个位置的List头部first
  2. 遍历这个List,如果有相同值的节点,就不插入了。
  3. 新建新的节点,赋值,插入List,并更新节点数++num_elements

insert_equal_noresizeinsert_unique_noresize的唯一区别就是在第2步遍历的过程中,如果发现了相同值的节点,就插入在这个节点的后面,并立即返回。

4. clear

  1. 遍历每一个bucket的每一个节点,利用delete_node()删除这个节点
  2. 删除完一个bucket的最后一个节点的时候,将其内容设置为NULL指针
  3. 将总结点个数设为0.
  4. clear()并没有释放掉空间,而是保持了原本的大小。

5. copy_from

  1. 先调用clear()清空这个容器
  2. 调用resize,如果对方的空间更大,就增大自己的空间。
  3. 利用指针操作将每一个对方的bucket及其中的节点移过来。
  4. 将对方的节点个数赋值过来。
  5. 如果中间出错了(空间不足等),清除移动过来的所有节点。

6. hash functions

hash_function是计算元素位置的元素,被之前提到的bkt_num调用, 来获得一个可以对哈希表进行模运算的值,针对char,ing,long等整数型别,通常什么都没做而返回一个原值,而对于字符字符串const char*,则设计了一个转换函数。

inline size_t __stl_hash_string(const char* s){
    unsigned long h = 0;
    for(; *s; ++s)
        h = 5 * h + *s;
    return size_t(h);
}

这是一个多项式哈希,设最后的映射结果为:
s t r [ 0 ] ∗ x n + s t r [ 1 ] ∗ x n − 1 + . . . + s t r [ n ] ∗ x 0 str[0] * x^n + str[1]*x^{n-1}+...+str[n]*x^0 str[0]xn+str[1]xn1+...+str[n]x0
可以重写如下:
( ( ( s t r [ 0 ] ∗ x ) + s t r [ 1 ] ) ∗ x + . . . ) ∗ x + s t r [ n ] (((str[0]*x)+str[1])*x+...)*x+str[n] (((str[0]x)+str[1])x+...)x+str[n]
所以:
h = 0 h = h ∗ x + s t r [ 1 ] h = h ∗ x + s t r [ 2 ] . . . h = h ∗ x + s t r [ n ] h=0 \\ h=h*x+str[1]\\ h=h*x+str[2]\\ ...\\ h=h*x+str[n] h=0h=hx+str[1]h=hx+str[2]...h=hx+str[n]
在这里x为5,具体为什么取5就不清楚了。

hash_function无法处理除整数型和const char*以外的型别的元素,比如string,double,float,如果想处理这些型别,需要用户自己去定义。

5. hash_set / hash_multiset

set / multiset 以红黑树作为底层实现,hash_set / hash_multiset则以哈希表作为底层实现,所以前者可以实现自动排序功能而后者不可以。后者的所有操作都是哈希表的转调用,哈希表的hash_function无法处理的型别,hash_set / hash_multiset同样无法处理。

hash_set的插入操作是利用底层机制为哈希表的insert_unique(),而hash_multiset允许两个元素有相同的键值。所以hash_multiset的插入操作的底层机制是利用哈希表的insert_equal()

6. hash_map / hash_multimap

map / multimap 以红黑树作为底层实现,hash_map / hash_multimap则以哈希表作为底层实现,所以前者可以实现自动排序功能而后者不可以。后者的所有操作都是哈希表的转调用,哈希表的hash_function无法处理的型别,hash_map / hash_multimap同样无法处理。

hash_map的插入操作是利用底层机制为哈希表的insert_unique(),而hash_multimap允许两个元素有相同的键值。所以hash_multimap的插入操作是利用底层机制为哈希表的insert_equal()

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值