最全版基础数据结构精细讲解(期末必备)

目录

  • 前言
  • 一,算法介绍
    • 1.1 算法特性
    • 1.2 算法设计的要求
    • 1.3 算法效率的度量方法
  • 二,算法时间复杂度
    • 2.1 定义及理解
    • 2.2 时间复杂度的推导
  • 三,函数调用的时间复杂度分析
    • 3.1 例题
    • 3.2 常见的事件复杂度
    • 3.3 最坏情况与平均情况
  • 四,算法的空间复杂度
  • 五,线性表
    • 5.1 理解
    • 5.2 线性表的抽象数据类型
    • 5.3 线性表的顺序存储结构
    • 5.4 获取元素操作
    • 5.5 插入操作
    • 5.6 删除操作
    • 5.8 插入和删除两个算法优缺点
    • 5.7 整体代码
  • 六,线性表的链式存储结构
    • 6.1 优点
    • 6.2 单链表的结点
    • 6.3 头结点与头指针
    • 6.3 单链表的数据定义
    • 6.4 单链表的读取
    • 6.5 单链表的插入
    • 6.6 单链表删除
    • 6.7 单链表整体创建
    • 6.8 单链表的整表删除
    • 6.9 单链表结构与顺序存储结构优缺点
    • 6.10 整体代码
    • 6.11 循环链表
    • 6.12 双向循环链表
  • 七,栈
    • 7.1 初识栈
    • 7.2 栈的插入和删除操作
    • 7.3 栈的顺序存储结构
    • 7.4 栈的顺序存储结构
    • 7.5 顺序栈整体代码
  • 八,链式堆栈
    • 8.1 链栈介绍
    • 8.2 链栈详细代码即解析
    • 8.3 链栈运用——括号匹配问题
  • 九,队列
    • 9.1 初识队列
    • 9.2 顺序队列的存储结构
    • 9.3 顺序队列的“假溢出”问题
    • 9.4 循环队列
    • 9.4 顺序循环队列的实现
    • 9.5 链式队列
  • 十,串
    • 10.1 串概述
    • 10.2 字符串的长度
    • 10.3 串的基本操作实现算法
    • 10.4 串的模式匹配算法
      • 10.4.1 Brute-Force算法
    • 10.4.2 KMP算法
  • 十一,树和二叉树
    • 11.1 树的定义
    • 11.2 二叉树
      • 11.2.1 二叉树的定义
    • 11.3 二叉树的操作实现
    • 11.4 二叉树的遍历
      • 11.4.1 前序遍历
      • 11.4.2 中序遍历
      • 11.4.3 后序遍历
      • 11.4.4 相关代码
    • 11.5 哈夫曼树
      • 11.5.1 二叉树中的基本概念
      • 11.5.2 哈夫曼树的绘制
  • 十二,图
    • 12.1 图的概述
    • 12.2 图的存储结构
      • 12.2.1 图的邻接矩阵存储结构(稠密)
      • 12.2.2 图的邻接链表存储结构(稀疏)
    • 12.3 图的遍历
      • 12.3.2 图的深度优先遍历
      • 12.3.3 图的广度优先遍历
    • 12.4 最小生成树
      • 12.4.1 普里姆算法
      • 12.4.2 克鲁斯卡尔算法

前言

首先,本节内容需要有c语言的语法基础。尤其是指针和结构体部分。不熟悉的朋友可以利用下面两节博客复习
指针
结构体

其次,本节博客包含了考试要考的数据结构中大部分内容。书本是第六版的数据结构。链表是最基础的部分,所以链表的代码讲解很细致。其余模块在每一个模块的整体代码处我也会把书上的代码及我的解析搬过来供大家参考~
(本文内容较全,期末请放心食用~)

另外,我之前还写过java版本的数据结构中的动态数组,链表,栈,队列,二叉树和二分搜索树,学有余力的朋友可以去我的主页查看。

最后,希望大家能够多实践,多思考。编程不是看会的,而是多练多写。
祝大家在IT路上一路顺风,学习顺利!!!谢谢关注~

一,算法介绍

1.1 算法特性

算法的五个特性: 输入,输出,有穷性,确定性和可行性

输入: 0个或多个输入,绝大部分算法输入参数都有必要。
输出:至少有一个或多个输出算法一定要有输出。输出形式可以是打印式输出,也可以是返回一个值或者多个值。
有穷性: 算法在执行有限的步骤之后自动结束而不会出现无限循环并且每一个步骤都在可以接受的时间范围内完成。
确定性:算法的每一个步骤都有确定的含义,每一个步骤都应该精确确定无歧义
可行性: 每一步都要可行

1.2 算法设计的要求

正确性:
-算法程序设计没有语法错误
-算法程序对于合法输入能够产生满足要求的输出
-算法程序对于非法输入能够产生满足规格的说明
-算法程序对于故意刁难的测试输入都有满足要求的输出结果

可读性:
-算法设计需要便于阅读理解交流
-写代码需要便于别人的修改

健壮性
-输入不合法要有异常处理

时间效率高和存储量低

1.3 算法效率的度量方法

事后统计方法:这种方法主要是通过设计好的测试程序和数据利用计算机计时器对不同算法编制的程序的运行时间进行比价,从而确定算法效率的高低
-缺陷: 必须依据算法编好测试程序花费大量时间精力
-不同测试环境差别很大

事前分析估算方法: 在计算机程序编写前依据统计方法对算法进行估算:
1.算法的策略方案
2.编辑产生的代码质量
3.问题输入规模
4.机器执行指令速度

二,算法时间复杂度

2.1 定义及理解

定义: 在进行算法分析时,语句的总执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的事件量读,记作T(n)=O(f(n))。它表示随时间规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中,f(n)是问题规模n的某个函数。

关键是需要知道,执行次数等于时间
随着输入规模n的增大,T(n)增长最慢的算法为最优算法

2.2 时间复杂度的推导

我们要明确概念“T(n)是关于问题规模n的函数”,所以对于初学者来说,不要认为有多少条语句O就有多少。
对于常数阶,大O一般为1

线性阶:一般含有费嵌套循环设计线性阶,线性阶就是随着时间规模n的扩大对应计算次数呈直线增长。循环复杂度为n,因为循环体中代码执行n次

平方阶: n=100,也就是说外层循环每执行一次,内层循环就执行100次,那总程序想要从这两个循环出来,需要执行100*100次,也就是n的平方,复杂度大O为n^2,一般用于多重嵌套。

对数阶:

int i=1,n=100;
while(i<n)
{
	i=i*2;
)

每一次i*2之后,就距离n更近一步,假设有x个2相乘后大于或等于n,则会退出循环。
由于是2^x=n,x=log(2)n,所以时间复杂度为logn

三,函数调用的时间复杂度分析

3.1 例题

