STL源码分析读书笔记

主要是关于标准库容器的整理

空间配置器

主要看SGI的实现,有两个空间配置器

  • _malloc_alloc_template<0>
  • __default_alloc_template<...>

用户可以选择单独使用第一个分配器,或者一起使用两个分配器。

当用户选择使用两个分配器时,编译器会分别将上述两个分配器typedef成 malloc_allocalloc, 容器的分配器默认使用alloc,即第二个分配器。

两个配置器的接口都有allocate() deallocate() reallocate(),这里主要聚焦于前两个接口。

第一个配置器(malloc_alloc)的allocate()从typedef的名字上可以看出,它只是简单调用malloc(), deallocate()也只是简单调用free(),唯一的特别之处是,这个配置器能够模拟C++ new 运算符的set_new_handler()以处理内存不足的情况。

而第二个配置器(alloc), 当内存小于128字节时则由自己管理这些内存块,会自己管理一个内存池当分配的内存大于128字节时,直接调用malloc::allocate(),即将分配请求转发给第一个配置器。如果系统空间不足,那么也调用malloc::allocate(),因为涉及内存分配器处理程序处理内存不足的情况。

为什么对于小于128bytes的内存块使用内存池来管理?

1.防止内存碎片

2.若使用malloc直接分配的内存,每块都带有一些cookie,若小内存偏分配次数多,那么cookie的占用空间相比于有用空间会很大,空间利用率不高。

alloc配置器如何管理内存池?这里只记个大概,细节看书。

alloc管理一个16个长度的数组,数组的每个元素都指向一个free_list, 每个free list都管理一种大小的空闲数据块。

内存块大小有8bytes 、16bytes、24bytes ... 128bytes。 因此一共要16个free list来进行空闲块的管理。

最初,alloc的内存池空无一物,当有请求来时,比如要申请8字节的空间,就调用malloc向系统申请空间大小为8 * 40,将其中的1块返回给用户,其中19块做切割处理后交给对应的free list管理,剩余的20块留给内存池的备用。当再有8字节申请时,则直接从这个free list中拨给用户空闲空间。当有16字节申请时,从内存池中的备用空间中找空闲内存,如果不够则再调用malloc重复刚刚的操作,但是在当前情景中,确实是存在备用内存的(刚刚分配8字节内存时剩余的20块)。

由此可见,这确实减少了cookie的大小,因为我们只是使用了一次malloc,只有一个cookie。

处理内存申请时,如果申请的内存块小于128bytes, alloc将从以下几方面递进式地申请内存

  • 首先看看对应freelist有无空闲空间
  • alloc的内存池的备用内存是否有空余块(end_free - start_free)
  • 如果没有,则使用malloc向系统申请内存
  • 如果malloc还失败了,那么再看看其他freelist是否还有没有划分出去的内存块
  • 最后,已然山穷水尽,调用malloc_alloc(第一个配置器)的allocate()看看它的 handler 处理程序能否空出一些内存

