Bootstrap

C++STL面试题笔记 01 vector、list

推荐一个学习网站:https://github.com/0voice

1.请解释vector容器和它的特点

在C++中,vector是标准模板库(STL)的一部分,它是一个动态数组。与普通数组相比,它的大小可以在运行时动态改变。下面是vector的一些主要特点和应用场景:

  1. 动态大小:与传统的数组不同,vector可以根据需要动态地扩展或缩减大小。这意味着你不需要事先知道数据的数量。
    #include <iostream>
    #include <vector>
    
    int main() {
        std::vector<int> vec;  // 初始化一个空的 vector
    
        // 添加元素(动态扩展大小)
        vec.push_back(1);
        vec.push_back(2);
        vec.push_back(3);
    
        // 打印 vector 中的元素和大小
        std::cout << "Elements: ";
        for (int i = 0; i < vec.size(); ++i) {
            std::cout << vec[i] << " ";
        }
        std::cout << std::endl;
    
        std::cout << "Size: " << vec.size() << std::endl;  // 打印当前大小
        std::cout << "Capacity: " << vec.capacity() << std::endl;  // 打印当前容量
    
        // 删除最后一个元素(动态缩减大小)
        vec.pop_back();
        std::cout << "Size after pop_back: " << vec.size() << std::endl;
    
        return 0;
    }
    
  2. 随机访问:就像数组一样,vector支持随机访问,这意味着你可以通过索引直接访问任何元素,访问时间是常数时间复杂度(O(1))。

    #include <iostream>
    #include <vector>
    
    int main() {
        std::vector<int> vec = {10, 20, 30, 40, 50};
    
        // 通过索引访问元素
        std::cout << "Element at index 2: " << vec[2] << std::endl;  // 快速访问
        std::cout << "Element at index 4: " << vec.at(4) << std::endl;  // 带越界检查的访问
    
        return 0;
    }
    
  3. 内存管理vector在内部管理其存储的内存。当元素被添加到vector中,并且当前分配的内存不足以容纳它们时,它会自动重新分配更多的内存。

    #include <iostream>
    #include <vector>
    
    int main() {
        std::vector<int> vec;
        vec.reserve(10);  // 提前分配内存,容量为10
    
        for (int i = 0; i < 10; ++i) {
            vec.push_back(i * 10);  // 添加元素
        }
    
        // 打印容量变化
        std::cout << "Size: " << vec.size() << std::endl;
        std::cout << "Capacity: " << vec.capacity() << std::endl;
    
        return 0;
    }
    
  4. 灵活性:你可以在vector的末尾添加或删除元素,而且效率很高。但在中间或开始位置插入或删除元素可能会比较慢,因为这可能需要移动现有的元素。

    #include <iostream>
    #include <vector>
    
    int main() {
        std::vector<int> vec = {1, 2, 3, 4, 5};
    
        // 在末尾添加元素
        vec.push_back(6);
    
        // 在第二个位置插入一个新元素
        vec.insert(vec.begin() + 1, 10);
    
        // 删除第三个位置的元素
        vec.erase(vec.begin() + 2);
    
        // 打印结果
        std::cout << "Elements after insert and erase: ";
        for (int val : vec) {
            std::cout << val << " ";
        }
        std::cout << std::endl;
    
        return 0;
    }
    

应用场景

  • 动态数据集合:当你需要一个可以根据数据量动态调整大小的数组时,vector是一个很好的选择。例如,处理用户输入的数据集,其中输入数量事先未知。

  • 需要快速访问的数据:由于vector支持随机访问,它非常适合于需要频繁读取元素的情况,比如查找或排序算法中。

  • 性能敏感的应用:由于其元素紧密排列在连续的内存块中,vector通常提供高效的内存访问性能,适合用于性能敏感的应用。

总之,vector是一个非常灵活且强大的容器,适合用于多种不同的编程场景。在实际应用中,选择正确的数据结构往往是优化程序性能的关键。

2.vector如何保证元素的连续存储?

std::vector 能够保证其元素在内存中是连续存储的,这是 vector 的一个关键特性,也是它与链表等数据结构的根本区别。元素的连续存储意味着每个元素在内存中紧挨着存放,这使得 vector 能够像数组一样支持高效的随机访问。为了实现这一点,vector 的内存管理机制非常关键。

