目录
该系列博客的目的是为了学习一遍数据结构中常用的概念以及常用的算法,为笔试准备;主要学习过程参考王道的《2018年-数据结构-考研复习指导》;
已总结章节:
上篇博客《数据结构与算法》-1-绪论中说到数据结构的三要素:逻辑结构、存储结构、数据的运算;其中,逻辑结构表示的是数据元素之间的关系,逻辑结构根据数据元素之间关系的不同,分成了线性结构与非线性结构,这里我们将要介绍的就是线性结构中的线性表,并根据线性表在计算机中存储结构的不同,介绍了线性表的两种存储形式,分别是:顺序存储、链式存储;
这篇主要介绍的内容有:
- 线性表的定义以及基本操作;
- 线性表的顺序存储;
- 线性表的链式存储;
- 顺序表与链表的比较;
其知识框架如下图所示:
1. 线性表的定义和基本操作
这一节主要介绍线性表,主要内容包括:线性表的定义及基本操作;
1.1 线性表的定义
定义:线性表是具有相同数据类型的n个数据元素的有限序列;
其中,n表示表长,\(n=0\)时表示空表;线性表的一般形式为:
\[ L=(a_1, a_2, \cdots, a_n) \]
其中,\(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 -> nxext = 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;
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)\);