int i,j;
for(int i=0;i<n;i++){
	function(i);
}
void function(int count){
	printf("%d%,count);
}

function函数的时间复杂度是O(1)所以整体的时间复杂度就是循环的次数O(n)

void function(int count) {
	int j;
	for(j=count;j<n;j++) {
		printf(j=count;j<n;j++){
			printf("%d",j);
		}
	}
}

是一个嵌套算法,function随着count的增加而减少,算法时间复杂度为n^2

n++;
function(n);
for(i=0;i<n;i++) {
	function(i);
}
for(int i=0;i<n;i++) {
	for(j=i;j<n;j++) {
		printf("%d",j);
	}
}

n++执行一次。O为1.
function(n)执行n次,里面嵌套了n次,O为n^2
第三行的for中有function,所以O为n^2
第二个for中同样也是n的平方
这几个都是并列的所以加起来是3n2+1,==保留最高阶,如果最高阶前面有常数与之相乘的话把常数去掉==,所以最后的结果是n2

3.2 常见的事件复杂度

O(1)

3.3 最坏情况与平均情况

我们在查找一个有n个随机数组中的某个数字,最好的情况是第一个数字就是,那么事件复杂度为O(1),最坏的情况是这个数字出现在最后一个位置,那么时间复杂度为O(n).

平均运行时间是期望的运行时间。

最坏运行时间是一种保证,在应用中,这是一种重要的需求,通常除非特别指定,我们提到的运行时间都是最坏情况的运行时间

四,算法的空间复杂度

我们在写代码时,可以用空间来换取时间。比如判断某年是不是闰年,第一种方法是写一个算法,每给一个年份可以判断该年份是不是闰年
第二种方法就是实现建立一个2040个元素的数组把所有的年份存入数组,如果闰年数组对应元素为1否则为0。这样判断一年是否被为闰年变成了查找这个数组某一个元素值的问题。

五,线性表

5.1 理解

定义:由0个或多个数据元素组成的有限序列。
注意点:
1.线性表示一个序列,元素之间有先来后到。
2.若元素存在多个,则第一个元素无前驱,第二个元素无后继,其他元素都有且只有一个前驱和后继。
3.线性表示有限的。

5.2 线性表的抽象数据类型

List–线性表
DataType–每个元素的类型
操作
InitList(*L): 初始化操作,建立一个空的线性表L
ListEmpty(L):判断线性表是否为空表,若线性表为空,则返回true,否则返回false
ClearList(*L):将线性表清空
GetElem(L,i,*e)将线性表L中的第i个位置元素值返回给e
LocateElem(L,e):在线性表L中金黄金欧诺个查找与给定值e相等的元素,如果查找成功,返回该元素在线性表中序号表示成功,否则返回0表示失败。
ListInsert(*L,i,e):在线性表L中第i个位置插入新元素e
ListDelete(*L,i,*e):删除线性表L中第i个位置元素,并用e返回其值
ListLength(L):返回线性表L的元素个数

初次看到上面的数据类型大家比较陌生,初学者可以先过一眼。这是官方给出的操作名称。
至于什么时候是指针什么时候不是将在后续为大家讲解

5.3 线性表的顺序存储结构

顺序:用一段地址连续的存储单元一次存储线性表的数据结构,也就是数组

物理上的存储方式是在内存中找个初始地址然后通过占位的形式把一定的内存空间占有。顺序表的位置是连续的

接下来看线性表顺序存储的结构代码

#define MaxSize 20
typedef int ElemType;
typedef struct
{
	ElemType data[maxSize];
	int length;
}SqList;

对数组进行了封装。
总结下:顺序存储结构封装需要三个属性
-存储空间的起始位置,数组data,它的存储位置就是线性表存储空间的存储位置
-线性表最大存储量:maxsize
-线性表当前长度:length,初始化不变,如果需要变化可以参考java版动态数组(虽是java但是逻辑算法一致)
-地址:Loc(ai+1)=Loc(ai)+c,ai是存储单元

5.4 获取元素操作

GetElem
即将第i个位置元素返回,把下标为i-1的元素返回即可

//Status是函数类型,其值是函数的结果状态代码:如OK
Status getElem(SqList L,int i,ElemType *e)
{
	if(L.length==0||i<1||i>L.length)
	{
		return ERROR:
	}
	*e=L.data[i-1];//e是返回值
	return OK;
}

5.5 插入操作

ListInsert(*L,i,e)
插入算法思路
-如果插入的位置不合理,抛出异常
-如果线性表长度大于等于数组长度则抛出异常或动态增加数组容量
-从最后一个元素开始向前遍历到第i个位置,分别将它们向后移动一个位置
-将插入元素填入位置i处

Status ListInsert(SqList *L,int i,ElemType e)
{
	int k;
	if(L->length==MAXSIZE)//顺序表已满
	{
		return ERROR;
	}
	if(i<1||i>L->length+1)//i不在范围之内
	{
		return ERROR;
	}
	if(i<=L->length-1;k>=i-1;k--)
	{
		//将要插入位置后数据元素向后移动一位
		for(k=L->length-1;k>=i-1;k--)
		{
			L->data[k+1]=L->data[k];
		}
	}
	L->data[i-1]=e;//将新元素插入
	L->length++;
	retuen OK;
}

5.6 删除操作

与上述插入一致,想要删除,我们挪出其中的一个元素,并把后面的所有元素往前挪一个单位
删除算法的思路
-如果删除位置不合理,抛出异常
-取出删除元素
-从删除元素位置开始遍历到最后一个元素位置,分别将它们都向前移动一个位置
-表长-1
代码如下

Status ListDelete(*L,i,*e)
{
	int k;
	if(i<1||i>L->length+1||L->length==0)
	{
		return ERROR;
	}
	if(i<L->length)
	{
		for(k=i;k<L->length;k++)
		{
			L->data[k-1]=L->data[k];//前一个等于后一个
		}
	}
	L->length--;
	return OK;
}

5.8 插入和删除两个算法优缺点

时间复杂度:
如果是在最后一个元素的位置进行插入和删除则时间复杂度大O为1
最坏的情况:在第一个元素进行插入和删除,时间复杂度大O为n
所以我们取中间值(n-1)/2,按照计算方式,舍去前面参数,时间复杂度为n

现行顺序存储结构的优缺点:
优点
-无需为表示表中元素之间的逻辑关系而增加额外的存储空间
-可以快速的存取表中任意位置的元素
缺点
-插入和删除操作需要移动大量元素
-当线性表长度变化较大,难以确定存储空间的容量
-容易造成存储空间的“碎片”

所以,我们引入链式存储结构

5.7 整体代码

#include

typedef struct {
	DataType list[MaxSize];
	int size;
}SeqList;

//初始化顺序表
void ListInitiate(SeqList *L) {
	L->size=0;
}
//求顺序表的长度 
int ListLength(SeqList *L) {
	return L.size;
}
//向顺序表中插入元素 
//由于函数中要改变参数L的size域的值,因此参数L应设计为输出型参数,即参数L设计为SeqList的指针类型
//否则,size域的修改值不能带回去 
int ListInsert(SeqList *L,int i,DataType x) {
	 int j;
	 if(L->size>=maxSize) {
	 	printf("顺序表已满无法插入!\n");
	 	return 0;
	 }
	 else if(i<0||i>L->size) {
	 	printf("参数i不合法!\n");
	 }
	 else {
	 	//从后向前依次移动数据,为插入做准备
		 for(j=L->size;j>i;j--) {
		 	L->list[j]=L->list[j-1];
		 	L->list[i]=x;
		 	L->size++;
		 	return 1;
		 } 
	 }
	 }
//从顺序表中删除元素
int ListDelete(SeqList *L,int i,) {
	int j;
	if(L->size<=0) {
		printf("顺序表已无元素可以删!\n");
	}
	else if(i>L->size-1||i<0) {
		printf("参数i不合法\n");
	}
	else {
		for(j=i+1;j<=L->size-1;j++) {
			L->list[j-1]=list[j];
			L->size--;
			return 1;
		   
		}
	}
}
//取元素
int ListGet(SeqList L;int i;DataType *x){
//取顺序表L中第i个元素存于x中,成功返回1,失败返回0
	if(i<0||i>L->size-1) {
		printf("参数不合法");
		return 0; 
	}
	else {
		*x=L->data[i];
		return 1;
	}
}

六,线性表的链式存储结构

6.1 优点

线性存储结构的插入和删除需要移动大量元素,原因就在于相邻两元素的存储位置也具有令居关系,它们在内存中式紧挨着的,中间没有间隙,所以无法快速插入和删除。

而链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以存在内存中未被占用的任意位置。
比起顺序存储结构每个数据元素只需要存储一个位置就可以了。现在链式存储结构中,除了要存储数据元素信息外,还要存储它的后续元素的存储地址(指针)。
我们把存储数据元素的信息

我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称为指针或链,这两部分信息组成数据元素称为存储影响称为结点Node
在这里插入图片描述
n个结点链接成一个链表,即为线性表的链式流结构。
因此链表的每个结点中只包含一个指针域,所以叫做单链表

6.2 单链表的结点

最全版基础数据结构精细讲解(期末必备)_第1张图片
data是数据,next是指向链表下一个元素的指针,数据和指针共同组成一个结点

头指针:线性表有头有尾,我们把链表中的第一个结点的存储位置叫做头指针,最后一个结点指针为空

6.3 头结点与头指针

头指针:
-头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针
-头指针具有标识作用,所以常用头指针冠以链表的名字(指针变量的名字)
-无论链表是否为空,头指针均不为空
-头指针是链表的必要元素
头结点:
-头结点是为了操作的统一和方便而设立的,放在第一个元素的结点之前,其数据域一般无意义(但也可以用来存放链表的长度)
-有了头结点,对在第一元素节点前插入结点和删除第一结点起操作与其他结点的操作就统一了
-头结点不是链表的必要元素
如果链表为空,头指针指向头结点指向null

6.3 单链表的数据定义

typedef struct Node 
{
	ElemType data;//数据域
	Struct Node *next;//指针域
	//我们看到结点由存放数据元素的数据域和存放后继结点地址的指针域组成
}Node;
typedef struct Node* LinkList;//定义了头指针,头指针可以是链表的名字

如果p是指向第i个元素的指针,则我们可以用p->data来访问第i个元素的数据域,用p->next来访问第i个元素的指针域。
p->next的值是一个指针
所以,如果p->data=ai,则p->next->data的值应该是ai+1

6.4 单链表的读取

线性表中我们寻找第i个元素十分容易,直接访问即可。但是在单链表中,如果要找第i个元素我们要从第一个结点开始找
思路:
-声明一个结点p指向链表的第一个结点,初始化j从1开始
-j>i遍历链表,让p的指针向后移动,不断指向下一个结点j+1
-若到链表末尾p为空,则说明第i个元素不存在

Status GetElem(LinkList L,int i,ElemType *e)
{
	int j;
	LinkList p;//typedef struct Node* LinkList;所以,LinkList代表头指针,这里仅代表p是头指针并未声明指向谁
	p=L->next;//让p指向L的第一个元素,现在位置才确定了p是指向头结点的指针
	j=1;
	while(p&&j<i)//p不能为空p存在,j
	{
		p=p->next;
		++j;
	}
	if(!p||j>i)
	{
		return ERROR;
	}
	*e=p->data;
	return OK:
}

时间复杂度为O(n),核心思想叫做“工作指针后移”

6.5 单链表的插入

假设存储元素e的结点为s,要实现结点p,p->next和s之间逻辑关系的变化
最全版基础数据结构精细讲解(期末必备)_第2张图片
s->next=p->next;
p.next=s;即可
==这里注意,这里不能交换位置,如果交换位置,则p->next会提前被赋值给s,s->next的赋值就无效了
单链表插入算法总结
-声明以节点p指向链表头结点,初始化j从1开始
-当j -若到链表末尾p为空,则说明第i个元素不存在
-若查抄成功,在系统中生成一个空结点s

status ListInsert(LinkList *L,int i,ElemType e)
{
	int j;
	LinkList p,s;
	p=*L;
	j=1;
	while(p&&j<i)
	{
		return ERROR;
		j++;
	}
	s=(LinkList)malloc(sizeof(Node));//malloc生成新节点类型为Node,LinkList
	s->data=e;
	s-next=p->next;
	p->next=s;
	return OK:
}

6.6 单链表删除

假设元素a的结点为q,要实现节点q删除单链表的操作,其实就是将它的前继结点的指针绕过后继结点即可。
那我么要做的p->next=p->next->next
也可以是q=p->next; p->next=q->next;

思路
-声明结点p指向链表的第一个结点,初始化j=1
-当j -若到链表末尾p为空,则说明第i个元素不存在
-否则查询成功,将欲删除结点p->next赋值给q
-单链表的删除标准语句p->next=q->next

Status ListDelete(LinkList *L,int i,ElemType *e)
{
	int j;
	LinkList p,q;
	p=*L;
	j=1;
	while(p->next&&j<i)
	{
		p=p->next;
		j++;
	}
	if(!(p->next)||j>i)
	{
		return ERROR;
	}
	q=p->next;
	p->next=q->next;

	*e=q->data;
	free(q);
	return OK;
}

删除的时间复杂度也为O(n)

6.7 单链表整体创建

创建单链表的过程是一个动态生成链表的过程,从“空表”的初始状态起,依次建立各元素结点并逐个插入链表

单链表整表创建算法思路:
–声明一结点p和技术器变量i
–初始化一空链表L
–让L的头结点指针指向NULL,即建立一个带头结点的单链表
–循环实现后继结点的赋值和插入

建立单链表思路:头插法
–头插法从一个空表开始,生成新结点,读取数据存放到新结点的数据域中,然后将新结点插入到当前链表的表头上直到
结束为止
–简单来说,就是把新加进的元素放在表头的第一个位置
----先让新结点的next指向头结点
----然后再让表头的next指向新结点

void CreateListH:ead(LinkList *L,int n)
{
	LinkList p;//指针p 
	int i;
	
	scrand(time(0));//初始化随机数种子
	*L=(LinkList)malloc(sizeof(Node));//创建头指针
	(*L)->next=NULL;
	
	for(int i=0;i<n;i++)
	{
		p=(LinkList)malloc(sizeof(Node));
		p->data=rand();
		p->next=(*L)->next;
		(*L)->next=p;//让表头的next指向新结点 
	 } 
	 
 } 

6.8 单链表的整表删除

当我们不打算使用这个单链表时,我们需要把它销毁
思路如下:
-声明结点p和q
-将第一个结点赋值给p,下一个结点赋值为q
-循环执行释放p和q赋值给p的操作

 Status ClearList(LinkList *L)
 {
 	LinkList p,q;
 	p=(*L)->next;
 	while(p)
 	{
 		q=p->next;
 		free(p);
 		p=q;
	 }
	 (*L)->next=NULL:
	return OK:
  } 

6.9 单链表结构与顺序存储结构优缺点

我们从存储分配方式,时间性能,空间性能三个方面来做对比

存储分配方式:
-顺序存储结构用一段连续的存储单元一次存储线性表的数据结构
-单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素

时间性能
-查找:
–顺序存储结构O(1)
–单链表O(n)

-插入和删除
–顺序存储结构需要平均移动表长一半的元素,时间为O(n)
–单链表在计算某位置的指针后插入和删除都是O(1)

空间性能:
-顺序结构需要预分配存储空间,分大了,容易造成空间浪费,分小了,容易发生溢出。
-单链表不需要分配存储空间,只要有就可以分配,元素个数也不受限制。

所以我们可以各取所需去做选择。

6.10 整体代码

#include
typedef struct Node {
	DataType data;
	struct Node *next;
}SLN} 
//单链表的初始化
void ListInitiate(SLNode **head){
	//在初始化操作前,头指针参数head没有具体的地址值。在初始化操作时,指针参数head才得到了具体的地址值,而
	//这个地址值要返回给调用函数,所以此时头指针参数head要设计成双重指针,如果此时头指针参数head设计成失真类型,调用函数将
	//无法的到在初始化函数中被复制的头指针参数head的值 
	*head=(SLNode *)malloc(sizeof(SLNode));//初始化了一个地址,后续把地址传给指针,如果我后续需要改变地址则*head必须也要是个指针 
	(*head)->next=NULL;
} 
//求当前元素的个数
ListLength(SLNode *head) {
	SLNode *p=head;//指向头结点
	int size=0;//size初始化为0 
	while(*p->next!=NULL) {
		p=p->next;
		size++;
	} 
	return size;
} 
//插入元素
int ListInsert(SLNode *head,int i,DataType x) {
	//在带头结点的单链表head的第i(0-size)个结点前插入一个存放元素x的结点
	//插入成功则返回1,失败则返回0
	SLNode *p,*q;
	int j;
	p=head;
	for(j=0;j<i;j++) {
		p=p->next;//遍历到第i-1个元素 
	} 
	q=(SLNode *)malloc(sizeof(SLNode));//生成新节点
	q->data=x;
	
	q->next=p->next;
	p->next=q;
	return 1; 
} 
//删除元素
int ListDelete(SLNode *head,int i,Data *x) {
	//删除带头结点单链表head的第i个结点
	//被删除结点的数据域由x带回,删除成功返回1
	SLNode *p,*s;
	int j;
	p=head;
	for(int j=0;j<i;j++) {
		p=p->next;
		//p是第i个元素的前一个元素 
	} 
	s=p->next;//要删除的节点
	*x=s->data;//返回值
	p->next=p->next->next;
	free(s); 
	return 1;
} 
//取元素
int ListGet(SLNode *head,int i,DataType *x) {
	SLNode *p;
	p=head;
	for(int j=0;j<=i;j++) {
		p=p->next;
	}
	*x=p->data;
	return 1;
} 
/*
撤销单链表,因为单链表中的结点空间是在程序运行时动态申请的,而系统只负责自动回收程序中静态分配的内存空间
//和顺序表相比,单链表需要增加一个撤销单链表操作,用来调用程序退出前释放动态申请的内存空间 
*/ 
void Destroy(SLNode **head) {
	SLNode *p *q;
	p=*head;
	while(p!=NULL) {
		q=p;
		p=p->next;
		free(q);
	}
	*head=NULL;
}

