数据结构|顺序表与单链表全面对比分析(原理、操作、优缺点、场景选择)

一、前言

线性表是最基础的数据结构,分为顺序存储结构顺序表 ) 和链式存储结构单链表)。很多初学者刚接触时容易混淆两者的底层原理、增删查改效率以及适用场景。本文从底层实现、常用操作、性能对比、使用场景全方位梳理顺序表与单链表。

二、顺序表

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 核心基本操作

  1. 创建新节点:动态 malloc 单个节点
  2. 头插:O (1),修改指针即可
  3. 尾插:不保存尾指针时需要遍历找到尾节点 O (n)
  4. 指定位置插入 / 删除:需要先找到前驱节点 O (n)
  5. 查找:只能从头遍历,不支持随机访问

拓展:如果频繁尾插,可以引入尾指针优化尾插为 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)
空间开销 小,仅存放数据 较大,额外存储指针
缓存效率 高(内存连续) 低(节点分散)
扩容 需要重新开辟空间 + 拷贝 无需整体扩容,单个节点申请

五、如何选择顺序表还是单链表?

选择顺序表

  1. 频繁根据下标访问元素、随机查找
  2. 查询多,插入删除较少,且大多在尾部操作
  3. 数据规模可以大致预估

典型场景:动态数组、栈、大多数缓冲区

选择单链表

  1. 需要频繁在任意位置插入、删除
  2. 数据量波动极大,无法提前预估大小
  3. 很少按下标随机访问

典型场景:哈希表链地址法、队列、邻接表

六、总结

顺序表胜在随机访问、高速遍历 ;单链表胜在灵活动态空间、任意位置修改(找到前驱后)。不存在绝对更优的数据结构,一切根据业务操作特征选择。掌握底层存储差异,才能理解时间复杂度差异的本质。

相关推荐
青山木13 小时前
Hot 100 --- 数组中的第K个最大元素
java·数据结构·算法·排序算法
wuyk55514 小时前
12.归并排序:分治思想的稳定排序算法
开发语言·数据结构·算法·排序算法
Chester_199917 小时前
CSP202312C.树上搜索
开发语言·数据结构·c++·蓝桥杯·stl
专注仿真19 小时前
RabbitMQ消息队列实战项目
数据结构·rabbitmq
血小板要健康19 小时前
队列 + 宽搜(BFS):二叉树层序遍历 算法总结
java·数据结构·笔记·算法·leetcode·宽度优先
Felven20 小时前
B. Deja Vu
数据结构·算法
不会就选b21 小时前
算法日常・每日刷题--<贪心>3
数据结构·算法·leetcode
ysu_031421 小时前
03-双链表与循环链表
c语言·数据结构·链表
wabs6661 天前
关于二叉树【力扣107.二叉树的层序遍历II的思考】
数据结构·c++·算法·leetcode·二叉树·层序遍历
小七在进步1 天前
数据结构:非比较排序:计数排序
数据结构