STL剖析(二):容器底层数据结构及常见用法-创新互联

一.概述

本文主要聚焦于STL容器,STL完整的容器分类体系如下所示,下文将逐一对各个容器底层的数据结构以及常见用法进行介绍。

成都创新互联公司是专业的郊区网站建设公司,郊区接单;提供成都网站建设、做网站,网页设计,网站设计,建网站,PHP网站建设等专业做网站服务;采用PHP框架,可快速的进行郊区网站开发网页制作和功能扩展;专业做搜索引擎喜爱的网站,专业的做网站团队,希望更多企业前来合作!

容器

测试环境:Ubuntu 22.04 g++ 11.3.0

二.顺序容器

顺序容器都对应着线性数据结构。

2.1 array

array的使用需要引入头文件,它表示固定大小的数组,与C风格的数组一致。在定义array时,需要指定类型T和数组大小N,即array,array创建及常见用法如下:

#include#include 

using namespace std;

int main()
{arrayarr1;
    // 对array填充特定的值
    arr1.fill(5); // arr1: {5, 5, 5}
    // 遍历数组方式一
    for (int a : arr1) {cout<< a<< " ";
    }
    cout<< endl; // 5 5 5
    //遍历数组方式二
    for (auto it = arr1.begin(); it != arr1.end(); it++) {cout<< *it<< " ";
    }
    cout<< endl; // 5 5 5

    // 定义时指定初值
    arrayarr2 = {2, 5, 4};
    // 交换两个数组的内容,前提是类型和数组大小都要相同
    arr1.swap(arr2); // arr1: {2, 5, 4}, arr2: {5, 5, 5}
    // 对数组进行排序

    arrayarr3 = {3.14, 2.56 };
    // 获取数组的大小
    cout<< arr3.size()<< endl; // 2
    // 获取数组的第一个元素元素
    cout<< arr3.front()<< endl; // 3.14
    // 获取数组的最后一个元素
    cout<< arr3.back()<< endl; // 2.56
    // 获取数组的首地址
    cout<< arr3.data()<< endl;

    return 0;
}
2.2 vector

vector表示动态数组,同array一样,vector中的元素也是连续存储在内存中的,但vector的大小是可变的。在使用vector时需要引入头文件,一般而言使用vector时仅需要指定数据类型T即可。

需要注意的是,vector并不是每添加一个元素就分配一次新的存储空间,实际上,当vector分配的容量全部填满元素后再添加新元素时,vector的分配器会重新分配两倍容量的新空间,然后将元素挪过去。vector示意图如下:

vector

图源自侯捷老师的《STL源码剖析》,侵权删。

注意:各家C++的实现可能略有区别,上述的重新分配规则不一定适用,但可以确定的是vector分配容量时一般都要未雨绸缪,即容量要比实际大小更大,否则需要重新分配

vector的创建及常见用法示例如下:

#include#includeusing namespace std;

int main()
{// 创建空vector
    vectorarr1;
    // vector末端插入元素
    arr1.push_back(1);
    arr1.push_back(3);
    arr1.push_back(5);
    arr1.push_back(7);
    arr1.push_back(9);

    // 创建时指定初值
    vectorarr2 = {1,2,3 };

    // 创建时指定大小和默认值
    vectorarr3(3, 5); // arr3: {5, 5, 5}

    // 遍历方式一
    for (int a : arr1) {cout<< a<< " ";
    }
    cout<< endl; // 1 3 5 7 9 

    // 遍历方式二
    for (auto it = arr2.begin(); it != arr2.end(); it++) {cout<< *it<< " ";
    }
    cout<< endl; // 1 2 3

    // 索引vector元素
    cout<< arr1[1]<< endl; // 3
    // 获取vector的第一个和最后一个元素
    cout<< arr2.front()<< " "<< arr2.back()<< endl; // 1 3

    // 判断数组是否为空
    cout<< arr1.empty()<< endl; // 0

    // 获取数组的大小和实际容量
    cout<< arr1.size()<< " "<< arr1.capacity()<< endl; // 5 8
    // 测试是否2倍扩张容量
    int n = 4;
    for (int i = 0; i< n; i++)
        arr1.push_back(i);
    cout<< arr1.size()<< " "<< arr1.capacity()<< endl; // 9 16

    // 清空vector的元素
    arr1.clear();
    // 插入元素,需要通过迭代器指定位置
    arr3.insert(arr3.begin(), 0); // arr3: {0, 5, 5, 5}
    // 删除vector末端的元素
    arr3.pop_back(); // arr3: {0, 5, 5}
    // 删除vector指定位置的元素,需要通过迭代器指定位置
    arr3.erase(arr3.begin());
    return 0;
}