6.11 循环链表

对于单链表,由于每个结点只存储了向后的指针,到了尾部标识就停止了向后链的操作。也就是说,按照这样的方式,只能索引后继结点不能索引前继结点

这会带来什么问题?
-如果不从头结点出发,就无法访问到全部结点
-事实上要解决这个问题只需要将单链表中终端结点的指针端由空指针改为指向头结点,问题就能得到解决
-将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表
最全版基础数据结构精细讲解(期末必备)_第3张图片
如果是空链表的话,则头指针的尾巴指向自己。
注意,循环链表不一定有头结点
循环链表和单链表的主要差异在于循环的判断空链表的条件上,原来判断head->next是否为空,现在则是head->next是否等于head

6.12 双向循环链表

循环双向链表则更加方便
每一个指针除了有一个后继指针还有一个前驱指针
最全版基础数据结构精细讲解(期末必备)_第4张图片

//双向循环链表
typedef struct Node {
	DataType data;
	struct Node *next;
	struct Node *prior;
}DLNode;
//初始化
void ListInitiate(DLNode **head) {
	*head=(DLNode *)malloc(sizeof(DLNode));
	(*head)->prior=*head;//构成前驱指针循环链表
	(*head)->next=*head;//构成后继指针循环链表 
} 
//插入元素
int ListInsert(DLNode *head,int i,DataType x) {
	//在带头结点的双向循环链表head的第i(0-size)个结点前,插入一个存放
	//元素x的结点,插入成功则返回1
	DLNode *p,*s;
	int j=0;
	p=head->next;//头结点的下一个,第1个结点  
	while(p!=head||j<i) {
		p=p->head;
		j++;//找到第i个结点,也就是现在p的位置 
	} 
	s=(DLNode *)malloc(DLNode);
	s->data=x;
	s->prior=p->prior;
	p->prior->next=s;
	s->next=p;
	//按照s的上一个和s的下一个来的 
	p->prior=s;
	return 1;
} 
//删除元素
Int ListDelete(DLNode *head,int i,DataType *x) {
//删除带头结点双向循环链表head的第i个结点,被删除结点的
//元素值由x带回,删除成功则返回1
	DLNode *p;
	int j;
	p=head-next;
	while(p!=head||j<i) {
		p=p->head;
		j++;
	}	
	*x=p->data;
	p->prior->next=p->next;
	p->next->prior=p->prior;
	free(p);
	rerturn 1;
} 
//求当前元素的长度
int ListLength(DLNode *heaD) {
	DLNode *p=head->next;
	int size=0;
	while(p!=head) {
		p=p->next;
		size++;
	}
	return size;
}
//撤销内存空间
void Destroy(DLNode **head) {
	DLNode *p,*q;
	int i;
	int n=ListLength(*head);
	p=*head;
	for(i=0;i<n;i++) {
		q=p;
		p=p->next;
		free(q);
	}
	*head=NULL;
} 

