STL序列式容器 STL Sequence Container
目录
STL序列式容器 STL Sequence Container
STL(Standard Template Library)概述
-
STL(Standard Template Library)概述
Stop Trying to Reinvent the Wheel. 不要重复造轮子。复用一个高效的程序无疑会提高软件开发效率,所以“可重复运用的东西”与制造“可重复运用的东西”的方法是很重要的轮子。从子程序(subroutines)、程序(procedures)、函数(functions)、类别(classes) ,到函数库(function libraries)、类别库(class libraries)、各种组件(components),从结构化设计、模块化设计、面向对象(object oriented)设计,到模式(patterns)的归纳整理,无一不是软件工程的漫漫奋斗史,为的就是复用性(reusability)的提升。[1]
数据结构与算法是程序最重要的两样东西,但在STL之前c++对数据结构和算法一直没有一套标准,为了提高程序开发的效率以及减少人力资源的浪费,惠普实验室使用模板类和模板函数的方式创造了STL,并且如今c++编译器都内置了STL标准库。
STL中包括很多组件,其中最重要的是容器(Container)、迭代器(Iterator)、算法(Algorithm)。同时STL将数据与算法分离,通过迭代器使得二者能够完美高效的实现程序,同时程序员只需要知道各个组件的接口而非各个组件的底层实现方式。
-
容器(Container)
STL将数据储存在容器中,同时为了适应不同需要将容器分为三大类:序列式容器(Sequence container)、关联式容器(Associative container)和无序容器(Unordered container)。
-
序列式容器(Sequence Container)
序列式容器是一种有序(ordered)的集合,其中每个元素都有确切的位置,与元素值无关,但它不是已排序的(sorted)。
序列式容器主要包括五种:向量(vector)、列表(list)、双端队列(deque)、栈(stack)、队列(queue)。
上图截取自《STL源码剖析》图4-1,为SGI STL各序列式容器(以内缩方式表达基层与衍生层的关系)这里所谓衍生,并非派生(inheritance)而是内含(containment)
-
向量(Vector)
-
概述
向量类似于数组是一种数据结构,其与数组的差别主要在于vector空间利用更加灵活。数组是静态空间,一旦确定数组最大容量后,当空间不足时只能由程序员重新分配一块更大的新空间,并且将原先的元素移动到新的空间。而向量的空间是动态的,随着元素加入,其内部机制会自动扩充空间以保证可以容纳新元素。同时由于以类模板的形式实现,使得释放空间可由析构函数自动执行,防止内存泄露的严重错误出现。
- 物理结构
向量与数组类似都是一串连续的储存空间,通过物理位置的前后表示逻辑顺序的先后顺序。
- template <typename T, typename Alloc>
- class vector
- {
- iterator start ;
- iterator finish ;
- iterator end_of_storage ;
- };
-
关键操作的具体实现概述
- 向量的扩容
- template <typename T> void Vector<T>::expand()
- //向量空间不足时扩容
- {
- if (_size < _capacity ) return;
- //尚未满员时,不必扩容
- if ( _capacity < DEFAULT_CAPACITY ) _capacity = DEFAULT_CAPACITY;
- //不低于最小容量
- T* oldElem = _elem;
- _elem = new T[_capacity <<= 1];
- //容量加倍
- for ( Rank i = 0; i < _size; i++ )
- _elem[i] = oldElem[i];
- //复制原向量内容(T为基本类型,或已重载赋值操作符'=')
- delete [] oldElem; //释放原空间
- }
向量扩容是在向量空间不足时将向量空间扩充到原先的二倍,这样扩容的的好处在于每次扩容后都会保证向量的空间利用率为50%以上,同时相较于每次扩充一个元素的空间,较少的执行新开辟空间以及元素移动的操作,使得时间复杂度较低。
2.重载[ ]运算符
- reference operator[] (size_type n)
- {
- return *(begin() + n);
- }
由于向量是一段连续的内存空间,所以可以通过重载[ ]运算符来通过下标定位元素。
例题
-
列表(List)
-
概述
List是一种通过指针确定元素的直接前驱和直接后继的容器,是一种双向链表,这使得list对空间的利用十分精准并且可以利用系统中的零碎空间。
-
物理结构
List储存的元素在物理上并不一定连续,其每个节点都分为指针域和数据域。其通过指针域来确定逻辑上的前后顺序。
- template <typename T> class List //列表模板类
- {
- private:
- int _size;
- ListNodePosi<T> header;
- ListNodePosi<T> trailer; //规模、头哨兵、尾哨兵
- };
-
关键操作的具体实现概述
- 插入元素
由于list是一个双向链表,所以其插入元素的时间复杂度都是O(1)。同时插入也会分成多种,将元素作为头节点,尾节点,某一元素的前驱或某一元素的后继。这几种情况只要处理好不同的位置最终都是类似的
- template <typename T> //将e紧靠当前节点之前插入于当前节点所属列表(设有哨兵头节点header)
- ListNodePosi<T> ListNode<T>::insertAsPred ( T const& e )
- {
- ListNodePosi<T> x = new ListNode ( e, pred, this ); //创建新节点
- pred->succ = x;
- pred = x; //设置正向链接
- return x; //返回新节点的位置
- }
- template <typename T> //将e紧随当前节点之后插入于当前节点所属列表(设有哨兵尾节点trailer)
- ListNodePosi<T> ListNode<T>::insertAsSucc ( T const& e )
- {
- ListNodePosi<T> x = new ListNode ( e, this, succ ); //创建新节点
- succ->pred = x;
- succ = x; //设置逆向链接
- return x; //返回新节点的位置
- }
- template <typename T> ListNodePosi<T> List<T>::insertAsFirst ( T const& e )
- {
- _size++; //e当作首节点插入
- return header->insertAsSucc ( e );
- }
- template <typename T> ListNodePosi<T> List<T>::insertAsLast ( T const& e )
- {
- _size++; //e当作末节点插入
- return trailer->insertAsPred ( e );
- }
- template <typename T> ListNodePosi<T> List<T>::insert ( ListNodePosi<T> p, T const& e )
- {
- _size++; //e当作p的后继插入
- return p->insertAsSucc ( e );
- }
- template <typename T> ListNodePosi<T> List<T>::insert ( T const& e, ListNodePosi<T> p )
- {
- _size++; //e当作p的前驱插入
- return p->insertAsPred ( e );
- }
2.删除元素
将元素从list中移除,首先需要找到此元素,然后为了不泄露内存需要提前保存删除节点的位置,并且在讲节点移出list后释放此空间。
- template <typename T> T List<T>::remove ( ListNodePosi<T> p ) //删除合法节点p,返回其数值
- {
- T e = p->data; //备份待删除节点的数值(假定T类型可直接赋值)
- p->pred->succ = p->succ;
- p->succ->pred = p->pred; //后继、前驱
- delete p;
- _size--; //释放节点,更新规模
- return e; //返回备份的数值
- }
-
双端队列(Deque)
-
概述
双端队列顾名思义是一种向两端发展的容器,其在两端安插元素十分迅速,在中间安插元素比较慢,因为需要移动其他元素。相较于vector 和array在空间不足时扩容需要复制,双端队列在扩容时避开了先开辟后复制最后释放的三步较为耗时的步骤。但与此同时代价是双端队列的迭代器架构十分复杂。
-
物理结构
Deque由一段段定量的连续的空间构成,一旦需要扩容,只需在首尾配置新的一段连续空间,省去了复制的过程。
-
关键操作的具体实现概述
- template template<typename T, typename Alloc>
- class deque
- {
- public:
- typedef T value_type;
- typedef value_type* pointer;
- protected: // Internal typedefs
- typedef pointer* mappointer;
- protected:
- map_pointer map;//指向map, map是块连续空间, 其内的每个元素
- //都是一个指针(称为节点), 指向一块缓冲区
- size_type map_size;// map内可容纳多少指针
- };
双端队列在头部插入元素有两种可能:第一个元素前有空余空间和第一元素前无空余空间,如果有空余空间只需将迭代器前移然后插入元素,如果没有空间便需要先申请一段空间后插入。
-
栈(Stack)
-
概述
栈是一种先进后出的数据结构(First In Last Out ,FILO)。栈可以添加移除元素,但是只能在顶端操作元素。使得入栈出栈的顺序较为固定。
-
物理结构
栈可由向量、列表派生出或者由双端列表改造接口构成,也被成为适配器(adapter)
-
关键操作的具体实现概述
- push()将一个元素放进stack内。
- top()取出最后一个元素
- pop()将最后一个元素删除
-
例题
给你一个字符串表达式 s ,请你实现一个基本计算器来计算并返回它的值。整数除法仅保留整数部分。你可以假设给定的表达式总是有效的。所有中间结果将在 [-231, 231 - 1] 的范围内。注意:不允许使用任何将字符串作为数学表达式计算的内置函数,比如 eval() 。
思路:
由于乘除优先于加减计算,因此不妨考虑先进行所有乘除运算,并将这些乘除运算后的整数值放回原表达式的相应位置,则随后整个表达式的值,就等于一系列整数加减后的值。
基于此,我们可以用一个栈,保存这些(进行乘除运算后的)整数的值。对于加减号后的数字,将其直接压入栈中;对于乘除号后的数字,可以直接与栈顶元素计算,并替换栈顶元素为计算后的结果。
具体来说,遍历字符串 s,并用变量 preSign记录每个数字之前的运算符,对于第一个数字,其之前的运算符视为加号。每次遍历到数字末尾时,根据preSign来决定计算方式:
加号:将数字压入栈;
减号:将数字的相反数压入栈;
乘除号:计算数字与栈顶元素,并将栈顶元素替换为计算结果。
代码实现中,若读到一个运算符,或者遍历到字符串末尾,即认为是遍历到了数字末尾。处理完该数字后,更新preSign 为当前遍历的字符。
遍历完字符串 s 后,将栈中元素累加,即为该字符串表达式的值。
- int calculate(string s)
- {
- vector<int> stk;
- char preSign = '+';
- int num = 0;
- int n = s.length();
- for (int i = 0; i < n; ++i)
- {
- if (isdigit(s[i]))
- {
- num = num * 10 + int(s[i] - '0');
- }
- if (!isdigit(s[i]) && s[i] != ' ' || i == n - 1)
- {
- switch (preSign)
- {
- case '+':
- stk.push_back(num);
- break;
- case '-':
- stk.push_back(-num);
- break;
- case '*':
- stk.back() *= num;
- break;
- default:
- stk.back() /= num;
- }
- preSign = s[i];
- num = 0;
- }
- }
- return accumulate(stk.begin(), stk.end(), 0);
- }
队列(Queue)
-
概述
队列是一种先进后出的数据结构(First In Fist Out ,FIFO)。它有两个出口,queue 允许新增元素、移除元素、从最底端加入元素、取得最顶端元素。但除了最底端可以加入、最顶端可以取出外, 没有任何其它方法可以存取queue 的其它元素。换言之, queue 不允许有遍历行为。将元素推入queue 的操作称为push, 将元素推出queue 的操作称为pop。
物理结构
与栈类似,队列也是一种容器适配器,可由deque更改接口得到。如图为其结构示意图
-
关键操作的具体实现概述
与stack类似有push()pop()front()back()
-
- push()将元素放入queue内
- front()返回queue内的下一个元素,即第一个被放入的元素
- back()返回queue内的最后一个元素
- pop()移除元素。
注意,pop()虽然移除下一个元素,但是并不返回它,front()和back()返同下一个元素,但并不移除它。所以,如果你想移除queue一个元素,又想处理它,那就得同时调用front()和pop()。如果queue内没有元素, 则front()、back()和pop()的执行会导致不确定的行为。你可以采用成员函数size()和empty()来检验容器是否为空。
-
例题
思路:两个队列,
- // 使用两个队列实现栈
- // 使用两个数组(队列)和四个指针定义栈,指针分别指向对应的队首和队尾
- typedef struct {
- int queue1[100], queue2[100];
- int front1, front2;
- int rear1, rear2;
- } MyStack;
- // 开辟一个栈
- MyStack* myStackCreate() {
- MyStack* stack = malloc(sizeof(MyStack));
- stack->front1 = 0, stack->front2 = 0;
- stack->rear1 = 0, stack->rear2 = 0;
- return stack;
- }
- // 将元素存入队列中,存入后队尾指针 +1
- void myStackPush(MyStack* obj, int x) {
- obj->queue1[(obj->rear1)++] = x;
- }
- int myStackPop(MyStack* obj) {
- // 优化:复制指针,减少对内存的访问次数
- int front1 = obj->front1, front2 = obj->front2;
- int rear1 = obj->rear1, rear2 = obj->rear2;
- // 将 queue1 除最后面的元素以外的所有元素都备份到 queue2
- while (rear1 - front1 > 1) {
- obj->queue2[rear2++] = obj->queue1[front1++];
- }
- // 弹出 queue1 的最后一个元素并保存
- int top = obj->queue1[front1++];
- // 将其他元素从 queue2 导回 queue1
- while (front2 != rear2) {
- obj->queue1[rear1++] = obj->queue2[front2++];
- }
- // 更新队首队尾指针
- obj->front1 = front1, obj->front2 = front2;
- obj->rear1 = rear1, obj->rear2 = rear2;
- // 返回栈顶指针
- return top;
- }
- // 直接返回队尾元素
- int myStackTop(MyStack* obj) {
- // 注意队列的队尾是 rear-1 而不是 rear
- return obj->queue1[(obj->rear1) - 1];
- }
- // 若队首队尾指针相等,则队列为空,即栈为空
- bool myStackEmpty(MyStack* obj) {
- return obj->rear1 == obj->front1;
- }
- // 将队首队尾指针都归 0
- void myStackFree(MyStack* obj) {
- obj->front1 = 0, obj->front2 = 0;
- obj->rear1 = 0, obj->rear2 = 0;
- }
参考文献:
[1] 侯捷著. STL源码剖析. 武汉:华中科技大学出版社, 2002.06.
[2] Nicolai M. Josuttis著;侯捷译. C++标准库 第2版. 北京:电子工业出版社, 2015.07.
代码来源:
力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台 (leetcode-cn.com)
Data Structures & Algorithms, Tsinghua Computer
例题来源:
[1] 胡凡,曾磊主编. 算法笔记上机训练实战指南. 北京:机械工业出版社, 2016.07.