STL中常用容器的选择

希望能在这里整理一下,首先看了一下《STL源码剖析》中对STL六大组件是这样介绍的:

容器(containers):各种数据结构,用来存放数据。从实现的角度看,容器是一种class template。

算法(algorithms):各种常用算法,如sort,search,copy...从实现角度看,算法是一种function template。

迭代器(iterators):扮演容器与算法之间的胶合剂,是所谓的泛型指针。共有五种类型,及其他的衍生变化。从实现角度看,迭代器是一种将operator*,operator->,operator++,operator--等指针相关操作予以重载的class template。所有STL容器都附带有自己的专属容器——是的,只有容器设计者才知道如何遍历自己的元素。

仿函数(functors):行为类似函数,可以作为算法的某种策略。从实现角度看,仿函数是重载了一种operator()的class或class template。

配接器(adapters):一种用来修饰容器或仿函数或迭代器接口的东西。配接器的实现技术很难一言以蔽之,不许逐一分析。

配置器(allocators):负责空间配置与管理。从实现的角度看,配置器是一个实现了动态空间配置,空间管理,空间释放的class template。

记得我第一次看这里的时候,对这些组件是没弄懂的,只是囫囵吞枣。看样子书还是要重复的看才行,隔一段时间再看,一些不懂的东西可能就豁然开朗了。

六大组件之间的关系如下:


根据数据在容器中的排列特性,可分为序列式容器和关联式容器:


容器

序列式容器

所谓序列式容器,其中的元素都可序,但未必有序。

vector

vector其实和标准库中的数组array十分相似。若在用C++编程中,你想到了用数组,那比较好的建议是你可以用vector。他们之间的区别是,数组是静态的,空间分配需要程序员自己来管理;而vector是动态的,由它的内部机制自己管理内存空间。

vector的内部机制,关键在于其对空间大小的控制以及重新分配时的数据移动效率。vector采用的是线性的连续空间,在新增元素时,如果超过当时的容量,则容量会扩充至现有的两倍。不是简单的增加一个元素空间,因为在连续空间中扩充空间是一个比较复杂的过程,需要重新配置、移动数据、释放原空间。但是,在erase元素时,容量大小是不会变的。

从效率上看,由于vector是连续的,所以随机读取效率很高,但是insert操作,效率比较低。若需要频繁的进行插入,vector不是很好的选择。

list

list的数据结构是一个循环双向链表,所以只需要一个指针,可以遍历整个链表。list的这种结构,使插入的效率比较高。

list和vector是两个比较常用的容器,选择哪一个必须视情况而定。list提供的元素操作很多,这里稍微列举一些:

push_front \ pop_front :插入\移除头结点

push_back \ pop_back :插入\移除尾节点

clear:移除所有节点

remove(value):将数值为value的所有元素移除

unique:移除数值相同的连续元素。只有连续相同的元素,才会被移除剩余一个。

splice(iterator position, list<T,Allocator>& x ):将x移动到pos位置之前,x必须不同于*this,x中的元素会删除

splice ( iterator position, list<T,Allocator>& x, iterator i ):将i所指元素,移动到pos之前,pos和i可以是同一个list,在x中i会被删除

splice ( iterator position, list<T,Allocator>& x, iterator first, iterator last ):将[first, last]内的所有元素移动到pos之前,pos和[first, last]可以指向同一个list,但pos不能在[first, last]之内。[first, last]将从x中删除。

merge(list& x):将x合并到*this身上,两个list的内容必须经过递增排序

reverse():将sort的内容逆向重置。

sort():list不能使用STL的算法sort,必须使用自己的成员函数sort()

list有一个重要性质:insert和splice都不会造成原有的list迭代器失效。这在vector中是不成立的,因为vector的insert操作会造成空间重新配置,原有的迭代器全部失效。要理解这句话,要结合实例思考一下。

deque

deque 顾名思义是双端队列,可以在头尾两端分别做元素的插入和删除操作。从逻辑上看,deque是一种双向开口的连续线性空间。实际上,它是由分段的连续空间组合而成。跟vector不一样,它没有容量的概念。在扩充空间的时候,不用像vector那样重新配置、移动数据、释放原空间。deque由一段一段的定量连续空间组成,一旦有必要在deque两端及头部或尾部新增加空间,便配置一段定量的连续空间,串接在整个deque的头部或尾部。

