【面试】标准库相关题型(三)

文章目录

    • 1. unordered_map底层实现原理
      • 1.1 散列表
      • 1.2 STL 中的 unordered_map 的实现
      • 1.3 unordered_map
    • 2. 迭代器底层实现原理及种类
      • 2.1 主要作用
      • 2.2 底层原理
      • 2.3 迭代器类型属性
    • 3. 迭代器失效
      • 3.1 容器类别
      • 3.2 失效情况
      • 3.3 C++11容器类别
    • 4. STL容器的线程安全
      • 4.1 背景知识
      • 4.2 解决方案

1. unordered_map底层实现原理

一句话概括:unordered_map 是基于散列表实现的 map。

1.1 散列表

  1. 定义和操作

    散列表是一种数据结构,它通过哈希函数把键(key)映射到哈希表的一个位置,然后在这个位置上存储键值对(key-value pair)。在 C++ 中,哈希表可以通过一个数组和一个哈希函数实现。

    template<typename Key, typename Value>
    class HashTable {
        vector<list<pair<Key, Value>>> data;  // 哈希表数据
        // 其他数据成员和函数成员...
    };
    
  2. 哈希碰撞和解决方案

    哈希碰撞是指多个不同的键被哈希函数映射到哈希表的同一个位置。主要有以下三种处理哈希碰撞的方法:

    • 线性探测:当碰撞发生时,向后寻找下一个空位存放数据。
    • 开放寻址:同样是在发生碰撞时向后寻找,但会使用二次哈希等方法改变探测的步长。
    • 拉链法:使用链表处理碰撞,把同一位置的键值对存入一个链表。

    STL 中的 unordered_map 使用的是拉链法处理哈希碰撞。

    // 拉链法的实现示例
    int hashFunc(Key key) { /* 哈希函数... */ }
    
    void insert(Key key, Value value) {
        int index = hashFunc(key);
        data[index].push_back(make_pair(key, value));  // 在链表尾部添加键值对
    }
    
  3. 负载因子

    负载因子是散列表的实际元素数量和位置数量(桶的数量)的比值。当负载因子过高时,哈希碰撞的概率增加,查询效率降低。

  4. 重新哈希

    当负载因子大于预设阈值(通常为1)时,会发起重新哈希(rehash),即扩大位置数量,并重新分配所有元素。

    void rehash() {
        // 创建新的更大的哈希表,把所有元素重新分配到新的哈希表
    }
    

1.2 STL 中的 unordered_map 的实现

  1. 哈希表的模板参数

    unordered_map 采用了复杂的模板参数设计,主要包括键类型 _Key、值类型 _Value、内存分配器 _Alloc、提取键的函数 _ExtractKey、比较键是否相等的函数 _Equal、哈希函数 _H1、映射函数 _H2、调用哈希函数和映射函数的函数 _Hash、重新哈希策略 _RehashPolicy 以及内存相关的 _Traits 等。

  2. 数据成员

    • _M_buckets:哈希表的指针数组,数组的每个元素是一个链表。
    • _M_bucket_count:数组长度,即哈希表的位置数量。
    • _M_element_count:实际存储的元素数量。
    • _M_before_begin:一个特殊的节点,它的下一个节点是哈希表的第一个节点。
    • _M_rehash_policy:重新哈希的策略对象,它决定何时需要重新哈希。
    • _M_single_bucket:一个临时的单节点桶,用于临时存储元素。
  3. 节点定义

    每个节点是一个结构体,包含了键值对的存储空间 _M_storage 和其他需要的信息。

    struct _Hash_node : _Hash_node_base {
        __stored_ptr _M_storage;
    };
    
  4. 主要的接口

    unordered_map 主要的接口包括插入、查找、删除等操作,它们的实现都是调用哈希表的对应函数。其中,插入操作 _M_insert_bucket_begin 是先确定插入的位置,然后在这个位置的链表头部插入新的键值对。

