c++ STL容器的内存分配

一.前言

在使用STL各类容器的时候,有时会出现迭代器失效,引用(指针)失效等情况的而发生,即使看似你的操作都是合法的情况下。

要了解问题的原因,我们就要了解C++中stl容器的内存分配策略。我们才知道在哪些操作下可能导致迭代器失效,引用(指针)失效。


二.问题分类

首先我们把以上的问题分成两类:

  • 容器的迭代器为什么会失效?
  • 容器元素的引用(指针)为什么会失效?

因为从内存角度上来讲,如果引用是失效了那么指针也就是失效了,也就是容器的该元素从这个内存地址搬家了!


三.第一类问题分析

问题: 容器的迭代器为什么会失效?

对于上面的问题我们可以换个说法:容器的元素在容器内部搬家了

我们可以把容器看做是一个小镇,有一个个的房子(无论是list,vector还是其他的,只是房子之间的联系关系不同。不过这不在我们目前考虑范围);
而元素就是相当于住在房子里面的人。
现在,假如小镇又搬进来一户人家。小镇自然需要为这户人家安排一户房子:

  • 假如是空房子还好(只需要住进去就行);
  • 如果新来的这户人家看中了已经居住的某户人家的房子,那么肯定需要之前那户人家搬出来,然后才能进去住。而某些容器中为了保证数据的顺序一致性,就会出现下面的情况:
    这里写图片描述

很显然,如果出现了以上图片所示的情况,那么就相当于容器的元素在容器内部搬家了,因为或多或少的造成了其他元素搬家。
而迭代器相当于什么呢?恩,我们可以在这里把它当做是房子的门牌号(每个房子的门牌号都不一样)。
以前我们知道通过一户人家的门牌号就可以很容易找到这户人家。但是,如果该户人家搬家了呢?
对,正是这样的”搬家”导致容器的迭代器失效。

问题原因找出来了,我们下面就来总结一下,各类容器发生迭代器失效的情况:

TypeInsertRemove(尾后迭代器end总是失效)
vector插入位置之前的迭代器有效,之后的迭代器(包括尾后)失效。删除之前迭代器有效,之后迭代器(包括尾后)失效。
string同上同上
deque(双向队列)任何插入位置都会使迭代器失效删除除首尾其他位置元素使迭代器失效
list任何插入位置迭代器都有效任何删除位置迭代器都有效
forward_list同上同上


可以看出来非线性表的适应性是最好的,因为不需要内存地址连续。
而线性表中,不适宜的插入删除位置会使得迭代器失效。(使得迭代器失效还有一种情况:容器存储空间的重新分配,我们后面来讲)

下面我来举个例子:
给定一个容器 vector< int >vi,删除容器中所有为3的元素。

下面是两个版本的代码:

  • 代码1:
for(auto it = vi.begin();it != vi.end();)
{
    if(*it == 3) it = vi.erase(it);
    else it ++;
}
  • 代码2:
auto end = vi.end();
for(auto it = vi.begin();it != end;)
{
    if(*it == 3) it = vi.erase(it);
    else it ++;
}

可能代码2的作者是考虑到每次循环都需要调用vi.end()的开销,于是就在循环外记录了尾后迭代器。
然而在上面的代码中。由于在循环内部可能会调用erase的函数;也就是说一旦发生删除,那么尾后迭代器就会失效。所以代码2是错误的!!
不过还有个地方值得注意:删除erase的时候会使得it迭代器失效,所以需要用it来接受erase函数的返回值。

四.第二类问题分析

问题: 容器元素的引用或指针为什么会失效?