内存分配机制

动态数组的工作原理
  • std::vector 在内部是基于动态数组实现的。在创建一个 vector 时,容器会为一定数量的元素预先分配一块连续的内存。当向 vector 中添加元素时,如果当前分配的内存容量足够,那么元素会被放置在分配的连续内存块中。
  • 如果内存不足以容纳新元素时,vector 会重新分配一块更大的内存空间(通常是当前容量的 2 倍),并将现有的元素从旧内存区域复制到新内存区域。然后,释放旧的内存块。
    #include <iostream>
    #include <vector>
    
    int main() {
        std::vector<int> vec;
        vec.reserve(4);  // 提前分配 4 个元素的空间,保证内存连续性
    
        // 添加元素
        for (int i = 0; i < 4; ++i) {
            vec.push_back(i);
            std::cout << "Added: " << i << ", size: " << vec.size() 
                      << ", capacity: " << vec.capacity() << std::endl;
        }
    
        // 添加第 5 个元素,触发内存重新分配
        vec.push_back(4);
        std::cout << "After adding one more element:" << std::endl;
        std::cout << "Size: " << vec.size() << std::endl;
        std::cout << "Capacity: " << vec.capacity() << std::endl;
    
        return 0;
    }
    

解释

  • 在上面的代码中,我们通过 reserve(4)vector 提前分配了存储 4 个元素的空间,这保证了添加前 4 个元素时内存是连续的。
  • 当第 5 个元素被添加时,由于当前容量不足,vector 会重新分配一块更大的内存,并将现有的 4 个元素复制到新内存中。这保证了内存连续性,但也引入了内存重新分配和数据拷贝的开销。

容量的扩展策略

std::vector 的扩展策略是每次需要扩容时,通常会将当前容量扩展到原来的 2 倍(具体倍数依实现不同可能有所不同)。这种策略有助于避免频繁的内存重新分配,同时确保每次重新分配的空间仍然是连续的。

代码示例:扩展策略
#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec;

    // 连续添加元素,观察容量的变化
    for (int i = 0; i < 10; ++i) {
        vec.push_back(i);
        std::cout << "Added: " << i << ", size: " << vec.size() 
                  << ", capacity: " << vec.capacity() << std::endl;
    }

    return 0;
}

解释

  • 随着元素不断添加,vector 的容量(capacity())会随着需要动态增加。在这段代码中,你会观察到每次超出容量时,capacity() 发生跳跃式增长。
  • 例如,当容量从 4 扩展到 8 时,vector 会重新分配足够大的内存空间,以保证元素的连续存储。

如何保证元素的地址连续性

std::vector 保证每个元素都存储在一个连续的内存块中,这意味着 vector 中的元素地址是连续的。这使得 vector 与数组类似,可以通过指针进行遍历,并支持常数时间的随机访问。

验证地址连续性
#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec = {10, 20, 30, 40, 50};

    // 打印元素的地址
    for (size_t i = 0; i < vec.size(); ++i) {
        std::cout << "Address of vec[" << i << "]: " 
                  << &vec[i] << std::endl;
    }

    return 0;
}

解释

  • 在这段代码中,我们打印了每个 vector 元素的地址。由于 vector 保证了连续存储,所以每个元素的地址会按照固定的步幅增加。
  • 可以看到,第 i 个元素的地址和第 i+1 个元素的地址是相邻的,步幅等于每个元素的大小(对 int 类型来说,步幅为 sizeof(int))。

拷贝与移动:内存重新分配的开销

vector 需要重新分配内存时,现有的所有元素都会被拷贝到新的内存块。这意味着在元素数量较多的情况下,频繁的扩展会带来性能开销。因此,如果你知道即将向 vector 中添加大量元素,可以提前使用 reserve() 方法一次性分配足够的空间,减少内存重分配和元素拷贝的次数。

预分配内存避免扩展开销
#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec;
    vec.reserve(100);  // 预先分配足够的空间,避免频繁扩展

    // 添加 100 个元素
    for (int i = 0; i < 100; ++i) {
        vec.push_back(i);
    }

    std::cout << "Final size: " << vec.size() << std::endl;
    std::cout << "Final capacity: " << vec.capacity() << std::endl;

    return 0;
}