deque采用一块所谓的map(不是STL 中的map容器)作为主控。这里的map是一小块连续的空间,其中每个元素都是指针,指向另一段较大的连续线性空间,称为缓冲区。缓冲区才是deque的存储主体。STL中可以指定缓冲区的大小,默认值0表示将使用512bytes缓冲区。

template<class T, class Alloc = alloc, size_t BufSize = 0>
class deque {
public:
    typedef T value_type;
    typedef value_type* pointer;
    ....

protected:
    typedef pointer* map_pointer;

public:
    typedef __deque_iterator<T, T&, T*, BufSize> iterator;

protected:
    iterator start;
    iterator finish;

    map_pointer map;      // map是一个T**
    size_type map_size;    //map可以容纳多少指针。一旦map容量不足,就必须重新配置一块更大map。初始最小值为8。
}

deque的结构设计图,map和缓冲区的关系如下图:

deque是分段连续空间,要维持“整体连续”的假象,由deque的迭代器完成。

map中控器,缓冲区和迭代器的相互关系如下图所示:

deque的数据结构与内存管理,可以由如下图说明:




从上面三个图片可以发现一个问题:当最后一个缓冲区满了,会多分配一个新的缓冲区备用;但当第一个缓冲区满了,再push_front新元素时,才会分配新的缓冲区。设计者们为什么要这样做?在pop元素时,在尾端和顶端释放缓冲区时是否也会这样?现在还不知道。。。。

这里有个比较关心的问题,map是如何工作的?当push_back和push_front,备用空间不足时,map是怎么增加节点的,当map容量不够时,有时如何重新配置更大map的。这个问题的实现是由reserve_map_at_back和reserve_map_at_front完成的,最终实现是由reallocate_map()执行的。

void reserve_map_at_back(size_type nodes_to_add = 1)
{
    if(nodes_to_add +1 > map_size - (finish.node - map))  //nodes_to_add+1和reserve_map_at_front中的不加1,可以看出处理第一缓冲区满和最后一个缓冲区满的区别
        reallocate_map(nodes_to_add, false);
}

void reserve_map_at_front(size_type nodes_to_add = 1)
{
    if(nodes_to_add > start.node - map)
         reallocate_map(nodes_to_add, false);
}

template<class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::reallocate_map(size_type nodes_to_add, bool add_at_front)
{
    size_type old_num_nodes = finish.node - start.node +1;
    size_type new_num_nodes = old_num_nodes + nodes_to_add;

    if(map_size > 2 * new_num_nodes)
    {
        //在原有的map下操作
    }
    else
    {
         //配置一块新的map空间
     }
}
deque的元素操作:pop_back, pop_front, clear, erase, insert
pop_back/pop_front: pop元素的时候,若刚好最后一个缓冲区没有元素或第一个缓冲区只有一个元素,则要对该缓冲区进行释放。
clear:deque在最初状态会保留一个缓冲区,clear之后会回到初始状态。
erase:可以清除一个元素,也可以清除一个区间的元素。
insert:允许在某个点之前插入一个元素,并设定其值。很容易想象的出来,因为deque的假象是“整体连续”的,所以插入的效率并不会很高,若插入点之前的元素比较少,则移动前面的元素;否则,移动插入点后面的元素。

queue和stack其实是非容器,准确说应该是配接器。它们底层是用其他容器实现的,默认通过deque,用list也可以。这里暂且把他俩放在容器这块。

stack

stack是一种先进后出的数据结构,只允许在一端插入元素和移出或取得最顶端元素。
STL中stack是以某种既有的容器为底部结构,将其接口改变,使其符合stack“先进后出”的特性。所以往往我们并不把stack归结为容器,而是被归类为配接器。
template<class T, class Sequence = deque<T> >
class stack{
.....
}
从stack的源代码可以看到,只要底层容器的函数有empty,size,back,pop_back, push_back, 就可以作为stack的底层容器。list和deque都符合,stack的默认底层容器是deque。

还要注意点,stack必须符合先进后出的特性,且只允许在顶端操作,所以stack是不提供迭代器的。

queue

queue是一种“先进先出”的数据结构。只允许底端加入元素,顶端取出元素,没有其他方法可以存取queue的其他元素,即不可以实现遍历。queue和stack一样,被归类为配接器,不应该属于容器。也可以通过既有容器deque,list作为它的底部结构。queue也没有迭代器。


关联式容器

set