七,栈

7.1 初识栈

栈的定义:
栈是一个后进先出的线性表,它要求只在表尾进行删除和插入操作。

所谓的栈,其实就是一个特殊的线性表,但是它在操作上有一些特殊的要求和限制:
–栈的元素必须后进先出
–栈的操作只能在这个线性表的表尾进行
最全版基础数据结构精细讲解(期末必备)_第5张图片
==注意:对于栈来说,这个表尾称为栈的栈顶(top),相应的表头称为栈底(bottom)

7.2 栈的插入和删除操作

栈的插入操作(push)叫做进栈,也称为压栈,入栈,类似于子弹放入弹夹的动作。
栈的删除操作(pop)叫做出栈,也称为弹栈。如同弹夹中的子弹出夹。

7.3 栈的顺序存储结构

因为栈本身是一个线性表,线性表有两种存储形式,栈也分顺序存储结构和栈的链式存储结构

最开始,栈中不含有任何数据,叫做空栈,此时栈顶就是栈底,然后数据从栈顶进入,栈顶栈底分离,整个栈的当前容量变大,数据出栈时从栈顶弹出,栈顶下移,整个栈的当前容量变小

7.4 栈的顺序存储结构

结构体的定义:

typedef struct 
{
	DataType stack[MaxStackSize];
	int top;
} SeqStack;

top指针用来之处当前存储的元素个数size。


初始化:
void StackInitiate(SeqStack *S)
{
	S->top=0;
}

判断栈是否为空 为空则返回0,不为空返回1
int StackNotEmpty(SeqStack S)
{
	if(S->top<=0)
	return 0;
	else return 1;
}

入栈操作
int StackPush(SeqStack *S,DataType x)
{
	//把x存入堆栈S中,若入栈成功则返回1,否则返回0
	if(S->top>=MaxStackSize)
	{
		printf("堆栈已满无法插入!\n");
		return 0;
	}
	else {
		S->Stack[S->top]=x;
		S->top++;
		return 1;
	}
}

这里就更加能强调处top的作用了。top实际作用是指向栈顶元素的指针,也就是当前的size,而MaxSize的作用实际是最初申请位置时为这个栈申请的位置,一旦超过这个位置则不能再往栈里放元素。这也是顺序栈的缺点之一,因为一旦申请了大小其实栈的空间就已经确定了


出栈
int StackPop(SeqStack *S,DataType *d) 
{
//取出顺序堆栈S的栈顶元素,由参数d带回,若出栈成功则返回1,否则返回0
	if(S->top<=0){
	//空栈
		return 0;
	}
	else{
		S->top--;	
		*d=S->Stack[S->top];
		return 1;
	}
}

这里我想对*d再一次说明,当我们需要知道d是多少时,我们可以输出d,即可。但是当我们需要改变d的值时,我们需要用到指针


取出栈顶元素
int StackTop(SeqStack S,DataType *d){
//取出顺序堆栈S的当前栈顶元素由d带回,若成功则返回1,否则返回0
	if(S->top<=0)
	{
		return 0;//空栈
	}
	else {
		*d=S->Stack[S->top-1]
		return 1;
	}
}

==可能回有小伙伴对S->top和S->top-1有问题。
大家仔细看,第一段代码第一步做出了S->top–,而第二段直接在[]内做出了top-1,为什么不是top而是top-1?。因为栈的元素计数(数组)是从0开始的,所以需要减一。

7.5 顺序栈整体代码

#include
//结构体定义
typedef struct {
	DataType stack[MaxStackSize];
	int top;
}SeqStack;
//顺序堆栈的操作实现
//初始化
void StackInitiate(SeqStack *S){
	S->top=0;
}
//非空否
int StackNotEmpty(SeqStack S) {
	if(S->top<=0)
	return 0;
	else return 1;
}
//入栈
int StackPush(SeqStack *S,DataType *x){
	if(S->top>=MaxSize){
		printf("栈已满无法输入!\n");
		return 0;
	}
	else {
		S-stack>stack[S->top]=x;
		S->top++;
		return 1;
	}
} 
//出栈
int StackPop(SeqStack *S,DataType *d) {
	if(S->top<=0){
		printf("栈已空无元素出栈!\n");
		return 0;
	}
	else {
		S->top--;
		*d=S->stack[S->top];
		return 1;
	}
} 
//取出栈顶元素 
void StackPush(SeqStack S,DataType *d) {
	if(stack->top<=0){
		printf("堆栈已空!\n");
		return 0;
	}
	else {
		*d=S->stack[S->top-1];
		return 1;
	}
} 

八,链式堆栈

8.1 链栈介绍

最全版基础数据结构精细讲解(期末必备)_第6张图片
堆栈有两端,插入元素和删除元素的一端为栈顶,也就是左边
为什么要把靠近头指针的一段定义为栈顶?
因为这样每次插入元素时不需要遍历整个链,时间复杂度为O(1),如果在把头指针定义在栈底,则每次插入元素或者删除元素都需要遍历链表,时间复杂度为O(n),因此,==链栈都设计成把靠近头指针的一端定义为栈顶。

8.2 链栈详细代码即解析

#include
//结构体定义,一个存放数据,还有一个存放指向下一个结点的指针域next 
typedef struct snode {
	DataType data;
	struct snode *next;
}LSNode; 
//初始化
void StackInitiate(LSNode **head){
	*head=(LSNode *)malloc(sizeof(LSNode));//给指针head创造空间
	(*head)->next=NULL; 
} 
//非空否
int StackNotEmpty(LSNode *head){
	//判断堆栈是否为空,非空则返回1,空则返回0
	if(head->next=NULL)
	return 0;
	else return 1; 
} 
//入栈
void StackPush(LSNode *head,DataType x){
	//把元素x插入链式堆栈head的栈顶作为新的栈顶
	LSNode *p;
	p=(LSNode *)malloc(sizeof(LSNode));//为p开辟空间 
	p->data=x; 
	p->next=head->next;//插入到head的后面
	head->next=p;//新节点称为栈顶,把head往后移动了一个位置 
} 
//出栈
int StackPop(LSNode *head,DataType *d){
	//出栈并把栈顶元素由参数d带回,若出栈成功则返回1,否则返回0
	LSNode *p=head->next;
	if(p==NULL){
		printf("堆栈已空出错!\n");
		return 0;
	}
	head->next=p->next;//栈顶往前移动过了一个,变成p后面的元素
	*d=p->data;//原栈顶元素赋值给d 
	free(p);//释放p的内存空间 
	return 1; 
} 
//取栈顶元素
int StackTop(LSNode *head,DataType *d){
	//取出栈顶元素并由参数d带回
	LSNode *p=head->next;//p指向第一个元素 
	if(p==NULL){
		printf("堆栈已空出错!\n");
		return 0;
	} 
	else{
		*d=p->data;
		return 1;
	} 	
} 
//撤销动态空间
//和单链表一样,链式堆栈也要增加一个撤销动态申请空间的操作
void Destroy(SLNode *head){
	LSNode *p,*q;
	p=head;
	while(p!=null){
		q=p;
		p=p->next;
		free(q);
	}
} 

8.3 链栈运用——括号匹配问题

假设一个算数表达式中包含圆括号,方括号和花括号三种类型的括号,编写一个函数,用来判别表达式中括号是否正确配对,并设计一个测试主函数。

算法思想:右括号和左括号匹配的次序正好符合后到的括号要是最先被匹陪的“后进先出”堆栈操作特点,因此可以借助一个堆栈来进行判断

括号匹配一共4中情况:
1.左,右括号配对次序不正确
2.右括号多于左括号
3.左括号多于右括号
4.左右括号匹配正确

在前面我先不把具体方法打出来,代码和解析如下,相信读者朋友们看完后肯定能明白

#include

void ExplsCorrect(char exp[],int n){
	//判断有n个字符的字符串exp中左,右括号是否配对正确
	SeqStack myStack;//定义顺序栈变量myStack
	int i;
	char c;
	StackInitiate(&myStack);//初始化堆栈
	for(int i=0;i<n;i++){
		if((exp[i]=='(')||(exp[i]=='[')||(exp[i]=='{'))
		{
			//如果是左括号则入栈 
			StackPush(&myStack,exp[i]);
		}
		else if(exp[i]==')'&&StackNotEmpty(myStack)&&StackTop(myStack,&c)&&c=='(')
		{
			//如果是右括号,栈非空且栈顶元素为(则出栈 
			StackPop(&myStack,&c);
		}
		else if(exp[i]==')'&&StackNotEmpty(myStack)&&StackTop(myStack,&c)&&c!='('){
			//如果是右括号,栈非空且栈顶元素不为)则报错 
			printf("左括号右括号不匹配!\n");
			return; 
		}
		else if(exp[i]==']'&&StackNotEmpty(myStack)&&StackTop(myStack,&c)&&c=='[')
		{
			//如果是右中括号,栈非空且栈顶元素为(则出栈 
			StackPop(&myStack,&c);
		}
		else if(exp[i]==']'&&StackNotEmpty(myStack)&&StackTop(myStack,&c)&&c!='['){
			//如果是右中括号,栈非空且栈顶元素不为)则报错 
			printf("左中括号右中括号不匹配!\n");
			return; 
		}
		else if(exp[i]=='}'&&StackNotEmpty(myStack)&&StackTop(myStack,&c)&&c=='{')
		{
			//如果是右花括号,栈非空且栈顶元素为(则出栈 
			StackPop(&myStack,&c);
		}
		else if(exp[i]=='}'&&StackNotEmpty(myStack)&&StackTop(myStack,&c)&&c!='{'){
			//如果是右花括号,栈非空且栈顶元素不为)则报错 
			printf("左花括号右花括号不匹配!\n");
			return; 
		}
		else if(((exp[i]==')')||(exp[i]==']')||(exp[i]=='}'))&&!StackNotEmpty(myStack)){
			//右括号还有但是栈已空 
			printf("右括号多于左括号!\n");
			return;
		}
		if(StackNotEmpty(myStack)){
			printf("左括号多于右括号\n");
		}
		else printf("左右括号匹配正确"!\n);
	} 
} 

