顺序表和链表(单链表、双链表)的区别|数据结构

目录

一、总体区别:

二、顺序表

      1、定义

      2、可实现方法

      (1)初始化与销毁

     (2)检查容量大小(动态开辟内存)

     (3)头插尾插/头删尾删

     (4)指定位置插入/删除

     (5)顺序表的查找

三、链表

      1、定义        

      2、链表的分类

      3、单链表

     (1)定义

     (2)可实现方法

        Ⅰ、 创建新节点和节点的打印

        Ⅱ、头插尾插/头删尾删

        Ⅲ、查找

        Ⅳ、在指定位置之前/之后插入数据

        Ⅴ、删除指定位置数据/删除指定位置之后数据

        Ⅵ、销毁链表

      4、双链表

     (1)定义

     (2)可实现方法

        Ⅰ、构建新节点/打印节点

        Ⅱ、初始化及销毁

        Ⅲ、头插尾插/头删尾删

        Ⅳ、在pos节点之后插入数据

        Ⅴ、删除pos节点

        Ⅵ、查找

四、总结


一、总体区别

不同点顺序表链表
存储结构上逻辑上连续,物理上也一样连续逻辑上连续,物理上不一定连续
随机访问支持下标访问不支持下标访问
任意位置插入或者删除元素

插入:内存不够时,需要扩容

删除:可能需要搬运元素

插入:没有容量的概念

删除:只需要修改指针指向

应用场景元素高效存储和频繁访问任意位置插入或删除元素频繁
缓存利用率

二、顺序表

      1、定义

        由于在物理上是连续的,所以在内存不够时需要手动扩容(动态顺序表)。因此,定义时需要定义三个值:数组、有效数据个数、表的空间大小。

typedef struct SeqList     
{
	SLDataType* arr;
	SLDataType size;//有效的数据个数
	SLDataType capacity;//空间大小
}SL;

      2、可实现方法

        在顺序表的应用中,通常需要以下方法:

//顺序表的初始化
void SLInit(SL* ps);

//顺序表的销毁
void SLDestroy(SL* ps);

//头部插入删除/尾部插入删除
void SLPushBack(SL* ps,SLDataType x);
void SLPushFront(SL* ps, SLDataType x);

void SLPopBack(SL* ps);
void SLPopFront(SL* ps);

//顺序表的打印
void SLPrint(SL s);

//在指定位置之前插入/删除数据
void SLInsert(SL* ps, int pos, SLDataType x);
void SLErase(SL* ps, int pos);
//查找指定位置数据
int SLFind(SL* ps, SLDataType x);

      (1)初始化与销毁

        在顺序表的初始化与销毁中,我们首先应该清楚动态顺序表的每个变量的含义,其次我们才可以对其初始化。我们之前提到过arr代表指针,指向一个数组,因此我们在初始化时应当先将其置为空指针。而顺序表的大小和容量也应该置为空,而这里我们要把它置为0。因此,动态顺序表的初始化方法为:

void SLInit(SL* ps) {
	ps ->arr = NULL;
	ps->capacity = ps->size = 0;
}

        在进行动态顺序表的销毁前,我们需要简单了解一下内存的相关知识。在我们每次的动态申请内存之后,我们都要讲自己申请的内存还给操作系统,以免造成内存泄漏(通俗来讲就是好借好还,再借不难)。了解了什么是内存泄漏之后,我们就应该才到顺序表的销毁步骤了—没错,那就是释放掉我们动态申请的内存。

//顺序表的销毁
void SLDestroy(SL* ps) {
	if (ps->arr) {
		free(ps->arr);
	}
	ps->arr = NULL;
	ps->capacity = ps->size = 0;
}

        这里我们注意,在释放掉ps->arr之后,我们要将arr指针再次置为空,避免其成为野指针。

     (2)检查容量大小(动态开辟内存)

        在进行动态顺序表的操作之前,我们知道动态顺序表最重要的就是“动态”二字,那么我们怎么实现“动态”呢?

        在C语言的学习过程中,我们学到过动态开辟内存的三个函数malloc、realloc、calloc(这里不清楚的看官们可以自行了解一下)。在动态顺序表的扩容方法中,我们主要用到的就是realloc函数。首先代码如下:

