数据结构之顺序表和链表

线性表

线性表是数据结构里面比较常见的结构。它的含义是在逻辑上是线性的。(物理存储上不一定是连续的)。常见的例子有:链表(list),顺序表(vector),栈(stack),队列(queue),字符串(string)等等。

线性表的储存一般是连续结构(如:数组)和链式结构(如:链表)。

连续结构图:
注:地址都是虚构的,只是体现特征
在这里插入图片描述
链式结构:
注:地址都是虚构的,只是体现特征
在这里插入图片描述

顺序表


前言:
我们要记住:数据结构只规定了结构的特征,具体如何实现或者说它的对应接口是什么等等问题,是没有一个统一规定的。因此具体实现是看每个人的需求。我们没必要追求一模一样。


顺序表主要满足下列需求即可:

1.连续储存(像数组一样)
2. 可以自动增容
3. 可以实现增删查改

顺序表的实现(C语言版本)

顺序表可以定义成静态的,也可以定义成动态的。

静态版本:

#define N 100
typedef int SeqListDataType;//顺序表数据类型
typedef struct SeqList//顺序表
{
	SeqListDataType a[N];//顺序表数组
	int size;//有效数据
}SeqList;

如果想要把顺序表扩大,就把N的值改变即可。但是这样就不能在运行阶段对顺序表进行扩容。只能在编译前就改变顺序表大小。
总体来说:不好用。


动态版本:

typedef int SeqListDataType;//顺序表
typedef struct SeqList//顺序表
{
	SeqListDataType* a;//定义成指针就可以用realloc进行动态扩容了
	int size;//有效数据
	int capacity;//最大容量
}SeqList;

动态版本的指针就可以进行动态扩容。比静态版本更加灵活好用。
下面我就实现这个版本的顺序表。

这里有一个问题:为什么动态的顺序表比静态的顺序表多一个成员capacity?

这是因为我们进行顺序表的扩容时并不是一个一个扩大的,capacity很有可能比size要大。

我们可以把顺序表理解成一个水杯。size是现在水杯里的水量。capacity是这个水杯的最大容量刻度。通过这个例子我们很容易就能理解:size不一定要等于capacity


顺序表接口实现

我不想空白的想出一些接口,我就拿一些C++中stl的container的vector里常用的一些接口作为例子来实现一下。(有兴趣的看下面的链接进入)

vector的文档
注:vector就是顺序表,只不过c++里面叫vector而已。


顺序表构造和销毁

constructor是构造函数的意思。我们知道这是创建顺序表即可(这是c++的语法,不用深究)

destructor是析构函数的意思,也不用深究,这些不关紧要。它是销毁顺序表的意思。
在这里插入图片描述
这两个接口很常用,我们用C语言实现一下。


构造顺序表函数(SeqListInit)

void SeqListInit(SeqList* Seq)//顺序表本质就是数组,扩容也是扩数组
{
	assert(Seq);
	Seq->a = (SeqListDataType*)malloc(sizeof(SeqListDataType)*2);//一开始给两个大小
	Seq->size = 0;
	Seq->capacity = 2;
}

首先我们创建两个空间给顺序表来存储数据。一开始开多大你可以根据实际情况来决定。


销毁顺序表函数(DestroySeqList)

void SeqListDestroy(SeqList* Seq)
{
	assert(Seq);
	Seq->size = 0;
	Seq->capacity = 0;
	free(Seq->a);
}

把这段空间释放即可。


顺序表关于capacity的接口

下面打勾的是c++里面关于capacity和size的常用接口。(没打勾的这里就不实现了)
在这里插入图片描述


返回顺序表的有效数据个数

size_t size(SeqList* s)
{
	assert(s);
	return s->size;
}

很简单


判断顺序表是否为空

bool empty(SeqList* s)
{
	assert(s);
	return s->size == 0;//为空就返回true,否则返回false
}

reserve是用来扩大capacity的,保证capacity可以存放下足够的数据。
在这里插入图片描述

void reserve(SeqList* s,size_t n)
	{
		size_t sz = size();
		if (n > capacity())
		{
			SeqListDataType* tmp = (SeqListDataType*)realloc(s->a,sizeof(SeqListDataType)*n);
			if(tmp)
				s = tmp;
			free(tmp);
		}
	}

上面这种扩容方法要传你想要的capacity(n),在c语言里面不是很好用。
再写一种不用传n的,只要你size==capacity就自动扩容。换一个名字:SeqListCheckCapacity**(顺序表检查容量)**