解释:先顺序扫描算数表达式(字符串),当遇到三种类型的左括号时让该括号入栈。
当扫描到某一种类型的右括号时,比较当前栈顶括号是否与之匹配,如果匹配则符合的左括号出栈,匹配继续;
如果栈顶元素与这个右括号不匹配,则匹配次序不正确;
若当前正在匹配右括号但是栈空,则右括号多于左括号;
如果当前正在匹配左括号但是右括号空,则左括号多于右括号;
如果没出现上面这三种情况则说明匹配正确。

九,队列

9.1 初识队列

队列定义:队列是一种特殊的线性表。线性表允许在任意位置删除和插入元素,队列值允许在一段进行插入,在另一单进行删除。
队尾是允许进行插入的一端
队头是允许进行删除的一段
相比于栈,栈是进行头插,队列进行尾插。
根据队列定义,每次入队列元素都放在队尾之后称为新的队尾元素,每次处队列元素都是原来的队头元素,所以是先进先出,而栈是先进后出。最全版基础数据结构精细讲解(期末必备)_第7张图片

9.2 顺序队列的存储结构

最全版基础数据结构精细讲解(期末必备)_第8张图片
刚刚讲过,对于顺序队列来说,我们从尾部插入元素,从头部取出元素(尾插法)。
当队列为空时头指针和尾指针都指向一个位置。有元素进入时,头指针向后移动。由于是数组所以从0开始计,而rear尾指针的位置是在最后一个元素后面的,头指针指向即将要出来的那个元素上,并不单独占有位置。

9.3 顺序队列的“假溢出”问题

最全版基础数据结构精细讲解(期末必备)_第9张图片
设顺序队列最大内存单元为6,当A,B,C入队列,A,B出队,D,E入队操作后,状态如上图。

此时,进入F,G入队操作。但是当G入队时,因为队尾指针月初数组下界而“溢出”。

然而顺序队列因多次入队列和出队列操作后出现的尚有内存单元,但是不能进行入队列操作的溢出称为假溢出

相对假溢出,一个顺序队列的内存单元全部被占满称为真溢出。怎么解决这个问题呢——循环队列

9.4 循环队列

循环队列原理:
把顺序队列所使用的内存单元构造成一个逻辑上首尾相连的循环队列。当==rear和font同时达到MaxQueueSize-1(最后一个位置),再前进一个位置就自动到0位置。这样就不会出现假溢出问题了。


循环队列的队满与队空的判断:

最全版基础数据结构精细讲解(期末必备)_第10张图片

可以看到,如果不作处理,则队列满与队列空无法正常判断。
从这张图也可以看出,无论是添加元素还是删除元素,指针都要向前移动一个位置

我们有三种方法可以判断队列的状态
方法一:
少用一个内存单元,如果少用一个内存单元,则队尾指针rear+1等于队头指针font为队满条件。
此时判断调键位:
(rear+1)%MaxQueueSizefont==
队空条件为rear==font

方法二:
设置一个标志位tag。初始tag=0;每当入队操作成功,tag=1;每当出队操作成功,tag=0;
队空:rearfont&&tag=0;
队满:rear
font&&font=1;

因为如果队空最后一个操作肯定是出队,反之如果队满最后一个操作肯定是入队

方法三:
设置一个计数器,初始位置count=0;每当入队列操作成功,count+1,出队列操作成功,count-1。
如果队空:count=0;
如果队满:
可以是count>0&&font==rear;
或者是count=MaxQueueSize;

9.4 顺序循环队列的实现

结构体定义

//结构体的定义                                             
typedef struct {
	DataType queue[MaxQueueSize];
	int rear;//队尾指针
	int font;//队头指针
	int count;//计数器 
}SeqCQueue;

初始化
//初始化
void QueueInitiate(SeqCQueue *Q){
	Q->rear=0;//定义初始队尾指针下标值
	Q->front=0;//定义初始队头下标值
	Q->count=0;//定义初始计数器值 
}         

判断队列是否为空
//判断队列是否为空
int QueueNotEmpty(SeqCQueue Q){
	//判断顺序循环队列Q非空否,若非空则返回1,否则返回0
	if(Q->count!=0)
	return 1;
	else return 0;//count不光能做计数器,还可以起到标志的作用,一般来说都是用count 
} 

入队列
//入队列
int QueueAppend(SeqCQueue *Q,DataType x) {
	//把x插入顺序循环队列Q的队尾,成功则返回1,失败则返回0
	if(Q->count>0&&Q->rear=Q->font){
		//队满判断
		printf("队列已满无法插入!\n");
		return 0; 
	}
	else {
		Q->queue[Q->rear]=x;//元素x插入队尾
		Q->rear=(Q->rear+1)%MaxQueueSize;//队尾元素加一。为什么要取余,因为队列循环,去余才能循环
		Q->count++;//计数器加1 
		return 1; 
	}
	} 
}

出队列
//出队列
int QueueDelete(SeqCQueue *Q,DataType *d){
	//删除顺序循环队列Q的队头元素并赋给d,成功则返回1,失败则返回0
	if(Q->count==0){
		printf("队列元素已空无元素出队列!\n");
		return 0;
	} 
	else {
		*d=Q->queue[Q->font];//取出队头元素存入d中
		Q->font=(Q->font+1)%MaxQueueSize;//队头指针加1
		Q->count--;
		return 1; 
	}
}

取队头元素
//取队头元素
IntQueueGet(SeqCQueue Q,DataType *d){
	//取顺序循环队列Q的当前队头元素并赋给d,成功则返回1,失败则返回0
	if(Q->count==0){
		//队空判断
		printf("队列已空无元素可取!\n");
		return 0; 
	} 
	else {
		*d=Q->queue[Q->font];
		return 1;
	}
} 

9.5 链式队列

队列的操作是受限制的线性表。队列有队头和队尾,插入元素的一端是队尾,删除元素的一端是队头。

链式队列的队头指针指向队列的当前队头节点的位置,队尾指针指向队列的当前队尾节点位置。对于不带头结点的链式队列,出队列可以直接删除头结点,因此不带头结点的链式队列更方便。
最全版基础数据结构精细讲解(期末必备)_第11张图片

结构体定义

//结点定义
typedef struct qnode {
	DataType data;
	struct qnode *next;
}LQNode;

//为了方便参数调用,把链式队列的队头指针front和队尾指针rear定义为如下结构体
typedef struct {
	LQNode *front;//队头指针
	LQNode *next;//队尾指针 
}LQueue;

初始化
//初始化
void QueueInitiate(LQueu;e *Q) {
	Q->front=NULL;//定义初始队头指针下标值 
	Q->rear=NULL;//定义初始队尾指针下标值 
}      

判断队列是否为空
//非空否 
int QueueNotEmpty(LQueue Q){
	//判断链式队尾Q是否为空,若空则返回1,非空则返回0
	if(Q->front==NULL){
		return 0;
	} 
	else return 1; 
} 

入队列
//入队列
void QueueAppend(LQueue *Q,DataType x){
	//把x插入链式队列Q的队尾
	LQNode *p;//定义一个指针p,即将新增在队尾 
	p=(LQNode *)malloc(sizeof(LQNode));//给p开辟空间 
	p->data=x;
	p->next=NULL;
	if(Q->rear!=NULL)Q->rear->next=p;//队尾原来非空时队尾加新节点
	Q->rear=p;//队尾指针指向p
	if(Q->front==NULL)Q->font=p;//队尾原来为空时修改队头指针(原来为空,现在加一个结点) 
} 

出队列
//出队列
int QueueDelete(LQueue *Q,DataType *d){
	//删除链式队列的Q的队头元素并赋给d,若出队列成功则返回1,否则返回0 
	LQNode *p;
	if(Q->front==NULL){
		printf("队列已空无元素出队列!\n");
	}
	else {
		*d=Q->front->data;
		p=Q->front;
		Q->front=Q->front-next;//出队列结点脱链
		if(Q->front==NULL) Q->rear=NULL;
		//删除最后一个结点后,要置队尾指针为空
		free(p);
		return 1; 
	}
}