注:对于vector,索引任意位置的元素的时间复杂度为 O ( 1 ) O(1) O(1),往末端插入或删除末端元素的时间复杂度为 O ( 1 ) O(1) O(1),往其它位置插入或删除其它位置元素的时间复杂度为 O ( n ) O(n) O(n)。

2.3 deque

deque表示双向队列,是一种双向开口的"连续"空间。要使用deque需要引入头文件,并指定数据类型T

连续只是用户看起来,实际deque的存储并非连续的,而是由一系列单独分配的固定大小的数组组成,另外,deque中还需要记录模块(中控器)来维护记录各个段的信息。

与vector比起来,deque的扩展更廉价,因为它不涉及将现有元素复制到新的内存位置。deque的示意图如下所示,其中map便是中控器,其中每个元素都指向一段连续的空间,称之为缓冲区,缓冲器才是用来存储数据的。从下图可以看出,deque还需要维护start和finish两个迭代器,分别指向第一缓冲区的第一个元素和最后缓冲区的最后一个元素。此外,这两个迭代器还需要指向map,否则无法在不同的缓冲区间转移。

deque

图源自stackoverflow: What really is a deque in STL?

deque的创建以及常见用法示例如下:

#include#includeusing namespace std;

int main()
{dequed = {1, 3, 5, 8, 10};
    //在deque开头插入元素
    d.push_front(0); // d: {0,1,3,5,8,10}
    // 删除deque开头的元素
    d.pop_front(); // d: {1,3,5,8,10}
    // 在deque结尾插入元素
    d.push_back(7); // d: {1,3,5,8,10,7}
    //删除deque结尾处的元素
    d.pop_back(); // d: {1,3,5,8,10}
    
    // 在指定位置插入元素,会返回一个指向插入元素的迭代器
    auto it = d.begin();
    it++;
    it = d.insert(it, 11); // d: {1,11,3,5,8,10}
    for (; it != d.end();it++)
    {cout<< *it<< " ";
    }
    cout<< endl; // {11,3,5,8,10}
    // 删除指定范围的元素
    d.erase(d.begin() + 2, d.begin() + 5); // {1,11,10}

    //获取deque开头和结尾的元素
    cout<< d.front()<< " "<< d.back()<< endl; // 1 10
    // 获取deque的大小
    cout<< d.size()<< endl; // 3
    
    return 0;
}

注:deque在开头和结尾可以进行元素的快速插入和删除。

2.4 list

list表示环形双向链表,链表大的特点的大小可变且存储非连续。要使用list需要引入头文件,创建对象时需要传入数据类型T

STL中list节点结构示意图如下图所示,其中包含两个指针prevnext分别指向当前节点的前一个和后一个节点,data存储当前节点的数据。

list_node

图源自侯捷老师的《STL源码剖析》,侵权删。

基于节点的结构,list的可视化示意图如下所示,可以看到,由于环状要求,还需要一个空白节点(红框标记)。

list

list的创建与常见用法如下所示:

#include#include#include 

using namespace std;

int main()
{// 创建list并初始化
    listl = {3, 5, 7, 9};
    // 在list前面添加一个数值为1的节点
    l.push_front(1);
    // 在list后面添加一个数值为11的节点
    l.push_back(11);

    // 遍历链表
    for(int a:l){cout<< a<< " ";
    }
    cout<< endl; // 1 3 5 7 9 11

    // 删除链表头的元素
    l.pop_front(); // l: {3,5,7,9,11}
    // 删除链表尾的元素
    l.pop_back(); // l:{3,5,7,9}
    // 寻找链表中大于6的第一个元素
    auto it = upper_bound(l.begin(), l.end(), 6);
    // 链表中插入元素
    if(it != l.end())
        l.insert(it, 6); // l: {3,5,6,7,9}
    // 删除满足某些特殊条件的元素
    l.remove_if([](const int x)
                {return x< 5;
    }); // l: {5,6,7,9}
    // 删除值等于某个特定值的元素
    l.remove(7); // l:{5,6,9}

    listl1 = {3, 1, 7, 10};
    // 对链表进行排序
    l1.sort(); // l1: {1,3,7,10}
    // 归并两个有序链表
    l.merge(l1); // l:{1,3,5,6,7,9,10}
    //对链表进行逆序
    l.reverse(); // l:{10,9,7,6,5,3,1}

    // 获取链表的大小
    cout<< l.size()<< endl; // 7
    // 获取链表头的元素
    cout<< l.front()<< endl; // 10
    // 获取链表尾的元素
    cout<< l.back()<< endl; // 1
    return 0;
}

