vector的动态扩容机制与下标越界问题

文章目录

    • 注意:动态扩容并不适用于数组下标越界
      • 为什么c++数组下标越界了但是没有报错?
    • 动态扩容原理与使用场景
      • 原理
      • size()与capacity()的区别
      • 扩容时capacity的策略
      • 使用场景
    • 扩容示例
      • resize和reverse操作
      • 示例1
        • realloc的含义
      • 示例2
    • 关于vector下标越界检查:at()
      • 总结
    • 引申:STL中允许动态扩容的容器
      • 不同容器扩容的时间复杂度

注意:动态扩容并不适用于数组下标越界

关于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];
    }
};

这个写法运行的时候,遇到下面用例输出了如下结果:

vector的动态扩容机制与下标越界问题_第1张图片
输入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++ 的数组或者向量在下标访问时,不会进行边界检查,这是因为 C++ 设计哲学中有一条就是"你不需要付出你不用的代价",也就是说,如果每次访问数组或者向量都进行边界检查,那么会带来额外的性能开销,而这部分开销对于绝大多数情况是没有必要的

对于下标访问的数组或者向量,如果下标超过了其大小,那么会进入未定义行为(Undefined Behavior),即任何事情都可能发生。可能是崩溃,可能是返回一个不可预期的值,也可能是毫无表现。在上面的情况下,看到的就是返回了一个看似随机的值

那么平时编程为什么下标为负数的时候会报错?

平时我们编程的时候,数组下标是负数的时候,C++ 会报错,这其实是因为我们使用的编译器或者环境开启了 AddressSanitizer 这样的内存错误检测工具。AddressSanitizer 通过添加一些运行时检查,可以发现一些内存错误,比如数组越界访问(包括下标为负和下标超过大小)。但是,AddressSanitizer 并不能保证检测所有的数组越界访问,特别是当下标超过数组大小很多时,可能就无法检测到了

总的来说,C++ 并不会在运行时对数组或者向量的下标访问进行边界检查如果下标超过了其大小,那么会进入未定义行为,即任何事情都可能发生。因此程序员需要自己确保不会发生越界访问。如果需要额外的安全性,可以使用一些工具来检测可能的错误,但是这些工具也不能保证能发现所有的问题。

动态扩容原理与使用场景

原理

C++的std::vector元素数量size()超出其当前容量(capacity())时,会自动进行内存重新分配以容纳更多元素。这个过程我们通常称之为"动态扩容"。在这个过程中,std::vector会申请一块更大的内存空间,将原有的元素复制到新的内存空间,并释放原来的内存空间

size()与capacity()的区别

std::vectorsize()方法返回的是容器中当前实际元素的数量,而capacity()则返回的是容器当前能够容纳的元素数量上限,也就是已经预留的内存空间可以容纳的元素数量。

在很多实现中,当我们往std::vector中插入元素时,如果size()超出了capacity(),那么std::vector会自动扩大其容量。扩大的具体程度取决于具体的库实现,通常情况下是扩大为原来的两倍,但这并不是标准规定,只是一种常见的实现方式

如果我们没有指定std::vector的初始大小,比如创建了一个空的std::vector,然后开始插入元素,那么对于第一个元素,std::vector可能会预留一定的容量,这个预留的容量大小也是取决于具体的库实现,可能是1,也可能是更大的数值。但是通常不会直接扩大到原始size()的两倍,而是在接下来的插入过程中,一旦size()超过capacity(),再进行扩容。

扩容时capacity的策略

std::vector进行扩容时,新的容量大小capacity是根据具体的实现来决定的,而并非是固定的规则。在许多实现中,新的容量大小是原来容量的两倍,但这并非一个固定的标准C++标准库并未规定std::vector的扩容策略,所以不同的编译器和标准库实现可能有不同的扩容策略

注意,std::vectorpush_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的动态扩容。但只有当新的元素数量超过当前的容量时,扩容才会发生。如果当前的容量足够容纳新的元素,那么这些操作就不会引发扩容。

扩容示例

resize和reverse操作

在 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”(野的)

示例1

  • 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的含义