解释

  • 使用 reserve(100) 提前分配了足够的内存空间,可以避免在添加元素时多次扩展和重新分配内存,提升性能。

总结:

  • std::vector 通过动态数组实现元素的连续存储,当容量不足时,会自动扩展内存,并将现有元素拷贝到新的内存块。
  • 为了减少内存重新分配和数据拷贝的开销,可以通过 reserve() 预分配足够的内存空间,尤其在你预知需要存储大量数据时。
  • 由于 vector 元素连续存储,支持常数时间的随机访问,并且可以通过指针或下标直接遍历,这使得它在需要频繁访问和遍历的场景中非常高效。

3.当vector空间不足时,如何扩容?

std::vector 的容量不足以容纳新元素时,它会自动进行扩容。这个扩容过程涉及到重新分配内存、复制现有元素到新内存块,并释放旧内存块。这个过程是由 vector 的实现自动管理的,作为开发者,你不需要手动干预扩容,但理解其背后的机制可以帮助优化性能。

扩容机制概述

  • vector 添加新元素时,如果当前容量(capacity())不足,vector 会申请一块新的、更大的内存空间,通常是当前容量的两倍(不同编译器和实现可能有所差异)。
  • 然后,现有元素会被复制到新内存块中,旧的内存块会被释放。
  • 新增元素被放入新内存块的相应位置中。
  • 由于内存重新分配涉及到元素拷贝,因此扩容是一个相对昂贵的操作,尤其当 vector 存储大量数据时。

扩容示例

通过以下代码可以观察 vector 的容量扩展过程:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec;

    // 连续添加元素,观察 vector 的扩容情况
    for (int i = 0; i < 10; ++i) {
        vec.push_back(i);
        std::cout << "Added element: " << i 
                  << ", Size: " << vec.size() 
                  << ", Capacity: " << vec.capacity() << std::endl;
    }

    return 0;
}
输出示例:
Added element: 0, Size: 1, Capacity: 1
Added element: 1, Size: 2, Capacity: 2
Added element: 2, Size: 3, Capacity: 4
Added element: 3, Size: 4, Capacity: 4
Added element: 4, Size: 5, Capacity: 8
Added element: 5, Size: 6, Capacity: 8
Added element: 6, Size: 7, Capacity: 8
Added element: 7, Size: 8, Capacity: 8
Added element: 8, Size: 9, Capacity: 16
Added element: 9, Size: 10, Capacity: 16
解释:
  • 初始时,vector 的容量从 1 开始。当需要添加第二个元素时,容量扩展到 2。
  • 当需要添加第 3 个元素时,vector 扩容为 4,接着在第 5 个元素加入时扩展到 8。
  • 之后,当第 9 个元素被添加时,vector 扩展到 16。可以看到,容量基本上是以倍增的方式扩展。

扩容细节:常见的扩容策略

倍增策略
  • 大多数 std::vector 实现采用倍增策略来扩展容量。通常是两倍扩展,即每次重新分配的内存是当前容量的两倍。
  • 这种策略是为了避免频繁的扩容操作。频繁的扩容会导致大量的内存重新分配和元素拷贝,导致性能瓶颈。
不同实现的细节
  • 不同的 C++ 标准库实现可能采用略有不同的扩展策略。虽然倍增策略是最常见的,但某些库可能会根据情况采取不同的增长方式(如 1.5 倍扩展等)。

性能影响

扩容时的性能开销
  • 重新分配内存:扩容时,vector 会为所有现有元素分配一块新的、更大的连续内存块。这个过程是需要时间的。
  • 元素拷贝:扩容后,所有现有元素必须从旧内存块复制到新内存块。对于大对象或有复杂拷贝构造函数的对象,这一步可能非常耗时。
  • 释放旧内存:旧的内存块在元素复制完成后会被释放。

因此,当你需要频繁向 vector 添加大量元素时,提前分配足够的内存可以显著减少扩容带来的开销。

如何优化扩容性能

reserve() 提前分配内存

