很多同学刚啃完 C 语言的数组、结构体和动态内存分配,总会有个疑问:数组我已经会用了,为什么还要学顺序表?
其实顺序表就是数组的「升级版」—— 它在数组的基础上,封装了一套标准化的增删改查接口,还能自动扩容,让数据管理更规范、更易用。它也是我们接触的第一个线性数据结构,是后续栈、队列、链表的基础。
一、基础概念
1.1 什么是线性表?
线性表是n 个相同类型数据元素的有限序列,逻辑上是一条连续的直线。我们常说的顺序表、链表、栈、队列、字符串,本质都属于线性表。
线性表有两个特点:
- 逻辑上连续:元素之间是一对一的前后关系
- 物理上不一定连续:存储时可以用数组(连续),也可以用链式结构(不连续)
顺序表,就是用物理地址连续的存储单元依次存放数据的线性表,底层一般用数组实现。
1.2 顺序表和数组有什么区别?
你可以这么理解:
- 数组是「 原材料」,只能通过下标访问,没有任何封装
- 顺序表是「成品容器」,在数组之上封装了插入、删除、查找、自动扩容等全套功能
就像同样是西蓝花,数组是菜市场买回来的 原食材,顺序表是餐厅里做好的成品菜 —— 功能更完善、使用更规范。
二、顺序表的两种实现方式
顺序表分为静态和动态两种,核心差异在于存储空间是否可变。
2.1 静态顺序表
静态顺序表使用定长数组存储数据,空间大小在编译时就固定死了。
typedef int SLDataType;//给int类型起别名,方便后续更改要存储的数据类型
#define N 7 // 定长数组大小
typedef struct SeqList
{
SLDataType a[N]; // 定长数组
int size; // 有效数据个数
}SL;
缺陷:空间大小固定,给少了不够用,给多了造成浪费。只适合数据量完全确定的极特殊场景。
2.2 动态顺序表
动态顺序表使用动态开辟的数组存储,空间不够就自动扩容,真正做到「按需申请」,这也是我们学习和使用的重点。
typedef int SLDataType;
typedef struct SeqList
{
SLDataType* a; // 指向动态开辟的数组
int size; // 当前有效数据个数
int capacity; // 当前数组的最大容量
}SL;
三个成员的含义:
a:指针,指向堆上申请的数组空间size:现在已经存了多少个数据capacity:这块空间最多能存多少个,满了就要扩容
三、实现动态顺序
实现顺序表,就是把初始化、增删改查、销毁这些操作,一个个写成函数。
3.1 前置准备:头文件结构
我们一般把结构定义和函数声明放在 SeqList.h 中,函数实现放在 SeqList.c 中。
// SeqList.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLDataType;
#define INIT_CAPACITY 4 // 初始容量
// 动态顺序表结构
typedef struct SeqList
{
SLDataType* a;
int size;
int capacity;
}SL;
// 接口声明
void SLInit(SL* ps); // 初始化
void SLDestroy(SL* ps); // 销毁
void SLPrint(SL* ps); // 打印
void SLCheckCapacity(SL* ps); // 检查扩容
void SLPushBack(SL* ps, SLDataType x); // 尾插
void SLPopBack(SL* ps); // 尾删
void SLPushFront(SL* ps, SLDataType x); // 头插
void SLPopFront(SL* ps); // 头删
void SLInsert(SL* ps, int pos, SLDataType x); // 指定位置插入
void SLErase(SL* ps, int pos); // 指定位置删除
int SLFind(SL* ps, SLDataType x); // 查找元素
用
typedef int SLDataType重定义数据类型,以后想存字符、浮点数,只改这一行就行,非常灵活。
3.2 初始化与销毁
初始化
给顺序表赋初始值,初始可以开辟 4 个元素的空间。
void SLInit(SL* ps)
{
assert(ps); // 断言指针不为空,防止传NULL
ps->a = (SLDataType*)malloc(INIT_CAPACITY * sizeof(SLDataType));
if (ps->a == NULL)
{
perror("malloc fail");
exit(-1);
}
ps->size = 0;
ps->capacity = INIT_CAPACITY;
}
销毁
动态申请的空间必须手动释放,防止内存泄漏。
void SLDestroy(SL* ps)
{
assert(ps);
free(ps->a); // 释放数组空间
ps->a = NULL; // 置空指针,避免野指针
ps->size = 0;
ps->capacity = 0;
}
3.3 核心:自动扩容函数
所有插入操作前,都要先检查空间够不够,不够就扩容。
void SLCheckCapacity(SL* ps)
{
assert(ps);
// 有效数据等于容量,说明满了
if (ps->size == ps->capacity)
{
// 扩容为原来的2倍
int newCapacity = ps->capacity * 2;
SLDataType* tmp = (SLDataType*)realloc(ps->a, newCapacity * sizeof(SLDataType));
if (tmp == NULL)
{
perror("realloc fail");
exit(-1);
}
ps->a = tmp;
ps->capacity = newCapacity;
}
}
- 扩容策略一般按 2 倍增长,平衡扩容次数和空间浪费
- 用
realloc扩容,它会自动处理「原地扩」还是「异地扩」
3.4 尾部插入 & 删除
尾插:在数组末尾加一个元素
void SLPushBack(SL* ps, SLDataType x)
{
assert(ps);
SLCheckCapacity(ps); // 先检查扩容
ps->a[ps->size] = x;
ps->size++; //有效个数+1
}
- 时间复杂度:O(1),直接在末尾放数据,效率极高
尾删:删除数组最后一个元素
void SLPopBack(SL* ps)
{
assert(ps);
assert(ps->size > 0); // 空表不能删
ps->size--; // 直接让有效个数减1,相当于逻辑删除
}
- 时间复杂度:O(1)
3.5 头部插入 & 删除
头插:在数组开头加一个元素
头插是顺序表的痛点:必须把所有元素从后往前依次往后挪一位,腾出第一个位置。
void SLPushFront(SL* ps, SLDataType x)
{
assert(ps);
SLCheckCapacity(ps);
// 从最后一个元素开始,往后挪
for (int i = ps->size - 1; i >= 0; i--)
{
ps->a[i + 1] = ps->a[i];
}
ps->a[0] = x;
ps->size++;
}
- 注意:必须从后往前挪,从前往后会导致数据覆盖
- 时间复杂度:O(N),数据越多越慢
头删:删除数组第一个元素
把第二个元素开始,从前往后依次往前覆盖。
void SLPopFront(SL* ps)
{
assert(ps);
assert(ps->size > 0);
for (int i = 0; i < ps->size - 1; i++)
{
ps->a[i] = ps->a[i + 1];
}
ps->size--;
}
- 时间复杂度:O(N)
3.6 指定位置插入 & 删除
在 pos 下标之前插入元素
void SLInsert(SL* ps, int pos, SLDataType x)
{
assert(ps);
assert(pos >= 0 && pos <= ps->size); // pos范围合法
SLCheckCapacity(ps);
// pos及之后的元素往后挪
for (int i = ps->size - 1; i >= pos; i--)
{
ps->a[i + 1] = ps->a[i];
}
ps->a[pos] = x;
ps->size++;
}
有了这个通用插入函数,尾插和头插都可以复用它:
- 尾插:
SLInsert(ps, ps->size, x) - 头插:
SLInsert(ps, 0, x)
删除 pos 下标位置的元素
void SLErase(SL* ps, int pos)
{
assert(ps);
assert(pos >= 0 && pos < ps->size);
// pos之后的元素往前挪
for (int i = pos; i < ps->size - 1; i++)
{
ps->a[i] = ps->a[i + 1];
}
ps->size--;
}
同理,尾删和头删也可以复用:
- 尾删:
SLErase(ps, ps->size - 1) - 头删:
SLErase(ps, 0)
3.7 查找元素
遍历数组,找到返回对应下标,没找到返回 -1。
int SLFind(SL* ps, SLDataType x)
{
assert(ps);
for (int i = 0; i < ps->size; i++)
{
if (ps->a[i] == x)
{
return i;
}
}
return -1;
}
- 时间复杂度:O(N)
3.8 打印函数
方便调试测试用。
void SLPrint(SL* ps)
{
assert(ps);
for (int i = 0; i < ps->size; i++)
{
printf("%d ", ps->a[i]);
}
printf("\n");
}
五、顺序表优缺点
优点
- 支持随机访问:通过下标 O (1) 直接定位元素,适合频繁查询的场景
- 缓存命中率高:物理空间连续,CPU 缓存友好,读取速度快
缺点
- 头部 / 中间插入删除效率低:需要搬移大量元素,时间复杂度 O (N)
- 扩容有损耗:扩容需要申请新空间、拷贝数据、释放旧空间,有一定开销
- 存在空间浪费:按 2 倍扩容后,用不满的部分就浪费了

6531

被折叠的 条评论
为什么被折叠?