1.3 unordered_map

  1. 定义

    unordered_map 是一个模板类,它的模板参数包括键类型、值类型、哈希函数、比较函数和内存分配器。它的主要数据成员是一个 _Hashtable 对象。

    template<typename _Key, typename _Tp, typename _Hash = std::hash<_Key>, typename _Pred = std::equal_to<_Key>, typename _Alloc = std::allocator<std::pair<const _Key, _Tp>>>
    class unordered_map {
        // 使用 __umap_hashtable 作为底层的哈希表实现
        typedef __umap_hashtable<_Key, _Tp, _Hash, _Pred, _Alloc> _Hashtable;
        _Hashtable _M_h;
        // 其他函数成员...
    };
    
  2. 插入操作

    插入操作 insert 实际上是调用 _M_insert_bucket_begin 函数,把新的键值对插入到哈希表的对应位置。

    pair<iterator, bool> insert(const value_type& __x) {
        return _M_h._M_insert_bucket_begin(__x, _M_h._M_bucket_index(__x));
    }
    
  3. 特点

    unordered_map 是无序的,它不保证元素的顺序。它的搜索、插入和删除操作的时间复杂度都接近 O(1),这是通过哈希表实现的。当哈希碰撞很少时,这些操作的时间复杂度可以认为是常数时间。

2. 迭代器底层实现原理及种类

一句话概括:迭代器提供了一种访问容器内部元素,同时不会暴露容器内部实现的方式。

2.1 主要作用

  1. 解引用和成员访问

    迭代器主要用于遍历和访问容器中的元素。通过解引用迭代器(例如 *it),可以访问当前迭代器所指向的元素。通过成员访问(例如 it->member),可以访问当前元素的成员。

  2. 统一不同容器的访问方式

    迭代器可以统一不同容器的访问方式,使得算法可以对不同类型的容器进行操作。例如,findmin_elementupper_boundreversesort 等算法,它们都接收迭代器作为参数,用于访问容器的元素。

    vector<int> vec = {1, 2, 3, 4, 5};
    auto it = find(vec.begin(), vec.end(), 3);  // find 在 vector 中使用
    

2.2 底层原理

  1. 考虑哪些问题

    在设计迭代器时,需要考虑的主要问题有:

    • 泛型编程:迭代器不是基于面向对象的思想编程,而是基于泛型编程的思想。泛型编程注重代码的可复用性,可以编写出容器无关的代码。
    • 通用算法问题:需要确定迭代器的类型,然后根据迭代器的类型来实现相应的算法。因此,需要定义迭代器的类型别名,这样可以通过类型别名来获取迭代器的类型。对于指针类型,由于不能定义类型别名,因此需要通过泛型特化的方式来处理。
  2. 定义了迭代器的5种类型

    STL 定义了5种迭代器,分别是:

    • 输入迭代器(Input Iterator):只读,只能向前移动。
    • 输出迭代器(Output Iterator):只写,只能向前移动。
    • 前向迭代器(Forward Iterator):可读写,只能向前移动。
    • 双向迭代器(Bidirectional Iterator):可读写,可以向前和向后移动。
    • 随机访问迭代器(Random Access Iterator):可读写,可以随机访问。

    这五种迭代器之间存在包含关系,具体为:输入迭代器 < 前向迭代器 < 双向迭代器 < 随机访问迭代器,输出迭代器独立于此关系。

  3. 迭代器萃取

    迭代器萃取是指从迭代器类型中提取出迭代器的特性,例如迭代器的类别、值类型、指针类型、引用类型等。这是通过模板的特化来实现的。

    template<class Iterator>
    struct iterator_traits {
        typedef typename Iterator::iterator_category iterator_category;
        typedef typename Iterator::value_type        value_type;
        typedef typename Iterator::difference_type   difference_type;
        typedef typename Iterator::pointer           pointer;
        typedef typename Iterator::reference         reference;
    };
    

    对于指针类型,需要进行特化:

    template<class T>
    struct iterator_traits<T*> {
        typedef std::random_access_iterator_tag iterator_category;
        typedef T                               value_type;
        typedef ptrdiff_t                       difference_type;
        typedef T*                              pointer;
        typedef T&                              reference;
    };
    
  4. 函数重载

    可以通过函数重载,根据不同的迭代器类型选择不同的算法实现。例如,对于随机访问迭代器,可以使用更高效的算法。

    template <class RandomAccessIterator>
    inline void sort(RandomAccessIterator first, RandomAccessIterator last, random_access_iterator_tag) {
        // 高效的排序算法
    }
    
    template <class BidirectionalIterator>
    inline void sort(BidirectionalIterator first, BidirectionalIterator last, bidirectional_iterator_tag) {
        // 低效的排序算法
    }
    