为了避免 vector 频繁扩容,可以通过 reserve() 提前分配足够的内存。这样,vector 在添加元素时就不必频繁重新分配和复制内存,从而提高性能。

使用 reserve() 优化扩容
#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec;
    vec.reserve(10);  // 提前分配足够的空间以容纳 10 个元素

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

    return 0;
}
输出示例:
Added element: 0, Size: 1, Capacity: 10
Added element: 1, Size: 2, Capacity: 10
Added element: 2, Size: 3, Capacity: 10
Added element: 3, Size: 4, Capacity: 10
Added element: 4, Size: 5, Capacity: 10
Added element: 5, Size: 6, Capacity: 10
Added element: 6, Size: 7, Capacity: 10
Added element: 7, Size: 8, Capacity: 10
Added element: 8, Size: 9, Capacity: 10
Added element: 9, Size: 10, Capacity: 10

解释

  • 通过 reserve(10),我们一次性为 vector 分配了足够的空间来存储 10 个元素,避免了扩容的发生。此时,无论添加多少元素,只要在容量范围内,vector 的容量保持不变,不会重新分配内存。

扩容与移动语义

在 C++11 及其之后的标准中,引入了移动语义,这对 vector 的扩容过程的性能提升有显著作用。当 vector 扩容时,如果元素支持移动操作(即支持移动构造函数),元素可以通过移动语义而不是拷贝语义进行传递,从而避免了不必要的拷贝。

移动语义在扩容中的应用
#include <iostream>
#include <vector>

class LargeObject {
public:
    LargeObject() {
        std::cout << "Constructed\n";
    }
    LargeObject(const LargeObject&) {
        std::cout << "Copy constructed\n";
    }
    LargeObject(LargeObject&&) noexcept {
        std::cout << "Move constructed\n";
    }
};

int main() {
    std::vector<LargeObject> vec;
    vec.reserve(5);

    for (int i = 0; i < 5; ++i) {
        vec.push_back(LargeObject());  // 利用移动构造避免拷贝
    }

    return 0;
}
输出示例:
Constructed
Move constructed
Constructed
Move constructed
Constructed
Move constructed
Constructed
Move constructed
Constructed
Move constructed

解释

  • LargeObject 支持移动构造时,在 vector 扩容时,元素会通过移动语义传递,避免了昂贵的拷贝操作,显著提升性能。

总结

  1. 扩容机制:当 vector 容量不足时,它会自动重新分配更大的连续内存空间,通常是当前容量的两倍,并将现有元素复制到新空间。
  2. 性能影响:扩容涉及内存重新分配和元素拷贝,可能会带来性能开销,尤其是在存储大量数据时。
  3. 优化扩容:可以使用 reserve() 提前分配足够的内存,减少扩容操作的频率。此外,利用 C++11 的移动语义,可以进一步优化扩容时的性能。

4.vector的push_back和emplace_back有什么区别?

std::vectorpush_back()emplace_back() 都是用于向 vector 的末尾添加元素的成员函数,但它们在执行添加元素的方式上有所不同,尤其在对象的构造、拷贝和移动操作方面有显著差异。

push_back()

push_back() 用于将一个现有的对象或值拷贝(或移动)到 vector 的末尾。也就是说,它要求你已经有一个对象,而不是直接在 vector 中构造新对象。

工作流程:
  • 如果传入的是临时对象或右值,C++ 会优先使用移动构造函数来将对象添加到 vector 中。
  • 如果传入的是左值,则会使用拷贝构造函数将对象复制到 vector 的末尾。
#include <iostream>
#include <vector>

class MyClass {
public:
    MyClass(int value) : value_(value) {
        std::cout << "Constructed with value: " << value_ << std::endl;
    }
    MyClass(const MyClass& other) {
        value_ = other.value_;
        std::cout << "Copy constructed" << std::endl;
    }
    MyClass(MyClass&& other) noexcept {
        value_ = other.value_;
        std::cout << "Move constructed" << std::endl;
    }
private:
    int value_;
};

