cpp笔记第4篇-关于vector,你知道多少?

400 阅读17分钟

vector如何构建?

在 C++ 中,std::vector 可以通过多种方式构建和初始化。以下是常见的构造方法及示例:


1. 默认构造函数

创建一个空的 vector(无元素):

std::vector<int> vec;  // 空 vector

2. 指定元素个数和初始值

创建包含 n 个元素的 vector,每个元素初始化为 value

std::vector<int> vec(5);       // 5 个 int,默认初始化为 0
std::vector<std::string> vec(3, "hello");  // 3 个 "hello"

3. 通过初始化列表(C++11 起)

直接通过花括号 {} 初始化元素:

std::vector<int> vec = {1, 2, 3, 4, 5};
// 或
std::vector<std::string> vec{"apple", "banana", "cherry"};

4. 拷贝构造

用另一个 vector 初始化新 vector(深拷贝):

std::vector<int> vec1 = {1, 2, 3};
std::vector<int> vec2(vec1);  // vec2 是 vec1 的副本

5. 移动构造(C++11 起)

通过移动语义转移另一个 vector 的资源:

std::vector<int> vec1 = {1, 2, 3};
std::vector<int> vec2(std::move(vec1));  // vec1 变为空,资源转移给 vec2

6. 通过迭代器范围构造

用其他容器的迭代器范围初始化:

int arr[] = {10, 20, 30};
std::vector<int> vec(arr, arr + 3);  // 从数组构造

std::list<int> lst = {4, 5, 6};
std::vector<int> vec(lst.begin(), lst.end());  // 从 list 构造

7. 使用 assign 方法

动态分配元素(会覆盖原有内容):

std::vector<int> vec;
vec.assign(5, 100);  // 5 个 100
vec.assign({2, 4, 6});  // 替换为 {2, 4, 6}

示例代码

#include <vector>
#include <iostream>
#include <list>

int main() {
    // 默认构造
    std::vector<int> v1;

    // 指定大小和初始值
    std::vector<int> v2(3, 10);  // {10, 10, 10}

    // 初始化列表
    std::vector<int> v3 = {1, 2, 3};

    // 拷贝构造
    std::vector<int> v4(v3);  // {1, 2, 3}

    // 移动构造
    std::vector<int> v5(std::move(v4));  // v4 变为空

    // 迭代器范围构造
    std::list<int> lst = {4, 5, 6};
    std::vector<int> v6(lst.begin(), lst.end());  // {4, 5, 6}

    return 0;
}

关键点

  • vector 的元素类型可以是任意可拷贝的类型。
  • 使用 reserve() 可以预分配内存,避免频繁扩容。
  • C++11 及更高版本支持更简洁的初始化方式(如 {})。

vector如何做到动态扩容的?

在 C++ 中,std::vector扩容策略是其动态内存管理的核心机制,目的是在元素数量超过当前容量时自动分配更大的内存空间。以下是其关键实现细节和策略:


1. 容量(Capacity)与大小(Size)

  • size() : 当前容器中实际存储的元素数量。
  • capacity() : 容器在不重新分配内存的情况下可以容纳的最大元素数量。
  • size() == capacity() 时,继续添加元素会触发扩容。

2. 扩容的基本步骤

vector 需要扩容时,会执行以下操作:

  1. 分配更大的内存块:新内存的大小通常是当前容量的 倍数(例如 2 倍)。

  2. 元素迁移:将旧内存中的元素拷贝或移动到新内存中。

    • 对于简单类型(如 int),直接拷贝。
    • 对于复杂类型(如对象),C++11 后优先使用移动语义(若元素支持移动构造)。
  3. 释放旧内存:销毁旧元素并释放原有内存。


3. 扩容策略

(1) 倍增策略(常见实现)
  • 大多数标准库实现(如 GCC 的 libstdc++)采用 倍增扩容(每次扩容为当前容量的 2 倍)。
  • 优势:均摊时间复杂度为 O(1) (均摊分析证明)。
  • 劣势:可能浪费最多 当前容量 的内存空间。
(2) 固定增量策略(少数实现)
  • 某些实现(如 MSVC)可能使用 1.5 倍扩容
  • 优势:内存利用率更高,减少浪费。
  • 劣势:均摊时间复杂度略高(但仍是 O(1))。

