从 0 看懂顺序表与链表:数据结构入门的 “孪生兄弟”

系列文章目录

《数据结构》


前言

欢迎来到本专栏!对编程新手而言,C 语言是入门关键,而数据结构与算法是其核心逻辑 —— 不仅是计算机基础,更是提升代码效率、应对学习与面试的必备能力。​
但很多初学者常被 “概念抽象”“代码难写”“学了不会用” 困住,难以真正掌握。为此,本专栏从初学者视角出发,从基础讲起:讲数组、链表等数据结构时,先结合生活案例讲用途,再逐行带练 C 语言实现;讲排序、查找等算法时,拆解核心思想 + 真题案例,还配练习题巩固,确保 “学一个会一个”。​
无论你是刚学完 C 语言想进阶,还是学数据结构遇瓶颈,这里都能给你清晰的学习路径。接下来,让我们一起攻克重点,在编程中成长,期待与你共同进步!​


正文

今天这篇文章,我将为大家讲解数据结构中最基础的两个数据结构——顺序表和链表

线性表

在学习这两个数据结构之前,我们先了解一下什么是线性表

线性表(linear list)是n个具有相同特性的数据元素的有限序列。 线性表是⼀种在实际中⼴泛使 ⽤的数据结构,常⻅的线性表:顺序表链表队列字符串等等

线性表在逻辑上是线性结构,也就说是连续的⼀条直线。但是在物理结构上不⼀定是连续的, 线性表在物理上存储时,通常以数组链式结构的形式存储
在这里插入图片描述

顺序表

接下来我们来看第一种线性表——顺序表

概念与结构

概念:顺序表是⽤⼀段物理地址连续的存储单元依次存储数据元素的线性结构,⼀般情况下采⽤数组存储
在这里插入图片描述
那么我们就会有疑问,既然顺序表是用数组实现的

那顺序表和数组的区别是什么?

其实顺序表的底层结构数组,但是它对数组进行了封装,实现了常⽤的增删改查接⼝
在这里插入图片描述

我们可以拿这张图来形象的体现它俩的关系

分类

数组又有两种:
一种数组,我们在写代码的时候就给它指定大小,后续不能改变;
还有一种数组,我先不给它确定的大小,而是我用的时候再去动态申请空间
那么我们顺序表同样也是分为两种

静态顺序表

如果我们编译时就可以确定大小的,我们叫静态顺序表

概念:使⽤定⻓数组存储元素

我们看看静态顺序的结构

我们定义一个结构体,其中包含一个静态的数组a和一个有效数据个数
在这里插入图片描述
但是静态顺序表有个明显的缺点:空间给少了不够⽤,给多了造成空间浪费

动态顺序表

我们再来看看动态顺序表

如果我们运行时才能知道大小的,我们叫动态顺序表

我们先定义一个指针a,初始情况下,我们先malloc一个空间,后续我们想增容,可以再realloc

同样我们有一个表示有效数据个数的size,多了一个记录空间容量的capacity
在这里插入图片描述
不同的业务场景下,两种顺序表是各有千秋的,没有绝对的谁好谁不好

因为动态顺序更加灵活复杂一些,接下里我们直接带大家去实现一下动态顺序表

动态顺序表的实现

我们先创建三个文件
在这里插入图片描述
在.h文件中我们只做结构的定义和函数的声明
在.c文件中我们才进行方法的实现
在test.c文件中我们继续代码的测试

动态顺序表结构的定义
typedef int SLDataType;//方便后续对存储数据类型的修改

 typedef struct SqeList{
	SLDataType * arr;//存储数据
	int size;//有效元素个数
	int capacity;//空间容量

}SL;//直接给结构体取一个别名

这里我们主要可以进行typedef来优化代码

初始化函数
// 初始化函数声明
 void SLInit(SL* ps);

这里我们要接收一个地址,用来初始化我们创建的空间

//实现初始化函数
void SLInit(SL* ps)
{
	ps->arr = NULL;//先置空
	ps->capacity = ps->size = 0;
}

在没有数据的时候,我们把arr置空,空间和有效数据个数为0

增容函数

插入的时候可能会出现空间不够的情况
所以我们可以单独封装一个增容函数,来处理了空间不够的情况

 //扩容
 void SLCheckCapacity(SL* ps);

我们可以在没有空间的时候给4,后序按照*2增加,这是数学计算出防止空间浪费最好的增容办法