有几个问题值得探讨

  1. sgi版本的stl管理内存的方式乍一看和linux内核的伙伴系统很像,但是stl内存池根本不涉及连续内存块合并的操作,也就是说没有伙伴的概念
  2. 内存池管理的内存在程序运行期间,并没有被调用free()库函数,也更谈不上这些内存会归还给操作系统了。
  1. type traits

    使用了模板推导、模板偏特化、模板特化特性而得的类型萃取机制。

    可以仿照源码写一些自己的萃取机制:

    1. 首先定义两个trueType、falseType,这两个只是类型,然后定义一个模板类,typetraits,仿照源码定义一些类型,我这里使用的是using,也可以使用typedef。除了指针类型和基本类型,其他类型的TypeTraits::isPointer都是falseType类型,TypeTraits::isFundamentalType都是falsetype类型。

      struct trueType{};
      struct falseType{};
      template <typename T>
      class TypeTraits {
      public:
          using isPointer = falseType;
          using isFundamentalType = falseType;
      };
      
    2. 写T* 和 const T*的偏特化,这两个类是指针类型,也是基本类型:

      /* 对指针类型的偏特化版本*/
      template <typename T>
      class TypeTraits<T*> {
      public:
          using isPointer = trueType;
          using isFundamentalType = falseType;
      
      };
      template <typename T>
      class TypeTraits<const T*> {
      public:
          using isPointer = trueType;
      	using isFundamentalType = falseType;
      };
      
    3. 然后再写char、short 、int 、long 、longlong、float、double等以及usigned等基本变量的全特化,它们不是指针类型,但属于基本数据类型

      /*全特化, char short int long longlong float double, 以及unsinged系列 */ 
      template <>
      class TypeTraits<char> {
      public:
          using isPointer = falseType;
      	using isFundamentalType = trueType;
      };
      template <>
      class TypeTraits<int> {
      public:
          using isPointer = falseType;
      	using isFundamentalType = trueType;
      };
      // .... 等等 .....// 
      
    4. 如何使用?我们都知道编译期多态的一种涉及到模板,下面就来看看萃取机制如何做到编译器多态。

      定义一个模板函数,将其转发给doIt函数:

      template<class T>
      void dosomething(T x) {
          doIt(x, typename TypeTraits<T>::isPointer()); // 萃取出T类型是否是pointer类型
      }
      

      doIt函数有两个版本,它使用第二个参数的参数类型做重载:即判断参数类型是否为指针类型,进行不同操作。

      // 重载方法
      template<class T>
      void doIt(T x, trueType) {
          cout << "doIt , pointer version\n";
      }
      
      template<class T>
      void doIt(T x, falseType) {
          cout << "doIt , NOT pointer version\n";
      }
      
    5. 测试:

      class A {
          public:
           int a;
      };
      int main() {
          int integerA = 1;
          int* ptrA = &integerA;
          dosomething(integerA);
          dosomething(ptrA);
          A classA;
          dosomething(classA);
      
          A* classAptr = new A();
          dosomething(classAptr);
      
          A& classRef = classA;
          dosomething(classRef);  
      }
      

      输出如下:

      doIt , NOT pointer version  # int 类型不是指针类型
      doIt , is pointer version   # int* 类型是指针类型
      doIt , NOT pointer version  # class A 类型不是指针类型
      doIt , is pointer version   # class A* 类型是指针类型
      doIt , NOT pointer version  # class A& 类型不是指针类型
      

      当然上面只是用了 isPointer类型做了重载,isFundamentalType类型同理。

容器

vector

迭代器

vector的迭代器是原生指针,因此用不着重载 operator* operator-> operator++ operator--等。

template <class T, class Alloc = alloc>
class vector {
public:
  typedef T value_type;
  typedef value_type* iterator;  // iterator的类型是原生指针
// ....
}

因此它属于random access iterator

vector数据成员

只有三个itetor,start、finish、 end_of_storage,因为vecotr的迭代器是原生指针,因此对任何一个vector进行size of 都返回3个指针的大小。在64位机器上,是24bytes。

protected:
  typedef simple_alloc<value_type, Alloc> data_allocator;
  iterator start;
  iterator finish;
  iterator end_of_storage;

start指向这个vector所管理空间的头部元素,finish指向目前使用空间的尾部的后一个元素(前闭后开区间),end_of_storage指向可用空间的尾部的后一个元素。

finish - start = vector::size()

end_of_storage - start= vector::capacity()

[start,finish)这段已经被分配,且有拷贝\构造(如果元素有non-trivial 构造函数的化)函数作用其上;

[finish,end_of_storage)这段空间已经被分配,但是没有元素被构造其上

image-20221207200059621

