数据结构(第二章 : 线性表)
文章目录
线性表
定义
- 线性表 是具有相同数据类型的 n ( n ≥ 0 ) n(n\ge0) n(n≥0) 个数据元素的有限序列,其中 n n n 为表长,当 n = 0 n=0 n=0 时线性表是一个空表。若用 L L L 命名线性表,则其一般表示为 L = ( a 1 , a 2 , ⋯ , a i , a i + 1 , ⋯ , a n ) L = (a_1 , a_2 , \cdots, a_i , a_{i+1} , \cdots , a_n ) L=(a1,a2,⋯,ai,ai+1,⋯,an)
-
a
i
a_i
ai 是线性表中的“第
i
i
i 个”元素线性表中的位序 (而下标则是减一, 即
i
−
1
i-1
i−1)
- 下标从0开始,位序从1开始。
-
a
1
a_1
a1 是表头元素;
a
n
a_n
an 是表尾元素。
- 除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。
- 线性表是一种逻辑结构
常见基本操作
-
初始化 :
InitList(&L)
-
销毁 :
DestroyList(&L)
-
插入 :
ListInsert(&L,i,e)
-
删除 :
ListDelete(&L,i,&e)
-
按值查找 :
LocateElem(L,e)
-
按位查找 :
GetElem(L,i)
-
求表长 :
Length(L)
-
输出操作 :
PrintList(L)
-
判空操作 :
Empty(L)
*
以上操作后面的函数命名是建议的命名方式,方便阅读代码。*
在408统考中数据结构只允许使用C语言或C++语言,&
操作符是C语言中的地址符,使用该操作符主要是为了修改传入函数的参数的值。若传入的参数无需修改就不需要使用该符号,都是C语言的基础,这里只是提示一下!
顺序表
-
定义 : 顺序表 —— 用顺序存储的方式实现线性表顺序存储。把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。
-
顺序表是一种存储方式(物理结构/存储结构)
-
-
-
每个数据元素的存储位置都和线性表的起始位置相差一个和该数据元素的位序成正比的常数,因此,线性表中的任一数据元素都可以随机存取,所以线性表的顺序存储结构是一种随机存取的存储结构。
通常用高级程序设计语言中的数组来描述线性表的顺序存储结构。
注意:线性表中元素的位序是从1开始的,而数组中元素的下标是从0开始的。顺序表最主要的特点是随机访问,即通过首地址和元素序号可在时间 O ( 1 ) O(1) O(1) 内找到指定的元素。
回顾 :
逻辑结构与存储结构的区别:
逻辑结构 : 指数据元素之间的逻辑关系,即从逻辑关系上描述数据。
存储结构(物理结构) :指数据结构在计算机中的表示(又称映像),也称物理结构。
顺序表的实现
-
静态分配:存储空间固定的分配,数组大小和空间。
栗子 :
#define MaxSize 50 //定义线性表的最大长度 typedef struct{ ElemType data[MaxSize]; //顺序表的元素 int length; //顺序表的当前长度 }SqList; //顺序表的类型定义
-
动态分配:存储空间在程序执行时动态分配存储空间。
栗子 :
#define InitSize 100 //表长度的初始长度 typedef struct{ ElemType *data; //动态分配数组的指针 int MaxSize, length; //数组的最大容量和当前个数 }SeqList; //动态分配数组顺序表的类型定义
注意 : 静态分配一旦空间占满后面再加入的数据会溢出;动态分配的空间一旦被占满,会先开辟一块比原来更大的空间用来存放原来的数据和新数据,再释放原来已满的空间,而不是直接在原来的空间上拓展新空间。因为顺序存储分配的存储空间都是连续的。
附加 : C与C++语言动态分配内存空间的语句用法区别
-
C语言 : 使用malloc函数分配内存空间,使用free函数释放内存空间
L.data = (ElemType*)malloc(sizeof(int)*InitSize); //分配空间 free(p); //释放p指针所指的内存空间
其中
ElemType*
是malloc函数返回的一个指针,后再将指针的类型转换为数据元素类型的指针。sizeof(int)*InitSize
是 数据类型的大小 × 该类型数据的个数 = 分配的连续内存空间 数据类型的大小\times该类型数据的个数=分配的连续内存空间 数据类型的大小×该类型数据的个数=分配的连续内存空间。 -
C++语言使用
new
关键字来分配新空间,delete
关键字来释放内存空间L.data = new ElemType(InitSize); //分配空间 delete(p); //释放p指针所指的空间
//malloc、free函数的头文件在stdlib.h中 #include <stdlib.h> #define InitSize 10 //默认的最大长度 typedef struct{ int *data; // 动态分配数组指针 int MaxSize; // 顺序表最大容量 int length; //顺序表当前长度, 顺序表当前存放的数据量 }SeqList; //初始化顺序表 void InitList(SeqList &L){ L.data = (int *)malloc(InitSize*sizeof(int)); //申请一片连续的内存空间 L.length = 0; //刚刚初始化,顺序表里没有数据元素, 所以长度为0 L.MaxSize = InitSize; //先将容量置为默认最大长度, 装满后再动态分配 } //增加动态数组的长度; 传入的参数为指向顺序表的指针L和增加的容量len void IncreaseSize(SeqList &L, int len){ //定义一个指向int型的指针p, p将指向原来顺序表的data指针, 即初始化所分配的那片连续内存空间 int *p = L.data; //重新分配一个连续的内存空间,大小为原顺序表的大小+增加的容量 L.data = (int *)malloc((L.MaxSize+len)*sizeof(int)); //扩容, 原data指针已经指向新的内存空间, 而原来的空间由p指针管理着 for(int i=0; i<L.length; i++){ L.data[i] = p[i]; //将顺序表的数据复制到新的内存空间 } L.MaxSize = L.MaxSize+len; // 顺序表的容量发生了改变 free(p); //释放原来的内存空间 } //添加数据到顺序表中 void AddElem(SeqList &L, int a){ L.data[L.length] = a; //在顺序表后插入元素 L.length++; } //主函数 int main(){ SeqList L; //声明一个顺序表 InitList(L); //初始化顺序表 //添加10个数据 for (int i=0;i<10;i++){ AddElem(L,i); //添加数据到顺序表中 } IncreaseSize(L,5); //往顺序表L中新增5个单位的空间 return 0; }
基本操作实现
1. 插入操作
从顺序表的第 i i i个位置插入新元素,第i个元素及其后面的元素都要依次往后移动一个位置。
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; //在位置i处放入元素e
L.length++; //顺序表长度加一
return true;
}
- 时间复杂度
- 最好情况是 O ( 1 ) O(1) O(1)
- 最坏情况是 O ( n ) O(n) O(n)
- 平均时间复杂度 : O ( n ) O(n) O(n)
2. 删除操作
ListDelete(&L,i,&e)
:删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。
bool ListDelete(&L, int i, int &e){
if (i<1||i>L.length){ //判断i的范围是否有效
return false;
}
e = L.data[i-1]; //将删除的元素赋给e
for(int j=i; j<L.length; j++){
L.data[j-1]=L.data[j]; //将第i个位置后的元素前移
}
L.length--; //线性表长度减一
return true;
}
-
时间复杂度分析
-
最好情况 : O ( 1 ) O(1) O(1)
-
最坏情况 : O ( n ) O(n) O(n)
-
平均情况 : O ( n − 1 2 ) O(\frac{n-1}{2} ) O(2n−1)
-
假设删除任何一个元素的概率相同,即 i = 1 , 2 , 3 , … , l e n g t h i = 1,2,3, … , length i=1,2,3,…,length 的概率都是 p = 1 n p = \frac{1}{n} p=n1 , i = 1 i=1 i=1,循环 n − 1 n-1 n−1次; i = 2 i=2 i=2时, 循环 n − 2 n-2 n−2次; i = 3 i=3 i=3,循环 n − 3 n-3 n−3次… i = n i=n i=n时, 循环0次, 平均循环次数 = ( n − 1 ) p + ( n − 2 ) p + . . . + 1 ⋅ p = n ( n − 1 ) 2 ⋅ 1 n = n − 1 2 (n-1)p+(n-2)p+...+1· p = \frac{n(n-1)}{2}· \frac{1}{n} = \frac{n-1}{2} (n−1)p+(n−2)p+...+1⋅p=2n(n−1)⋅n1=2n−1
-
平均时间复杂度为 O ( n ) O(n) O(n)
-
-
3. 按值查找(顺序查找)
-
按位查找:直接通过下标来查找某个位置元素的值
由于顺序表的各个数据元素在内存中连续存放,因此可以根据起始地址和数据元素大小立即找到第 i 个元素——“随机存取”特性。
ElemType GetElem(SeqList L, int i){ return L.data[i-1]; //按位置查找, 此处是用 位序-1 等于 下标 }
时间复杂度 : O ( 1 ) O(1) O(1)
-
按值查找 :在线性表中查找具有给定关键字值的元素
int LocateElem(SeqList L, ElemType e){ for(int i=0;i<L.length;i++){ if(L.data[i]==e){ return i+1; //在数组下标为i的元素值等于e,返回其位序i+1 } } return 0; }
平均时间复杂度为 O ( n ) O(n) O(n)
链表
-
线性表的链式表示
-
顺序表可以随时存取表中的任意元素, 但是插入和删除可能需要移动大量元素。
-
链式存储线性表时, 不需要使用连续的物理地址来存放。
-
逻辑相邻的元素在物理位置上不一定相邻。
-
定义 : 顺序表 —— 用顺序存储的方式实现线性表顺序存储。把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。
-
顺序表是一种存储方式(物理结构/存储结构)
拓展:(C语言的小知识)
int *a;
代表一个int类型的指针型变量a
int a;
代表int类型变量aC语言提供了一个叫做 typedef的功能来声明一个已有的数据类型的新名字。即数据类型重命名。
typedef int num;
用num
这个名字代替int
这个类型
比如 :int a = 1;
等价于num a = 1;
typedef int *num;
用num
这个名字代替int *
这个类型
比如:int *p;
等价于num p;
单链表
- 定义 : 单链表 使用链式存储来存放线性表
- 单链表的结点类型 :
typedef struct LNode{ //定义单链表结点类型
ElemType data; //数据域:存放数据元素
struct LNode *next; //指针域: 指向下一个结点
}LNode, *LinkList; //拥有两个别名
LNode* GetElem(LinkList L, int i){
int j = 1;
LNode *p = L->next;
if(i==0)
return L;
while(p!=NULL && j<1){
p = p->next;
j++;
}
return p;
} //返回一个结点(一个指向结点的指针)
-
要表示一个单链表时, 需要声明一个头指针L, 指向单链表的第一个结点:
LNode *L
或者LinkList L
(这两个的作用完全相同, 但从名称上还是有特定的说法)- 使用
LinkList
主要用于强调这个是 单链表 - 而使用
LNode *
是强调这个是一个 结点
-
增加一个新的结点,在内存中申请一个结点所需的空间, 并用指针p指向这个结点 :
struct LNode *p = (struct LNode *)malloc(sizeof(struct LNode));
拓展 : C语言中的malloc函数用于分配内存空间的函数,用法:
(类型)malloc(类型大小)
,一般用sizeof
函数来计算该类型的字节大小。比如上面的, 是指分配一个LNode
结构体大小的内存, 该类型为指针类型, 即用一个指针指向内存空间。
- 通常用头指针来标识一个单链表,当头指针为NULL时可以判断出是一个空表。(前提是没有头结点)
- 为了方便操作又引申出头结点:在单链表第一个结点之前附加一个结点。头结点可以不记录信息,也可以记录表长等信息 。当头结点的指针为NULL时即为空表。除非特别声明,之后的代码默认带头结点。
插入操作
按位序插入
-
大致做法 :从头结点开始遍历到指定位序的结点,再将要插入的元素插入
- 因为单链表没有顺序表的下标, 所有需要进行计数,查找对应的位序
- 只能顺着指针进行访问, 不能像顺序表那样直接随机访问。(单链表的每个结点都有指针域,即指向下个结点的指针, 若不这样遍历,是找不到对应结点的地址)
- 当找到对应的位序后,向把前一个结点的指针域赋给带插入结点的指针域, 再将带插入结点的地址给前一个结点(即前一个结点的指针域指向待插入结点)
typedef struct LNode{ ElemType data; //数据域 struct LNode *next; //指针域 }LNode, *LinkList; //定义单链表结点 // 在第i个位置插图元素e bool ListInsert(LinkList &L, int i, ElemType e)\{ if(i<1){ return false; ///不合法的位序直接返回false, 退出该函数 } LNode *p; //声明一个指向LNode结点的指针,这个不需要分配LNode结点的内存空间, 因为此时还没需要增加结点 int j = 0; //默认下标为0 p = L; //让指针p指向头指针所指的结点,注意不是头结点 while (p!=NULL && j<i-1){ p = p->next; j++; } //使用while循环进行遍历单链表, 而且通过计数来记录所访问到的结点对应的位序 if(p==NULL){ return false; //若p指针指向NULL,则说明插入的位序i不合法,超出原位序的范围, 直接返回false, 退出该函数 } //找到对应的位序后, 接下来是插入操作 LNode * s = (LNode *)malloc(sizeof(LNode)); //新增一个存放元素的e的LNode结点, 此时需要分配内存空间,同时s指针指向该结点 s->data = e; //将该结点的数据域用于存放元素e s->next = p->next; //首先将该结点的指针域指向它的下一个结点 p->next = s; //再将该结点地址给到上一个结点, 即上一个结点的指针指向新增结点 return true; //最后插入成功返回true, 退出函数 }
-
平均时间复杂度为 O ( n ) O(n) O(n)
-
ps : 一般头指针是代表一条链表的, 不会轻易变动的, 查链表时, 是使用额外指针p来行操作的。
-
![](https://img-blog.csdnimg.cn/fb775749568b4ea6b9ced136ebc0a471.png)
头插法建立单链表
- 从一个空表开始,生成新结点,并将读到的数据存放到新结点的数据域中,然后将新结点插入到当前链表的表头。每次插入都是从表头开始插。
LinkList List_HeadInsert(LinkList &L){ //头插法建链表
LNode *s; int x;
L->next = NULL; //初始化单链表
scanf("%d",&x);
while(x!=-1){ //输入-1退出
s = (LNode *)malloc(sizeof(LNode)); //新增结点
s->data = x; //新结点的数据域赋值
s->next = L->next; //新结点的指针域指向头结点的下一个结点
L->next = s; //将头结点的指针指向新加入结点
scanf("%d",&x); //继续新增结点
}
return L; //返回单链表
}
尾插法建立单链表
-
头插法每次都是从头指针处插入结点, 生成的链表次序和输入数据的顺序不一致, 若要保持一致可以使用尾插法, 每次都是从链表尾部插入数据。
-
每次都是从尾部插入为了方便操作就设立尾指针, 专门指向链表的最后一个结点,这样就不用每次都从头遍历到尾再插入, 直接从尾结点插入即可。
-
LinkList List_TailInsert(LinkList &L){ //正向建立单链表 int x; L = (LinkList)malloc(sizeof(LNode));//创建新的链表结点, 头结点L指向该结点 LNode *s, *r = L; //声明s为指向结点的指针, r为尾指针指向尾结点 scanf("%d",&x); //输入结点的值 while(x != -1){ s=(LNode*)malloc(sizeof(LNode)); //s指针指向一个新增结点 s->data = x; //数据域赋值为输入的结点值 r->next = s; //指针域将尾指针指向s所指的新结点 scanf("%d",&x); //循环输入结点值 } r->next = NULL; //尾指针所指结点的指针域置空, 即最后的结点指向NULL return L; //返回正向建立的单链表
删除操作
- 遍历查找到要删除的结点
- 待删除的前驱结点指向待删除的后继结点, 然后再释放待删除结点的空间
//部分代码
p = GetElem(L,i-1); //查找待删除结点的位置, 返回指针p. p所指的结点为待删除结点的前驱结点
q = p->next; //让指针q指向待删除的结点
p->next = q->next; //让待删除的前驱结点指向待删除的后继结点
free(q); //释放待删除结点空间
执行
free(q)
的作用是由系统回收一个LNode
型的结点,回收后的空间可供再次生成结点时用。
双链表
单链表:无法逆向检索,只能从头带尾进行遍历,当要访问前驱结点时要从头开始遍历链表才能访问到该结点的前驱结点。
双链表:有两个指针,
prior
指针和next
指针,分别指向其前驱结点和后继结点。这样访问前驱结点就不用从头进行遍历了。
typedef struct DNode{ //定义双链表结点类型
ElemType data; //数据域
struct DNode *prior,*next; //指针域:前驱指针和后继指针
}DNode, *DLinkList;
插入操作
- 在双链表中指针p所指的结点之后插入s指针所指的结点。
做法很简单, 找前后结点靠近的结点的指针域; 只有相邻的结点才知道各自的地址, 这是双链表的特点之一。单链表的结点只知道后继结点的地址。
部分代码:
//此时指针p指向a结点, 现在要a结点后面插入结点x
//指针s指向要插入的结点x
s->next = p->next; //将结点x的后继指针指向结点b, 即结点a的后继结点
p->next->prior = s; //再将b的前驱指针指向x结点, 这样就建立好了结点x与结点b的连接
s->prior = p; //插入结点x的前驱指针指向结点a
p->next = s; //结点a的后继指针指向结点x, 这样就建立了结点a与结点x的连接
从中可以看出, 不能先断掉结点a与结点b的联系,目前只能确定p指针和s指针所指的两个结点,其他的结点都是通过自身结点的指针域相关联起来的, 若a指向b的指针断掉,就会丢失结点b以及后面的信息。所以只能通过结点b先与插入结点x建立联系,这样就能确保b结点以及后面的结点不丢失。
删除操作
- 删除双链表中结点
*p
的后继结点*q
, 即删除结点 b
p->next = q->next; //p指针指向的结点a的next指针先指向结点c, 即a指向c
q->next->prior = p; //指针q所指的结点b即删除结点的下一个结点的prior指针指向a, 即c指向a
//这样两个a和c结点建立了联系
free(q); //释放q所指的b结点的空间, 即删除b结点
从中可以看出a和b结点都是有指针指向的, 即目前已知的结点地址是a和b,而b后面的地址只能通过b的next指针才能访问到。
说明一下, 这个步骤中1和2调过来也没有问题, 原因是b结点的地址可以通过指针q来获取, 即使a与b之间断了联系, 也能通过q指针找到b, 间接找到c。
循环链表
循环单链表
- 与单链表的区别是最后一个结点不是指向NULL而是指向头结点,让整个链表形成环。
设置尾指针是为了从表尾开始的操作效率更高, 单链表与循环链表的区别是单链表只能从头结点开始遍历整个链表而循环链表可以任意个结点开始遍历整个链表。
代码可以不用掌握
循环双链表
- 在双链表的基础上,将头结点的
prior
指针指向最后一个结点, 将最后一个结点的next
指针指向头结点,这样就形成一个环。
不需要设置尾指针, 因为可以通过头结点去直接访问最后一个结点。
代码可以不用掌握
静态链表
- 借助数组来描述线性表的链式存储结构(说人话就是把链表的内容用数组来装。既然是数组,那么它的空间是固定的,就仿佛是“静态”的,而链表时可以动态分配内存空间的。)
静态链表结构类型的描述:
#define MaxSize 100 //定义静态链表最大长度
typedef struct{ //静态链表结构类型的定义
ElemType data; //数据域
int next; //类指针域, 下一个元素的数据下标
}SLinkList[MaxSize];
代码可以不用掌握
未完待续….
参考资料 :
- 23版王道408数据结构单科书
- 王道考点精讲视频课件