void SLCheckCapacity(SL* ps) {
	if (ps->capacity == ps->size) {        //首先判断是否够用
		//不够得申请
		int newCapacity = ps->capacity == 0 ? 4 : 2 * ps->capacity;
		SLDataType* tmp = (SLDataType*)realloc(ps->arr, 2 * newCapacity * sizeof(SLDataType));
		if (tmp == NULL) {//判断是否增容成功
			printf("realloc fail!\n");
			exit(1);//直接退出!
		}
		//空间申请成功:
		ps->arr = tmp;
		ps->capacity = newCapacity;
	}
}

        在内存的扩容时,我们通常会让其呈指数的形式扩大,一般以2的倍数为主。在扩容时,我们还要考虑是否为第一次扩容。如果是第一次开辟空间,我们要先把容量大小设置为非零的数(这里我们设置成4),这里我们用了三目操作符来巧妙地实现。

        扩容成原来的2倍之后,接下来需要判断是否成功,如果没有申请成功,就退出程序或返回。最后将新的数组指针赋值给arr,并且将内存大小赋值给capacity。

     (3)头插尾插/头删尾删

       在学习这四种方法时,我们将插入放在一起,将删除方法放在一起。原因是在执行插入方法时,不论是头插还是尾插,我们都要先判断内存是否够用。

        在第一章讲的总体区别时,我们知道动态顺序表的一个缺点就是在进行插入操作时,如果是头插操作,那么需要后移每一个元素。而在尾插时,则是先判断容量大小,并且将size加上一位。

//头插
void SLPushFront(SL* ps, SLDataType x) {
	
	assert(ps);

	SLCheckCapacity(ps);
	
	//先后移每一个值
	for (int i = ps->size; i > 0; i--) {
		ps->arr[i] = ps->arr[i - 1];//arr[i]=arr[i-1]
	}

	ps->arr[0] = x;
	ps->size++;
}

//
//尾插:
void SLPushBack(SL* ps, SLDataType x) {
	传过来空顺序表,比较温柔得解决方式为:
	//if (ps == NULL) {
	//	return;
	//}
	assert(ps);//等价于 assert(ps != NULL)

	//尾插之前查看空间大小够不够
	SLCheckCapacity(ps);

	ps->arr[ps->size++] = x;

}

        在进行删除操作时,会比插入操作少了检查容量是否够这一步骤,但在头删操作时,还需要进行数据的前移,在尾删操作时,也要进行size的减一操作。代码如下:

//尾删
void SLPopBack(SL* ps) {
	assert(ps);
	assert(ps->size);//顺序表是否为空

	//ps->arr[ps->size - 1] = -1;//要或不要都不影响
	--ps->size;
}

//头删
void SLPopFront(SL* ps) {

	assert(ps);
	assert(ps->size);

	for (int i = 0; i < ps->size - 1; i++) {
		ps->arr[i] = ps->arr[i + 1];
	}
	--ps->size;
}

        这里需要注意,进入函数之后,我们都要先判断结构体指针和数据个数是否为空,如果size为空则代表没有数据,则删除就没有意义。

     (4)指定位置插入/删除

        在指定位置插入或者删除数据,除了在尾部操作之外,都要涉及数据的移动以及size的变化,但不同的是,函数的参数在传递过程中多了两个值:要操作的位置pos以及插入的值x。

//指定位置插入
void SLInsert(SL* ps, int pos, SLDataType x) {
	assert(ps);
	assert(pos >= 0 && pos <= ps->size);

	//判断空间是否够
	SLCheckCapacity(ps);
	
	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 < ps->size - 1; i++) {
		ps->arr[i] = ps->arr[i + 1];
	}
	ps->size--;
}

     (5)顺序表的查找

        与之前的几个方法不同,查找函数的返回类型为int类型,即返回的是数组下标,并且满足如果没找到,返回一个小于0的值。

//顺序表的查找
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、定义        

        链表是一种逻辑上的顺序表,但在物理方面讲,它却是不连续的,即链表是逻辑上连续,物理上不连续。它的数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

      2、链表的分类

        在链表的分类中,我们可以将链表分为八类。其中,最常见的就是无头单向非循环链表(即常见的单链表)和带头双向循环链表(即常见的双向链表)。

        上面提到链表分为八类,主要根据三个特征:带头和不带头、单向和双向、循环和不循环。

        (1)单项或双向

        (2)带头或不带头

        (3)循环或不循环

        上面的带头或者不带头,表示的是带不带头节点,这个头节点又称为哨兵位

      3、单链表

        常见的单链表即以上提到的不带头单向不循环链表,这里个人的感觉是它像是处在顺序表和双向链表的之间。它不像顺序表那样,每次使用都要判断容量是否够用、需不需要进行扩容,并且也不需要在插入或删除操作时移动其他元素。但是跟双向链表(带头双向循环链表)相比,它却在进行除尾部之外的操作时都要遍历一边数组。

     (1)定义

        单链表主要包含数据,以及下一个节点。

