跳至主要内容

【转】学习STL map, STL set之数据结构基础

作者: winter

摘要:本文列出几个基本的STL map和STL set的问题,通过解答这些问题讲解了STL关联容器内部的数据结构,最后提出了关于UNIX/LINUX自带平衡二叉树库函数和map, set选择问题,并分析了map, set的优势之处。对于希望深入学习STL和希望了解STL map等关联容器底层数据结构的朋友来说,有一定的参考价值。

STL map和set的使用虽不复杂,但也有一些不易理解的地方,如:

  • 为何map和set的插入删除效率比用其他序列容器高?
  • 为何每次insert之后,以前保存的iterator不会失效?
  • 为何map和set不能像vector一样有个reserve函数来预分配数据?
  • 当数据元素增多时(10000到20000个比较),map和set的插入和搜索速度变化如何?
  • 或许有得人能回答出来大概原因,但要彻底明白,还需要了解STL的底层数据结构。

    C++ STL 之所以得到广泛的赞誉,也被很多人使用,不只是提供了像vector, string, list等方便的容器,更重要的是STL封装了许多复杂的数据结构算法和大量常用数据结构操作。vector封装数组,list封装了链表,map和set封装了二叉树等,在封装这些数据结构的时候,STL按照程序员的使用习惯,以成员函数方式提供的常用操作,如:插入、排序、删除、查找等。让用户在STL使用过程中,并不会感到陌生。

    C++ STL中标准关联容器set, multiset, map, multimap内部采用的就是一种非常高效的平衡检索二叉树:红黑树,也成为RB树(Red-Black Tree)。RB树的统计性能要好于一般的平衡二叉树(有些书籍根据作者姓名,Adelson-Velskii和Landis,将其称为AVL-树),所以被STL选择作为了关联容器的内部结构。本文并不会介绍详细AVL树和RB树的实现以及他们的优劣,关于RB树的详细实现参看红黑树: 理论与实现(理论篇)。本文针对开始提出的几个问题的回答,来向大家简单介绍map和set的底层数据结构。

    为何map和set的插入删除效率比用其他序列容器高?

    大部分人说,很简单,因为对于关联容器来说,不需要做内存拷贝和内存移动。说对了,确实如此。map和set容器内所有元素都是以节点的方式来存储,其节点结构和链表差不多,指向父节点和子节点。结构图可能如下:

                A
               / \
              B   C
             / \ / \
            D  E F  G

    因此插入的时候只需要稍做变换,把节点的指针指向新的节点就可以了。删除的时候类似,稍做变换后把指向删除节点的指针指向其他节点就OK了。这里的一切操作就是指针换来换去,和内存移动没有关系。

    为何每次insert之后,以前保存的iterator不会失效?

    看见了上面答案的解释,你应该已经可以很容易解释这个问题。iterator这里就相当于指向节点的指针,内存没有变,指向内存的指针怎么会失效呢(当然被删除的那个元素本身已经失效了)。相对于vector来说,每一次删除和插入,指针都有可能失效,调用push_back在尾部插入也是如此。因为为了保证内部数据的连续存放,iterator指向的那块内存在删除和插入过程中可能已经被其他内存覆盖或者内存已经被释放了。即使时push_back的时候,容器内部空间可能不够,需要一块新的更大的内存,只有把以前的内存释放,申请新的更大的内存,复制已有的数据元素到新的内存,最后把需要插入的元素放到最后,那么以前的内存指针自然就不可用了。特别时在和find等算法在一起使用的时候,牢记这个原则:不要使用过期的iterator。

    为何map和set不能像vector一样有个reserve函数来预分配数据?

    我以前也这么问,究其原理来说时,引起它的原因在于在map和set内部存储的已经不是元素本身了,而是包含元素的节点。也就是说map内部使用的Alloc并不是map<Key, Data, Compare, Alloc>声明的时候从参数中传入的Alloc。例如:

    map<int, int, less<int>, Alloc<int> > intmap;

    这时候在intmap中使用的allocator并不是Alloc<int>, 而是通过了转换的Alloc,具体转换的方法时在内部通过Alloc<int>::rebind重新定义了新的节点分配器,详细的实现参看彻底学习STL中的Allocator。其实你就记住一点,在map和set内面的分配器已经发生了变化,reserve方法你就不要奢望了。

    当数据元素增多时(10000和20000个比较),map和set的插入和搜索速度变化如何?

    如果你知道log2的关系你应该就彻底了解这个答案。在map和set中查找是使用二分查找,也就是说,如果有16个元素,最多需要比较4次就能找到结果,有32个元素,最多比较5次。那么有10000个呢?最多比较的次数为log10000,最多为14次,如果是20000个元素呢?最多不过15次。看见了吧,当数据量增大一倍的时候,搜索次数只不过多了1次,多了1/14的搜索时间而已。你明白这个道理后,就可以安心往里面放入元素了。

    最后,对于map和set Winter还要提的就是它们和一个c语言包装库的效率比较。在许多unix和linux平台下,都有一个库叫isc,里面就提供类似于以下声明的函数:

    void tree_init(void **tree);
    void *tree_srch(void **tree, int (*compare)(), void *data);
    void tree_add(void **tree, int (*compare)(), void *data, void (*del_uar)());
    int tree_delete(void **tree, int (*compare)(), void *data,void (*del_uar)());
    int tree_trav(void **tree, int (*trav_uar)());
    void tree_mung(void **tree, void (*del_uar)());

    许多人认为直接使用这些函数会比STL map速度快,因为STL map中使用了许多模板什么的。其实不然,它们的区别并不在于算法,而在于内存碎片。如果直接使用这些函数,你需要自己去new一些节点,当节点特别多,而且进行频繁的删除和插入的时候,内存碎片就会存在,而STL采用自己的Allocator分配内存,以内存池的方式来管理这些内存,会大大减少内存碎片,从而会提升系统的整体性能。Winter在自己的系统中做过测试,把以前所有直接用isc函数的代码替换成map,程序速度基本一致。当时间运行很长时间后(例如后台服务程序),map的优势就会体现出来。从另外一个方面讲,使用map会大大降低你的编码难度,同时增加程序的可读性。何乐而不为?

    | 引用

    评论

    此博客中的热门博文

    【转】VxWorks中的地址映射

    在运用嵌入式系统VxWorks和MPC860进行通信系统设计开发时,会遇到一个映射地址不能访问的问题。 缺省情况下,VxWorks系统已经进行了如下地址的映射:   memory地址、bcsr(Board Control and Status)地址、PC_BASE_ADRS(PCMCIA)地址、Internal Memory地址、rom(Flach memory)地址等,但是当你的硬件开发中要加上别的外设时,如(falsh、dsp、FPGA等),对这些外设的访问也是通过地址形式进行读写,如果你没有加相应的地址映射,那么是无法访问这些外设的。   和VxWorks缺省地址映射类似,你也可以进行相应的地址映射。   如下是地址映射原理及实现:   1、 地址映射结构 在Tornado\target\h\vmLib.h文件中 typedef struct phys_mem_desc { void *virtualAddr; void *physicalAddr; UINT len; UINT initialStateMask; /* mask parameter to vmStateSet */ UINT initialState; /* state parameter to vmStateSet */ } PHYS_MEM_DESC; virtualAddr:你要映射的虚拟地址 physicalAddr:硬件设计时定义的实际物理地址 len;要进行映射的地址长度 initialStateMask:可以初始化的地址状态: 有如下状态: #define VM_STATE_MASK_VALID 0x03 #define VM_STATE_MASK_WRITABLE 0x0c #define VM_STATE_MASK_CACHEABLE 0x30 #define VM_STATE_MASK_MEM_COHERENCY 0x40 #define VM_STATE_MASK_GUARDED 0x80 不同的CPU芯片类型还有其特殊状态 initialState:实际初始化的地址状态: 有如下状态: #define VM_STATE_VALID 0x01 #define VM_STATE_VALID_NOT 0x00 #define VM_STATE_WRITA

    【转】cs8900网卡的移植至基于linux2.6内核的s3c2410平台

    cs8900网卡的移植至基于linux2.6内核的s3c2410平台(转) 2008-03-11 20:58 硬件环境:SBC-2410X开发板(CPU:S3C2410X) 内核版本:2.6.11.1 运行环境:Debian2.6.8 交叉编译环境:gcc-3.3.4-glibc-2.3.3 第一部分 网卡CS8900A驱动程序的移植 一、从网上将Linux内核源代码下载到本机上,并将其解压: #tar jxf linux-2.6.11.1.tar.bz2 二、打开内核顶层目录中的Makefile文件,这个文件中需要修改的内容包括以下两个方面。 (1)指定目标平台。 移植前:         ARCH?= $(SUBARCH) 移植后: ARCH            :=arm (2)指定交叉编译器。 移植前: CROSS_COMPILE ?= 移植后: CROSS_COMPILE   :=/opt/crosstool/arm-s3c2410-linux-gnu/gcc-3.3.4-glibc-2.3.3/bin/arm-s3c2410-linux-gnu- 注:这里假设编译器就放在本机的那个目录下。 三、添加驱动程序源代码,这涉及到以下几个方面。(1)、从网上下载了cs8900.c和cs8900.h两个针对2.6.7的内核的驱动程序源代码,将其放在drivers/net/arm/目录下面。 #cp cs8900.c ./drivers/net/arm/ #cp cs8900.h ./drivers/net/arm/ 并在cs8900_probe()函数中,memset (&priv,0,sizeof (cs8900_t));函数之后添加如下两条语句: __raw_writel(0x2211d110,S3C2410_BWSCON); __raw_writel(0x1f7c,S3C2410_BANKCON3); 注:其原因在"第二部分"解释。 (2)、修改drivers/net/arm/目录下的Kconfig文件,在最后添加如下内容: Config ARM_CS8900    tristate "CS8900 support" depends on NET_ETHERNET && A

    【转】多迷人Gtkmm啊

    前边已经说过用glade设计界面然后动态装载,接下来再来看看怎么改变程序的皮肤(主题)     首先从 http://art.gnome.org/themes/gtk2 下载喜欢的主题,从压缩包里提取gtk-2.0文件夹让它和我们下边代码生成的可执行文件放在同一个目录下,这里我下载的的 http://art.gnome.org/download/themes/gtk2/1317/GTK2-CillopMidnite.tar.gz     然后用glade设计界面,命名为main.glade,一会让它和我们下边代码生成的可执行程序放在同一个目录下边     然后开始写代码如下: //main.cc #include <gtkmm.h> #include <libglademm/xml.h> int main(int argc, char *argv[]) {     Gtk::Main kit(argc,argv);         Gtk::Window *pWnd;        gtk_rc_parse("E:\\theme-viewer\\themes\\gtk-2.0\\gtkrc");       Glib::RefPtr<Gnome::Glade::Xml> refXml;     try     {         refXml = Gnome::Glade::Xml::create("main.glade");     }     catch(const Gnome::Glade::XmlError& ex)     {         Gtk::MessageDialog dialog("Load glade file failed!", false,       \                                   Gtk::MESSAGE_ERROR, Gtk::BUTTONS_OK);         dialog.run();               return 1;     }         refXml->get_widget("main", pWnd);     if(pW