注:list插入和和删除元素的时间复杂度为 O ( 1 ) O(1) O(1)。

2.5 forward_list

foward_list为单向链表,其行为与list类似,只不过只能单向访问。要使用forward_list需要引入头文件,创建对象时需要指定数据类型参数T

forward_list的示意图如下所示:

forward_list

forward_list的创建与常见用法如下所示:

#include#includeusing namespace std;

int main()
{// 创建对象并初始化
    forward_listl = {2, 4, 6, 8};
    // 在链表开头插入一个元素
    l.push_front(0); // l {0,2,4,6,8}
    // 删除链表头的元素
    l.pop_front(); // {2,4,6,8}
    // 在链表中指定位置后插入元素,位置通过迭代器来指定
    auto it = l.begin();
    it++;
    l.insert_after(it, 10); // {2,4,10,6,8}
    return 0;
}

forward_list的好多操作与list类似,这里不做重复赘述。

三.关联容器

关联容器底层的数据结构是红黑树。

3.1 红黑树

红黑树是一种平衡二叉搜索树,其示意图如下所示:

rbtree

红黑树需要满足如下规则:

  • 每个节点不是红色就是黑色。
  • 根节点需为黑色。
  • 红节点的子节点不能为红色。
  • 任一节点至NULL(树尾端)的任何路径所包含的黑色节点数须相同。

当新节插入时,若未符合上述规则,则需要调整颜色并旋转树形,使得其仍然是保持上述性质的平衡二叉搜索树。

红黑树中节点是有序的,即每个节点的左子树中各个节点的值都小于根节点,右子树中所有节点的值都大于根节点。

红黑树的插入、删除和查找操作的时间复杂度都是 O ( log N ) O(\text{log}N) O(logN)。

限于篇幅,本文不对红黑树进行展开讲解,后续将会写一篇专门的博客来介绍红黑树。

3.2 容器说明

对于set、multiset、map、multimap四种容器,其底层的数据结构都是红黑树,其中:

  • set表示集合,其中包含一系列无重复的键(key)。set的使用需要引入头文件,另外还需要指定键的数据类型T和排序准则Compare,注意后者不是必须的,只有当需要自定义键的排序准则时才需要显式指定。
  • multiset与set类似,但其可以包含重复的键。
  • map表示字典,即map中每个元素是一个键唯一的键值对。map的使用需要引入头文件,另外需要指定键(key)和值(value)的数据类型KeyT,此外其同样可以指定排序准则Compare
  • multimap与map类似,但其允许键值对的键重复。

上述四种容器中,set和map用的比较多,下面仅介绍两者的用法。

set的创建和常见用法示例如下:

#include#includeusing namespace std;

int main()
{// 创建并初始化,注意set会自动去重
    sets = {1, 3, 3, 5, 5};
    cout<< s.size()<< endl; // 3
    // 插入元素
    s.insert(7); // s: {1,3,5,7}
    // 删除指定位置的元素
    s.erase(s.begin()); // s: {3,5,7}
    // 删除特定的键
    s.erase(7); // s:{3,5}
    // 寻找集合中特定的key的数量(非1即0)
    cout<< s.count(3)<< " "<< s.count(7)<< endl; // 1 0

    s.insert(8);
    s.insert(0);
    s.insert(4); // s: {0,3,4,5,8}
    // 返回第一个不小于给定键的元素的迭代器
    auto it = s.lower_bound(6);
    if(it != s.end())
        cout<< *it<< endl; // 8
    // 返回第一个大于给定键的元素的迭代器
    it = s.upper_bound(2);
    if(it !=s.end())
        cout<< *it<< endl; // 3
    return 0;
}