member function

  • push_back : 在finish之后的内存上再构造一个元素。如果此时finish = end_of_storage,造成空间的再分配,会申请新的空间,大小为源vector的两倍,将旧容器的元素一一拷贝\构造到新的内存中,析构并释放原vector的内容,最后重新设置vector的三个数据成员的指向(指向新的空间)。因此如果pushback引起空间重分配,那么原先vector的迭代器将全部失效

    那么push_back的时间复杂度为?

    “均摊”时间复杂度还是O(1)

  • pop_back: finish指针--,然后析构最后一个元素

  • insert:引起元素的拷贝赋值,插入元素之后的元素整体向后挪。可能造成空间重分配,因此有可能全部迭代器失效,也有可能只有在插入元素之后的迭代器失效。

  • erase:将要删除元素之后的元素往前移覆盖掉要删除的元素,然后调用destroy函数析构(但不释放空间)后面多余的元素,finish迭代器往前移动对应的距离。注意只是析构,没有释放内存空间,所以size方法的返回值会变,而capacity方法的返回值不会变。

      iterator erase(iterator position) {
        if (position + 1 != end())
          copy(position + 1, finish, position); // 将被删除元素接下来的所有元素都往前挪一个位置
        --finish;		// finish指针往前移动一格
        destroy(finish); // 析构最后一个元素
        return position;
      }
      iterator erase(iterator first, iterator last) {
        iterator i = copy(last, finish, first);  // last之后的元素移动到first上
        destroy(i, finish); // 析构多余的元素
        finish = finish - (last - first); // finish指针往前移动(last - first)个位置
        return first;
      }
    
  • resize(n):强行将vector的元素数量增至或者减至n,即最后size()的结果 == n

    • void resize(size_type new_size) { resize(new_size, T()); }
      // 正真做事的是下面这个重载方法
      void resize(size_type new_size, const T& x) {
        if (new_size < size()) 
          erase(begin() + new_size, end());
        else
          insert(end(), new_size - size(), x);
      }
      
    • 如果n < size(), 那么调用erase 删除剩余元素

    • 如果n > size() , 那么调用insert增减对应数量元素。(此时也可能有insert引起内存扩容)

  • reserve(n), 将vector的容量增至能够容纳n个元素的大小,调用该方法后,capacity()的结果 >= n

    • void reserve(size_type n) {
        if (capacity() < n) {
          const size_type old_size = size();
          iterator tmp = allocate_and_copy(n, start, finish);
          destroy(start, finish);
          deallocate();
          start = tmp;
          finish = tmp + old_size;
          end_of_storage = start + n;
        }
      }
      
    • 如果原来的容量能够容纳n个元素,那么什么都不用做。

    • 如果不能,则进行内存重分配,与resize不同的是,reserve只会为分配增加空间,但不会为增加的空间调用构造函数

  • clear(): 清空元素,注意不能释放空间!

    void clear() { erase(begin(), end()); }
    

    如上所示,clear只是调用了erase方法,但是通过上面的erase方法已经知道了erase仅仅是析构元素(通过调用destroy),但是没有释放空间。

    在调用clear后,size()的调用结果为0,但是capacity()的调用结果不变。

  • 发现无论是clear还是resize都不能正真地减小vector的capacity大小,该如何缩小或者清空vector占用的内存呢? --- 使用swap:

    vector(Vec).swap(Vec); //将Vec中多余内存清除; 
    vector().swap(Vec); //清空Vec的全部内存;
    

迭代器失效

可能造成迭代器失效的成员函数:

  • erase:造成被删除元素及之后的迭代器失效
  • insert:
    • 如果没有造成空间重分配,则插入元素及之后的迭代器失效
    • 如果造成了空间重分配,所有迭代器都失效
  • push_back:
    • 如果没有造成空间重分配,只有end()迭代器失效
    • 如果造成了空间重分配,所有迭代器都失效
  • emplace:同insert
  • emplace_back: 同push_back
  • popback: 末尾元素的迭代器以及end()迭代器失效

扩容因子

关于扩容因子:为什么g++ vector的扩容因子是2,但是vc vector的扩容因子是1.5呢?

  • 使用1.5的扩容因子,有机会重新使用之前分配的地址
  • 但是为什么g++的vector的扩容因子是2?我个人想到了两点原因,
    • 内存池freelist管理的对象大小都是2的幂次方,malloc自身的内存池分配的大小也是2的幂次方,linux系统的伙伴系统分配的内存以页对齐,也是2的幂次方。所以从C++应用层到linux内核层,在linux上的C++应用分配得到的内存地址大概率是2的幂次方,因此g++的vector的扩容因子设置为2,方便系统的内存分配和回收。
    • 内存池freelist中串联的内存并不保证连续,而重用之前分配的地址则需要先后分配的地址连续,g++索性不实现重用这个特性了,所以扩容因子被设置成了2。

