数据结构—顺序表和链表

目录

线性表

线性表的定义

顺序表

顺序表的定义

顺序表结构

顺序表初始化

顺序表扩容

顺序表打印

 顺序表尾插

顺序表头查

顺序表尾删

顺序表头删

在顺序表任意位置插入

在顺序表任意位置删除

顺序表销毁

链表

链表的定义

链表的分类

单向非循环链表

单链表结构

动态申请一个节点

单链表头插

单链表尾插

单链表头删

单链表尾删

单链表查找

单链表在目标节点前插入

单链表在目标节点后插入

单链表删除目标节点

单链表删除目标节点后的值

单链表销毁

双向带头循环链表

双链表结构

动态申请一个节点

双链表初始化

双链表判空

双链表尾插

双链表头查

双链表尾删

双链表头删

双链表查找

双链表在目标位置前插入

双链表删除目标位置节点

双链表销毁

总结


线性表

线性表的定义

在程序中,经常需要将一组(通常是同为某个类型的)数据元素作为整体管理和使用,需要创建这种元素组,用变量记录它们,传进传出函数等。一组数据中包含的元素个数可能发生变化(可以增加或删除元素)。

对于这种需求,最简单的解决方案便是将这样一组元素看成一个序列,用元素在序列里的位置和顺序,表示实际应用中的某种有意义的信息,或者表示数据之间的某种关系。

这样的一组序列元素的组织形式,我们可以将其抽象为 线性表。一个线性表是某类元素的一个集合,还记录着元素之间的一种顺序关系。线性表是最基本的数据结构之一,在实际程序中应用非常广泛,它还经常被用作更复杂的数据结构的实现基础。

根据线性表的实际存储方式,分为两种实现模型:

顺序表:将元素顺序地存放在一块连续的存储区里,元素间的顺序关系由它们的存储顺序自然表示。

链表:将元素存放在通过链接构造起来的一系列存储块中。


顺序表

顺序表的定义

用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储,与数组的数据类型是一致的,而数组的地址也是连续的。

顺序表结构

实现的顺序表要能随意更改长度,指定位置增删元素,更改长度,且会使用到动态内存,我们可以在堆区开辟空间,在顺序表空间不足时用realloc及时开辟空间,但静态的结构无法随意更改长度,所以动态的结构更有优势。

// 静态顺序表
#define N 10
typedef int SLDatetype;
typedef struct SeqList
{
	SLDatetype a[N]; //定义有效长度的数组
	int size;		 //有效数据个数
}SL;

// 动态顺序表
typedef int SLDatetype;
typedef struct SeqList
{
	SLDatetype* a;	//动态开辟的数组
	int size;		//有效数据个数
	int capacity;	//容量空间大小
}SL;

顺序表初始化

初始化时,理论上我们只需要开辟一个空间并置为空指针,并将结构体中的数据全部初始化为0即可。
但在实际开发过程中,我们一般会开辟一定大小的空间

void SLInit(SL* psl)
{
	psl->a = (SLDatatype*)malloc(sizeof(SLDatatype) * 4);
	if (psl->a == NULL)
	{
		perror("malloc fail");
		return;
	}
	psl ->capacity = 4;
	psl->size = 0;
}

顺序表扩容

在后续我们插入数据时,已开辟容量可能已经无法满足需求了。这是就需要扩容。
那一次扩到多少呢?在实际开发过程中我们一般是扩到原有空间的两倍。

void SLCheckCapacity(SL* psl)
{
	if (psl->size == psl->capacity)
	{
		SLDatatype* tmp = (SLDatatype*)realloc(psl->a, sizeof(SLDatatype) * psl->capacity * 2);
		if (tmp == NULL)
		{
			perror("realloc fail");
			return;
		}
		psl->a = tmp;
		psl->capacity *= 2;
	}
}

顺序表打印

上述函数定义完成后,我们通常需要测试打印以下相关数据,来判断相关函数定义是否成功。

void SLPrint(SL* psl)
{
	for (int i = 0; i < psl->size; i++)
	{
		printf("%d ", psl->a[i]);
	}
	printf("\n");
}

 顺序表尾插

但是在数据的尾部插入一个数据时,我们需要考虑一个问题:原有空间是否可以容纳新的数据,是否需要扩容。
所以我们在插入数据时,要先调用 SLCheckCapacity函数来检查是否需要扩容。

