408考研笔记系列(二)(PS:本人使用的是王道四本书和王道视频)
绪论中介绍了什么是数据结构,并用图书管理系统的例子进行了解释说明,数据结构包括三个部分:逻辑结构、存储结构和数据的运算;今天,我们便开始学习第一个数据结构:线性表。
线性表其实是一种逻辑结构,它指的是具有相同数据类型的n个数据元素的有限序列,一般是从1开始的;
线性表从存储结构而言可以分为以顺序存储的顺序表和以链式存储的链表。
线性表的运算主要包括创建、销毁、增加、删除、修改和查找。
顺序表,顾名思义,是一种顺序存储方式实现的线性表,逻辑上相邻的数据在物理上也是相邻的;
具有随机访问、存储密度高、拓展容量不方便和插入删除不方便的特点。
顺序表有两种创建方式:静态分配和动态分配;静态分配就是用我们常见的数组方式创建顺序表,顺序表之后的长度一般是不可变的,而且值得注意的是:线性表中元素的位序是从1开始的,而数组中元素的下标是从0开始的;动态分配主要通过malloc的方式自己分配数据空间,当分配的空间满了之后,我们需要开辟一片更大的空间来替换原来的存储空间,并且将已经满的空间转移到刚刚分配的空间中。
线性表运算中除了按位序查找的时间复杂度为O(1),其他均为O(n),由于顺序表的其他操作代码较为简单不再复现。
链表,便是一种链式存储的线性表;不要求大片连续的空间,改变容量方便,但是不能随机查找,并且存储密度较低;
单链表的几种运算实现是非常重要的知识点,因此下面主要贴代码,文字解释就放在注释里面了;
首先,链表的创建,分为头插法和尾插法,一般会在链表前添加一个头结点,方便之后代码的编写;尾插法一般再添加一个尾指针,用于指向尾结点,方便之后的插入;
头插法代码:
// 头插法创建链表(有头结点的情况下)
LinkList head_insert(LinkList *PPhead)
{
LNode* s;
int x;
// 创建头结点
*PPhead = (LNode*)malloc(sizeof(LNode));
// 头结点没有值,且头结点指向为空
(*PPhead)->pnext = NULL;
// 对链表中插入数据
while (scanf("%d", &x) != EOF)
{
// 首先创建新的结点
s = (LNode*)malloc(sizeof(LNode));
s->pnext = (*PPhead)->pnext;
(*PPhead)->pnext = s;
s->data = x;
}
return PPhead;
}
尾插法代码:
// 尾插法创建链表
LinkList tail_insert(LinkList *L)
{
// 尾插法创建链表可以得到顺序输出的链表序列
// 尾插法需要一个尾指针指向最后一个结点,方便之后插入新的结点
int x;
*L = (LNode*)malloc(sizeof(LNode));
// 这里相当于创建一个头结点
LNode *s, *r = *L;
while (scanf("%d", &x) != EOF)
{
s = (LNode*)malloc(sizeof(LNode));
s->data = x;
r->pnext = s;
r = s;
}
r->pnext = NULL;
return L;
}
头插法生成的链表在输出时元素的顺序是相反的,因此经常会用于转置操作;尾插法生成的链表结点次序和输入次序是一致的;
查找操作包括按值查找和按序号查找,都需要单链表的第一个结点往后查找,直至最后的尾结点,他们的时间复杂度都是O(n);
这里放按序号查找的代码:
// 按序号查找结点值
LNode* get_elem(LinkList L, int num)
{
// 单链表按序号查找结点值,只能按照结点顺序查找直到找到该顺序的结点值
// 如果序号值小于1则表示无该结点
int j = 1;
LNode *p = L->pnext;
if (num < 1)
{
printf("无该结点");
return NULL;
}
else
{
// 当找到该序号结点或者已经达到链表尾部的情况下结束
while (p && j < num)
{
p = p->pnext;
j++;
}
return p;
}
}
插入和删除操作都包括两种情况:按照位序插入或者删除或者对某一个结点进行插入或者删除;
按照位序插入或者删除都需要先查找该结点的前驱结点,再对结点进行插入或者删除,这样的时间复杂度为O(n);
按照位序插入的代码:
// 在链表中插入结点,这里分为按照序号插入结点以及对某一个结点插入结点
// 按照序号插入结点一般实现找到这个结点的前驱结点,然后插入,时间复杂度为O(n);
// 对某一个结点插入结点可以找到前驱结点再插入,时间复杂度为O(n);也可以后插再交换的方法进行插入,这样时间复杂为O(1)
LinkList insert_num(LinkList* L, int num)
{
//首先查找该结点的前驱结点
int x;
LNode *s;
LNode *p = get_elem(*L, num - 1);
printf("请输入插入的值:");
scanf("%d", &x);
s = (LNode*)malloc(sizeof(LNode));
s->data = x;
s->pnext = p->pnext;
p->pnext = s;
return L;
}
对某一个结点进行插入或者删除,也可以先通过该结点找到其前驱结点,在进行插入或者删除操作,但是时间复杂度较高,都为O(n);
也可以利用该结点先后插,再将两个结点的值进行交换的方法进行插入,这样时间复杂度为O(1);但是删除操作并不适合这样,对于大部分结点而言,这样的操作是可以的,但是当要删除的结点是最后一个尾结点时,这样的操作便不可以实现了。
单链表,当我们对单链表进行遍历时只能从头结点向尾结点进行遍历,不能从后往前进行遍历,只能知道一个结点的后继结点是谁,但是缺不知道他的前驱结点,这让我们在一些特殊操作 的时候变得非常憋屈,这能忍嘛?当然不能,索性就给之前的指针域中加一个前驱指针,这不就行了嘛!双链表就这么出来了
// 定义结点的数据类型
typedef struct DNode {
int data;//数据域,用于存放结点中的数据
struct DNode *pnext,*prior;//指针域,用于存放前驱结点和后继结点
}DNode, *DLinkList;
其实双链表的很多操作和单链表是一样的,只是她做到了可以前向搜索,这就很NICE;当然随之而来的也就是写代码时候会多一些麻烦,毕竟人家是知道自己前面是谁嘛,所以时时刻刻都要想着还有一个前驱结点;
比如创建双链表的时候,我们依然可以用头插和尾插的方法,这里放一下头插法的代码:
// 头插法创建双链表
DLinkList Head_insert(DLinkList * DL)
{
int x;
DNode *s;
// 首先创建头结点
*DL = (DNode*)malloc(sizeof(DNode));
(*DL)->pnext = NULL;
while (scanf("%d", &x) != EOF)
{
s = (DNode*)malloc(sizeof(DNode));
s->data = x;
if ((*DL)->pnext != NULL)
{
(*DL)->pnext->prior = s;
}
s->pnext = (*DL)->pnext;
(*DL)->pnext = s;
s->prior = *DL;
}
return DL;
}
在插入和删除时,我们需要注意对其的前驱指针也要进行重定向,但是当我们在插入和删除最后一个元素,需要注意最后尾结点的后继指针指向的是NULL,但是NULL是没有前驱指针的,需要进行判断;
这里给出删除双链表结点的代码:
// 按位序删除
DLinkList Delete(DLinkList *DL, int num)
{
DNode *p = GetElem(*DL, num - 1);
DNode *q = p->pnext;
p->pnext = p->pnext->pnext;
if (p->pnext != NULL)
{
p->pnext->prior = p;
}
free(q);
return DL;
}
既然双链表都有了,那么剩下的就是让链表的头和尾连接起来了;这时,循环链表便诞生了,循环链表又可以划分为循环单链表和循环双链表;
循环单链表,便是尾结点的后继指针指向头结点;这样使得单链表可以循环起来,可以通过判断
if (L->pnext == L)
看循环单链表是否为空;
循环单链表常会对表头和表尾进行操作,此时设置一个尾指针,那么对表头和表尾进行操作只需要O(1)的时间复杂度;
循环双链表,便是尾结点的后继指针指向头结点,头结点的前驱指针指向尾结点;使得真个双链表形成一个闭环;
循环单链表和循环双链表在很多操作上都是同单链表以及双链表几乎一样,但是在判空时不会去看尾结点的pnext是否为空;
静态链表,不像其他链表是通过指针组成的链式存储结构;而是数组来描述的链式存储结构,这里的指针对应的是结点在数组中的相对位置(又称为游标);
操作系统在文件管理时使用的FAT表便是静态链表,静态链表只有当固定容量且增加删除时不适合移动大量元素时更加适用;