list

迭代器

list的迭代器就不会像vector那么简单了,因为list的迭代器不能使用原生指针。

list的迭代器的数据成员成员只有一个,一根指向list node的指针,然后重载一些列操作符模拟原生指针的行为

template<class T, class Ref, class Ptr>
struct __list_iterator {
    // ...
    typedef __list_node<T>* link_type;
    // ...
  	link_type node; // 指向一个listnode
    
  bool operator==(const self& x) const { return node == x.node; }
  bool operator!=(const self& x) const { return node != x.node; }
  // 模拟原生指针的 * 和 ->操作
  reference operator*() const { return (*node).data; } // 直接返回所指listnode的data
  pointer operator->() const { return &(operator*()); } // 调用*() 调用所致listnode的地址

  self& operator++() { // 前置自增 
    node = (link_type)((*node).next);
    return *this;
  }
  self operator++(int) { // 后置自增
    self tmp = *this;
    ++*this;
    return tmp;
  }
  self& operator--() { // 前置自减
    node = (link_type)((*node).prev);
    return *this;
  }
  self operator--(int) { // 后置自减
    self tmp = *this;
    --*this;
    return tmp;
  }
}

注意前置自增和后置自增的区别,前置自增仅仅把node指向下一个节点;而后置自增创建临时对象保存当前iter的状态,然后调用前置自增改变node指向,最后返回临时对象。正因此,两者返回值也不同,前置自增返回引用,而后置自增返回对象,因为我们不可能返回一个临时对象的引用。

为什么有这样的区别?我想同样是为了与C++的对基本数据类型后置自增操作相容。 i++ 被决议为旧值,然后执行+1, ++i则直接+1,被决议为新值。

从重载 -- 操作符可以看出,list的迭代器是一个bidirectional_iterator

数据成员

list只需要一个node指针即可表示整个链表(2.9版本,当前版本存储了3个指针)

class list {
    typedef list_node* link_type; 
    ...
    link_type node;
}

listnode 则有两个指针,和一笔数据

template <class T>
struct __list_node {
  typedef void* void_pointer;
  void_pointer next;
  void_pointer prev;
  T data;
};

其他实现比较简单(相对来说),erase操作只会使当前的迭代器失效,插入操作不会引起迭代器失效

deque

概览

vector是单向开口的连续线性空间,deque则是一种双向开口的连续线性空间。

然而deque的连续性是伪装的,主要依靠重载迭代器的++ 和 --操作来给出这种错觉。

内存布局上,一个deque有一个“中控器”和许多等长度的缓冲块组成。

image-20221207220439119

如图所示,deque拥有一个map指针,指向“中控器”,中控器的每个元素分别指向不同的缓冲区,这些缓冲区才是正真存放元素的地方。

迭代器

deque迭代器有4个指针

  T* cur; // 指向某个缓冲区的当前元素
  T* first; // 指向某个缓冲区的头部元素
  T* last;  // // 指向某个缓冲区的尾部元素
  map_pointer node; // 指向中控器 map的一个元素

image-20221207221032375

deque的迭代器重载++、--等运算符,维护deque “整体连续的错觉”。

self& operator++() {
    ++cur;
    if (cur == last) { // 如果指向了当前缓冲区的最后一个元素
      set_node(node + 1); // 那么将node、fist、指针指向下一个缓冲区的对应位置
      cur = first;  // 改变cur指针,指向新缓冲区的首元素
    }
    return *this; 
}

  void set_node(map_pointer new_node) {
    node = new_node;
    first = *new_node;
    last = first + difference_type(buffer_size());
  }

此外deque的迭代器类型是random_access_iterator,为了实现随机存取,需要重载 []、 +、 +=、-、-= 运算符