void SLPushBack(SL* psl, SLDatatype x)
{
	SLCheckCapacity(psl);

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

顺序表头查

在头部插入元素时需要注意几个细节:确保顺序表插入数据时内存足够,将顺序表当前的所有元素后移一位,只能从后往前挪动,从前往后挪动会覆盖数据,最后在头部插入元素。

void SLPushFornt(SL* psl, SLDatatype x)
{
	SLCheckCapacity(psl);
	int end = psl->size - 1;
	while (end >= 0)
	{
		psl->a[end + 1] = psl->a[end];
		end--;
	}
	psl->a[0] = x;
	psl->size++;
}

顺序表尾删

尾部删除元素,只需要size--即可,但尾删同样也要考虑一个问题,空间中是否还有数据删除,所以在进行尾删时,采用assert函数断言,防止越界和判断空间中是否还有数据可删除。

void SLPopBack(SL* psl)
{
	assert(psl->size > 0);
	psl->size--;
}

顺序表头删

头部删除元素,同样需要进行断言,防止越界和判断空间是否还有数据可删除,再将所有元素向前移动一位,直接将第一位元素覆盖掉即可。

void SLPopFront(SL* psl)
{
	assert(psl->size > 0);

	int left = 1;
	while (left < psl->size)
	{
		psl->a[left - 1] = psl->a[left];
		left++;
	}
	psl->size--;
}

在顺序表任意位置插入

进行插入之前需要检查pos位置的下标是否是有效下标,并检查是否有足够空间来容纳新数据,是否需要扩容。之后从输入的数据下标开始,所有元素向后移动一位,并把新数据插入到下标为pos处即可。

void SLInsert(SL* psl, int pos, SLDatatype x)
{
	SLCheckCapacity(psl);
	int end = psl->size - 1;
	while (end >= pos)
	{
		psl->a[end + 1] = psl->a[end];
		end--;
	}
	psl->a[pos] = x;
	psl->size++;
}

在顺序表任意位置删除

代码思路和上面任意位置插入数据思想类似。首先还是检查输入下标pos是否合法。之后从输入下标开始,后一个元素拷贝到前一个元素空间。

void SLErase(SL* psl, int pos)
{
	assert(psl);
	assert(pos >= 0 && pos < psl->size);

	int begin = pos + 1;
	while (begin < psl->size)
	{
		psl->a[begin - 1] = ps->a[begin];
		begin++;
	}
	psl->size--;
}

顺序表销毁

void SLDestroy(SL* psl)
{
	free(psl->a);
	psl->a = NULL;
	psl->capacity = 0;
	psl->size = 0;
}

由于上述空间是动态开辟的。所以当我们使用完时,要及时销毁,释放空间。


链表

链表的定义

顺序表与链表都属于线性表,线性表可以简单理解为一个有限的序列。顺序表在线性表的要求上,要求数据在内存中是连续的,所以我们用动态内存开辟了一个连续的空间来放顺序表。相比于顺序表,链表是一种物理存储结构上非连续非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 

从上图看出,链式结构逻辑上是连续的,但在内存中的存储可能是不连续的。

现实中的节点一般都是在堆上面申请的。

从堆上面申请空间是有其规律的,两次申请的空间可能连续也可能不连续。


链表的分类

实际中链表的结构非常多样,以下情况组合起来就有8种链表结构:

单向或双向:

带头(带哨兵位)或不带头:

循环或者非循环:

 8种链表结构分别是:单向链表,单向带头,单向循环,单向带头循环,双向链表,双向带头,双向循环,双向带头循环。

其中最常用的有两个分别是单向链表,双向带头循环链表

无头单向非循环链表:结构简单,一般不会单独用来存数据。实际中更多是作为其他数据结构的子结构,如哈希桶、图的邻接表等等。

带头双向循环链表:结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。


单向非循环链表

在使用顺序表时,若遇到了内存不足时,一般会选择2倍扩容,这会导致一定内存的浪费。而对于链表,由于没有对内存的限制,完全可以用添加一个数据开辟一个空间,删除一个数据销毁一个空间。


单链表结构

创建结构体,其中有两个成员,一个用来存储数据,一个用来指向下一个空间,并将其名字定义为SListNode方便后续使用

typedef int SLTDataType;
typedef struct SListNode
{
	SLTDataType data;		// 当前结构体数据
    struct SListNode* next; // 指向下一个结构体的指针

}SLTNode;

动态申请一个节点

申请节点用malloc函数,申请成功后把此空间的指针返回给newnode。
malloc开辟空间时有可能失败的,当开辟失败,malloc就会返回NULL

SLTNode* BuyLTNode(SLTDataType x)
{
    // 开辟一块空间,并用指针newnode维护此节点
	SLTNode* newnode = (SLTNode*)malloc(sizeof(SLTNode));
	if (newnode == NULL)
	{
		perror("newnode fail");
		return 0;
	}
	newnode->data = x;     // 将x赋值给data
	newnode->next = NULL;  // 初始化为NULL

	return newnode;        // 返回该节点的指针
}

单链表头插

先将phead的值赋给newnode->next,再将newnode的地址赋给phead

void SLTPushFront(SLTNode** phead, SLTDataType x)
{
	assert(phead);
	SLTNode* newnode = BuyLTNode(x);
	newnode->next = *phead;
	*phead = newnode;
}

单链表尾插

phead指针,即链表头指针,链表无法直接定位链表的尾部。每一个节点的next指针都存放着下一节点的指针,所以想要找到链表的尾部,就需要遍历此链表。遍历链表就会有一个不断改变的指针变量,假设此变量为tail,当tail->next == NULL时,tail指针指向的空间就是最后一个节点了,此过程称为找尾,最后将newnode指针赋值给tail->next

void SLTPushBack(SLTNode** phead, SLTDataType x)
{
	SLTNode* newnode = BuyLTNode(x);
    // 判断phead是否为NULL,若为NULL则将新节点赋值给phead
	if (*phead == NULL)
	{
		*phead = newnode;
	}
	else
	{
		SLTNode* tail = *phead;
        // 尾结点特征,当tail->next为空时就是尾结点
		while (tail->next != NULL)
		{
			tail = tail->next;
		}
        // 链接新节点
		tail->next = newnode;
	}
}

单链表头删

头删需要free的是头节点的地址,当只有一个节点的时候可以直接free,当有多个节点就需要注意,头节点被直接free后,第二个节点的指针就找不到了,可以创建一个del节点先保存第二个节点的指针,然后再free掉头结点,此时phead还是指向原来的空间,再把del->next赋值给phead。

void SLPopFront(SLTNode** phead)
{
	//没有节点
	assert(*phead);
	//一个节点
	if ((*phead)->next == NULL)
	{
		free(*phead);
		*phead = NULL;
	}
	//多个节点
	else
	{
		SLTNode* del = *phead;
		*phead = del->next;

		free(del);
	}
}

单链表尾删

尾删只有一个节点时候,头节点就是尾节点可以直接free,还需要对phead置空,当有多个节点时,与尾插一样,需要先找到尾结点,还需要将尾部节点的前一个节点置空

void SLPopBack(SLTNode** phead)
{
	//没有节点
	assert(*phead);
	//一个节点
	if ((*phead)->next == NULL)
	{
		free(*phead);
		*phead = NULL;
	}
	//多个节点
	else
	{
		SLTNode* tail = *phead;
		while (tail->next->next)
		{
			tail = tail->next;
		}
		free(tail->next);
		tail->next = NULL;
	}
}

单链表查找

循环遍历查找某个数字,找到了返回该地址,没有找到返回NULL

SLTNode* SListFind(SLTNode* phead, SLTDataType x)
{
	SLTNode* cur = phead;
	while (cur)
	{
		if (cur->data == x)
		{
			return cur;
		}
		cur = cur->next;
	}
	return NULL;
}

单链表在目标节点前插入

由于单链表只能从前往后走,而想要从中间位置插入数据,则需要改变前一个结构体的指针,所以只能插入指定位置后面的数据。该函数与尾插的思路一致,如果对一个空链表进行插入,就需要修改phead,所以要传入pphead。

需要先判断链表是否为空链表,如果为空则可以直接头查,不为空则创建一个新节点ptr,将新节点的next指向目标节点,最后目标位置前一个节点的next指向新节点完成链接。

void SLTInsert(SLTNode** phead, SLTNode* pos, SLTDataType x)
{
	assert(phead);
	assert(pos);
	if (*phead == pos)
	{
		SLTPushFront(phead, x);
	}
	else
	{
		SLTNode* ptr = *phead;
		while (ptr->next != pos)
		{
			ptr = ptr->next;
		}
		SLTNode* newnode = BuyLTNode(x);
		ptr->next = newnode;
		newnode->next = pos;
	}
}

单链表在目标节点后插入

该函数只需要遍历链表,找到pos位置之后先将新节点链接到pos的下一个节点,再让pos->next指向新节点即可。

void SListInsertAfter(SLTNode* pos, SLTDataType x)
{
	assert(pos);
	SLTNode* newnode = BuyLTNode(x);
	newnode->next = pos->next;
	pos->next = newnode;
}

单链表删除目标节点

判断该链表是否为一个或者多个节点,如果为一个节点则直接调用头删函数,如果为多个节点仍需要遍历找到pos的位置并用一个ptr指针来指向pos的前一个节点,使ptr->next指向pos后一个位置。直接链接ptr与pos下一个节点即可。

void SLTErase(SLTNode** phead, SLTNode* pos)
{
	assert(phead);
	assert(pos);
	if (pos == *phead)
	{
		SLPopFront(phead);
	}
	else
	{
		SLTNode* ptr = *phead;
		while (ptr->next != pos)
		{
			ptr = ptr->next;
		}
		ptr->next = pos->next;
		free(pos);
	}
}

单链表删除目标节点后的值

删除pos后面的节点,需要先断言pos后面有无节点。

void SListEraseAfter(SLTNode* pos)
{
	assert(pos);
	assert(pos->next);

	SLTNode* next = pos->next;
	pos->next = next->next;
	free(next);
}

单链表销毁

遍历一遍,每个都需要进行释放

void SLTDestroy(SLTNode** pphead)
{
	assert(pphead);
	SLTNode* cur = *pphead;

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

双向带头循环链表

双链表也叫双向链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双链表中的任意一个结点开始,都可以很方便地访问它的前驱结点后继结点。
结构最复杂,一般用在单独存储数据。实际中使用的链表数据结构,都是带头双向循环链表。另外这个结构虽然结构复杂,但是使用代码实现以后会发现结构会带来很多优势,实现反而简单

双链表结构

创建结构,其中包含三个成员,一个用来存放数据,另外两个指针,分别指向前一个空间和后一个空间

typedef int LTDataType;
typedef struct ListNode
{
	LTDataType data;
	struct ListNode* next;  // 前驱指针
    struct ListNode* prev;  // 后驱指针
}ListNode;

动态申请一个节点

每次给链表插入数据时,都需要动态开辟空间申请结点。所以我们将这个过程封装成函数,方便后续使用。

ListNode* BuyLTNode(LTDataType x)
{
	ListNode* newnode = (ListNode*)malloc(sizeof(ListNode));
	if (newnode == NULL)
	{
		perror("malloc fail");
	}
	newnode->data = x;
	newnode->next = NULL;
	newnode->prev = NULL;

	return newnode;
}

双链表初始化

定义一个双向循环链表后,初始化链表,此时只有一个phead(哨兵位),前驱指针和后驱指针都指向phead自己 哨兵位的数据(data)在应用中不使用,就设置成-1了,与之后使用的正整数形成差异。

ListNode* LTInit()
{
	ListNode* phead = BuyLTNode(-1);
	phead->next = phead;
	phead->prev = phead;

	return phead;
}

双链表判空

判空函数方便检查链表是否为空,后续方便使用

bool LTEmpty(ListNode* phead)
{
	assert(phead);

	return phead->next == phead;
}

双链表尾插

由于双链表的特性,其哨兵位的前置指针指向尾结点,所以我们不用像单链表一样进行遍历后才能尾插。

创建一个新结点newnode,然后将newnode插入到尾结点tail的后面,让tail的next指向newnode,让newnode的prev指向tail;让newnode的next指向头结点phead,头结点phead的prev指向newnode。建立这样的连接后,尾插就完成了。

void ListPushBack(ListNode* phead, LTDataType x)
{
	assert(phead);

	ListNode* tail = phead->prev;
	ListNode* newnode = BuyLTNode(x);

	tail->next = newnode;
	newnode->prev = tail;

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

}

双链表头查

在双链表的头结点(哨兵位)后面的一个结点前插入数据。我们调用BuyLTNode()函数创建一个新结点newnode,让newnode的next指向头结点phead的next,头结点phead的next的prev指向newnode;让头结点phead的next指向newnode,newnode的prev指向phead。

void ListPushFront(ListNode* phead, LTDataType x)
{
	assert(phead);
	ListNode* newnode = BuyLTNode(x);
	ListNode* first = phead->next;


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

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

双链表尾删

尾插插入结点的时候,只要能够找到尾结点,就可以很轻松地插入新结点。如果经常用尾插法,可以设置尾指针,加快查找的速度。

void ListPopBack(ListNode* phead)
{
	assert(phead);
	//链表不能只有一个哨兵位
	assert(phead->next != phead);
	ListNode* del = phead->prev;
	//删除节点的前驱指针
	del->prev->next = phead;
	//phead的前驱指针
	phead->prev = del->prev;
}

双链表头删

创建一个指针del,让该指针先存放第二个结点的地址,避免第一个结点被释放后,第二个结点找不到,先让del的下一个节点的头指针指向头结点,再让头结点的尾结点指向del的下一个节点,最后释放该结点del。

void ListPopFront(ListNode* phead)
{
	assert(phead);
	ListNode* del = phead->next;

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

	free(del);
	del = NULL;
}

双链表查找

定义一个指针cur从第一个结点(非头结点)开始查询,如果找到了,就返回该结点的地址,如果找不到就返回NULL。

ListNode* ListFind(ListNode* phead, LTDataType x)
{
	assert(phead);
	ListNode* cur = phead->next;

	while (cur != phead)
	{
		if (cur->data == x)
		{
			return cur;
		}
		cur = cur->next;
	}
	return NULL;
}

双链表在目标位置前插入

因为是插入数据 所以我们要先申请一个内存,又因为我们已经得到了pos的位置,所以可以直接找到pos的上一个数据,原理在于找两个位置 一个是pos,一个是pos的上一个数据,pos我们已经有了它的位置 pos的上一个位置我们只需要用指针指向它就可以了,pos的上一个位置命名为node,然后让新数据在pos的上一个数据与pos中间进行插入。

void ListInsert(ListNode* pos, LTDataType x)
{
	assert(pos);
	ListNode* newnode = BuyLTNode(x);
	ListNode* prev = pos->prev;

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

}

双链表删除目标位置节点

双向循环链表的删除大致与单链表相同,但在两相隔结点的重新链接时需要连结两个指针,即被删除结点的前置结点的后继指针指向被删除结点的后继结点,而后继结点的前置指针指向前置结点。

void ListErase(ListNode* pos)
{
	assert(pos);
	ListNode* posPrev = pos->prev;
	ListNode* posNext = pos->next;

	posPrev->next = posNext;
	posNext->prev = posPrev;
	free(pos);

}

双链表销毁

遍历一遍链表进行销毁,cur碰到phead哨兵位为止,释放cur前,记录下cur->next,释放cur后,把cur->next赋值给cur,以此避免销毁cur后,cur->next不能指向下一个节点的情况,最后再把哨兵位释放置空。

void ListDestory(ListNode* phead)
{
	assert(phead);
	ListNode* cur = phead->next;

	while (cur != phead)
	{
		ListNode* next = cur->next;
		free(cur);
		cur = next;
	}

	free(phead);
}

总结

不同点顺序表链表
存储空间上
物理上一定连续
逻辑上连续,但物理上不一定连
随机访问
支持:O(1)
不支持:O(N)
任意位置插入或者删除元
可能需要搬移元素,效率低O(N)

只需修改指针指向

插入
动态顺序表,空间不够时需要扩
没有容量的概念
应用场景
元素高效存储+频繁访问
任意位置插入和删除频繁
缓存利用率

顺序表:

优点:尾删尾插效率高,访问随机下标快

缺点:空间不够需扩容(扩容代价大);头插头删及中间插入删除需要挪动数据,效率低

链表:

优点:需要扩容时,按需申请小块空间;任意位置插入效率都高(O(1))

缺点:不支持下标随机访问

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值