map的创建和常见用法示例如下:

#include#includeusing namespace std;

int main()
{// 创建并初始化
    mapwc = {{"tom", 18}, {"andy", 12}};
    // 使用[]可以获取特定key对应的元素
    cout<< wc["tom"]<< endl; // 18
    // 使用[]时当不存在指定的key时,则会插入给定键的节点,若未指定值则值会设置为Value的默认值
    wc["jake"]; // {{"andy", 12}, {"jake":0}, {"tom", 18}}

    // 获取map中元素的个数
    cout<< wc.size()<< endl; // 3
    // 插入的另一种方式
    wc.insert({"smith", 22});

    auto it = wc.find("sala");
    if(it == wc.end()) // not find
        cout<< "not find"<< endl;
    else
        cout<< "find"<< endl;
    return 0;
}

注:map中存在很多查找操作与set类似,故没有重复介绍。

四.无序关联容器

无序关联容器底层的数据结构是哈希表。

4.1 哈希表

哈希表(散列表)在插入、删除、查找等操作上具有平均复杂度为 O ( 1 ) O(1) O(1),其数据结构示意图如下:

hash table

图源:hast-table

假定数组有 m m m个桶(bucket),哈希表通过哈希函数(hash function)计算key映射到索引(哈希代码),然后将元素放置到索引对应的桶。

但使用哈希函数可以回带来一个问题,哈希碰撞,即不同的元素被映射到相同的位置。目前解决哈希碰撞最常见的方法为开链(separate chaining),即数组每个桶都维护一个list,通过哈希函数可以为key分配一个桶,然后可以在桶对应list上进行相应的插入、删除和查找操作,若list足够短,速度仍然是非常快的。

限于篇幅,后面写博客详细介绍。

4.2 容器说明

unordered_set、unordered_multiset、unordered_map和unordered_multimap的底层数据结构都是哈希表,其中

  • unordered_set:包含一组唯一的键(key),使用需要引用,并指定键的数据类型。
  • unordered_multiset:与unordered_set类似,但键可以重复。使用需要引用
  • unordered_map:包含一组键(key)唯一的键值对,引用需要导入,并指定键和值的数据类型。
  • unordered_multimap:与unordered_map类似,但键可以重复。使用需要引用

注意:上述容器还可以指定哈希函数Hash和判断键是否相等的函数KeyEqual

上述四种容器中,unordered_set和unordered_map用的比较多,下面仅介绍两者的用法。

unordered_set的创建和常见用法如下所示:

#include#includeusing namespace std;

int main()
{// 创建并初始化
    unordered_setus = {3, 3, 4, 4, 7, 9, 1};
    // 获取元素个数
    cout<< us.size()<< endl; // 5
    // 获取哈希表的桶个数
    cout<< us.bucket_count()<< endl; // 13
    // 判断容器是否为空
    cout<< us.empty()<< endl; // 0
    // 返回特定桶的元素数
    cout<< us.bucket_size(0)<< endl; // 0
    // 返回每个桶的平均元素数
    cout<< us.load_factor()<< endl; // 0.384615

    // 往哈希表中插入元素
    us.insert(10); // us: {}
    auto it = us.find(9);
    if(it != us.end()) // find key!!!
        cout<< "find key!!!"<< endl;
    else
        cout<< "not find"<< endl;

    unordered_setus1 = {11, 8, 9};
    us.merge(us1); // us:{11,8,10,1,9,7,4,3}
    return 0;
}

unordered_map的创建和常见用法如下所示:

#include#includeusing namespace std;

int main()
{unordered_mapgrades = {{"math", 85}, {"chinese", 77}};
    // 插入特定的键与值
    grades["english"] = 90;
    // 插入特定的元素
    grades.insert({"biology", 85.5});
    // 获取特定键对应的值
    cout<< grades["math"]<< endl; // 85
    // 返回特定键的元素个数,非1即0
    cout<< grades.count("phyics")<< " "<< grades.count("math")<< endl; // 0 1
    return 0;
}

unordered_map的好多用法与unordered_set类似,故不重复赘述。

