再学C++:STL理解:序列式容器(array,vector,deque,forward_list,list)

前言


学过数据结构的同学们应该知道动态数组,双向链表,模拟栈,队列等基础数据结构的原理。比如我们在学习时,老师就要求我们使用数组,结构体等,实现模拟的循环队列双向链表等复杂的数据结构。
对于这种可能需要重复劳动的“体力活”,C++标准模板库(STL)对这些进行了封装,提供了一系列强大且灵活的容器,可大概分类为这三类:

  • 序列式容器
  • 关联式容器
  • 无序(关联式)容器

从名字可以看出:前者存在一个顺序,就像数组链表一样。后两者侧重于对应,类似哈希表。
本文将介绍序列式容器,也可说顺序型容器;

本文章侧重于容器的理解与核心不涉及具体使用,如需具体使用方法,请参照官网使用文档。


核心


总览

序列式容器常用的共有5种:

  • 数组(array):定长的顺序表,C 风格数组的简单包装。
  • 向量(vector):后端可高效增加元素的顺序表。
  • 双端队列(deque):双端都可高效增加元素的顺序表。
  • 单向列表(forward_list):只能沿一个方向遍历的链表。
  • 列表(list):可以沿双向遍历的链表。

学过数据结构的应该清楚,前三者都是本质数组,后两者为链表。
没学过也不用担心,只要你知道什么是数组,什么是链表就够了,我们一个一个来介绍:

数组(array)

array本质就是一个数组,没什么可以说的:

特点:

  • 固定大小
  • 支持随机访问
  • 不支持动态大小调整

复杂度分析:

  • 访问:常数时间复杂度O(1)
  • 尾部插入和删除:如允许,线性时间复杂度O(n)
  • 中间插入和删除:如允许,线性时间复杂度O(n)

代码示例:

int main() {
    std::array<int, 5> arr = {1, 2, 3, 4, 5};
    arr[2] = 10; // 随机访问

    for (const auto& elem : arr) 
    {
        std::cout << elem << " ";
    }
    return 0;
}

向量(vector)

vector是动态数组,能够自动调整其大小。它的本质依然是数组

数组(array)的不足在于,我定义了一个10个元素的数组,那么我想插入第11个元素,很显然不允许。那我应该怎么做,答案很粗暴简单,我开一个11元素的数组,把原来数组拷贝过来,插入第11个元素,把数组的头指针移过来,不就搞定了? 对的,这就是动态数组的扩容机制,当然,大家也应该看到了,扩容操作是一个并不高效的操作,在实际运行时,往往都是倍增起步,即10个元素满了,有新的插入需求时直接倍增,扩充为20或更大的数组。

特点:

  • 动态大小调整
  • 支持随机访问
  • 允许插入和删除操作,在末尾时时间复杂度为O(1)

复杂度分析:

访问:常数时间复杂度O(1)
尾部插入和删除:均摊常数时间复杂度O(1)
其他插入和删除:线性时间复杂度O(n)

代码示例:

int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    vec.push_back(6); // 在末尾插入元素
    vec[2] = 10;      // 随机访问

    for (const auto& elem : vec) 
    {
        std::cout << elem << " ";
    }
    return 0;
}

可以看出,vector相比array,最大的改善就是允许直接插入删除,不在需要担心容量问题,是一个相当优秀的上位替代。


双端队列(deque)

vector解决了一个问题,即直接从尾部插入删除元素,但是如果需要频繁更改头部元素呢?
和数组一样,如果需要在头部增删一个数据,那么需要将后面所有元素都进行移动,如果频繁的插入删除,效率会很低。
回顾一下vector,vector之所以可以在尾部快速增删,本质是因为尾部并没有满,所谓的增删本质上是将尾部的空数据替换成要变更的数据。这个尾部实际上存在,只是被我们屏蔽了。
既然尾部可以这样操作,那首部可不可以呢?
当然可以,定义10个元素的数组,然后在前面额外分配10个空间,后面额外分配10个空间,然后封装起来,当我们访问a[0]的时候,本质上是*a(10+0)。对的,这就是双端队列的核心思想
当然,实际上的双端队列是分段存储的,实现要比vector复杂的多,但是正如前面这个例子,核心并不难。

特点:

  • 双端高效插入和删除
  • 支持随机访问
  • 动态大小调整

复杂度分析:

  • 访问:常数时间复杂度O(1)
  • 两端插入和删除:常数时间复杂度O(1)
  • 中间插入和删除:线性时间复杂度O(n)

代码示例:

int main() {
    std::deque<int> deq = {1, 2, 3, 4, 5};
    deq.push_front(0); // 在前端插入元素
    deq.push_back(6);  // 在末尾插入元素

    for (const auto& elem : deq) {
        std::cout << elem << " ";
    }
    return 0;
}

实际使用中,需要根据实际需要进行选择,并无明显的优劣之分。


单向列表(forward_list)

单向列表就是一个普通的单向链表,每一个元素保存当前元素的内容和下一个元素的地址。

特点:

  • 只能沿一个方向遍历
  • 高效的头部插入和删除

复杂度分析:

  • 访问:线性时间复杂度O(n)
  • 头部插入和删除:常数时间复杂度O(1)
  • 其他插入和删除:线性时间复杂度O(n)

关于增删复杂度,这里认为没有相关引用,即首先查询,然后增删
关于其他情况,假如向单向列表ABC中,在B后插入元素D;
如果已经有要插入位置上一个元素B的引用,则为O(1)。
如果已经有要插入位置下一个元素C的引用,我们无法得到B的位置,则依然需要先查询到B,才可以进行插入。
总之,具体情况需要进行分析。

代码示例:

int main() {
    std::forward_list<int> flist = {1, 2, 3, 4, 5};
    flist.push_front(0); // 在头部插入元素

    for (const auto& elem : flist) {
        std::cout << elem << " ";
    }
    return 0;
}

列表(list)

list是双向链表,可以沿双向遍历。每个元素保存当前元素的内容,上一个和下一个元素的地址。

特点:

  • 高效的插入和删除操作
  • 支持双向遍历
  • 动态大小调整

复杂度分析:

访问:线性时间复杂度O(n)
首部和尾部插入和删除:常数时间复杂度O(n)
其他位置插入和删除:常数时间复杂度O(n)

同前面单向列表,这里假设不知道插入位置相关的引用,故需要先遍历链表查询位置。

代码示例:

int main() {
    std::list<int> lst = {1, 2, 3, 4, 5};
    lst.push_back(6);   // 在末尾插入元素
    lst.push_front(0);  // 在前端插入元素

    for (const auto& elem : lst) {
        std::cout << elem << " ";
    }
    return 0;
}

总结与理解


简单总结一下各个顺序容器:

  • array:适用于大小固定且需要随机访问的场景,但不支持动态调整大小。
  • vector:适用于需要动态调整大小且尾部插入和删除操作频繁的场景,是大多数情况下的首选容器。
  • deque:适用于需要双端高效插入和删除操作的场景,结合了数组和链表的优点。
  • forward_list:适用于需要高效头部插入和删除且不需要双向遍历的场景,内存占用较低。
  • list:适用于需要双向遍历和高效插入删除操作的场景,提供了最大的灵活性。

顺序容器是C++ STL中最常用的容器之一,也是算法比赛,项目开发中常用的数据结构,熟练使用可以大大提升编程的灵活性。尽管有些地方认为STL是模版编程,将它归为高级特性,但事实就是,灵活使用STL是一个重要的基本功。

  • 15
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

云璃丶夢紡

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

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

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

打赏作者

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

抵扣说明:

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

余额充值