“realloc” 是重新分配内存的缩写。在 C++ 中,std::vector 或其他动态数组容器的容量不足以存储更多的元素时,会进行一次内存重新分配。这个过程大致如下:

  1. 新的(更大的)内存空间被分配出来。
  2. 旧的元素被复制(或者移动)到新的内存空间。
  3. 旧的内存空间被释放。

重新分配内存是一个比较昂贵的操作,因为它涉及到复制(或移动)元素和释放内存。为了避免频繁的内存重新分配,std::vector 会预分配一些额外的内存空间,也就是 capacity。当 std::vector 的 size(即元素数量)增长并超过其当前的 capacity 时,会进行内存重新分配,并且新的 capacity 会比 old capacity 大。这样,std::vector 可以在一段时间内添加元素而无需重新分配内存。

这种预分配策略使得 std::vectorpush_back 操作可以达到摊销常数时间复杂度(即,在大多数情况下,push_back 的时间复杂度是 O(1),只有在需要进行内存重新分配的时候,时间复杂度才会变为 O(n))

示例2

#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;
}

vector的动态扩容机制与下标越界问题_第2张图片
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下标越界检查:at()

如果我们直接用[]访问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++中,未定义的行为可能会导致程序崩溃,也可能会静默地继续运行,但产生错误的结果

引申:STL中允许动态扩容的容器

针对 capacity 这个属性,STL 中的其他容器,如 list map set deque,由于这些容器的内存是散列分布的,因此不会发生类似 realloc() 的调用情况,因此我们可以认为 capacity 属性针对这些容器是没有意义的,因此设计时这些容器没有该属性

在 STL 中,拥有 capacity 属性的容器只有 vector 和 string,但是STL中允许动态扩容的容器并不只有这两个。

C++ Standard Template Library (STL) 中的许多容器都能够动态地扩容。实际上,所有的序列容器(sequence containers)都可以根据需要动态地增长或收缩。这些序列容器包括 std::vectorstd::stringstd::dequestd::liststd::forward_list

stl容器数据结构如下:STL 容器简介 - OI Wiki (oi-wiki.org)

vector的动态扩容机制与下标越界问题_第3张图片
然而,不同的容器使用了不同的内存分配和管理策略,因此他们的扩容方式和性能特性也有所不同。例如:

  1. std::vectorstd::string他们在内存中保持一个连续的空间,所以当容器需要扩容时,他们可能需要分配一个新的更大的内存块,然后把旧的数据复制过去,然后释放旧的内存块。这就是为什么它们有 capacity() 这个概念,用于标识预分配的内存块的大小
  2. std::deque:双端队列有一个稍微复杂一些的内存结构,它保持一系列的固定大小的块,而这些块可以不连续。这样,当 deque 需要扩展时,它只需要分配一个新的块,而不需要重新分配和复制所有的数据
  3. std::liststd::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 在插入新元素时会分配新的节点
}

不同容器扩容的时间复杂度

  1. std::vectorstd::string 的扩容操作具有线性时间复杂度 O(n)。这是因为当容器需要扩容时,它必须分配一个新的内存块,然后把旧的数据复制到新的内存块中,这个操作需要遍历旧的内存块中的所有元素。然而,这只是在扩容时的时间复杂度,平均时间复杂度(摊销时间复杂度)是 O(1)。也就是说,push_back 这样的插入操作平均来看是常数时间复杂度
  2. std::deque 的扩容操作具有常数时间复杂度 O(1)deque 通过在两端分配新的固定大小的块来扩容,所以它不需要复制旧的数据。
  3. std::liststd::forward_list 的扩容操作也具有常数时间复杂度 O(1)。当这两个链表容器需要插入新的元素时,它们只需要分配一个新的节点,然后更新相应的指针。这个操作只涉及到固定数量的步骤,所以它具有常数时间复杂度。

注意,以上的复杂度分析是在忽略内存分配的时间的情况下进行的。在实际情况下,内存分配可能需要花费相当大的时间,特别是在内存紧张的情况下。

你可能感兴趣的:(CPP语法,容器相关与易错点记录,c++,数据结构)