第一章:数据结构绪论
1.1数据结构的基本概念
- 数据:数据是信息的载体,是描述客观事务属性的数、字符,以及所有能够输入到计算机中并被计算机程序识别和处理的符号的集合。数据是计算机程序加工的原料。
- 数据元素: 数据元素是数据的基本单位,通常作为一个整体进行考虑和处理。
- 数据项:一个数据元素可以由若干个数据项组成,数据项是构成数据元素不可分割的最小单位。
- 数据对象:数据对象是具有相同性质的数据元素的集合,是数据的一个子集。(如上图所示)
- 数据结构:数据结构是相互之间存在一种或者多种特定的关系的数据结构的集合。
举几个栗子:
- 清华的各种表单——数据
- 清华2026年研究生拟录取名单——数据对象
- 名单中我的信息集合:姓名、成绩、考生号等——数据元素
- 其中我的姓名、成绩、考生号等——数据项
1.2数据结构三要素
数据结构更关注数据元素之间的关系,以及对这些数据元素的操作,而不关心数据项的具体内容。
1.2.1数据结构的逻辑结构
逻辑结构是指数据元素之间的逻辑关系,即从逻辑关系上描述数据。
线性结构——一对一结构
树形结构——一对多结构
图结构(网状结构)——多对多结构
1.2.2数据运算
- 数据上的运算包括运算的定义和实现。
- 运算的定义是针对逻辑结构指出运算的功能。
- 运算的实现是针对存储结构的,指出运算的具体操作步骤。
对于线性结构可以定义:1.查找第i个元素;2.在第i个位置插入新的数据元素;3.删除第i个位置的元素等等(增删改查排等)。
1.2.3存储结构
存储结构是指数据结构在计算机中的表示(又称映像),也称物理结构。
存储结构重点在于如何用计算机实现该数据结构。
- 顺序存储——酒店房间挨着住
优点: 随机存取,元素占用最少存储空间
缺点: 只能使用相邻的一整块存储单元,产生较多的外部碎片
2. 链式存储——酒店房间随机住(通过连接指针相连)
优点: 不会出现碎片现象
缺点: 存储指针占用额外的存储空间; 只能顺序存取
3. 索引存储——酒店前台查询入住信息
优点: 检索速度快
缺点: 占用较多存储空间; 增加和删除数据要修改索引表,花费较多时间
4. 散列存储——(还不会以后补充嘤嘤嘤)
优点: 检索,增加和删除结点都很快
缺点: 若散列函数不好,出现元素存储单元冲突,会增加时间和空间的开销
1.2.4数据类型和抽象数据类型
在探讨一种数据结构时理解几点:
- 定义逻辑结构(数据元素之间的关系)
- 定义数据的运算(针对现实需求应该对这种逻辑结构进行什么样的运算)
- 确定某种存储结构,实现数据结构,并实现一些对数据结构的基本运算
1.3算法的基本概念
程序 = 数据结构+算法
数据结构:如何用数据正确地描述现实世界的问题,并存入计算机。
算法:如何高效地处理这些数据,以解决实际问题、
算法(Algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或多个操作。
算法的特性:
- 有穷性:算法要在有穷步骤后结束,并且每一步都要在有穷时间内完成。(注:算法有穷而程序无穷。例如wx就是程序而非算法。)
- 确定性:算法语句有确定含义;相同输入对应相同产出,即无论算法执行多少次结果都相同。(例:成绩排序两人分数相同要有解决条件,如按姓名首字母)
- 可行性:算法中描述的操作都可以通过已经实现的基本运算执行有限次来实现。
- 输入:一个算法有零个或多个输入。
- 输出:一个算法有一个或多个输出。
好的算法达到的目标:
- 正确性:算法应能够正确的求解问题。
- 可读性:算法应具有良好的可读性,以帮助人们理解。
- 健壮性:输入非法数据时,算法能适当地做出反应或进行处理,而不会产生莫名奇妙地输出结果。
- 效率与低存储量需求:花的时间少即时间复杂度低。不费内存即空间复杂度低。
1.4算法的时间复杂度
定义:事前预估算法时间开销T(n)与问题规模n的关系
衡量算法随着问题规模增大,算法执行时间增长的快慢
同一个算法,实现的语言的级别越高级,执行效率越低(0/1>汇编>C/C++>java)
利用大O法求时间复杂度
大O法:
1.用常数1取代运行时间中的所有加法常数。 O(3) = O(1)
2.在修改后的运行次数函数中,只保留最高阶项。O(3n+1) = O(n)
3.如果最高阶项存在且不是1,则去除与这个项相乘的常数。得到的结果就是大О阶。O(3n²+2)=O(n²)
对于大段程序来说,只要找到次幂最大的那段程序即可。一般为for嵌套或者是while类循环
顺序执行的代码只会影响常数项,可以忽略。
只需挑循环中的一个基本操作分析它的执行次数与n的关系即可
如果有多层嵌套循环,只需关注最深层循环循环了几次
1.5算法的空间复杂度
与时间复杂度算法大致相同但没时间复杂度考的多
第二章:线性表
2.1线性表的定义和基本操作
定义:
线性表是具有相同数据类型的n(n≥0)个数据元素的有限序列,其中n为表长,当n=0时线性表是一个空表。若用L表示线性表,其一般表示为:
几个概念:
是线性表中的“第i个”元素在线性表中的位序。
是表头元素,是表尾元素。
除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。
线性表有两种存储结构:
顺序存储结构:顺序表 ; 链式存储结构:链表。
线性表的基础操作:
- InitList(&L): 初始化表。构造一个空的线性表L,并分配存储空间。
- DestoryList(&L): 销毁操作。销毁线性表,并释放线性表L所占用的内存空间。
- ListInster(&L,i,e): 插入操作。在表L的第i个位置插入元素e。
- ListDelete(&L,i,&e): 删除操作。删除表L的第i个位置上的元素,并用e返回删除元素的值。
- LocateElem(L,e): 按值查找。在表L中查找具有制定关键字值e的元素。
- GetElem(L,i): 按位查找。获取表L中第i个位置的元素的值。
- Length(L): 求表长。返回线性表L的长度,即L中元素的个数。
- PrintList(L): 输出操作。按前后顺序输出线性表L的所有元素。
- Empty(L): 判空操作。若L是空表,则返回true,否则返回false。
解释一下:
“&”为引用操作符,其作用是获取地址。&L即获取表L的地址,一般为第一个元素的首地址。
给函数传参如果带有&就叫做传址,此时函数中的操作会直接改变该参数的值,如果不带&就是传值,此时传入函数的是该值在内存中的一个“复制体”,在函数中对该数值操作并不会影响函数外的“本体”。
所以,一般对传入的值有修改都要带&,反之则不带。例如初始化和销毁链表,都对L做出了改变,所以传入函数的参数L必须带&。
2.2顺序表
2.2.1顺序表的概念
顺序表:用顺序存储的方式实现线性表顺序存储。把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。
顺序表的特点:
- 随机访问,即可以在O(1)时间内找到第 i 个元素。
- 存储密度高,每个节点只存储数据元素。
- 拓展容量不方便(即使使用动态分配的方式实现,拓展长度的时间复杂度也比较高(O(n)),因为需要把数据复制到新的区域)。
- 插入删除操作不方便,需移动大量元素。
2.2.2顺序表的实现
ps:为方便演示顺序表以int类型为例
静态分配方式(表长固定)
//顺序表的实现--静态分配
#include<stdio.h>
#define MaxSize 10 //定义表的最大长度
typedef struct{
int data[MaxSize]; //用静态的"数组"存放数据元素
int length; //顺序表的当前长度
}SqList; //顺序表的类型定义
void InitList(SqList &L){
//该for循环可以省略,因为只要不违规强行输出有没有脏数据都无所谓了,后面的输出函数会优化掉
for(int i=0;i<MaxSize;i++){
L.data[i]=0; //将所有数据元素设置为默认初始值
} //为了防止系统中有脏数据
L.length=0;
}
int main(){
SqList L; //声明一个顺序表
InitList(L); //初始化一个顺序表
for(int i=0;i<MaxSize;i++){ //顺序表的打印
printf("data[%d]=%d\n",i,L.data[i]);
}
return 0;
}
动态分配方式(表长可修改)
//顺序表的实现——动态分配
#include<stdio.h>
#include<stdlib.h> //malloc、free函数的头文件
#define InitSize 10 //默认的初始值
typedef struct{
int *data; //指示动态分配数组的指针
int MaxSize; //顺序表的最大容量
int length; //顺序表的当前长度
}SeqList;
void InitList(SeqList &L){ //初始化
//用malloc 函数申请一片连续的存储空间
//用C++语法也可以且更简便 L.data = mew int;
L.data =(int*)malloc(InitSize*sizeof(int)) ;
L.length=0;
L.MaxSize=InitSize;
}
void IncreaseSize(SeqList &L,int len){ //增加动态数组的长度
int *p=L.data;
//同理,这里可以用 new关键字动态分配空间
L.data=(int*)malloc((L.MaxSize+len)*sizeof(int));
for(int i=0;i<L.length;i++){
L.data[i]=p[i]; //将数据复制到新区域
}
L.MaxSize=L.MaxSize+len; //顺序表最大长度增加len
free(p); //释放原来的内存空间
//如果使用了new关键字就要用delete p; 了
}
int main(){
SeqList L; //声明一个顺序表
InitList(L); //初始化顺序表
IncreaseSize(L,5);//增加顺序表的长度
return 0;
}
2.2.3顺序表的基本操作
ListInster(&L,i,e): 插入操作。在表L的第i个位置插入元素e。
时间复杂度:最好O(1),最坏O(n),平均O(n)。
//顺序表的插入操作
#define MaxSize 10
tppedef struct{
int data[MaxSize];
int length;
}SqList;
//插入函数
bool ListInster(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++; //表长加一
return true;
}
int main()
{
SqList L; //定义一个顺序表
InitList(L); //初始化顺序表
//省略一些代码,用来插入几个元素
ListInster(L, 3, 3);
return 0;
}
ListDelete(&L,i,&e): 删除操作。删除表L的第i个位置上的元素,并用e返回删除元素的值。
时间复杂度:最好O(1),最坏O(n),平均O(n)。
//顺序表的删除操作
#define MaxSize 10
tppedef struct{
int data[MaxSize];
int length;
}SqList;
bool ListDelete(SqList &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++) //将第i个位置之后的元素逐个前移
L.data[j-1] = L.data[j];
L.length--; //表长减一
return true;
}
int main(){
SqList L;
InitList(L);
//省略一些插入元素的代码
int e = -1; //用来接收返回的删除元素的值
if(ListDelete(L, 3, e))
printf("已删除第3个元素,删除的元素的值为%d\n",e);
else
print("位序i不合法,删除失败\n");
return 0;
}
GetElem(L,i): 按位查找。获取表L中第i个位置的元素的值。
时间复杂度:O(1)
//顺序表的查询——静态分配
#define MaxSize 10
typedef struct{
ElemType data[MaxSize];
int length;
}Sqlist;
ElemType GetElem(Sqlist L, int i){
return L.data[i-1];
}
//ElemType为自定义类型
//主函数
//顺序表的查询——动态分配
#define InitSize 10 //顺序表初始长度
typedef struct{
ElemType *data;
int MaxSize; //顺序表的最大容量
int length; //顺序表当前长度
}Seqlist;
ElemType GetElem(SeqList L, int i){
return L.data[i-1];
}
//主函数
LocateElem(L,e): 按值查找。在表L中查找具有制定关键字值e的元素。
时间复杂度:最好O(1),最坏O(n),平均O(n)。
//按值查找
#define InitSize 10
typedef struct{
ElemType *data;
int MaxSize;
int length;
}SeqList;
//在顺序表中查找第一个元素值等于e的元素,并返回他的位序
int LocateList(SeqList L, ElemType e){
for(int i=0; i<L.length; i++)
if(L.data[i] == e)
return i+1; //数组下标为i的元素,其位序为i+1
return 0; //退出循环,说明查找失败
}
2.3链表
2.3.1单链表的定义
什么是单链表嘞?就是顺序表的一种存储结构,如下:
用代码定义一个单链表:
//单链表的定义
typedef struct LNode{ //定义单链表节点类型
ElemType data; //每个节点存放一个数据元素
struct LNode *next; //指针指向下一个结点
}LNode, *LinkList;
其中的typedef关键字用来对数据类型进行重命名,以简化所需编写的代码量。
其格式为 typedef <数据类型> <别名>
LNode和*LinkList就是对struct LNode的重命名:
typedef struct LNode LNode;
typedef struct LNode *LinkList;
当我们要表示一个单链表时,只需要声明一个头指针L,指向单链表的第一个结点。
LNode *L; //声明一个指向单链表第一个结点的指针
或者 LinkList L ; //声明一个指向单链表第一个结点的指针
从功能上看,两者是相同的;但是从含义上看,前者强调这是一个节点,后者强调这是一个单链表。可以并建议在一个代码中混用两者使得程序更具有可读性。
单链表的初始化:
单链表分带头节点和不带头结点两种,带头节点的单链表在编码实现时会更简单,但是两种方式都会考试.........
//不带头结点的单链表
//初始化一个空表
bool InitList(LinkList &L){
L = NULL; //空表,暂时没有任何结点(用来防止脏数据)
return true;
}
void test(){
LinkList L; //声明一个指向单链表的指针
//初始化空表
InitList(L);
//后续代码
}
//判断单链表是否为空
bool Empty(LinkList L){
if(L == NULL)
return true;
else
return false;
}
//或者
bool Empty(LinkList L){
return (L == NULL);
}
//带头结点的单链表
//初始化一个单链表
bool InitList(LinkList &L){
L = new LNode;
//或者使用 L = (LNode*)malloc(sizeof(LNode));
if(L == null)
return false; //内存不足分配失败
L->next = NULL; //头结点后还没有结点
return true;
}
void test(){}
//判断是否为空
bool Empty(LinkList L){
return (L->next == NULL);
}
2.3.2单链表的基本操作
单链表的插入删除
ListInster(&L,i,e): 插入操作。在表L的第i个位置插入元素e。
//单链表按位序插入(带头结点)
bool ListInster(LinkList &L, int i, ElemType e){
if(i<1) //i非法(i位于第一个元素之前)
return false;
LNode *p; //定义指针p来定位当前扫描的结点
int j =0; //定义p所指向的第j个结点
p = L; //让p先指向头结点
while(p != Null && j<i-1){
p = p->next;
j++;
}
if(p == NULL){ //i不合法(超过表长的情况)
return false;
}
s = new LNode;
s->data = e;
s->next = p->next;
p->next = s; //将s连接到p之后
return true;
}
while循环来改变指针指向的位置,到第i-1个元素结束
其中!s->next = p->next; p->next = s; 两句顺序不能交换
如果不带头结点,则插入第一个结点的操作与其他结点不同,如下
//不带头结点特殊情况
if(i == 1){
s = new LNode;
s->data = e;
s->next = L;
L = s;
}
//int j = 1;不带头结点时扫描应该从第一个结点开始
//其他与带头结点相同
插入操作还有后插和前插两种,顾名思义即在第i个结点的后面(或前面)插入一个结点。
//单链表的后插操作
bool InsterNextNode(LNode *p, ElemType e){
if(p = NULL)
return false;
s = new LNode;
if(s == NULL) //内存分配失败,即内存已满
retutn false;
s->data = e;
s->next = p->next;
p->next = s;
return true;
}
前插操作与按位插入操作基本相同,但是加入了存储空间的判断,其余插入操作也可以加入该判断使代码更健壮。
由于单链表的特性,我们无法根据已知结点去找到它的前驱结点,所以我们只能选择一种更骚气的方法,其核心思想是,结点不能找到前驱但是可以改变结点中的元素使他变为前驱,即后插一个结点然后交换两者的数据域,使原结点变为“前驱结点”。我们称之为“偷天换日大法”!如下:
//前插操作的实现
bool InsterPriorNode (LNode *L, ElemType e){
if(p == Null)
return false;
s = new LNode;
if(s == NULL)
return false;
s->next = p->next;
p->next = s;
s->data = p->data;
p->data = e; //这四行看上图细品
return true;
}
妙~~~~~~~~~~啊! 想出来这个方法的人简直是个天才
ListDelete(&L,i,&e): 删除操作。删除表L的第i个位置上的元素,并用e返回删除元素的值。
//按位序删除(带头结点)
bool ListDelete (LinkLsit &L, int i, ElemType &e){
if(i<1)
return false;
LNode *p;
int j = 0;
p = L;
while (p != Null && j<i-1){
p = p->next;
j++;
}
if(p == NULL) //i值不合法
return 0;
if(p->next == NULL) //第i-1后已无结点,故无法删除i结点
return false;
LNode *q = p->next; //新建指针q指向被删除结点
e = p->data; //用e返回被删除结点元素值
p->next = q->next; //将*q从链中断开
free(q); //释放结点空间
return true;
}
OK接下来问题又来了,如果我们要删除指定结点*p如何操作?
分析: 由于单链表的特性,我们只能找到p的后继结点而无法找到前驱结点,故而无法让前驱直接指向后继,因此,偷天换日大法又来啦!本次思想:让后继结点“变成”原结点故而通过删除后继来实现指定结点的删除! 妙~~~~~~~~~~~啊!代码如下:
//删除指定结点
bool DeleteNode (LNode *p) {
if (p == NULL)
return false;
LNode *q = p->next;
p->data = q->data; //将原结点的数据域变为后继结点的数据域
p->next = q->next;
free(q); //可怜的q奉献了一切
return true;
}
封装的好处:
经过观察我们不难发现,按位插入代码while循环之后的代码段和后插操作一模一样,所以我们可以直接用 return InsterNextNode(p,e); 来代替后面几行,是不是突然方便了许多~? 这就是封装的好处。(其实按位插入的逻辑就是,先找到前一个元素,然后进行后插,那么我们也可以将查找操作进行封装,从而实现两个函数实现按位插入操作!爽啦!!!)
单链表的查找
单链表的查找之按位查找:
查找操作在上面已经实现过了,只不过找的是第i-1个元素,我们现在把它单独写出来:
//按位查找,返回第i个元素(带头结点)
LNode* GetElem(LinkList L, int i){
if(i<0)
return NULL;
LNode *p; //定义指针指向扫描到的结点
int j = 0; //记录当p指向的是第几个结点
p = L; //让p指向头结点(第0个结点)
while(p != NUll && j<i) { //循环查找
p = p->next;
j++;
}
return p;
}
实现了查找函数后,按位插入和删除操作就可以如上图一样简化了。而且查找函数刚好返回的是指针p,在插入操作中刚刚好可以进行后插函数中的第一个if判断,妙啊!妙到家了!天才一般的设计!
这里也能完美体现封装的好处,可以简化代码,而且易于维护,即后续项目设计中如果查找出错可以直接找到查找函数进行修改,而不用将所有用到查找操作的代码都改一遍。
单链表的查找之按值查找:
//按值查找,找到数据域 ==e的结点(带头)
LNode * LocateElem (LinkList L, ElemType e) {
LNode *p = L->next; //从第一个结点开始查找数据域位e的结点
while(p != NULL && p->data != e)
p = p->next;
return p; //找到就返回该结点指针,找不到就返回链表最后的NULL
}
强调:如果数据域是结构体则不能直接用== 或 !=来作比较,而应该将结构体中的每个数据都做比较,建议抽象成函数完成。
求表长:
//求表长(带头结点)
int Length (LinkList L){
int len = 0; //统计表长
LNode *p = L;
while(p->next != NULL){
p = p->next;
len++;
}
return len;
}
思考:不带头结点怎么求表长?
答:将循环条件改为 p != NULL
单链表的建立
为了解决将多个元素存储到单链表中这个问题,我们需要完成很多元素的连续插入,下面我们将通过头插和尾插两种方法实现单链表的建立。
尾插法:
如图所示我们就完成了第一个元素的插入,如此循环就可以实现尾插法建立单链表,代码如下:
//尾插法建立单链表
LinkList List_Tailnsert(LinkList &L){
int x; //要插入数据域的元素
L = new LNode; //建立头结点
L->next = NULL; //初始化防止其指向神秘未知区域,虽然没啥用但是是个好习惯
LNode *s, *r = L; //r为表尾指针
scanf("%d",&x); //输入结点的值
while(x != 9999){ //这个9999并没有特殊意义只是一个结束标志
s = new LNode; //建立新结点,实现s指针的后移
s->data = x;
r->next = s;
r = s; //实现r指针的后移
scanf("%d", &x);
}
r->next = NULL; //尾指针置空,防止尾结点的指针域指向神秘未知区域
return L;
}
头插法:
头插法的思想就比较简单,就是一直在头结点后面插入新元素,其他元素后移。但是作为链表,“后移”这个操作并没有什么意义,并没有像顺序表一样真正移动 。
//头插法建立单链表
LinkList List_HeadInsert(LinkList &L){
LNode *s; //新建结点指针s
int x; //要插入数据域的元素
L = (LinkList)malloc(sizeof(LNode)); //建立头结点
L->next = NULL; //初始化链表,防止L指向脏数据
scanf("%d",&x); //输入结点的值
while(x != 9999){ //这个9999并没有特殊意义只是一个结束标志
s = new LNode; //建立新结点,实现s指针的后移
s->data = x;
s->next = L->next;
L->next = s; //插入新结点
scanf("%d", &x);
}
return L;
}
经过推算我们不难发现,使用头插法插入的元素顺序是逆置的,这一特点使之成为链表逆置的重要方法!
question:不带头结点咋办?
answer:新建结点存储元素,然后将该结点视为头结点进入循环。