线性表的结构详解


线性表总结

  • 一、线性表的定义
  • 二、线性表的存储结构
    • 1、顺序存储
      • 1.1 线性表获得元素的操作
      • 1.2 插入操作
      • 1.3 删除操作
      • 1.4 插入与删除的时间复杂度分析
      • 1.5 线性表的顺序存储结构的优缺点
    • 2、链式存储
      • 2.1链式存储结构
      • 2.2 头指针与头节点的区别
      • 2.3单链表的结构定义
      • 2.4 链表查数据
      • 2.5 插入操作
      • 2.6 删除操作
      • 2.7 单链表的整表创建
        • 2.7.1 头插法
        • 2.7.2 尾插法
      • 2.8 单链表的整表删除
      • 2.9 单链表结构与顺序表存储结构优缺点
  • 三、循环链表
  • 四、双向链表

一、线性表的定义 线性表(List):零个或多个元素的有限序列
需要注意的地方:
  1. 它是一个序列:也就是说,元素之间是有顺序的,若元素存在多个,则第一个元素无前驱,最后一个元素无后继,其他每个元素都有且只有一个前驱和后继。
  2. 线性表强调是有限的,即元素的个数是有限的。
    线性表包含:顺序表和链表,所以不管是哪一个都遵循线性表的这个约定。
二、线性表的存储结构 1、顺序存储 线性表的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。因为底层实现原理是基于数组实现,所以,在内存中的分配会开辟一段连续的地址空间,如图所示:
线性表的结构详解
文章图片

具体的结构定义为:
#define MAXSIZE 20 typedef int ElemType; typedef struct{ ElemType data[MAXSIZE]; /*数组存储数据元素,最大为MAXSIZE*/ int length /*线性表当前的长度*/ }SqList;

“数组的长度”与“线性表的长度”区别:
  1. 数组长度是存放线性表的存储空间的长度,即能够存放多少个数据值
  2. 线性表的长度即当前线性表中数据元素的个数
  3. 在任意时刻,线性表的长度应该小于等于数组的长度。
1.1 线性表获得元素的操作
/*获取给定第i个元素位置,返回该位置的值*/ #define OK 1; #define ERROR 0; typedef int Status; Status GetElem(SqList L,int i,ElemType *e) { if(L.length == 0 || i<1|| i>L.length){ return ERROR; } *e = L.data[i-1]; return OK; }