int main() {
    std::vector<MyClass> vec;
    
    MyClass obj(10);        // 构造一个对象
    vec.push_back(obj);     // 通过拷贝构造将对象添加到 vector
    vec.push_back(MyClass(20)); // 通过移动构造将临时对象添加到 vector

    return 0;
}
输出:
Constructed with value: 10
Copy constructed
Constructed with value: 20
Move constructed

解释

  • 第一次调用 push_back(obj),由于传递的是一个已有对象(左值),vector 使用拷贝构造函数obj 复制到容器中。
  • 第二次调用 push_back(MyClass(20)),传递的是临时对象(右值),使用了移动构造函数来避免拷贝操作。

emplace_back()

emplace_back() 直接在 vector 的末尾原地构造对象,而不是像 push_back() 那样需要构造一个临时对象然后再拷贝或移动它。它接收构造对象所需的参数,并直接调用该对象的构造函数。

工作流程:
  • emplace_back() 接收对象构造函数的参数,直接在 vector 的内存位置上构造对象,而不是先创建一个临时对象再拷贝或移动它。
  • 它避免了临时对象的创建和拷贝,因此在某些情况下比 push_back() 更高效。
emplace_back()
#include <iostream>
#include <vector>

class MyClass {
public:
    MyClass(int value) : value_(value) {
        std::cout << "Constructed with value: " << value_ << std::endl;
    }
private:
    int value_;
};

int main() {
    std::vector<MyClass> vec;

    vec.emplace_back(30);  // 直接在 vector 中构造对象,无需创建临时对象

    return 0;
}
输出:
Constructed with value: 30

解释

  • emplace_back(30) 直接使用参数 30vector 的末尾调用 MyClass 的构造函数构造对象。这避免了临时对象的创建和移动/拷贝操作。

区别总结

1. 操作方式
  • push_back():需要一个已构造的对象或值(现有对象),然后将其拷贝或移动到 vector 的末尾。
  • emplace_back():直接在 vector 内存中构造对象,传递构造函数所需的参数,而不是拷贝或移动现有对象。
2. 性能差异
  • push_back() 可能涉及到临时对象的构造和随后的拷贝/移动操作,特别是在使用自定义类型的情况下,这可能带来性能开销。
  • emplace_back() 可以避免不必要的临时对象构造和拷贝/移动操作,因此在某些情况下更高效,特别是当对象构造较为复杂或拷贝代价较高时。
3. 使用场景
  • 如果你已经有一个现成的对象,或者只是添加基本数据类型,使用 push_back() 很合适。
  • 如果你需要直接构造对象,且不想多余的临时对象开销,emplace_back() 是更高效的选择。

 实例分析:push_back() vs emplace_back()

为了更直观地展示两者的差异,以下是一个完整的对比示例:

#include <iostream>
#include <vector>

class MyClass {
public:
    MyClass(int value) : value_(value) {
        std::cout << "Constructed with value: " << value_ << std::endl;
    }
    MyClass(const MyClass& other) {
        value_ = other.value_;
        std::cout << "Copy constructed" << std::endl;
    }
    MyClass(MyClass&& other) noexcept {
        value_ = other.value_;
        std::cout << "Move constructed" << std::endl;
    }
private:
    int value_;
};

int main() {
    std::vector<MyClass> vec;

    // 使用 push_back,需要先构造临时对象再拷贝或移动
    vec.push_back(MyClass(10));  // Move constructed

    // 使用 emplace_back,直接在 vector 中构造对象
    vec.emplace_back(20);  // Constructed with value: 20

    return 0;
}
输出:
Constructed with value: 10
Move constructed
Constructed with value: 20

解释

  • 对于 push_back()MyClass(10) 先创建了一个临时对象,然后通过移动构造函数将其移动到 vector 中。
  • 对于 emplace_back(),则直接在 vector 中调用 MyClass 的构造函数,无需临时对象的创建和移动操作。

使用建议

  • 当对象的构造、拷贝或移动操作代价较高时,优先考虑使用 emplace_back(),这样可以避免不必要的临时对象开销。
  • 如果你已经有了现成的对象,直接使用 push_back() 会更加直观。

5.使用Vector,需要注意哪些问题

在使用 std::vector 时,虽然它是一个功能强大且灵活的容器,但在特定情况下需要注意一些常见问题,以避免性能损失或错误行为。以下是使用 vector 需要注意的几个关键点:


容量 vs 大小 (capacity() vs size())

  • size() 表示 vector 中当前元素的个数,而 capacity() 表示 vector 分配的内存可以容纳的元素数量。当向 vector 中添加新元素时,如果 size() 超过了 capacity()vector 会触发扩容(重新分配更大的内存块并复制现有元素)。

  • 注意事项:频繁的扩容会导致性能开销(内存重新分配和元素拷贝)。如果你预先知道数据的大致数量,应该使用 reserve() 来避免频繁的扩容操作。


迭代器失效问题

  • 什么是迭代器失效:当 vector 扩容、删除元素或插入元素时,可能会导致原来的迭代器、指针或引用指向无效的内存地址,从而出现迭代器失效问题。

  • 扩容导致失效:当 vector 发生扩容时,所有指向 vector 元素的迭代器都会失效,因为底层内存位置发生了变化。

  • 删除元素导致失效:当删除 vector 中的某个元素时,指向被删除元素及其后续元素的迭代器将失效,因为这些元素的索引发生了变化。

  • 解决办法:当你使用迭代器并且有可能触发扩容或删除元素时,应该重新获取迭代器或使用下标访问来避免失效。

大数据量操作的性能

  • 虽然 vector 是非常高效的容器,但在处理非常大数据量的情况下,某些操作(如插入、删除)可能会带来显著的性能问题。

  • 插入和删除元素的复杂度push_back() 是在 vector 末尾添加元素,其时间复杂度是常数(O(1)),但在中间插入或删除元素时,可能需要移动后续的所有元素,导致线性时间复杂度(O(n))。

  • 解决办法:如果你需要频繁在 vector 中间插入或删除元素,可以考虑使用其他容器,如 std::liststd::deque,它们在插入和删除元素时效率更高。


元素的构造、拷贝与移动

  • 拷贝与移动:当 vector 发生扩容时,所有现有的元素都需要被拷贝或移动到新的内存位置。如果 vector 中的元素是复杂对象,这个过程会涉及到拷贝构造或移动构造操作,可能会带来性能开销。

  • emplace_back()push_back()push_back() 需要传递一个对象或临时对象,而 emplace_back() 可以直接在容器中构造对象,避免了不必要的拷贝或移动操作。对于复杂对象,emplace_back() 通常比 push_back() 更高效。


避免多余的动态内存分配

  • vector 底层依赖于动态内存分配。当频繁增加或删除元素时,如果不注意内存管理,可能会导致性能下降。因此,可以通过 reserve() 预先分配内存,避免每次增加元素时都进行重新分配。

  • shrink_to_fit():在移除大量元素后,vector 的容量可能会大于实际需要。此时可以使用 shrink_to_fit() 来释放多余的内存,不过它只是一个提示,并不保证一定会减少容量。

    #include <vector>
    #include <iostream>
    
    int main() {
        std::vector<int> vec(100, 1);  // 初始化包含 100 个元素的 vector
    
        std::cout << "Before erase, Capacity: " << vec.capacity() << std::endl;
    
        vec.erase(vec.begin(), vec.end() - 10);  // 删除大部分元素
    
        std::cout << "After erase, Capacity: " << vec.capacity() << std::endl;
    
        vec.shrink_to_fit();  // 提示释放多余内存
    
        std::cout << "After shrink_to_fit, Capacity: " << vec.capacity() << std::endl;
    
        return 0;
    }
    

使用reserve()避免不必要的扩容

当你知道 vector 需要存储的大致元素数量时,调用 reserve() 预先分配足够的内存空间是一个非常有效的优化策略。这样可以避免多次扩容带来的性能损耗。


总结

  1. 扩容的性能开销:频繁的动态扩容会导致性能问题,建议使用 reserve() 来预先分配足够的内存。
  2. 迭代器失效:扩容、插入或删除元素可能导致迭代器失效,特别是在 vector 扩容或修改结构时。
  3. 插入和删除性能push_back() 是高效的,但在中间插入或删除元素会导致元素移动,影响性能。
  4. 对象拷贝和移动:在存储复杂对象时,使用 emplace_back() 可以避免不必要的拷贝或移动操作,提升性能。
  5. 管理内存:合理使用 reserve()shrink_to_fit() 可以有效控制 vector 的内存占用,避免不必要的内存分配。

