【(C语言)数据结构奋斗100天】顺序表和链表

前言

🏠个人主页:泡泡牛奶

🌵系列专栏:[C语言] 数据结构奋斗100天

本期所介绍的线性表包括顺序表和链表,那么线性表优势什么一回事呢?就让我们带着以下问题,了解基本数据结构吧( •̀ ω •́ )✧

  1. 什么是线性表?
  2. 线性表有哪些?
  3. 什么是顺序表?
  4. 链表的物理结构和逻辑结构是什么样的?

一、线性表

1. 线性表定义

​ 线性表就是有n个具有 相同性质的数据元素 组成的有限序列,线性表是一种在实际生活钟广泛运用的数据结构。常见的线性结构有:顺序表、链表、栈、队列、散列表

2. 线性表的逻辑结构

​ 线性表在逻辑上是线性结构,也可以说是连续的一条直线。但经常在物理结构上并不一定是连续的,线性表子啊物理上存储时,通常以数组或者链式结构让它理论上是一条直线。

二、顺序表

​ 现在我们知道了什么是线性表,那么在你的记忆中哪种结构最贴合线性表的逻辑呢?

​ 没错,是数组,我们知道 数组 有一个特性,那就是可以 在内存中连续存储,利用这个特性我们是不是也可以封装成一个顺序表呢?

image-20221023183122202

1. 顺序表的静态存储

设计一个静态顺序表我们需要考虑下面3种情况:

  • 需要规定静态顺序表的大小
  • 记录当前元素个数,方便判断顺序表是否已满
  • 顺序表具有一定的可维护性

那么,我们就可以对结构体进行如下封装:

#define N 10
typedef int SLDataType;

typedef struct SeqList
{
    SLDataType data[N];
    size_t size;//当前元素个数
}SeqList;

​ 而在日常生活中,一个 静态的顺序表 很明显 不能满足 我们的大多需求。若静态顺序表数量较少时,存储了大量的数据,那么就稍显不合适。这时,就有些人会有些疑问,那我直接一开始就定义很大的空间不就好了, 那当你定义了很大的空间之后,我又只需要少量的空间呢?

2. 顺序表的动态存储

​ 此时,静态的顺序表就不能满足我们的需求,接下来就由我来带大家看看动态顺序表是怎么实现的吧φ(゜▽゜*)♪

想要实现动态的顺序表,我们可以对静态顺序表进行简单的修改:

  • 去除了限制的容量,那我们就需要添加一个现在开辟的容量大小方便对是否需要扩容进行判断
typedef int SLDataType;

typedef struct SeqList
{
    SLDataType* data;
    size_t size;//当前元素个数
    size_t capacity;//当前容量
}SeqList;

1)顺序表的初始化

​ 对于一个封装的顺序表来说,初始化是非常重要的。创建了一个顺序表,若不进行初始化,那么sizecapacity就是随机值,为了避免这种情况,最好对顺序表进行初始化一下。

​ 具体代码如下:

void SLInit(SeqList* psl)
{
	assert(psl);

	psl->data = NULL;
	psl->capacity = psl->size = 0;
}

2)顺序表的删除

​ 有了初始化,当然也少不了删除,删除可以将动态开辟的空间归还。

​ 代码如下:

void SLDestory(SeqList* psl)
{
	assert(psl);
	
    free(psl->a);
    psl->a = NULL;
    psl->capacity = psl->size = 0;
}

3)顺序表插入元素

​ 在考虑插入元素之前,我们首先耀有一个概念,那就是顺序表是基于数组之上的封装,而数组的实际元素数量有可能超过数组本身的长度,例如下面的情况:

image-20221023175206435因此,插入元素时我们需要考虑两种种情况:

  • 普通插入
  • 超容量插入

尾部插入

普通插入这也是最简单的一种插入情况,直接把元素插入到数组尾部空间就好了。

image-20221023181439133

超容量插入,这又是什么意思呢?

​ 假设现在有一个长度为7的数组,但已经被装满了,这时你突然想插入一个新元素。

image-20221023182536011

​ 而这就需要我们对数组进行 扩容 。好了,现在我们知道要对数组进行扩容了,可是又应该怎样扩容?扩容扩多大呢?

怎样扩容?

​ 在C语言中有一个函数realloc可以将开辟的内存空间重新分配,realloc会向堆区寻找一块合适的空间,若找到的新空间与原空间位置不相同,则将原空间的数据拷贝到新空间,释放掉之前的空间(防止内存泄漏)。

扩容扩多大?

​ 我们一般采用的是,原数组大小的2倍