void SeqListCheckCapacity(SeqList* Seq)
{
	assert(Seq);
	if (Seq->capacity == Seq->size)
	{
		Seq->capacity *= 2;
		SeqListDataType* tmp  = (SeqListDataType*)realloc(Seq->a, sizeof(SeqListDataType) * (Seq->capacity));
		if (tmp != NULL)
		{
			Seq->a = tmp;
		}
		else
		{
			printf("realloc fail\n");
			exit(-1);
		}
	}
}

下面是最经典的增删查改接口了。
在这里插入图片描述


push_back

void push_back(SeqList* Seq,SeqListDataType x)
{
	assert(Seq);
	SeqListCheckCapacity(Seq);
	Seq->a[Seq->size] = x;
	Seq->size++;

pop_back

void pop_back(SeqList* Seq)
{
	assert(Seq);
	assert(Seq->size > 0);
	Seq->size--;
}

直接让size–就可以了。不需要让数据为0,没有意义。万一那个数据是0呢?


insert
insert是在任意位置插入数据,因此需要挪动数据。时间复杂度是O(N),尽量还是不要使用了。

void insert(SeqList* Seq, int pos, SeqListDataType x)
{
	assert(Seq);
	assert(pos <= Seq->size&& pos >= 0);
	SeqListCheckCapacity(Seq);
	int end = Seq->size - 1;
	for (int i = end; i >= pos; i--)
	{
		Seq->a[i + 1] = Seq->a[i];
	}
	Seq->a[pos] = x;
	Seq->size++;
}

erase
erase也要挪动数据。时间复杂度也是O(N),尽量不要用。除非尾删。

void erase(SeqList* Seq, int pos)
{
	assert(Seq);
	assert(pos <= Seq->size && pos >= 0);
	SeqListCheckCapacity(Seq);
	for (int i = pos; i < Seq->size-1; i++)
	{
		Seq->a[i] = Seq->a[i + 1];
	}
	Seq->size--;
}

注:通过insert和erase可以让push_back和pop_back复用。把pos改一改就行了。

链表

前言:在学数据结构中的链表时,往往会出现引用&或者二级指针问题。这里会着重讲。其他问题其实单链表并不难。这里就不着重关注接口如何实现了

链表有许多种。但都可以归为三类:单向双向,带不带头,是否循环

因此排列组合可以有8种搭配。

最常用的两种是:

  1. 不带头不循环的单向链表
  2. 带头双向循环链表

  1. 无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构。在刷题网站种的链表也一般是这种形式。
  2. 带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。

这里就实现一下这两种。

不带头单链表

单链表的结构体是每一个节点。然后我们通过这每一个节点来连接成一个单链表。

typedef int SLDataType;
typedef struct SListNode
{
	struct SList* next;
	SLDataType data;
}SListNode;

单链表接口实现

还是一边看一下stl库里的接口一边结合c语言来写适合c语言的接口。

单链表的构造和销毁

单链表的构造和销毁其实都是针对节点来说的,本质是创建节点和销毁节点。
在这里插入图片描述
创建节点

SListNode* SListCreateNode(SLDataType x)
{
	SListNode* newnode = (SListNode*)malloc(sizeof(SListNode));
	newnode->data = x;
	newnode->next = NULL;

	return newnode;
}

销毁整条链表
即把每一个节点都给销毁了。

void Destroy(SListNode* head)
{
	if (head)
		return;
	SListNode* cur = head,*next = head->next;
	while (cur)
	{
		free(cur);
		cur = next;
		if (next)
			next = next->next;
	}
}

增删接口

在这里插入图片描述
insert
注:insert接口在stl里面默认是前插的。这里我们写两种,一种前插一种后插。

关于单链表的二级指针问题(***)

push_back接口

void SListPushBack(SList** plist, SLDataType x)
{
	SList* newnode = SListBuyNode(x);
	if (plist == NULL)
	{
		*plist = newnode;
		return;
	}
	SList* tail = *plist;
	while (tail->next != NULL)
	{
		tail = tail->next;
	}
	tail->next = newnode;
}

push_back的逻辑就是创建一个新节点,然后让尾部链接起来就可以了。

问题是:为什么会有这个二级指针?没有可不可以呢?
或者说:什么时候该有,什么时候没必要有?

画个图就能很好的理解了。

如果使用二级指针:
在这里插入图片描述
在这里插入图片描述
现在是要在尾部插入,因此ppList不断迭代走到尾部。如图:
在这里插入图片描述
然后插入数据。这是二级指针的效果。完成了任务。

现在看一下一级指针的效果:
由于函数传参是一个临时拷贝,因此传一级指针并不是把自己传了进去,而是传了自己的拷贝。
在这里插入图片描述
然后不断迭代到尾部:
在这里插入图片描述
然后直接插入数据,结果也是没有问题的。在这种情况下一级指针依旧可以完成任务。

有些人可能就晕了。既然两种指针都可以,为什么要做区分。注意:这里只是尾插push_back的一种情况。
再举一个例子:假设链表里面一个节点也没有。
在这里插入图片描述
现在要push_back,我们首先要创建一个节点。然后让pList指向这个新的节点,让这张链表就有了头指针。

现在假设pList还是一级指针,看一下会发生什么?
这是刚开始的代码对应的内存视角图
在这里插入图片描述
开始指向:
在这里插入图片描述
函数结束:
在这里插入图片描述
总结一下:用一级指针在链表为空的时候是行不通的。
我们来看一下二级指针的图:
在这里插入图片描述
总结一下:二级指针是行的通的。

上面两个例子可以慢慢感悟一下。
这里直接说一个类似结论的东西:
如果你要改变头指针,就要传二级指针。不需要改变头指针的话,传一级是完全没问题的。
这也是为什么有时候课本上有一些带头节点的链表在push_back或者push_front的时候可以传一级指针,因为它们的头节点不会被改变,因此不用传二级指针。

注:引用的效果和二级指针是一样的。
剩下的接口要不要传二级指针也是一样的解决。现在把后面的接口也实现一下吧(这些其实不太重要了)

pop_back()
传二级指针的原因是如果说只剩下一个节点了,再删除就会改变头指针,改变头指针要影响到主函数,因此传二级指针。

void SListPopBack(SList** plist)
{
	if (*plist == NULL)
	{
		return;
	}
	else if ((*plist)->next == NULL)
	{
		free(*plist);
		*plist = NULL;
	}
	SList* tail = *plist;
	SList* prev = NULL;
	while (tail->next != NULL)
	{
		prev = tail;
		tail = tail->next;
	}
	prev->next = NULL;
	free(tail);
	tail = NULL;
}

insert
同样的,如果要头插,头指针一样被改变了,因此要传二级指针。
具体的实现不是重点。

void SListInsert(SList** pphead, SList* pos, SLTDateType x)
{
	SLTNode* newnode = BuyListNode(x);
	if (*pphead == pos)
	{
		newnode->next = *pphead;
		*pphead = newnode;
	}
	else
	{
		// 找到pos的前一个位置
		SLTNode* posPrev = *pphead;
		while (posPrev->next != pos)
		{
			posPrev = posPrev->next;
		}

		posPrev->next = newnode;
		newnode->next = pos;
	}
}

erase同理,也要传二级指针。

双向带头循环链表

这是在实际中用来存储数据的链表类型。也是stl中的list容器。

erase的实现:
这里看到并没有传二级指针。再一次验证了上面所说的。因为这是带头节点的链表,头指针并不会改变。

void ListInsert(LTNode* pos, LTDateType x)
{
	assert(pos);
	LTNode* posPrev = pos->prev;
	LTNode* newnode = BuyListNode(x);

	// posPrev newnode pos
	posPrev->next = newnode;
	newnode->prev = posPrev;
	newnode->next = pos;
	pos->prev = newnode;
}

push_back
这次就用复用函数的角度去实现吧。

void ListPushBack(LTNode* phead, LTDateType x)
{
	assert(phead);
	ListInsert(phead, x);
}

注:这里是phead的原因,其实就相当于在链表的尾部的前一个位置插入了一个节点。
在这里插入图片描述
其余接口这里不写了。

顺序表和链表的对比

顺序表的优点:

  1. 支持下标访问,可以随机访问
  2. cpu高速缓存命中率更高(*)

顺序表的缺点:

  1. 插入删除时间复杂度高,要挪动数据
  2. 空间增容有一定损耗,为了避免频繁增容,我们要以一定的倍数增容,因此会造成一些内存的浪费。

这里讲一下高速缓存命中率。

下面以一种大白话的方式来说,比较好懂
计算机在读取数据先把数据读到缓存。(读到缓存的好处是缓存读取速度快),然后再去缓存里面读取。如果数据的地址是连续的,计算机就在缓存里面可以读到很多有效数据。如果地址不是连续的,缓存里面就有很多无用信息。

由于数组的数据是连续存储的,因此缓存中的命中率高。
链表的存储是离散的,因此缓存的命中率低。

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值