ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++顺序表实现:从动态数组到STL vector核心原理

2026/8/12 22:12:05 拓冰建站 浏览量
C++顺序表实现:从动态数组到STL vector核心原理 1. 项目概述为什么顺序表是数据结构的基石如果你刚开始学习数据结构或者正在准备C相关的面试那么“顺序表”这个概念你绝对绕不开。它听起来简单甚至有些“古老”但正是这种简单和直接构成了我们理解更复杂数据结构如链表、栈、队列的起点。我自己在带新人或者面试初级开发者时发现很多人对顺序表的理解停留在“一个数组”的层面这其实错过了它最精髓的设计思想和工程考量。简单来说顺序表就是用一段物理地址连续的存储单元依次存储线性表中的数据元素。在C中最直接的实现方式就是使用数组。但一个工业级的、健壮的顺序表实现远不止一个int arr[100]那么简单。它需要动态管理内存、在任意位置高效插入删除、自动扩容缩容、保证异常安全。这次我们就来亲手实现一个完整的、具备STLvector部分核心思想的顺序表并深入探讨每一个设计决策背后的“为什么”。2. 顺序表的核心设计与思路拆解2.1 物理连续性与随机访问优势顺序表的核心特征在于“物理连续性”。这意味着如果我们知道了第一个元素的内存地址基地址那么第i个元素的地址就可以通过一个简单的公式直接计算出来基地址 i * 每个元素的大小。这个特性带来了一个巨大的优势常数时间复杂度O(1)的随机访问。无论你想访问第1个还是第1000个元素计算地址的时间是固定的。注意这里的“随机访问”指的是按索引访问而不是随机数。这是顺序表与链表最本质的区别。链表需要从头开始遍历访问时间是O(n)。基于这个特性顺序表非常适合“读多写少”且需要频繁按位置访问的场景。比如存储一个已经排序好的学生名单需要经常按学号索引快速查找成绩或者作为其他数据结构的底层容器如栈、队列的数组实现。2.2 动态与静态之辨为何选择动态数组你可能会问直接用C风格数组T data[N]定义不行吗这就是静态顺序表。它有一个致命缺陷容量N必须在编译期确定一旦定义就无法改变。如果空间开小了数据装不下开大了又浪费内存。因此现代几乎所有的顺序表实现都采用动态顺序表。其核心思路是在堆Heap上申请一块动态内存作为存储空间并用一个指针指向它。同时我们维护两个关键变量_size: 当前已经存储的有效数据个数。_capacity: 当前动态数组的总容量。当_size即将达到_capacity时我们就执行“扩容”操作申请一块更大的新内存将旧数据拷贝过去释放旧内存并更新指针和容量。这样顺序表就能在运行时根据需要灵活调整大小。我们即将实现的正是这种动态顺序表。2.3 接口设计模仿STL培养良好习惯一个好的类设计接口应该清晰、简洁、符合直觉。我们将参考C标准模板库STL中vector的命名和风格来设计我们的SeqList类。这样做有两个好处一是让你的代码更专业二是能帮助你未来更好地理解和使用STL。主要接口包括构造与析构管理资源的生命周期。容量相关size(),capacity(),empty(),reserve(),resize()。元素访问operator[](重载下标运算符)front(),back()。这里会重点实现边界检查的版本和不检查的版本并讨论其取舍。修改操作push_back(),pop_back(),insert(),erase(),clear()。迭代器提供简单的指针迭代器以支持范围for循环。3. 核心细节解析与实操要点3.1 类的骨架与成员变量首先我们搭建出顺序表类的框架。我们将使用模板Template让这个顺序表能够存储任意类型T的数据而不仅仅是整数。template typename T class SeqList { private: T* _data; // 指向动态开辟数组的指针 size_t _size; // 当前有效数据个数 size_t _capacity; // 当前容量 // 一个内部使用的扩容函数 void _reallocate(size_t new_capacity); public: // 类型定义方便后续使用迭代器 typedef T* iterator; typedef const T* const_iterator; // 构造函数们 SeqList(); explicit SeqList(size_t n, const T val T()); // 填充构造 SeqList(const SeqListT other); // 拷贝构造 SeqListT operator(const SeqListT other); // 赋值运算符重载 // 析构函数 ~SeqList(); // 迭代器 iterator begin() { return _data; } iterator end() { return _data _size; } const_iterator begin() const { return _data; } const_iterator end() const { return _data _size; } // 容量操作 size_t size() const { return _size; } size_t capacity() const { return _capacity; } bool empty() const { return _size 0; } void reserve(size_t new_capacity); void resize(size_t new_size, const T val T()); // 元素访问 T operator[](size_t pos); const T operator[](size_t pos) const; T front() { return _data[0]; } T back() { return _data[_size - 1]; } // 修改操作 void push_back(const T val); void pop_back(); iterator insert(iterator pos, const T val); iterator erase(iterator pos); void clear(); };关键点解析使用size_t_size和_capacity永远不会是负数使用无符号类型size_t是更合适的选择也能避免一些隐式类型转换的警告。explicit关键字在第二个构造函数前加了explicit这是为了防止隐式类型转换。比如没有explicit的话SeqListint list 5;这种代码会被编译通过但它实际想表达的意思很模糊。加上explicit强制要求显式调用构造函数让代码意图更清晰。迭代器我们简单地用原生指针T*作为迭代器。这使我们的SeqList可以无缝使用C11的范围for循环for (const auto elem : myList) { ... }。3.2 深拷贝与浅拷贝资源管理的第一课这是实现动态顺序表乃至任何管理资源的C类时最容易出错和内存泄漏的地方。编译器默认生成的拷贝构造函数和赋值运算符执行的是“浅拷贝”成员逐一复制。对于指针_data浅拷贝只复制了指针值地址而不是指针指向的那块内存。这会导致两个对象指向同一块内存析构时会被释放两次造成程序崩溃。因此我们必须手动实现深拷贝。// 拷贝构造函数 template typename T SeqListT::SeqList(const SeqListT other) : _data(nullptr), _size(0), _capacity(0) { // 先为自己申请一块和other一样大的内存 _data new T[other._capacity]; // 可能抛出bad_alloc异常 // 将other的数据逐个拷贝过来 for (size_t i 0; i other._size; i) { _data[i] other._data[i]; // 调用T类型的赋值运算符 } _size other._size; _capacity other._capacity; } // 赋值运算符重载 (现代写法copy-and-swap) template typename T SeqListT SeqListT::operator(const SeqListT other) { if (this ! other) { // 防止自赋值: a a SeqListT temp(other); // 调用拷贝构造创建临时副本 // 交换当前对象和临时对象的内容 std::swap(_data, temp._data); std::swap(_size, temp._size); std::swap(_capacity, temp._capacity); } // 临时对象temp离开作用域析构掉旧的资源 return *this; }实操心得自赋值检查在赋值运算符中if (this ! other)这个检查非常重要。没有它在自赋值时delete[] _data会先释放自己的内存导致后续拷贝操作访问非法内存。Copy-and-Swap上面赋值运算符的实现是一种称为“拷贝-交换”的现代C idiom。它异常安全并且代码简洁。核心思想是先利用拷贝构造函数创建一个临时副本然后交换当前对象和副本的内容。函数结束时临时对象现在持有旧资源被析构自动完成清理。异常安全在拷贝构造函数中new可能会失败并抛出std::bad_alloc异常。我们的写法在new失败时_data仍然是nullptr_size和_capacity为0对象处于一个可安全析构的状态这是基本异常安全的保证。4. 实操过程与核心环节实现4.1 构造、析构与基础容量操作我们从最简单的开始确保资源的正确获取和释放。// 默认构造函数 template typename T SeqListT::SeqList() : _data(nullptr), _size(0), _capacity(0) {} // 填充构造函数构造一个包含n个val的列表 template typename T SeqListT::SeqList(size_t n, const T val) : _data(nullptr), _size(0), _capacity(0) { reserve(n); // 预分配空间 for (size_t i 0; i n; i) { push_back(val); // 利用push_back填充 } } // 析构函数 template typename T SeqListT::~SeqList() { if (_data) { delete[] _data; // 释放数组注意是delete[]而不是delete _data nullptr; _size _capacity 0; } } // reserve: 增加容量但不改变size template typename T void SeqListT::reserve(size_t new_capacity) { if (new_capacity _capacity) { _reallocate(new_capacity); } // 如果new_capacity _capacity, 标准库vector通常什么都不做我们也遵循这一行为。 } // resize: 改变size可能增/减元素 template typename T void SeqListT::resize(size_t new_size, const T val) { if (new_size _capacity) { // 需要扩容通常扩容到至少new_size这里采用2倍策略后续详解 _reallocate(std::max(new_size, _capacity * 2)); } if (new_size _size) { // 新增元素用val初始化 for (size_t i _size; i new_size; i) { _data[i] val; } } // 如果new_size _size则只是逻辑上减小size多余元素被“丢弃” _size new_size; }关键点解析delete[]vsdelete_data是通过new T[]分配的数组必须用delete[]来释放。用delete会导致未定义行为通常只调用第一个元素的析构函数内存泄漏。reservevsresize这是两个初学者容易混淆的函数。reserve(n)只保证容量至少为n不影响_size和现有元素。它是一个性能优化函数如果你知道要插入大量数据提前reserve可以避免多次扩容拷贝。resize(n, val)直接改变_size为n。如果n更大多出的位置用val填充如果n更小则多余的元素被逻辑上“移除”但内存可能还在。4.2 核心中的核心动态扩容策略_reallocate这是动态顺序表的性能关键。扩容是一个昂贵的操作申请新内存 拷贝所有旧元素 释放旧内存。频繁扩容比如每次push_back都扩1个会导致性能灾难。常见的策略是成倍扩容例如STLvector的常见实现是2倍或1.5倍。我们来实现这个内部函数template typename T void SeqListT::_reallocate(size_t new_capacity) { // 1. 申请新内存 T* new_data new T[new_capacity]; // 注意这里会调用T的默认构造函数吗对于POD类型是未初始化对于类类型会调用默认构造。这可能不是我们想要的。 // 2. 搬运数据 (更优做法使用std::move实现移动语义后文会讲) for (size_t i 0; i _size; i) { new_data[i] _data[i]; // 拷贝赋值 // 更好的做法new_data[i] std::move(_data[i]); } // 3. 释放旧内存 delete[] _data; // 4. 接管新资源 _data new_data; _capacity new_capacity; }这里有一个重大陷阱直接new T[new_capacity]对于非平凡类型non-trivial会调用new_capacity次默认构造函数然后再在拷贝时进行赋值操作。这造成了无谓的“构造赋值”开销特别是对于复杂对象。优化方案使用operator new和placement new进行内存分配与构造分离。这是更接近STLallocator的高级做法。但对于入门教学为了简化我们暂时使用上述方法并意识到这个性能问题。在后续“高级话题”部分我们会探讨优化方案。扩容倍数选择为什么是2倍时间复杂度摊还分析假设我们从1开始每次插入满后就扩容2倍。经过n次插入总拷贝次数大约是1 2 4 ... n/2 n。平均到每次插入操作其摊还时间复杂度是O(1)。这是一个非常重要的结论它意味着虽然单次扩容开销大但平均下来push_back仍然是常数时间。空间与时间的权衡2倍扩容能较快增长减少扩容次数但可能造成最多50%的空间浪费最后一次扩容后最多有一半空间闲置。1.5倍扩容空间利用率更高但扩容稍频繁。STL的实现通常选择一个介于1.5到2之间的因子。4.3 元素访问安全与效率的权衡我们提供了下标运算符operator[]。STL的vector提供了两个版本一个不检查边界为了效率一个检查边界at()成员函数会抛出std::out_of_range异常。我们也来实现这两个版本的思想。// 不检查边界的版本 (效率高但调用者需自己保证pos有效) template typename T T SeqListT::operator[](size_t pos) { // assert(pos _size); // 在Debug版本可以用断言 return _data[pos]; } template typename T const T SeqListT::operator[](size_t pos) const { // assert(pos _size); return _data[pos]; } // 我们可以模拟一个带边界检查的at函数 template typename T T SeqListT::at(size_t pos) { if (pos _size) { throw std::out_of_range(SeqList::at: pos (which is std::to_string(pos) ) _size (which is std::to_string(_size) )); } return _data[pos]; }实操心得在追求极致性能的代码中如循环内部使用不检查的operator[]。在不确定索引是否安全的场景使用at()利用C的异常机制来捕获错误。const重载注意我们为operator[]提供了const和非const两个版本。const对象只能调用const成员函数返回const引用防止修改对象内容。这是良好的const正确性实践。4.4 修改操作插入与删除的艺术push_back和pop_back相对简单它们只在尾部操作。template typename T void SeqListT::push_back(const T val) { // 检查容量 if (_size _capacity) { // 如果容量为0则扩容到1或一个初始值如4否则按策略扩容 size_t new_cap (_capacity 0) ? 4 : _capacity * 2; _reallocate(new_cap); } _data[_size] val; // 在尾部构造新元素 _size; } template typename T void SeqListT::pop_back() { if (!empty()) { --_size; // 注意这里不需要析构元素。因为_size减小了最后一个元素逻辑上已移除。 // 当T是类对象时如果希望立即调用析构函数可以_data[_size].~T(); } // 否则可以抛出异常或什么也不做STL的pop_back在空时是未定义行为 }**任意位置插入insert和删除erase**是顺序表的核心难点因为它们涉及到元素的移动。// 在迭代器pos位置前插入val template typename T typename SeqListT::iterator SeqListT::insert(iterator pos, const T val) { // 计算插入位置的索引 size_t index pos - begin(); // 边界判断允许在end()位置插入即尾部追加 if (index _size) { // 通常认为pos无效可以抛出异常或返回end()。这里简单处理在尾部插入。 index _size; } // 1. 检查容量 if (_size _capacity) { // 扩容注意扩容会导致_data指针改变原来的pos会失效 // 必须先计算索引扩容后根据索引重新计算迭代器位置。 size_t new_cap (_capacity 0) ? 4 : _capacity * 2; _reallocate(new_cap); } // 重新获取pos因为可能扩容了 iterator new_pos begin() index; // 2. 移动元素从后往前将[new_pos, end())的元素向后移动一位 // 使用std::move_backward可以优化 for (iterator it end(); it new_pos; --it) { *it std::move(*(it - 1)); // 移动赋值避免拷贝 } // 3. 在new_pos位置构造新元素 *new_pos val; // 或者使用placement new: new (new_pos) T(val); // 4. 更新大小 _size; // 5. 返回指向新插入元素的迭代器 return new_pos; } // 删除迭代器pos位置的元素 template typename T typename SeqListT::iterator SeqListT::erase(iterator pos) { if (pos begin() || pos end()) { return end(); // 无效位置返回尾后迭代器 } // 1. 移动元素从前往后将[pos1, end())的元素向前移动一位 // 使用std::move可以优化 for (iterator it pos; it end() - 1; it) { *it std::move(*(it 1)); } // 2. 更新大小 --_size; // 3. 返回指向被删除元素之后位置的迭代器 (STL规范) return pos; // 注意此时pos指向的是原来pos1位置的元素 }关键点与陷阱迭代器失效这是顺序表和vector操作中最需要注意的问题任何可能引起扩容的操作如insert,push_back都会使所有指向容器元素的迭代器、指针、引用失效。因为扩容后数据被搬到了新的内存地址。上面的代码中我们在扩容前计算了索引index扩容后根据索引重新计算了new_pos就是为了解决这个问题。移动语义代码中使用了std::move。在C11之后对于支持移动构造/移动赋值的类型如std::string,std::vectorstd::move可以将一个左值转换为右值引用从而触发移动操作避免深拷贝提升性能。我们的SeqList存储int等基本类型时移动和拷贝没区别但存储复杂对象时这个优化至关重要。时间复杂度insert和erase在平均和最坏情况下的时间复杂度都是O(n)因为可能需要移动大量元素。这是顺序表在中间位置插入删除的固有缺点。如果应用场景有大量此类操作链表可能是更好的选择。5. 常见问题与排查技巧实录在实际实现和使用顺序表时你会遇到各种各样的问题。下面是我总结的一些典型“坑”和解决思路。5.1 内存问题排查表问题现象可能原因排查与解决思路程序崩溃Segmentation fault1. 访问了_data为nullptr空表。2. 下标越界pos _size。3. 迭代器失效后继续使用。4. 浅拷贝导致双重释放。1. 在operator[]、front()、back()等函数中加入空表判断。2. 使用at()函数或在Debug版中用断言检查边界。3.牢记insert/push_back可能扩容后所有旧的迭代器都失效4. 务必实现拷贝构造和赋值运算符深拷贝。内存泄漏Memory Leak1. 析构函数未正确释放_data。2. 赋值运算符未释放旧内存。3._reallocate中申请新内存后释放旧内存前发生异常。1. 检查~SeqList()是否delete[] _data。2. 检查operator是否先释放this的旧资源或使用copy-and-swap。3. 确保_reallocate是异常安全的。可以先new成功后再delete旧内存。数据错乱或值不对1._size或_capacity更新逻辑错误。2. 插入/删除时元素移动的范围或方向错误。3. 自赋值问题导致数据被清空。1. 在每次修改_size/_capacity的地方打日志或调试。2. 画图用一个小数组如容量5已有元素[1,2,3]在纸上模拟insert和erase的每一步。3. 在operator中务必检查if (this ! other)。5.2 迭代器失效的实战案例这是最隐蔽的Bug之一。看这段代码SeqListint vec {1, 2, 3, 4}; auto it vec.begin() 2; // it指向3 vec.push_back(5); // 可能导致扩容 std::cout *it std::endl; // 危险it可能已经失效访问它是未定义行为。解决方案规则修改容器容量后假定所有迭代器都失效。实践如果需要保留位置不要保存迭代器而是保存索引int index it - vec.begin()。在扩容操作后用索引重新获取迭代器auto new_it vec.begin() index。5.3 关于new T[n]与默认构造的深入讨论前面提到_reallocate中new T[new_capacity]会调用T的默认构造函数。如果T没有默认构造函数或者我们不想默认构造因为紧接着就要用push_back的值覆盖这就成了问题。高级优化技巧使用::operator new和placement new这是一种“内存分配”与“对象构造”分离的技术也是STLallocator的基础。template typename T void SeqListT::_reallocate(size_t new_capacity) { // 1. 仅分配原始内存不构造对象 // void* operator new[](std::size_t count); 的用法 T* new_data static_castT*(::operator new(new_capacity * sizeof(T))); // 2. 将旧数据“移动”到新内存 (假设T有移动构造函数) for (size_t i 0; i _size; i) { // placement new: 在指定内存地址构造对象 new (new_data i) T(std::move(_data[i])); // 析构旧对象如果T有非平凡的析构函数 _data[i].~T(); } // 3. 释放旧内存注意是释放原始内存不是delete[] ::operator delete(_data); // 对应 ::operator new 的释放 // 如果_data是new T[]分配的这里应该是 delete[] _data; // 4. 更新指针和容量 _data new_data; _capacity new_capacity; }注意这个版本复杂得多需要处理异常安全如果new (new_data i) T(...)构造失败需要析构之前已构造的对象并释放内存并且要求T类型支持移动语义。对于初学者理解其思想即可第一版使用new T[]的实现更直观、更安全。5.4 测试你的顺序表实现完成后必须进行全面的测试。编写测试用例时要覆盖边界情况。void TestSeqList() { // 1. 基础功能 SeqListint list1; assert(list1.empty()); assert(list1.size() 0); // 2. push_back 和 访问 list1.push_back(1); list1.push_back(2); list1.push_back(3); assert(list1.size() 3); assert(list1[0] 1); assert(list1.front() 1); assert(list1.back() 3); // 3. 拷贝构造和赋值 SeqListint list2(list1); // 拷贝构造 assert(list2.size() 3); SeqListint list3; list3 list1; // 赋值 assert(list3.size() 3); // 4. 插入和删除 auto it list1.insert(list1.begin() 1, 99); // 在1和2之间插入99 assert(list1.size() 4); assert(list1[1] 99); assert(*it 99); it list1.erase(list1.begin() 2); // 删除元素2 assert(list1.size() 3); assert(list1[2] 3); // 现在[1, 99, 3] // 5. 扩容测试 SeqListint list4; for (int i 0; i 1000; i) { list4.push_back(i); } assert(list4.size() 1000); assert(list4.capacity() 1000); // 6. 范围for循环 (迭代器) int sum 0; for (const auto num : list4) { sum num; } // sum 01...999 499500 assert(sum 499500); std::cout All tests passed! std::endl; }通过自己动手实现一遍完整的顺序表你会对动态数组、内存管理、迭代器、算法复杂度等核心概念有刻骨铭心的理解。这远比只看书或调用现成的std::vector要收获大得多。当你再使用STL的vector时你会清楚地知道它底层在做什么性能开销在哪里该如何高效地使用它。这就是“造轮子”的意义。