6.list和vector的区别

std::liststd::vector 都是 C++ 标准库中的容器,它们分别适合不同的应用场景。它们的区别主要在于底层数据结构、性能特性和使用场景。


底层数据结构

  • std::vector:底层是一个动态数组。所有元素都存储在一块连续的内存中,因此支持随机访问。当容量不足时,vector 会自动进行内存扩展,并将所有元素复制到新的内存块中。

  • std::list:底层是一个双向链表。每个元素都有一个指向下一个和上一个元素的指针,因此内存不是连续的。list 不支持随机访问,必须通过遍历找到指定的元素。

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

int main() {
    std::vector<int> vec = {1, 2, 3};
    std::list<int> lst = {1, 2, 3};

    std::cout << "Vector element access: " << vec[1] << std::endl;  // O(1), 支持随机访问
    auto it = lst.begin();
    std::advance(it, 1);
    std::cout << "List element access: " << *it << std::endl;  // O(n), 需要遍历找到元素

    return 0;
}

访问和修改性能

  • std::vector

    • 随机访问vector 支持常数时间复杂度的随机访问,[] 操作符可以直接访问任意位置的元素。时间复杂度为 O(1)
    • 插入/删除元素:在末尾插入或删除元素非常高效,时间复杂度为 O(1)。但是,在中间或开头插入/删除元素时,需要移动元素,时间复杂度为 O(n)
  • std::list

    • 随机访问:不支持随机访问,只能通过遍历访问元素,时间复杂度为 O(n)
    • 插入/删除元素:在任意位置插入或删除元素非常高效,时间复杂度为 O(1),只需要调整指针指向,不需要移动数据。
#include <vector>
#include <list>
#include <iostream>

int main() {
    std::vector<int> vec = {1, 2, 3};
    std::list<int> lst = {1, 2, 3};

    // 在 vector 的开头插入元素,性能较差 O(n)
    vec.insert(vec.begin(), 0);
    std::cout << "Vector after insert: ";
    for (int i : vec) {
        std::cout << i << " ";
    }
    std::cout << std::endl;

    // 在 list 的开头插入元素,性能较高 O(1)
    lst.insert(lst.begin(), 0);
    std::cout << "List after insert: ";
    for (int i : lst) {
        std::cout << i << " ";
    }
    std::cout << std::endl;

    return 0;
}

内存管理与空间开销

  • std::vector

    • vector 使用连续内存,因此空间利用率较高,适合需要频繁访问的场景。
    • 扩容时会重新分配内存,导致已有的元素被复制到新的位置,扩容时可能会带来性能开销。
    • 空间开销vector 只保存元素的值,没有额外的指针开销。
  • std::list

    • list 的元素分散存储在不同的内存块中,每个元素占用额外的空间来存储指向前一个和后一个元素的指针。
    • 空间开销:相比 vectorlist 由于每个元素都有两个指针(前向和后向),其空间开销更大。

典型使用场景

  • std::vector

    • 适合场景:频繁访问数据、查找、排序等操作非常适合使用 vector,因为它支持快速的随机访问。
    • 不适合场景:频繁在中间插入或删除元素时不适合,因为这会导致大量的数据移动。
  • std::list

    • 适合场景:需要频繁在任意位置进行插入和删除操作的场景非常适合使用 list
    • 不适合场景:不适用于需要随机访问和排序的场景,因为链表的随机访问性能很差。

算法复杂度对比

操作std::vector 性能std::list 性能
访问元素(随机访问)O(1)O(n)
在末尾插入/删除O(1)O(1)
在开头插入/删除O(n)O(1)
在中间插入/删除O(n)O(1)
遍历O(n)O(n)

总结

  • std::vector 适合于需要随机访问和频繁末尾操作的场景,尤其在数据量固定或不常改变的情况下表现非常优秀。
  • std::list 适合频繁进行插入和删除操作的场景,尤其是在数据量不断变化或在容器的中间频繁操作时效果更佳。

6.list如何实现元素的插入和删除?