原因:

  1. 小于2倍,考虑到可能要多次重复向内存申请空间,会造成申请空间的时间消耗;
  2. 大于2倍,考虑到开过多的内存可能会用不到,造成空间浪费

综上所述,数组要重新分配新空间一般推荐是原空间大小的2倍。

顺序表_1

顺序表_2

代码实现:

// 检查容量
// 如果满了就扩容
void CheckCapacity(SeqList* psl)
{
	if (psl->size == psl->capacity)
	{
		int newCapcity = psl->capacity == 0 ? 4 : psl->capacity * 2;
		SLDataType* tmp = (SLDataType*)realloc(psl->a, newCapcity*sizeof(SLDataType));
		if (tmp == NULL)
		{
			perror("realloc fail");
			return;
		}

		psl->data = tmp;
		psl->capacity = newCapcity;
	}

}
/*
*  尾部插入元素
*/
void SeqListPushBack(SeqList* psl, SLDataType x)
{
	assert(psl);
	
    //检查是否要扩容
	CheckCapacity(psl);

	psl->data[psl->size] = x;
	psl->size++;
}

中间插入

​ 中间插入,相较于尾部插入会稍微复杂一些。由于数组每一个元素都有其固定的下标,所以需要将想要插入元素后面的元素向后移动,空出地方,再把要插入的元素放到对应的地方。

顺序表_3

​ 再考虑到可能会有扩容的情况,再进行移动之前,我们可以先对数组进行检查(是否需要扩容),让我们来看看代码改怎样实现吧

( •̀ ω •́ )✧

void SeqListInsert(SeqList* psl, size_t pos, SLDataType x)
{
	assert(psl);
	assert(pos <= psl->size);

    //检查是否需要扩容
	CheckCapacity(psl);

	// 挪动数据
	size_t end = psl->size;
	while (end > pos)
	{
		psl->data[end] = psl->data[end-1];
		--end;
	}

	psl->data[pos] = x;
	++psl->size;
}

任意位置插入元素

​ 刚刚我们实现了在中间插入元素,那么我们是否可以通过调用SeqListInset实现任意位置插入元素呢?

//头插
void SeqListPushFront(SL* psl, SLDataType x)
{
	SeqListInsert(psl, 0, x);
}

//尾插
void SeqListPushBack(SL* psl, SLDataType x)
{
	SeqListInsert(psl, psl->size, x);
}

4)顺序表删除元素

​ 顺序表的删除元素操作与插入操作的过程相反,如果删除的元素位于数组中间,可以将后面的元素向前挪动1位,将前面的元素覆盖掉,最后将size个数减1,就不妨碍插入操作了。这相比较于插入来说不用考虑到是否需要扩容,但也不要画蛇添足去实现缩容😂

顺序表_4

来看看代码实现吧:

void SeqListErase(SeqList* psl, size_t pos)
{
	assert(psl);
	assert(pos < psl->size);

	size_t begin = pos;
	while (begin < psl->size - 1)
	{
		psl->data[begin] = psl->data[begin + 1];
		++begin;
	}

	psl->size--;
}

5)查找顺序表元素

​ 经历了上面的大关,向要实现顺序表查找就很简单了,只需要遍历整个数组,找到符合条件的值返回其下标就行了

​ 简单的代码实现如下:

int SeqListFind(SeqList* psl, SLDataType x)
{
	assert(psl);

	for (int i = 0; i < psl->size; ++i)
	{
		if (psl->data[i] == x)
		{
			return i;
		}
	}

	return -1;
}

7)更改顺序表某个位置的元素

​ 更改某一位置元素,可以直接用下标直接更改你想要修改的位置的值。

​ 代码如下:

void SLModify(SeqList* psl, size_t pos, SLDataType x)
{
	assert(psl);
	assert(pos < psl->size);

	psl->a[pos] = x;
}

3. 顺序表小结

1)时间复杂度分析

​ 刚刚我们知道了顺序表的增删查改,那么顺序表增加一个元素、删除一个元素、查找一个元素 的时间复杂度是多少呢🤔?

通过图,我们可以简单的分析出,增删插所需要的 时间复杂度 O ( n ) O(n) O(n),而更改一个元素的时间复杂度为 O ( 1 ) O(1) O(1)

2)顺序表(数组)优势

​ 通过时间复杂度,我们可以清楚的看到,修改内部数据要的时间复杂度明显比,直接访问某一位置所需要的时间复杂度高,那么结论就很清晰了,顺序表(数组) 多适合在 读操作多,写操作少 的环境中,而这也正好与我们接下来要讲的链表恰恰相反。

三、链表

