线性表的实现分顺序存储结构和链式存储结构
上一节我们主要介绍了顺序存储结构,在最后我们还分别总结了顺序存储结构的优缺点,
对于顺序结构的缺点,我们有没有什么好的解决方法呢?
我们今天要介绍的线性表的链式存储结构就可以很好的解决顺序结构的缺点,一起来看。
顺序结构最大的缺点就是在进行插入和删除操作的时候,如果插入位置不理想,那么我们需要移动大量的元素,那产生这一问题的原因是什么呢?
仔细分析后,我们可以发现在顺序存储结构中,他们相邻的元素的存储位置也是相邻的,我们在申请内存的的时候,是一次性申请一块连续的内存,中间是没有空隙的,这样我们也就没办法进行快速的插入,如果进行删除操作,就需要进行位置的填充。
链式存储结构之所以能很好地解决这个问题,原因就在于它不考虑存储位置的相邻关系了,哪里有空位就存到哪里,我们只需要让每个元素都知道下一个元素的位置在哪里就可以了。
这样就是我们在定义链式存储结构的时候,除了定义它本身需要存储的信息之外,还需要存储一个能够指示它直接后继的一个信息,这个信息我们可以用指针来表示。
这也就是我们课本上讲的数据域和指针域。
我们将这种只带有一个指针域的线性表称为单链表。
链表中第一个结点的存储位置叫做头指针。
单链表的第一个结点钱附设一个结点,称为头结点。
上一节我们提到过,线性表的最后一个元素是没有直接后继的,所以在链式存储中,我们将最后一个结点的指针域设置为null.
下面我们来看单链表的具体代码实现
1 typedef struct LNode{
2 ElemType data; //数据域
3 struct LNode *next; //指针域,用来指向本节点的直接后继
4 }LNode,*LinkList; //定义节点,以及头指针
链式存储结构
很多同学分不清头指针,头结点,以及第一个结点之间的关系和区别,我们下面简单地区分下。
头指针:是指向链表的指针,如果链表有头结点,它会指向头结点
头结点:第一个结点之前的一个辅助结点,其next指向第一个结点
第一个结点:就是一个结点,data变量存放第一个数据,next指针变量指向第二个结点
(画图渣渣,大家将就着看)
这里要注意的就是头指针是一个链表的必要元素,而头结点却不是,那么头结点存在的意义是什么呢?
个人的理解就是使第一个结点的插入操作和删除操作和后面结点的操作一致,否则我们在修改第一个结点的时候,就需要修改头指针。
如果没有头结点,头指针直接指向第一个结点。
下面我们来看链式存储结构的具体操作
在进行操作之前我们需要建立一个连式存储结构的链表
下面看插入操作
1 void CreateList(LinkList *L,int n){
2 //创建一个有n个结点的链表
3 LinkList p; //新结点
4 int i; //循环变量
5 L = (LinkList)malloc(sizeof(LNode));
6 L->next = NULL; //构建一个带头结点的空链表
7 for(i=0;i<n;i++){
8 p = (listLink1)malloc(sizeof(LNode)); //申请申结点的内存
9 scanf(&(p->data)); //赋值
10 p->next = L->next;
11 L->next = p; //插入的表头
12 }
13
14 }
创建链表
我觉得这个创建链表的方法可以叫插队法,它就是无限在插队,哈哈
1 int ListInsert(LinkList *L,int i,ElemType e){
2 //向第i个位置之前插入节点,数据域为e
3 int j; //查找插入位置的辅助变量,用于循环
4 LinkList p,newNode;
5 p = *L; //将指针p指向链表头结点
6 j = 1; //从1开始遍历
7 while(p&&j<i){
8 p = p->next;
9 j = j + 1;
10 } //寻找插入位置,第i-1个节点
11 //这里之所以不用for是因为我不知道这个链表的长度
12 if(!p||j>i-1){
13 return 0;
14 } //找到位置后,我们需要对位置j的合理性进行验证
15
16 //下面就是正式的插入节点了
17 newNode = (LinkList)malloc(sizeof(LNode));
18 //申请新节点的内存位置
19
20 newNode->data = e; //将e的值存入新节点的数据域
21 newNode->next = p->next; //新节点的指针域指向第i个位置
22 p->next = newNode; //i-1位置的节点的指针域指向新节点
23 //但一定要注意上面三步的顺序
24
25 return 1; //插入成功
26
27 }
插入操作
写完代码之后,我们一起来整理下插入结点的思路:
- 声明一个指针p指向链表的头结点
- 循环遍历,找到插入的位置,让p的指针不断向后移动,不断指向下一个结点,计数变量j累加
- 判断插入位置的合理性
- 生成新结点
- 将要插入的值赋给新结点的数据域
- 单链表插入的重点:newNode->next = p->next; p->next = newNode;
- 成功
接下来看链式存储的删除操作
1 int ListDelete(LinkList *L,int i,ElemType *e){
2 //删除第i个位置的结点,并用e返回其数据域
3 int j; //寻找删除位置的辅助变量,用于循环
4 LinkList p,q;
5 p = *L; //将指针p指向链表头结点
6 j = 1; //从1开始遍历
7
8 while(p->next&&j<i){
9 p = p->next;
10 j = j+1;
11 }
12 //寻找删除的位置,注意这里是用p->next开始遍历的
13 if(!(p->next)||j>i){
14 return 0;
15 } //判断删除位置是否合理
16 //这里要给大家简单解释一下,我们这里要判断的是p->next而不是p
17 //原因是,删除的时候要将删除位置的前后两个结点连起来,通过前面的while循环,前结点一定是存在的
18 //那么重点就是要判断一下是否存在后继结点了
19 //这样就会有另一个问题,最后一个元素怎么删除,答案是尾结点
20 //这里我们默认是存在尾结点的,这个尾结点和头结点的作用差不多,为了让最以后一个结点的插入和删除操作和别的结点是一样的
21
22
23 q = p->next; //让q指向要删除位置的结点
24 p->next = q ->next; //让要删除结点的前驱的后继指向要删除点的后继
25 e = q->data; //赋值
26 free(q); //释放内存
27
28 return 1; //删除成功
29 }
删除操作
整理一下删除结点的思路:
- 声明指针p指向链表的头结点
- 循环遍历查找要删除的位置,计算变量累加
- 判断删除位置的合理性,如果要删除的结点的后继为空,则位置不合理
- 将要删除的结点p->next 赋值给q
- 单链表删除语句 p->next = q->next
- 赋值
- 释放内存
- 成功
写完插入和删除操作,我们便可以看出,链式存储结构对于插入和删除的优势是明显的,不需要进行大量的元素的移动。
增删改查,增和删给大家说完了,下面来说改和查,个人感觉这两个其实差不多,改就是比查多了个修改元素,所以这里我只给大家贴一个查的代码了
1 int GetElem(LinkList L,int i,ElemType &e){
2 int j; //计数变量
3 LinkList p;
4
5 p = L->next; //p指向链表的第一个结点
6 j = 1;
7
8 while(p && j<i){
9 p = p->next; //p指向下一个结点
10 j = j+1;
11 }
12
13 if(!p||j>i){
14 return 0;
15 } //判断位置合理性
16
17 e = p->next; //赋值
18 return 1; //成功
19 }
查找操作
观察查找操作,大家就会发现,链式存储结构也是有其缺点的,其不便于进行查找和修改
这里再给大家说下另外两种其他形式的链表
1、循环链表:
这个就是在单链表的基础上头尾相接了,将最后一个结点的指针指向了L->next;这里我们也不多做赘述,它的大部分操作和单链表是相似的。
还有一点要注意的就是判断一个循环链表是否为空条件是看头结点是否指向其本身。
2、双向链表:
就是在单链表的基础上多了一个指针域,用来指向其前驱,这个主要是解决了单链表只能顺指针往后选差其他的结点的问题。
1 typedef struct DulNode
2 {
3 struct DulNode *prior; //前驱指针
4 struct DulNode *next; //后继指针
5 ElemType e; //数据域
6 }DulNode,*DuLinkList;
双线链表
然后简单说说插入操作,我画个简图帮助大家理解,然后写下关键代码
1 s->prior = p; //把p赋值给s的前驱,如图中的1
2 s->next = p->next; //把p->next赋值给s的后继,如图中的2
3 p->next->prior = s; //把s赋值给p-next的前驱,如图中的3
4 p->next = s; //把s赋值给p的后继,如图中的4
插入关键代码
下面是删除,老规矩,先上图
1 P->prior->next = p->next; //把p->next的值赋给p->prior的后继,如图中的①
2 p->next->prior = P->prior;//把p=>prior赋值给p->next的前驱,如图中的②
删除关键代码
对于双向离岸边来说,它要稍微复杂些,因为了一个前驱指针,这样,在插入和删除的时候尤其小心指针变换的顺序。
但是由于前后指针都有,这样一定程度上给我们的操作带来了方便。
到这里,我们的链式存储就算是讲完了,最后将顺序存储结构和链表存储结构进行一下对比
我们主要从两个方面进行对比,一个是时间,一个是空间
时间:
- 查找
顺序存储结构 O{1}
链式存储结构O{n}
- 插入和删除
顺序存储结构 O{n}
链式存储结构O{1}
空间:
- 顺序存储结构:需要提前分配空间大小,分配打了,产生碎片,浪费,分配小了,容易导致溢出
- 连式存储结构:不需要预分配,只要有就可以分配,并且数量不受变量限制
从以上比较不难看出
1、若线性表需要频繁的查找,很少进行插入和删除操作的时候,应该采用顺序存储。相反,则应采用链式存储结构
2、当线性表不知道到底有多大的时候,建议采用链式存储结构,如果我们已经提前知道其大小,可采用顺序存储结构
3、顺序存储结构和链式存储结构各有各的优缺点,不能一概而论说就是哪种更好。我们需要根据实际情况来选择我们需要的结构
线性表到这里就算是结束了,接下来会带大家一起学习栈。