typedef int SLTDataType;

//定义单链表
typedef struct SListNode
{
	int data;
	struct SListNode* next;//指向下一个节点的指针
}SLTNode;

     (2)可实现方法

        单链表在应用过程中,主要有以下方法:

//创建新节点:
SLTNode* SLTBuyNode(SLTDataType x);

//链表的打印
void SLTPrint(SLTNode* phead);

//链表的尾插
void SLTPushBack(SLTNode** pphead,SLTDataType x);

//链表的头插
void SLTPushFront(SLTNode** pphead, SLTDataType x);

//尾删
void SLTPopBack(SLTNode** pphead);
//头删
void SLTPopFront(SLTNode** pphead);

//查找
SLTNode* SLTFind(SLTNode* pphead,SLTDataType x);

//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead,SLTNode* pos,SLTDataType x);
//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x);

//删除pos节点
void SLTErase(SLTNode** pphead,SLTNode* pos);
//删除pos节点之后的数据
void SLTEraseAfter(SLTNode* pos);

//销毁链表
void SListDesTory(SLTNode** pphead);
        Ⅰ、 创建新节点和节点的打印

        链表虽然不像顺序表,不需要进行容量的判断及扩容,但是在每次有新节点的创建时,都需要额外申请新节点,也就是说我们需要额外malloc空间,用来存放单个节点数据。并且在申请完成之后,要判断是否申请成功。将下一个节点置为空后,返回新节点的指针。

        节点的打印只需要循环一边链表,打印出节点数据即可。

//创建新节点:
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 SLTPrint(SLTNode* phead) {
	
	SLTNode* pcur = phead;

	while (pcur) {
	
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
	printf("NULL\n");
}

        Ⅱ、头插尾插/头删尾删

        在实现这四种方法之前,我们需要先考虑函数参数,是像顺序表那样传递一级指针吗?在单链表中,我们假设一个指针phead指向链表的头,然后我们进行头插头删时,我们改变的是指向头节点的指针的值,所以这四种方法都需要传递二级指针。

         在每次的操作之后,都要考虑结构体中的(节点的)next指向的节点是否发生改变。在头插中,新节点的next指向的是头节点;头删时需要移动phead指向原先的next;尾插时要把新节点的next置为空;尾删时要把尾节点的上一个节点的next置为NULL。

        在删除操作中,我们还要注意将删除节点释放并置空,防止内存泄漏。

//链表的尾插
void SLTPushBack(SLTNode** pphead, SLTDataType x) {
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);
	//空链表和非空链表
	if (*pphead == NULL) {
		*pphead = newnode;
	}
	else
	{
		//先找尾
		SLTNode* ptial = *pphead;

		while (ptial->next) {
			ptial = ptial->next;
		}
		//ptail指向的就是尾节点
		ptial->next = newnode;
	}
}

//头插
void SLTPushFront(SLTNode** pphead, SLTDataType x) {
	assert(pphead);
	SLTNode* newnode = SLTBuyNode(x);
	//空链表和非空链表
		newnode->next = *pphead;
		*pphead = newnode;
}

//头删
void SLTPopFront(SLTNode** pphead) {
	//链表不能为空 
	assert(*pphead && pphead);

	只有一个节点:
	//if (((*pphead)->next) == NULL) {
	//	free(*pphead);
	//	*pphead = NULL;
	//}
	//else {
	//	//有多个节点:
	//	SLTNode** prev = *pphead;
	//	*pphead = (*pphead)->next;
	//	free(prev);
	//	prev = NULL;
	//}
	SLTNode** prev = *pphead;
		*pphead = (*pphead)->next;
		free(prev);
		prev = NULL;
	
}
//尾删
void SLTPopBack(SLTNode** pphead) {
	
	//链表不可以为空
	assert(pphead && *pphead);

	//链表只有一个节点
	if ((*pphead)->next == NULL) {	//->的优先级高于*
		free(*pphead);
		*pphead = NULL;
	} 
	else
	{
		//链表里面有多个节点
		SLTNode* prev = *pphead;
		SLTNode* ptail = *pphead;

		while (ptail->next) {
			prev = ptail;
			ptail = ptail->next;
		}
		free(ptail);
		ptail = NULL;
		prev->next = NULL;
	}
}
        Ⅲ、查找

        在查找操作中,我们将要查找的数传给函数,并返回值的指针;如果没有找到,需要返回NULL。

