C++入门(十二):STL底层原理

详细的STL接口及介绍可以参考官方文档:https://cplusplus.com/reference/,本文重点介绍不同容器的底层原理以及使用注意事项。

需要特别说明的是,所有是STL容器或适配器,都不是线程安全的,因此在多线程环境保证外部同步。

各容器clear()行为总结:

容器类型clear() 是否释放内存说明
vector❌ 不释放只销毁元素,容量保持不变
deque⚠️ 部分释放释放数据块,中控器可能保留
list / forward_list✅ 完全释放释放所有节点内存
set / map 等树容器✅ 完全释放释放所有树节点内存
unordered_set 等哈希容器⚠️ 部分释放释放元素,桶数组通常保留

1. std::vector

时间复杂度分析:

操作时间复杂度说明
随机访问O(1)常量时间
在末尾插入/删除O(1) 均摊可能触发扩容
在开头或中间插入/删除O(n)需要移动元素
查找O(n)线性搜索
排序O(n log n)使用std::sort
扩容O(n)拷贝所有元素

vector是一种序列容器,其内存管理是一个一块连续分配的内存,支持随机访问,支持以移动拷贝的方式进行动态扩展(一般以2倍大小进行扩展)std::vector 的典型实现包含三个关键指针:

/*
_M_start       _M_finish    _M_end_of_storage
  ↓              ↓           ↓
[0 | 1 | 2 | 3 | ? | ? | ? | ? ]
 │              │           │
 │              │           └── 分配的内存边界
 │              └────────────── 已构造的元素边界  
 └───────────────────────────── 内存块起始位置

size() = 4 (已使用元素数量)
capacity() = 8 (总容量)
*/

template<typename T, typename Allocator = std::allocator<T>>
class vector {
private:
    T* _M_start;           // 指向已使用内存的起始位置
    T* _M_finish;          // 指向已使用内存的结束位置(最后一个元素的下一个)
    T* _M_end_of_storage;  // 指向整个分配内存的结束位置
    
    // 内存分配器
    Allocator _M_alloc;
    
public:
    // 关键成员函数
    size_type size() const noexcept { return _M_finish - _M_start; }
    size_type capacity() const noexcept { return _M_end_of_storage - _M_start; }
    bool empty() const noexcept { return _M_start == _M_finish; }
};

在使用std::vector时,有以下几点需要特别注意下:

  • 因为std::vector是动态扩容的,因此在多线程使用时,需要特别注意迭代器失效问题。
  • 在调用std::vector的成员函数clear()时,其内存并没有立即释放,若需要其立即释放,建议通过以下方式实现:
std::vector<int> vec(1000000);

// 方法1: clear() + shrink_to_fit() (C++11)
vec.clear();
vec.shrink_to_fit();  // 请求释放未使用内存(仅仅是请求)

// 方法2: swap技巧 (C++98/03)
std::vector<int>().swap(vec);  // 保证释放所有内存

// 方法3: 赋值空vector
vec = std::vector<int>();  // 移动赋值或拷贝赋值,释放旧内存

// 方法4: resize(0) + shrink_to_fit()
vec.resize(0);
vec.shrink_to_fit();// 仅仅是请求

// 性能测试:方法2通常最可靠,但C++11后方法1更直观
  • 坚决避免使用std::vector< bool>类型,因为其底层不是直接存储了1字节的bool,而是存储了bit位,如下所示:
std::vector<int> int_vec{1, 0, 1};    // 真正的数组:每个int占4字节
std::vector<bool> bool_vec{true, false, true}; // 位压缩:3个bit只占1字节