取队头元素
//取队头元素 
int QueueGet(LQueue *Q,DataType *d){
	//取链式队列Q的队头元素并赋给d,若出队列成功则返回1,否则返回0
	LQNode *p;
	if(Q->front==NULL){
		printf("队列已空无元素出队列!\n");
		return 0;
	} 
	else {
		*d=Q->front->data;
		return 1;
	}
} 


撤销队列
//撤销动态申请空间
//和单链表的操作集合相同,链式队列也要增加一个撤销动态申请空间的操作
void Destropy(LQueue Q) {
	LQNode *p,*q;
	p=Q->front;
	while(p!=NULL){
		q=p;
		p=p->next;
		free(q);
	}
} 

十,串

其实串在测试中并不是一个重点内容,但是为了理解KMP算法这里还是详细解说一下。

10.1 串概述

定义: 串(也称作字符串)是由(n>=0)个字符组成的有限序列。串一般记作s="s0s1…sn-1’,其中s称作串名,n称作串的长度。双引号括起来的字符序列称作串的值。
需要注意,串是从第0个开始计算的。而长度为n

与线性表的差别:
1.线性表的元素可以是任意数据类型,而串元素只允许是字符类型。
2.线性表一次操作一个元素,串一次操作若干个元素。

串相等:
一个字符在一个串中的位置序号(大于等于0的正整数)称为该字符在串中的位置。可以比较任意两个串的大小。当且仅当两个串的值完全相等,不仅仅长度相等,而且每个对应位置的字符都相等,则我们称作两个串相等。

串和字符:
串是由字符组成,然而串和字符是两个不同的概念。串是长度不确定的字符序列。字符只是一个字符。即使是长度为1的串也和字符不同。串不仅要存储字符,还要存储长度数据。而字符仅需要存储数据。

10.2 字符串的长度

字符串的长度就是字符串所包含字符的个数
C语言中的字符串长度指的是第一个’\0’字符前出现的字符个数
C语言中通过’\0’结束符来确定字符串的长度
比如aaaaa,存储按照aaaaa\0,实际上该字符串长度为5而不是6

10.3 串的基本操作实现算法

了解即可。主要是为了方便大家理解串的原理。

//串基本操作实现算法
//串的动态数组结构体定义
typedef struct {
	char *str;
	int maxLength;
	int length;
}DString;
//初始化操作
//初始化操作用来申请存储串的动态数组空间以及给相关的数据域赋值
void Initiate(DString *S,int max,char *String){
	int i;
	S->str=(char *)malloc(sizeof(char)*max);//申请动态数组空间
	S->maxLength=max;//置动态数组元素最大个数
	S->length=strlen(string);//置串当前长度值
	//其中,strlen是计算字符串长度值的一种函数,对象必须是指针
	for(int i=0;i<S->length;i++){
		S->str[i]=string[i];//赋值 
	} 
} 
//初始化操作时,通过参数max给出指定串变量的长度值,通过参数string给出串变量的初值
//插入子串操作
int Insert(DString *S,int pos,DString T){
	//在子串S的pos位置插入子串,插入成功则返回1,失败则返回0
	int i;
	if(pos<0){
		printf("参数pos出错");
		return 0;
	} 
	else {
		if(S->length+T.length>S->maxLength){
			//重新申请S->str所指数组空间,原数组元素存放在新数组的前面
			realloc(S->str,(S->length+T.length)*sizeof(char));
			S->maxLength=S->length+T.length; 
		}
	}
	for(i=S->length-1;i>=pos;i--){
		S->str[i+T.length]=S->str[i];//依次后移T.length个位置
		for(i=0;i<T.length;i++)
			S->str[pos+i]=T.str[i];//插入
			S->length=S->length+T.length;//置新的元素个数 
	}
} 
//删除子串操作
int Delete(DString *S,int pos,int len){
	//删除主串S从pos位置开始长度为len的子串,删除成功则返回1,失败则返回0
	int i;
	if(S->length<=0){
		printf("数组中未存放字符无元素可删!\n");
		return 0;
	} 
	else if(pos<0||len<0||pos+len>S->length){
		printf("参数pos和len不合法");
		return 0;
	}
	else {
		for(i=pos+len;i<=S->length-1;i++){
			S->str[i-len]=S-str[i];//依次前移len个位置 
		}
		S->length=S->length-len;//置新的元素个数
		return 1; 
	}
} 
//取子串操作
int SubString(DString *S,int pos,int len,DString *T) {
	//取主串S从pos位置开始的长度为len的子串,取成功则返回1,失败则返回0
	int i;
	if(pos<0||len<0||pos+len>S->length){
		printf("参数pos和len出错!");
		return 0; 
	}
	if(len>T->maxLength){
		T->str=(char *)malloc(len*sizeof(char));//重新申请数组空间
		T->maxLength=len; 
	}
	for(i=0;i<len;i++){
		T->str[i]=S->str[pos+i];
		T->length=len;
		return 1;
	}
}
//撤销操作
void Destroy(DString *S){
	//撤销串S占用的内存空间
	free(S->str);
	S->maxLength=0;
	S->length=0;
} 

10.4 串的模式匹配算法

模式匹配具体含义是:在主串(也称目标串)S中,从位置start开始查找是否存在子串(也称作模式串)T,如果在子串S中查找到一个与模式串T相同的子串,则查找成功,否杂而查找失败。
Barute-Force和KMP算法是两种经常使用的顺序存储结构下的串模式匹配算法

10.4.1 Brute-Force算法

最全版基础数据结构精细讲解(期末必备)_第12张图片
算法思想:从主串的第一个字符开始与子串的第一个字符比较,如果相等则比较后续字符,如果不相等则上一次比较子串开头的位置的后一个位置开始与子串进行比较,以此类推。
结束循环条件:如果存在子串中的每个字符依次和主串中的每一个连续字符序列相等则匹配成功。

针对上述标黄的解释
最全版基础数据结构精细讲解(期末必备)_第13张图片

比如我们第一次比较从Si-j处开始,但是比较失败,则第二次比较的起始位置是第一次比较的起始位置的后一个位置,也就是Si-j+1开始

相关代码

int BFIndex(DString S,int start,DString T){
	//查找主串S从start开始的子串T,成功则返回T在S中的首字母位置,失败则返回-1 
	int i=start,j=0,v;
	while(i<S.length&&j<T.length) {
		if(S.str[i]==T.str[j]){
			j++;
			i++;
		}
		else {
			i=i-j+1;
			j=0;
		}
	}
	if(j=T.length) {
		v=i-T.length;
	}
	else v=-1;
	return v;
} 

算法优缺点:
优点:易于理解,大部分情况下效率良好。
缺点;有些情况下效率很低。

10.4.2 KMP算法

KMP算法是三位学者在Brute-Force算法基础上同时提出的模式匹配的改进算法。KMP算法消除了当多个字符比较相等只要有一个字符比较不相等便需要回退的缺点。

如何思考这个问题?必要的回退是要有的,但是有些情况可以不用回退第一种情况是,子串中无真子串。第二种是,模式串中有真子串,t=“abacabab”,子串t=“abab”。
我们对它作一次比较
最全版基础数据结构精细讲解(期末必备)_第14张图片
第一步推导:s0=t0,s1=t1l,s2=t2,s3!=t3

可以接着推导
观察可知,t0!=t1,s1=t1所以s1!=t0。
又因为,t0=t2,s2=t2,所以s2=t0,因此可以直接比较s3和t1进行比较,这样就不用s1开始比较了。

所以,一旦si和tj不相等,主串s的比较位置也不用回退,主串si(或者si+1)可以直接和子串tk进行比较,其中,k的确定与s没有关系,只与t本身有关。因此从子串本身可以求出k的值,这就变成了求模式串的真子串问题。
KMP
这个链接哈顿之光博主的KMP算法解释,思路非常清晰,脱离课本的讲解。大家可以看看。

十一,树和二叉树

11.1 树的定义

定义:树是由n(n>=0)个结点构成的集合,n=0的结点称为空树,n=1的树只有一个结点,对n>1的树T有以下规律:
1.有一个特殊的结点称为根结点,根结点没有前驱结点;
2.除根结点外,其余节点被分为m个互不相交的集合T1,T2,…Tm,其中,每个集合本身又是一棵结构与树类同的子树。
最全版基础数据结构精细讲解(期末必备)_第15张图片
树的一些常见用语:
结点:结点包括一个元素及若干指向其子树的分支。a树有一个结点,b树有12个结点
结点的度:结点所拥有的的子树个数为该结点的度
叶结点:度为0的结点称为叶结点,叶节点也称作终端结点
分支结点:度不为0的结点称为分支结点,分支结点也称作非终端结点。一棵树除叶结点外的所有结点都是分支结点
双亲结点:树的一个结点的子树的根结点称为这个结点的双亲结点、
兄弟结点:具有相同的双亲结点的结点称作兄弟结点
树的度:树中所有结点的度的最大值称作该树的深度
无序树:树的任意一个结点的个孩子结点之间的次序构成无关紧要的树称作无序树
有序树:树种任意一个结点的个孩子结点有严格排列次序的树称为有序树
森林:m棵树的集合称为森林。m>=0,根据定义一棵树也可以称作森林

11.2 二叉树

11.2.1 二叉树的定义

二叉树是n个有限结点构成的集合,n=0的树称作空二叉树,n=1的树只有一个根结点,n>1的二叉树由一个根结点和之至多两个不相交,分别称作左子树和右子树的空二叉树。