4. 扩容的性能影响

  • 时间复杂度:单次扩容的时间复杂度为 O(n) (需要拷贝/移动所有元素)。
  • 均摊分析:经过多次 push_back 操作后,均摊时间复杂度为 O(1)
  • 迭代器失效:扩容后,原有的迭代器、指针和引用会失效(因为元素被移动到新内存)。

5. 手动控制扩容

  • reserve(n) : 预先分配至少能容纳 n 个元素的内存。

    std::vector<int> vec;
    vec.reserve(100);  // 提前分配 100 个元素的内存,避免多次扩容
    
  • shrink_to_fit() : 请求释放未使用的内存(将 capacity() 缩减到 size())。


6. 示例代码:观察扩容过程

#include <vector>
#include <iostream>

int main() {
    std::vector<int> vec;
    std::cout << "Initial capacity: " << vec.capacity() << std::endl;

    for (int i = 0; i < 20; ++i) {
        vec.push_back(i);
        std::cout << "Size: " << vec.size() 
                  << ", Capacity: " << vec.capacity() << std::endl;
    }

    return 0;
}

输出示例(GCC 环境)

Initial capacity: 0
Size: 1, Capacity: 1
Size: 2, Capacity: 2
Size: 3, Capacity: 4
Size: 4, Capacity: 4
Size: 5, Capacity: 8
...(每次容量不足时倍增)

7. 为什么选择倍增策略?

  • 均摊分析:假设每次扩容为 2 倍,经过多次 push_back 操作后,均摊到每次操作的时间复杂度为 O(1)。
  • 数学证明:假设扩容到 2 倍,前一次扩容的成本会被后续的 push_back 操作分摊。

8. 注意事项

  • 避免频繁扩容:如果已知元素数量,优先使用 reserve() 预分配内存。
  • 元素移动语义:为自定义类型实现移动构造函数(T(T&&))和移动赋值运算符,以提高扩容效率。

通过理解 vector 的扩容策略,可以更高效地使用这一容器,避免性能陷阱。

vector的元素是不是没有放在栈上,而是放在堆上?

在 C++ 中,std::vector 的元素确实存储在堆(Heap)上,而 vector 对象本身的管理结构(如指向堆内存的指针、sizecapacity 等元数据)可能存储在栈(Stack)或堆上,具体取决于 vector 对象的声明方式。以下是详细解释:


1. vector 对象本身的位置

  • 栈上的 vector 对象

    void func() {
        std::vector<int> vec;  // vec 对象本身在栈上
        // ...
    }
    
    • vec 对象的管理结构(指针、sizecapacity)存储在栈上。
    • 但元素的内存由 vector 内部动态分配在堆上
  • 堆上的 vector 对象

    std::vector<int>* pVec = new std::vector<int>();  // pVec 指向堆上的 vector 对象
    
    • vector 对象本身(包括管理结构)在堆上,但元素的内存依然由 vector 内部动态分配在堆上。

2. vector 元素的存储位置

无论 vector 对象本身在栈还是堆上,元素的内存始终由 vector 内部通过动态分配(new/malloc)在堆上管理。这是为了支持动态扩容和收缩。

示例:
#include <vector>

int main() {
    std::vector<int> vec = {1, 2, 3};  // vec 对象在栈上
    // vec 的元素存储在堆上,由 vec 内部的指针管理
    return 0;
}
  • vec 对象销毁时(如函数返回),其析构函数会自动释放堆上的元素内存。

3. 为什么元素必须放在堆上?

std::vector 的核心功能是动态扩容,而栈内存的特性与此冲突:

  • 栈内存固定:栈内存大小在编译时确定,无法动态扩展。
  • 堆内存灵活:堆内存可以按需分配和释放,支持动态调整容量。
扩容机制:

vector 需要扩容时,会在堆上分配更大的内存块,并将旧元素移动或拷贝到新内存中,最后释放旧内存。这一过程需要堆内存的动态管理能力。


4. 验证元素在堆上

可以通过以下方式验证:

(1) 观察地址范围

栈地址通常较小(如 0x7ff...),堆地址较大(如 0x55...0x10...):

#include <vector>
#include <iostream>

int main() {
    int x = 0;  // 栈上的变量
    std::vector<int> vec = {1, 2, 3};

    std::cout << "栈变量地址: " << &x << std::endl;
    std::cout << "vector元素地址: " << &vec[0] << std::endl;
    return 0;
}

输出示例:

栈变量地址: 0x7ffd12345678
vector元素地址: 0x55a1b2d3e4a0

明显看出元素地址属于堆内存范围。

(2) 手动释放验证

如果元素在栈上,手动释放会导致错误,但 vector 的元素释放由析构函数自动完成:

std::vector<int>* vec = new std::vector<int>(100);
delete vec;  // 正确:释放 vec 对象和它管理的堆内存

5. 与静态数组的对比

  • 静态数组(栈内存)

    int arr[100];  // 栈上分配,大小固定,无法扩容
    
  • vector(堆内存)

    std::vector<int> vec;  // 元素在堆上,支持动态扩容
    vec.reserve(100);      // 预分配堆内存
    

6. 总结

  • vector 对象本身:可能在栈或堆上,取决于声明方式。
  • vector 的元素始终在堆上,由 vector 内部动态管理。
  • 设计原因:堆内存的灵活性支持动态扩容,而栈内存无法满足这一需求。

理解这一点有助于避免以下误区:

  • 误以为 vector 的元素在栈上,导致对内存生命周期的错误假设。
  • 手动尝试释放 vector 元素的内存(应由 vector 的析构函数自动处理)。

如果一个类实例元素是自己新建的,但是存放到vector里面,谁负责析构呢?

先说结论,如果存放在vector里的是实例本身而非指针,那么vector负责析构,否则自己析构。

在 C++ 中,std::vector 存储的元素是否由 vector 负责析构,取决于元素的存储方式所有权管理。以下是具体分析:


1. 存储对象本身(直接存储实例)

如果将类的实例直接作为对象存入 vector(而非指针),则 vector 会负责其析构:

  • 所有权vector 拥有元素的所有权。

  • 析构时机:当 vector 被销毁(如离开作用域)或调用 clear()erase() 等方法时,会自动调用每个元素的析构函数。

  • 示例

    class MyClass { /* ... */ };
    std::vector<MyClass> vec;
    vec.push_back(MyClass());  // 直接存储对象
    
    • MyClass 对象会在 vector 析构时自动销毁。

2. 存储原始指针(需手动管理)

如果存储的是通过 new 创建的原始指针,则 vector 不会自动释放指针指向的内存

  • 所有权:程序员需手动管理内存。

  • 风险:若忘记释放,会导致内存泄漏。

  • 示例

    std::vector<MyClass*> vec;
    vec.push_back(new MyClass());  // 存储原始指针
    // 必须手动释放:
    for (auto ptr : vec) delete ptr;
    

3. 存储智能指针(推荐方式)

使用 std::shared_ptrstd::unique_ptr 存储动态分配的对象时:

  • 所有权:智能指针自动管理内存。

  • 析构时机:当 vector 析构或元素被移除时,智能指针的引用计数减少,自动释放对象。

  • 示例

    std::vector<std::shared_ptr<MyClass>> vec;
    vec.push_back(std::make_shared<MyClass>());  // 存储智能指针
    
    • 无需手动释放,内存由智能指针自动管理。

4. 自定义类型的特殊要求

vector 存储自定义类型的对象,需确保:

  • 拷贝/移动语义:对象应支持拷贝或移动构造(用于 push_back 等操作)。

  • 析构函数:自定义类型的析构函数需正确释放资源(如动态内存、文件句柄等)。

  • 示例

    class Person {
    public:
        ~Person() { /* 释放资源(如动态内存) */ }
    };
    std::vector<Person> people;  // vector 会调用 Person 的析构函数
    

总结