//定义检查空间函数
void SLCheckCapacity(SL* ps)
{
	int newCapacity = ps->capacity == 0 ? 4 : 2 * ps->capacity;
	//增容
	if (ps->size == ps->capacity)
	{
		SLDataType* tmp = realloc(ps->arr, newCapacity * sizeof(SLDataType));
		if (tmp == NULL)
		{
			perror("realloc fail!");
			exit(1);
		}
		ps->arr = tmp;
		ps->capacity = newCapacity;
	}
}

realloc给的空间是字节,所以我们需要按照我们数据类型个数进行增容
在动态开辟内存的时候,我们还需要检查返回值,处理开辟失败的情况

尾插函数

接下来我们实现向顺序表尾部去插入数据

//尾插
void SLPushBack(SL* ps, SLDataType x);
//实现尾插函数
void SLPushBack(SL* ps, SLDataType x)
{
	assert(ps);
	//空间不够
	SLCheckCapacity(ps);
	//空间足够
	ps->arr[(ps->size)++] = x;
}

防止传空地址过来,所以我们使用assert断言一下

接下来为了方便大家快速了解,我就直接给大家看函数实现的代码

头插函数

除了尾插,我们还有头插

//实现头插函数
void SLPushFront(SL* ps, SLDataType x)
{
	assert(ps);
	//空间不够
	 SLCheckCapacity(ps);
	//空间足够
	//已有数据整体向后移动一位
	 ps->size++;
	for (int i = ps->size; i > 0; i--)
	{
		ps->arr[i] = ps->arr[i - 1];
	}
	ps->arr[0] = x;
}

头插主要需要把数据向后整体挪移一位

尾删和头删

插入有了,我们接下来看删除

我们的删除,不一定是真的要在内存中删除这个数据

而是只要把顺序表的有效长度修改一下,后面再有数据,会对原来的空间进行覆盖

//尾删
void SLPopBack(SL* ps)
{
	assert(ps&& ps->size);
	ps->size--;
}
//头删
void SLPopFront(SL* ps)
{
	assert(ps && ps->size);
	//覆盖前一个数据
	for (int i = 0; i+1 < ps->size; i++)
	{
		ps -> arr[i] = ps->arr[i + 1];
	}
	ps->size--;
}
查找x
/查找函数
int SLFind(SL* ps, SLDataType x)
{
	assert(ps);
	for (int i = 0; i < ps->size; i++)
	{
		if (ps->arr[i] == x)
		{
			return i;
		}
	}
	return -1;
}

我们只需要遍历一遍顺序表,找到了返回下标,没找到返回-1

在指定位置之前插入
//指定位置之前插入函数实现
void SLInsert(SL* ps, int pos, SLDataType x)
{
	assert(ps);
	//pos>=0 pos<size
	assert(pos >= 0 && pos < ps->size);
	//空间不够
	SLCheckCapacity(&ps);
	//空间足够
	//pos及之后的数据集体向后移动
	for (int i = ps->size; i > pos; i--)
	{
		ps->arr[i] = ps->arr[i - 1]; 
	}
	ps->arr[pos] = x;
	ps->size++;
}

这个没什么好说的,和头插差不多,还有指定位置之后,大家可以自己实现一下

删除指定位置
//指定位置删除
void SLErase(SL* ps, int pos)
{
	assert(ps);
	assert(pos >= 0 && pos < ps->size);
	for (int i = pos; i + 1 < ps->size; i++)
	{
		ps->arr[i] = ps->arr[i + 1];
	}
	ps->size--;
}
销毁
//销毁
void SLDestroy(SL* ps)
{
	if (ps->arr)
	{
		free(ps->arr);
		ps->arr=NULL
	}
	ps->capacity = ps->size = 0;
}

因为我们是动态申请的空间,所以在最后要把动态申请的arr释放


小结

上面就是我们顺序表相关的一些接口的实现,在每个接口功能写完之后,我们可以在test.c文件中去测试一下

顺序表问题与思考

在实现顺序表的时候,我们会发现这些问题

中间/头部的插⼊删除,时间复杂度为O(N)

增容需要申请新空间,拷⻉数据,释放旧空间。会有不⼩的消耗

增容⼀般是呈2倍的增⻓,势必会有⼀定的空间浪费,例如当前容量为100,满了以后增容到200,

我们再继续插⼊了5个数据,后⾯没有数据插⼊了,那么就浪费了95个数据空间

那么我们如何解决以上问题呢

有没有一种数据结构,插入和删除的时间复杂度都是O(1);不需要增容没有空间浪费

有的兄弟有的
接下来我们看看链表


单链表

概念:链表是⼀种物理存储结构⾮连续⾮顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现
在这里插入图片描述
淡季时⻋次的⻋厢会相应减少,旺季时⻋次的⻋厢会额外增加⼏节。只需要将⽕⻋⾥的某节⻋厢去掉/加上,不会影响其他⻋厢,每节⻋厢都是独⽴存在的。