最全版基础数据结构精细讲解(期末必备)_第16张图片
这就是两颗不同的二叉树。
满二叉树:如果所有的分支结点都存在左子树和右子树,并且所有叶结点都在同一层上,则这样的二叉树称作满二叉树
最全版基础数据结构精细讲解(期末必备)_第17张图片

完全二叉树:如果一棵具有n个结点的二叉树的结构与满二叉树的前n个结点结构相同,这样的二叉树称作完全二叉树
最全版基础数据结构精细讲解(期末必备)_第18张图片
还要补充一点:完全二叉树的叶结点都几种在左边。
满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树

11.3 二叉树的操作实现

使用链表

#include
#include
typedef char DataType;

typedef struct Node {
	DataType data;//元素
	struct Node *leftChild;//左孩子指针
	struct Node *rightChild;//右孩子指针 
}BiTreeNode;//结点结构体定义

//初始化
void Initiate(BiTreeNode **root) {
	//初始化建立二叉树头结点
	*root=(BiTreeNode *)malloc(sizeof(BiTreeNode));
	(*root)->leftChild=NULL;
	(*root)->rightChild=NULL;
} 
//左插入结点
BiTreeNode *InsertLeftNode(BiTreeNode *curr,DataType x){
	//若当前节点curr非空,则在curr的左子树插入元素为x的新结点
	//原curr的左子树成为新插入结点的左子树
	//若插入成功,则返回新插入结点的指针,否则返回空结点 
	BiTreeNode *s,*t;
	if(curr==NULL)return NULL;
	t=curr->leftChild;//保存原curr的左孩子指针
	s=(BiTreeNode *)malloc(sizeof(BiTreeNode));
	s->data=x;
	s->leftChild=t;//新插入结点的左子树称为原curr的左子树 
	s->rightChild=NULL;
	curr->leftChild=s;//新结点称为curr的左子树
	return curr->leftChild;//返回新插入结点的指针 
} 
//右插入结点
BiTreeNode *InsertRightNode(BiTreeNode *curr,DataType x){
	//若当前curr非空,则在curr的右子树插入元素为x的新结点
	//原curr的右子树成为新插入结点的右子树
	//若插入成功,则返回新插入结点的指针,否则返回空指针
	BiTreeNode *s,*t;
	if(curr==NULL)return NULL;
	t=curr->rightChild;//保存原curr的右结点
	s=(BiTreeNode *)malloc(sizeof(BiTreeNode));
	s->data=x;
	s->rightChild=t;
	s->leftChild=NULL;
	curr->rightChild=s;
	return curr->rightChild; 
} 
//左删除子树
BiTreeNode *DeleteLeftTree(BiTreeNode *curr){
	//若当前结点curr非空,则删除curr的左子树
	//若删除成功,则返回删除节点的双亲结点指针,否则返回空指针
	if(curr==NULL||curr->leftChild==NULL)return NULL:
	Destroy(&curr->leftChild);
	curr->leftChild=NULL;
	return curr; 
} 
//右删除子树
BiTreeNode *DeleterightTree(BiTreeNode *curr){
	if(curr==NULL||curr->rightChild==NULL)return NULL;
	Destroy(&curr->rightChild);
	curr->rightChild=NULL;
	return curr;
} 

树的子树删除示意图
最全版基础数据结构精细讲解(期末必备)_第19张图片

11.4 二叉树的遍历

二叉树的遍历有四种方法
前序遍历,中序遍历,后序遍历,层序遍历。
实际上前序遍历,中序遍历,后序遍历,都是根据访问根结点的顺序来定的,下面内容将依次讲解

11.4.1 前序遍历

前序遍历顺序:先访问根节点,再访问前序遍历根结点的左子树,然后再访问根结点的右子树。
最全版基础数据结构精细讲解(期末必备)_第20张图片
比如上图,我们先访问根结点的A。然后再访问A结点左边的B,由于B也是一个结点,所以我们需要把B访问完。于是我们访问B左边的D,同理再访问G。B目前为止才算访问完毕。现在访问A右边的C,再访问C的左结点E,最后访问C的右结点F。
所以前序遍历(先序遍历)的顺序为ABDGCEF

11.4.2 中序遍历

中序遍历的顺序是,先访问根结点的左结点,再访问根结点,最后访问右结点。之所以叫中序遍历,是因为根结点的遍历放在中间。
还是刚刚那课二叉树
最全版基础数据结构精细讲解(期末必备)_第21张图片
根结点A。先遍历左边的B。由于中序遍历所有的结点都需要中序遍历所以,我们还不能访问B,而是要先访问B的左结点D。但是可以先遍历D么?不能,还是刚刚的原因,所以我们要先从G开始。
于是第一部分的顺序是: GDBA
目前位置,左边的结点以及根结点A都遍历完了,我们最后遍历右结点C。但是C可以直接访问么,不能。我们要先访问C左边的E,再访问C,然后访问F,最后回到A。
第二部分的顺序是:EFC
所以整个遍历结果是:GDBAECF
一定要牢记:中序遍历中,树的每一个结点都需要按照中序遍历的顺序去遍历

11.4.3 后序遍历

总结上述两个遍历的规律,我们发现,遍历的名字来历其实就是根结点的遍历顺序。所以后序遍历,由名字就可以知道,是最后遍历的。
所以遍历顺序是:根结点的左结点,根结点的右结点,根结点。注意,左右不可调换。
最全版基础数据结构精细讲解(期末必备)_第22张图片
遍历上面的树:
按照规律,我们需要先遍历根结点A的左结点,也就是B,但是无论是哪个结点都需要遵循遍历顺序,所以我要先访问B的左结点,也就是D,但是遍历B我们一样也要先访问G,G访问完后访问D,然后访问B。由于是后续遍历,根结点要放在最后访问,所以我们先从根结点的右结点C下手。按照后序遍历的顺序,先访问E,再访问F,然后哦访问C,最后访问A。
所以最后的遍历顺序是:G->D->B->E->F->C->A

11.4.4 相关代码

//二叉树的三种遍历
//先序遍历
void PreOrder(BiTreeNode *root,void visit(DataType item)){
	//前序遍历二叉树root,访问操作为visit
	if(root!=NULL){
		visit(root->data);
		PreOrder(root->leftChild,visit);
		PreOrder(root->rightChild,visit);
	}
} 
//中序遍历
void InOrder(BiTreeNode *root,void visit(DataType item)){
	if(root!=NULL){
		InOrder(root->leftChild,visit);
		visit(root->data);
		InOrder(root->rightChild,visit);
	}
} 
//后序遍历
void PostOrder(BiTreeNode *root,void visit(DataType item)){
	if(root!=NULL){
		PostOrder(root->leftChild,visit);
		PostOrder(root->rightChild,visit);
		visit(root->data);
	}
} 

11.5 哈夫曼树

11.5.1 二叉树中的基本概念

路径:在一棵二叉树中,定义从A到B结点所经过的分支序列为从A结点到B结点所经过的分支序列为从A结点到B结点的路径
路径长度:A结点到B结点所经过的分支个数为从A到B结点的路径长度
二叉树的带权路径长度:如果每个二叉树中叶结点都带权值,可以把路径长度的定义进行推广。设二叉树有n个带权值的叶结点,定义从二叉树的根结点到二叉树中所有叶结点的路径长度与相应叶结点权值的成绩之和为该二叉树的带权路径长度
最全版基础数据结构精细讲解(期末必备)_第23张图片
就比如上图中的a。我们把一段路径的长度设为1,则a树的带权路径长度为
12+32+52+72
以此类推,b中的树的带权路径长度为
12+33+53+71

什么是哈夫曼树
对于一组具有确定权值的叶结点,可以构造处多个不同带权路径长度的二叉树,我们把其中具有最小带权路径长度的二叉树称作哈夫曼树。

11.5.2 哈夫曼树的绘制

下面将以一道例题来讲解哈夫曼树的绘制
最全版基础数据结构精细讲解(期末必备)_第24张图片
22题
第一步:我们先把哈夫曼树的字母以及对应的权值写下来
最全版基础数据结构精细讲解(期末必备)_第25张图片
第二步:从小到大进行排序
最全版基础数据结构精细讲解(期末必备)_第26张图片
第三步:从下往上画,从小往大画,小数在左边,大数在右边
最全版基础数据结构精细讲解(期末必备)_第27张图片
第四步:哈希慢数是权值路径加起来最小的,所以要选择较小的那个写在上面。主要按照上面排序的顺序来
最全版基础数据结构精细讲解(期末必备)_第28张图片
第五步:按照刚刚的步骤来,我们应该在37的左边写上28,当时如果写了28

可是我们需要的是路径权值最小的值,每步都需要最小,而28+37=66<28+35=63,所以这么写是错误的
最全版基础数据结构精细讲解(期末必备)_第29张图片
正确写法是
最全版基础数据结构精细讲解(期末必备)_第30张图片
每个字符的哈夫曼编码,首先,根结点不用编码,因为不再字母范畴之内,排在左边的都是1,排在右边的都是0,具体见下图
最全版基础数据结构精细讲解(期末必备)_第31张图片
A对应的是17,从上往下读,A的编码是00
B对应的是12,B的编码是011
以此类推
C 0101
D 10
E 11
F 0100