存储方式析构责任风险
直接存储对象vector 自动析构
存储原始指针程序员手动释放(delete内存泄漏风险
存储智能指针智能指针自动析构无(需避免循环引用)

最佳实践

  • 优先直接存储对象(适用于小型、可复制的类型)。
  • 动态分配对象时使用智能指针(如 std::shared_ptr)。
  • 避免在 vector 中存储原始指针,除非有明确的性能需求且能确保手动释放。

验证示例

#include <vector>
#include <memory>

class Test {
public:
    Test() { std::cout << "构造\n"; }
    ~Test() { std::cout << "析构\n"; }
};

int main() {
    {
        std::vector<Test> vec;
        vec.push_back(Test());  // 存储对象
    } // vector 析构时自动调用 Test 的析构函数

    {
        std::vector<std::shared_ptr<Test>> vec;
        vec.push_back(std::make_shared<Test>());  // 存储智能指针
    } // shared_ptr 自动释放

    return 0;
}

输出:

构造
析构  // vector 析构时释放对象
构造
析构  // 智能指针引用计数归零时释放

通过合理选择存储方式,可以避免内存泄漏并简化代码逻辑。

vector能不能存储引用?

在 C++ 中,std::vector 不能直接存储引用类型(如 int&),但可以通过其他方式间接实现类似功能。以下是详细分析:


1. 为什么不能直接存储引用?

  • 引用不是对象:引用是变量的别名,本身不占用独立的内存空间,也没有实际地址。而 vector 的底层实现要求元素必须是可连续存储的独立对象。
  • 初始化限制:引用必须在声明时初始化且无法重新绑定,这与 vector 的动态扩容机制冲突(扩容时需要移动或拷贝元素)。
  • 类型不兼容:C++ 标准规定 vector 的元素类型必须是可拷贝构造(CopyConstructible)和可赋值(Assignable)的,而引用不满足这些条件。
示例代码(错误):
std::vector<int&> vec;  // 编译错误:引用不能作为vector的元素类型

2. 替代方案:间接存储引用

虽然不能直接存储引用,但可以通过以下方式间接实现类似功能:

(1) 存储指针

指针是对象,可以存储引用指向的对象的地址:

int x = 42;
std::vector<int*> vec;
vec.push_back(&x);  // 存储指针
  • 注意:需确保指针指向的对象生命周期足够长,避免悬垂指针。
(2) 存储智能指针

使用 std::shared_ptrstd::unique_ptr 管理动态对象:

auto ptr = std::make_shared<int>(42);
std::vector<std::shared_ptr<int>> vec;
vec.push_back(ptr);  // 存储智能指针
  • 优势:自动管理内存,避免内存泄漏。
(3) 使用 std::reference_wrapper(C++11)

std::reference_wrapper 是一个可拷贝、可赋值的引用包装器:

#include <functional>
int a = 10, b = 20;
std::vector<std::reference_wrapper<int>> vec;
vec.push_back(a);  // 存储引用包装器
vec.push_back(b);
vec[0].get() = 100;  // 修改a的值
  • 特点:行为类似引用,但满足容器的存储要求。

3. 为什么引用不能作为容器元素?

  • 内存布局冲突vector 要求元素在内存中连续排列,而引用没有独立地址,无法满足这一条件。
  • 动态扩容问题vector 扩容时需要迁移元素,但引用无法重新绑定到新对象。
  • 类型系统限制:C++ 标准明确禁止将引用作为容器元素类型。

4. 实际应用建议

  • 优先使用对象本身:若数据量小且可复制,直接存储对象到 vector
  • 动态对象管理:使用智能指针(如 std::shared_ptr)存储堆上的对象。
  • 需要别名语义时:使用 std::reference_wrapper 或指针实现间接引用。
示例(正确用法):
#include <vector>
#include <functional>
#include <memory>

int main() {
    // 存储指针
    int x = 42;
    std::vector<int*> vec1;
    vec1.push_back(&x);

    // 存储智能指针
    auto ptr = std::make_shared<int>(100);
    std::vector<std::shared_ptr<int>> vec2;
    vec2.push_back(ptr);

    // 存储引用包装器
    int a = 10, b = 20;
    std::vector<std::reference_wrapper<int>> vec3;
    vec3.push_back(a);
    vec3.push_back(b);
    
    return 0;
}

总结

需求解决方案注意事项
直接存储引用不可行编译错误
间接引用动态对象使用智能指针(shared_ptr避免循环引用
引用语义的容器元素使用 std::reference_wrapper需确保被引用对象的生命周期
存储外部变量的引用使用原始指针避免悬垂指针

通过合理选择间接存储方式,可以在 vector 中实现类似引用的功能,同时规避语言层面的限制。

引用不是指针对吧?

是的,引用(Reference)和指针(Pointer)是 C++ 中两个完全不同的概念,尽管它们都用于间接访问数据,但本质特性、语法规则和使用场景存在显著差异。以下是两者的核心区别:


1. 本质特性不同

  • 引用是变量的别名
    引用本质上是对已存在变量的别名,它与原变量共享同一块内存空间,没有独立的内存地址。例如:

    int a = 10;
    int &ref = a;  // ref 是 a 的别名,两者共享内存
    

    ref 的操作等同于直接操作 a,且引用无法脱离原变量独立存在。

  • 指针是内存地址的变量
    指针是一个独立的变量,存储的是另一个变量的内存地址,自身占用内存空间(32 位系统为 4 字节,64 位系统为 8 字节)。例如:

    int *p = &a;  // p 存储 a 的地址,p 本身有独立内存
    

2. 初始化与绑定规则

  • 引用必须初始化且不可重新绑定
    引用在声明时必须绑定到一个有效的变量,且一旦绑定后无法更改目标。例如:

    int &ref = a;  // 正确
    int &ref2;     // 错误!引用必须初始化
    ref = b;       // 错误!无法让 ref 重新指向 b
    
  • 指针可延迟初始化并重新指向
    指针可以不初始化(但可能成为野指针),且可随时改变指向的目标,甚至指向空值(nullptr)。例如:

    int *p;        // 允许但不安全(野指针)
    p = &a;        // 指向 a
    p = &b;        // 重新指向 b
    p = nullptr;   // 指向空值
    

3. 内存管理与安全性

  • 引用不涉及内存管理
    引用只是别名,不分配独立内存,也不需手动释放资源。其生命周期与原变量绑定,无需担心内存泄漏。

  • 指针需手动管理内存
    指针常用于动态内存分配(如 new/delete),若管理不当可能导致内存泄漏或悬垂指针。例如:

    int *p = new int(10);  // 动态分配内存
    delete p;              // 必须手动释放
    

4. 语法与使用场景

  • 引用语法简洁,无解引用操作
    引用直接使用原变量名访问数据,无需 * 解引用运算符。例如:

    ref = 20;  // 直接修改 a 的值
    
  • 指针需解引用并支持算术运算
    指针需通过 * 访问目标数据,且支持地址算术运算(如 p++ 移动至下一个元素)。例如:

    *p = 20;        // 解引用修改目标值
    p++;            // 指向下一个内存单元
    

5. 空值与多级间接访问

  • 引用不可为空
    引用必须绑定有效对象,无法指向 nullptr

    int &ref = nullptr;  // 编译错误
    
  • 指针可为空并支持多级间接访问
    指针可指向空值,且支持多级指针(如 int**)。例如:

    int **pp = &p;  // 指向指针的指针
    

总结

特性引用指针
本质变量的别名存储地址的独立变量
初始化必须初始化且不可重新绑定可延迟初始化并重新指向
空值不可为空可为 nullptr
内存管理无独立内存,无需手动管理需手动管理动态内存
语法复杂度简洁,无解引用操作需解引用,支持地址算术运算
多级间接访问不支持支持(如 int**

使用建议

  • 优先使用引用:若需避免拷贝、传递大型对象或确保参数有效性时,引用更安全高效。
  • 使用指针的场景:动态内存分配、处理数组、需要空值或多级间接访问时。

通过理解两者的差异,可以更合理地选择工具,避免内存错误并提升代码可读性。

列举一些常见的类似线性数据结构?

1. 顺序容器(Sequence Containers)

(1) std::array(C++11)
  • 特点:固定大小的数组,内存分配在栈上(或静态存储区)。

  • 与 vector 的区别

    • 大小不可变,初始化后无法扩容。
    • 性能更高(无堆内存分配开销)。
  • 适用场景:已知固定大小的数据存储。

    cpp
    复制
    #include <array>
    std::array<int, 5> arr = {1, 2, 3, 4, 5};
    
(2) std::deque(双端队列)
  • 特点:支持高效的头尾插入和删除(均摊 O(1) 时间复杂度)。

  • 与 vector 的区别

    • 内部实现为分段连续内存(多块动态数组)。
    • 扩容时不需要整体迁移元素,但随机访问稍慢。
  • 适用场景:需要频繁在头部和尾部操作。

    cpp
    复制
    #include <deque>
    std::deque<int> dq;
    dq.push_front(10);  // 头部插入
    dq.push_back(20);    // 尾部插入
    
(3) std::list(双向链表)
  • 特点:双向链表结构,支持任意位置的插入和删除(O(1) 时间复杂度)。

  • 与 vector 的区别

    • 不支持随机访问(只能通过迭代器顺序访问)。
    • 内存占用更高(每个元素需要额外存储前后节点的指针)。
  • 适用场景:频繁在中间插入或删除元素。

    cpp
    复制
    #include <list>
    std::list<int> lst = {1, 2, 3};