其中减号调用加号完成。而且[]调用+号,而+号调用+=号,因此主要任务落在了+=函数上。大体逻辑与++运算符是相似的,都是判断索要的元素在不在当前缓冲区,如果在就很容易实现,和vector差不多;但如果不在一个缓冲区中,那么就要向后寻找对应的缓冲区。

  self& operator+=(difference_type n) {
    difference_type offset = n + (cur - first);
    if (offset >= 0 && offset < difference_type(buffer_size()))
        // 目标位置在同一缓冲区
      cur += n;
    else {
        // 目标位置不在同一缓冲区
      difference_type node_offset =
        offset > 0 ? offset / difference_type(buffer_size())
                   : -difference_type((-offset - 1) / buffer_size()) - 1;
       // 切换至目标缓冲区
      set_node(node + node_offset);
       // 设置cur指针
      cur = first + (offset - node_offset * difference_type(buffer_size()));
    }
    return *this;
  }

deque数据成员

每一个deque容器有4个成员,其中有两个迭代器分别指向第一个节点和最后一个节点,一个map指针指向中控器,一个size_t变量记录中控器有多长。

又deque的迭代器存放4个指针,因此一个deque容器的大小为40bytes(16*2 + 4 + 4)。

protected:                      // Data members
  iterator start;  // 指向第一个缓冲区的第一个元素
  iterator finish; // 指向最后一个缓冲区的最后一个元素

  map_pointer map; // map数组的指针
  size_type map_size; // map数组的大小

deque的内存布局如下图所示,存放20个元素,要开辟3个长度为8的缓冲区,前两个缓冲区放满,后一个缓冲区留4个空位。

image-20221207230007336

start迭代器的cur指针指向第一个缓冲区的头部,finish的cur指针指向最后一个缓冲区的最后一个有效元素。

member function

deque的成员函数实在是太长了,细节看书吧。

  • push_back: 首先会在finish所指向的缓冲区插入新元素,如果缓冲区不够了,那么就开辟一块缓冲区将其挂在到map上,然后finish指向这个新的缓冲区执行插入。如果map的所有元素都已经挂在了缓冲区,那么此时需要重新分配map和缓冲区,将旧数据拷贝到新空间中;与vector类似,新map的大小几乎是旧map大小的两倍,且所有原迭代器均失效。
  • pop_back: 会在finish迭代器的cur指向的缓冲区元素上调用析构函数,如果这个缓冲区空了(finish.first == finish.cur),则会释放这块缓冲区这是与vector不同的点,vector只会动态扩张,而deque则会动态伸缩
  • insert 、 erase等操作与vector类似,会引发一系列的赋值拷贝行为。但deque是一个双端“连续”结构,它会检测插入\删除的位置,如果离deque头更近,则将操作元素之前的元素整体向前挪,如果离deque尾最近,则将操作元素之后的元素整体向后挪动。

stack、queue

这俩其实算是接配器,而不是容器,因为它们封装其他容器的接口,来实现它们的功能。

用来实现它们功能的底层容器可以有多种,默认为deque。

它们都没有迭代器

priority_queue

这也是接配器,默认的使用vector作为底层容器。配合push_heap pop_heap算法,使用连续内存的底层容器来实现堆的特性(根元素)

它没有迭代器。

map、set

红黑树

map和set底层都是红黑树,rb_tree对插入、查找、删除的复杂度都是O(logN);

红黑树相比于二叉搜索树的优点:当插入的数据有序时,二叉搜索树会退化为链表,因此所有操作的时间复杂度都与链表相同。

红黑树相比于平衡二叉树的优点:平衡树的左右子树高度严格不大于1,它的搜索操作可能比红黑树快,但是插入和删除操作叫容易引起存储结构的调整,因此时间复杂度比红黑是慢。

红黑树的整体结构如图所示:

image-20221208163545892

一个红黑树结构需要三个成员来描述:

template <class Key, class Value, class KeyOfValue, class Compare,
          class Alloc = alloc>
class rb_tree {
    ...
typedef rb_tree_node* link_type;
protected:
  size_type node_count; // 整棵的节点个数
  link_type header;   // header节点的指针,见上图
  Compare key_compare; // 节点值得比较准则,是一个function object
    ...
}

而一个node则由5笔数据来表现:

  • 3个指针,分别指向父节点、左子节点、右子节点
  • 表示颜色的bool值
  • 正真的存储数据

rb_tree有两个插入元素的函数

  • __insert_equal, 允许插入重复的元素
  • __insert_unique, 不允许插入重复的元素

multimap与map的区别,multiset与set的区别,都在这两个函数上。