五.容器适配器

容器适配器是依靠其底层容器来完成特定数据结构的功能。

5.1 堆栈

stack是一种先进后出的数据结构,其示意图如下所示:

stack

图源自侯捷老师的《STL源码剖析》,侵权删。

可以看出stack只允许在栈顶进行元素的插入(push)、删除(pop)和获取。(stack不允许遍历行为)

STL中要使用stack,需要引入头文件,并指定栈内数据类型T,stack的默认底层容器为deque。由于deque是双向队列,因此只要将deque的底部结构封闭,便能形成一个栈。

stack的创建以及常见用法为:

#include#includeusing namespace std;

int main()
{stacks;
    // 往栈中插入元素
    s.push(1);
    s.push(3);
    s.push(4);
    s.push(9);
    s.push(10);
    // 获取栈中元素的个数
    cout<< s.size()<< endl; // 5
    // 通过empty()来判断栈是否为空
    while(!s.empty()) // 
    {// 获取栈顶的元素
        cout<< s.top()<< " ";
        // 弹出栈顶的元素
        s.pop();
    }
    cout<< endl; // 10 9 4 3 1

    return 0;
}
5.2 队列

queue是一种先进先出的数据结构,其示意图为:

queue

图源自侯捷老师的《STL源码剖析》,侵权删。

可以看出,queue允许从队首获取元素,从对尾插入元素。(queue不允许遍历行为)

要使用queue,需要引入头文件,并指定队列元素的数据类型T,queue的默认底层容器同样为deque。对于deque,只要封闭其底端的出口和首端的入口,便能轻易形成一个queue。

queue的创建及常见用法如下:

#include#includeusing namespace std;

int main()
{queueq;
    // 往队尾插入元素
    q.push(3);
    q.push(5);
    q.push(4);
    q.push(8);
    q.push(1);
    // 获取队列的大小
    cout<< q.size()<< endl; // 5
    // 通过empty判断队列是否为空
    while(!q.empty()){// 获取队首的元素
        cout<< q.front()<< " ";
        // 弹出队首的元素
        q.pop();
    }
    cout<< endl; // 3 5 4 8 1
    return 0;
}
5.3 优先级队列

priority_queue表示具有元素优先级的队列,其示意图如下:

priority_queue

图源自侯捷老师的《STL源码剖析》,侵权删。

priority_queue的行为与queue类似,都是只能从队尾加入元素,从队首获取元素。另外,由于priority_queue中元素带有优先级,因此其内的元素并非依次按照入队的次序排列,而是按照元素的优先级权值来进行排序,权值高的排在前面。

要使用priority_queue需要引用头文件,并指定元素类型。priority_queue的底层是利用一个大堆(max-heap)完成的,而max-heap是一个以vector表现的完全二叉树。使用大堆,可以满足优先级队列中权值高的排在前面的特性。

注意,priority_queue还可以指定权值大小的比较方法Compare

大堆中根节点的值都不小于其孩子节点的值。

priority_queue的创建及常见用法如下:

#include#includeusing namespace std;

int main()
{priority_queuepq;
    int n = 10;
    for (int i = 0;i// 往优先级队列中插入元素
        pq.push(rand() % 20);
    }
    // 获取队列大小
    cout<< pq.size()<< endl; // 10
    // 通过empty判断队列是否为空
    while(!pq.empty())
    {// 获取队首元素
        cout<< pq.top()<< " ";
        // 弹出队首元素
        pq.pop();
    }
    cout<< endl; // 17 15 15 13 12 9 6 6 3 1
    return 0;
}
六.参考资料

本文的完成参考了如下资料:

  • cppreference.com
  • cplusplus.com
  • 《STL源码剖析》侯捷著

以上便是本文的全部内容,要是觉得不错可以关注或点赞支持一下,有任何问题也敬请批评指正!!!。

你是否还在寻找稳定的海外服务器提供商?创新互联www.cdcxhl.cn海外机房具备T级流量清洗系统配攻击溯源,准确流量调度确保服务器高可用性,企业级服务器适合批量采购,新人活动首月15元起,快前往官网查看详情吧


网页标题:STL剖析(二):容器底层数据结构及常见用法-创新互联
分享链接:http://hbruida.cn/article/hhgph.html