在链表⾥,每节“⻋厢”是什么样的呢?

在这里插入图片描述
与顺序表不同的是,链表⾥的每节"⻋厢"都是独⽴申请下来的空间,我们称之为“结点/结点

结点的组成主要有两个部分:当前结点要保存的数据和保存下⼀个结点的地址(指针变量)

图中指针变量 plist保存的是第⼀个结点的地址,我们称plist此时“指向”第⼀个结点,如果我们希望plist指向”第⼆个结点时,只需要修改plist保存的内容为0x0012FFA0

链表中每个结点都是独⽴申请的(即需要插⼊数据时才去申请⼀块结点的空间),我们需要通过指针变量来保存下⼀个结点位置才能从当前结点找到下⼀个结点

链表的性质

1、链式机构在逻辑上是连续的,在物理结构不⼀定连续
2、结点⼀般是从上申请的
3、从堆上申请来的空间,是按照⼀定策略分配出来的,每次申请的空间可能连续可能不连续

结合前⾯学到的结构体知识,我们可以给出每个结点对应的结构体代码:
假设当前保存的结点为整型

struct SListNode
{
int data; //结点数据
struct SListNode* next; //指针变量⽤保存下⼀个结点的地址
};

当我们想要保存⼀个整型数据时,实际是向操作系统申请了⼀块内存,这个内存不仅要保存整型数据,也需要保存下⼀个结点的地址(当下⼀个结点为空时保存的地址为空)

当我们想要从第⼀个结点⾛到最后⼀个结点时,只需要在当前结点拿上下⼀个结点的地址就可以了

链表的打印

给定的链表结构中,如何实现结点从头到尾的打印?
在这里插入图片描述
我们可以通过指针,不断去找结点,然后打印,在继续找下一个结点,打印,直到链表的结束

单链表的实现

接下来,我们还是来实现一下,单链表相关接口代码
在这里插入图片描述
同样的,创建这三个文件

单链表结构定义
typedef int SLTDataType;
//定义结构体
typedef struct SListNode
{
	SLTDataType data;//结点数据
	struct SListNode* next;//下一个结点的地址

}SLTNode;
结点创建
//新建节点
SLTNode* SLTBuyNode(SLTDataType x)
{
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	//经典防错
	if (newnode == NULL)
	{
		perror("malloc fail!");
		exit(1);
	}
	newnode->data = x;
	newnode->next = NULL;
	return newnode;
}

我们向内存申请一个结点大小的空间

尾插和头插
//尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x)
{
	//防止第一个节点为空
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);
	//如果是空链表
	if (*pphead == NULL)
	{
		*pphead = newnode;
	}
	else
	{
		SLTNode* ptail = *pphead;
		while (ptail->next != NULL)//寻找尾节点
		{
			ptail = ptail->next;
		}
		//找到尾节点
		ptail->next = newnode;
	}
}

//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x)
{
	assert(pphead);
	//新建节点
	SLTNode* newnode = SLTBuyNode(x);
	newnode->next = *pphead;
	*pphead = newnode;

}

因为我们的插入操作,需要修改头结点的内容,因此这里我们需要传递的是头结点指针的地址,需要用二级指针来接收

其次对于尾插来说,如果是空链表,我们需要把头结点的指向改为新结点,其他情况还是在结点后面进行插入

头删和尾删
//尾删
void SLTPopBack(SLTNode** pphead)
{
	//链表不为空
	assert(pphead && *pphead);
	//只有一个节点
	if ((*pphead)->next == NULL)
	{
		free(*pphead);
		*pphead = NULL;
	}
	else
	{
		//找最后一个节点
		SLTNode* ptail = *pphead;
		assert(ptail->next);
		//找最后一个节点的前一个节点
		SLTNode* prev = NULL;
		while (ptail->next)
		{
			prev = ptail;
			ptail = ptail->next;
		}
	
	//前一个节点置空
	prev->next = NULL;
		//找到最后一个节点了
		free(ptail);
		ptail = NULL;	
	}
}
//头删
void SLTPopFront(SLTNode** pphead)
{
	assert(pphead && *pphead);
		//存新头节点的地址
		SLTNode* next = (*pphead)->next;
	//释放原头节点空间
	free(*pphead);
	*pphead = next;
	

}

首先我们需要保证有数据,所以在assert进行条件的增加

和插入操作一样,涉及到头结点的改变,需要二级指针

最后就是对于动态申请的结点空间进行释放