这个问题就是涉及到了不同容器中如何去管理内存的。以下用 vector(string) 和 deque 来举例。

  • vector(string 可看做vector< char >)

    为了支持快速随机访问,vector只能将元素连续存储——每个元素紧挨着前一个元素存储。然而当我们向vector 或者string添加元素的时候;如果没有空间容纳新元素,容器不可能简单地把它添加在内存的其他位置——因为元素必须连续存储。容器就必须重新分配新的内存空间来保存已有的元素和新元素,将旧元素移动到新空间,然后添加新的元素。

  • deque

    deque看似和vector很相似,但是 deque 能高效的在首位进行元素的插入删除;并且 deque 也支持随机访问;说明 deque 的内部内存实现要比vector复杂得多。事实上 deque 采用的是动态内存块的策略,块的内部是一段连续的内存,但是块与块之间物理内存不一定连续。

    deque的元素数据采用分块的线性结构进行存储,如图所示。deque分成若干线性存储块,称为deque块。块的大小一般为512个字节,元素的数据类型所占用的字节数,决定了每个deque块可容纳的元素个数。

    所有的deque块使用一个Map块进行管理,每个Map数据项记录各个deque块的首地址。Map是deque的中心部件,将先于deque块,依照deque元素的个数计算出deque块数,作为Map块的数据项数,创建出Map块。以后,每创建一个deque块,都将deque块的首地址存入Map的相应数据项中。

    在Map和deque块的结构之下,deque使用了两个迭代器M_start和M_finish,对首个deque块和末deque块进行控制访问。迭代器iterator共有4个变量域,包括M_first、M_last、M_cur和M_node。M_node存放当前deque块的Map数据项地址,M_first和M_last分别存放该deque块的首尾元素的地址(M_last实际存放的是deque块的末尾字节的地址),M_cur则存放当前访问的deque双端队列的元素地址。
    这里写图片描述

简单介绍了 vector 和 deque 的内存管理策略之后;我们知道,如果当容器中进行了内存重新分配。那么元素的物理存储地址必然改变,所以这就是导致引用和指针失效的原因!!

我们下面就来总结一下,各类容器发生引用(指针)失效的情况:

TypeInsertRemove
vector插入位置之前的引用有效,之后的引用失效。如果发生内存重新分配那么所有引用(指针)都失效。删除位置之前引用有效,之后引用失效。
string同上同上
deque(双向队列)除首尾外地址插入会使得引用失效除首尾外地址删除会使得引用失效
list任何插入位置引用都有效任何删除位置引用都有效
forward_list同上同上


五.后话

值得注意的是,不同的编译器对于stl内存管理策略可能略有差别。下面拿 vector 举例:

我们知道 vector 的内存管理策略是:当push_back的时候发现容量不够存储新的元素就需要去开辟一个更大的内存,然后将旧元素复制过去,然后再加入新元素。

那么有个问题是:当容量不够的时候需要开辟一个多大的内存呢?这个实现机制在不同的编译器中就可能有不同。

目前我见过有三个版本的:

  • VS2013自带G++编译器 : 每次开辟新内存比原来多一个元素内存。
  • VS2010自带G++编译器: 每次开闭新内存比原来容量多一半。(*150%)
  • CodeBlocks自带GCC编译器: 每次开辟新内存为原来容量的一倍。(*200%)

不管开辟内存的策略如何,总需要满足一个定则:
在一个初始化为空的 vector 上调用n次push_back来创建一个n个元素的vector,所花费时间不能超过n的常数倍。

还有一个值得注意的地方就是:
对于vector的 pop_back 或者 erase ,clear 只会减小容器的元素个数,并不会减少容器的容量。resize也是一样,如果小于size。只会使得容器元素个数减小,容器的容量不会减小。

换句话来说,一个vector容器对象容量是只增不减的。只会在析构函数的时候对占用内存进行释放。

当然以上问题也不是不可以解决的:

使用swap函数可以使得两个vector对象内部元素甚至所占内存空间都互换。
比如想释放vector< int > vt 占用的内存:

  • 清空所有元素,释放所有容量

    vector<int>().swap(vt);
  • 元素不改变,释放多余的容量

    vector<int>(vt).swap(vt);

从第二段代码我们也可以得知,vector 的拷贝构造函数只是会将元素拷贝过来,而容量也只是会初始化为和元素个数一样。

还值得注意的一个地方是:

如果会需要向一个vector中插入很多记录,比如说100000条,为了避免在插入过程中移动内存,咱实现向系统预订一段足够的连续的空间,例如:

a.reserve(100000);
  • 7
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 8
    评论