__insert_unique的返回值是一个pair,其第一个元素是红黑树的某个迭代器,第二个元素是一个布尔值,表示插入是否成功。

如果调用__insert_unique插入了一个重复值,则返回的迭代器指向已经存在的哪个节点,布尔值则为false。

如果调用__insert_unique插入了一个不重复的值,则返回的迭代器指向新插入的节点,布尔值为true。

反正,不管怎么样__insert_unique都会返回一个合法的迭代器,在下面的[]运算符中有作用。

map与set的区别

map存放在节点的值是一个键值对,而set存放在节点的值就是单个值。然而这两个的底层数据结构都是红黑树,怎么做到的呢?

得从红黑树的类模板参数看起:

template <class Key, class Value, class KeyOfValue, class Compare,
          class Alloc = alloc>
class rb_tree { 
    // ...
	typedef __rb_tree_node<Value> rb_tree_node; // 红黑树节点的值只是存放Value而没有Key
    // ...
    Compare key_compare; // 比较节点的Key的大小关系
}

红黑树的模板参数有5个,其中

  • Key : 键的类型
  • Value : 值的类型
  • KeyOfValue: 从Value中提取出Key的方法

从上面三个可以看出,红黑树把Key视为Value的一部分,通过上层指定的KeyOfValue从Value中提取Key, KeyOfValue是同时实现set和map的精髓所在

每个红黑树节点实际存储的就是Value,可以从上面代码的typedef可以看出。

模板参数的Compare则是用于比较节点Key之间的大小关系。

现在看set模板类的定义

template <class Key, class Compare = less<Key>, class Alloc = alloc>
class set {
    // ...
  typedef Key key_type;
  typedef Key value_type;
  typedef Compare key_compare;  // 键的比较函数与值的比较函数相同,都是Compare,默认为less<Key>
  typedef Compare value_compare;
   // ...
  private:
  typedef rb_tree<key_type, value_type, 
                  identity<value_type>, key_compare, Alloc> rep_type; // 注意这里的identity<value_type>
  rep_type t;  // set里有一个红黑树
   // ...
}

关键在于几个typedef,set将建和值的类型都定义成了模板参数Key的类型,然后传给rb_tree的模板参数的KeyOfValue指定为identity<value_type>,看名字也能看出,当这个函数作用Value时返回等值得Key,也就是说在Set的Key和Value是一回事。

因此,set的红黑树的节点能说存放Key,也能说存放Value

再看map模板类的定义

template <class Key, class T, class Compare = less<Key>, class Alloc = alloc>
class map {
public:
// ...
  typedef Key key_type;
   typedef pair<const Key, T> value_type;
    //...
  typedef Compare key_compare;
  typedef Compare key_compare;
  class value_compare {
       // 另外定义value的比较规则
  }
private:
  typedef rb_tree<key_type, value_type, 
                  select1st<value_type>, key_compare, Alloc> rep_type; // 注意这里的select1st<value_type>
  rep_type t;
}

能够清楚地看到,map怎样使用红黑树存储key-value键值对。

主要还是在两个typedef中,map将key的类型定义为Key(也就是键值对中的键),将value的类型定义为一个pair,这个pair的第一个存储const类型的Key,pair的第二个存储键值对中的值。

然后定义红黑树时,自然地与set产生了不同,Key模板参数是键值对中的键,而Value模板参数则是整个KeyValue对组成的pair。KeyOfValue则是select1st,即取出pair中的第一个就是key。

显而易见,map的红黑树节点存储的值是键值对组成的pair。

迭代器

红黑树的迭代器,是一个bidirectional_iterator,为了实现这一点,重载 ++ 和 -- 运算符。

它的迭代器只存储一个node指针,指向某个红黑树节点。

set和map的迭代器直接使用红黑树的迭代器即可,

typedef typename rep_type::iterator iterator;

迭代器失效

insert方法:不会引起迭代器失效

erase方法:除了被删除的那个迭代器,其他迭代器都不会失效。

map的[]运算符

验证effective STL的条款24。

