《数据结构与算法》-2-线性表
该系列博客的目的是为了学习一遍数据结构中常用的概念以及常用的算法,为笔试准备;主要学习过程参考王道的《2018年-数据结构-考研复习指导》;
已总结章节:
上篇博客《数据结构与算法》-1-绪论中说到数据结构的三要素:逻辑结构、存储结构、数据的运算;其中,逻辑结构表示的是数据元素之间的关系,逻辑结构根据数据元素之间关系的不同,分成了线性结构与非线性结构,这里我们将要介绍的就是线性结构中的线性表,并根据线性表在计算机中存储结构的不同,分别介绍了:顺序存储(顺序表)、链式存储(链表);
这篇主要介绍的内容有:
- 线性表的定义以及基本操作;
- 线性表的顺序存储;
- 线性表的链式存储;
- 顺序表与链表的比较;
其知识框架如下图所示:
1. 线性表的定义和基本操作
这一节主要介绍线性表,主要内容包括:线性表的定义及基本操作;
1.1 线性表的定义
定义:线性表是具有相同数据类型的n个数据元素的有限序列;
其中,n表示表长,\(n=0\)时表示空表;线性表的一般形式为:
其中,\(a_1\)称为表头元素;\(a_n\)称为表尾元素;
线性表中,除表头元素外,其他元素有且仅有一个直接前驱;除表尾元素外,其他元素有且仅有一个直接后继;
线性表的一些特点:
- 表中元素有限;
- 表中元素具有逻辑上的顺序性;
- 表中元素都是数据元素,每一个元素都是单个元素;
- 表中元素的数据类型都相同,意味着每一个元素占用相同大小的存储空间;
- 表中元素具有抽象性,仅讨论元素间的逻辑关系;不考虑元素究竟表示什么内容;
1.2 线性表的基本操作
一个数据结构的基本操作是指其最核心、最基本的操作;如下:
InitList(&L)
:初始化表;构造一个空的线性表;
Length(L)
:求表长;即表中元素个数;
locateElem(L, e)
:按值查找操作;
GetElem(L, i)
:按位查找;获取表中指定位的元素;
ListInsert(&L, i, e)
:插入操作;在表中指定位插入新的元素e;
ListDelete(&L, i, &e)
:删除操作;删除表中指定位的元素,返回e表示删除位置上的值;
PrintList(L)
:输出操作;按照前后顺序输出表中所有元素值;
DestroyList(&L)
:销毁操作;
我们知道线性表是逻辑结构;那么线性表在计算机中是如何表示的呢?根据线性表在计算机中有两种存储结构,分别是顺序表示和链式表示;
2. 线性表的顺序表示
2.1 顺序表的定义
定义:线性表的顺序存储称为顺序表;
是用一组地址连续的存储单元,依次存储线性表中的数据元素;使得逻辑上相邻的数据元素在物理位置上也相邻;
假设线性表的元素类型为ElemType,则线性表的顺序存储类型描述为:
# define MaxSize 50 // 定义线性表的最大长度
typedef struct{
ElemType data[MaxSize]; // 顺序表的元素
int length; // 顺序表的当前长度
}SqList; // 顺序表的类型定义
一维数组即可以静态分配,也可以动态分配;
动态分配:
# define InitSize 100 // 表长的初始定义
typedef struct{
ElemType *data; // 指示动态分配数组的指针
int MaxSize, length; // 数组的最大容量和当前个数
}SeqList;
// C的初始动态分配语句
L.data = (ElemType*)malloc(sizeof(ElemType)*InitSize);
// C++的初始动态分配语句
L.data = new ElemType[InitSize];
顺序表的特性:
- 主要的特点是随机存取;通过首地址和元素序号能够在\(O(1)\)时间内找到指定元素;
- 顺序表存储密度高,每个节点只存储数据元素;
- 表中数据元素的逻辑顺序与物理顺序相同;因此,插入和删除操作需要移动大量的元素;
2.2顺序表上基本操作的实现
2.2.1 插入操作
在顺序表L指定位置i插入新元素e;
bool ListInsert(SqList &L, int i, ElemType e){
if(i < 1 || i > L.length+1) // 判断i的范围是否有效
return false;
if(L.length >= MaxSize) // 当前存储空间已满,不能插入
return false;
for(int j = L.length; j >= i; j--) // 将第i个位置上的元素及之后的元素后移
L.data[j] = L.data[j-1];
L.data[i-1] = e; // 将e放入到第i个位置
L.length++; // 线性表长度加1
return true;
}
平均移动次数\(\dfrac{n}{2}\);插入操作的平均时间复杂度\(O(n)\);
2.2.2 删除操作
删除顺序表L中第i个位置上的元素;用true表示删除成功,删除的元素用e返回;否则返回false;
bool ListDelete(SqList &L, int i, int &e){
if(i < 1 || i > L.length) // 判断i的范围是否有效
return false;
e = L.data[i-1];
for(int j = i; j < L.length; j++) // 将i之后的元素前移
L.data[j-1] = L.data[j];
L.length--; // 线性表长度减1
return true;
}
平均移动次数\(\dfrac{n-1}{2}\);删除操作的平均时间复杂度为\(O(n)\);
2.2.3 按值查找
在顺序表L中查找第一个元素值等于e的元素,并返回其位序;
int LocateElem(SqList L, ElemType e){
int i;
for(i=0, i < L.length; i++)
if(L.data[i] == e)
return i+1; // 下标为i的元素值为e,其位序为i+1
return 0;
}
需要比较的平均次数\(\dfrac{n+1}{2}\);按值查找的平均时间复杂度\(O(n)\);
3. 线性表的链式表示
上面一小节讲述了线性表的顺序存储,即顺序表;由于顺序表的插入、删除操作需要移动大量的元素,影响运行效率;下面介绍的链表,在插入、删除操作时,不需要移动元素,只需要修改指针。
3.1 单链表的定义
定义:线性表的链式存存储称为单链表;
单链表是通过一组任意的存储单元来存储线性表中的数据元素;其中,每一个链表节点,包括两部分内容:数据元素自身信息(数据域data)、一个指向后继的指针(指针域next);
单链表中节点类型的描述如下:
typedef struct LNode{ // 定义单链表节点类型
ElemType data; // 数据域
struct LNode *next; // 指针域
}LNode, *LinkList;
由于单链表是非随机存取的存储结构;查找某个特定节点时,需要从表头遍历,依次查找;
通常使用”头指针“来标识一个链表;头指针为”NULL“时表示空表;此外,为了操作方面,在单链表的第一个节点前,增加了一个头结点,头结点的数据域可以不设置任何信息,其指针域指向线性表的第一个节点;
头指针与头结点:
- 不管有没有头结点,头指针一直指向链表的第一个节点;
- 带有头结点的链表,头结点是该链表的第一个节点;
- 头结点的数据域可以不存储信息,也可以存储其他信息如表长;头结点的指针域指向链表的后一个节点;
链表引入头结点后,带来两个优点:
- 由于链表的开始节点的位置被存储在头结点的指针域中,因此,链表的第一个位置(开始节点)上的操作与表中其他位置上的操作一致,无须进行特殊处理;
- 无论链表是否为空,头指针是非空指针,因为其指向头结点;这样,空表与非空表的处理也就统一了;
3.2 单链表基本操作的实现
3.2.1 头插法建立单链表
头插法建立单链表;首先,从一个空表开始,新建结点,对其数据域赋值,然后将链表的头结点指针指向该结点,然后将该结点的指针域指向原开始结点;如下图:
头插法建立单链表的算法:
LinkList CreateList1(LinkList &L){
LNode *s; int x;
L = (LinkList)malloc(sizeof(LNode)); // 创建头结点
L -> next = NULL; // 初始空链表
scanf("%d", &x); // 输入结点的值
while(x != 9999){
s = (LNode*)malloc(sizeof(LNode)); // 创建新结点
s -> data = x;
s -> next = L -> next;
L -> next = s;
scaf("%d", &x);
}
return L;
}
头插法,读入数据的顺序与链表中元素的顺序相反;每个结点插入的时间为\(O(1)\),设单链表长为n,总的时间复杂度为\(O(n)\);
3.2.2 尾插法建立单链表
头插法中,读入数据的顺序与链表中元素顺序相反;为保持一致,采用尾插法,即从链表尾部插入;需要借助一个尾指针r,使其始终指向尾结点;
尾插法建立单链表:
LinlList CreateList2(LinkList &L){
int x; // 设置元素类型为整形
L = (LinkList)malloc(sizeof(LNode)); // 创建头结点
LNode *s, *r = L; // r表示表尾指针
scanf("%d", &x); // 输入结点的值
while(x != 9999){
s = (LNode *)malloc(sizeof(LNode)); // 创建心新结点
s -> data = x; // 对新结点数据域赋值
r -> next = s; // 将新结点连接到表尾
r = s; // r指向新的表尾结点
scanf("%d", &x)
}
r -> next = NULL; // 尾结点指针置空
return L;
}
时间复杂度也是\(O(n)\);
3.2.3 按照序号查找结点值
找到链表中第i个结点,否则返回最后一个结点的指针域NULL;
LNode *GetElem(LinkList L, int i){
int j = 1; // 计数
LNode *p = L -> next; // 头结点指针赋给p
if(i == 0)
return L; // 返回头结点
if(i < 1)
return NULL; // i无效,返回NULL
while(p && j < i){
p = p -> next
j ++;
}
return p;
}
按序号查找操作时间复杂度为\(O(n)\);
3.2.4 按值查找表结点
按值查找表结点,从第一个结点开始,由前往后依次比较表中各结点数据域的值;若某结点数据域值等于给定的e,则返回该结点指针;若整个单链表中不存在这样的结点,则返回NULL;
LNode *LocateElem(LinkList L, ElemType e){
LNode *p = L -> next; // 头结点指针赋给p
while(p != NULL && p -> data != e)
p = p -> next;
return p;
}
按值查找操作时间复杂度为\(O(n)\);
3.2.5 插入结点操作
插入操作在链表中将值为x的结点插入到单链表的第i个位置上;
首先,检查位置的合法性,然后找到其前驱结点,再在其后插入新结点;(后插操作)
p = GetElem(L, i-1):
s -> next = p -> next;
p -> next = s;
时间复杂度为\(O(n)\);主要消耗在查找上;
扩展:在指定结点前执行前插操作;(注意:和上面不同,这里给定的是结点,不是位置;)
- 第一种方法:先查找其前驱结点,再执行后插操作;时间复杂度为\(O(n)\);
- 第二种方法:先执行后插操作,再交换两者的数据域;时间复杂度为\(O(1)\);
s -> next = p -> next;
p -> next = s;
// 交换数据域
temp = p -> data
p -> data = s -> data;
s -> data = temp;
3.2.6 删除结点操作
将单链表中第i个位置结点删除,先检查位置合法性,再找到其前驱结点,再删除;
p = GetElem(L, i-1);
q = p -> next;
p -> next = q -> next;
free(q);
这种方法时间复杂度为\(O(n)\);时间主要消耗在查找上;
扩展: 删除指定结点;(注意:与上面不同,这里给定的是结点,不是位置;)
- 第一种方法:先查找其前驱结点,再执行删除该结点;时间复杂度为\(O(n)\);
- 第二种方法:将给定结点的后继结点的值赋给该结点,再删除其后继结点;时间复杂度为\(O(1)\);
q = p -> next;
p -> data = q -> data;
p -> next = q -> next;
free(q);
3.2.7 求表长操作
统计表中元素个数,需要从第一个结点依次访问表中每一个结点,时间复杂度为\(O(n)\);
3.3 双链表
上面一小节中,我们说到单链表,单链表中每一个结点中有一个数据域和一个指针域,指针域指向其后继结点,因此单链表只能从头结点依次顺序地从前往后访问每一个结点;当我们想要访问某个结点的前驱结点时,就需要从头开始遍历;
- 单链表访问后继结点,时间复杂度为\(O(1)\);
- 单链表访问前驱结点,时间复杂度为\(O(n)\);
因此,为克服上述单链表的缺点,引入了双链表,即一个结点包含一个数据域和两个指针域,两个指针域分别指向前驱结点、后继结点;
双链表中结点类型的描述如下:
typedef struct DNode{ // 定义双链表结点类型
ElemType data; // 结点数据域
Struct DNode *prior, *next; // 结点前驱指针和后继指针
}DNode, *DLinkList;
因此,在双链表中,执行按位查找和按值查找的操作和单链表相同;但在插入和删除操作的实现上,则是不同的,因为在保证链不断的前提下,还要修改其前驱指针;
3.3.1 双链表的插入操作
在双链表中p所指的结点之后插入结点*s:
s -> next = p -> next;
p -> next -> prior = s;
p -> next = s;
s -> prior = p;
3.3.2 双链表的删除操作
删除双链表中结点*p的后继结点*q:
p -> next = q -> next;
q -> next -> prior = p;
free(q);
3.4 循环链表
3.4.1 循环单链表
一般单链表中,最后一个结点的指针是NULL,如果将其指针指向头结点,这就有了循环单链表;
在单链表中,判断其是否为空是根据头结点的的指针是否为空;而在循环单链表中,判断其是否为空是根据头结点指针是否等于头指针,也就是说头结点指针是否指向头结点;
循环单链表与单链表的异同点:
- 循环单链表的插入、删除几乎与单链表一致,不同的是,如果操作在表尾,需要保持循环的性质;
- 单链表只能从头结点遍历,循环单链表可以从任意结点遍历;
- 循环单链表在任意位置上的操作都是等价的,无须判断是否为表尾;
假如对单链表最常执行的操作是在表头或表尾进行,这时可以只设置尾指针,不设置头指针;这是因为,如果只设置头指针的话,想要对表尾进行操作,则需遍历整个表,其时间复杂度为\(O(n)\);如果只设置尾指针,尾指针的next就是头指针,这时对表头表尾操作都很方便,时间复杂度为\(O(1)\);
3.4.2 循环双链表
根据循环单链表可以想到循环双链表,即在双链表的基础上,尾结点的next指针指向头结点;头结点的prior指针指向尾结点;
判空:头结点的prior指针域与next指针域都等于L(即头指针);
3.5 静态链表
静态链表是借助数组来描述线性表的链式存储结构,结点也有数据域data和指针域next;
静态链表与链表的不同的是,静态链表的指针是结点的相对地址(数组下标),又称为游标;
静态链表与顺序表一样,需要预先分配一块连续的内存空间。
静态链表以next=-1作为其结束的标志;
4. 顺序表与链表的比较
在存取方式、逻辑结构与存储结构、查找/插入/删除操作、空间分配四个方面进行分析比较;
存取方式的比较:
顺序表可以顺序存取,也可以随机存取;链表只能顺序存取;
逻辑结构与存储结构的比较:
顺序存储时,逻辑上相邻的元素,其物理位置也相邻;
链式存储时,逻辑上相邻的元素,物理位置则不一定相邻;其对应的逻辑关系通过指针链接来表示的;
查找、插入、删除操作的比较:
按值查找 | 按序号查找 | 插入删除操作 | |
---|---|---|---|
顺序表 | 无序:\(O(n)\);有序:\(O(\log_2n)\); | \(O(1)\) | 平均移动半个表长;\(O(n)\) |
链表 | \(O(n)\) | \(O(n)\) | 修改相应指针;\(O(1)\) |
空间分配:
顺序表:静态分配(溢出)、动态分配(移动大量元素);
链表:不用分配,高效率、灵活;
5. 如何选择存储结构
基于存储时的考虑:
长度规模难以估计时,不宜使用顺序表;
基于运算的考虑:
如果大部分操作是访问表中元素,使用顺序表;
如果大部分操作是插入、删除,使用链表;
基于环境的考虑:
顺序表容易实现,任何高级语言都是数组类型;链表的操作是基于指针的;