vector的介绍
vector - C++ Reference (cplusplus.com)
1.vector 的基本概念和存储特性
vector
是表示可变大小数组的序列容器,属于顺序容器的一种。其本质是动态增长的顺序表,借助动态分配的数组来存储元素,从而实现大小可变的功能。- 与普通数组一样,
vector
采用连续的存储空间来存储元素。这使得我们能够像操作数组那样,通过下标高效地访问vector
中的元素,访问效率和数组相当。然而,与普通数组不同的是,vector
的大小可以动态改变,并且容器会自动处理大小变化相关的操作。
2.vector 的内存分配与增长策略
- 从本质上讲,
vector
使用动态分配的数组存储元素。当有新元素插入且当前分配的数组空间不足时,需要重新分配更大的数组空间。具体做法是,先分配一个新的更大的数组,然后将原数组中的全部元素复制到新数组中。由于重新分配数组空间并复制元素的操作相对耗时,所以vector
并不会在每次插入新元素时都重新分配空间。 vector
的分配空间策略是会额外分配一些空间以适应可能的增长,即实际分配的存储空间通常会比当前存储元素所需的空间更大。不同的标准库实现可能采用不同的策略来权衡空间使用和重新分配的频率。但总体来说,重新分配空间的操作一般以对数增长的间隔进行,这样可以保证在vector
末尾插入一个元素的操作在平均情况下具有常数时间的复杂度。- 由于
vector
会额外分配空间以实现动态增长的能力,因此它相比刚好存储当前元素所需的空间会占用更多的存储空间。
3.vector 与其他容器的比较
- 与其他动态序列容器(如
deque
、list
和forward_list
)相比,vector
在访问元素时更加高效,因为其元素在内存中连续存储,可通过简单的指针算术运算快速定位元素。 - 在末尾添加和删除元素时,
vector
也相对高效。但对于不在末尾的删除和插入操作,由于需要移动后续元素来保持连续性,其效率会较低。 - 相较于
list
和forward_list
,vector
的迭代器和引用在元素位置变化(如插入、删除操作)时的稳定性更好。list
和forward_list
的迭代器和引用在容器结构发生变化时更容易失效,而vector
只要没有发生重新分配空间的操作,迭代器和引用通常仍然有效。
4.vector 与 string 类的比较
string
类主要用于管理字符数组,专门针对字符串操作进行了优化,提供了许多方便的字符串处理函数。而vector
类的功能更具通用性,它可以管理任意类型的数组,能够满足各种不同数据类型元素存储及操作的需求。
vector的使用
1.vector的定义
vector::vector - C++ Reference (cplusplus.com)
注意:上面4个构造函数,常用的是无参构造函数、拷贝构造函数。
1.1.默认构造函数vector()
注:也叫无参构造函数
- 功能:创建一个空的
vector
对象,此时该vector
中不包含任何元素,其大小(size
)和容量(capacity
)都为0
。后续可通过push_back
等函数向其中添加元素。 - 参数说明:无参数传入,直接调用即可创建空的
vector
对象。 - 使用方法:使用
vector<元素类型> 对象名;
的形式调用,例如vector<int> v;
创建一个空的存储int
类型元素的vector
对象v
。 - 测试用例:
1.2.带参构造函数 vector(size_type n, const value_type& val = value_type())
注:用n
个 val
构造。
- 功能:创建一个
vector
对象,该对象包含n
个值为val
的元素。通过此构造函数可以在创建vector
时就初始化好一定数量且值相同的元素。 - 参数说明:
n
:表示要创建的vector
中元素的个数,类型通常为size_t
(无符号整数类型)。val
:表示每个元素的初始值,其类型与vector
存储的元素类型一致,该参数有默认值value_type()
,若不传入则元素使用默认值初始化。
- 使用方法:使用
vector<元素类型> 对象名(n, val);
的形式调用,例如vector<int> v1(10, 1);
创建一个包含10
个值为1
的int
类型元素的vector
对象v1
。 - 测试用例:
1.3.迭代器区间构造函数 vector(InputIterator first, InputIterator last, const allocator_type& alloc = allocator_type())
- 功能:利用给定的迭代器区间
[first, last)
(左闭右开区间)来构造vector
对象,即将该区间内的元素复制到新创建的vector
中。此构造函数可以使用vector
自身的迭代器,也可以使用其他容器(如string
等)的迭代器,但要求其他容器存放的数据类型与vector
存放的数据类型一致或能合理转换。 - 参数说明:
first
:输入迭代器,指向要复制元素范围的起始位置。last
:输入迭代器,指向要复制元素范围的结束位置(不包含该位置的元素)。alloc
:分配器对象,用于定义内存分配的方式,有默认值allocator_type()
,一般情况下可省略不传入,使用默认分配器。
- 使用方法:使用
vector<元素类型> 对象名(迭代器 first, 迭代器 last);
的形式调用,例如vector<int> v2(v1.begin(), v1.end());
使用v1
的迭代器区间构造v2
;vector<int> v3(s1.begin(), s1.end());
使用string
对象s1
的迭代器区间构造v3
(前提是char
类型能转换为int
类型)。 - 测试用例:
1.4.拷贝构造vector(const vector& x)
- 功能:创建一个新的
vector
对象,该对象是另一个已存在的vector
对象x
的副本,即将x
中的所有元素复制到新创建的vector
中,新vector
的大小、容量和元素值都与x
相同。 - 参数说明:
x
是一个vector
对象的常量引用,用于指定要复制的源vector
。 - 使用方法:使用
vector<元素类型> 对象名(已存在的 vector 对象);
的形式调用,例如vector<int> copy(v);
创建一个v
的副本copy
。 - 测试用例:
2.vector iterator的使用
2.1.迭代器介绍
(1)迭代器的使用属性和特性属性
①使用属性
迭代器的使用属性主要指其遍历方向,分为正向和反向:
- 正向:迭代器按照元素存储的顺序从容器的开头向结尾进行遍历,这是最常见的使用方式。多数迭代器类型都支持正向遍历,可通过
begin()
函数获取正向迭代器。 - 反向:与正向相反,迭代器从容器的结尾向开头进行遍历。通常通过
rbegin()
和rend()
函数获取反向迭代器来实现,适用于需要从后往前处理容器元素的情况。
②特性属性
迭代器的特性属性是指其功能特性,包括单向、双向和随机:
- 单向:迭代器只能沿着一个方向移动,通常是向前(
++
),如输入迭代器和输出迭代器,适用于只需要顺序处理元素一次的场景,例如单链表。 - 双向:迭代器可以向前(
++
)和向后(--
)移动,适用于需要在元素序列中来回操作的情况,如双向链表的迭代器。 - 随机:迭代器不仅能双向移动,还能进行随机访问,可通过算术运算直接访问任意位置的元素,适用于需要高效访问任意元素的容器,如
string
、vector
。
(2)迭代器的类型
- 输入迭代器(InputIterator):用于从序列中读取数据,支持单向遍历,主要操作包括解引用(读取元素值)和递增操作(移动到下一个元素),可以进行相等和不相等比较。其主要目的是按顺序访问元素一次用于输入操作,比如从标准输入读取数据到容器中。
- 输出迭代器(OutputIterator):用于向序列写入数据,也是单向的,主要支持递增操作和解引用(用于赋值),不保证能多次遍历相同的元素,常用于将容器中的数据输出到外部设备或者其他容器。
- 单向迭代器(ForwardIterator):继承了输入迭代器的功能,并且可以对序列进行多次单向遍历,支持所有输入迭代器的操作,同时在多次遍历过程中保证行为的一致性,适用于需要多次遍历序列进行读取操作的场景。
- 双向迭代器(BidirectionalIterator):在前向迭代器的基础上增加了向后遍历的功能,支持递减操作(
--
),可以方便地在序列中来回移动,双向链表的迭代器通常是这种类型。 - 随机访问迭代器(RandomAccessIterator):功能最强的迭代器类型,除了具备双向迭代器的所有功能外,还支持随机访问操作,如通过
it + n
、it - n
(n
为整数)来访问距离当前迭代器n
个位置的元素,也支持比较操作(<
、>
、<=
、>=
),vector
的迭代器属于这种类型。
(3)vector
使用的迭代器类型
vector
使用的是随机访问迭代器(RandomAccessIterator)。由于vector
在内存中是连续存储元素的,这种存储结构使其天然适合支持随机访问操作,如同访问数组一样便捷。随机访问迭代器能够充分利用vector
的这一特性,快速地访问任意位置的元素,并且可以进行各种算术运算和比较操作,这与vector
的使用方式和性能要求相匹配。
(4)对迭代器区间构造函调的迭代器类型InputIterator的解析
在template <class InputIterator> vector (InputIterator first, InputIterator last, const allocator_type& alloc = allocator_type());
这个构造函数中,InputIterator
要求至少是输入迭代器类型。这意味着传入的迭代器必须能够单向遍历元素序列(支持++
操作),并且可以读取元素值(支持解引用操作*
),同时可以进行==
和!=
比较。
这样的设计使得该构造函数能够灵活地接受多种迭代器类型,只要满足输入迭代器的基本要求即可。实际上,由于vector
本身使用随机访问迭代器,当传入更高级别的迭代器(如随机访问迭代器)时,构造函数也能充分利用这些迭代器的高级功能来更高效地构建vector
容器。例如,在复制元素序列时,如果传入的是随机访问迭代器,可以利用其算术运算(如last - first
来快速计算元素个数)来更快速地构建vector
容器。
2.2.迭代器的使用
vector::begin - C++ Reference (cplusplus.com)
vector::end - C++ Reference (cplusplus.com)
vector::rbegin - C++ Reference (cplusplus.com)
vector::rend - C++ Reference (cplusplus.com)
(1)正、反向迭代器使用案例
(2)注意事项
① 类内部的成员类型——在 C++ 中,类内部成员类型有两种常见的定义方式:
- 内部类:在类内部定义一个类。内部类的作用域限定在外部类的内部,外部类外部无法直接访问内部类,要访问内部类需要借助外部类的作用域来进行,语法上形如
外部类名::内部类名
这样的形式去引用内部类相关元素。例如,在一些复杂的类设计场景里,考虑设计一个通用的list
(链表)模板类,它内部定义了一个Node
(节点)类用于表示链表中的节点信息。但是,在vector
类中并没有采用这种方式来定义iterator、reverse_iterator
。 - typedef 类型重命名(或
using
别名声明):在 C++ 中,更常用的是通过typedef
(在 C++11 及以后也可以使用using
关键字)来定义类内部成员类型。例如,在vector
类中,iterator、const_iterator、reverse_iterator、const_reverse_iterator
是通过类型重命名来定义的,这样可以方便地在vector
类的实现中使用正向迭代器和反向迭代器。 -
template <typename T> class vector { public: typedef T* iterator; typedef const T* const_iterator; typedef T* reverse_iterator; typedef const T* const_reverse_iterator; //其他成员和函数实现等 }; //注:实际vector的迭代器定义远比这复杂,这里是简化示意
②vector 的迭代器支持正向、反向遍历;由于范围 for 只能正着遍历,不能倒着遍历,也不能从中间某个位置开始遍历,使得 vector 使用范围 for 遍历很不方便,所以 vector 很少使用范围 for 遍历;即使迭代器和 operator [] 都可以正着遍历、倒着遍历、从中间某个位置开始遍历,但是迭代器写起来繁琐,所以 vector 常使用 operator [] 像数组一样遍历,但是有时会使用迭代器;对于 vector 来说,简单的正向遍历场景用范围 for 遍历,当涉及到按索引灵活操作或需与算法等配合的复杂场景时使用迭代器,而在单纯按索引遍历等场景下常使用 operator []。
当我们自定义实现 vector 时,只有自定义实现了迭代器,则范围 for 才能使用。因为范围 for 的底层是调用迭代器实现的。
③对于 list 容器 (带头双向循环链表) 来说,即使可以重载 operator [],但是要想访问第 n 个元素的话是要从头到尾遍历查找第 n 个元素后再返回该元素的值则此时的时间复杂度是 O (n),然后再套层 for 循环后用 operator [] 遍历整个 list 容器则时间复杂度是 O (n^2),所以对于 list 容器来说,重载 operator [] 来遍历整个容器效率太低,所以对 list 容器来说,我们应该自定义实现迭代器来遍历 list 容器。
④在常见的容器中,只有 string、vector 使用 operator [] 遍历,对于其他容器常使用迭代器遍历。
2.3.vector的3种遍历方式
(1)使用下标operator[ ]遍历
for (size_t i = 0; i < v.size(); ++i)
{
cout << v[i] << " ";
}
cout << endl;
- 解析:这种方式利用了
vector
在内存中连续存储元素的特性,与操作数组类似,通过下标来访问每个元素。v.size()
用于获取vector
中元素的个数,循环变量i
从 0 开始,每次递增 1,通过v[i]
可以依次获取到每个元素的值并进行相应操作。
(3)使用迭代器遍历
vector<int>::iterator it = v.begin();
while (it!= v.end())
{
cout << *it << " ";
++it;
}
cout << endl;
- 解析:首先通过
v.begin()
获取vector
的起始迭代器并赋值给it
,然后通过判断it
是否不等于v.end()
(即是否到达容器末尾)来控制循环。在循环体中,使用*it
解引用迭代器以获取当前指向的元素值并输出,接着使用++it
将迭代器移动到下一个元素位置。 - 注意事项:在 C++ 标准库中,
vector
类模板将iterator
定义为公有类型,目的是方便遍历vector
容器内的元素。由于不同容器(如vector
、list
等)的数据结构不同,各自有适配的迭代器类型,彼此不能通用。因此,使用vector
的迭代器时,必须指定其所属类域,例如std::vector<int>
,这样能明确该迭代器是针对存储int
类型元素的vector
容器的,从而保证迭代器相关操作(如解引用、移动到下一元素等)的准确性。
(4)使用范围for
遍历
//只读
for (auto e : v)
{
cout << e << " ";
}
cout << endl;
//可读可写
for (auto& e : v)
{
cout << e << " ";
//注意:这里通过直接修改e就可以改变对象v中存储数据的值
}
cout << endl;
- 解析:使用范围 for 循环时,编译器会自动根据容器的类型推断出迭代器的类型,并在循环过程中依次取出容器中的每个元素赋值给变量
e
,然后执行循环体中的代码。当使用auto e
时,元素以值传递的方式赋给e
,对e
的修改不会影响容器中的元素;当使用auto& e
时,元素以引用传递的方式赋给e
,对e
的修改会直接影响容器中的元素。
3.vector 空间增长问题
vector::size - C++ Reference (cplusplus.com)
vector::capacity - C++ Reference (cplusplus.com)
vector::empty - C++ Reference (cplusplus.com)
- capacity的代码在vs和g++下分别运行会发现,vs下capacity是按1.5倍增长的,g++是按2倍增长的。所以不要固化的认为,vector增容都是2倍,具体增长多少是根据具体的需求定义的。vs是PJ版本STL,g++是SGI版本STL。
- reserve只负责开辟空间,如果确定知道需要用多少空间,reserve可以缓解vector增容的代价缺陷问题。
- resize在开空间的同时还会进行初始化,影响size。
- 在string中经常使用reserve,而在vector中经常使用resize。
- 我们通常不建议使用缩容函数
shrink_to_fit
对string
、vector
这类具有连续物理空间的数据结构进行缩容。因为对于string
和vector
,操作系统一般难以实现原地缩容,多采用异地缩容。异地缩容需开辟新空间、拷贝数据、释放旧空间,操作消耗大,效率低。对于string
和vector
,在进行删除数据(例如erase
、resize
)操作时,并不会自动调用shrink_to_fit
进行缩容 。但需要注意,resize
操作在一些情况下容器内部可能会进行内存优化(并非严格的shrink_to_fit
缩容),而erase
操作通常不会主动触发缩容机制 。
3.1.reverse
(1)观察reverse默认扩容机制
#include <iostream>
#include <vector>
using namespace std;
//测试vector默认扩容机制
void TestVectorExpand()
{
vector<int> v;
//记录vector当前的容量
size_t sz = v.capacity();
//输出提示信息,表明开始观察vector的扩容过程
cout << "making v grow:\n";
//循环100次,每次向vector中添加一个元素
for (int i = 0; i < 100; ++i)
{
//向vector的尾部添加一个元素i
v.push_back(i);
//检查vector的容量是否发生了变化
if (sz != v.capacity())
{
//如果容量发生了变化,更新记录的容量值
sz = v.capacity();
//输出容量变化的信息,显示新的容量大小
cout << "capacity changed: " << sz << '\n';
}
}
}
int main()
{
TestVectorExpand();
return 0;
}
// vs:运行结果:vs下使用的STL基本是按照1.5倍方式扩容
// making foo grow:
// capacity changed: 1
// capacity changed: 2
// capacity changed: 3
// capacity changed: 4
// capacity changed: 6
// capacity changed: 9
// capacity changed: 13
// capacity changed: 19
// capacity changed: 28
// capacity changed: 42
// capacity changed: 63
// capacity changed: 94
// capacity changed: 141
// g++运行结果:linux下使用的STL基本是按照2倍方式扩容
// making foo grow:
// capacity changed: 1
// capacity changed: 2
// capacity changed: 4
// capacity changed: 8
// capacity changed: 16
// capacity changed: 32
// capacity changed: 64
// capacity changed: 128
//注意:我们很少会看到是按照3倍方式扩容的,大多是1.5倍 或者 2倍方式扩容。
(2)若已知存储数据个数,则使用reserve提取开空间可以减少或者避免扩容
//如果已经确定vector中要存储元素大概个数,可以提前将空间设置足够
//就可以避免边插入边扩容导致效率低下的问题了
void TestVectorExpandOP()
{
vector<int> v;
size_t sz = v.capacity();
v.reserve(100); //提前将容量设置好,可以避免一遍插入一遍扩容
cout << "making bar grow:\n";
for (int i = 0; i < 100; ++i)
{
v.push_back(i);
if (sz != v.capacity())
{
sz = v.capacity();
cout << "capacity changed: " << sz << '\n';
}
}
}
- 测试:
3.2.resize
注意事项:
resize
函数用于调整vector
的大小,它会改变vector
的size
和capacity
(当需要开辟新空间时)的值。- reserve只改变capacity的值,不改变size的值。
string
类常用reserve
来预先分配空间,很少使用resize
;而vector
类常用resize
来调整元素个数,很少使用reserve
来单纯调整空间(reserve
主要用于预先分配足够的容量以避免频繁扩容)。
(1)resize在不同情况下分别执行的功能
①情况 1:size = 0 && capacity = 0,功能:开空间 + 初始化
- 解析:调用
resize
函数对一个空的vector
进行操作(例如vector
刚被创建还未添加元素时),或者vector
通过clear
函数被清空后(像v.clear()
执行完之后)再调用resize
函数,这种情况下会进行开辟空间并对其初始化的操作。 -
//案例1:利用resize对空vector辟空间并初始化: vector<int> v; //开空间 + 初始化: v.resize(3, 1); //案例2:先清空已有vector再利用resize重新开辟空间并初始化: vector<int> v = { 1, 2, 3 }; v.clear(); //开空间 + 初始化: v.resize(2, 5);
-
②情况2:n > size,功能:插入数据
- 不扩容:n <= capacity
当调用resize
函数且新的大小n
大于当前容器中元素的数量size()
,并且新的大小n
小于或等于容器的容量capacity()
时,会插入新的数据,且不会进行扩容操作。 - 扩容:n > capacity
当调用resize
函数且新的大小n
大于当前容器的容量capacity()
时,会插入新的数据并且进行扩容操作,以满足新的大小要求。
③情况3:size!= 0 && n < size,功能:删除数据 + 不缩容
- 解析:当调用
resize
函数且新的大小n
小于当前容器中元素的数量size()
时,会将超出新指定大小n
的多余数据删除,不过不会对容器进行缩容操作。
(2)动态一维数组的3种开辟方式对比
① C 语言方式:使用malloc
函数开辟动态数组空间,需要手动检查空间开辟是否成功,使用完后需手动调用free
释放空间。示例代码如下:
void test_vector()
{
int n = 10;
//开空间
int* arr = (int*)malloc(sizeof(int) * n);
//检查是否成功开空间
if (arr == NULL)
{
perror("malloc fail");
exit(-1);
}
//若成功开辟空间,则使用完后,需手动释放开辟的空间
free(arr);
}
② C++ 中new
方式:使用new[]
运算符开辟动态数组空间,new[]
操作可能会抛出异常,因此通常需要使用try-catch
块捕获异常。使用完后需手动调用delete[]
释放空间。示例代码如下:
void test_vector()
{
int n = 10;
try
{
//开空间
int* arr = new int[n] {0};
//注:new[]失败后是抛异常的,所以要使用try-catch来捕获异常,
//若执行类catch的语句说明new[]开空间失败了。
//若成功开辟空间,则使用完后,需手动释放开辟的空间
delete[] arr;
}
catch (const exception& e)
{
cout << e.what() << endl;
}
}
③ C++ 中vector
方式:使用vector
容器来管理动态数组,vector
在超出作用域后会自动释放空间,无需手动调用delete
。示例代码如下:
void test_vector()
{
//写法1:用resize来开空间 + 初始化
vector<int> v1;
v1.resize(10); //注:由于一开始_size = 0, 则此时resize的功能是开空间 + 初始化为0
//写法2:用构造函数构造一个数组
vector<int> v2(10, 0);
}
vector
创建数组的两种写法对比
- 写法 1:先创建空的
vector
,再使用resize
调整大小并初始化,如vector<int> v1; v1.resize(10, 0);
,这种写法较为灵活,可以解决大多数问题。 - 写法 2:直接使用构造函数初始化
vector
,如vector<int> v2(10, 0);
。
(3)使用vector创建动态二维数组
#include <iostream>
#include <vector>
using namespace std;
void Test_vector()
{
//创建3行4列的二维数组并初始化为0
//写法1:
vector<vector<int>> vv;
vv.resize(3);//默认初始化为 vector<int>(),调用默认构造函数
for (size_t i = 0; i < vv.size(); ++i)
{
vv[i].resize(4);// 默认初始化为 int() 即为 0
}
//解析:这种方式先将外层 vector 的大小调整为 3,此时每个元素都是一个空的
//vector<int>。然后再遍历外层 vector,将每个内层 vector 的大小调整为 4,
//元素默认初始化为 0。
////写法2
//vector<vector<int>> vv(3, vector<int>(4, 0));
//解析:这种方式在创建 vv 时直接指定外层 vector 的大小为 3,每个元素都是一个大小
//为 4 且元素初始值为 0 的 vector<int>。
////写法3
//vector<vector<int>> vv(3, vector<int>());
//for (size_t i = 0; i < vv.size(); ++i)
//{
// vv[i].resize(4);// 默认初始化为 int() 即为 0
//}
//解析:这种方式先将外层 vector 的大小调整为 3,每个元素都是一个空的 vector<int>。
//然后再遍历外层 vector,将每个内层 vector 的大小调整为 4,元素默认初始化为 0。
//写法4
//vector<vector<int>> vv;
//for (int i = 0; i < 3; ++i)
//{
// vector<int> v;//创建临时一维数组
// for (int j = 0; j < 4; ++j)
// {
// v.push_back(0);//尾插0
// }
// vv.push_back(v);//尾插一维数组
//}
//解析:这种方式通过两层循环,先创建一个临时的一维 vector,并将其元素初始化为 0,
//然后将这个一维 vector 插入到外层 vector 中。
//二维数组的3种遍历方式
//1.operator[]遍历
for (size_t i = 0; i < vv.size(); ++i)
{
for (size_t j = 0; j < vv[i].size(); ++j)
{
cout << vv[i][j] << " ";
}
cout << endl;
}
//2.范围for遍历
//for (const auto& v : vv)
//{
// //遍历外层
// for (const auto& e : v)
// {
// //遍历内层vector
// cout << e << " ";
// }
// cout << endl;
//}
//3.迭代器遍历
/*for (auto v = vv.begin(); v != vv.end(); ++v)
{
for (auto it = (*v).begin(); it != (*v).end(); ++it)
{
cout << *it << " ";
}
cout << endl;
}*/
}
常用方式:写法 1 和写法 2 都是较为常用的方式。写法 1 可以很好地控制每行每列的个数,通过先调整外层 vector
的大小,再调整内层 vector
的大小,使用 resize
来创建数组可以灵活应对多种不同的需求场景,能解决大多数问题。写法 2 则使用 vector
的构造函数,直接指定了外层和内层 vector
的大小和初始值,代码简洁,在已知二维数组确切大小和初始值的情况下,使用起来高效且方便,也被广泛应用。 写法 3 思路和写法 1 类似,不过代码相对繁琐一些;写法 4 通过循环和 push_back
函数来插入元素,由于 push_back
可能会导致内存重新分配,效率相对较低,使用场景相对较少,但在某些特定情况下也会用到 。
3.3.shrink_to_fit
vector::shrink_to_fit - C++ Reference (cplusplus.com)
在使用 C++ 标准库的诸多容器(像 std::vector
等)时,这些容器提供的常规接口一般无法直接释放已分配的整块动态内存空间中的部分空间以实现缩容。这是因为容器的内存是连续分配的,不能仅释放其中一部分而维持内存的连续性和有效性。
所以,当需要进行缩容操作时,常见做法与容器的扩容操作类似,往往是采用异地缩容的方式。具体来说,就是重新分配一块大小合适的内存空间,把原有数据拷贝到新的内存空间,然后释放原来的内存空间。
不过,需要注意的是,不建议轻易使用缩容操作。因为缩容是有代价的,比如会涉及数据拷贝等额外开销,在容器元素数量较多或者元素本身较大时,可能会对程序性能产生显著影响。因此,要根据具体的应用场景和性能需求来谨慎考虑是否进行缩容。
4.vector 增删查改
vector::push_back - C++ Reference (cplusplus.com)
vector::pop_back - C++ Reference (cplusplus.com)
vector::swap - C++ Reference (cplusplus.com)
vector::operator[] - C++ Reference (cplusplus.com)
4.1.insert、erase、find
vector::insert - C++ Reference (cplusplus.com)
vector::erase - C++ Reference (cplusplus.com)
find - C++ Reference (cplusplus.com)
(1)注意事项
- 容器插入和删除操作的访问方式:std::string 和 std::vector 的 insert 和 erase 操作在访问方式上有所不同。std::string 的 insert 和 erase 使用下标访问,这是因为 std::string 更多用于处理多个字符的插入(如插入一个字符串)和删除(如删除一个子串),使用下标能方便地定位到字符串中的具体位置。而 std::vector 可以存储任意类型的数据,并且可以灵活地处理单个或多个元素的插入和删除,迭代器提供了更灵活的方式来定位元素,所以 std::vector 的 insert 和 erase 使用参数迭代器 iterator pos 进行访问,适用于各种类型元素及不同数量元素的插入和删除操作。
- std::vector 不提供 头插与头删 的接口的原因:std::vector 没有直接提供头插、头删的成员函数接口,因为头插、头删都是通过挪动数据来完成数据的插入、删除操作,会导致较高的时间复杂度,效率低,所以最好少使用。但是 std::vector 使用 insert 函数并传入 begin () 迭代器作为插入位置,来实现头插操作;erase 成员函数并传入 begin () 迭代器,就可以删除 vector 的第一个元素(头删)。
- 插入和删除操作的效率:
insert
和erase
操作尽量少用,因为这些操作通常需要挪动数据来完成插入或删除,会带来较高的时间复杂度,从而影响代码效率。 - vector自己没有查找函数的原因:
std::vector
类本身没有实现查找函数find
,而是使用标准库中的std::find
函数模板。这是因为查找算法是所有容器都可能需要的通用操作,在标准库中实现一个std::find
函数模板可以让任意容器使用,避免了每个容器都重复实现查找功能。而std::string
实现了string::find
成员函数,这是因为std::string
有查找子串(即查找多个字符)的需求,string::find
可以更方便地处理这种情况。std::find
在左闭右开区间[first, last)
中查找元素,查找失败时返回last
,这是其设计规定的统一标识。 vector<char>
不能代替string
的原因:- 终止符问题:
vector<char>
和string
的实现有很大差别。vector<char>
的结尾没有字符串终止符'\0'
,而string
的结尾有终止符。这使得vector<char>
不能很好地兼容 C 语言函数接口,因为有些 C 语言函数接口需要传入以'\0'
结尾的字符串,而vector<char>
不能直接作为参数传递,只能使用string
的成员函数c_str()
返回的字符串指针来传参。 - 比较规则不同:
vector<char>
比较大小通常是比较容器的长度size
,而string
比较大小是按照 ASCII 码进行比较。 - 操作便利性:
string
插入数据非常方便,可以使用operator+=
插入一个字符或多个字符,同时string
提供了string::find
函数,可用于查找一个字符或多个字符,并配合insert
和erase
进行数据的插入和删除操作。而vector<char>
插入数据可以通过push_back
、insert
等直接进行,不一定要配合std::find
使用。std::find
可以查找任意类型的元素,并非只能查找一个字符。 - 总的来说,
vector<char>
不能满足string
的所有需求,因此不能用vector<char>
完全代替string
。
- 终止符问题:
(2)案例
4.3.迭代器失效问题
迭代器的主要作用就是让算法能够不用关心底层数据结构,其底层实际就是一个指针,或者是对指针进行了封装,比如:vector的迭代器就是原生态指针T* 。因此迭代器失效,实际就是迭代器底层对应指针所指向的空间被销毁了,而使用一块已经被释放的空间,造成的后果是程序崩溃(即如果继续使用已经失效的迭代器,程序可能会崩溃)。
对于vector可能会导致其迭代器失效的操作有:
4.3.1. 会引起其底层空间改变的操作,都有可能是迭代器失效
比如:resize、reserve、insert、assign、push_back等。
#include <iostream>
#include <vector>
using namespace std;
int main()
{
vector<int> v{ 1,2,3,4,5,6 };
auto it = v.begin();
//1.将有效元素个数增加到100个,多出的位置使用8填充,操作期间底层会扩容
//v.resize(100, 8);
//2.reserve的作用就是改变扩容大小但不改变有效元素个数,操作期间可能会引起底层容量改变
//v.reserve(100);
//3.插入元素期间,可能会引起扩容,而导致原空间被释放
//v.insert(v.begin(), 0);
//v.push_back(8);
//4.给vector重新赋值,可能会引起底层容量改变
//v.assign(100, 8);
//出错原因:以上 1 ~ 4 操作分别单独取消注释,都有可能会导致vector扩容,也就是说vector底层原理旧空间被释放掉,
//而在打印时,it还使用的是释放之间的旧空间,在对it迭代器操作时,实际操作的是一块已经被释放的空间,而引起代码运行时崩溃。
//解决方式:在以上操作完成之后,如果想要继续通过迭代器操作vector中的元素,只需给it重新
//赋值即可。即在使用while循环之前重新用v.begin()给it赋值 it = v.begin().
while (it != v.end())
{
cout << *it << " ";
++it;
}
cout << endl;
return 0;
}
测试:
解决方式:在以上操作完成之后,如果想要继续通过迭代器操作vector中的元素,只需给it重新
赋值即可。例如:在使用while循环打印之前重新用v.begin()给it赋值 it = v.begin()。
注意:这里我只演示操作1迭代器失效的解决方式,其他操作都是类似的解决方式。
4.3.2. 指定位置元素的删除操作--erase
(1)erase以后,迭代器失效的原因说明
在 C++ 的标准模板库(STL)中,std::vector
是一种动态数组,它的元素在内存中是连续存储的。当使用 erase
方法删除 vector
中的元素时,会导致迭代器失效,下面详细解释其原因。
①std::vector
的内存布局特性:std::vector
会在内存中分配一块连续的存储空间来存储其元素。例如,当你创建一个 vector<int> v = {1, 2, 3, 4};
时,v
中的元素 1
、2
、3
、4
会依次存储在一段连续的内存区域中。这种连续存储的特性使得随机访问元素变得高效,因为可以通过简单的指针算术来计算元素的地址。
②erase
操作的实现原理:当调用 vector
的 erase
方法(例如 v.erase(it)
,其中 it
是指向要删除元素的迭代器)时,erase
会执行以下操作:
- 元素移动:为了保持元素的连续性,
erase
会将被删除元素之后的所有元素向前移动一个位置,以填补被删除元素留下的空缺。例如,若要删除v
中的元素2
,erase
会将3
、4
依次向前移动一个位置,覆盖掉原来2
的位置。 - 调整大小:
erase
会更新vector
的大小信息,将其大小减 1。
③迭代器失效的原因:由于 erase
操作会移动元素和调整 vector
的大小,这会导致迭代器失效,具体表现为以下两种情况。
- 被删除元素的迭代器失效:当调用
erase(it)
删除一个元素时,it
所指向的元素已经被移除,该迭代器不再指向任何有效的元素,因此它失效了。继续使用这个失效的迭代器会导致未定义行为,例如可能会访问到已经被覆盖的数据 或者 已经释放的内存(注:例如使用erase删除最后一个元素后,迭代器it就指向已经释放的内存(此时 it 变成野指针),若此时继续使用迭代器it访问则会越界访问从而造成程序发生崩溃)。 - 被删除元素之后的所有迭代器失效:因为
erase
会将被删除元素之后的所有元素向前移动一个位置,所以原本指向这些元素的迭代器现在指向的是错误的元素。例如,在删除元素2
后,原本指向3
的迭代器现在指向了4
,而原本指向4
的迭代器现在指向了一个无效的位置(可能是vector
之外的内存)。因此,被删除元素之后的所有迭代器都失效了。
示例代码说明
综上所述,erase
导致迭代器失效的根本原因是erase会改变 vector
中元素的存储位置和数量,从而使得迭代器指向的位置不再对应正确的元素。在使用 erase
时,需要特别注意迭代器的更新,以避免使用失效的迭代器。
(2)案例1
#include <iostream>
#include <vector>
using namespace std;
int main()
{
int a[] = { 1, 2, 3, 4 };
vector<int> v(a, a + sizeof(a) / sizeof(int));
//使用find查找3所在位置的iterator
vector<int>::iterator pos = find(v.begin(), v.end(), 3);
//删除pos位置的数据,导致pos迭代器失效。
v.erase(pos);
cout << *pos << endl; //此处会导致非法访问
return 0;
}
解析:erase删除pos位置元素后,pos位置之后的元素会往前搬移,没有导致底层空间的改变,理论上讲迭代器不应该会失效,但是:如果pos刚好是最后一个元素,删完之后pos刚好是end的位置,而end位置是没有元素的,那么pos就失效了。因此删除vector中任意位置上元素时,vs就认为该位置迭代器失效了。
(3)案例2:以下代码的功能是删除vector中所有的偶数。
①错误写法
#include <iostream>
#include <vector>
using namespace std;
int main()
{
//定义一个整数类型的数组 v,并初始化为包含 1, 2, 3, 4 四个元素
vector<int> v{ 1, 2, 3, 4 };
//定义一个迭代器 it,初始化为指向 v 的第一个元素
auto it = v.begin();
//开始循环,只要 it 不指向 v 的末尾,就继续循环
while (it != v.end())
{
//检查当前迭代器 it 所指向的元素是否为偶数
if (*it % 2 == 0)
{
//如果是偶数,调用 erase 函数删除该元素
//注意:这里存在问题,调用 erase 以后,it 及其之后的迭代器会失效
v.erase(it);
}
//无论是否删除元素,都将迭代器 it 向后移动一位
//当之前删除了元素导致 it 失效时,继续使用失效的 it 会引发未定义行为
++it;
}
return 0;
}
问题原因
-
迭代器失效:
vector
的erase
方法会删除指定位置的元素,并且会使指向被删除元素以及之后元素的所有迭代器失效。当调用v.erase(it)
时,it
所指向的元素被删除,此时it
就变成了一个无效的迭代器。- 在
erase
操作之后,代码接着执行++it
,这会导致未定义行为,因为it
已经失效,对其进行递增操作是不安全的。
-
跳过元素:
- 即使不考虑迭代器失效的问题,
erase
操作会使后续元素向前移动填补被删除元素的位置。如果删除了一个元素,下一个元素会移动到当前位置,而代码中直接对it
进行递增操作( it++ ),会跳过这个移动过来的元素,从而导致部分偶数没有被检查到。
- 即使不考虑迭代器失效的问题,
②正确写法
#include <iostream>
#include <vector>
using namespace std;
int main()
{
//定义一个整数类型的数组 v,并初始化为包含 1, 2, 3, 4 四个元素
vector<int> v{ 1, 2, 3, 4 };
//定义一个迭代器 it,初始化为指向 v 的第一个元素
auto it = v.begin();
//开始循环,只要 it 不指向 v 的末尾,就继续循环
while (it != v.end())
{
//判断当前迭代器 it 所指向的元素是否为偶数
if (*it % 2 == 0)
{
//如果是偶数,调用 erase 函数删除该元素
//erase 函数会返回指向被删除元素之后的元素的迭代器
//将返回的迭代器赋值给 it,更新 it 使其指向有效元素
it = v.erase(it);
}
else
{
//如果不是偶数,将迭代器 it 向后移动一位,指向下一个元素
++it;
}
}
return 0;
}
4.3.3.注意:Linux下,g++编译器对迭代器失效的检测并不是非常严格,处理也没有vs下极端。
注:从下面三个案例中可以看到:SGI STL中,迭代器失效后,代码并不一定会崩溃,但是运行结果肯定不对,如果it不在begin和end范围内,肯定会崩溃的。
(1)案例1:reserve扩容
#include <iostream>
#include <vector>
using namespace std;
//1. 扩容之后,迭代器已经失效了,程序虽然可以运行,但是运行结果已经不对了
int main()
{
vector<int> v{ 1,2,3,4,5 };
for (size_t i = 0; i < v.size(); ++i)
cout << v[i] << " ";
cout << endl;
auto it = v.begin();
cout << "扩容之前,vector的容量为: " << v.capacity() << endl;
//通过reserve将底层空间设置为100,目的是为了让vector的迭代器失效
v.reserve(100);
cout << "扩容之后,vector的容量为: " << v.capacity() << endl;
//经过上述reserve之后,it迭代器肯定会失效,在vs下程序就直接崩溃了,但是linux下不会
//虽然可能运行,但是输出的结果是不对的
while (it != v.end())
{
cout << *it << " ";
++it;
}
cout << endl;
return 0;
}
//程序输出:
//1 2 3 4 5
//扩容之前,vector的容量为: 5
//扩容之后,vector的容量为 : 100
(2)案例2:erase删除中间任意位置的元素
//2. erase删除任意位置代码后,linux下迭代器并没有失效
//因为空间还是原来的空间,后序元素往前搬移了,it的位置还是有效的
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
int main()
{
vector<int> v{ 1,2,3,4,5 };
vector<int>::iterator it = find(v.begin(), v.end(), 3);
v.erase(it);
cout << *it << endl;
while (it != v.end())
{
cout << *it << " ";
++it;
}
cout << endl;
return 0;
}
//程序可以正常运行,并打印:
//4
//4 5
(3)案例3:erase删除最后一个元素
//3: erase删除的迭代器如果是最后一个元素,删除之后it已经超过end
//此时迭代器是无效的,++it导致程序崩溃
int main()
{
//第一组数据
vector<int> v{ 1,2,3,4,5 };
//第二组数据
//vector<int> v{1,2,3,4,5,6};
auto it = v.begin();
while (it != v.end())
{
if (*it % 2 == 0)
v.erase(it);
++it;
}
for (auto e : v)
cout << e << " ";
cout << endl;
return 0;
}
//========================================================
//使用第一组数据时,程序可以运行
//[sly@VM - 0 - 3 - centos 20250308]$ g++ testVector.cpp - std = c++11
//[sly@VM - 0 - 3 - centos 20250308]$ . / a.out
//1 3 5
//=========================================================
//使用第二组数据时,程序最终会崩溃
//[sly@VM - 0 - 3 - centos 20250308]$ vim testVector.cpp
//[sly@VM - 0 - 3 - centos 20250308]$ g++ testVector.cpp - std = c++11
//[sly@VM - 0 - 3 - centos 20250308]$ . / a.out
//Segmentation fault
4.3.4.与vector类似,string在insert/erase/reserve后,迭代器也会失效
(1)案例
#include <iostream>
#include <string>
using namespace std;
void TestString()
{
//初始化一个字符串 s,内容为 "hello"
string s("hello");
//定义一个迭代器 it,指向字符串 s 的起始位置
auto it = s.begin();
// s.resize(20, '!');//若取消这行代码的注释,程序会崩溃。原因是 resize 方法将字符串容量扩展到 20 时,
//若原内存空间不足以容纳新大小的字符串,string 会重新分配一块更大的内存空间,
//并将原内容复制过去,然后释放旧的内存空间。此时,it 仍指向旧的已释放的内存空间,
//迭代器失效。后续若再通过 it 访问内存,程序就会崩溃。
//遍历字符串 s,逐个输出字符
while (it != s.end())
{
cout << *it;
++it;
}
cout << endl;
//删除所有元素,将迭代器 it 重新指向字符串 s 的起始位置
it = s.begin();
////错误写法
//while (it != s.end())
//{
// //调用 erase 方法删除 it 指向的字符后,it 以及 it 之后的迭代器都会失效。
// //因为 erase 操作会使后面的字符依次向前移动,填补被删除字符的位置,
// //导致 it 不再指向有效的字符。接着执行 ++it 会使用失效的迭代器,
// //这会引发未定义行为,可能导致程序崩溃。
// s.erase(it);
// ++it;
//}
////错误写法
//it = s.begin();
//while (it != s.end())
//{
// //用 it 重新接收 erase 的返回值,此时 it 指向被删除元素的下一个元素,
// //解决了迭代器失效的问题。
// it = s.erase(it);
// //但这里直接 it++ 会跳过下一个元素。当 erase 删除最后一个元素时,
// //it 会指向 s.end()。再执行 it++ 后,it 就会指向 s.end() 之后的位置,
// //这是一个无效的位置,成为野指针。后续若再使用 it 进行操作,
// //会导致越界访问,造成程序崩溃。
// it++;
//}
//正确写法
it = s.begin();
while (it != s.end())
{
//s.erase(it) 会删除 it 指向的字符,并返回指向被删除字符下一个字符的迭代器。
//将这个返回值赋给 it,使得 it 始终指向有效的字符位置,
//这样可以避免迭代器失效问题,正确地删除字符串中的所有字符。
it = s.erase(it);
}
}
int main()
{
TestString();
return 0;
}
迭代器失效解决办法:在使用前,对迭代器重新赋值即可。