其源码如下:

  T& operator[](const key_type& k) {
    return (*((insert(value_type(k, T()))).first)).second;
  }
  • value_type(k, T()): 其中T表示data_type, k表示key_type变量的一个引用,而在map中value_type则是一个pair类型,具体为pair<const key_type, T>。那么这句话的意思就是,创建一个pair类型,其第一个元素的类型为key_type,值为k, 第二元素的类型为data_type,其值为该类型变量的默认值T()。 因此value_type(k, T()) 可以等效地看为pair<key_type,T>
  • 然后调用insert(pair<key_type,T>{k ,T()}), insert会调用__insert_unique函数。从上面几节的内容可以了解到,_insert_unique函数会返回一个pair<iterator, bool>, 其中bool表示插入是否成功,如果有元素重复则插入不成功,则返回旧值,如果成功则返回新值,这里的新值的value是一个默认量T()。

那么调用[]插入新元素和直接调用insert插入新元素的区别很容易看出来。

  • []运算符,首先调用insert使用用户给的键和默认的值构建了一个pair临时对象,把这个临时对象插入到红黑树中。上面说过,如果insert插入成功则返回pari,它的第一个值是指向新节点的迭代器,以引用的形式返回迭代器的第二个值,以便用户赋值。最后再用=运算符为这个新节点赋值。

  • 而insert则直接按照用户需求构造这个键值对,插入红黑树就完事了。

  • 所以如果想要插入一个新的值,直接insert会比 先[] 然后 = 赋值 来的快一些。

    umap[0] = "0"; // 住[]使用v默认值构造临时对象的值,这里会为临时变量使用=号赋值
    umap.insert({0,"0"}) // 新对象使用其他重载构造函数创建,直接一步到位
    

    可以看出第一种方法额外多了一步=赋值操作。

unordered_map 和 unordered_set

实现

stl源码一书上的库版本还没有将这两个容器纳入标准之内,但是也已经实现了hash_set 和 hash_map这两个类。

这两个容器的底层数据结构都是hash_table,如何使用一种数据结构同时实现hash_set和hash_map的技巧,与上一节的map和set类似,关键在hash_table的类模板参数的ExtractKey上。

template <class Value, class Key, class HashFcn,
          class ExtractKey, class EqualKey,
          class Alloc>
class hashtable {///}

可以预见,hash_map 和 hash_set对这个模板参数的设置是不同的,与上一节完全相同。

下面主要记录以下hash_table的实现:

  • stl的hash_table使用开链法处理碰撞

    image-20221208230740791

  • hash_table里有一个vector,用它的元素当作“桶”,但是hash_table自己控制vector的扩容时机,以及扩容大小

    • hash_table的buckets扩容时机:当负载因子(现有元素个数/桶的数量) > 1时, 每次在insert时检测是否要扩容
    • hash_table的buckets扩容大小:新的buckets大小并不是简单的两倍于旧的buckets大小。hash_table有一个static const数组,从小到大存放了128个质数。扩容时,从小到大的遍历这个数组,选择第一个比当前buckets大小更大的质数,这个新的质数就是新的buckets的大小。
    • 扩容的rehash操作会导致所有迭代器失效!
  • insert操作同样有两个版本,一个insert_unique,一个insert_equal,看名字就知道什么意思了。

  • hashtable的findcount函数,记录这两个函数主要是为了验证effective stl的条款45中关于这两个函数的建议。

      iterator find(const key_type& key) 
      {
        size_type n = bkt_num_key(key);
        node* first;
        for ( first = buckets[n];
              first && !equals(get_key(first->val), key); // 找到一个相同的就退出
              first = first->next)
          {}
        return iterator(first, this);
      } 
    
      size_type count(const key_type& key) const
      {
        const size_type n = bkt_num_key(key);
        size_type result = 0;
    
        for (const node* cur = buckets[n]; cur; cur = cur->next)
          if (equals(get_key(cur->val), key))
            ++result; // 遍历完所有的元素才退出
        return result;
      }
    
  • hashtable的迭代器的类型是ForwardIterator,不能执行自减操作

迭代器

unorederd_map的迭代其类型位forward_iterator,只能++不能--

它的迭代器只存放两个指针:

const node* cur;
const hashtable* ht;

一个指向具体的节点,一个指向哈希表自身

迭代器失效

erase方法:除了被删除的那个元素,其他迭代器都不会失效

insert方法:

  • 如果该方法没有引起扩容进行rehash则没有迭代器会失效。
  • 如果该方法引起了rehash,则所有迭代器失效

hash碰撞处理策略

另外再补充一些关于hahs碰撞的内容:

参考文章:

处理碰撞,主要分两种:

  • 开放寻址法

    • 线性探测
      • 做法:当要放入的bucket已被占用时,向后 + 1 寻找
      • 负载因子很小时,cache命中率很高,因此性能也高。与开放寻址的其他方法相比,当负载因子很大时,由于“主集团”(primary clustring)的影响,性能会大幅降低。 此外,分布不均匀的输入对线性探测有更大的影响,它需要更好的hash函数来消除输入的不均匀性.
    • 二次探测(quadratic probing) :
      • 做法: 当要放入的bucket已被占用时,向后 + (i) ^ 2 继续寻找,i 是探测的次数
      • 相比于线性探测,二次探测解决了因主集团的性能下降问题。但是却有次集团问题
      • 介于线性探测和double hashing之间
    • double hashing:
      • It uses one hash value generated by the hash function as the starting point and then increments the position by an interval which is decided using a second, independent hash function
      • 解决了集团问题,但是cache命中率差(降低了局部性),且计算量更大。
  • 开链法

    • 优点 : 相比于开放寻址,它不会受clustring的影响;随着负载因子的提高,性能下降地更“优雅”

      image-20220914195920564

    • 缺点: cache命中率比open addressing 低(太多分散的指针了);不容易被序列化(也是由于指针)

一般而言,开放寻址法的负载因子严格限制在了0.7-0.8,而开链法的负载因子能够趋近1(stl的实现就是当负载因子等于1时在扩容并rehash)

算法

sort

Introsort - C++’s Sorting Weapon - GeeksforGeeks : 很好的关于Introsort的文章

std::sort使用introsort算法,集成了quicksort、insertion sort以及heapsort。

  • 为什么使用insertionsort? 因为插入排序在小数据集的情况下时间复杂度低,且对部分有序数组的排序性能很高为O(kn)。参考Insertion sort - Wikipedia

  • Quicksort vs. Heapsort | Baeldung on Computer Science : 讲了quicksort与heapsort的对比。虽然quicksort与heapsort的平均时间复杂度都是O(nlogn),但是在实践中,quicksort比heapsort快,因为heapsort的时间复杂度的常数项比quicksort大。但如果空间复杂度有要求(比如嵌入式系统),那么heapsort可能是一个很好的选择。但是为了避免quicksort的时间复杂度退化为O(n2)的情况,当递归深度超出限制时,在introsort的策略下将改用heapsort。

introsort的部分C++实现源码:

template <class RandomAccessIterator>
inline void sort(RandomAccessIterator first, RandomAccessIterator last) {
  if (first != last) {
    __introsort_loop(first, last, value_type(first), __lg(last - first) * 2); // 最大递归深度限制为 log(last - first) * 2
      // 上面的函数退出后,元素为部分有序状态
    __final_insertion_sort(first, last);  // 插入排序
  }
}

template <class Size>
inline Size __lg(Size n) {
  Size k;
  for (k = 0; n > 1; n >>= 1) ++k;
  return k;
}

// 
template <class RandomAccessIterator, class T, class Size>
void __introsort_loop(RandomAccessIterator first,
                      RandomAccessIterator last, T*,
                      Size depth_limit) {
  while (last - first > __stl_threshold) { // 元素个数大于16时一直循环,否则退出
    if (depth_limit == 0) {
      partial_sort(first, last, last); // 超出允许的递归深度,改用堆排序
      return;
    }
     // 执行正常的quicksort流程
    --depth_limit;
    RandomAccessIterator cut = __unguarded_partition
      (first, last, T(__median(*first, *(first + (last - first)/2),
                               *(last - 1))));  // quicksort的partition操作
    __introsort_loop(cut, last, value_type(first), depth_limit);
    last = cut;
  }
}
posted @ 2023-05-04 20:36  别杀那头猪  阅读(85)  评论(0编辑  收藏  举报