//查找
SLTNode* SLTFind(SLTNode* pphead, SLTDataType x) {
	assert(pphead);

	SLTNode* pcur = pphead;
	while (pcur) {
		if (pcur->data == x)
		{
			return pcur;
		}
		else {
			(pcur) = (pcur)->next;
		}
	}
	return NULL;
}
        Ⅳ、在指定位置之前/之后插入数据

        在插入之前,我们需要判断要插入的数据是否在头节点或尾节点,如果是头节点,则调用前面的头插操作,如果是尾节点,则调用尾插操作。

//在指定位置之前插入数据
void SLTInsert(SLTNode** pphead, SLTNode* pos, SLTDataType x) {
	assert(*pphead && pphead);
	assert(pos);


	SLTNode* newnode = SLTBuyNode(x);
	SLTNode* prev = *pphead;

	//若pos==pphead
	if (pos == *pphead) {
		SLTPushFront(pos, x);
	}
	else {
		while (prev->next != pos) {
			prev = prev->next;
		}

		//链接prev newnode pos
		prev->next = newnode;
		newnode->next = pos;
	}
	
}

//在指定位置之后插入数据
void SLTInsertAfter(SLTNode* pos, SLTDataType x) {
	assert(pos);

	SLTNode* newnode = SLTBuyNode(x);
	SLTNode* prev = pos;
	prev = pos->next;
	if (prev == NULL) {
	//尾插
		SLTPushBack(pos, x);
	}
	else {
		newnode->next = prev;
		pos->next = newnode;
	}
}
        Ⅴ、删除指定位置数据/删除指定位置之后数据

        在删除之前呢,我们同样是需要注意指定位置,如果在头节点,则直接调用头删,在尾节点,则调用尾删操作。同时还需要释放掉指定节点指针并置空。

//删除pos节点
void SLTErase(SLTNode** pphead, SLTNode* pos) {
	assert(*pphead && pphead);
	assert(pos);

	SLTNode* pcur = *pphead;

	if (*pphead == pos) {
		//pos节点为头节点
		SLTPopFront(pphead);
	}
	else {
		while (pcur->next != pos) {
			pcur=pcur->next;
		}
		//链接pos前后两个节点
		pcur->next = pos->next;
		free(pos);
		pos = NULL;
	}
}
//删除pos节点之后的数据
void SLTEraseAfter(SLTNode* pos) {
	assert(pos && pos->next);

	SLTNode* del = pos->next;
	pos->next = del->next;
	free(del);
	del = NULL;
}
        Ⅵ、销毁链表

        在销毁链表操作中,我们不能像顺序表那样一次性销毁。由于链表在物理结构上是不一定连续的,所以我们要循环链表,并一个一个释放置空。