set的特性是,所有元素都会根据元素的键值进行自动排序。set元素的键值就是实值,不允许两个元素有相同的键值。不能根据set的迭代器改变set的元素值,因为元素的实值就是set的键值,改变会破坏排序规则。
set拥有和list某些相同的性质:当进行元素新增操作或删除操作后,操作之前的所有迭代器都依然有效,除了被删除的那个元素的迭代器。
set采用的底层机制是红黑树--RB-tree。红黑树是一种平衡二叉搜索树,自动排序的效果很不错。
注意一点:在set中一般不用stl中的find算法,一般用set提供的find方法更有效率。

multiset的特性以及用法 和set完全一样,唯一区别是multiset允许键值重复。因为它的插入操作采用的是RB-tree的insert_equal,而不是insert_unique。

map

map的特性是,所有的元素会根据元素的键值自动排序,map的所有元素都是pair,pair的第一元素视为键值,第二元素视为实值。map不允许元素拥有相同的键值,不可改变map的键值,但可以修正元素的实值。
map在进行新增操作或删除操作之后,操作之前的迭代器也都依然有效。因为map和set一样,底层也是采用红黑树——RB-tree来实现。

multimap的特性以及用法 和map完全一样,唯一区别是multimap允许键值重复。因为它的插入操作采用的是RB-tree的insert_equal,而不是insert_unique。

priority_queue

priority_queue 是一个拥有权值的queue,其内的元素并非按照被推入的次序排列,而是依照元素的权值排列。
在默认情况下,priority queue底层是利用max heap完成,大顶堆是通过vector实现的完全二叉树——complete binary tree。
heap并不属于STL容器组件,它是个幕后英雄,扮演priority_queue的助手。binary heap是一种完全二叉树,有max heap和min heap之分,STL中采用的是max heap——大顶堆。所以priority queue允许用户以任何次序将元素推入容器内,但取出时一定从优先级最高的元素开始取。

可以思考,priority queue为什么不采用list或binary search tree作为底层机制?
若使用list,元素插入达到常数级别,但取出极值,需要对整个list线性扫描;也可先对list进行排序,这时取极值很快,但插入操作只有线性表现。
若使用binary search tree, 插入和取极值的时间复杂度都可达到o(log(n)), 但一来二叉查找树的输入需要足够的随机性(没弄懂);二来binary search tree的实现不容易。

hashtable

hash table实现是通过hash function实现的,但hash function 会带来“碰撞”问题。避免元素“碰撞”的方法比较多,常用的是二次线性探测和开链法。在STL中,hash table采用的是开链法。

hash table内的元素为bucket, 每个bucket维护一个link list,但list并不是用stl的list和slist,而是自己定义hash table node。buckets聚合体则是由vector来完成,以便有动态扩展的能力。

template<class Value>
struct __hashtable_node{
__hashtable_node* next;
Value val;
}
注意一点,hashtable的迭代器没有后退操作。

hashtable的模版参数很多,要正确运用它不太容易。

template<class Value, class Key, //节点的实值型别
class HashFcn, //节点的键值型别
class ExtractKey, //从节点中取出键值的方法(函数或仿函数)
class EqualKey, //判断键值相同与否的方法(函数或仿函数)
class Alloc>//空间配置器,默认std::alloc
class hashtable{
....
}

虽然开链法(separate chaining)并不要求表格大小为质数, 但SGI STL仍以质数来设计表格大小。并且先将28个质数计算好(大约两倍的关系), 以备随时访问, 同时提供一个函数,用来查询28个质数中,最接近某数并大于某数的质数。


在进行插入操作时,表格有可能会重建。表格是否重建的判断,是拿元素个数和bucket vector的大小来比,若前者大于后者,则重建。表格重建操作分解:

前面说过,hashtable是通过hash functions实现的,其实hash functions只是用来计算元素位置的函数,STL中定义有现成的hash functions,全都是仿函数。通过调用hash functions,取得一个可以对hashtable进行模运算的值。char,int, long等整数型别,hash functions什么都没做,返回原值,但对字符串(const char*),需要设计转换函数。因此,stl中的hashtable很多型别是不支持的,比如:string,float,double。要处理这些,就必须自己定义hash functions。

hash_set

hash_set是以hashtable为底层机制,RB-tree有自动排序功能而hash table没有,因此set的元素有自动排序功能,而hash_set没有。这是它俩的唯一区别,其他操作是一样的。

hash_multiset与multiset的操作基本一样,唯一区别也是无自动排序功能。

hash_map

hash_map底层机制也是hash table,与map的区别也是五排序功能。hash_multimap,亦如此。



猜你喜欢

转载自blog.csdn.net/wonitawonitawonita/article/details/79708095