/*
vector<int>:    [0x00000001 | 0x00000000 | 0x00000001]  // 12字节
vector<bool>:   [0b00000101]                            // 1字节(位压缩)
*/

    这通常导致使用方式不当会产生奇奇怪怪的问题,例如:

    std::vector<bool> flags{true, false, true};
    
    // 看起来element 是bool,实际上是std::vector<bool>::reference
    auto element = flags[0];

    2. std::list

    时间复杂度分析:

    操作时间复杂度说明
    在任意位置插入/删除O(1)已知位置时
    随机访问O(n)需要遍历
    查找O(n)线性搜索
    排序O(n log n)成员函数sort

    std::list是由环形双向链表实现的,每个节点存储一个元素,内存分配是不连续的,内存布局示意图如下

    实际的链表结构:
    _M_node (哨兵) ↔ 节点1 ↔ 节点2 ↔ 节点3 ↔ 回到_M_node
    
    内存示意图:
    _M_node: [prev→节点3 | next→节点1]
    节点1:   [prev→_M_node | data1 | next→节点2]
    节点2:   [prev→节点1   | data2 | next→节点3]  
    节点3:   [prev→节点2   | data3 | next→_M_node]

    在使用std::list时,有两点需要额外注意的:

    1. list的迭代器在插入和删除操作后仍然保持有效(除了被删除元素的迭代器),这是它的一个重要优势。但需要注意的是,虽然迭代器本身有效,但指向的内容可能已经改变。
    2. 在遍历list时删除元素需要特别小心。错误的做法是在删除后继续使用原来的迭代器。正确的做法是使用 erase 函数的返回值来更新迭代器,因为 erase 返回指向下一个元素的迭代器。
    void correct_traversal_erase_return() {
        std::list<int> lst = {1, 2, 3, 4, 2, 5, 2};
        for (auto it = lst.begin(); it != lst.end(); ) {
            if (*it == 2) {
                it = lst.erase(it);  //erase返回指向下一个元素的迭代器,更新it,不需要手动++it
            } else {
                ++it;  // 只有没有删除时才前进
            }
        }
        
        // 现在lst包含: 1, 3, 4, 5
    }

    3. std::forward_list

    时间复杂度分析:

    操作时间复杂度说明
    在头部插入/删除O(1)push_front()pop_front()
    在已知位置后插入/删除O(1)insert_after()erase_after()
    随机访问O(n)需要从头遍历
    查找O(n)线性搜索
    获取大小O(n)std::distance(begin(), end())
    排序O(n log n)成员函数 sort()
    合并O(n)成员函数 merge()
    反转O(n)成员函数 reverse()

    forward_list是一个序列容器,它的底层实现为单向链表。下面汇总了其核心特性和与 std::list 的主要区别,帮你快速把握要点:

    特性维度std::forward_list (单向链表)std::list (双向链表)
    内存布局每个节点含数据一个后继指针 next每个节点含数据一个前驱指针一个后继指针
    迭代器类型前向迭代器,仅支持 ++ 操作双向迭代器,支持 ++ 和 -- 操作
    随机访问不支持,访问元素需线性时间遍历不支持,访问元素需线性时间遍历
    头部操作push_front()pop_front() 等,O(1)push_front()pop_front() 等,O(1)
    尾部操作不直接提供,因需从头遍历,代价高push_back()pop_back() 等,O(1)
    中间插入/删除insert_after()erase_after()O(1)insert()erase()O(1)
    size() 成员函数,计算大小需 std::distance(begin, end)O(n),通常 O(1)
    内存开销(64位)每个节点约 sizeof(T) + 8 字节每个节点约 sizeof(T) + 16 字节
    迭代器失效插入操作通常不使其他迭代器失效;被删除元素的迭代器会失效-1插入操作不使其他迭代器失效;被删除元素的迭代器会失效

    4. std::deque

    时间复杂度分析:

    操作时间复杂度说明
    在头部插入/删除O(1)push_front()pop_front()
    在尾部插入/删除O(1)push_back()pop_back()
    随机访问O(1)operator[]at()
    在中间插入/删除O(n)insert()erase()
    查找O(n)线性搜索
    排序O(n log n)std::sort()

    std::deque(双端队列):是一种双开口的“连续”空间的数据空间,双开口的含义是:可以在头尾俩段进行插入和删除操作,且时间复杂度为O(1)。与vector相比,头插效率高,不需要搬移元素;与list相比,空间利用率高。

    和 vector 容器采用连续的线性空间不同,deque 容器存储数据的空间是由一段一段等长的连续空间构成,各段空间之间并不一定是连续的,可以位于在内存的不同区域。为了管理这些连续空间,deque 容器用数组(数组名假设为 map)存储着各个连续空间的首地址。也就是说,map数组中存储的都是指针,指向那些真正用来存储数据的各个连续空间,这也就意味着对std::deque的索引访问必须执行两次指针引用(vector仅需要执行一次):

    动态扩容策略,当在两端插入元素时:

    • 前端插入:如果第一个数据块还有空间,直接使用;否则分配新数据块并更新中控器

    • 后端插入:如果最后一个数据块还有空间,直接使用;否则分配新数据块

    • 中控器扩容:当数据块数量超过中控器容量时,重新分配更大的中控器

    在使用std::deque时,有以下几点需要特别注意下(同std::vector):

    • 因为std::deque是动态扩容的,因此在多线程使用时,需要特别注意迭代器失效问题。
    • 在调用std::deque的成员函数clear()时,其内存并没有立即释放,若需要其立即释放,建议通过以下方式实现:
    std::deque<int> deq(1000000);
    
    // 方法1: clear() + shrink_to_fit() (C++11)
    deq.clear();
    deq.shrink_to_fit();  // 请求释放未使用内存(仅仅是请求)
    
    // 方法2: swap技巧 (C++98/03)
    std::deque<int>().swap(deq);  // 保证释放所有内存
    
    // 方法3: 赋值空vector
    deq= std::deque<int>();  // 移动赋值或拷贝赋值,释放旧内存
    
    // 方法4: resize(0) + shrink_to_fit()
    deq.resize(0);
    deq.shrink_to_fit();// 仅仅是请求
    
    // 性能测试:方法2通常最可靠,但C++11后方法1更直观

    5. std::array

    时间复杂度分析:

    操作时间复杂度说明
    随机访问O(1)operator[]at()front()back()
    遍历访问O(n)需要访问n个元素
    查找O(n)线性搜索
    排序O(n log n)std::sort()
    大小查询O(1)size()empty()
    填充O(n)fill()

    std::array本质是C数组包装:零开销抽象,性能与C数组完全相同,其空间是在编译期确定大小,内存是完全连续的内存,不支持动态分配。

    零大小的std::array是合法的,但它们不应该被引用以访问它的内容。

    6. std::queue

    时间复杂度分析:

    操作时间复杂度说明
    入队 (push())O(1)在尾部添加元素
    出队 (pop())O(1)移除头部元素
    访问队首 (front())O(1)查看头部元素
    访问队尾 (back())O(1)查看尾部元素
    大小查询 (size()empty())O(1)元素数量查询
    查找元素不支持队列不支持查找

    std::queue被实现为容器适配器,是一种 FIFO(先进先出)数据结构,无迭代器,对空队列操作 front()/pop() 会导致未定义行为,默认使用 std::deque 作为底层容器,但也可以指定其他容器,例如:

    // 默认使用 deque,所有操作: O(1)
    std::queue<int> q1;
    
    // 显式指定 deque
    std::queue<int, std::deque<int>> q2;  
    
    // 显式指定 list,所有操作仍然是 O(1),但内存开销更大
    std::queue<int, std::list<int>> q; 
    
    // 显式指定 vector,出队操作变成 O(n), 不推荐!
    std::queue<int, std::vector<int>> q;

    7. std::priority_queue

    时间复杂度分析:

    操作时间复杂度说明
    插入元素 (push())O(log n)堆插入操作
    删除堆顶 (pop())O(log n)堆删除操作
    访问堆顶 (top())O(1)直接访问根部
    大小查询 (size()empty())O(1)元素数量查询

    std::priority_queue 的排序通常是基于完全二叉树来实现的,默认使用vector作为其底层容器,当然也可以选择deque, 不支持随机访问或迭代器。

    // 默认使用vector,缓存友好
    std::priority_queue<int> pq;  
    
    // deque也可以工作
    std::priority_queue<int, std::deque<int>> pq;  
    
    // 错误!list不支持随机访问,堆算法无法工作
    std::priority_queue<int, std::list<int>> bad_pq;

    8. std::stack

    时间复杂度分析:

    操作时间复杂度说明
    入栈 (push())O(1)在栈顶添加元素
    出栈 (pop())O(1)移除栈顶元素
    访问栈顶 (top())O(1)查看栈顶元素
    大小查询 (size()empty())O(1)元素数量查询

    stack是一种容器适配器,专门设计用于执行 LIFO(后进先出)操作的适配器,其元素仅从适配器的一端进行插入和提取,默认使用std::deque 作为底层容器,但也可以指定其他容器,但性能差异较大:

    操作std::deque(默认)std::vectorstd::list
    push()O(1)O(1) 均摊O(1)
    pop()O(1)O(1)O(1)
    top()O(1)O(1)O(1)
    size()O(1)O(1)O(1)
    内存开销中等
    缓存性能良好最佳
    在空栈上调用top()或pop()是未定义行为,没有迭代器。

    9. std::multiset/set/multimap/map

    时间复杂度分析:

    操作时间复杂度说明
    插入元素 (insert())O(log n)红黑树插入
    删除元素 (erase())O(log n)红黑树删除
    查找元素 (find())O(log n)二叉搜索树查找
    计数元素 (count())O(log n + k)k为元素出现次数
    范围查询 (lower/upper_bound())O(log n)边界查找
    最小值/最大值 (begin()rbegin())O(1)最左/最右节点
    访问元素 (operator[])O(log n)map特有,查找或插入
    遍历所有元素O(n)中序遍历

    std::multiset/std::set/std::multimap/std::map通常基于红黑树(Red-Black Tree)实现,这是一种自平衡的二叉搜索树。

    std::map的operator[]函数会在键值不存在时,自动插入新元素:

    std::map<int, std::string> m;
    
    // 键值5不存在,则自动插入键值为5,value为空字符串的元素
    std::string value = m[5];
    
    // 若键值已存在,则会被强制覆盖替换
    m[5] == "something";
    
    // 编译错误!multimap 没有 operator[]
    std::multimap<int, std::string> mm;
    auto value = mm[5];  

    关于迭代器何时失效方面(只有被删除元素的迭代器会失效):

    • 插入新元素不会使现有迭代器失效

    • 删除其他元素不会影响指向未被删除元素的迭代器

    • 删除元素会使指向该元素的迭代器失效

    • 清除整个容器会使所有迭代器失效

    10. std::unordered_set/unordered_multiset/unordered_map/unordered_multimap

    时间复杂度分析:

    操作平均情况最坏情况说明
    插入元素 (insert()emplace())O(1)O(n)哈希表插入
    删除元素 (erase())O(1)O(n)哈希表删除
    查找元素 (find())O(1)O(n)键查找
    访问元素 (operator[]at())O(1)O(n)unordered_map/unordered_multimap特有
    计数元素 (count())O(1)O(n)键的出现次数
    遍历所有元素O(n)O(n)遍历所有桶
    桶操作和哈希策略O(1)O(1)元数据访问

    std::unordered_set 和 std::unordered_multiset以及unordered_map、unordered_multimap都是基于开链法(Separate Chaining)的哈希表实现。

     std::unordered_map的operator[]函数会在键值不存在时,自动插入新元素:

    std::unordered_map<int, std::string> m;
    
    // 键值5不存在,则自动插入键值为5,value为空字符串的元素
    std::string value = m[5];
    
    // 若键值已存在,则会被强制覆盖替换
    m[5] == "something";
    
    // 编译错误!multimap 没有 operator[]
    std::unordered_multimap<int, std::string> mm;
    auto value = mm[5];  

    关于迭代器何时失效方面(只有被删除元素的迭代器会失效):

    • 插入新元素不会使现有迭代器失效

    • 删除其他元素不会影响指向未被删除元素的迭代器

    • 删除元素会使指向该元素的迭代器失效

    • 清除整个容器会使所有迭代器失效

    • 重新哈希会使所有迭代器失效

    哈希函数将键映射到桶索引,其质量直接影响哈希碰撞的概率,这将导致不同健映射到同一个桶中,这引发一系列性能问题:

    1. 链表长度增加:在开链法实现中,同一个桶内的元素形成链表。碰撞越多,链表越长。

    2. 查找性能退化:在链表中查找元素的时间复杂度是 O(k),其中 k 是链表长度。最坏情况下,所有元素都碰撞到同一个桶,查找性能从 O(1) 退化为 O(n)。

    3. 缓存不友好:链表节点在内存中不连续,导致大量的缓存未命中。现代CPU中,缓存未命中的代价比指令执行代价高几个数量级。

    负载因子 = 元素数量 / 桶数量,负载因子与性能的关系:

    • 低负载因子(如 0.1-0.5):链表短,查找速度快,性能稳定,很少触发重哈希

    • 适中负载因子(如 0.7-1.0):查找性能仍然很好,重哈希频率适中
    • 高负载因子(如 >1.5):内存利用率最高,链表长,查找性能显著下降,性能不稳定,频繁触发重哈希

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包

    打赏作者

    Chiang木

    你的鼓励将是我创作的最大动力

    ¥1 ¥2 ¥4 ¥6 ¥10 ¥20
    扫码支付:¥1
    获取中
    扫码支付

    您的余额不足,请更换扫码支付或充值

    打赏作者

    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

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

    余额充值