1.2 插入操作
/*将元素e插入到顺序表中的第i个位置*/ Status ListInsert(SqList *L,int i,ElemType e){ int k; if(L->length == MAXSIZE){//容量已经达到上界 return ERROR; } if(i < 1 || i > L->length+1){//插入位置不合法 return ERROR; } if(i < L->length){//若插入的位置不在表尾 for(k = L->length-1; k >= i-1; k--){ L->data[k+1] = L->data[k]; //从第最后一个元素开始,依次向后挪一个位置,直到i的前一位 } } L->data[i-1] = e; //将元素插入第i个位置; L->length++ ; return OK; }

1.3 删除操作
/*删除顺序表L中的第i个位置的值,并将其返回,length-1*/ SqList ListDelete(SqList *L ,int i,ElemType *e){ int k; if(L->length == 0) return ERROR; if(i < 1 || i > L->length - 1){ return ERROR; } *e = L->data[i-1]; if(i < L->length -1){ for(k = i; k < L->length; k++){ L->data[k-1] = L->data[k]; } } L->length --; return OK; }

1.4 插入与删除的时间复杂度分析
先看 最好的情况: 如果插入最后一个位置,或者是删除最后一个位置,时间复杂度为 O(1),此时不需要移动任何元素;最坏的情况: 如果插入和删除都是第一个位置,需要将所有的元素都进行移动,时间复杂度为O(n)。 平均的情况: (0+1+…n-1)/n = (n-1)/2次,所以平均时间复杂度为O(n)。
说明线性表的顺序存储结构,在读数据时,不管是哪个位置,时间复杂度均为O(1);而插入数据或删除数据时,时间复杂度均为O(n)。
1.5 线性表的顺序存储结构的优缺点
优点: 1. 无须为表示表中元素之间的逻辑关系而增加额外的存储空间 2. 可以快速存储表中的任一元素 缺点: 1. 插入和删除操作需要移动的数据元素相对较高 2. 当线性表的长度变化较大时,难以确定存储空间的容量 3.造成存储空间的“碎片”。

2、链式存储
在引入链式存储之前应该知道一个问题:为什么会出现链式存储的结构? 个人的思考:一个新的东西出现肯定是在改进以前的方式存在的不足,从而让性能得到最优化,提高效率。 所以,引入链式存储的原因也是在改进顺序存储结构的不足之处(上面已经介绍了顺序存储的不足),提升性能。

2.1链式存储结构
线性表的链式存储结构特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以不是连续的。 这一点就和顺序表存在一的一种区别,即不需要再内存单元中分配连续的内存地址,在链式存储中具体的结构,在下文会继续深入。
以前在顺序存储结构中,每个数据元素只需存储数据元素信息就够了。现在链式存储结构中,除了要存储数据元素的信息之外,还要存储后继元素的存储地址。
因此,为了表示每个数据元素 a i a_i ai?与其直接后继数据元素 a i + 1 a_i+_1 ai?+1?之间的逻辑关系,对元素 a i a_i ai?来说,除了存储本身的信息之外,还需存储一个指示其后继的信息(即直接后继的存储位置)。我们将数据元素的域称为data域,将存储直接后继的位置称为next指针域,指针域中存储的信息称为指针或链。这两部分组成了数据元素的存储映像,称为节点(Node)
具体的形式为:
线性表的结构详解
文章图片

链表中的第一个节点的存储位置叫做头指针,那么整个链表的存取就是必须从头指针开始进行。之后的每一个节点,其实就是上一个的后继指针指向的位置。最后一个位置,由于后面没有元素,因此其指向为NULL。
因此,在进行单链表的存储时,会在第一个节点之前插入一个节点,称为头节点。头节点也会存在两个域,即data域和指针域next,其中头节点的data域可以不存储数据,也可以用来存储链表的长度等信息,不是很重要。
2.2 头指针与头节点的区别
**头指针:** 1、头指针是指链表指向第一个节点的指针,若链表有头节点,则是指向头节点的指针 2、头指针具有标识作用,常用头指针称为链表的名字 3、无论链表是否为空,头指针均不为空。头指针是链表的必要元素(即必须要有)。 ** 头节点 ** 1、头节点是为了操作的统一和方便而设立的,放在第一个元素的节点之前,其数据域一般无意义(也可以存放链表的长度)。 2、有了头节点在第一个元素之前的操作就与其他的节点操作就统一了。 3、头节点不一定是链表的必要元素(不是必须要有)。

2.3单链表的结构定义
/*线性表的存储结构*/ typedef struct Node{ ElemType data; struct Node * Next; } Node; typedef struct Node * LinkList; //定义单链表

从结构定义可以看出,节点由存放数据的data域和存放后继指针的next指针域组成。假设p是指向线性表第i个元素的指针,则节点 a i a_i ai?的数据域我们可以用p->data来表示,表示当前节点的值,节点的指针域用p->next表示,其值是第 i + 1 _i+_1 i?+1?个元素的地址。也就是说:p->data =https://www.it610.com/article/a i a_i ai?,p->next->data =https://www.it610.com/article/a i + 1 a_i+_1 ai?+1?,具体如图所示:
线性表的结构详解
文章图片

2.4 链表查数据
/* 用 e 返回L中第i个数据的元素*/ Status GetElem(Linklist L,int i,ElemType *e) { int k; LinkList p; //定义一个指针p p = L->next; //将其指向第一个节点 k = 1; //用于计数 while(p && k < i){ p = p->next; ++k; } if(!p || k> i){ return ERROR; //第i个节点不存在 } *e = p->data; return OK; }

链表查数据就是从头开始找,直到第i个节点为止。时间复杂度取决于i,当i = 1时,不需要遍历,时间复杂度为O(1),当 i=n时,遍历n-1此,最坏时间复杂度为O(n),
2.5 插入操作
单链表的插入可以理解为:
线性表的结构详解
文章图片

将元素e插入到链表中,实现为:
s->next = p->next; p->next = s ;

具体的实现算法如下:
/*向单链表的第i个位置插入一个新的节点e*/ Status ListInsert(LinkList *L,int i,ElemType *e){ int k = 1; //标记位 LinkList p,s; p = *L; while(p && k < i){//寻找第i-1个位置 p = p->next; k++; } if(!p || k > i){// 第i个节点不存在 return ERROR; } s = (LinkList) malloc(sizeof(Node)); //生成一个类型位LinkList,大小为Node的节点s s->data = https://www.it610.com/article/e; s->next = p->next; //将p的后继链接在s后面 p->next = s; //将s链接在p后面 return OK; }

2.6 删除操作
单链表的删除操作可以理解为:
线性表的结构详解
文章图片

实现单链表的删除操作,其实就是将 a i ? 1 a_i-_1 ai??1?的next指向 a i + 1 a_i+_1 ai?+1?,实现为:
q = p->next; p->next = q->next; delete(q); //将内存中的q节点释放掉,即释放掉要删除的元素内存

具体的实现代码如下:
/*删除链表L的第i个节点,并将其值返回到e,长度减1*/ Status ListDelete(LinkList *L,int i,ElemType *e){ int k = 1; LinkList p,q; p = *L; while(p->next && k < i){ p = p->next; k++; } if(!(p->next) || k > i){ return ERROR; } q = p->next; //将q指向p的后继 p->next = q->next; //将p的后继指向q的后继 *e = q->data; delete(q); //释放节点q L->length--; }

从整个算法来看,很容易推出,他们的时间复杂度都为O(n),如果不知道单链表第i个节点的位置,单链表的插入删除操作,与线性表的顺序存储结构是没有区别的,但是如果希望从第i个位置,插入节点,则在顺序表中的时间复杂度为O(n),需要将n-i个数据不断的进行移动,但是在单链表中,进行插入删除操作时,只需要改变链表的指针指向位置,时间复杂度为O(1)。因此,对于插入和删除操作很频繁的时候,选用链表的结构时比较方便高效的。
2.7 单链表的整表创建
在顺序表的创建中,其实就是数组的初始化操作,即声明一个类型和大小的数组并赋值的过程。但是在单链表中,它不像顺序结构这么集中,它可以很分散,是一种动态结构。对于链表来说,它所占用的空间大小和位置不需要预先分配好,可以根据实际需求进行即时生成。
2.7.1 头插法 单链表创建整表的头插法算法思路:
/* 随机产生n个值,并将其插入到单链表头部 */ void CreateListHead(LinkList *L,int n){ LinkList p; int i; srand(time(0))//初始化 *L = (LinkList) malloc(sizeof(Node)); (*L)->next = NULL; for(i = 0; i < n; i++){ p = (LinkList) malloc(sizeof(Node)); //生成新的节点 p->data = https://www.it610.com/article/rand()%100 +1; p->next = (*L)->next; (*L)->next = p; //插入到表头 } }

2.7.2 尾插法 单链表创建整表的尾插法算法思路:
/* 随机产生n个值,并将其插入到单链表尾部 */ void CreateListTail(LinkList *L,int n){ LinkList p,r; int i; srand(time(0))//初始化 *L = (LinkList) malloc(sizeof(Node)); (*L)->next = NULL; r = *L; //将r指向表尾 for( i = 0; i < n; i++){ p = (LinkList) malloc(sizeof(Node)); //生成新的节点 p->data = https://www.it610.com/article/rand()%100 +1; r->next = p; //将一个新的节点插入到表尾 r = p; //移动尾节点的指针到最后p节点位置 } r->next = NULL; }

2.8 单链表的整表删除
当不需要单链表时,则对其进行删除操作,其实就是将它在内存中的空间释放掉。
/*将单链表中的元素逐个删除,清空单链表*/ Status ClearList(LinkList *L){ LinkList p,q; p = (*L)->next; while(p){ q = p->next; delete(p); p = q; } (*L)->next = NULL; //将头节点的指针域置为空 return OK; }

在该代码中,应该思考一个问题,q的作用:在进行删除操作时,如果删除当前节点,为了下次能寻找到下一个节点在哪,所以必须在进行删除之前,将当前节点的下一个节点存储起来。
2.9 单链表结构与顺序表存储结构优缺点
单链表结构和顺序存储结构的对比:
  1. 存储分配方式
    • 顺序存储结构用一段连续的地址空间进行存储。
    • 单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素。
  2. 时间性能:
    1、查找: 顺序存储结构O(1) 链式存储结构O(n)
    2、插入和删除:顺序存储结构 需要平均移动表长一半的元素,时间为O(n);单链表 的插入和删除仅为O(1)。
  3. 空间性能
    顺序存储结构需要预分配存储空间,不好确定大小,所以一般都会预分配相对较大的空间;
    单链表不需要分配存储空间,只要内存有空间就可以分配,元素个数也不受限制。
三、循环链表 定义: 将单链表中终端节点的指针由空指针改为指向头节点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表。
对于非空循环单链表结构示意图可以表示为:
线性表的结构详解
文章图片

  • 其实循环链表和单链表的主要差异在于循环的判断条件上,单链表判断 p->next = NULL,循环链表是判断p->next是否为头节点,则循环结束。
  • 在单链表中有了头节点,可以用O(1)时间访问第一个节点,但对于要访问最后一个结点,却需要O(n)时间将整个链表访问一遍。
如果要在时间O(1)内访问链表的尾部结点,可以在链表的尾部设置一个尾部指针rear,则其头部指针可以表示为rear->next。
举个例子:将两个循环链表rearA、rearB合成一个表,示意图为:
线性表的结构详解
文章图片

操作步骤:
线性表的结构详解
文章图片

实现的过程:
p = rearA->next; //操作 ①保存A表的头节点 rearA->next = rearB->next->next; //操作② 将B表链接在A表后面 q = rearB->next; rearB->next = p; //操作 3 讲A表的头节点赋给rearB->next delete(q); //删除B表的头节点

四、双向链表 为了克服单项性的缺点,设计了双向链表,双向链表是在单链表的每个结点中,再设置一个指向其前驱结点的指针域,所以在双线链表中,每个结点都有两个域:前驱指针、后继指针。
双向链表的结构定义为:
typedef DulNode{ ElemType data; stauctDulNode *prior; stauctDulNode *next; }DulNode,*DuLinkList;

结构示意图为:
线性表的结构详解
文章图片

因此,在双向链表中,p->next->prior = p = p->prior->next
双向链表的插入操作:
线性表的结构详解
文章图片

实现细节:
s->prior = p; //将s的前驱指向p s->next = p->next; //将s的后继指向p的后继 p->next->prior = s; //将p的后继的前驱指向s p->next = s; //将p的后继指向s

【线性表的结构详解】双向链表的删除操作:
线性表的结构详解
文章图片

实现细节:
p->prior->next = p->next; p->next->prior = p->prior;

    推荐阅读