第6~13讲: 线性表
一 线性表相关知识
1 定义:由零个或多个数据元素组成的有限序列
数学定义:若将线性表定义为(a1,a2,...,ai-1,ai,ai+1,...an),则表中ai-1领先于ai,ai领先于ai+1,称ai-1是ai的直接前驱元素,ai+1是ai的直接后继元素
线性表的长度:线性表元素的个数n(n>0),当n=0时,称为空表。
2 特点:
- 线性表是一个序列,有先后顺序
- 若存在多个元素,则第一个元素无前驱,而最后一个元素无后继,其它元素有且只有一个前驱和后继
- 线性表的元素是有限的
二 抽象数据类型
1 数据类型的定义:指一组性质相同的类型的集合及定义在此集合上的一些操作的总称,例如:整型、浮点型、布尔型、字符型
2 C语言中数据类型的分类
原子类型:不可以再分解的基本类型,例如:整型、浮点型、字符型
结构类型:由若干个类型组合而成,是可以再分解的,例如整型数组是由若干整型数据组成的。
3 抽象:是指抽取出事物具有的普遍性本质。它要求抽象出问题的特征而忽略非本质的细节,是对具体事物的一个概括。
4 抽象数据类型:我们对数据类型进行了抽象,就得到了抽象数据类型
- 抽象数据类型的定义(Abstract Data Type,ADT):指一个数据类型及定义在该数据类型上的一组操作
- 特点:数据类型的定义仅取决于他的一组逻辑特性,而与其在计算机内部如何表示和实现无关
- 意义:“抽象”的意义在于数据类型的数学抽象特性
- 类型:
- 已经设计定义并实现的数据类型
- 计算机编程人员在设计软件程序时自己定义的数据类型
- 描述抽象数据类型的标准格式:
- ADT 抽象数据类型名
- Data
- 数据元素之间逻辑关系的定义
- Operation
- 操作
- endADT
三 线性表的抽象数据类型
1 定义:
- ADT 线性表(list)
- Data
- 线性表的数据对象集合为{a1,a2,...,an},每个元素的类型均为DataType。其中,除第一个元素a1外,每个元素有且只有一个直接前驱元素;除了最后一个元素an外,每个元素有且只有一个直接后继元素。数据元素之间的关系是一对一关系
- Operations
- Initlist(*L):初始化操作,建立一个空的线性表L。
- ListEmpty(L):判断线性表是否为空表,若线性表为空,则返回true,否则,返回false
- ClearList(*L):将线性表清空
- GetElem(L,i,*e):将线性表L中的第i个位置元素值返回给e
- LocateElem(L,e):在线性表L中查找与给定值e相等的元素,如果查找成功,返回该元素在表中序号表示成功;否则返回0表示失败
- ListInsert(*L,i,e):在线性表L中第i个位置插入新元素e
- ListDelete(*L,i,e):删除线性表中第i个位置的元素,并用e返回其值
- ListLength(L):返回线性表L的元素个数
- endADT
注意:对于不同的应用,线性表的基本操作是不同的,上述操作是最基本的,对于实际问题中涉及的关于线性表的更复杂的操作,完全可以用这些基本操作的组合来实现
2 求并集的例子
- A=AUB——即把存在集合B中但不存在集合A中的元素插入到A中即可。
- 编程思路:循环遍历集合B中的每个元素,然后判断是否在集合A中,如果不在,将该元素插入集合A中即可
- 需要的操作:
- C语言的伪代码(运行不了,部分功能得自己实现,目前我也不知道怎么弄)
-
1 void unionL(List *La,list Lb) 2 { 3 int La_len,Lb_len,i; 4 5 ElemType e; 6 La_len = ListLength(*La); 7 Lb_len = ListLength(Lb); 8 9 for (i=1;i<=Lb_len;i++) 10 { 11 GetElem(Lb,i,&e); 12 if(!LocateElem(*La,e)) 13 { 14 ListInsert(La,++La_len,e); 15 } 16 } 17 }
四 线性表的顺序存储结构
线性表有两种物理存储结构,顺序存储结构和链式存储结构
1 定义:用一段地址连续的存储单元依次连续存储线性表的数据元素。
2 结构代码:
1 # define MAXSIZE 20 2 typedef int ElemType; 3 typedef struct{ 4 ElemType data[MAXSIZE]; 5 int length; // 线性表当前长度 6 }Sqlist;
3 顺序结构封装需要三个属性
- 存储空间的起始位置:数组Data,它的存储位置就是线性表存储空间的存储位置。
- 线性表的最大存储容量:数组的长度MAXSIZE。
- 线性表的当前长度:length
- 注意:
- 数组长度:存放线性表的存储空间的总长度,一般初始化之后不变
- 线性表的当前长度:线性表中元素的个数,是会变化的。
4 地址计算方法
- 假设ElemType占用的是C个存储单元(字节),那么线性表中第i+1个数据元素和第i个数据元素的存储位置的关系是:LOC(ai+1) = LOC(ai) + C
- 所以对于第i的数据元素ai的存储位置可以由a1推算得出:LOC(ai) = LOC(a1) + C*(i-1)
- 通过该表达式可以推算出线性表中任意位置的地址,不管是第一个还是最后一个,都是相同的时间
- 存储时间性能:O(1)——随机存储结构
5 获得元素操作
- GetElem操作:将线性表L中第i个位置的元素值返回
- 编程思路:只需要把数组第i-1下标的值返回即可
- 实现代码:getElem.c文件
-
1 #define OK 1 2 #define ERROR 0 3 #define TRUE 1 4 #define FALSE 0 5 6 typedef int Status; 7 // Status 是函数的类型,其值是函数结果的状态代码,入OK等。 8 // 初始条件:顺序线性表L已存在,1 <= i <= ListLength(L) 9 // 操作结果:用e返回L中第i个数据元素的值。 10 Status GetElem(SqList L,int i,ElemType *e) 11 { 12 if(L.length == 0 || i<1 || i>L.length) 13 { 14 return ERROR; 15 } 16 *e = L.data[i-1]; 17 18 return OK; 19 }
- 注意:这里的返回值类型Status是一个整型,约定返回1代表OK,返回0代表ERROR。
6 插入操作
- ListInsert(*L,i,e):在线性表L中第i个位置插入新元素e,用代码如何实现?
- 插入算法的思路:
- 如果插入位置不合理,抛出异常;
- 如果线性表长度大于等于数组长度,则抛出异常或动态增加数组容量;
- 从最后一个元素开始向前遍历到第i个位置,分别将他们都向后移动一个位置;
- 将要插入的元素填入位置i处
- 线性表长度加1
- 实现代码:ListInsert.c
-
1 // 初始条件:顺序线性表L已存在,1 <= i <= ListLength(L) 2 // 操作结果:在线性表L中第i个位置插入新元素e,L长度+1 3 Status listInsert(SqList *L,int i,ElemType e) 4 { 5 int k; 6 7 if(L->length == MAXSIZE) // 顺序线性表已经满了 8 { 9 return ERROR; 10 } 11 if(i<1 || i>L-length+1) // 当i不在范围内时 12 { 13 return ERROR; 14 } 15 if(i<= L->length) // 若插入数据不在表尾 16 { 17 // 将要插入位置后数据元素向后移动一位 18 for(k=L->length-1;k>=i-1;k--) 19 { 20 L->data[k+1] = L->data[k]; 21 } 22 } 23 24 L->data[i-1] = e; // 将新元素e插入 25 L->length++; 26 27 return OK; 28 }
7 删除元素操作
- ListDelete(*L,i,e):删除线性表中第i个位置的元素,并用e返回其值
- 删除算法思路:
- 如果删除位置不合理,抛出异常;
- 取出删除元素;
- 从删除元素位置开始遍历到最后一个元素位置,分别将他们都向前移动一个位置;
- 表长-1
- 实现代码:ListDelete.c
-
1 // 初始条件:顺序线性表L已存在,1 <= i <= ListLength(L) 2 // 操作结果:删除线性表中第i个位置的元素,并用e返回其值,L长度-1 3 Status ListDelete(SqList *L,int i,ElemType *e) 4 { 5 int k; 6 7 if(L->length == 0) 8 { 9 return ERROR; 10 } 11 if(i<1 || i>L-length) 12 { 13 return ERROR; 14 } 15 *e = L->data[i-1] 16 17 if(i < L->length) 18 { 19 for(k=i;k < L->length; k++) 20 { 21 L->data[k-1] = L->data[k]; 22 } 23 } 24 25 L->length--; 26 27 return OK; 28 }
8 时间复杂度
- 写/存、读/取操作:不管是哪个位置,时间复杂度都是O(1)
- 插入和删除操作:
- 最好的情况:插入和删除刚好要求在最后一个位置操作,因为不需要移动任何元素,所以此时的时间复杂度为O(1)
- 最坏的情况:插入和删除的位置是第一个元素,那意味着要移动所有的元素向后或者向前,所以此时的时间复杂度为O(n)
- 平均情况:取中间值:O((n-1)/2),其简化之后复杂度还是O(n)
9 线性表顺序存储结构的优缺点
- 特性:由对时间复杂度的分析可知:它适合元素个数比较稳定,不经常插入和删除元素,而更多的操作是存取数据的应用
- 优点:
- 无需为表示表中元素之间的逻辑关系而增加额外的存储空间;
- 可以快速的存取表中任意位置的元素。
- 缺点:
- 插入和删除操作需要移动大量元素;
- 当线性表长度变化较大时,难以确定存储空间的容量;
- 容易造成存储空间的“碎片”;
五 线性表的链式存储结构
1 定义:
- 用一组任意的存储单元存储线性表中的数据元素,这组存储单元可以存在内存中未被占用的任意位置
- 链式存储结构中,除了要存储数据元素信息外,还要存储它的后继元素的存储地址(指针)。
- 数据域:存储存储数据元素信息的域
- 指针域:存储直接后继位置的域
- 指针/链:指针域中存储的信息
- 节点:数据域和指针域中的信息组成的元素称为存储映像,也叫节点
- n个节点链接成一个链表,即为线性表(a1,a2,...,an)的链式存储结构
2 单链表的结构
- 单链表:链表的每个节点中只包含一个指针域
- 头指针
- 我们把链表中第一个节点的存储位置叫做头指针,最后一个节点的指针为空(NULL)
- 头指针是指链表指向第一个节点的指针,若链表有头节点,则是指向头节点的指针
- 头指针具有标识作用,所以常用头指针冠以链表的名字(指针变量的名字)
- 无论链表是否为空,头指针均不为空
- 头指针是链表的必要元素
- 头节点
- 头节点是为了方便和统一而设计的,放在第一个元素的节点之前,其数据域一般无意义(但也可以用来存放链表的长度)
- 有了头节点,对在第一个节点之前插入元素和删除第一个节点的操作与其它节点就统一了
- 头节点不一定是链表的必要元素
3 图例
- 单链表图例
-
空链表:
4 描述:C语言中用结构指针来描述单链表
-
1 typedef struct Node 2 { 3 ElemType data; // 数据域 4 struct Node* Next; // 指针域 5 } Node; 6 typedef struct Node* LinkList;
- 节点由存放数据元素的数据域和存放后继节点地址的指针域构成
5 单链表的读取
- 获得链表第i个元素的算法思路:
- 声明一个节点p指向链表的第一个节点,初始化j从1开始;
- 当j<i时,就遍历列表,让p的指针向后移动,不断指向下一个节点:j+1
- 若到链表末尾p为空,则说明第i个元素不存在;
- 若查找成功,则返回节点p的数据
-
1 // 初始条件:顺序线性表L已存在,1<=i<=Listlength(L) 2 // 操作结果:用e返回L中第i个数据元素的值 3 Status GetElem(LinkList L, int i,ElemType *e) 4 { 5 int j; 6 LinkList p; 7 8 p = L->next; 9 j = 1; 10 11 while(p && j<i) 12 { 13 p = p->next; 14 ++j; 15 } 16 17 if(!p || j>i) 18 { 19 return ERROR; 20 } 21 *e = p->data; 22 return OK; 23 }
- 平均时间复杂度:O(n)
- 核心思想:工作指针后移
6 单链表的插入
- 单链表第i个数据插入节点的算法思路:
- 声明一节点p指向链表头节点,初始化j从1开始;
- 当j<1的时,遍历链表,让p指针向后移动,不断指向下一节点,j累加1;
- 若到链表末尾p为空,则说明第i个元素不存在;
- 否则查找成功,在系统中生成一个空节点s;
- 将数据元素e赋值给s->data;
- 单链表中插入两个语句(顺序不能调换):
- s->next = p->next
- p->next = s
- 返回成功
-
1 // 初始条件:顺序线性表L已存在,1<=i<=Listlength(L) 2 // 操作结果:在L中第i个位置之前插入新的数据元素e,L的长度加1 3 Status ListInsert(LinkList *L, int i, ElemType e) 4 { 5 int j; 6 LinkList p,s; 7 8 p = *L; 9 j = 1; 10 11 while (p && j<1) 12 { 13 p = p->next; 14 j++; 15 } 16 17 if (!p || j>i) 18 { 19 return ERROR; 20 } 21 22 s = (LinkList)malloc(sizeof(Node)); 23 s->data = e; 24 25 s->next = p->next; 26 p->next = s; 27 28 return OK; 29 }
7 单链表的删除操作
- 单链表第i个数据删除节点的算法思路
- 声明节点p指向链表第一个节点,初始化j=1;
- 当j<1的时,遍历链表,让p指针向后移动,不断指向下一节点,j累加1;
- 若到链表末尾p为空,则说明第i个元素不存在;
- 否则查找成功,将欲删除节点p->next赋值给q;
- 单链表的删除2标准语句:p->next = q->next;
- 将q节点中的数据赋值给e,作为返回值;
- 释放q节点。
-
1 // 初始条件:顺序线性表L已存在,1<=i<=Listlength(L) 2 // 操作结果:删除L的第i个数据元素,并用e返回其值,L的长度-1 3 Status ListDelete(LinkList *L, int i, ElemType *e) 4 { 5 int j; 6 LinkList p,q; 7 8 p = *L; 9 j = 1; 10 11 while (p->next && j<1) 12 { 13 p = p->next; 14 j++; 15 } 16 17 if (!(p->next) || j>i) 18 { 19 return ERROR; 20 } 21 22 q = p->next; 23 p->next = q->next; 24 25 *e = q->data; 26 free(q); 27 28 return OK; 29 }
8 效率PK(线性存储结构VS链式存储结构)
- 插入/算法:两部分组成
- 第一部分:遍历查找第i个元素
- 第二部分:实现插入/删除元素
- 时间复杂度:O(n)
- 对于插入/删除数据越频繁的操作,单链表的效率优势越明显
五 单链表的整表创建
1 线性和链式存储结构区分
- 对于顺序存储结构的线性表的整表创建,可以通过数组的初始化来直观理解
- 对于单链表来说:
- 它的数据分散存储在内存的各个角落,它的增长也是动态的;
- 它所占用的空间的大小和位置不需要预先分配划定,可根据系统的情况和实际需求即时生成
2 单链表的整表创建
- 定义:创建单链表的过程是一个动态生成链表的过程,从“空表”的初始状态起,依次建立各元素结点,并逐个插入列表
- 算法思路:
- 声明一个结点p和计数变量i;
- 初始化一个空链表L;
- 让L的头结点的指针指向NULL,即建立一个带头结点的单链表;
- 循环实现后继结点的赋值和插入;
- 方法:
- 头插法建立单链表
- 定义:头插法从一个空表开始,生成新结点,读取数据存放到新结点的数据域中,然后将新结点插入到当前链表的表头上,直到结束为止。
- 过程:把新加进的元素放在表头后的第一个位置:
- 先让新结点的next指向头结点之后
- 然后让表头的next指向新结点(表头在不断地变换,类似于栈)
- 特点:
- 生成的链表中结点的顺序和输入顺序相反
- 代码:
1 /* 头插法建立单链表示例*/ 2 void CreateListHead(LinkList *L, int n) 3 { 4 LinkList p; 5 int i; 6 7 srand(time(0)); // 初始化随机数种子 8 9 *L = (LinkList)malloc(sizeof(Node)); 10 (*L)->next = NULL; 11 12 for (i=0;i<n;i++) 13 { 14 p = (LinkList)malloc(sizeof(Node)); // 生成新结点 15 p->data = rand()%100 + 1; 16 p->next = (*L)->next; 17 (*L)->next = p; 18 } 19 }
- 尾插法建立单链表
- 定义:把新节点都插入到最后
- 过程:
- 定义一个指针量r表示尾结点的位置,并将其初始化为表头
- 先让尾结点的next指向新结点p
- 然后将新结点的内容赋值给表示尾结点的变量r
- 将尾结点的next更新为NULL
- 特点:
- 生成的链表中结点的顺序和输入顺序相同
- 代码:
1 /* 尾插法建立单链表示例*/ 2 void CreateListTail(LinkList *L, int n) 3 { 4 LinkList p,r; 5 int i; 6 7 srand(time(0)); // 初始化随机数种子 8 *L = (LinkList)malloc(sizeof(Node)); 9 r = *L; // 指向尾部的结点 10 11 for (i=0;i<n;i++) 12 { 13 p = (Node *)malloc(sizeof(Node)); // 生成新结点/中介结点 14 p->data = rand()%100 + 1; 15 r->next = p; 16 r = p; // 将r更新为最后一个结点 17 } 18 19 r->next = NULL; 20 }
- 头插法建立单链表
3 单链表的整表删除
- 单链表的删除,也就是释放存储单链表的内存空间
- 单链表的整表删除算法思路:
- 声明结点p和q;
- 将第一个结点赋值给p,下一个结点赋值给q;
- 循环执行释放p和将q赋值给p的操作;
- 代码:
1 // 单链表整表的删除 2 Status ClearList(LinkList *L) 3 { 4 LinkList p,q' 5 6 p = (*L)->next; 7 8 while(p) 9 { 10 q = p->nexr; 11 free(p); 12 p = q; 13 } 14 15 (*L)-> next = NU; 16 17 return OK; 18 }
六 单链表结构与顺序存储结构优缺点
1 存储分配方式
- 顺序存储结构一般用一段连续的存储单元依次存储线性表的数据元素
- 单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素
2 时间性能
- 查找
- 顺序存储结构O(1)
- 单链表O(n)
- 插入和删除
- 顺序存储结构需要平均移动表长一半的元素,时间为O(n)
- 单链表在计算出某位置的指针之后,插入和删除时间仅为O(1)
3 空间性能
- 顺序存储结构需要预分配存储空间,分大了,容易造成空间浪费;分小了,容易发生溢出
- 单链表不需要分配存储空间,只要有就可以分配,元素个数也不受限制
4 综上所述,在顺序存储结构和链式存储结构的选择方面,有以下结论:
- 若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构
- 若需要频繁进行插入和删除操作,则宜采用单链表结构
- 当线性表中的元素个数变化较大或者根本不知道有多大时,最好使用单链表结构,这样可以不用考虑存储空间大小的问题
- 如果事先知道线性表的大致长度,宜选择顺序存储结构
eg:(1)游戏开发中用户注册的个人信息,除了注册时插入数据外,绝大多数情况都是读取,所以应该考虑用顺序存储结构
(2)游戏中玩家的武器或者装备列表,需要随时增加或删除,就可以考虑采用单恋表结构