1. 链表的概念

​ 首先我们要知道,链表在物理内存上是由很多分散的空间组成的,那么,要怎样才能知道其它元素所住的地方呢?欸,我们的链表很聪明,会在自己家里讲下一家的地址抄在小本本上,这样就方便找到下家的地址了。

​ 所以,链表是一种在 **物理存储结构上非连续、非有顺 **的存储结构,数据元素的逻辑顺序是通过链表内的 指针链接顺序 去实现的。

​ 而我们从宏观的角度去看链表就是下面这样的结构:

image-20221025132720117

2. 单向链表的实现

​ 现在我们知道,每一个房间里面需要一个小本本来记录下家的地址,那么我们的结构体可以这样定义:

typedef int SLTDataType;

typedef struct SListNode
{
	SLTDataType data;//元素数据
	struct SListNode* next;//下家地址
}SLTNode;

1)创建一个节点

​ 相比较与顺序表,链表在物理空间上并不是一块连续的空间,那么就不能很好的对链表进行初始化,所以我们在只需要创建一个节点的时候,再说明指向的下家地址就好啦

设计想法:

  1. 传入一个
/*
 * 创建一个节点,返回新节点的地址
 * @program x    插入元素的值
 */
SLTNode* BuySLTNode(SLTDataType x)
{
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	if (newnode == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}
    
	newnode->data = x;
	newnode->next = NULL;

	return newnode;
}

2)销毁链表

​ 对比顺序表,顺序表的删除可以直接将一串连续的空间直接删除,但链表一串分散的空间,我们又应该怎样删除呢?

​ 我们可以创建一个cur指针,删除一个空间之前,记录下一房间地址的门牌号,再将这块空间删出,以此类推,依次向后,就可以将整串链表删除了。

链表_1

​ 那么代码该如何实现呢?

/*
 * 链表销毁
 * @program pphead   链表头节点地址
 */
void SListDestory(SLTNode** pphead)
{
	assert(pphead);

	SLTNode* cur = *pphead;
	while (cur)
	{
		SLTNode* next = cur->next;
		free(cur);
		cur = next;
	}

	*pphead = NULL;
}

4)链表元素查找

​ 想要实现在链表中查找只要从前向后依次遍历整个链表就好啦。

​ 假设要找到元素数值为3的元素,那么只要从前向后,只要找到满足条件就返回该节点的地址,若找不到,就返回NULL

链表_2

​ 代码实现如下:

/*
 * 链表查找某一元素位置
 * @program phead    链表头节点
 * @program x        要找的元素值
 */
SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
	SLTNode* cur = phead;
	while (cur)
	{
		if (cur->data == x)
		{
			return cur;
		}
		
		cur = cur->next;
	}

	return NULL;
}

5)链表插入元素

​ 吃完了链表的前菜,让我们快来看看链表的正餐吧ο(=•ω<=)ρ⌒☆。

​ 链表插入元素时,一样可以分为3种情况。

  • 尾部插入
  • 中间插入
  • 头部插入

尾部插入

​ 对于这样结构的单链表来说,想要实现尾部插入,可以定义一个指针遍历整个链表,直到指向最后一个节点,再像最后一个节点的next指针存入要插入的元素即可。但是,我们需要注意一个特殊情况,当链表为空时,无需向后查找,直接连接就好了。

链表_3

​ 那么代码该如何实现呢?

/*
 * 链表尾部插入
 * @program pphead   链表头节点地址
 * @program x        插入元素的值
 */
void SListPushBack(SLTNode** pphead, SLTDataType x)
{
	assert(pphead);

	SLTNode* newnode = BuySLTNode(x);

	// 链表为空
	if (*pphead == NULL)
	{
		*pphead = newnode;
	}
    // 链表为非空
	else
	{
		// 找尾
		SLTNode* tail = *pphead;
		while (tail->next != NULL)
		{
			tail = tail->next;
		}

		tail->next = newnode;
	}
}

中间插入

​ 链表中间插入与尾部插入类似,都是遍历整个数组,知道找到合适的位置,再将新的节点插入进去。

链表_4

​ 代码实现如下:

/*
 * 链表尾部插入
 * @program pphead   链表头节点地址
 * @program pos      链表节点地址
 * @program x        插入元素的值
 */
void SListInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x)
{
	assert(pphead);
	assert(pos);

	if (pos == *pphead)
	{
		SListPushFront(pphead, x);
	}
	else
	{
		SLTNode* cur = *pphead;
		while (cur != pos)
		{
			cur = cur->next;
            
			// 暴力检查,pos不在链表中.prev为空,还没有找到pos,说明pos传错了
			assert(cur);
		}

		SLTNode* newnode = BuySLTNode(x);
		newnode->next = cur->next;
		cur->next = newnode;
	}
}