查找
//查找
SLTNode* SLTFind(SLTNode* phead, SLTDataType x)
{
	SLTNode* pcur = phead;
	while (pcur)
	{
		if (pcur->data == x)
		{
			return pcur;
		}
		pcur = pcur->next;
	}
	//没有找到
	return NULL;
}

我们需要一个pcur指针来遍历链表

在指定位置前后进行插入
//指定位置之前插入
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
	assert(pphead && pos);
	SLTNode* newnode = SLTBuyNode(x);
	SLTNode* prev = *pphead;
	//如果在头节点之前插入,就是头插
	if (pos->next==NULL)
	{
		SLTPushFront(pphead, x);
	}
	else
	{
		//寻找pos前一个节点
		while ((prev->next) != pos)
		{
			prev = prev->next;
		}
		prev->next = newnode;
		newnode->next = pos;
	}
}
//指定位置之后插入
void SLTInsertAfter(SLTNode* pos, SLTDataType x)
{
	assert(pos);
	SLTNode* newnode = SLTBuyNode(x);
	//先连后断
	newnode->next = pos->next;
	pos->next = newnode;
}

我们插入的时候,一定要先保证新结点先连接上,在让原链表的结点断开重连,不然会找不到需要连接的结点

删除pos结点
//删除pos
void SLTErase(SLTNode** pphead, SLTNode* pos)
{
	assert(pphead && *pphead && pos);
	//pos刚好是头节点
	if (pos == *pphead)
	{
		//头删
		SLTPopFront(pphead);
	}
	else
	{
		//找pos前一个节点
		SLTNode* prev = *pphead;
		while (prev->next != pos)
		{
			prev = prev->next;
		}
		prev->next = pos->next;
		free(pos);
		pos = NULL;
	}
}

依旧注意使用二级指针

删除pos之后的结点
//删除pos之后的节点
void SLTEraseAfter(SLTNode* pos)
{
	assert(pos && pos->next);
	//先找pos之后的节点
	SLTNode* del = pos->next;
	pos->next = del->next;
	free(del);
	del = NULL;
}
销毁
//销毁链表
void SListDestroy(SLTNode** pphead)
{
	assert(pphead);
	SLTNode* pcur = *pphead;
	//从前往后依次删除
	while (pcur)
	{
		SLTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}
	*pphead = NULL;
}

从前往后,依次对申请的空间进行释放

小结

上面就是我们单链表相关的一些接口的实现,在每个接口功能写完之后,我们可以在test.c文件中去测试一下


双向链表

链表的分类

链表的结构⾮常多样,以下情况组合起来就有8种(2 x 2 x 2)链表结构:
在这里插入图片描述

虽然有这么多的链表的结构,但是我们实际中最常⽤还是两种结构:单链表双向带头循环链表

  1. ⽆头单向⾮循环链表:结构简单,⼀般不会单独⽤来存数据。实际中更多是作为其他数据结构的⼦结构,如哈希桶图的邻接表等等。另外这种结构在笔试⾯试中出现很多
  2. 带头双向循环链表:结构最复杂,⼀般⽤在单独存储数据。实际中使⽤的链表数据结构,都是带头双向循环链表
  3. 另外这个结构虽然结构复杂,但是使⽤代码实现以后会发现结构会带来很多优势,实现反⽽简单了,后⾯我们代码实现了就知道了

带头双向循环链表的概念与结构

在这里插入图片描述

注意:这⾥的“带头”跟前⾯我们说的“头结点”是两个概念,实际前⾯的在单链表阶段称呼不严谨,但是为了我们更好的理解就直接称为单链表的头结点

带头链表⾥的头结点,实际为“哨兵位”,哨兵位结点不存储任何有效元素,只是站在这⾥“放哨的

相比单链表结构,我们还需要创建一个指针,来指向上一个结点

带头双向循环链表的实现

结点结构的定义
typedef int LTDataType;
typedef struct ListNode {
	LTDataType val;//存的东西
	struct ListNode* next;//指向下一个结点
	struct ListNode* prev;//指向前一个结点
}LTN;
创建新结点
//创建新结点
LTN * LTBuyNode(LTDataType x)
{
	//创建新结点
	LTN* newNode = (LTN*)malloc(sizeof(LTN));
	//判断返回值
	if (newNode == NULL)
	{
		perror("malloc fail:");
		exit(1);
	}
	newNode->val = x;
	//新结点的前后指针都先指向自己
	newNode->next = newNode;
	newNode->prev = newNode;
}

这里对于每个结点,我们都先让它的两个指针指向自己

初始化
//初始化
void LTInit(LTN** pphead)
{
	*pphead = LTBuyNode(-1);
}

