一、前言
线性表是最基础的数据结构,分为顺序存储结构 (顺序表 ) 和链式存储结构 (单链表)。很多初学者刚接触时容易混淆两者的底层原理、增删查改效率以及适用场景。本文从底层实现、常用操作、性能对比、使用场景全方位梳理顺序表与单链表。
二、顺序表
2.1 存储原理
顺序表采用连续的内存空间存放数据元素,逻辑上相邻的元素,物理内存地址也相邻。 类似数组,但顺序表会额外记录有效元素长度、最大容量。
2.2 C 语言简易结构定义
cpp
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int ElemType;
typedef struct SeqList
{
ElemType* data; // 动态开辟数组
int size; // 当前有效数据个数
int capacity; // 总容量
} SeqList;
2.3 核心基本操作
- 初始化:动态分配内存,初始化 size 与 capacity
- 尾插:容量充足 O (1);容量不足需要扩容
- 头插 / 中间插入:后续元素整体后移,时间复杂度 O (n)
- 指定位置删除:后续元素向前覆盖,时间复杂度 O (n)
- 随机访问:支持下标直接访问
data[i],O(1)
2.4基本操作的实现
初始化 SeqListInit
初始 data 置空,size、capacity 为 0,此时没有分配堆内存。
cpp
void SeqListInit(SeqList* ps)
{
assert(ps);
ps->data = NULL;
ps->size = 0;
ps->capacity = 0;
}
扩容 SeqListExpand
当有效元素等于容量时扩容;初始默认开辟 4 个空间,后续二倍扩容(基本上在开发的过程中都是以2-3倍去扩容的)。
cpp
void SeqListExpand(SeqList* ps)
{
assert(ps);
if (ps->size == ps->capacity)
{
int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
ElemType* tmp = (ElemType*)realloc(ps->data, newCapacity * sizeof(ElemType));
if (tmp == NULL)
{
perror("realloc fail");
exit(1);
}
ps->data = tmp;
ps->capacity = newCapacity;
}
}
尾插 PushBack
容量足够直接尾部写入,
O(1);触发扩容时存在内存拷贝开销。
cpp
// 尾插
void SeqListPushBack(SeqList* ps, ElemType val)
{
assert(ps);
SeqListExpand(ps);
ps->data[ps->size] = val;
ps->size++;
}
头插 / 指定位置插入 PushFront / Insert
插入位置后面所有元素向后移动,时间复杂度
O(n)。
cpp
// 头插
void SeqListPushFront(SeqList* ps, ElemType val)
{
assert(ps);
SeqListExpand(ps);
// 元素整体向后移动一位
for (int i = ps->size; i > 0; i--)
{
ps->data[i] = ps->data[i - 1];
}
ps->data[0] = val;
ps->size++;
}
cpp
// 指定下标pos位置插入 [0 <= pos <= size]
void SeqListInsert(SeqList* ps, int pos, ElemType val)
{
assert(ps);
assert(pos >= 0 && pos <= ps->size);
SeqListExpand(ps);
// pos及之后元素后移
for (int i = ps->size; i > pos; i--)
{
ps->data[i] = ps->data[i - 1];
}
ps->data[pos] = val;
ps->size++;
}
随机访问 SeqListGet
依靠连续内存直接通过下标寻址
data[pos],O(1),这是顺序表最大优势。
cpp
// 随机访问:按下标获取元素
ElemType SeqListGet(SeqList* ps, int pos)
{
assert(ps);
assert(pos >= 0 && pos < ps->size);
return ps->data[pos];
}
指定位置删除 Erase :删除位置后续元素向前覆盖,时间复杂度
O(n)。
cpp
// 删除pos位置元素
void SeqListErase(SeqList* ps, int pos)
{
assert(ps);
assert(pos >= 0 && pos < ps->size);
// pos之后元素向前覆盖
for (int i = pos; i < ps->size - 1; i++)
{
ps->data[i] = ps->data[i + 1];
}
ps->size--;
}
释放堆内存Destroy
cpp
// 销毁顺序表,释放堆内存
void SeqListDestroy(SeqList* ps)
{
assert(ps);
free(ps->data);
ps->data = NULL;
ps->size = 0;
ps->capacity = 0;
}
2.4 优点/缺点
优点:
1.支持随机访问,查找指定下标元素极快
2.内存连续,缓存命中率高,访问速度快
3.结构简单,不需要额外存储指针开销
缺点:
1.中间 / 头部插入删除需要移动大量元素,效率低
2.需要预先分配空间,扩容存在空间浪费、拷贝开销
3.难以预估数据量时,容易出现容量不足或者空间闲置
三、单链表
3.1 存储原理
内存空间不要求连续 ,每个节点包含两部分:数据域 + 指向下一个节点的指针域。 元素依靠指针串联,逻辑相邻,物理地址不一定相邻。常用带头结点单链表简化边界操作。
3.2 C 语言节点定义
本文基于无头结点单链表实现所有基础操作,包含:节点创建、打印、头插/尾插、头删/尾删、查找、指定位置插入、指定位置删除等核心功能。所有代码均附带详细逻辑注解、时间复杂度说明,适配初学理解。首先定义单链表基础结构体,统一数据类型,方便代码复用:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLTDataType;
typedef struct SListNode {
SLTDataType data;
struct SListNode* next;
}SLTNode;
3.3 核心基本操作
- 创建新节点:动态 malloc 单个节点
- 头插:O (1),修改指针即可
- 尾插:不保存尾指针时需要遍历找到尾节点 O (n)
- 指定位置插入 / 删除:需要先找到前驱节点 O (n)
- 查找:只能从头遍历,不支持随机访问
拓展:如果频繁尾插,可以引入尾指针优化尾插为 O (1)
3.4基本操作的实现
1. 开辟新节点函数 SLTBuyNode
功能:动态在堆区申请内存,创建一个新的链表节点,初始化数据域、置空指针域,是所有增容操作的基础函数。
时间复杂度:O(1),单次内存开辟,无循环遍历
cpp
//开辟链表空间
SLTNode* SLTBuyNode(SLTDataType x)
{
SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
if (newnode == NULL)
{
perror("malloc newnode");
exit(1);
}
newnode->data = x;
newnode->next = NULL;
return newnode;
}
2. 链表尾插函数 SLTPushBack
功能:在链表末尾插入新节点,适配空链表、非空链表两种场景。
核心逻辑:空链表直接将新节点作为头节点;非空链表遍历找到尾节点,挂载新节点。
时间复杂度:O(n),需要遍历找到尾部节点
cpp
//尾插链表
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = SLTBuyNode(x);
//断言phead 不能为空指针
//断言* phead 可以是空指针
assert(pphead);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLTNode* ptail = *pphead;
while (ptail->next)//ptail != NULL
{
ptail = ptail->next;
}
ptail->next = newnode;
}
}
3. 链表头插函数 SLTPushFront
功能:在链表头部插入新节点,是链表效率最高的插入操作。
核心逻辑:新节点指针指向原头节点,再将头节点更新为新节点,无需遍历链表。
时间复杂度:O(1),仅修改指针,无遍历操作
cpp
//头插链表
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
SLTNode* newnode = SLTBuyNode(x);
SLTNode* ptail = *pphead;
newnode->next = ptail;
*pphead = newnode;
}
4. 链表尾删函数 SLTPopBack
功能:删除链表最后一个节点,处理单节点链表、多节点链表两种场景。
核心逻辑:单节点链表直接置空;多节点链表遍历找到倒数第二个节点,删除尾节点并置空尾部指针。
时间复杂度:O(n),需要遍历找到倒数第二个节点
cpp
//尾删链表
void SLTPopBack(SLTNode** pphead)
{
assert(pphead && *pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
SLTNode* ptail = *pphead;
SLTNode* pver = *pphead;
while (ptail->next)
{
pver = ptail;
ptail = ptail->next;
}
free(ptail);
ptail = NULL;
pver->next = NULL;
}
}
5. 链表头删函数 SLTPopFront
功能:删除链表第一个节点。
核心逻辑:保存原头节点的后继节点,释放原头节点,更新头节点。
时间复杂度:O(1),仅修改指针、释放内存,无遍历
cpp
//头删链表
void SLTPopFront(SLTNode** pphead)
{
assert(pphead && *pphead);
SLTNode* ptail = (*pphead)->next;
free(*pphead);
*pphead = ptail;
}
6. 链表查找函数 SLTFind
功能:根据数据值遍历链表,找到对应节点并返回节点地址,未找到返回空。可用于后续修改、删除指定节点。
时间复杂度:O(n),最坏情况遍历整个链表
cpp
//查找链表
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
assert(phead);
SLTNode* ptail = phead;
while (ptail)
{
if (ptail->data == x)
{
return ptail;
}
ptail = ptail->next;
}
return NULL;
}
7. 指定位置前插入节点 SLTPushInder
功能 :在指定节点 pos 之前插入新节点,适配头部插入、中间插入场景。
核心逻辑:pos为头节点等价于头插;其余情况遍历找到pos的前驱节点,修改指针完成插入。
时间复杂度:O(n),需要遍历查找前驱节点
cpp
//在指定位置之前插入链表
void SLTPushInder(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
assert(pphead&&*pphead);
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
if (*pphead == pos)
{
SLTNode* newnode = SLTBuyNode(x);
SLTNode* ptail = *pphead;
newnode->next = ptail;
*pphead = newnode;
}
else
{
SLTNode* prev = *pphead;
while (prev->next != pos)
{
prev = prev->next;
}
prev->next = newnode;
newnode->next = pos;
}
}
8. 指定位置后插入节点 SLTPushAfter
功能 :在指定节点 pos 之后插入新节点,无需遍历前驱,效率更高。
核心逻辑:先保存pos的后继节点,再串联pos→新节点→原后继节点。
时间复杂度:O(1),无需遍历,直接指针操作
cpp
//在指定位置之后插入链表
void SLTPushAfter(SLTNode* pos, SLTDataType x)
{
// 保证指定节点合法
assert(pos);
SLTNode* newnode = SLTBuyNode(x);
// 保存pos原本的后继节点
SLTNode* ptail = pos->next;
// pos指向新节点,新节点指向原后继节点,完成插入
pos->next = newnode;
newnode->next = ptail;
}
9. 删除指定位置节点 SLTDelNode
功能:删除传入的pos目标节点,处理头节点删除、普通节点删除两种场景。
时间复杂度:O(n),非头节点需要遍历查找前驱节点
cpp
//删除指定位置链表
void SLTDelNode(SLTNode** pphead, SLTNode* pos)
{
assert(pphead && *pphead);
assert(pos);
if (pos == *pphead)
{
SLTPopFront(pphead);
}
else
{
SLTNode* ptail = *pphead;
while (ptail->next != pos)
{
ptail = ptail->next;
}
ptail->next = pos->next;
free(pos);
pos = NULL;
}
}
10. 删除指定位置后一位节点 SLTDelBack
功能 :删除pos节点的后继节点,无需遍历前驱,操作高效。
核心逻辑:直接跳过后继节点,串联pos和后继的后继节点,释放被删除节点。
时间复杂度:O(1)
cpp
//删除指定位置后一位的链表
void SLTDelBack(SLTNode* pos)
{
assert(pos && pos->next);
SLTNode* pver = pos->next;
pos->next = pver->next;
free(pver);
pver = NULL;
}
11. 链表打印函数 SLPrint
功能:遍历整个单链表,打印所有节点数据,链表末尾拼接 NULL,直观展示链表结构。
时间复杂度:O(n),需要遍历全部节点
cpp
//打印链表
void SLPrint(SLTNode* phead)
{
SLTNode* pcur = phead;
while (pcur)//pcur != NULL
{
printf("%d->", pcur->data);
pcur = pcur->next;
}
printf("NULL\n");
}
12.链表的内存释放SLTDestroy功能 :循环遍历链表,逐个 free 所有节点,彻底释放堆上开辟的内存,避免内存泄漏。 时间复杂度:O(n),需要遍历全部节点。
cpp
//销毁链表,释放所有节点内存
void SLTDestroy(SLTNode** pphead)
{
assert(pphead);
SLTNode* pcur = *pphead;
while (pcur != NULL)
{
//保存下一个节点地址,防止free之后找不到后继
SLTNode* next = pcur->next;
free(pcur);
pcur = next;
}
//链表全部释放完毕,头指针置空,防止野指针
*pphead = NULL;
}
3.5优点/缺点
优点:
1.按需动态开辟节点,没有容量限制,不存在空间浪费
2.在已知前驱节点时,插入、删除仅修改指针,无需移动元素
缺点:
1.不支持随机访问,查找元素必须从头遍历 O (n)
2.每个节点额外存储指针,存在一定内存开销
3.内存分散,缓存友好性差
四、顺序表 VS 单链表 核心对比表
| 对比维度 | 顺序表 | 单链表 |
|---|---|---|
| 内存分布 | 连续空间 | 离散空间 |
| 随机访问 | 支持 O (1) | 不支持 O (n) |
| 头部 / 中间插入删除 | O (n)(移动元素) | O (n)(查找前驱;找到后修改指针) |
| 尾插 | 一般 O (1),扩容耗时 | 无尾指针 O (n),有尾指针 O (1) |
| 空间开销 | 小,仅存放数据 | 较大,额外存储指针 |
| 缓存效率 | 高(内存连续) | 低(节点分散) |
| 扩容 | 需要重新开辟空间 + 拷贝 | 无需整体扩容,单个节点申请 |
五、如何选择顺序表还是单链表?
选择顺序表
- 频繁根据下标访问元素、随机查找
- 查询多,插入删除较少,且大多在尾部操作
- 数据规模可以大致预估
典型场景:动态数组、栈、大多数缓冲区
选择单链表
- 需要频繁在任意位置插入、删除
- 数据量波动极大,无法提前预估大小
- 很少按下标随机访问
典型场景:哈希表链地址法、队列、邻接表
六、总结
顺序表胜在随机访问、高速遍历 ;单链表胜在灵活动态空间、任意位置修改(找到前驱后)。不存在绝对更优的数据结构,一切根据业务操作特征选择。掌握底层存储差异,才能理解时间复杂度差异的本质。