第二章 线性表
前言:算法题出的多,不难,但是对时间复杂度要求较高。
2.1 线性表的定义和基本操作
2.1.1 线性表的定义
线性表是具有相同数据类型的n(n≥0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用L命名线性表,则其一般表示为
L = (a1,a2,...an)
其中,a1是“第一个”数据元素,也称表头元素,an是“最后一个”数据元素,也称表尾元素。
线性表具有以下特点:
1)表中元素的个数有限
2)表中元素具有逻辑上的顺序性,表中元素有其先后次序
3)表中元素都是数据元素,每个元素都是单个元素
4)表中元素的数据类型都相同,这意味着每个元素占有相同大小的空间
5)表中元素具有抽象性,即只讨论元素间的逻辑关系,而不考虑元素究竟表示什么内容
2.1.2 线性表的基本操作
InitList(&L) //初始化表,构建一个空的线性表
Length(L) //求表长,返回L的长度
LocateElem(L, e) //按值查找,在L中查找值为e的元素
GetElem(L, i) //按位查找,获取L中第i个元素的值
ListInsert(&L, i, e) //插入操作,在L中的第i个位置上插入指定元素e
ListDelete(&L, i, &e) //删除操作,删除L中第i个元素,并用e返回删除元素的值
PrintList(L) //输出操作,按前后顺序输出线性表L的所有元素值
Empty(L) //判空操作,若L为空表,则返回true,否则返回 false.
DestroyList(&L) //销毁操作,销毁线性表,并释放线性表L所占用的内存空间。
//符号“&”表示C++中的引用调用,在C语言中使用指针也可达到同样效果
2.2 线性表的顺序表示
2.2.1 顺序表的定义
线性表的顺序存储也称顺序表,顺序表的特点是表中元素的逻辑顺序与其存储的物理顺序相同。顺序表中的任意一个元素都可以随机存取,通常用高级程序设计语言中的数组来描述线性表的顺序存储结构。
静态分配的数组大小固定,空间占满后再加入新数据会产生溢出。动态分配的数组在空间占满后会另外开辟一块更大的存储空间,将原表的数据全部拷贝到新空间,实现动态扩充空间。
假定线性表的元素类型为ElemType,则静态分配的顺序表存储结构描述为
#define MaxSize 50 //定义线性表的最大长度
typedef struct{
ElemTye data[MaxSize]; //顺序表的元素
int length; //顺序表的当前长度
}SqList; //顺序表的类型定义
动态分配的顺序表存储结构描述为
#define InitSize 100 //表长度的初始定义
typedef struct{
ElemType *data //定义了一个动态分配的指针
int MaxSize ,length; //定义了一个数组的最大容量和当前长度
}SqList; //动态分配数组顺序表的类型定义
C和C++的初始动态分配语句为
L.data = (ElemType*)malloc(sizeof(ElemType) *InitSize); //C
L.data = new ElemType[InitSize]; //C++
顺序表的优点
1)支持随机访问;
2)存储密度高。
顺序表的缺点
1)插入和删除时需移动大量的元素;
2)顺序存储分配需要一段连续的存储空间,不够灵活。
2.2.2 顺序表上基本操作的实现
1. 顺序表的初始化
//静态
//所有元素初始化为0,表的长度初始化为0
void InitList(SqList &L){
for(int i = 0;i<MAXSIZE;i++){
L.data[i] = 0;
}
L,length = 0;
}
//动态
//分配预定义大小的数组空间,数组长度初始为0,一旦空间不足就再进行分配。
void InitList(SqList &L){
L.data = (int*)malloc(sizeof(int)*InitSize);
L.length = 0;
L.MaxSize = InitSize;
}
2. 插入操作
bool ListInsert(SqList &L,int i,int 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; //在位置i处放入e;
L.length++; //表的长度加1。
return true;
}
顺序表插入算法的平均时间复杂度为O(n)。
3. 删除操作
bool ListDelete(SqList &L,int i,int &e){
if(i<1||i>L.length+1) //判断i范围是否有效
return false;
e= L.data[i-1]; //将被删除的元素赋值给e
for(int j=i;j<L.length;j++) //将第i位置后的元素前移
L.data[j-1] = L.data[j];
L.length--; //线性表长度减1
return true;
}
顺序表删除算法的平均时间复杂度为O(n)。
4. 按值查找(顺序查找)
int LocateElem(SqList L, int e){
int i;
for(i = 0; i < L.length;i++){
if(L.data[i] == e){
return i + 1; //下标为i的元素值等于e,返回其位序i+1
}
}
return 0; //退出循环,说明查找失败
}
顺序表按值查找算法的平均时间复杂度为O(n),按序号查找的时间复杂度为O(1)。
2.3 线性表的链式表示
如果说顺序表是一根电棍,那链表就是一根项链,你觉得项链串的不好可以随时重新串,但电棍想要改装就会很麻烦。所以链表的插入和删除很简单,只需要修改指针即可,但也会失去顺序表可随机存储的特点。
2.3.1 单链表的定义
线性表的链式存储又称单链表,它是指通过一组任意的存储单元来存储线性表中的数据元素。单链表的结点结构如下,其中data为数据域,存放数据元素;next为指针域,存放后继结点的指针。
data | next |
单链表中结点类型的描述如下
typedef struct LNode{ //定义单链表结点类型
int data; //数据域
struct LNode *next; //指针域
}LNode, *LinkList;
单链表可以解决顺序表需要大量连续存储单元的缺点,但附加的指针域会浪费存储空间,同时也不支持随机存取。
通常使用头指针L(或者head)来标识一个单链表,指出链表的起始地址,头指针为NULL时表示一个空表。此外,为操作方便,在单链表的第一个数据结点前附加一个结点,称为头结点。头结点的数据域可以记录表长等信息,也可以不设置任何信息。单链表带头结点时,头指针指向头结点;单链表不带头结点时,头指针指向第一个数据结点。表尾结点的指针域为NULL(用“^”表示)。
头指针和头结点的关系:不论带不带头结点,头指针始终指向链表的第一个结点,而头结点是带头结点的链表中的第一个结点,结点内通常不存储信息。
引入头结点的优点:
1)链表第一个数据结点的操作和其他位置一样,不用做特殊处理
2)无论链表是否为空,头指针都是指向头结点的非空指针。空表和非空表的处理也得到了统一。
2.3.2 单链表上基本操作的实现
1. 单链表的初始化
//带头结点的单链表初始化
bool InitList(LinkList &L){
L = (LNode*)malloc(sizeof(LNode)); //创建头结点
L->next = NULL; //头结点后暂时还没有元素结点
return true;
}
//不带头结点的单链表初始化
bool InitList(LinkList &L){
L = NULL;
return true;
}
//设p为结构体指针,则*p是结点本身,因此可用p->data或者*(*p).data去访问这个结点的数据域
//(*(*p) .next) .data是下一个结点中存放的数据,或者直接用p->next->data
2. 求表长操作
//带头结点
int Length(LinkList L){
int len = 0; //计数变量,初始为0
LNode *p = L;
while(p->next != NULL){
p = p->next;
len++; //每访问一个结点,计数加1
}
return len;
}
//不带头结点
int Length(LinkList L){
int len = 0;
LNode *p = L;
while(p != NULL){ //不同点在于循环判定
p = p->next;
len++;
}
return len;
}
求表长操作的时间复杂度为O(n)。需要注意单链表的长度不包括头结点,因此带头结点和不带头结点的单链表在求表长操作上会有所不同。
3. 按序号查找结点
LNode *GetElem(LinkList L,int i){
LNode *p = L; //p指针指向当前扫描到的结点
int j = 0; //记录当前结点的位序
while(p != NULL && j < i){ //循环找到第i结点
p = p->next;
j++;
}
return p; //返回第i个结点的指针或NULL
}
按序号查找操作的时间复杂度为O(n)。
4. 按值查找结点
LNode *LocateElem(LinkList L,int e){
LNode *p = L->next;
while(p != NULL && p->data != e){ //从第一个结点开始查找数据域为e的节点
p = p->next;
}
return p; //找到后返回该结点指针,否则返回NULL
}
按值查找操作的时间复杂度为O(n)。
5. 插入结点操作
bool ListInsert(LinkList &L, int i, int e){
LNode *p = L; //当前扫描到的结点
int j = 0; //记录当前结点的位序,头结点是第0个结点
while(p != NULL && j < i-1) //循环找到i-1个结点
p = p->next;
j++;
if(p == NULL) //i值不合法
return false;
LNode *s = (LNode *)malloc(sizeof(LNode));
s->data = e;
s->next = p->next; //操作1
p->next = s; //操作2
return true;
}
需要注意的是,上述代码中操作1和操作2的顺序不能颠倒,否则先执行p->next = s后,指向其原后继结点的指针就不存在了。本算法的时间开销在于查找第i-1个元素,时间复杂度为O(n)。若在指定结点后插入新结点,则时间复杂度仅为O(1)。
扩展:对某一节点进行前插操作
在单链表插入算法中,通常都采用后插操作,对结点的前插操作均可转化为后插操作,前提是从单链表的头结点开始顺序查找到其前驱结点,时间复杂度为O(n)。
此外,可通过将*s插入到*p后面,然后将s->data和p->交换,这样既满足逻辑关系,又能使得时间复杂度为O(1)。
s->next = p->next; //修改指针域,不能颠倒
p->next = s;
temp = p->data; //交换数据域部分
p->data = s->data;
s->data = temp;
6. 删除结点操作
//把头结点看做第0个结点
//找到i-1的结点,将指针指向第i+1的结点
//释放第i的结点
bool LIstDelete(LinkList&L, int i, int &e){
//判断第i的位置是否存在
if(L == NULL || i < 1)
return false;
LNode *p;
int j = 0;
p = L;
while(p != NULL && j < i - 1){
p = p->next;
j++;
}
//当i值不合法
if(p == NULL){
return false;
}
//当i-1个结点就是最后一个结点了
if(p->next == NULL){
return false;
}
LNode *q = p->next;//令q指向被删除的结点
e = q->data;
p->next = q->next;
free(q);//释放结点
return true;
}
同插入算法一样,该算法的主要时间也耗费在查找操作上,时间复杂度为O(n)。
扩展:删除结点*p。
删除结点*p的操作也可通过删除*p的后继来实现,实质就是将其后继的值赋予其自身,然后删除后继,通俗点说,就是夺舍。
bool DeleteNode(LNode *p){
if(p == NULL){
return false;
}
LNode *q = p->next //令q指向*p的后继结点
p->data = p->next->data; //和后继结点交换数据
p->next = q->next; //将*q结点从链表中断开
free(q);
return true;
}
7. 采用头插法建立单链表
LinkList List_HeadInsert(LinkList &L){ //逆向建立单链表
LNode *s; //设元素类型为整型
int x;
L = (LNode*)malloc(sizeof(LNode)); //创建头结点
L->next = NULL; //初始为空链表
scanf("%d", &x); //输入结点的值
while(x != 9999){ //输入9999表示结束
s = (LNode *)malloc(sizeof(LNode)); //创建新结点
s->data = x;
s->next = L->next;
L->next = s; //将新结点插入表中,L为头指针
scanf("%d",&x);
}
return L;
}
每个结点插入的时间为O(1),设单链表长度为n,则总时间复杂度为O(n)。
8. 采用尾插法建立单链表
LinkList List_TailInsert(LinkList &L){
int x;
L=(LNode *)malloc(sizeof(LNode)); //创建头结点
LNode *s, *r = L; //R为表尾指针
scanf("%d", &x); //输入结点的值
while(x != 9999){
s=(LNode *)malloc(sizeof(LNode)); //给s结点分配内存
s->data = x;
r->next = s;
r = s; //r指向新的表尾结点
scanf("%d",&x);
}
r->next = NULL; //尾结点指针悬空
return L;
}
和头插法相同,时间复杂度为O(n)。
2.3.3 双链表
双链表结点中有两个指针prior和next,分别指向其直接前驱和直接后驱,表头结点的prior域和表尾结点的next域都是NULL。
typedef struct DNode{ //定义双链表结点类型
int data //数据域
struct DNode *prior,*next; //前驱和后继指针
}DNode,*DLinkList;
双链表的按值查找和按位查找与单链表的相同,但是插入和删除不同,关键是保证在修改的过程中不断链。双链表可以和方便的找到当前结点的前驱,因此,插入、删除操作的时间复杂度都是O(1)。双链表不可以随机存取,按位查找、按值查找操作只能用遍历的方式实现,时间复杂度为O(n)。
1. 双链表的插入操作
//在双链表中p所指的结点后插入结点*S
s->next = p->next; //1
p->next->prior = s; //2
s->prior = p; //3
p->next = s; //4
//上述代码的语句顺序不是唯一的,但也不是任意的,1操作必须在4操作之前,否则会断链
2. 双链表的删除操作
//删除双链表中结点*p的后继结点*q
p->next = q->next;
q->next->prior = p;
free(q); //释放结点空间
2.3.4 循环链表
1. 循环单链表
循环单链表表尾节点*r的next域指向L,表中没有指针域为NULL的结点。 循环单链表的判空条件不是头结点的指针是否为空,而是它是否等于头指针L。有时对循环单链表不设头指针,而是仅设尾指针r,r->next就是头指针,这样对表尾或者表头插入元素只需要O(1)的时间复杂度。
2. 循环双链表
由循环单链表的定义不难推出循环双链表,在循环双链表中,表头结点的prior指向表尾结点,表尾结点的next指向头结点。
2.3.5 静态链表
静态链表是用数组来描述线性表的链式存储结构,结点也有数据域data和指针域next,这里的指针是结点在数组中的下标,也称游标。
0 | 2 | |
---|---|---|
1 | b | 6 |
2 | a | 1 |
3 | d | -1 |
4 | ||
5 | ||
6 | c | 3 |
静态链表结构类型的描述如下
#define MazSize 10 //静态链表的最大长度
typedef struct Node{ //结构类型的定义
int data; //存储数据元素
int next; //下一个元素的数组下标。
}SLinkList[MaxSize];
静态链表以next == -1作为结束标志。静态链表的插入、删除操作与动态链表相同,只需要修改指针,而不需要移动元素。一般用于一些不支持指针的高级语言(如Basic)。
2.3.6 顺序表和链表的比较
1. 存取(读、写)方式
顺序表支持顺序存取、随机存取,而链表只能从表头开始依次顺序存取。
2. 逻辑结构与物理结构
顺序表中逻辑结构相邻的元素,物理结构上也相邻。链表中逻辑结构相邻的元素,物理结构上不一定相邻。
3. 查、删、插
对于按值查找,顺序表无序时,两者的时间复杂度均为O(n);顺序表有序时,可采用折半查找,此时的时间复杂度为O(logn)。对于按序号查找,顺序表支持随机访问,时间复杂度仅为O(1),而链表的平均时间复杂度为O(n)。顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需修改相关结点的指针域即可。
4. 空间分配
顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,若再加入新元素,则会出现内存溢出,因此需要预先分配足够大的存储空间。预先分配过大,可能会导致顺序表后部大量闲置:预先分配过小,又会造成溢出。动态存储分配虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中没有更大块的连续存储空间,则会导致分配失败。链式存储的结点空间只在需要时申请分配,只要内存有空间就可以分配,操作灵活、高效。此外,链表的每个结点都带有指针域,因此存储密度不够大。
总之,两种存储结构各有长短。通常较稳定的线性表选择顺序存储,而频繁进行插入删除操作的线性表(动态性较强)选择链式存储。