### 回答1: C++ STL容器中的栈(stack)是一种后进先出(LIFO)的数据结构。栈的基本操作包括入栈(push)、出栈(pop)、查看栈顶元素(top)和判断栈是否为空(empty)。栈可以用数组或链表实现,但使用STL容器可以更方便地实现栈的操作。在STL中,栈是由适配器(adapter)stack实现的,可以使用push、pop、top和empty等成员函数来操作栈。栈的应用场景包括函数调用、表达式求值、括号匹配等。 ### 回答2: 栈(stack)是C++ STL(标准模板库)中的一个容器,是一个后入先出(Last In, First Out,LIFO)的数据结构。堆栈的基本操作是退栈(Pop)和入栈(Push),即在栈顶插入和删除元素。除了这两个基本操作,堆栈还提供了访问栈顶元素的方法,即Top()。 堆栈可以通过STL中的std::stack<T>来使用,其中T是元素的类型。堆栈的定义非常简单,只需要使用一个std::stack<T>对象即可。在使用之前,需要包含头文件<stack>。 堆栈的主要特性是插入和删除元素的时间复杂度为常数时间O(1),因为栈只需要在栈顶进行操作。堆栈一般用于实现递归、表达式求值、内存分配等。例如,在递归深度优先搜索中,可以使用堆栈来存储遍历的路径。 堆栈的操作非常简单,以下是常用的操作列表: 1. push():将一个元素插入栈顶。 2. pop():删除栈顶元素。 3. top():返回栈顶元素。 4. empty():判断堆栈是否为空。 5. size():返回堆栈中元素的个数。 下面是一个简单的堆栈的例子,可以更好地理解堆栈的基本操作: #include <iostream> #include <stack> using namespace std; int main() { stack<int> s; // 定义一个int类型的栈 s.push(10); // 将10入栈 s.push(20); // 将20入栈 s.push(30); // 将30入栈 while (!s.empty()) { cout << s.top() << " "; // 输出栈顶元素 s.pop(); // 删除栈顶元素 } return 0; } 在上面的例子中,我们首先定义了一个堆栈s,然后在堆栈s中依次插入了三个元素10、20和30。接下来使用while循环,栈顶元素依次输出,同时删除栈顶元素,直到堆栈为空。由于堆栈是后进先出的,所以输出的顺序是30、20和10。 总之,堆栈是一个非常常用的数据结构,STL中的栈(stack)提供了非常方便的使用,可以减轻我们对堆栈数据结构进行操作的负担,提高代码的可读性和复用性。 ### 回答3: 栈(stack)是 C++ STL(Standard Template Library)中常见的一种容器数据结构,它可以在一端进行元素的插入和删除操作,遵循“后进先出”(LIFO,Last-In-First-Out)的原则。栈的操作不需要访问元素中间的部分,只需要在栈顶执行操作,保证了操作效率。栈可以用数组或链表等数据结构实现,但 C++ STL 提供了封装好的栈容器,使用起来方便且安全。 C++ STL 中栈容器的定义方式为:`std::stack`。栈默认情况下使用双端队列(deque)实现,用户也可以指定其他底层容器,如 vector、list 等。可以使用`push()`向栈顶插入元素,使用`pop()`弹出栈顶元素,使用`top()`获取栈顶元素。栈的元素个数可以使用`size()`来获取,判断栈是否为空可以使用`empty()`,在栈容器中查找某个元素的功能则不支持。 在实际应用中,栈容器可以用来实现函数的递归调用、表达式求值、括号匹配等操作。例如,可以使用栈来判断一个字符串中的括号是否匹配,具体做法是将左括号入栈,遇到右括号时弹出栈顶元素检查是否为相应的左括号。如果不匹配或者栈已经为空,则括号不匹配;如果字符串中所有的括号都匹配,则最后栈为空。 总之,栈作为一种容器数据结构,在实际应用中有着广泛的应用场景,C++ STL 提供的封装好的栈容器,具有使用方便、效率高等特点,可以帮助我们更快更方便地实现各种数据处理和算法设计。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值