关于vector的动态扩容,我们需要首先明白一点,那就是vector动态扩容并不适用于数组下标越界的情况。
示例:leetcode188.买卖股票的最佳时机
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
if(prices.size()==1) return 0;
vector<vector<int>>dp(prices.size(),vector<int>(2*k+1,0));
//初始化
for(int i=1;i<=2*k;i++){
//此处手误写错,-prices[i]会导致下标越界
dp[0][i]=(i%2==0)?0:-prices[i];
}
//递推
for(int i=1;i<prices.size();i++){
for(int j=1;j<=2*k;j++){
dp[i][j]=max(dp[i-1][j],dp[i-1][j-1]+(j%2==0?prices[i]:-prices[i]));
}
}
//return *max_element(dp[prices.size()-1].begin(),dp[prices.size()-1].end());
return dp[prices.size()-1][2*k];
}
};
这个写法运行的时候,遇到下面用例输出了如下结果:
输入prices的size是3,而k=2的时候2*k=4,也就是说下面这个for循环访问了prices数组不存在的下标prices[4]
for(int i=1;i<=2*k;i++){
//此处手误写错,-prices[i]会导致下标越界
dp[0][i]=(i%2==0)?0:-prices[i];
}
但是这种情况并没有报错,只是输出了一个极大的数值1094795580。
本来以为这种情况是vector底层发生了动态扩容,所以并没有报错。但是实际上,vector下标越界而没有报错,并非由于 vector
的动态扩容机制导致的。
而是因为,在 C++ 中,访问数组(包括 vector
)下标超出其大小时,其行为是未定义的,也就是说,它可能产生任何结果,包括但不限于返回一个随机值、导致程序崩溃等。
得到的 1094795590 这个很大的数字,是因为代码访问了 vector
空间之外的内存,那块内存恰好有这个值。这个数值并不取决于 C++ 的版本,而是取决于访问的那块内存中恰好存储的值,这个值是随机的。
C++ 的数组或者向量在下标访问时,不会进行边界检查,这是因为 C++ 设计哲学中有一条就是"你不需要付出你不用的代价",也就是说,如果每次访问数组或者向量都进行边界检查,那么会带来额外的性能开销,而这部分开销对于绝大多数情况是没有必要的。
对于下标访问的数组或者向量,如果下标超过了其大小,那么会进入未定义行为(Undefined Behavior),即任何事情都可能发生。可能是崩溃,可能是返回一个不可预期的值,也可能是毫无表现。在上面的情况下,看到的就是返回了一个看似随机的值。
那么平时编程为什么下标为负数的时候会报错?
平时我们编程的时候,数组下标是负数的时候,C++ 会报错,这其实是因为我们使用的编译器或者环境开启了 AddressSanitizer 这样的内存错误检测工具。AddressSanitizer 通过添加一些运行时检查,可以发现一些内存错误,比如数组越界访问(包括下标为负和下标超过大小)。但是,AddressSanitizer 并不能保证检测所有的数组越界访问,特别是当下标超过数组大小很多时,可能就无法检测到了。
总的来说,C++ 并不会在运行时对数组或者向量的下标访问进行边界检查,如果下标超过了其大小,那么会进入未定义行为,即任何事情都可能发生。因此程序员需要自己确保不会发生越界访问。如果需要额外的安全性,可以使用一些工具来检测可能的错误,但是这些工具也不能保证能发现所有的问题。
C++的std::vector
在元素数量size()
超出其当前容量(capacity()
)时,会自动进行内存重新分配以容纳更多元素。这个过程我们通常称之为"动态扩容"。在这个过程中,std::vector
会申请一块更大的内存空间,将原有的元素复制到新的内存空间,并释放原来的内存空间。
std::vector
的size()
方法返回的是容器中当前实际元素的数量,而capacity()
则返回的是容器当前能够容纳的元素数量上限,也就是已经预留的内存空间可以容纳的元素数量。
在很多实现中,当我们往std::vector
中插入元素时,如果size()
超出了capacity()
,那么std::vector
会自动扩大其容量。扩大的具体程度取决于具体的库实现,通常情况下是扩大为原来的两倍,但这并不是标准规定,只是一种常见的实现方式。
如果我们没有指定std::vector
的初始大小,比如创建了一个空的std::vector
,然后开始插入元素,那么对于第一个元素,std::vector
可能会预留一定的容量,这个预留的容量大小也是取决于具体的库实现,可能是1,也可能是更大的数值。但是通常不会直接扩大到原始size()
的两倍,而是在接下来的插入过程中,一旦size()
超过capacity()
,再进行扩容。
当std::vector
进行扩容时,新的容量大小capacity是根据具体的实现来决定的,而并非是固定的规则。在许多实现中,新的容量大小是原来容量的两倍,但这并非一个固定的标准。C++标准库并未规定std::vector
的扩容策略,所以不同的编译器和标准库实现可能有不同的扩容策略。
注意,std::vector
的push_back
操作可能导致扩容,而扩容是一个比较消耗性能的操作,因为它涉及到内存的申请和释放,以及元素的复制。所以在知道需要存储的元素数量的情况下,提前使用reserve()
函数预留足够的容量,可以避免多次扩容,从而提高性能。
至于std::vector
的动态扩容,通常会在试图添加元素到容器而容器的当前容量(capacity()
)不足以容纳新元素时发生。通常会在以下操作:
push_back()
: 在std::vector
的末尾添加元素。如果当前的容量不足以容纳新的元素,std::vector
将会进行扩容。std::vector<int> vec;
vec.push_back(1); // 这可能会触发扩容
insert()
: 在std::vector
的指定位置插入元素。如果当前的容量不足以容纳新的元素,std::vector
将会进行扩容。std::vector<int> vec;
vec.insert(vec.begin(), 1); // 这可能会触发扩容
resize()
: 调整std::vector
的大小。如果新的大小大于当前的容量,std::vector
将会进行扩容。std::vector<int> vec;
vec.resize(10); // 这可能会触发扩容
这些操作可能会引发std::vector
的动态扩容。但只有当新的元素数量超过当前的容量时,扩容才会发生。如果当前的容量足够容纳新的元素,那么这些操作就不会引发扩容。
在 C++ 的 std::vector
中,reserve()
和 resize()
是两个用来改变容器大小的方法,但是他们的行为和效果是不同的:
resize(n)
将会改变容器的实际大小(size()
),如果 n
大于当前的 size()
,会创建 n-size()
个新的元素,如果 n
小于当前的 size()
,会删除末尾的 size()-n
个元素。总的来说,resize(n)
会保证容器包含 n
个元素。reserve(n)
是预留空间,它会改变容器的容量(capacity()
),但不会改变容器的实际大小(size()
)。如果 n
大于当前的 capacity()
,会重新分配内存来保证容器能够容纳 n
个元素而不需要重新分配内存。但是,这些新的空间并不会被初始化,也就是说,他们不会成为容器的一部分(size()
不会改变)。”野空间“指的是当 reserve()
扩大 capacity()
,并未增加 size()
时产生的那部分新的空间。这部分空间被分配出来,但是并没有初始化,也就是说,这部分空间中的内容是未知的,因此被称为 “wild”(野的)。
size 是当前 vector 容器真实占用的大小,也就是容器当前拥有多少个容器。
capacity 是指在发生 realloc 前能允许的最大元素数,即预分配的内存空间。
这两个属性分别对应两个方法:resize() 和 reserve()。
使用 resize() 容器内的对象内存空间是真正存在的。
使用 reserve() 仅仅只是修改了 capacity 的值,容器内的对象并没有真实的内存空间(空间是"野"的)。
此时切记使用 [] 操作符访问容器内的对象时,有可能会出现数组越界的问题。
当使用 []
操作符访问 std::vector
中的元素时,必须确保访问的索引在 [0, size())
范围内,否则就会发生数组越界。如果尝试使用 []
访问 reserve()
所预留出来的那部分空间,例如 v[v.size()]
,即使这个索引在 [0, capacity())
范围内,仍然会产生未定义行为,因为这部分空间并未被初始化,其中的内容是未知的。
std::vector<int> v; // 创建一个空的 vector
v.reserve(10); // 预留空间,使得 capacity 变为 10
std::cout << v[0] << std::endl; // 错误!这是未定义行为,因为 v 的 size() 仍然是 0
“realloc” 是重新分配内存的缩写。在 C++ 中,当 std::vector
或其他动态数组容器的容量不足以存储更多的元素时,会进行一次内存重新分配。这个过程大致如下:
重新分配内存是一个比较昂贵的操作,因为它涉及到复制(或移动)元素和释放内存。为了避免频繁的内存重新分配,std::vector
会预分配一些额外的内存空间,也就是 capacity。当 std::vector
的 size(即元素数量)增长并超过其当前的 capacity 时,会进行内存重新分配,并且新的 capacity 会比 old capacity 大。这样,std::vector
可以在一段时间内添加元素而无需重新分配内存。
这种预分配策略使得 std::vector
的 push_back
操作可以达到摊销常数时间复杂度(即,在大多数情况下,push_back
的时间复杂度是 O(1),只有在需要进行内存重新分配的时候,时间复杂度才会变为 O(n))。
#include
#include
using std::vector;
int main(void)
{
vector<int> v; // 创建一个空的 vector
std::cout<<"v.size() == " << v.size() << " v.capacity() = " << v.capacity() << std::endl; // 打印其 size 和 capacity
v.reserve(10); // 预留空间,使得 capacity 变为 10
std::cout<<"v.size() == " << v.size() << " v.capacity() = " << v.capacity() << std::endl; // 打印其 size 和 capacity
v.resize(10); // 改变其 size 为 10
v.push_back(0); // 在末尾插入一个元素
std::cout<<"v.size() == " << v.size() << " v.capacity() = " << v.capacity() << std::endl; // 打印其 size 和 capacity
return 0;
}
C++ STL 教程 | 菜鸟教程 (runoob.com)
在上面示例代码中,reserve(10)
只预留了内存,此时容器的 capacity()
变为 10,但是并没有实际的元素在容器中,所以 size()
仍然为 0。
接着,resize(10)
将 std::vector
的大小改为 10,这会在容器中创建 10 个默认初始化的元素,所以 size()
变为 10。注意,这个操作改变了容器中元素的数量,但并没有改变容器预分配的内存大小,所以 capacity()
仍然为 10。
最后,push_back(0)
在 std::vector
的末尾插入一个元素,此时 size()
变为 11,超过了之前预留的内存大小(capacity()
为 10)。在这种情况下,std::vector
需要进行内存的重新分配以容纳更多的元素,所以 capacity()
会增加(具体增加多少取决于具体的实现,可能是当前 capacity()
的两倍或者更多)。
这个例子说明了 size()
和 capacity()
的区别,以及 std::vector
如何管理内存。在预分配的内存满了之后,如果还要插入新的元素,std::vector
就需要重新分配内存,使 capacity()
变大。但是,如果我们能提前知道需要存储多少元素,那么就可以通过 reserve()
预先分配足够的内存,避免频繁的内存重新分配,从而提高程序的效率。
如果我们直接用[]访问vector,vector 退化为数组,不会进行越界的判断。
但是,std::vector
类有一个成员函数叫做at()
,它与operator[]
相似,都是用于访问容器中的元素。两者的区别在于,at()
会进行边界检查,如果访问的下标超出了vector
的大小(size()
),它会抛出一个std::out_of_range
异常。而operator[]
则不进行这样的检查,如果访问超出了边界,会导致未定义的行为。
示例:
首先创建一个大小为5的vector
std::vector<int> v(5);
然后试图访问一个不存在的元素,这将导致一个std::out_of_range
异常
try {
int x = v.at(10); // at方法访问元素,抛出 std::out_of_range
}
catch (const std::out_of_range& e) {
std::cerr << "Out of Range error: " << e.what() << '\n';
}
但是如果使用operator[]
来访问同样的元素,就不会得到任何错误或警告,而且无法预测程序会做什么。它可能会返回一个无意义的值,或者导致程序崩溃。这就是为什么需要谨慎使用operator[]
访问vector
,或者更好的做法是尽可能使用at()
方法。
std::vector
的自动扩容机制并不能防止数组下标越界的问题,需要防止下标越界可以用at()来访问vector元素。
即使std::vector
可以自动扩容,但是访问std::vector
的元素还是需要确保下标在有效范围内,否则仍然会导致未定义行为。如果访问了std::vector
中没有初始化的下标,那么可能会遇到任何数值,这是未定义的行为。在C++中,未定义的行为可能会导致程序崩溃,也可能会静默地继续运行,但产生错误的结果。
针对 capacity 这个属性,STL 中的其他容器,如 list map set deque
,由于这些容器的内存是散列分布的,因此不会发生类似 realloc() 的调用情况,因此我们可以认为 capacity 属性针对这些容器是没有意义的,因此设计时这些容器没有该属性。
在 STL 中,拥有 capacity 属性的容器只有 vector 和 string,但是STL中允许动态扩容的容器并不只有这两个。
C++ Standard Template Library (STL) 中的许多容器都能够动态地扩容。实际上,所有的序列容器(sequence containers)都可以根据需要动态地增长或收缩。这些序列容器包括 std::vector
、std::string
、std::deque
、std::list
和 std::forward_list
。
stl容器数据结构如下:STL 容器简介 - OI Wiki (oi-wiki.org)
然而,不同的容器使用了不同的内存分配和管理策略,因此他们的扩容方式和性能特性也有所不同。例如:
std::vector
和 std::string
:他们在内存中保持一个连续的空间,所以当容器需要扩容时,他们可能需要分配一个新的更大的内存块,然后把旧的数据复制过去,然后释放旧的内存块。这就是为什么它们有 capacity()
这个概念,用于标识预分配的内存块的大小。std::deque
:双端队列有一个稍微复杂一些的内存结构,它保持一系列的固定大小的块,而这些块可以不连续。这样,当 deque
需要扩展时,它只需要分配一个新的块,而不需要重新分配和复制所有的数据。std::list
和 std::forward_list
:这两个容器是链表结构,他们把数据存储在独立的节点中,每个节点通过指针和其他节点相连。当添加或删除元素时,只需要分配或释放对应的节点即可,不需要对整个容器进行重新分配。以上这些容器都有插入元素的操作,如 push_back()
、push_front()
或 insert()
,这些操作都可能会导致容器扩容。
示例:
std::vector<int> v;
for(int i = 0; i < 10; ++i)
{
v.push_back(i); // 当 vector 的 capacity 不足以容纳新元素时,它会自动扩容
}
std::deque<int> dq;
for(int i = 0; i < 10; ++i)
{
dq.push_back(i); // 同样,当 deque 需要容纳新元素时,它也会分配新的内存块
}
std::list<int> lst;
for(int i = 0; i < 10; ++i)
{
lst.push_back(i); // list 在插入新元素时会分配新的节点
}
std::vector
和 std::string
的扩容操作具有线性时间复杂度 O(n)。这是因为当容器需要扩容时,它必须分配一个新的内存块,然后把旧的数据复制到新的内存块中,这个操作需要遍历旧的内存块中的所有元素。然而,这只是在扩容时的时间复杂度,平均时间复杂度(摊销时间复杂度)是 O(1)。也就是说,push_back
这样的插入操作平均来看是常数时间复杂度。std::deque
的扩容操作具有常数时间复杂度 O(1)。deque
通过在两端分配新的固定大小的块来扩容,所以它不需要复制旧的数据。std::list
和 std::forward_list
的扩容操作也具有常数时间复杂度 O(1)。当这两个链表容器需要插入新的元素时,它们只需要分配一个新的节点,然后更新相应的指针。这个操作只涉及到固定数量的步骤,所以它具有常数时间复杂度。注意,以上的复杂度分析是在忽略内存分配的时间的情况下进行的。在实际情况下,内存分配可能需要花费相当大的时间,特别是在内存紧张的情况下。