尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++顺序表实现:从数组到动态扩容,掌握数据结构核心原理

C++顺序表实现:从数组到动态扩容,掌握数据结构核心原理 1. 从“数组”到“顺序表”一个数据结构的诞生如果你写过C那你肯定用过数组。比如int arr[10];简单直接在内存里开辟一块连续空间然后通过下标arr[i]就能快速访问。这几乎是所有编程入门课的第一个数据结构。但用久了你会发现数组有个很要命的问题它的长度是固定的。你声明了int arr[10];那它这辈子就只能装10个整数。想装第11个对不起要么你重新声明一个更大的数组把老数据拷贝过去要么就等着程序崩溃。顺序表Sequential List就是为了解决这个“僵化”问题而生的。你可以把它理解为一个“会自己长大的数组”。它的底层依然是一块连续的内存空间这是它“顺序”二字的由来也是它高效随机访问的基石。但和原生数组不同顺序表封装了一套管理逻辑能够动态地调整这块内存的大小以适应数据量的变化。在C的标准模板库STL里std::vector就是一个功能极其强大的顺序表实现。那么为什么我们还要自己动手实现一个呢直接#include vector不香吗香当然香。但在学习的初期亲手从零搭建一个顺序表其价值远超学会调用一个API。这个过程会让你透彻理解内存管理的本质new和delete如何与操作系统交互内存是如何被分配和释放的。动态扩容的成本为什么vector::push_back操作有时是O(1)有时又是O(n)扩容策略如常见的2倍扩容背后的权衡是什么。数据结构的封装思想如何将数据数组指针、容量、大小和对数据的操作增删改查捆绑在一起形成一个独立的、安全的“黑盒”。异常安全的基础在内存分配失败、拷贝构造失败时如何保证你的数据结构不泄露资源、不破坏数据一致性。这篇文章就是带你用C从最原始的new int[10]开始一步步构建一个具备基本功能的顺序表。我们会像搭积木一样先搭出骨架结构定义再赋予它呼吸构造函数与析构函数最后教会它行走跑跳增删改查等操作。过程中我会穿插大量我在实际开发和教学中的踩坑经验比如为什么拷贝构造函数是必须的以及如何避免浅拷贝导致的“双杀”问题。2. 顺序表的核心骨架数据成员与内存布局一个顺序表无论功能多么复杂其内部最核心的东西就三样一块连续的内存、这块内存能装多少东西、以及当前已经装了多少东西。在C中我们用三个数据成员来表征它们。2.1 三个核心数据成员template typename T // 使用模板让我们的顺序表能存放任意类型 class SeqList { private: T* _data; // 指向动态分配数组的指针是顺序表的“心脏” size_t _capacity; // 当前顺序表的最大容量能装多少个元素 size_t _size; // 当前顺序表中实际存储的元素个数 // ... 后续的成员函数将围绕这三个核心数据展开 };_data (T*)这是一个指向类型T的指针。它指向的是我们在堆Heap上动态申请的一块连续内存的首地址。这块内存就是我们存放数据的地方。为什么用指针因为我们需要动态管理内存大小可以变化。为什么在堆上因为栈Stack空间通常很小且生命周期随函数结束而结束不适合存放可能很大的、生命周期独立的数据集合。_capacity (size_t)容量。它表示_data所指向的那块内存最多可以容纳多少个T类型的对象。注意_capacity是“物理”上的限制由我们申请的内存大小决定。_size (size_t)大小或长度。它表示当前顺序表中实际有效的数据元素有多少个。_size永远小于等于_capacity。_size是“逻辑”上的概念代表用户可见的数据量。2.2 内存布局可视化假设我们有一个SeqListint初始_capacity 4 我们插入了3个数字10 20 30。那么它在内存中的布局大致如下_data 指向的内存块 (堆上) 索引: [0] [1] [2] [3] 内容: 10 20 30 (未初始化/垃圾值) ^ ^ _size 3 _capacity 4用户通过我们的接口比如get(0)访问时他只能“看到”索引0到2共_size个的数据。索引3的位置虽然内存存在但对用户来说是“不可见”的因为它不属于有效数据范围。这种“已用”和“未用”的区分是顺序表管理的基础。一个至关重要的经验在几乎所有涉及索引的操作中如插入、删除、访问我们首先要检查索引是否在[0, _size)这个左闭右开区间内。试图访问_size或更大的索引就是访问了未初始化的内存会导致未定义行为Undefined Behavior, UB这是C/C程序中最常见的崩溃根源之一。所以在写任何data[i]之前先判断i _size应该成为你的肌肉记忆。3. 赋予生命构造、拷贝与析构有了骨架我们需要赋予这个类生命周期的管理能力。这主要靠构造函数、拷贝控制成员和析构函数来完成。3.1 构造函数从无到有一个顺序表在诞生时可以有多种状态。我们通常提供几种常见的构造函数。public: // 1. 默认构造函数创建一个空的顺序表 SeqList() : _data(nullptr), _capacity(0), _size(0) {} // 2. 指定初始容量的构造函数 explicit SeqList(size_t initCapacity) : _capacity(initCapacity), _size(0) { if (initCapacity 0) { _data new T[initCapacity]; // 在堆上分配 initCapacity 个 T 类型的空间 } else { _data nullptr; } } // 3. 从初始化列表构造 (C11) - 非常方便 SeqList(std::initializer_listT initList) : _capacity(initList.size()), _size(initList.size()) { if (_capacity 0) { _data new T[_capacity]; std::copy(initList.begin(), initList.end(), _data); // 将列表数据拷贝到_data } else { _data nullptr; } }关键点解析默认构造将指针置为nullptr容量和大小置为0。这是一个“空”的、零开销的状态。nullptr是现代C中表示“空指针”的首选方式。explicit关键字在第二个构造函数中我们使用了explicit。这是为了防止隐式类型转换。如果没有explicit写SeqList list 10;会被编译器解释为SeqList list(10);这可能不是程序员的本意。加上explicit后这种写法会报错必须显式地写SeqList list(10);。这是一种良好的实践能避免很多潜在的bug。初始化列表构造这是C11带来的语法糖允许你像写数组一样初始化顺序表SeqListint list {1, 2, 3, 4};。内部实现就是先分配足够内存再用std::copy进行拷贝。3.2 深拷贝与拷贝控制避免“双杀”这是实现顺序表乃至任何管理动态资源的类时最核心、最容易出错的部分。我们先看一个灾难场景void badExample() { SeqListint list1(5); // ... 向list1添加一些数据 SeqListint list2 list1; // 默认的拷贝构造函数是“浅拷贝” } // 函数结束list1和list2的析构函数被调用如果使用编译器为我们自动生成的默认拷贝构造函数它会进行“成员逐一拷贝”Member-wise Copy。对于指针_data这意味着list2._data list1._data。现在list1._data和list2._data指向了同一块堆内存。当函数结束时list2先析构它delete[]了那块内存。紧接着list1析构它试图delete[]同一块已经被释放的内存这会导致“重复释放”Double Free的严重错误程序几乎必然崩溃。这就是著名的“浅拷贝”问题。因此我们必须自己实现“深拷贝”Deep Copy不是拷贝指针而是拷贝指针所指向的内容。public: // 拷贝构造函数 SeqList(const SeqList other) : _capacity(other._capacity), _size(other._size) { if (_capacity 0 other._data ! nullptr) { _data new T[_capacity]; // 关键逐元素拷贝而不是拷贝指针 for (size_t i 0; i _size; i) { _data[i] other._data[i]; // 调用 T 类型的拷贝赋值运算符 } } else { _data nullptr; } } // 拷贝赋值运算符 SeqList operator(const SeqList other) { if (this ! other) { // 1. 自赋值检查防止 a a // 2. 释放当前资源 delete[] _data; // 3. 分配新资源并拷贝数据 _capacity other._capacity; _size other._size; if (_capacity 0 other._data ! nullptr) { _data new T[_capacity]; for (size_t i 0; i _size; i) { _data[i] other._data[i]; } } else { _data nullptr; } } return *this; // 4. 返回本对象的引用以支持链式赋值 a b c }拷贝赋值运算符的注意事项自赋值检查if (this ! other)至关重要。没有它在list1 list1;这样的自赋值操作中我们会先把自己的_data释放掉然后试图从一个已经被释放的内存区域other._data现在指向已释放内存拷贝数据后果不堪设想。异常安全上面的写法在new失败时会抛出std::bad_alloc异常此时_data已被释放对象状态被破坏_data是nullptr但_capacity和_size可能不是0。更健壮的写法是“拷贝并交换”Copy-and-Swap idiom但为了清晰我们先理解这个基础版本。3.3 析构函数善始善终析构函数的职责很简单释放构造函数中申请的资源。public: ~SeqList() { delete[] _data; // 释放动态数组 // 通常不需要将 _data 置为 nullptr因为对象即将销毁。 // 但在某些调试场景下置空可以防止悬空指针被误用。 _data nullptr; _capacity _size 0; }记住一个原则new和deletenew[]和delete[]必须配对使用。我们用new T[...]分配数组就必须用delete[] _data来释放。如果误用delete _data只会释放第一个元素的内存导致内存泄漏。4. 基础功能实现增、删、查、改有了生命周期的管理接下来就是实现顺序表的核心操作。这些操作都围绕着_data_size_capacity这三个变量进行。4.1 查与改随机访问的威力顺序表最大的优势就是常数时间O(1)的随机访问。因为内存是连续的要访问第i个元素计算机只需要计算基地址 i * 元素大小就能直接找到。public: // 获取下标为 index 的元素只读版本 const T get(size_t index) const { if (index _size) { // 边界检查 throw std::out_of_range(Index out of range in SeqList::get); } return _data[index]; } // 获取下标为 index 的元素可修改版本 T get(size_t index) { // 使用 const_cast 和静态转换避免代码重复 // 这是一种常见技巧非const版本调用const版本 return const_castT(static_castconst SeqList*(this)-get(index)); } // 重载 [] 运算符使其像数组一样使用更直观 const T operator[](size_t index) const { // 同样可以复用 get 函数或者直接实现边界检查 if (index _size) { throw std::out_of_range(Index out of range in SeqList::operator[]); } return _data[index]; } T operator[](size_t index) { return const_castT(static_castconst SeqList*(this)-operator[](index)); } // 修改指定位置的元素 void set(size_t index, const T value) { if (index _size) { throw std::out_of_range(Index out of range in SeqList::set); } _data[index] value; // 调用 T 类型的拷贝赋值运算符 }重要经验边界检查get,set,operator[]必须进行边界检查。在生产代码中抛出异常如std::out_of_range是比直接崩溃或静默失败更好的选择因为它给了调用者处理错误的机会。在性能极其关键的场景可能会提供at()带检查和operator[]不带检查信任调用者两个版本就像std::vector做的那样。const 重载注意我们为get和operator[]提供了const和非const两个版本。对于一个const SeqList对象你只能调用const版本它返回一个const T防止你修改数据这是C保证常量正确性的重要机制。4.2 动态扩容顺序表的“成长之痛”在尾部插入元素push_back是顺序表最常用的操作。如果_size _capacity直接放入空闲位置即可时间复杂度O(1)。但如果_size _capacity意味着空间已满就必须先扩容。private: // 内部扩容函数 void _reserve(size_t newCapacity) { if (newCapacity _capacity) { return; // 如果新容量不大于当前容量什么都不做 } // 1. 申请新的、更大的内存块 T* newData new T[newCapacity]; // 2. 将旧数据迁移到新内存拷贝构造 for (size_t i 0; i _size; i) { newData[i] std::move(_data[i]); // 使用移动语义提升效率C11 // 如果 T 不支持移动则 fallback 到拷贝赋值 newData[i] _data[i]; } // 3. 释放旧内存 delete[] _data; // 4. 更新指针和容量 _data newData; _capacity newCapacity; // _size 保持不变 } public: // 在尾部插入元素 void push_back(const T value) { // 检查是否需要扩容 if (_size _capacity) { // 计算新容量如果当前容量为0则扩容到1否则通常加倍2倍扩容策略 size_t newCap (_capacity 0) ? 1 : _capacity * 2; _reserve(newCap); } // 在 _size 位置放入新元素然后 _size 加 1 _data[_size] value; // 调用 T 的拷贝赋值运算符 _size; } // 支持移动语义的 push_back (C11)避免不必要的拷贝 void push_back(T value) { if (_size _capacity) { size_t newCap (_capacity 0) ? 1 : _capacity * 2; _reserve(newCap); } _data[_size] std::move(value); // 调用 T 的移动赋值运算符 _size; }扩容策略的深度剖析 为什么是2倍扩容而不是每次固定增加10个或100个这涉及到摊还分析Amortized Analysis。固定增量扩容如每次10假设从容量0开始插入N个元素。你需要大约N/10次扩容。每次扩容需要将旧数据拷贝到新位置第k次扩容需要拷贝10*k个元素。总的拷贝操作次数大约是10 20 30 ... (N/10)*10这是一个等差数列求和与N^2成正比。平均下来每次push_back的成本是O(N)这太慢了。几何倍数扩容如每次*2同样插入N个元素。扩容发生在容量为1, 2, 4, 8, ... 直到超过N。扩容次数大约是log2(N)。每次扩容的拷贝量就是当前的容量。总拷贝量大约是1 2 4 8 ... N/2这个等比数列的和小于2N。因此总的拷贝操作是O(N)级别的平摊Amortized到每次push_back操作上成本就是O(1)。这就是std::vector采用2倍或1.5倍如MSVC扩容的原因它保证了在绝大多数情况下尾部插入是高效的平均O(1)操作。一个实战中的大坑在_reserve函数中我们使用了new T[newCapacity]。这不仅仅分配了内存还会对从0到newCapacity-1的每一个位置调用T的默认构造函数如果T是类类型。然后我们又用newData[i] _data[i];进行赋值。这造成了先默认构造再拷贝赋值的开销。对于像int这样的内置类型编译器会优化掉。但对于复杂的类这可能很浪费。更优的做法是使用std::allocator或::operator new来分配“原始”内存然后用placement new在指定位置构造对象。但为了代码清晰和安全性我们这里使用更简单的new T[]在学习和大多数场景下已经足够。std::vector的实现就使用了Allocator来精细控制这个过程。4.3 插入与删除数据搬移的成本在顺序表中间插入或删除元素是它的劣势操作因为需要移动后续的所有元素。public: // 在指定位置 index 前插入一个元素 void insert(size_t index, const T value) { if (index _size) { // 注意允许在尾部插入(index _size) throw std::out_of_range(Index out of range in SeqList::insert); } // 1. 确保有足够空间 if (_size _capacity) { size_t newCap (_capacity 0) ? 1 : _capacity * 2; _reserve(newCap); } // 2. 搬移数据将 [index, _size) 区间的元素整体向后移动一位 // 必须从后向前移动避免覆盖未移动的数据 for (size_t i _size; i index; --i) { _data[i] std::move(_data[i - 1]); } // 3. 在空出的 index 位置放入新元素 _data[index] value; // 4. 更新大小 _size; } // 删除指定位置的元素 void erase(size_t index) { if (index _size) { throw std::out_of_range(Index out of range in SeqList::erase); } // 1. 搬移数据将 [index1, _size) 区间的元素整体向前移动一位 // 从前向后移动 for (size_t i index; i _size - 1; i) { _data[i] std::move(_data[i 1]); } // 2. 更新大小 --_size; // 注意对于最后一个元素index _size-1循环不会执行直接 --_size 即可。 // 我们并没有“清除”原来 _data[_size-1] 位置的数据它依然存在只是逻辑上不属于顺序表了。 // 如果 T 是类类型并且需要调用析构函数这里需要显式调用 _data[_size].~T()。 // 但通常我们依赖后续 push_back/insert 覆盖它或者依赖顺序表析构时整体 delete[]。 // 更严谨的做法是调用 _data[_size].~T()。 } // 从尾部删除元素高效 void pop_back() { if (_size 0) { --_size; // 同样可以显式调用析构 _data[_size].~T(); } // 如果 _size 0可以抛出异常或什么都不做。std::vector 的 pop_back 在空时是未定义行为。 }时间复杂度分析insert和erase在最坏情况下在头部插入/删除需要移动所有_size个元素时间复杂度为O(n)。平均情况也需要移动大约一半的元素仍然是 O(n)。pop_back只需要减少_size时间复杂度为O(1)。移动语义的运用注意在搬移数据时我们使用了std::move。如果类型T定义了移动赋值运算符T operator(T)那么_data[i] std::move(_data[i-1]);会调用移动赋值这通常比拷贝赋值需要深拷贝高效得多特别是对于管理资源的类如std::string,std::vector本身。如果T没有移动赋值则会 fallback 到拷贝赋值代码依然正确。这是C11后编写通用代码的重要技巧。5. 完善与优化迭代器、容量查询与内存管理一个完整的顺序表还需要一些辅助功能让它的接口更友好、更符合C标准库的约定。5.1 容量查询与调整public: // 获取当前元素数量 size_t size() const { return _size; } // 获取当前总容量 size_t capacity() const { return _capacity; } // 判断是否为空 bool empty() const { return _size 0; } // 调整容量通常只会增大 void reserve(size_t newCapacity) { _reserve(newCapacity); // 直接调用内部函数 } // 调整逻辑大小如果 newSize _size则丢弃尾部元素如果 newSize _capacity则扩容。 void resize(size_t newSize, const T value T()) { if (newSize _capacity) { _reserve(newSize); // 需要扩容 } if (newSize _size) { // 如果新大小更大用 value 填充新增的位置 for (size_t i _size; i newSize; i) { _data[i] value; // 在预留的空间上构造/赋值 } } // 如果 newSize _size只需修改 _size多余的元素逻辑上被“丢弃” _size newSize; } // 清空所有元素逻辑清空不释放内存 void clear() { // 如果 T 是需要析构的类型应该先析构所有元素 // for (size_t i 0; i _size; i) { // _data[i].~T(); // } _size 0; } // 释放未使用的内存缩容到刚好容纳当前元素 void shrink_to_fit() { if (_size _capacity) { // 只有实际需要缩容时才操作 if (_size 0) { delete[] _data; _data nullptr; _capacity 0; } else { T* newData new T[_size]; for (size_t i 0; i _size; i) { newData[i] std::move(_data[i]); } delete[] _data; _data newData; _capacity _size; } } }关于clear()和shrink_to_fit()clear()通常只将_size设为0这是一个O(1)操作。它并不释放内存_capacity不变这样如果后续马上又要添加元素可以复用已分配的内存避免重复分配的开销。这是std::vector::clear()的标准行为。shrink_to_fit()是一个“请求”它希望将容量减少到刚好等于_size。这是一个可能很耗时的操作需要分配新内存、搬移数据、释放旧内存。std::vector的shrink_to_fit()不保证容量一定会变为_size实现可以忽略这个请求。在我们的实现中我们强制进行了缩容。5.2 迭代器让顺序表融入C生态迭代器Iterator是C STL的核心概念之一它提供了一种统一的方法来遍历容器。为我们的顺序表实现迭代器可以让它兼容很多标准库算法如std::sort,std::find。最简单的方式是直接使用原生指针作为迭代器因为对于连续内存的容器指针的行为完全符合随机访问迭代器RandomAccessIterator的要求。public: // 迭代器类型别名让代码更清晰 using iterator T*; using const_iterator const T*; // 迭代器起始 iterator begin() { return _data; } const_iterator begin() const { return _data; } const_iterator cbegin() const { return _data; } // 迭代器末尾指向最后一个元素的下一个位置 iterator end() { return _data _size; } const_iterator end() const { return _data _size; } const_iterator cend() const { return _data _size; }有了这些你就可以像使用数组或std::vector一样使用范围for循环和算法SeqListint myList {5, 2, 8, 1, 9}; // 范围for循环 for (int num : myList) { std::cout num ; } std::cout std::endl; // 使用标准库算法排序 std::sort(myList.begin(), myList.end()); // 查找元素 auto it std::find(myList.begin(), myList.end(), 8); if (it ! myList.end()) { std::cout Found: *it std::endl; }实现迭代器的意义这不仅仅是语法糖。它意味着你的自定义容器现在可以无缝接入C庞大的标准算法库极大地提升了代码的通用性和可复用性。这是学习数据结构与STL设计理念结合的关键一步。6. 从零到一测试我们的顺序表理论说再多不如跑一遍。我们来写一个简单的测试程序验证我们实现的顺序表基本功能是否正确。#include iostream #include algorithm // for std::sort #include cassert // 假设我们的 SeqList 类定义在 SeqList.h 中 // #include SeqList.h void testSeqList() { std::cout 测试 SeqList std::endl; // 1. 测试默认构造和 push_back SeqListint list1; assert(list1.size() 0); assert(list1.empty()); list1.push_back(10); list1.push_back(20); list1.push_back(30); assert(list1.size() 3); assert(list1[0] 10); assert(list1.get(1) 20); // 2. 测试拷贝构造 SeqListint list2 list1; // 调用拷贝构造函数 assert(list2.size() 3); assert(list2[2] 30); // 修改 list2不应影响 list1 (深拷贝测试) list2[2] 99; assert(list1[2] 30); // list1 的第三个元素还是30 assert(list2[2] 99); // list2 的第三个元素是99 // 3. 测试拷贝赋值 SeqListint list3; list3 list1; // 调用拷贝赋值运算符 assert(list3.size() 3); list3.push_back(40); assert(list3.size() 4); assert(list1.size() 3); // list1 不应受影响 // 4. 测试插入和删除 list1.insert(1, 15); // 在索引120之前插入15 assert(list1.size() 4); assert(list1[0] 10); assert(list1[1] 15); assert(list1[2] 20); list1.erase(0); // 删除第一个元素10 assert(list1.size() 3); assert(list1[0] 15); // 5. 测试迭代器和算法 SeqListint list4 {33, 11, 55, 22, 44}; std::sort(list4.begin(), list4.end()); assert(list4[0] 11); assert(list4[4] 55); // 6. 测试容量管理 SeqListint list5; size_t prevCap list5.capacity(); for (int i 0; i 100; i) { list5.push_back(i); if (list5.capacity() ! prevCap) { std::cout Capacity changed from prevCap to list5.capacity() std::endl; prevCap list5.capacity(); } } assert(list5.size() 100); list5.shrink_to_fit(); assert(list5.capacity() list5.size()); // 容量应收缩到等于大小 std::cout 所有测试通过 std::endl; } int main() { testSeqList(); return 0; }运行这个测试程序观察扩容的日志输出你能直观地看到2倍扩容策略是如何工作的。如果所有断言assert都没有触发那么恭喜你一个基础但功能完整的顺序表已经成功实现了。7. 进阶思考与避坑指南自己实现一遍后再回头看std::vector你会理解它很多设计选择的深意。这里分享几个更深层次的思考点和常见陷阱1. 异常安全Exception Safety我们的实现版本在异常安全方面是脆弱的。考虑拷贝赋值运算符SeqList operator(const SeqList other) { delete[] _data; // 如果这里之后new 抛出了异常那么当前对象就处于无效状态_data 被删但新内存没拿到 _data new T[other._capacity]; // 可能抛出 std::bad_alloc // ... }更健壮的写法是“拷贝并交换”Copy-and-SwapSeqList operator(SeqList other) { // 注意这里参数是值传递会调用拷贝构造函数 swap(*this, other); // 交换当前对象和临时对象 other 的内容 return *this; } // 临时对象 other 在离开作用域时析构会释放掉旧的资源。这需要实现一个swap成员函数或友元函数它只交换三个成员变量是noexcept的。这种写法天然提供了强异常安全保证。2. 移动语义C11我们实现了拷贝构造和拷贝赋值但在C11以后还应该实现移动构造和移动赋值以支持高效地转移资源所有权避免不必要的深拷贝。// 移动构造函数 SeqList(SeqList other) noexcept : _data(other._data), _capacity(other._capacity), _size(other._size) { other._data nullptr; // 至关重要防止 other 析构时释放我们刚偷来的资源 other._capacity other._size 0; } // 移动赋值运算符 SeqList operator(SeqList other) noexcept { if (this ! other) { delete[] _data; // 释放自己的旧资源 _data other._data; _capacity other._capacity; _size other._size; other._data nullptr; other._capacity other._size 0; } return *this; }3. 元素类型的构造与析构我们的实现假设类型T是“平凡的”Trivial或者至少提供了正确的拷贝/移动控制。如果T的构造函数、赋值运算符或析构函数会抛出异常或者有特殊的资源管理需求我们的简单new T[]和delete[]可能不够。std::vector使用allocator来分离内存分配和对象构造提供了emplace_back等原地构造的接口这些都是为了更精细、更高效、更安全地管理元素生命周期。这是我们的简易实现与工业级库的主要差距之一。4. 迭代器失效这是使用顺序表以及std::vector时必须时刻警惕的问题。任何可能引起内存重新分配的操作如push_back导致扩容insert导致扩容都会使所有指向容器元素的指针、引用和迭代器失效。失效后继续使用它们会导致未定义行为。SeqListint vec {1, 2, 3}; int ref vec[0]; // ref 是对 vec[0] 的引用 auto it vec.begin(); // it 是指向 vec[0] 的迭代器 vec.push_back(4); // 假设这导致了扩容内存地址变了 // 危险ref 和 it 已经失效 // std::cout ref std::endl; // 可能崩溃或输出错误值 // std::cout *it std::endl; // 同样危险在编写涉及迭代器的循环时要特别注意在循环体内进行可能引起扩容的操作通常需要重新获取迭代器。自己动手实现一个顺序表就像亲手搭建了一座房子的地基。你知道了每一块砖内存是怎么来的每一根梁指针是如何承重的也知道了在什么情况下房子可能会垮迭代器失效、浅拷贝。有了这个扎实的基础你再去看std::vector的文档和源码就不再是记忆冰冷的API而是能理解其背后的设计哲学和性能权衡。这才是学习数据结构与算法乃至进阶C编程的正确路径。
返回列表