十二,图

12.1 图的概述

相关术语:
图: 图是由定点集合及定点间的关系集合组成的一种数据结构。

顶点和边:图的结点一般称作顶点,图中第i个顶点称作Vi,若两个顶点vi和vj相关联,则称顶点vi和vj之间有一条边,图中的第k条边记作ek。一般情况下,有向边,无向边(x,y)

有向图和无向图:顶点对有序,顶点对称为从顶点x到顶点y的一条有向边,因此是两条不同的边。而顶点对(x,y)是无需的,称为x和y相关联的一条边,(x,y)和(y,x)是同一条边,无向边(x,y)等于有向边和有向边

完全图:任意两个顶点之间有且 只有一条边,为无向完全图,若任意两个顶点之间有且只有方向相反的两条边,则成为有向完全图

邻接顶点: 一条边(u,v),若(u,v)是E(G)中的一条边,则称u和v互为邻接顶点,并成边(u,v)依附于顶点u和v

顶点的度:顶点v的度是指与它相关联边的条数。对于有向图:顶点的度等于该顶点入度和出度之和。对于无向图,顶点的度等于该顶点的入度或出度。

路径:一个顶点到另一个顶点的路:比如顶点0->顶点1->顶点3

权值: 有些图的边携带数据信息,这些携带的数据信息称为权值。

路径长度: 不带权的图:一条路径的路径长度是指该路径上边的条数,带权的图一条路径的长度时该路径各个边权值之和。

连通图和强连通图: 无向图中如果vi和vj有路径,则称顶点vi和顶点vj联通,如果任意一对联通则称该图示连通图。在邮箱途中,如果任意一对顶点vi和vj都存在路径,则称为强连通图

生成树:一个连通图最小联通子图称作该图的生成树,n个顶点的连通图生成树有n个顶点和n-1条边

简单路径和回路:若路径上各个顶点v1,v2…vm互不重复,则称为简单路径,如果有顶点重合则为回路或者环

12.2 图的存储结构

12.2.1 图的邻接矩阵存储结构(稠密)

矩阵中的元素只有0和1,0代表两个顶点之间没有边,1代表两个顶点之间有边。
先看无向图:
最全版基础数据结构精细讲解(期末必备)_第32张图片
上图是无向图a的邻接矩阵,其中v表示图的顶点集合矩阵,可以看到,由于是无向图,无向图的一条路径等于有向图的两条路径,如果顶点1和2之间有路径,则在矩阵中对应的(1,2)和(2,1)都是1。同样,顶点4和5之间没有路径,则就(4,5)(5,4)都用0表示。
无向图一定是对称矩阵


再看有向图:
最全版基础数据结构精细讲解(期末必备)_第33张图片
A和B之间,A是路径尾部,箭头从A发出,所以(A,B)之间是1,而(B,A)之间是0


带权图:
最全版基础数据结构精细讲解(期末必备)_第34张图片
与上面类似,但是矩阵里的是权值而不是1和0,上图是一个无向图,所以依旧对称。

12.2.2 图的邻接链表存储结构(稀疏)

图的邻接矩阵特点:把图的边信息存储在一个nn的阶矩阵中,n为图中顶点个数,而nn这个稠密矩阵,是图的高效存储结构。但是当图的边数很少,顶点数值较大,n*n存储问题编程了稀疏矩阵的存出问题,邻接表则更适用。
最全版基础数据结构精细讲解(期末必备)_第35张图片
a是一个有向图,右边是邻接表。
b中数组data域存储的是图的顶点信息,source域存储的是顶点在数组中的下标,这个下标也是所有以该顶点为弧尾的边在shuzude下标,adj域存储该顶点的邻接顶点单链表的头指针。比如A的邻接顶点有E,E的source域为4,所以存储的是4,而B没有一次作为弧尾,所以它没有指向别人,source处为空。

而第i行中的dest域存储所有其实顶点为vi的邻接顶点vj在数组中的下标,next与存储单链表中下一个邻接顶点的指针比如A结点,dest装了4后下一个为什么不是与4连着的A或者C而是D?因为指的是A的指针,而不是连着的E,了就是这个道理,请大家着重注意标注的那一句话,好好体会。

12.3 图的遍历

图的遍历与树的操作类同,图的遍历操作定义:访问图中每个结点且每个顶点只被访问一次,图的遍历方法主要有两种:深度优先,广度优先。深度优先类似于树的先序遍历,图的广度优先类似于树的层序遍历
图的遍历算法设计要考虑以下三个问题:
1.图的特点是没有首位之分,所以算法的参数要指定访问第一个顶点
2.因为对图的遍历路径有可能构成一个回路,从而造成死循环,所以算法设计要考虑遍历都被访问到
3.一个顶点可能和若干个顶点都是邻接顶点,要使一个顶点的所有邻接顶点按照某种次序都被访问到

12.3.2 图的深度优先遍历

图的深度遍历算法是遍历时深度优先的算法,即在图中的所有邻接顶点中,每次都在访问完当前顶点后,首先访问当前顶点的第一个邻接顶点。

对于连通图,从初始顶点出发一定存在路径和连通图中所有其他顶点相连,所以对于连通图来说,从初始顶点触发一定可以遍历该图,连通图的深度优先遍历递归算法如下:
1.访问节点v并标记顶点v为访问
2.查找顶点v的第一个邻接顶点w
3.若顶点v的邻接顶点w存在,则继续执行,否则算法结束
4.若顶点w未被访问,则深度优先遍历递归访问w
查找顶点v的w临界几点下一个邻接顶点w,转到步骤3
最全版基础数据结构精细讲解(期末必备)_第36张图片
上面这个图:
我们先从顶点v1开始遍历,v1遍历完了发现v2没有遍历,所以遍历v2,v2遍历完了发现v4没有遍历,遍历v4,v4遍历完了发现v8没有遍历,遍历v8,v8遍历完了发现v5没有遍历,遍历v5,v5遍历完了发现v5周围接借点都遍历完了,所以返回v8,以此类推v8返回到v4,v4返回到v2,v2返回到v1…

最后遍历的顺序是:
v1->v2->v4->v8->v5->v3->v5->v7

12.3.3 图的广度优先遍历

图的挂你过度优先遍历算法是一个分层搜索的过程,广度优先遍历是指,从指定顶点开始,按照该顶点路径长度由短到长的顺序,依次访问图中的其余顶点。
图的广度优先遍历也需要一个队列来保存访问过的顶点的顺序,以便按照访问这些顶点的邻接顶点,连通图的广度优先遍历算法如下:
1.访问初始顶点v并标记顶点v为已访问
2.顶点v入队列
3.若队列非空则继续执行,否则算法结束
4.出队列去的头顶点u
5.查找顶点u的第一个邻接顶点w
6.若顶点u的邻接顶点w干不存在,则转到步骤3,否则循环执行
最全版基础数据结构精细讲解(期末必备)_第37张图片
层序遍历:就是按照层来
v1->v2->v3->v4->v5->v6->v7->v8从上往下依次遍历

12.4 最小生成树

一个有n个顶点的连通图的生成树是原图的极小连通子图,包含原图中的所有n个顶点,并且具有保持图连通的最少的边。
推论:
1.若删除生成树中的一条边,就会使该生成树因为编程非连通图而不满足定义
2.增加一条边会存在回路而不满足生成树的定义
3.连通图的生成树可能有很多,使用不同方法可以得到不同生成树
最全版基础数据结构精细讲解(期末必备)_第38张图片
丛生成树的定义,有n个顶点的无向连通图,无论生成树的形状如何,一定有n个结点,有且只有n-1条边。
如果无向连通图是一个带权图,所有生成树中必定有一棵生成树,边的权值总和最小,我们称这棵树为最小代价生成树,简称最小生成树。
最小生成树要满足三个条件:
1.构造的最小生成树必须包括n个 节点
2.构造最小生成树中有且只有n-1条边
3.构造的最小生成树中不存在回路
本节最小生成数将讲解普里姆算法,克鲁斯卡算法。

12.4.1 普里姆算法

先看普里姆算法构成最小生成树的过程
最全版基础数据结构精细讲解(期末必备)_第39张图片

a图为原始图,边为带权值的边。我们的任务是把它变成一个权值最小的连通子图。
既然是图,则每一个顶点都要包含,而且,最基本的连通需要满足。

b是初始状态,我们从A下手(任意边都可以),连起来的第一条边是最小的50,然后再接着连B,连起来的是最小的40,接着到了E,连起来的是最小的50,然后是D,连着的是最小的F和较小的G,最后是C

每一回都是在保证图连通的情况下连出权值最小以及数目最少的边。

12.4.2 克鲁斯卡尔算法

克鲁斯卡尔算法是一种按照带权图中边的权值的递增顺序构造最小生成树的方法
最全版基础数据结构精细讲解(期末必备)_第40张图片
还是刚刚的图,现在利用克鲁斯卡尔算法来达成目的
最全版基础数据结构精细讲解(期末必备)_第41张图片
观察图a到图f我们可以发现,我们并没有关注从哪个点开始连接,而是从小到大的边开始连,直到把整个图变成连通图

你可能感兴趣的:(数据结构,c语言的学习,数据结构,链表,c++)