在 C++ 的标准库容器 std::list 中,元素的插入和删除基于双向链表的结构实现。双向链表中的每个节点存储一个元素,同时有两个指针:一个指向前一个节点,另一个指向下一个节点。因此,在 list 中插入或删除元素不需要移动其他元素,只需调整指针即可。这使得插入和删除操作非常高效,时间复杂度为 O(1),无论是在链表的开头、中间还是末尾操作。

1. 插入操作的实现

std::list 提供了多种插入方式,最常见的是在某个位置之前插入元素。list 的插入操作不会移动任何已有的元素,只需要调整相关节点的指针。

  • 常用插入函数
    • push_back:在链表的尾部插入元素。
    • push_front:在链表的头部插入元素。
    • insert:在指定位置插入元素。
插入元素的基本原理:
  1. 创建一个新的节点。
  2. 将新节点的前后指针分别指向其前驱和后继节点。
  3. 更新前驱和后继节点的指针,使其指向新节点。
#include <list>
#include <iostream>

int main() {
    std::list<int> lst = {10, 20, 30};

    // 在开头插入元素
    lst.push_front(5);  // {5, 10, 20, 30}

    // 在尾部插入元素
    lst.push_back(40);  // {5, 10, 20, 30, 40}

    // 使用 insert 在指定位置插入元素
    auto it = lst.begin();
    std::advance(it, 2);  // 迭代器移动到第三个位置 (20 后)
    lst.insert(it, 15);   // {5, 10, 15, 20, 30, 40}

    // 打印结果
    for (const int &val : lst) {
        std::cout << val << " ";
    }
    std::cout << std::endl;

    return 0;
}
解释:
  • push_frontpush_back 分别在链表的头部和尾部插入元素。
  • insert 函数插入元素到指定位置,通过调整前后节点的指针来完成操作。

2. 删除操作的实现

std::list 同样支持高效的删除操作。因为是双向链表结构,删除某个节点只需要更新前后节点的指针即可,时间复杂度为 O(1)。删除元素不会导致其他元素的移动。

  • 常用删除函数
    • pop_back:删除链表的尾部元素。
    • pop_front:删除链表的头部元素。
    • erase:删除指定位置的元素。
删除元素的基本原理:
  1. 将待删除节点的前驱和后继节点的指针互相指向。
  2. 释放待删除节点的内存。
#include <list>
#include <iostream>

int main() {
    std::list<int> lst = {5, 10, 15, 20, 30, 40};

    // 删除头部元素
    lst.pop_front();  // {10, 15, 20, 30, 40}

    // 删除尾部元素
    lst.pop_back();   // {10, 15, 20, 30}

    // 使用 erase 删除指定位置的元素
    auto it = lst.begin();
    std::advance(it, 1);  // 迭代器移动到第二个位置 (15)
    lst.erase(it);        // {10, 20, 30}

    // 打印结果
    for (const int &val : lst) {
        std::cout << val << " ";
    }
    std::cout << std::endl;

    return 0;
}
解释:
  • pop_frontpop_back 分别删除链表的头部和尾部元素。
  • erase 函数删除指定位置的元素,通过更新其前驱和后继节点的指针来完成删除操作。

3. 时间复杂度分析

  • 插入和删除操作:在双向链表中插入或删除元素的复杂度为 O(1),因为不需要移动其他元素,只需调整前后节点的指针。
  • 遍历:在链表中查找某个元素或遍历链表的时间复杂度为 O(n),因为需要从头到尾依次访问每个元素。

4. 使用场景

  • 当需要在容器的任意位置进行频繁的插入或删除操作时,std::list 是合适的选择。例如,任务调度、事件处理等场景中,常需要在中间插入或删除数据。
  • 但如果你需要快速的随机访问,std::list 不是一个好的选择,因为它不支持常数时间的随机访问。

总结:

  • 插入和删除操作std::list 的插入和删除操作非常高效,适合需要频繁调整元素位置的应用场景。
  • 访问性能:由于链表的结构特点,std::list 不适合频繁进行随机访问的场景,但非常适合动态调整数据结构的场合,比如实现队列、双向队列或某些需要不断增删元素的场景。
;