2.3 迭代器类型属性

  1. 输入迭代器

    输入迭代器只读并且只能读取一次,常见的例子是 istream_iterator。在迭代器传递过程中,上一个迭代器会失效。

    istream_iterator<int> it(cin);
    
  2. 输出迭代器

    输出迭代器只写并且只能写入一次,常见的例子是 ostream_iterator。在迭代器传递过程中,上一个迭代器会失效。

    ostream_iterator<int> it(cout, " ");
    
  3. 前向迭代器

    前向迭代器可以读写并且可以多次读写,可以保存迭代器。例如 forward_listunordered_mapunordered_set 的迭代器。

    forward_list<int> lst = {1, 2, 3};
    auto it = lst.begin();
    
  4. 双向迭代器

    双向迭代器可以读写并且可以多次读写,可以保存迭代器,并且可以向前和向后移动。例如 listmapsetmultimapmultiset 的迭代器。

    list<int> lst = {1, 2, 3};
    auto it = lst.begin();
    ++it;
    --it;
    
  5. 随机访问迭代器

    随机访问迭代器可以读写并且可以多次读写,可以保存迭代器,并且可以进行随机访问。例如 vectordeque 的迭代器。

    vector<int> vec = {1, 2, 3};
    auto it = vec.begin() + 2;
    
  6. 属性图
    【面试】标准库相关题型(三)_第1张图片

3. 迭代器失效

迭代器失效是指在对容器进行一些操作(如插入、删除元素)后,之前获取的迭代器可能不再有效。失效的迭代器不应再被使用,否则可能导致未定义的行为。

3.1 容器类别

  1. 序列型容器

    这些容器维护一个严格的线性序列,例如 vectordequequeue。对这类容器进行插入或删除操作时,需要注意迭代器的失效情况。

  2. 关联型容器

    这些容器维护的是一个排序的关键字集合,例如 setmapmultisetmultimap。在这些容器中插入或删除元素,可能会导致迭代器失效。

  3. 链表型容器

    链表型容器包括 forward_listlist、以及unordered_* 系列容器。由于这些容器底层采用了链表结构,所以在插入或删除元素时,除了涉及操作的迭代器外,其他迭代器通常都不会失效。

3.2 失效情况

  1. 单独删除或插入

    当单独对某个位置进行插入或删除操作时,会影响到一部分迭代器的有效性。

    • 插入

      插入操作的方法有 insertemplacepush_backpush_front 等。在 vectordeque 中,如果在中间位置插入元素,可能会导致所有迭代器失效;如果在尾部插入元素,可能会导致所有迭代器失效,因为可能引发容器的扩容。在 listforward_list、以及 unordered_* 系列容器中,插入元素不会导致其他迭代器失效。

    • 删除

      删除操作的方法有 erasepop_backpop_frontclear 等。在 vectordeque 中,删除元素会导致从删除位置到尾部的所有迭代器失效。在 listforward_list、以及 unordered_* 系列容器中,删除元素只会让指向被删除元素的迭代器失效。

  2. 遍历删除

    在遍历过程中删除元素需要特别注意,一般需要在删除元素后及时更新迭代器。

    • 序列型容器

      对于 vectordeque 等序列型容器,可以使用以下方式在遍历中删除元素:

      for (auto it = container.begin(); it != container.end(); ) {
          if (shouldDelete(*it
      
      )) {
              it = container.erase(it);
          } else {
              ++it;
          }
      }
      
    • 关联型容器

      对于 setmapmultisetmultimap 等关联型容器,在 C++11 之前,通常采用以下方式在遍历中删除元素:

      for (auto it = container.begin(); it != container.end(); ) {
          if (shouldDelete(*it)) {
              container.erase(it++);
          } else {
              ++it;
          }
      }
      

      在 C++11 之后,可以像处理序列型容器一样,直接使用 erase 方法的返回值更新迭代器:

      for (auto it = container.begin(); it != container.end(); ) {
          if (shouldDelete(*it)) {
              it = container.erase(it);
          } else {
              ++it;
          }
      }
      
    • 链表型容器

      对于 forward_listlistunordered_* 系列容器,由于其底层为链表结构,因此可以采用与关联型容器在 C++11 之后相同的方式在遍历中删除元素。在这些容器中,除了被删除元素的迭代器会失效外,其他迭代器不会失效。