//销毁顺序表
void SListDesTory(SLTNode** pphead) {
	assert(*pphead && pphead);

	SLTNode* pcur = *pphead;

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

      4、双链表

     (1)定义

        双链表,常见的即带头双向循环链表,在定义时除了包含数据外,还要有指向前一个节点和下一个节点的指针变量。

typedef int STDataType;
//定义双向链表节点的结构
typedef struct ListNode
{
	STDataType data;
	struct ListNode* next;
	struct ListNode* prev;
}LTNode;

     (2)可实现方法

        常见的双链表方法如下:

//构建新节点
LTNode* LTBuyNode(STDataType x)
//初始化
void LTInit(LTNode** pphead);
//打印
void LTPrint(LTNode* pphead);
//尾插
void LTPushBack(LTNode* pphead,STDataType x);//传一级指针即可,哨兵位不需要修改
//头插
void LTPushFront(LTNode* phead, STDataType x);

//尾删
void LTPopBack(LTNode* pphead);
//头删
void LTPopFront(LTNode* pphead);

//在pos位置之后插入数据
void LTInsert(LTNode* pos,STDataType x);
//删除pos节点
void LTErase(LTNode* pos);
LTNode* LTFind(LTNode* phead, STDataType x);

//销毁链表
void LTDesTory(LTNode* pphead);
        Ⅰ、构建新节点/打印节点

        双链表的新节点的构建和打印与单链表相似,这里不过多介绍。

//构建新节点
LTNode* LTBuyNode(STDataType x) {
	LTNode* node = (LTNode*)malloc(sizeof(LTNode));
	if (node == NULL) {
		perror("malloc");
		exit(1);
	}
	node->data = x;
	node->next = node->prev = node;

	return node;
}
//打印
void LTPrint(LTNode* phead) {
	LTNode* pcur = phead->next;
	while (pcur != phead) {
		printf("%d->", pcur->data);
		pcur = pcur->next;
	}
	printf("\n");
}
        Ⅱ、初始化及销毁

        双链表的初始化就是给链表创建一个哨兵位。

//初始化
//给双向链表创建一个哨兵位
void LTInit(LTNode** pphead) {
	*pphead = LTBuyNode(-1);
}

        双链表的销毁跟单链表略有不同,主要多了一个头节点(哨兵位)的销毁。 

//销毁链表
void LTDesTory(LTNode* phead) {
	assert(phead);

	LTNode* pcur = phead->next;
	while (pcur != phead) {
		LTNode* next = pcur->next;
		free(pcur);
		pcur = next;
	}

	//此时pcur指向phead,而phead还没有被销毁
	free(phead);
	phead = NULL;
}
        Ⅲ、头插尾插/头删尾删

        双链表的插入操作与单链表类似,但是头插操作需要注意链接头节点与新节点,并且将尾插的新节点的下一个值指向头节点(哨兵位)。

        需要注意的是,这里的头插在实际上并不是真正的头插,它是插在哨兵位之后的第一个节点,因此插入之后需要链接哨兵位与新节点。

//尾插
void LTPushBack(LTNode* phead, STDataType x) {
	assert(phead);
	LTNode* newnode = LTBuyNode(x);
	//phead  phead->prev(尾节点)  newnode
	newnode->prev = phead->prev;
	newnode->next = phead;

	phead->prev->next = newnode;
	phead->prev = newnode;
}

//头插
void LTPushFront(LTNode* phead, STDataType x) {
	assert(phead);
	LTNode* newnode = LTBuyNode(x);

	newnode->next = phead->next;
	newnode->prev = phead;

	phead->next->prev = newnode;
	phead->next = newnode;
}

        双链表的删除操作与单链表类似,但是注意尾节点与头节点(哨兵位)的链接。 

//尾删
void LTPopBack(LTNode* phead) {
	//链表必须有效并且不为空(只有一个哨兵位)
	assert(phead && phead->next != phead);

	LTNode* del = phead->prev;

	del->prev->next = phead;
	phead->prev = del->prev;

	free(del);
	del = NULL;
}

//头删
void LTPopFront(LTNode* phead) {
	assert(phead && phead->next != phead);

	LTNode* del = phead->next;
	//phead del del->next
	del->next->prev = phead;
	phead->next = del->next;

	free(del);
	del = NULL;
}
        Ⅳ、在pos节点之后插入数据

        在双链表的pos结点之后插入数据,与单链表不同的就是还要链接pos节点的next和新节点的prev。这里不需要区分是不是头插还是尾插。

//在pos位置之后插入数据
void LTInsert(LTNode* pos, STDataType x) {
	assert(pos);

	LTNode* newnode = LTBuyNode(x);

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

	pos->next->prev = newnode;
	pos->next = newnode;
}
        Ⅴ、删除pos节点

        删除pos节点的数据,只需要链接pos节点之前和后边的数据,随后释放掉pos节点即可。

//删除pos节点
void LTErase(LTNode* pos) {
	//pos理论上不能是phead,但是没有参数phead,无法增加校验
	assert(pos);

	//pos->prev pos pos->next
	pos->next->prev = pos->prev;
	pos->prev->next = pos->next;

	free(pos);
	pos = NULL;
}
        Ⅵ、查找

        双链表的查找方法需要将找的节点指针传递回去,如果没有该节点,则需要返回NULL。

//查找
LTNode* LTFind(LTNode* phead, STDataType x) {
	LTNode* pcur = phead->next;
	while (pcur!=phead) {
		if (pcur->data == x) {
			return pcur;
		}
		pcur = pcur->next;
	}
	//没有找到
	return NULL;
}

四、总结

        不论是顺序表、单链表还是双链表,我们在调用方法时都需要考虑传递的参数的实际意义。在实际运用中需要考虑各个结构的优缺点进行选择。

        顺序表的优点是存储效率高,并且可以通过下标访问,空间连续,提高了缓存命中率。但是不足同样是空间连续,容易造成空间浪费。

        链表的优点是任意位置插入空间复杂度为O(1),并且没有增容问题,随插随扩。缺点是以节点为单位,缓存命中率低。

        单链表与双链表相比,双链表在访问尾节点时不需要一个一个遍历。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值