对于哨兵位结点,我们要对它进行修改,和之前的单链表要修改头结点一样,需要使用二级指针,对于它的值。我们不需要特别设置

尾插和头插
//尾插
void LTPushBack(LTN* phead, LTDataType x)
{
assert(phead);
	//新建结点
	LTN* newNode = LTBuyNode(x);
		//先让newNode连接前后
	newNode->prev = phead->prev;
	newNode->next = phead;
	//修改尾结点
	phead->prev->next = newNode;
	//修改头结点
	phead->prev = newNode;
}
//头插
void LTPushFront(LTN* phead, LTDataType x)
{
	assert(phead);
	LTN* newNode = LTBuyNode(x);
	newNode->next = phead->next;
	newNode->prev = phead;
	phead->next->prev = newNode;
	phead->next = newNode;
}

对于双向循环链表的插入,我们还是一样的思想,先保证让新结点连接到相应的位置,之后我们再修改受到影响的结点,这里我们因为是双向循环链表,所以我们找到前后结点的方式有许多

但是需要注意,有些结点修改后指向的改变

判空
//判断链表是否为空
bool LTEmpty(LTN* phead)
{
	assert(phead);
	return (phead->next == phead);
}
尾删和头删
//尾删
void LTPopBack(LTN* phead)
{
	//链表不为空且哨兵位不为空
	assert(!(LTEmpty(phead)));
	//先把尾结点先存起来
	LTN* del = phead -> prev;
	del->prev->next = phead;
	phead->prev = del->prev;
	free(del);
	del = NULL;
}
//头删
void LTPopFront(LTN* phead)
{
	assert(!(LTEmpty(phead)));
	//先存一下要删除的结点
	LTN* del = phead->next;
	phead->next = del->next;
	del->next->prev = phead;
	free(del);
	del = NULL;
}

删除要注意都是差不多的,需要先存一下删除结点,防止释放后找不到结点连接

按值查找
// 按值查找结点
LTN* LTFind(LTN* phead, LTDataType x)
{
	assert(phead);
	LTN* pcur = phead->next;
	while (pcur != phead)
	{
		if (pcur->val == x)//找到了
			return pcur;
		pcur = pcur->next;
	}
	return NULL;
}
在pos之后插入
//在pos位置之后插入结点
void LTInsert(LTN* pos, LTDataType x)
{
	assert(pos);
	LTN* newNode = LTBuyNode(x);

	newNode->next = pos->next;
	newNode->prev = pos;

	pos->next->prev = newNode;
	pos->next = newNode;
}
删除pos结点
//删除pos位置的结点
void LTErase(LTN* pos)
{
	assert(pos);
	pos->prev->next = pos->next;
	pos->next->prev = pos->prev;
	free(pos);
	pos = NULL;

销毁
//销毁链表1
void LTDesTroy(LTN** pphead)
{
	LTN* pcur = (*pphead)->next;
	while (pcur != *pphead)
	{
		LTN* next = pcur->next;
		free(pcur);
		pcur = next;
    }
	free(*pphead);
	*pphead = NULL;
}

要对哨兵位进行释放,使用二级指针,依次释放空间

小结

上面就是我们双向循环链表相关的一些接口的实现,在每个接口功能写完之后,我们可以在test.c文件中去测试一下

接口统一化

上面我们实现的接口,会发现有时候需要使用二级指针,但是我们在使用的时候还需要特地去记,什么时候传指针的地址,什么时候传指针,所以我们的初始化销毁函数还可以用传指针的方式来实现

优化初始化
LTN* LTInit()
{
	return LTBuyNode(-1);
 }

直接返回一个初始化指针

优化销毁函数
//销毁链表2,接口一致性
void LTDesTroy(LTN* phead)
{
		LTN* pcur = phead->next;
	while (pcur != phead)
	{
		LTN* next = pcur->next;
		free(pcur);
		pcur = next;
    }
	free(phead);
	phead = NULL;
}

这样销毁,我们需要在调用完之后,手动把哨兵位置空


总结

顺序表链表作为数据结构的基础,核心差异源于存储方式 —— 顺序表依托连续内存实现快速随机访问,却受限于扩容成本与内存浪费;链表靠离散节点和指针实现灵活增删,却需通过遍历降低了访问效率。二者无绝对优劣,实际应用中,只需根据 “高频查询用顺序表、高频增删用链表” 的核心逻辑,结合场景需求权衡性能,就能精准选对工具,为后续复杂数据结构的学习打下扎实基础

接下来,我将为大家带来栈和队列相关的文章,希望大家可以多多支持,有什么问题可以评论留言或者后台私信,感谢大家

相关练习请关注《Leetcode&nowcode代码强化刷题》

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值