3.3 C++11容器类别

  1. 连续存储容器

    对于如 vectorstringdeque 这类连续存储的容器,插入或删除元素可能会导致所有迭代器失效。在遍历过程中删除元素时,需要更新迭代器:

    it = container.erase(it);
    
  2. 非连续存储容器

    对于如 listsetmapunordered_* 这类非连续存储的容器,在插入或删除元素时,只有指向被插入或删除元素的迭代器会失效。在遍历过程中删除元素时,也需要更新迭代器:

    it = container.erase(it);
    

    或者:

    container.erase(it++);
    

4. STL容器的线程安全

在多线程环境下,如果多个线程同时操作同一个容器,那么就需要考虑线程安全问题。STL中的容器并不是线程安全的,也就是说,它们并没有内部机制来防止并发操作带来的数据竞争或其他问题。

4.1 背景知识

STL容器的内部实现是已经固定的,它们没有加锁机制,也不能在其源码中添加锁。当多个线程并发操作同一容器时,可能会引发数据竞争或者其它未定义的行为。

  1. 容器内部实现原理

    • 扩缩容

      对于vectordeque以及基于deque的容器(如priority_queuequeuestack),当容器空间不足以容纳新的元素时,就需要进行扩容,即重新分配更大的内存空间,并将原来的元素复制到新的内存空间中。在多线程环境下,如果有一个线程在对容器进行扩容操作,而另一个线程试图访问或者修改容器的元素,那么就可能发生错误。

    • rehash

      对于unordered_*系列容器,当容器内元素数量增多时,为了保持良好的查找性能,它们会进行rehash操作,即增加哈希表的桶数量,并将原来的元素重新进行哈希放入新的桶中。这同样可能引起多线程下的问题。

    • 节点关系

      对于vector,当在其中间位置插入或删除元素时,会引起该位置之后的所有元素移动,改变它们与容器的关系。对于基于红黑树的容器(如setmap等),插入或删除元素可能会引起树的rebalance操作,改变节点间的关系。这两种情况在多线程环境下都可能造成问题。

4.2 解决方案

  1. 加锁

    一个直接的解决方案就是加锁,即在对容器进行操作前先获得锁,操作完成后再释放锁。

    • 互斥锁

      互斥锁可以防止两个线程同时对同一个容器进行操作。例如,在C++中可以使用std::mutex

      std::mutex mtx;
      // ...
      {
          std::lock_guard<std::mutex> lock(mtx);
          // 在此区域内对容器进行操作
      }
      
    • 读写锁

      如果一个容器主要用于读取操作,只偶尔进行写入操作,那么可以使用读写

      锁来提高效率。在C++中可以使用std::shared_mutex

      std::shared_mutex smtx;
      // ...
      {
          std::shared_lock<std::shared_mutex> lock(smtx);
          // 在此区域内进行读取操作
      }
      // ...
      {
          std::unique_lock<std::shared_mutex> lock(smtx);
          // 在此区域内进行写入操作
      }
      
  2. 不加锁,避免加锁

    除了使用锁,还可以通过设计避免在多线程中对同一个容器进行操作。一种方法是预先分配足够的节点,并将数据分成多份,每个线程只操作专属的那份数据。这样可以避免线程之间的冲突,但可能会引入新的问题,比如线程操作的不均衡等。

你可能感兴趣的:(面试,面试,散列表,哈希算法)