头部插入

​ 头部插入相较于前面是最简单的一种操作,只需要创建一个节点,next指向头节点即可。

​ 代码实现:

/*
 * 链表头部插入
 * @program pphead   链表头节点地址
 * @program x        插入元素的值
 */
void SListPushFront(SLTNode** pphead, SLTDataType x)
{
	assert(pphead);
	
	SLTNode* newnode = BuySLTNode(x);
	newnode->next = *pphead;
	*pphead = newnode;
}

6)链表的节点删除

​ 链表删除某一节点同样有3种情况:

  • 尾部节点删除
  • 中间节点删除
  • 头部节点删除

尾部节点删除 / 中间节点删除

​ 尾部节点删除和中间节点删除相类似,都是将当前节点删除,上一个节点的next指向下一个节点的地址。

链表_5

​ 代码实现如下:

/*
 * 链表某一节点删除
 * @program pos   节点地址
 */
void SListEraseAfter(SListNode* pos)
{   
    assert(pos);
    SListNode* next = pos->next;
    assert(next);
    
    pos->next = next->next;
    
    free(next);
    next = NULL;
}

​ 在使用这个函数之前,可以先使用我们前面写的SListFind(链表元素查找),传入一个已经确定要删除的地址,再将其删除 。

头部节点删除

​ 头部删除是最简单的一种删除方式,直接将第一个链表删除,再更新一下头节点就好了。

​ 代码如下:

/*
 * 链表头节点删除
 * @program pplist   头节点地址
 */
void SListPopFront(SListNode** pplist)
{
	assert(pplist);
	assert(*pplist != NULL);
	SListNode* del = *pplist;
	*pplist = (*pplist)->next;
	free(del);
	del = NULL;
}

3. 链表小结

1)时间复杂度分析

​ 我们可以简单思考一下,在链表查找一个元素,修改一个元素,尾部或中间插入一个元素,所需要的时间复杂度是多少呢?若删除元素,插入元素的时候不知道插入元素的位置,那么是不是只能在一个函数内将这些功能全部实现。

​ 单从我们今天实现的单链表接口来说,我们创建节点、插入节点、节点删除的时间复杂度是 O ( 1 ) O(1) O(1)

​ 但是,从总体使用的角度去看,要删除一个节点,我们首先要知道删除节点的位置,需要先查找链表,找到符合条件的节点才能得到要删除的节点位置,而查找链表所需要的时间复杂度是 O ( n ) O(n) O(n),故从使用的角度来看,删除一个链表节点我们所需要的时间复杂度是 O ( n ) O(n) O(n)

​ 那么再来考虑链表的头插和头删呢?链表头插和头删并不需要搜索整个链表,直接在第一个删除或者增加就好了,那么他的时间复杂度是多少?头删和头插的时间复杂度是 O ( 1 ) O(1) O(1)

2)链表的优势

​ 从上面我们知道,链表对于插入元素和删除元素具有独特的优势,只要知道插入或删除的位置,那么时间复杂度就是 O ( 1 ) O(1) O(1),而数组想要插入或删除元素,就算知道了插入或删除的位置,一样要使用 O ( n ) O(n) O(n)的时间复杂度。

​ 所以,链表多用于 写操作多,读操作少 的环境中。

四、数组 vs 链表

​ 现在,我们链表的知识已经懂了,那么链表和顺序表都属于线性表的结构,用哪一个更好呢?

​ 正确解答:数据结构没有绝对的好坏之分,数组和链表各有各的好处。

​ 数组在内存中是连续存储的,我们可以通过[]下标引用操作符对数组进行随机访问,而且所能达到的时间复杂度是 O ( 1 ) O(1) O(1) ,但链表却要使用 O ( n ) O(n) O(n)的时间复杂度;链表呢?链表方便在靠前的地方插入元素,若直接使用头插,链表所用的时间复杂度是 O ( 1 ) O(1) O(1),而数组想要实现先头插,需要将所有元素向后移动,这样所用的时间复杂度是 O ( n ) O(n) O(n)

小结:链表适合头插,数组适合尾插;链表相较于数组更适合中间插入,数组相较于链表更适合随机读取。

​ 好啦,本期的内容就到这里啦,链表和顺序表你收获了多少呢?如果绝对对你有帮助的话,还不忘三连支持一下博主,我们下期再见ο(=•ω<=)ρ⌒☆

评论 40
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

泡泡牛奶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值