顺序表是所有数据结构的入门第一课 ,也是最基础、最常用的线性数据结构。你每天都在用的 C++ std::vector、Java ArrayList、Python 列表,底层本质都是顺序表。很多看似复杂的高级数据结构(栈、队列、哈希表),最终都是基于顺序表实现的。
很多同学觉得顺序表简单,背几句定义就过去了,但一到面试就被问倒:vector 扩容为什么是 2 倍?插入元素的时间复杂度到底是多少?深拷贝和浅拷贝有什么区别?本文带你从零吃透顺序表的底层原理、完整实现、核心优缺点和面试高频考点,不仅让你会用,更让你懂为什么这么用。
一、先搞懂:什么是顺序表?
1.1 线性表的概念
在讲顺序表之前,我们先明确一个基础概念:线性表。
线性表是具有相同数据类型的 n 个数据元素的有限序列。它的特点是:
- 元素之间是一对一的线性关系
- 有且只有一个第一个元素(表头)和最后一个元素(表尾)
- 除了表头和表尾,每个元素都有且只有一个前驱和一个后继
常见的线性表有:顺序表、链表、栈、队列。
1.2 顺序表的定义和核心特性
顺序表是线性表的一种实现方式,它的本质是:用一段连续的内存空间 **,依次存储相同类型的数据元素 **。
你可以把顺序表想象成一排连续的储物柜:
- 每个柜子大小一样,只能放同一种东西
- 柜子是连续排列的,一个挨着一个
- 每个柜子都有一个唯一的编号(下标),从 0 开始
- 通过编号可以直接找到对应的柜子,不需要挨个找
顺序表最核心的特性,也是它最大的优势:支持随机访问 。也就是说,通过下标可以在 O(1) 时间复杂度内找到任意位置的元素,这是链表永远做不到的。
二、顺序表的底层实现(C++ 模板类完整实现)
为了彻底理解顺序表,我们来手写一个完整的动态顺序表模板类,包含所有核心功能。这个实现和 C++ std::vector 的底层原理几乎一模一样。
2.1 成员变量设计
一个动态顺序表需要三个核心成员变量:
cpp
template <typename T>
class SeqList {
private:
T* _data; // 指向动态分配的连续内存空间
size_t _size; // 当前顺序表中元素的个数
size_t _capacity; // 顺序表的最大容量(能容纳的元素个数)
};
_data:指针,指向我们在堆上申请的连续内存块的首地址_size:当前已经存储的元素数量,永远小于等于_capacity_capacity:内存块的总容量,当_size == _capacity时,就需要扩容
2.2 构造与析构(深拷贝是重点)
顺序表的构造和析构函数非常重要,这里最容易犯的错误是浅拷贝,会导致内存泄漏和重复释放的问题。
cpp
public:
// 默认构造函数:创建一个空的顺序表
SeqList() : _data(nullptr), _size(0), _capacity(0) {}
// 带初始容量的构造函数
explicit SeqList(size_t capacity) : _size(0), _capacity(capacity) {
_data = new T[_capacity]; // 在堆上申请连续内存
}
// 析构函数:释放堆上的内存
~SeqList() {
delete[] _data;
_data = nullptr;
_size = _capacity = 0;
}
// 拷贝构造函数(必须深拷贝!)
SeqList(const SeqList<T>& other) {
_size = other._size;
_capacity = other._capacity;
// 重新申请一块新的内存,而不是直接复制指针
_data = new T[_capacity];
// 逐个拷贝元素
for (size_t i = 0; i < _size; ++i) {
_data[i] = other._data[i];
}
}
// 赋值运算符重载(必须深拷贝!)
SeqList<T>& operator=(const SeqList<T>& other) {
if (this == &other) {
return *this; // 防止自赋值
}
// 先释放自己原来的内存
delete[] _data;
// 深拷贝
_size = other._size;
_capacity = other._capacity;
_data = new T[_capacity];
for (size_t i = 0; i < _size; ++i) {
_data[i] = other._data[i];
}
return *this;
}
**面试高频考点:为什么必须深拷贝?**如果不写拷贝构造函数,编译器会生成默认的浅拷贝函数,它只会直接复制指针的值。这样两个顺序表对象会指向同一块堆内存,当其中一个对象析构时,会释放这块内存,另一个对象就变成了野指针,再次析构时会导致程序崩溃。
2.3 核心操作:增删改查
1. 尾插元素(最常用)
在顺序表的末尾添加一个元素,这是顺序表效率最高的插入操作。
cpp
// 尾插元素
void push_back(const T& val) {
// 如果容量已满,先扩容
if (_size == _capacity) {
// 扩容:新容量是原来的2倍,如果原来为空则初始化为4
size_t new_capacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(new_capacity);
}
_data[_size] = val;
_size++;
}
2. 指定位置插入元素
在 pos 下标位置插入一个元素,需要把 pos 及之后的所有元素都向后移动一位。
cpp
// 在pos位置插入元素
void insert(size_t pos, const T& val) {
// 检查pos是否合法
if (pos > _size) {
throw std::out_of_range("插入位置非法");
}
// 容量不足则扩容
if (_size == _capacity) {
size_t new_capacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(new_capacity);
}
// 从后往前移动元素,空出pos位置
for (size_t i = _size; i > pos; --i) {
_data[i] = _data[i - 1];
}
// 插入新元素
_data[pos] = val;
_size++;
}
时间复杂度:O (n),因为最坏情况下需要移动所有元素。
3. 尾删元素
删除顺序表末尾的元素,效率最高。
cpp
// 尾删元素
void pop_back() {
if (_size == 0) {
throw std::runtime_error("顺序表为空,无法删除");
}
_size--; // 只需要把大小减1,不需要真正释放内存
}
4. 指定位置删除元素
删除 pos 下标位置的元素,需要把 pos 之后的所有元素都向前移动一位。
cpp
// 删除pos位置的元素
void erase(size_t pos) {
if (pos >= _size) {
throw std::out_of_range("删除位置非法");
}
// 从前往后移动元素,覆盖pos位置
for (size_t i = pos; i < _size - 1; ++i) {
_data[i] = _data[i + 1];
}
_size--;
}
时间复杂度:O (n),最坏情况下需要移动所有元素。
5. 随机访问元素
这是顺序表最大的优势,通过下标直接访问元素。
cpp
// 重载[]运算符,支持下标访问
T& operator[](size_t pos) {
if (pos >= _size) {
throw std::out_of_range("下标越界");
}
return _data[pos];
}
const T& operator[](size_t pos) const {
if (pos >= _size) {
throw std::out_of_range("下标越界");
}
return _data[pos];
}
时间复杂度:O (1),这是顺序表无可替代的优势。
2.4 动态扩容机制(核心重点)
动态顺序表的核心就是自动扩容。当现有容量不足以容纳新元素时,需要申请一块更大的连续内存,把原来的元素拷贝过去,然后释放原来的内存。
cpp
// 扩容到new_capacity
void reserve(size_t new_capacity) {
// 如果新容量小于等于当前容量,什么也不做
if (new_capacity <= _capacity) {
return;
}
// 1. 申请一块新的更大的连续内存
T* new_data = new T[new_capacity];
// 2. 把原来的元素拷贝到新内存
for (size_t i = 0; i < _size; ++i) {
new_data[i] = _data[i];
}
// 3. 释放原来的旧内存
delete[] _data;
// 4. 更新指针和容量
_data = new_data;
_capacity = new_capacity;
}
面试高频考点:为什么扩容倍数通常是 2 倍?
- 时间复杂度均摊:每次扩容都需要拷贝所有元素,时间复杂度是 O (n)。但如果每次扩容都扩大一倍,那么 n 个元素总共需要拷贝的次数是 n + n/2 + n/4 + ... + 1 ≈ 2n,均摊到每个元素上的时间复杂度是 O (1)。
- 内存碎片最少:2 倍扩容可以保证释放的旧内存块刚好能容纳下一次扩容前的所有元素,减少内存碎片。
- 不同编译器的差异 :GCC 下
vector是 2 倍扩容,VS 下是 1.5 倍扩容。1.5 倍扩容的好处是内存利用率更高,缺点是均摊时间复杂度稍高。
三、顺序表的核心优缺点(面试必背)
优点
- 随机访问 O (1):通过下标可以直接访问任意位置的元素,这是顺序表最大的优势,也是很多算法选择顺序表的根本原因。
- 缓存友好:由于内存是连续的,CPU 缓存可以预加载后续的元素,缓存命中率极高,访问速度非常快。
- 存储密度高:不需要额外的指针存储下一个元素的地址,空间利用率高。
- 实现简单:逻辑简单,容易理解和实现。
缺点
- 插入删除效率低:在中间或头部插入删除元素时,需要移动大量元素,时间复杂度是 O (n)。
- 扩容有开销:当容量不足时需要扩容,扩容过程中需要申请新内存、拷贝元素、释放旧内存,有一定的性能开销。
- 可能造成内存浪费:为了避免频繁扩容,通常会预留一部分空闲内存,这部分内存如果没有被使用,就会造成浪费。
四、实战:C++ std::vector 底层原理
你每天都在用的 std::vector,本质上就是一个封装得非常完善的动态顺序表。它的底层实现和我们上面写的顺序表几乎一模一样。
4.1 vector 的三个核心指针
std::vector 内部不是用 _size 和 _capacity 两个变量,而是用三个指针来表示:
bash
template <typename T>
class vector {
private:
T* _start; // 指向内存块的起始位置
T* _finish; // 指向最后一个有效元素的下一个位置
T* _end_of_storage; // 指向内存块的末尾位置
};
_size = _finish - _start_capacity = _end_of_storage - _start
这种实现方式比我们用两个变量的方式更简洁,计算大小和容量只需要指针相减即可。
4.2 push_back vs emplace_back
这是 C++11 引入的一个重要优化,也是面试高频考点:
push_back(const T& val):接收一个已经构造好的对象,然后把这个对象拷贝到 vector 中。emplace_back(Args&&... args):接收构造对象需要的参数,直接在 vector 的内存中原地构造对象,不需要拷贝。
结论 :emplace_back 比 push_back 更高效,尤其是对于自定义类型的对象,可以避免一次拷贝构造和析构的开销。在 C++11 及以后的代码中,优先使用 emplace_back。
五、高频面试题汇总
-
顺序表和链表的区别?
表格
特性 顺序表 链表 内存分布 连续 离散 随机访问 支持,O (1) 不支持,O (n) 插入删除 中间 / 头部 O (n),尾部 O (1) 任意位置 O (1)(已知前驱节点) 缓存友好 是,命中率高 否,命中率低 空间利用率 可能有浪费 按需分配,无浪费 -
为什么数组下标从 0 开始,而不是从 1 开始? 从底层计算角度看,下标是偏移量。第一个元素的地址是
_data + 0,第二个是_data + 1。如果从 1 开始,每次访问元素都需要多做一次减法运算_data + (i - 1),会有额外的性能开销。 -
**顺序表的尾插操作时间复杂度是多少?**均摊时间复杂度是 O (1)。虽然偶尔会遇到扩容需要 O (n) 的时间,但扩容的次数很少,均摊到每个元素上的时间复杂度是 O (1)。
-
如何实现一个循环顺序表? 循环顺序表通常用来实现队列,通过取模运算
(i + 1) % capacity来实现头尾相连,避免元素移动,让头部插入删除也能达到 O (1) 的时间复杂度。
六、总结
顺序表虽然是最简单的数据结构,但却是所有数据结构的基础。它的核心思想 ------用连续内存实现随机访问,深刻影响了后续几乎所有高级数据结构的设计。
学习顺序表的重点不是背代码,而是理解它的底层内存布局 和动态扩容机制。搞懂了顺序表,你再去学栈、队列、哈希表,就会发现它们的底层原理都是相通的。
建议你动手把本文中的顺序表代码完整写一遍,编译运行,测试每一个功能,这样你才能真正理解顺序表的每一个细节,面试时无论怎么问都能从容应对。