数据结构和算法——学习笔记(C语言版)

数据结构和算法——学习笔记

第1章 数据结构绪论

1.1基本概念和术语

1、数据:是描述客观事物的符号,是计算机中可以操作的对象,是能被计算机识别,并输入给计算机的符号集合。

2、数据元素:是组成数据的、有一定意义的基本单位,在计算机中通常作为整体处理。也被称为记录。

3、数据项:一个数据元素可以由若干个数据项组成。数据项是数据不可分割的最小单位。

4、数据对象:是性质相同的数据元素的集合,是数据的子集。

5、数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。

1.2逻辑结构与物理结构

1、逻辑结构:是指数据对象中数据元素之间的相互关系。

(1)集合结构:集合结构中的元素除了同属于同一个集合外,它们之间没有其他关系。

(2)线性结构:线性结构中的数据元素之间是一对一的关系。

(3)树形结构:树形结构是数据元素之间存在一种一对多的层次关系。

(4)图形结构:图形结构的数据元素是多对多的关系。

2、物理结构:是指数据的逻辑结构在计算机中的存储形式。

(1)顺序存储结构;;是把数据元素存放在地址连续的存储单元中,其数据间的逻辑关系和物理关系是一致的。

(2)链式存储结构:是把数据元素存放在任意的存储单元里,这组存储单元可以是连续的,也可以是不连续的。

1.3抽象数据类型

1、数据类型:是指一组性质相同的值得集合及定义在此集合上的一些操作的总称。

2、在C语言中,按照取值的不同,数据类型可以分为两类:

(1)原子类型:是不可以再分解的基本类型,包括整型、实型、字符型等。

(2)结构类型:由若干类型组合而成,是可以再分解的。例如,整型数组是由若干整型数据组成的。

3、抽象是指抽取出事物的普遍性的本质。

4、抽象数据类型(ADT):是指一个数据模型及定义在该模型上的一组操作。

5、“抽象”的意义在于数据类型的属性抽象特性。抽象数据类型体现了数据程序设计中问题分解、抽象和信息隐藏的特性。

6、描述抽象数据类型的标准格式:

ADT 抽象数据类型名
Data
	数据元素之间的逻辑关系的定义
Operation
	操作1
		初始条件
		操作结果描述
	操作2
		……
	操作n
		……
endADT

第2章 算法

2.1算法定义

1、算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。

2.2算法的特性

1、输入输出:算法具有零个或多个输入,算法至少有一个或多个输出。

2、有穷性:指算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每个步骤在可接受的时间内完成。

3、确定性:算法的每一步骤都具有确定的意义,不会出现二义性。

4、可行性:算法每一步骤都必须是可行的,也就是说,每一步都能够通过执行有限次数完成。

2.3算法设计的要求

1、正确性:算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反映问题的需求、能够得到问题的正确答案。

2、可读性:算法的设计的另一目的是为了便于阅读,理解和交流。

3、健壮性:但数据数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结构。

4、时间效率高和存储量低:设计算法应尽量满足时间效率高和存储量的需求。

2.4算法效率的度量方法

1、事后统计方法

2、事前分析估算方法

2.5算的时间复杂度定义

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

2、大O记法:用大写O()来体现算法的时间复杂度的记法。

3、推导大O阶方法:

(1)用常数1取代运行时间中额所有加法常数。

(2)在修改后的运行次数函数中,只保留最高阶项。

(3)如果最高阶项存在且不是1,则去除与这个项相乘的常数。

2.6常见的时间复杂度

执行次数函数 非正式术语
12 O(1) 常数阶
2n+3 O(n) 线性阶
3n2+2n+1 O(n2) 平方阶
5log2n+20 O(logn) 对数阶
2n+3nlog2n O(nlogn) nlogn阶
6n3+2n2+3n+4 O(n3) 立方阶
2n O(2n) 指数阶

常见的时间复杂度所耗费时间从小到大依次是:

O(1)2)3)n)n)

2.7最坏情况与平均情况

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

2、平均运行时间是所有情况中最有意义的,,因为它是期望的运行时间。

3、一般在没有特殊说明的情况下,都是最坏时间复杂度。

2.8算法空间复杂度

1、算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作:S(n)=O(f(n)),其中,n为问题的对魔,f(n)为语句关于n所占存储空间的函数。

2、通常,我们都是使用“时间复杂度”来指运行时间的需求,使用“空间复杂度”指空间需求。当不用限定地使用“复杂度”时,通常是指时间复杂度。

第3章 线性表

3.1线性表的定义

1、线性表(List):零个或多个数据元素的有限序列。

2、数学语言定义:若线性表记为(a1,……,ai-1,ai,ai+1,……,an),则表中ai-1领先于ai,ai领先于ai+1,称ai-1是ai的直接前驱元素,ai+1是ai的直接后继元素。当i=1,2,3,……,n-1是ai有且仅有一个直接后继,当i=2,3,……,n是,ai有且仅有一个直接前驱。

3、空表:线性表元素的个数n(n>=0)定义为线性表的长度,当0时,成为空表

3.2线性表的抽象数据类型

线性表的抽象数据类型定义如下:

ADT 线性表(List)
Data
	线性表是数据对象集合为{
     a1,a2,……,an},每个元素的类型均为DataType。其中,除第一个元素a1外,每一个元素有且只有一个直接前驱元素,除了最后一个元素an外,每一个元素有且只有一个直接后继元素。数据元素之间的关系是一对一的关系。
Operation
	InitList(*L);	初始化操作,建立一个空的线性表LListEmpty(L);	若线性表为空,返回true,否则返回falseClearList(*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的元素个数。
endADT

实现两个线性表集合A和B的操作。

/*将所有的在线性表Lb中但不在La中的数据元素插入到La中*/
void union(List *La,List *Lb)
{
     
	int La_len,Lb_len,i;
	ElemType e;	/*声明与La和Lb相同的数据元素e*/
	La_len = ListLength(La);	/*求线性表的长度*/
	Lb_len = ListLength(Lb);
	for(i=1; i<Lb_len; i++)
	{
     
		GetElem(Lb, i, e);	/*取出Lb中第i个数据元素赋值给e*/
		if(!LoacteElem(La,e)) /*La中不存在和e相同数据元素*/
			ListInsert(La, ++La_len, e);	/*插入*/
	}
}

3.3线性表的顺序存储结构

1、顺序存储定义:线性表的顺序存储结构,指的是用一段连续的存储单元依次存储线性表的数据元素。

2、顺序存储方式:可以用C语言的一维数组来实现顺序存储结构。

线性表的顺序存储结构代码。

	#define MAXSIZE 20	/*存储空间初始分配量*/
	typedef ine ElemType;	/*ElemType类型根据实际情况而定,这里假设为int*/
	typedef struct
	{
     
		ElemType data[MAXSIZE];	/*数组存储数据元素,最大值为MAXSIZE*/
		int length;	/*线性表当前长度*/
	}SqList;

3、数组长度与线性表长度的区别

(1)数组的长度是存放线性表的存储空间的长度,存储分配后这个量是一般不变的。

(2)线性表的长度是线性表中的元素个数,随着线性表插入和删除操作的进行,这个量是变化的。

(3)在任何时刻,线性表的长度应该小于等于数组的长度。

4、地址计算方法

(1)线性表的数是从1开始数的,可C语言中的数组确实从0开始第一个下标的,于是线性表的第i个元素是要存储在数组下标为-1的位置。

(2)存储器中每个存储单元都有自己的编号,这个编号称为地址。

(3)假设每个数据元素占用的是c个存储单元,那么线性表中的第i+1个数据元素的存储位置和第i个数据的存储位置满足下列关系

	LOC(a(i+1))=LOC(ai)+c

所以对于第i个数据元素ai的存储位置可以有a1推算得出:

	LOC(ai)=LOC(ai)+(i-1)*c

5、存取结构:分为随机存取和非随机存取(又称顺序存取)

(1)随机存取就是直接存取,可以通过下标直接访问的那种数据结构与存储结构位置无关,例如数组。

(2)非随机存取就是顺序存取,不能通过下标访问,只能按照存储顺序存取,与存储位置有关,例如链表。

(3)顺序存取就是存取第N个数据时,必须先访问前(N-1)个数据 (list),随机存取就是存取第N个数据时,不需要访问前(N-1)个数据,直接就可以对第N个数据操作 (array)。

(4)顺序表是顺序存储,随机存取的结构; 链表是随机存储,顺序存取的结构; 注意储存和存取的区别。

3.4顺序存储结构的插入和删除

1、获得元素操作

	#define OK 1
	#define ERROR 0
	#define TRUE 1
	#define FLASE 0
	typedef int Status;
	/*Status是函数的类型,其值是函数结果状态代码,如OK等*/
	/*初始条件:顺序线性表已存在,1<=i<=ListLength(L)*/
	/*操作条件:用e返回L中第i个数据元素的值*/
	Status GetElem(SqList L,int i, ElemType *e)
	{
     
		if(L.length==0 || i<1 || i>L.length)
			return ERROR;
		*e=L.data[i-1];
		return OK;
	}

2、插入操作

插入算法的思路:

(1)如果插入位置不合理,抛出异常;

(2)如果线性表长度大于数组长度,则抛出异常或动态增加容量;

(3)从最后一个元素开始向前遍历到第i个位置,分别将它们都向后移动一个位置;

(4)将要插入的元素填入位置i处;

(5)表长加1.

实现代码如下:

/*初始条件:顺序线性表L已存在,1<=i<=ListLength(L)*/
/*操作结果:在L中第i个位置之前插入新的数据元素e,L的长度加1*/
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)	/*若插入数据位置不在表尾*/
	{
     
		for(k=L->length-1;k>=i-1;k--) /*将要插入位置后的数据元素向后移动一位*/
			L->data[k+1]=L->data[k];
	}
	L->data[i-1]=e; /*将新元素插入*/
	L->length++;
	return OK;
}

3、删除操作

删除算法的思路:

(1)如果删除位置不合理,抛出异常;

(2)取出删除元素;

(3)从删除元素位置开始遍历到最后一个元素位置,分别将它们都向前移动一个位置;

(4)表长减1。

实现代码如下:

/*初始条件:顺序线性表L已存在,1<=i<=ListLength(L)*/
/*操作结果:删除L中第i个位置的数据元素,并用e返回其值,L的长度减1*/
Status ListDelete(Sqlist *L,int i,ElemType *e)
{
     
	int k;
	if(L->length==0) /*顺序线性表为空*/
		return ERROR;
	if(i<1 || i>L->length)	/*删除位置不正确*/
		return ERROR;
	*e=L->data[i-1];
	if(i<L->length)	/*如果删除不是最后位置*/
	{
     
		for(k=i;k<L->length;k++) /*将删除位置后继元素前移*/
			L->data[k-1]=L->data[k];
	}
	L->length--;
	return OK;
}

4、线性表顺序存储结构的优缺点

优点:

(1)无需为表中元素之间的逻辑关系而增加额外的存储空间;

(2)可以快速地存取表中任一位置的元素。

缺点:

(1)插入和删除操作需要移动大量元素;

(2)当线性表长度变化较大时,难以确定存储空间的容量;

(3)造成存储空间的“碎片”。

3.5线性表的链式存储结构

1、数据域,指针域,结点

为了表示每个数据元素ai与其直接连接后继数据元素ai+1之间的逻辑关系,对数据元素ai来说,除了存储其本身的信息之外,还需存储一个指示其直接后继的信息(即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称作指针或链。这两部分信息组成数据元素ai的存储映像,称为结点(Node)。

2、单链表

n个结点(ai的存储映像)链结成一个链表,即为线性表(a1,a2,……,an)的链式存储结构,因此此链表的每个结点中只包含一个指针域,所以叫单链表。线性链表的最后一个结点指针为“空”(通常用NULL或“^”符号表示)。

3、头指针

链表中第一个结点的存储位置叫做头指针。

4、头结点

单链表的第一个结点前附设一个结点,称为头结点。头结点可以不存储任何信息,也可以存储如线性表长度等附加信息,头结点的指针域存储指向第一个结点的指针。

数据结构和算法——学习笔记(C语言版)_第1张图片
5、头指针与头结点的异同

头指针

(1)头指针是指指向第一个结点的指针,若链表有头结点,则是指向头结点的指针。

(2)头指针具有表示作用,所有常用头指针冠以链表的名字。

(3)无论链表是否为空,头指针均不为空。头指针是链表的必要元素。

头结点

(1)头结点是为了操作的统一和方便而设立的,放在第一元素的结点之前,其数据域一般无意义(也可存放链表的长度)。

(2)有了头结点,对在第一元素结点前插入结点和删除第一结点,其操作与其它结点的操作就统一了。

(3)头结点不一定是链表必须要素。

6、若线性表为空表,则头结点的指针域为“空”

数据结构和算法——学习笔记(C语言版)_第2张图片

存储示意图表示单链表

数据结构和算法——学习笔记(C语言版)_第3张图片

若带有头结点的单链表

数据结构和算法——学习笔记(C语言版)_第4张图片

空链表

数据结构和算法——学习笔记(C语言版)_第5张图片

7、单链表中,C语言中可用结构指针来描述

/*线性表的单链表存储结构*/
typedef struct Node
{
     
	ElemType data;
	struct Node *next;
}Node;
typedef struct Node *LinkList; /*定义LinkList*/

结点由存放数据元素的数据域存放后继结点地址的指针域组成。

数据结构和算法——学习笔记(C语言版)_第6张图片

3.6单链表的读取

单链表实现获取第i个元素的数据的操作GetElem,单链表的结构中没有定义表长,不能事先知道要循环多少次,其主要核心思想是“工作指针后移”。
获得链表第i个数据的算法思路:

1、声明一个结点p指向链表的第一个结点,初始化j从1开始;

2、当j

3、若到链表末尾p为空,则说明第i个元素不存在;

4、否则查找成功,返回结点p的数据。

实现代码算法如下:

/*初始条件:顺序线性表L已存在*,1<=i<=ListLength(L)*/
/*操作结果:用e返回L中第i个数据元素的值*/
Status GetElem(LinkList L,int i,ElemType *e)
{
     
	int j;
	LinkList p;	/*声明一结点p*/
	p=L->next;	/*让p指向链表L的第一个结点*/
	j=1;	/*j为计数器*/
	while(p&&j<i)	/*p不为空或者计数器j还没有等于i时,循环继续*/
	{
     
		p=p->next;	/*让p指向下一个结点*/
		++j;
	}
	if(!p||j>i)
		return ERROR;	/*第i个元素不存在*/
	*e=p->data;	/*取第i个元素的数据*/
	return OK;
}

3.7单链表的插入和删除

1、单链表的插入

单链表第i个数据插入结点的算法思路:

(1)声明一结点p指向链表的第一个结点,初始化j从1开始;

(2)当j

(3)若到链表末尾p为空,则说明第i个元素不存在;

(4)否则查找成功,在系统中生成一个空结点s;

(5)将数据元素e赋值给s->data;

(6)单链表的插入标准语句s->next=p->next, p->next=s;

(7)返回成功。

实现代码算法如下:

/*初始条件:顺序线性表L已存在,1<=i<=ListLength(L)*/
/*操作结果:在L中第i个位置之前插入新的数据元素e,长度加1*/
Status ListInsert(LinkList *L,int i,ElemType e)
{
     
	int j;
	LinkList p,s;
	p=*L;
	j=1;
	while(p&&j<i)	/*寻找第i个结点*/
	{
     
		p=p->next;
		++j;
	}
	if(!p||j>i)
		return ERROR;	/*第i个元素不存在*/
	s=(LinkList)malloc(sizeof(Node));	/*生成新结点*/
	s->data=e;
	s->next=p->next;	/*将p的后继结点赋值给s的后继*/
	p->next=s;	/*将s赋值给p的后继*/
	return OK;
}

2、单链表的删除

单链表第i个数据删除结点的算法思路:

(1)声明一结点p指向链表第一个结点,初始化j从1开始;

(2)当j

(3)若到链表末尾p为空,则说明第i个元素不存在;

(4)否则查找成功,将欲删除的结点p->next赋值给q;

(5)单链表的删除标准语句p->next=q->next;

(6)将q结点中的数据赋值给e,作为返回;

(7)释放q结点;

(8)返回成功。

实现代码算法如下:

/*初始条件:顺序线性表L已存在,1<=i<=ListLength(L)*/
/*操作结果:删除L的第i个数据元素,并用e返回其值,L的长度减1*/
Status ListDelete(LinkList *L,int i,ElemType *e)
{
     
	int j;
	LinkList p,q;
	p=*L;
	j=1;
	while(p->next&&j<i)	/*遍历寻找第i个元素*/
	{
     
		p=p->next;
		++j;
	}
	if(!(p->next)||j>i)
		return ERROR;	/*第i个元素不存在*/
	q=p->next;	
	p->next=q->next;	/*将q的后继赋值给p的后继*/
	*e=q->data;	/*将q结点中的数据给e*/
	free(q);	/*让系统回收此结点,释放内存*/
	return OK;
}

3.8单链表的整表创建

单链表整表创建的算法思路:

1、声明一结点p和计数器变量i;

2、初始化一空链表L;

3、让L的头结点的指针指向NULL,即建立一个带头结点的单链表;

4、循环:

(1)生成一新结点赋值给p;

(2)随机生成一数字赋值给p的数据域p->data;

(3)将p插入到头结点与前一新结点之间。

头插法:

数据结构和算法——学习笔记(C语言版)_第7张图片

/*随机产生n个元素的值,建立带有头结点的单链线性表L(头插法)*/
void CreateListHead(LinkList *L,int n)
{
     
	LinkList p;
	int i;
	srand(time(0)); /*初始化随机数种子*/
	*L=(LinkList)malloc(sizeof(Node));
	(*L)->next=NULL; /*先建立一个带头结点的单链表*/
	for(i=0;i<n;i++)
	{
     
		p=(LinkList)malloc(sizeof(Node));	/*生成新结点*/
		p->data=rand()%100+1;
		p->next=(*L)->next;
		(*L)->next=p;	/*插入到表头*/
	}
}

尾插法:

数据结构和算法——学习笔记(C语言版)_第8张图片

数据结构和算法——学习笔记(C语言版)_第9张图片

/*随机产生n个元素的值,建立带表头结点的单链线性表L(尾插法)*/
void CreateListTail(LinkList *L,int n)
{
     
	LinkList p,r;
	int i;
	srand(time((0));
	*L=(LinkList)malloc(sizeof(Node));
	r=*L;	/*r为指向尾部的结点*/
	for(i=0;i<n;i++)
	{
     
		p=(Node *)malloc(sizeof(Node));	/*生成新结点*/
		p->data=rand()%100+1;	/*随机生成100以内的数字*/
		r->next=p;	/*将表尾终端结点的指针指向新结点*/
		r=p;	/*将当前的新结点定义为表尾终端结点*/
	}
	r->next=NULL;	/*表示当前链表结束*/
}

3.9单链表的整表删除

单链表整表删除的算法思路如下:

1、声明一结点p和q;

2、将第一个结点赋值给p;

3、循环:

(1)将下一个结点赋值给q;

(2)释放p;

(3)将q赋值给p。

实现代码算法如下:

Status ClearList(LinkList *L)
{
     
	LinkList p,q;
	p=(*L)->next;	/*p指向第一个结点*/
	while(p)	/*没到表尾*/
	{
     
		q=p->next;
		free(p);
		p=q;
	}
	(*L)->next=NULL; /*头结点指针域为空*/
	return OK;
}

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

1、若线性表需要频繁查找,很少进行插入操作时,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。

2、当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不用考虑存储空间问题。而如果事先知道线性表的大致长度,这种用户顺序存储结构效率会高很多。

3.11静态链表

1、静态链表(游标实现法),用数组来代替指针描述的链表叫做静态链表。首先我们让数组的元素都是由两个数据域组成,data和cur。也就是说,数组的每一个下标都对应一个data和一个cur。数据域data,用来存放数据元素,也就是通常我们要处理的数据;而游标cur相当于单链表中的next指针,存放该元素的后继在数组中的下标。

/*线性表的静态链表存储结构*/
#define MAXSIZE 1000	/*假设链表的最大长度1000*/
typedef struct
{
     
	ElemType data;
	int cur;	/*游标,为0是表示无指向*/
}	Component,StaticLinkList(MAXSIZE);

2、对数组第一个和最后一个元素作为特殊元素处理,不存数据。通常把未使用的数组称为备用链表。而数组第一元素,即下标为0的元素的cur就存放备用链表中的第一个结点的下标;而数组最后一个元素的cur则存放第一个有数值的元素的下标,单链表中的头结点作用,当整个链表为空时,则为0。

数据结构和算法——学习笔记(C语言版)_第10张图片

此时图示相当于初始化的数组状态,见下面代码:

/*将一维数组space中各分量链成一备用链表*/
/*space[0].cur 为头指针,"0"表示空指针*/
Status InitList(StaticLinkList space)
{
     
	int i;
	for(i=0;i<MAXSIZE-1;i++)
		space[i].cur=i+1;
	space[MAXSIZE-1].cur=0;	/*目前静态链表为空,最后一个元素的cur为0*/
	return OK;
}

3、静态链表的插入操作

静态链表中药解决的是:如何用静态模拟动态链表结构的存储空间的分配,需要是时申请,无用时释放。

为了辨明数组中哪些分量未被使用,解决的办法是将所有未使用过的及已被删除的分量用游标链成一个备用的链表,每当进行插入时,便可以从备用链表上取得第一个结点为待插入的新结点。

/*若备用空间链表非空,则返回分配的结点下标,否则返回0*/
int Malloc_SLL(StaticLinkList space)
{
     
	int i=spacep[0].cur;	/*当前数据第一元素的cur存的值,就是要返回的第一个备用空闲的下标*/
	if(space[0].cur)
		space[0].cur=space[i].cur;	/*由于要拿出一个分量来使用了,所以我们就得把它的下一个分量用来做备用*/
	return i;
}
Status LinkInsert(StaticLinkList L,int i,ElemType e)
{
     
	int j,k,l;
	k=MAX_SIZE-1;	/*注意k首先是最后一个元素的下标*/
	if(i<1||i>ListLength(L)+1)
		return ERROR;
	j=Malloc_SSL(L);	/*获得空闲分量的下标*/
	if(j)
	{
     
		L[j].data=e;	/*将数据赋值给此分量的data*/
		for(l=1;l<i-1;l++)	/*找到第i个元素之前的位置*/
			k=L[k].cur;
		L[j].cur=L[k].cur	/*把第i个元素之前的cur赋值给新元素的cur*/
		L[k].cur=j;	/*把新元素的下标赋值给第i个元素之前元素的cur*/
		return OK;
		
	}
	return ERROR;
}

4、静态链表的删除操作

/*删除在L中第i个数据元素e*/
Status ListDelete(StaticLinkList L,int i)
{
     
	int j,k;
	if(i<1||i>ListLength(L))
		return ERROR;
	k=MAX_SIZE-1;
	for(j=1;j<=i-1;j++)
		k=L[k].cur;
	j=L[k].cur;
	L[k].cur=L[j].cur;
	Free_SSL(L,j);
	return OK;
}
/*将下标为k的空闲结点回收到备用链表*/
void Free_SSL(StaticLinkList spcace,int k)
{
     
	space[k].cur=space[0].cur;	/*把第一个元素cur值赋给要删除的分量cur*/
	space[0].cur=k;	/*把要删除的分量下标赋值给第一个元素的cur*/
}

5、静态链表的长度

/*初始条件:静态链表L已存在。操作结果:返回L中数据元素个数*/
int ListLength(StaticLinkList L)
{
     
	int j=0;
	int i=L(MAXSIZE-1).cur;
	while(i)
	{
     
		i=L[i].cur;
		j++;
	}
	return j;
}

3.12循环链表

1、将单链表中终端结点的指针端由空指针改为指向头指针,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表

2、循环链表带头结点的空链表

数据结构和算法——学习笔记(C语言版)_第11张图片

3、对于非空的循环链表

数据结构和算法——学习笔记(C语言版)_第12张图片

4、循环链表和单单链表的主要差异就在于循环的判断条件上,原来是判断p->next是否为空,现在则是p->next不等于头结点,则循环未结束。

5、将两个循环链表合并成一个表时,下面两个循环链表的尾指针分别是rearA和rearB。

数据结构和算法——学习笔记(C语言版)_第13张图片

数据结构和算法——学习笔记(C语言版)_第14张图片

p=rearA->next;	/*保存A表的头结点*/
rearA->next=rearB->next->next;	/*将本是指向B表的第一个结点(不是头结点,赋值给rearA->next)*/
rearB->next=p;	/*将原A表的头结点赋值给rearB->next*/
free(p);	/*释放p*/

3.12双向链表

1、双向链表是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。

/*线性表的双向链表存储结构*/
typedef struct DulNode
{
     
	ElemType data;
	struct DulNode *prior;	/*直接前驱指针*/
	struct DulNode *next;	/*直接后继指针*/
}DulNode,*DuLinkList;

第4章 栈与队列

4.1 栈的定义

1、栈是限定仅在表尾进行插入和删除操作的线性表。

2、允许插入和删除的一端称为栈顶,另一端称为栈底,不含任何数据元素的栈称为空栈。栈又称为后进先出(Last In First Out)的线性表,简称LIFO结构。

3、栈的插入操作,叫作进栈,也称压栈、入栈。

4、栈的删除操作,叫作出栈,也有的叫作弹栈。

数据结构和算法——学习笔记(C语言版)_第15张图片

4.2 栈的抽象数据类型

ADT (stack)
Data
	同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
	InitStack(*S):初始化操作,建立一个空栈SDestroyStack(*S):若栈存在,则销毁它。
	ClearStack(*S):将栈清空。
	StackEmpty(S):若栈为空,返回true,否则返回falseGetTop(*S,e):若栈存在且非空,用e返回S的栈顶元素。
	Push(*S,e):若栈S存在,插入新元素e到栈S中并成为栈顶元素。
	Pop(*S,*e):删除S中栈顶元素,并用e返回其值。
	StackLength(S):返回栈S的元素个数。
endADT

4.3 栈的顺序存储结构及实现

1、栈的顺序存储结构

typedef int SElemType;	
typedef struct
{
     
	SElemType data[MAXSIZE];
	int top;	/*用于栈顶指针*/
}SqlStack;

若现在有一个栈,StackSize是5,则栈普通情况、空栈和栈满的情况:

数据结构和算法——学习笔记(C语言版)_第16张图片

2、栈的顺序存储结构——进栈操作

数据结构和算法——学习笔记(C语言版)_第17张图片

/*插入元素e为新的栈顶元素*/
Status Push(SqStack *s, SElemType e)
{
     
	if(S->top == MAXSIZE-1) /*栈满*/
	{
     
		return ERROR;
	}
	S->top++;	/*栈顶指针加1*/
	S->data[S->top]=e;	/*将新插入元素赋值给栈顶元素*/
	return OK;
}

3、栈的顺序存储结构——出栈操作

/*若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK,否则返回ERROR*/
Status Pop(SqStack *S, SElemType *e)
{
     
	if(S->top == -1)
	{
     
		return ERROR;
	}
	*e=S->data[S->top];	/*将要删除的栈顶元素赋值给e*/
	S->top--;	/*栈顶指针加一*/
	return OK;
}

4.4 两栈共享空间

1、数组有两个端点,两个栈有两个栈底,让第一个栈的栈底为数组的始端,即下标为处,另一个栈为栈的末端,即下标数组长度n-1处。这样两个栈如果增加元素,就是两端点向中间延伸。

数据结构和算法——学习笔记(C语言版)_第18张图片

两栈共享空间的结构的代码:

/*两栈共享空间结构*/
typedef struct
{
     
	SElemType data[MAXSIZE];
	int top1; /*栈1栈顶指针*/
	int top2; /*栈2栈顶指针*/
}SqDoubleStack;

对于两栈共享空间的push方法,我们除了要插入元素值参数外,还需要判断是栈1还是栈2的栈号参数stackNumber。插入元素的代码如下:

/*插入元素e为新的栈顶元素*/
Status Push(SqDouble *S, SElemType e, int stackNumber)
{
     
	if(S->top1+1==S->top2)	/*栈已满,不能再push新元素了*/
		return ERRORif(StackNumber==1) /*栈1有元素进栈*/
		S->data[++S->top1]=e;	/*若栈1则先top1+1后给数组元素赋值*/
	else if (StackNumber==2)	/*栈2有元素进栈*/
		S->data[--S->top2]=e;	/*若栈2则先top2-1后给数组元素赋值*/
	return OK;
}

对于;两栈共享空间的pop方法,参数就只判断栈1栈2的参数stackNumber,代码如下:

/*若栈不空,则删除S的栈顶元素,用e返回其值,并返回OK,否则返回ERROR*/
Status Pop (SqDoubleStack *S, SElemType *e, int stackNumber)
{
     
	if(stackNumber==1)
	{
     
		if(S->top1==-1)
			return ERROR; /*说明栈1已经是空栈,溢出*/
		*e=S->data[S->top1--];	/*将栈1的栈顶元素出栈*/
			
	}
	else if (stackNumber==2)
	{
     
		if(S->top2==MAXSIZE)
			return ERROR; /*说明栈2已经是空栈,溢出*/
		*e=S->data[S->top2++];	/*将栈2的栈顶元素出栈*/
	}
	return OK;
}

4.5 栈的链式存储结构及实现

1、栈的链式存储结构,简称为链栈。

对于空栈来说,链表原定义是头指针指向空,那么链栈的空其实就是top=NULL的时候。

链栈的结构代码如下:

typedef struct StackNode
{
     
	SElemType data;
	struct StackNode *next;
}StackNode, *LinkStackPtr;

typedef struct LinkStack
{
     
	LinkStackPtr top;
	int count;
}LinkStack;

2、栈的链式存储结构——进栈操作

对于链栈的进栈push操作,假设元素值为e的新结点是s,top为栈顶指针。

数据结构和算法——学习笔记(C语言版)_第19张图片

/*插入元素e为新的栈顶元素*/
Status Push(LinkStack *S, SElemType e)
{
     
	LinkStackPtr s = (LinkStackPtr)malloc(sizeof(StackNode));
	s->data=e;
	s->next=S->top;
	S->top=s;
	S->count++;
	return OK;
} 

3、栈的链式存储结构——出栈操作

至于链栈的出栈pop操作,也是简单的三句操作。假设变量p用来存储要删除的栈顶结点,将栈顶指针下移一位,最后释放p即可。

数据结构和算法——学习笔记(C语言版)_第20张图片

/*若栈不空,则删除S的栈顶元素,用e返回其值*/
Status Pop(LinkStack *S, SElemType *e)
{
     
	LinkStackPtr p;
	if(StackEmpty(*S))
		return ERROR;
	*e=S->top->data;
	p=S->top;	/*将栈顶元素赋值给p*/
	S->top=S->top->next;	/*使得栈顶指针下移一位,指向后一结点*/
	free(p);	/*释放结点p*/
	S->count--;
	return OK;
}

4、如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,它的变化在可控范围内,建议使用顺序栈会更好一些。

4.6 栈的应用——递归

1、斐波那契数列实现

打印出前40位斐波那契数列数

(1)常规的迭代实现

int main()
{
     
	int i;
	int a[40];
	a[0]=0;
	a[1]=1;
	printf("%d ",a[0]);
	printf("%d ",a[1]);
	for(i=2;i<40;i++)
	{
     
		a[i] = a[i-1] + a[i-2];
		printf("%d ",a[i]);
	}
	return 0;
}

(2)递归实现

/*斐波那契的递归函数*/
int Fbi(int i)
{
     
	if(i<2)
		return i == 0 ? 0 : 1;
	return Fbi(i-1) + Fbi(i-2);
}
int main()
{
     
	int i;
	for(i=0; i<40; i++)
	{
     
		printf("%d ", Fbi(i));
	}
	return 0;
}

2、递归定义

(1)把一个直接调用自己或通过一系列的调用语句间接地调用自己的函数,称作递归函数。

(2)每个递归定义必须至少有一个条件,满足时递归不再进行,即不再引用自身而是返回值退出。

(3)递归和迭代的区别是:迭代使用的是循环结构,递归使用的是选择结构。

4.7 栈的应用——四则运算表达式求值

1、后缀(逆波兰)表示法定义

一种不需要括号的后缀表达式,对于“9+(3-1)×3+10÷2”,用后缀表示法应该是“9 3 1 - 3 * + 10 2 / +”,这样的表达式称为后缀表达式,叫后缀的原因在于所有的符号都是在要运算数字的后面出现。

2、后缀表达式的计算结果

规则:从左到右遍历表达式的每个数字和符号,遇到的是数字就进栈,遇到的是符号,就将处于栈顶的两个数字出栈,进行运算,运行结果进栈,一直到最终获得结果。

3、中缀表达式转后缀表达式

(1)标准四则运算表达式叫做中缀表达式。

(2)中缀表达式“9+(3-1)×3+10÷2”转化为后缀表达式“9 3 1 - 3 * + 10 2 / +”。

(3)规则:从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级低于栈顶符号(乘除优先加减)则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。

4.8 队列的定义和抽象数据类型

1、队列是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。

2、队列是一种先进先出(First In First Out)的线性表,简称FIFO。允许插入的一端称为队尾,允许删除的一端称为队头。

数据结构和算法——学习笔记(C语言版)_第21张图片

3、队列的抽象数据类型

同样是线性表,队列也有类似线性表的各种操作,不同的就是插入数据只能在队尾进行,删除数据只能在队头进行。

ADT 队列(Queue)
Data
	同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
	InitQueue(*Q):初始化操作,建立一个空队列QDestroyQueue(*Q):若队列Q存在,则销毁它。
	ClearQueue(*Q):将队列Q清空。
	QueueEmpty(Q):若队列Q为空,则返回true,否则返回falseGetHead(Q,*e):若队列Q存在且非空,用e返回队列Q的队头元素。
	EnQueue(*Q,e):若队列Q存在,插入新元素e到队列Q中并成为队尾元素。
	DeQueue(*Q,*e):删除队列Q中队头元素,并用e返回其值。
	QueueLength(Q):返回队列Q的元素个数
endADT

4.9 循环队列

1、队列顺序存储的不足

假设一个队列有n个元素,则顺序存储队列需建立一个大于n的数组,并把队列的所有元素存储在数组的前n个单元,数组下标为0的一端即是队头。

入队列操作是在队尾追加一个元素,不需要移动任何元素,因此时间复杂度为O(1)。

数据结构和算法——学习笔记(C语言版)_第22张图片

队列元素的出列是在队头,即下标0的位置,队列中所有的元素都得向前移动,以保证队列的队头,也就是下标为0的位置不为空,此时时间复杂度为O(n)。

数据结构和算法——学习笔记(C语言版)_第23张图片

如果不去限制队列元素必须存储在数组的前n个单元这一条件,出队的性能就大大增加。队头不需要一定在下表为0的位置。

数据结构和算法——学习笔记(C语言版)_第24张图片

为了避免当只有一个元素是,队头和队尾重合使处理变得麻烦,所以引入两个指针,front指针指向队头元素,rear指针指向队尾元素的下一个位置,这样当front等于rear时,此队列不是还剩一个元素,而是空队列。

假设是长度为5的数组,初始状态,front与rear指针均指向下标为0的位置。然后入队a1,a2,a3,a4,front指针依然指向下标为0的位置,而rear指针指向下标为4的位置。

数据结构和算法——学习笔记(C语言版)_第25张图片

出队a1,a2,则front指针指向下标为2的位置,rear不变。再入对a5,此时front指针不变,rear指针移动到数组之外。

数据结构和算法——学习笔记(C语言版)_第26张图片

假设这个队列的总个数不超过5个,但目前如果接着入队的话,因数组末尾元素已经占用,再向后加,就会产生数组越界的错误,可实际上,我们的队列在下标为0和1的地方还是空闲的,这种现象叫做“假溢出”。

2、循环队列定义

所以解决假溢出的办法就是后面满了,就再从头开始,也就是头尾相接的循环。我们把队列这种头尾相接的顺序存储结构称为循环队列。

数据结构和算法——学习笔记(C语言版)_第27张图片

接着入队a6,将它放置与下标0处,rear指针指向下标为1处,若再入队a7,则rear指针就与front指针重合,同时指向下标为2的位置。

数据结构和算法——学习笔记(C语言版)_第28张图片

空队列时,front等于rear,现在当队列满时,也是front等于rear。判断队列是空还是满:

(1)设置一个标志变量

(2)当队列满时,修改其条件,保留一个元素空间。队列满时,数组中还存有一个空闲单元。

数据结构和算法——学习笔记(C语言版)_第29张图片

由于rear可能比front大,也可能比front小,所以尽管它们相差一个位置是就是满的情况,但也可能是相差整整一圈。所以队列的最大尺寸为QueueSize,那么队列满的条件是(rear+1)%QueueSize == front。

另外,当rear>front时,此时队列的长度为rear-front。但当rear (rear-front+QueueSize)%QueueSize。

循环队列的顺序存储结构代码如下:

typedef int QElemType;/*QElemType类型根据实际情况而定,这里假设为int*/
/*循环队列的顺序存储结构*/
typedef struct
{
     
	QElemType data[MAXSIZE];
	int front;	/*头指针*/
	int rear;	/*尾指针,若队列不空,指向队列尾元素的下一位置*/
}SqQueue;

循环队列的初始化代码如下:

/*初始化一个空队列*/
Status InitQueue(SqQueue *Q)
{
     
	Q->front=0;
	Q->rear=0;
	return OK;
}

循环队列求队列长度代码如下:

/*返回Q的元素个数,也就是队列的当前长度*/
int QueueLength(SqQueue Q)
{
     
	return (Q.rear-Q.front+MAXSIZE)%MAXSIZE;
}

循环队列的入队操作代码如下:

/*若队列未满,则插入元素e为Q新的队尾元素*/
Status EnQueue(SqQueue *Q, QElemType e)
{
     
	if((Q->rear+1)%MAXSIZE == Q->front)	/*队列满的判断*/
		return ERROR;
	Q->data[Q->rear]=e;	/*将元素e赋值给队尾*/
	Q->rear=(Q->rear+1)%MAXSIZE;	/*rear指针向后移一位置,若到最后则转到数组头部*/

	return OK
}

循环队列的出队操作代码如下:

/*若队列不空,则删除Q中队头元素,用e返回其值*/
Status DeQueue(SqQueue *Q, QElemType *e)
{
     
	if(Q->front == Q->rear)	/*队列空的判断*/
		return ERROR;
	*e=Q->data[Q->front];	/*将队头元素赋值给e*/
	Q->front=(Q->front+1)%MAXSIZE;	/*front指针向后移一位置,若到最后则转到数组头部*/

	return OK
}

4.10 队列的链式存储结构及实现

1、队列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。为了操作上的方便,我们将队头指针指向链队列的头结点,而队尾指针指向终端结点。

数据结构和算法——学习笔记(C语言版)_第30张图片

空队列时,front和rear都指向头结点。

数据结构和算法——学习笔记(C语言版)_第31张图片

链队列的结构为:

typedef int QElemType;	/*QElemType类型根据实际情况而定,这里假设为int*/
typedef struct QNode	/*结点结构*/
{
     
	QElemType data;
	struct QNode *next;
	
}QNode, *QueuePtr;

typedef struct	/*队列的链表结构*/
{
     
	QueuePtr front,rear; /*队头、队尾指针*/
}LinkQueue;

2、队列的链式存储结构——入队操作

入队操作是,其实就是在链表尾部插入结点。

数据结构和算法——学习笔记(C语言版)_第32张图片

/*插入元素e为新的队尾元素*/
Status EnQueue(LinkQueue *Q, QElemType e)
{
     
	QueuePtr s=(QueuePtr)malloc(sizeof(QNode));
	if(!s)	/*存储分配失败*/
		exit(OVERFLOW);
	s->data=e;
	s->next=NULL;
	Q->rear->next=s;	/*把拥有元素e新结点s赋值给原队尾结点的后继*/
	Q->rear=s;	/*把当前的s设置为队尾结点,rear指向s*/
	return OK;
}

3、队列的链式存储结构——出队操作

出队操作时,就是头结点的后继结点出队,将头结点的后继改为它后面的结点,若链表除头结点外只剩一个元素时,则需将rear指向头结点。

数据结构和算法——学习笔记(C语言版)_第33张图片

/*若队列不空,删除Q的队头元素,用e返回其值,并返回OK,否则返回ERROR*/
Status DeQueue(LinkQueue *Q, QElemType *e)
{
     
	QueuePtr p;
	if(Q->front==Q->rear)
		return ERROR;
	p=Q->front->next;	/*将欲删除的队头结点暂存给p*/
	*e=p->data;	/*将欲删除的队头结点的值赋给e*/
	Q->front->next=p->next;	/*将原队头结点后继p->next赋值给头结点后继*/
	if(Q->rear==p)	/*若队头是队尾,则删除后将rear指向头结点*/
		Q->rear=Q->front;
	free(p);
	return OK;
}

4、在可以确定队列长度最大值的情况下,建议用循环队列,如果无法预估队列的长度是,则用链队列。

第5章 串

5.1 串的定义

1、串是由零个或多个字符组成的有限序列,又名叫字符串。串中的字符数目称为串的长度。零个字符的串称为空串,它的长度为零,可以直接用“ “” ”表示,也可以用希腊字母“Φ”表示。

2、空格串是只包含空格的串,空格串是有内容有长度的,而且可以不止一个空格。

3、子串与主串,串中任意个数连续字符组成的字符序列称为该串的子串,相应的,包含子串的串称为主串。

4、子串在主串中的位置就是子串的第一个字符在主串中的序号。

5.2 串的比较

1、C语言中要比较两个串是否相等,必须是它们串的长度以及它们各自对应位置的字符都相等时,才算是相等。

2、给定两个串:s=“a1a2a3……an”,t=“b1b2b3……bm”

(1)例如当s=“hap”, t=“happy”,就有s

(2)例如当s=“happen”,t=“happy”,因为两串的前4个字母相同,而两串的第5个字母,字母e的ASCII码是101,而字母y的ascii码是121,显然e

(3)例如当s=“abc”,t="bc"时,s

5.3 串的抽象数据类型

ADT (String)
Data
	串中元素仅由一个字符组成,相邻元素具有前驱和后继关系。
Operation
	StrAssign(T,*chars):生成一个其值等于字符串常量chars的串TStrCopy(T,S):串S存在,由串S复制的串TClearString(S):串S存在,将串清空。
	StringEmpty(S):若串S为空,返回true,否则返回falseStringLength(S):返回串S的元素个数,即串的长度。
	StrCompare(S,T):若S>T,返回值>0,若S=T,返回0,若S<T,返回值<0Concat(T,S1,S2):用T返回有S1S2连接而成的新串。
	SubString(Sub,S,pos,len):串S存在,1<=pos<=StrLength(S)0<=len<=StringLength(S)-pos+1,用Sub返回串S的第pos个字符起长度为len的子串。
	Index(S,T,pos):串ST存在,T是非空串,1<=pos<=StrLength(S)。若主串S中存在和串T值相同的子串,则返回它在主串S中第pos个字符之后第一次出现的位置,否则返回0Replace(S,T,V):串STV存在,T是非空串。用V替换主串S中出现的所有与T相等的不重叠的子串。
	StrInsert(S,pos,T):串ST存在,1<=pos<=StrLength(S)+1。在串S的第pos个字符之前插入串TStrDelete(S,pos,len):串S存在,1<=pos<=StrLength(S)-len+1。从串S中删除第pos个字符起长度为len的子串。
endADT
/*T为非空串。若主串S中第pos个字符之后存在与T相等的子串,则返回第一个这样的子串在S中的位置,否则返回0*/
int Index(String S,String T,int pos)
{
     
	int n,m,i;
	String sub;
	if(pos>0)
	{
     
		n = StrLength(S);	/*得到主串S的长度*/
		m = StrLength(T);	/*得到子串T的长度*/
		i = pos;
		while(i<n-m+1)
		{
     
			SubString(sub,S,i,m);	/*取主串第i个位置,长度与T相等给sub*/
			if(StrCompare(sub,T) != 0)	/*如果两串不相等*/
				++i;
			else	/*如果两串相等*/
				return i;	/*则返回i值*/
		}
	}
	return 0;	/*若无子串与T相等,返回0*/
}

5.4 串的存储结构

1、串的顺序存储结构
(1)串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列的。按照预定义的大小,为每个的定义的串变量分配一个固定长度的存储区。一般是用定长数组来定义。

(2)既然是定长数组,就存在一个预定义的最大串长度,一般可以将实际的串长度值保存在数组的0下表位置,有的也会定义存储在数组的最后一个下标位置。但也有些编程语言不想这么干,它规定在串值后面加一个不计入串长度的结束标记字符,比如“\0”来表示串值的终结,这个时候要知道此时的串长度,就需要遍历计算一下。

(3)串值的存储空间可在程序执行过程中动态分配而得。比如在计算机中存在一个自由存储区,叫做“堆”。这个堆可有C语言的动态分配函数malloc()和free()来管理。

数据结构和算法——学习笔记(C语言版)_第34张图片

2、串的链式存储结构

对于串的链式存储结构,与线性表是相似的,一个结点可以存放多个字符,最后一个结点若是未被占满时,可以用“#”或其他非串值字符补全。

在这里插入图片描述

5.5 朴素的模式匹配算法

子串的定位操作通常称作串的模式匹配。

假设从"S"=“goodgoogle"中,找到"T”="google"这个子串的位置。

1、主串S第一位开始,S与T前三个字母都匹配成功,但S第四个字母是d而T的是g。第一位匹配失败。

数据结构和算法——学习笔记(C语言版)_第35张图片

2、主串S第二位开始,主串S首字母是o,要匹配的T首字母是g,匹配失败。

数据结构和算法——学习笔记(C语言版)_第36张图片

3、主串S第三位开始,主串的首字母是o,要匹配的T的首字母是g,匹配失败。

数据结构和算法——学习笔记(C语言版)_第37张图片

4、主串S第四位开始,主串S首字母是d,要匹配的T首字母是g,匹配失败。

数据结构和算法——学习笔记(C语言版)_第38张图片

5、主串S第五位开始,S与T,6个字母全匹配,匹配成功。

数据结构和算法——学习笔记(C语言版)_第39张图片

数组实现模式匹配的算法Index,假设主串S和要匹配的子串T的长度在S[0]和T[0]中,实现代码如下:

/*返回子串T在主串S中第pos个字符之后的位置。若不存在,则函数返回值为0*/
/*T非空,1<=pos<=StrLength(S)*/
int Index(String S, String T, int pos)
{
     
	int i = pos;	/*i用于主串S中当前位置的下标,若pos不为1,则从pos位置开始匹配*/
	int j = 1;	/*j用于子串T中当前位置下标值*/
	while(i<=S[0] && j<=T[0])	/*若i小于S长度且j小于T的长度时循环*/
	{
     
		if(S[i] == T[j])	/*两字母相等则继续*/
		{
     
			++i;
			++j;
		}
		else	/*指针后退重新开始匹配*/
		{
     
			i = i-j+2;	/*i退回上次匹配首位的下一位*/
			j = 1;	/*j退回到子串T的首位*/
		}
	}
	if(j>T[0])
		return i-T[0];
	else
		return 0;
}

5.6 KMP模式匹配算法

1、KMP模式匹配算法原理

问题是由模式串决定的,不是由目标串决定的 。KMP匹配算法在每次匹配失败后模式串移动的距离不一定是 1,某些情况下一次可移动多个位置,这样可以大大减少重复比较。在KMP匹配算法中,定义了一个next数组进行记录串应该返回的地方。next数组的长度为子串的长度,next数组表示若当前位不匹配我们该从第几位开始重新比较。

2、next数组值的推导

前缀:除了最后一个字符外,字符串的全部头部组合。

后缀:除了第一个字符外,字符串的全部尾部组合。

前缀必须要从头开始算,后缀要从最后一个数开始算,中间截一段相同字符串是不行的。“ababa” 前缀[a,ab,aba,abab],后缀[a,ba,aba,baba],最长公共部分为[aba]。

KMP算法中next数组保存当前字符之前的字符的最长公共前后缀+1。

(1)T=“abcdex”

数据结构和算法——学习笔记(C语言版)_第40张图片

  • 当j=1时,next[1] = 0;

  • 当j=3时,j由1到j-1的串是“ab”,“a”与“b”不等,next[3] = 1;

(2)T=“abcabx”

数据结构和算法——学习笔记(C语言版)_第41张图片

  • 当j=1时,next[1] = 0;

  • 当j=6时,j由1到j-1的串是“abcab”,由于前缀字符“ab”和后缀字符“ab”相等,所以,next[6] = 3;

(3)T=“ababaaaba”

数据结构和算法——学习笔记(C语言版)_第42张图片

  • 当j=1时,next[1] = 0;

  • 当j=6时,j由1到j-1的串是“ababa”,由于前缀字符“aba”和后缀字符“aba”相等,所以,next[6] = 4;

(4)T=“aaaaaaaab”

数据结构和算法——学习笔记(C语言版)_第43张图片

  • 当j=1时,next[1] = 0;

  • 当j=9时,j由1到j-1的串是“aaaaaaaa”,由于前缀字符“aaaaaaa”和后缀字符“aaaaaaa”相等,所以,next[9] = 8;

3、KMP模式匹配算法实现

/*通过计算返回子串T的next数组*/
void get_next( String T, int *next ) {
     

	int i,j;
	i = 1;
	j = 0;
	next[1] = 0;
	while( i<T[0] )	/*T[0]表示串T的长度*/
	{
     
		
		if( j==0 || T[i]==T[j] )	/*T[i]表示后缀的单个字符,T[j]表示前缀的单个字符*/
		{
     
			
			++i;
			++j;
			next[i] = j;
		}
		else
			j = next[j]; /*若字符不相同,则j值回溯*/
	}
}
/*返回子串T在主串S中第pos个字符之后的位置,若不存在,则函数返回值为0*/
/*T非空,1<=pos<=StrLength(S)*/
int Index_KMP( String S, String S, int pos ) {
     

	int i = pos; /*i用于子串S当前位置的下标值,若pos不为1,则从pos位置开始匹配*/
	int j =  1;	/*j用于子串T中当前位置的下标*/
	int next[255]; /*定义一next数组*/
	get_next(T,next);	/*对串T作分析,得到next数组*/
	while( i<=S[0] && j<=T[0] )
	{
     
		if (  j==0 || S[i]==T[j] ) {
     

			++i;
			++j;
		}
		else {
     
			
			j = next[j];
		}
	}
	if ( j>T[0] )
		return i-T[0];
	else
		return 0;
}

4、KMP模式匹配算法改进

/*求模式串T的next函数修正值并存入数组nextval*/
void get_nextval( String T, int *nextval ) {
     

	int i,j;
	i = 1;
	j = 0;
	nextval[1] = 0;
	while( i<T[0] )	/*T[0]表示串T的长度*/
	{
     
		
		if( j==0 || T[i]==T[j] )	/*T[i]表示后缀的单个字符,T[j]表示前缀的单个字符*/
		{
     
			
			++i;
			++j;
			if ( T[i]!=T[j] )	/*若当前字符与前缀字符不同*/
				nextval[i] = j;	/*则当前的j为nextval子i位置的值*/
			else
				nextval[i] = nextval[j];	/*如果与前缀字符相同,则将前缀字符的nextval值赋值给nextval在i位置的值*/
		}
		else
			j = nextval[j]; /*若字符不相同,则j值回溯*/
	}
}

5、nextval数组值推导

1、T=“ababaaaba”

数据结构和算法——学习笔记(C语言版)_第44张图片

  • 当j=1时,nextval[1]=0;

  • 当j=2时,因第二个字符“b”的next值是1,而第一位就是“a”,它们不相等,所以nextval[2]=next[2]=1,维持原值。

  • 当j=3时,因为第三个字符“a”的next值为1,所以与第一位的“a”比较得知它们相等,所以nextval[3]=nextval[1]=0;

2、T=“aaaaaaaab”

数据结构和算法——学习笔记(C语言版)_第45张图片

  • 当j=1时,nextval[1]=0;

  • 当j=2时,next值为1,第二个字符与第一个字符相等,所以nextval[2]=nextval[1]=0;

  • 当j=9时,next值为8,第九个字符“b”与第八个字符“a”不相等,所以nextval[9]=“8”;

总结改进过的KMP算法,它是在计算出next值得同时,如果a为字符与它next指向的b位字符相等,则该a为的nextval就指向b为的nextval值,如果不等,则该a位nextval值就是它自己a位得next的值。

第6章 树

6.1 树的定义

树(Tree)是n(n>=0)个结点的有限集。n=0时称为空树。在任意一棵非空树中:

(1)有且仅有一个特定的称为根的(Root)结点;

(2)当n>0时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、……、Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。

数据结构和算法——学习笔记(C语言版)_第46张图片

数据结构和算法——学习笔记(C语言版)_第47张图片

子树T1和子树T2就是跟结点A的子树。当然,D、G、H、I组成的树又是B结点的子树,E、J组成的树是C为结点的子树。

对于树的定义还需要强调两点:

  • n>0时,根结点是唯一的,不可能存在多个根结点,别和现实中的大树混在一起,现实中的树有很多根须,那是真实的树,数据结构中的树是只能有一个根结点。

  • m>0时,子树的个数没有限制,但它们一定是互不相交的。

1、结点分类

树的结点包含一个数据元素及其若干指向其子树的分支。

结点拥有的子树数称为结点的度(Degree)。

度为0的结点称为叶结点(Leaf)或终端结点。

度不为0的结点称为非终端结点或分支结点。

除根结点之外,分支结点也称为内部结点。

树的度是树内各结点的度的最大值。

数据结构和算法——学习笔记(C语言版)_第48张图片

2、结点间关系

结点的子树的根称为该结点的孩子(Child),相应的,该结点称为孩子的双亲(Parent)。

同一个双亲的孩子之间互称兄弟(Sibling)。

结点的祖先是从根到该结点所经分支上所有结点。

以某结点为根的子树中的任一结点都称为该结点的子孙。

数据结构和算法——学习笔记(C语言版)_第49张图片

对H来说,D、B、A都是它的祖先,B的子孙有D、G、H、I。

3、树的其他相关概念

数据结构和算法——学习笔记(C语言版)_第50张图片

结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层。若某结点在第l层,则其字树的根就在l+1层。其双亲在同一层的结点互为堂兄弟。

树中结点的最大层次称为树的深度(Depth)或高度。

如果将树中结点的各子树看成从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树。

森林(Forest)是m(m>=0)棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。

线性表与数的结构的比较

线性结构

  • 第一个数据元素:无前驱

  • 最后一个数据元素:无后继

  • 中间元素:一个前驱一个后继

树结构

  • 根结点:无双亲,唯一

  • 叶结点:无孩子,可以多个

  • 中间结点:一个双亲多个孩子

6.2 树的抽象数据类型

ADT (Tree)
Data
	树是由一根结点和若干子树构成,树中结点具有相同数据类型及层次关系。
Operation
	InitTree(*T):构造空树TDestroyTree(*T):销毁树TCreateTree(*T,definitation):按defination中给出树的定义来构造树。
	ClearTree(*T):若树T存在,则将树T清为空树。
	TreeEmpty(T):T为空树,返回true,否则返回falseTreeDepth(T):返回T的深度。
	Root(T):返回树的根结点。
	Value(T,cur_e):cur_e是树T中的一个结点,返回此结点的值。
	Assign(T,cur_e,value):给树T的结点cur_e赋值为value。
	Parent(T,cur_e):若cur_e是树T的非根结点,则返回它的双亲,否则返回空。
	LeftChild(T,cur_e):若cur_e是树T的非叶结点,则返回它的最左孩子,否则返回空。
	RightSibling(T,cur_e):若cur_e有右兄弟,则返回它的右兄弟,否则返回空。
	InsertChild(*T,*p,i,c):其中p指向树T的某个结点,i为所指结点p的度街上1,非空树c与T不相交,操作结果为插入c为树T中p指向结点的第i棵子树。
	DeleteChild(*T,*p,i):其中p指向树T的某个结点,i为所指结点p的度,操作结果为删除T中p所指结点的第i棵子树。

endADT;

6.3 树的存储结构

1、双亲表示法

除了根结点外,其余每个结点,它不一定有孩子,但是一定有且仅有一个双亲。

假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指示其双亲结点到链表中的位置。也就是说,每个结点除了知道自己是谁外,还知道它的双亲在哪里。

在这里插入图片描述

其中data是数据域,存储结点的数据信息。而parent是指针域,存储该结点的双亲在数组中的下标。

/*树的双亲表示法结点结构定义*/
#define MAX_TREE_SIZE 100
typedef int TElemType;	/*树结点的数据类型,目前暂定为整型*/
typedef struct PINode	/*结点结构*/
{
     
	
	TElemType data;	/*结点数据*/
	int parent; /*双亲位置*/
}PINnode;

typedef struct
{
     
	
	PTNode nodes[MAX_TREE_SIZE];	/*结点数组*/
	int r,n;	/*根的位置和结点数*/
}PTree;

由于根结点是没有双亲的,所以我们约定根结点的位置域设置为-1,这也就意味着,所有的结点都存有它双亲的位置。

数据结构和算法——学习笔记(C语言版)_第51张图片

这样的存储结构很容易找到结点的双亲,时间复杂度为O(1),但如果要知道结点的孩子需要遍历整个结构才行。

增加一个结点左边孩子的域,这样就可以容易得到结点的孩子。如果没有孩子的结点,这个域就设置为-1。

数据结构和算法——学习笔记(C语言版)_第52张图片

对于有0或1个孩子结点来说,这样的结构是解决了要找结点孩子的问题了。甚至是两个孩子,知道了长子是谁,另一个当然就是次子了。

增加一个右兄弟域来体现兄弟关系。也就是说,每一个结点如果存在右兄弟,则记录下右兄弟的下标。同样的,如果右兄弟不存在,则赋值为-1。

数据结构和算法——学习笔记(C语言版)_第53张图片

如果结点的孩子很多,超过了2个。还可以把此结构扩展为有双亲域、长子域、再有右兄弟域。

存储结构的设计是一个非常灵活的过程。一个存储结构过程设计的是否合理,取决于基于该存储结构的运算是否适合,是否方便、时间复杂度好不好等。

2、多重链表表示法

由于树中每个结点可能有多个子树,可以考虑用多重链表,及每个结点有多个指针域,其中每个指针指向一棵子树的根结点,我们把这种方法叫做多重链表表示法。树的每个结点的度,也就是它的孩子的个数是不同的,所以可以设计两种方案来解决。

方案1

第一种是指针域的个数就等于树的度。树的度是树各个结点度的最大值。

在这里插入图片描述

其中data是数据域。child1到childd是指针域,用来指向该结点的孩子结点。

数据结构和算法——学习笔记(C语言版)_第54张图片

这种方法对于树中各结点的度相差很大时,显然是浪费空间的,因为有很多的结点,它的指针域都是空的。

方案2

第二种是每个结点指针域的个数等于该结点的度,专门去一个位置来存储结点指针域的个数。

在这里插入图片描述

其中data是数据域,degree为度域,也就是存储该结点的孩子结点的个数,child1到childd为指针域,指向该结点各个孩子的结点。

数据结构和算法——学习笔记(C语言版)_第55张图片

这种方法克服了浪费空间的缺点,对于空间利用率是很高了,但由于各个结点的链表是不相同的结构,加上要维护结点的度的数值,在运算上就会带来时间的损耗。

3、孩子表示法

为了要遍历整棵树,把每个结点放到一个顺序存储结构的数组中是合理的,但每个结点的孩子有多少是不确定的,所以我们再对每个结点的孩子建立一个单链表体现他们的关系。

这就是孩子表示法。具体方法是:把每个结点的孩子结点排列起来,以单链表作存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空,然后n个头指针有组成一个线性表,采用顺序存储结构,存放进一个一维数组中。

数据结构和算法——学习笔记(C语言版)_第56张图片

为此,设计两种结点结构,一个是孩子链表的孩子结点。

在这里插入图片描述

其中child是数据域,用来存储某个结点在表头数组中的下标。next是指针域,用来存储指向某结点的下一个孩子结点的指针。

另一个表头数组的表头结点。

在这里插入图片描述

其中data是数据域,存储某结点的数据信息。firstchild是头指针域,存储该结点的孩子链表的头指针。

/*树的孩子表示法结构定义*/
#define MAX_TREE_SIZE 100
typedef struct CTNode	/*孩子结点*/
{
     
	int child;
	struct CTNode *next;
}*ChildPtr;

typedef struct	/*表头结构*/
{
     
	TElemType data;
	ChildPtr firstchild;
}CTBox;

typedef struct	/*树结构*/
{
     
	CTBox nodes[MAX_TREE_SIZE]; /*结点数组*/
	int r, n;	/*根的位置和结点数*/
}CTree;

这样的结构对于我们要查找某个结点的某个孩子,或者找某个结点的兄弟,只需要查找这个结点的孩子单链表即可。对于遍历整棵树也是很方便的对头结点的数组循环即可。

但是,要知道某个结点的双亲是谁,比较麻烦,需要整棵树遍历才行。这种情况下可以把双亲表示法和孩子表示法综合一下。

数据结构和算法——学习笔记(C语言版)_第57张图片

我们把这种方法成为双亲孩子表示法,应该算是孩子表示的改进。

4、孩子兄弟表示法

任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,,分别指向该结点的第一个孩子和此结点的右兄弟。

在这里插入图片描述

其中data是数据域,firstchild为指针域,存储该结点的第一个孩子结点的存储地址,rightsib是指针域,存储该结点的右兄弟结点的存储地址。

/*树的孩子兄弟表示法结构定义*/
typedef struct CSNode
{
     
	TElemType data;
	struct CSNode *firstchild, *rightsib;
}CSNode,*CSTree;

数据结构和算法——学习笔记(C语言版)_第58张图片

这种表示法,给查找某个结点的某个孩子带来了方便,只需要通过firstchild找到此结点的长子,然后通过长子结点的rightchild找到它的二弟,接着一直下去,直到找到具体的孩子。
如果要找某个结点的双亲,完全可以增加一个parent指针域来解决快速查找双亲问题。

这个表示法的最大好处是它把一棵复杂的树,变成了一棵二叉树。

数据结构和算法——学习笔记(C语言版)_第59张图片

6.4 二叉树的定义

对于在某个阶段都是两种结果的情形,比如开和关、0和1、真和假、上和下、对与错、正面与反面等,都适合用树状结构来建模,而这种树是一种很特殊的树,叫做二叉树。

二叉树(Binary Tree)是n(n>=0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。

数据结构和算法——学习笔记(C语言版)_第60张图片

1、二叉树特点

二叉树的特点有

  • 每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点。注意不是只有两棵子树,而是最多有。没有子树或者有一棵子树都是可以的。

  • 左子树和右子树是有顺序的,次序不能颠倒。

  • 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。树1和树2是同一棵树,但它们却是不同的二叉树。

数据结构和算法——学习笔记(C语言版)_第61张图片

二叉树具有的五种基本形态

  • 空二叉树。

  • 只有一个根结点。

  • 根结点只有左子树。

  • 根结点只有右子树。

  • 根结点既有左子树又有右子树。

如果是由三个结点的树,有几种形态?如果是由三个结点的二叉树,又由于几种形态?

数据结构和算法——学习笔记(C语言版)_第62张图片

若只从形态上考虑,三个结点的树只有两种情况,有两层的树1和有三层的后四种的任意一种。

但对于二叉树来说,由于要区分左右,所以就演变成五种形态,树2、树3、树4和树5分别代表不同的二叉树。

2、特殊二叉树

1、斜树

所有的结点都只有左子树的二叉树叫左斜树。所有结点都是只有右子树的二叉树交右斜树,这两者统称为斜树。

斜树有很明显的特点,就是每一层都只有一个结点,结点的个数与二叉树的深度相同。

2、满二叉树

在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。

数据结构和算法——学习笔记(C语言版)_第63张图片

满二叉树的特点有:

(1)叶子只能出现在最下一层。出现在其它层就不可能达成平衡。

(2)非叶子结点的度一定是2。

(3)正在同样深度的二叉树中,满二叉树的结点个数最多,叶子树最多。

3、完全二叉树

对一棵具有n个结点的二叉树按层序编号,如果编号为(1<=i<=n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中的位置完全相同,则这棵二叉树称为完全二叉树。

数据结构和算法——学习笔记(C语言版)_第64张图片

满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。

数据结构和算法——学习笔记(C语言版)_第65张图片

完全二叉树的所有结点与同样深度的满二叉树,它们按层序编号相同的结点,是一一对应的。

树1中,因为5结点没有左子树,却有右子树,那就使得按层序编号的第10个编号空档了。

树2中,由于3结点没有子树,使得6、7编号的位置空档了。

完全二叉树的特点

(1)叶子结点只能出现在最下两层。

(2)最下层的叶子一定集中在左部连续位置。

(3)倒数二层,若有叶子结点,一定都在右部连续位置。

(4)如果结点度为1,则该结点只有左孩子,即不存在只有右子树的情况。

(5)同样结点数的二叉树,完全二叉树的深度最小。

判断某二叉树是否是完全二叉树的方法,就是给每个结点按照满二叉树的接否逐层顺序编号,如果编号出现空档,就说明不是完全二叉树,否则就是。

6.5 二叉树的性质

性质1:在二叉树的第i层上至多有2i-1个结点(i>=1)。n

性质2:深度为k的二叉树至多有2k-1个结点(k>=1)。

性质3:对任何一棵二叉树T,如果其终端结点数为n0,度为2的结点数为n2,则n0=n2+1。

终端结点数其实就是叶子结点数,而一棵二叉树,除了叶子结点外,剩下的就是度为1或2的结点数了,设n1为度是1的结点数,则树T结点总数n=n0+n1+n2

性质4:具有n个结点的完全二叉树的深度为[log2n]+1([x]表示不大于x的最大整数)。

性质5:如果对一棵有n个结点的完全二叉树(其深度为[log2n] + 1)的结点按层序编号(从第1层到第[log2n] + 1层。每层从左到右),对任一结点i(1<=i<=n)有:

(1)如果i=1,则结点i是完全二叉树的根,无双亲;如果i>1,则其双亲是结点[i/2]。

(2)如果2i>n,则结点i无左孩子(结点i为叶子结点);否则其左孩子是结点2i。

(3)如果2i+1>n,则结点i无右孩子;否则其右孩子是结点2i+1。

数据结构和算法——学习笔记(C语言版)_第66张图片

第一条,i=1时就是根结点。i>1时,比如结点7,它的双亲就是[7/2]=3,结点9,它的双亲就是[9/2]=4。

第二条,比如结点6,因为2×6=12超过了结点总数10,所以结点6无左孩子,它是叶子结点。同样,而结点5,因为2×5=10正好是结点总数10,所以它的左孩子是结点10。

第三条,比如结点5,因为2×5+1=11,大于结点总数10,所以它无右孩子,而结点3,因为2×3+1=7小于10,所以它的右孩子是结点7。

6.6 二叉树的存储结构

1、二叉树的顺序存储结构

二叉树的顺序存储结构就是用一维数组存储二叉树中的结点,并且结点的存储位置,也就是数组的下标要体现结点之间的逻辑关系,比如双亲与孩子的关系,左右兄弟的关系等等。

完全二叉树的顺序存储

数据结构和算法——学习笔记(C语言版)_第67张图片

将这棵二叉树存入到数组中,相应的下标对应其同样的位置。

数据结构和算法——学习笔记(C语言版)_第68张图片

对于一般的二叉树,尽管层序编号不能反映逻辑关系,但是可以将其按完全二叉树编号,只不过,把不存在的结点设置为"^"而已。

数据结构和算法——学习笔记(C语言版)_第69张图片

考虑一种极端的情况,一棵深度为k的右斜树,它只有k个结点,却需要分配2k-1个存储空间单元,这显然是对存储空间的浪费。
所以顺序存储结构一般只用于完全二叉树。

数据结构和算法——学习笔记(C语言版)_第70张图片

2、二叉链表

二叉树每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域,我们称这样的链表叫做二叉链表。

在这里插入图片描述

其中data是数据域,lchild和rchild都是指针域,分别存放指向左孩子和右孩子的指针。

/*二叉树的二叉链表结点结构定义*/
typedef struct BiTNode	/*结点结构*/
{
     

	TElemType data; /*结点数据*/
	struct BiTNode *lchild, *rchild;	/*左右孩子指针*/
}BiTNode,*BiTree;

数据结构和算法——学习笔记(C语言版)_第71张图片

6.7 遍历二叉树

1、二叉树遍历原理

二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。

2、二叉树遍历方法

如果限制了从左到右的习惯方式,那么主要就分为四种:

(1)前序遍历

规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,在前序遍历右子树。

数据结构和算法——学习笔记(C语言版)_第72张图片

(2)中序遍历

规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。

数据结构和算法——学习笔记(C语言版)_第73张图片

(3)后序遍历

规则是若树为空,则空操作返回,否则从从左到右先叶子后结点方式遍历访问左右子树,最后是访问根结点。

数据结构和算法——学习笔记(C语言版)_第74张图片

(4)层序遍历

规则是若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。

数据结构和算法——学习笔记(C语言版)_第75张图片

3、前序遍历算法

/*二叉树的前序遍历递归算法*/
void PreOrderTraverse(BiTree T)
{
     
	if(T==NULL)
		return 0;
	printf("%c",T->data);	/*显示结点数据,可以更改为其他对结点操作*/
	PreOrderTraverse(T->lchild);	/*再先序遍历左子树*/
	PreOrderTraverse(T->rchild);	/*最后先序遍历右子树*/
}

4、中序遍历算法

/*二叉树的中序遍历递归算法*/
void InOrderTraverse(BiTree T)
{
     
	if(T==NULL)
		return 0;
	InOrderTraverse(T->lchild);	/*中序遍历左子树*/
	printf("%c",T->data);	/*显示结点数据,可以更改为其他对结点操作*/
	InOrderTraverse(T->rchild);	/*最后中序遍历右子树*/
}

5、后序遍历算法

/*二叉树的后序遍历递归算法*/
void PostOrderTraverse(BiTree T)
{
     
	if(T==NULL)
		return 0;
	PostOrderTraverse(T->lchild);	/*先后序遍历左子树*/
	PostOrderTraverse(T->rchild);	/*再后序遍历右子树*/
	printf("%c",T->data);	/*显示结点数据,可以更改为其他对结点操作*/
}

6、推导遍历结果

  • 已知前序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。

  • 已知后序遍历序列和中序遍历序列,可以唯一确定一棵二叉树。

  • 已知前序遍历序列和后序遍历序列,是不能确定一棵二叉树的。

6.8 二叉树的建立

为了能让每个结点确定是否有左右孩子,对它进行扩展,也就是将二叉树中每个结点的空指针引出一个虚结点,其值为一特定值,比如“#”。称这种处理后的二叉树为原二叉树的扩展二叉树。扩展二叉树就可以做到一个遍历序列确定一棵二叉树了。

数据结构和算法——学习笔记(C语言版)_第76张图片

扩展二叉树的前序遍历序列AB#D##C##,中序遍历序列#B#D#A#C#,后序遍历序列为###DB##CA。

生成一棵二叉树,假设二叉树的结点均为一个字符,吧前序遍历序列AB#D##C##用键盘挨个输入。实现算法如下:

/*按前序输入二叉树中结点的值(一个字符)*/
/*#表示空树,构造二叉链表表示二叉树T。*/
void CreateBiTree(BiTree *T)
{
     
	TElemType ch;
	scanf("%c", &ch);
	if(ch=="#")
		*T=NULL;
	else
	{
     
		*T=(BiTree)malloc(sizeof(BiTNode));
		if(!*T)
			exit(OVERFLOW);
		(*T)->data=ch;	/*生成根结点*/
		CreateBiTree(&(*T)->lchild);	/*构造左子树*/
		CreateBiTree(&(*T)->rchild);	/*构造右子树*/
	}
}

6.9 线索二叉树

1、线索二叉树原理

指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树(Threaded Binary Tree)。

对二叉树以某种次序遍历使其变为线索二叉树的过程称作是线索化。

在这里插入图片描述

  • ltag为0时指向该结点的左孩子,为1时指向该结点的前驱。

  • rtag为0时指向该结点的右孩子,为1时指向该结点的后继。

数据结构和算法——学习笔记(C语言版)_第77张图片

数据结构和算法——学习笔记(C语言版)_第78张图片

2、线索二叉树结构实现

typedef enum {
     Link,Thread}	PointerTag;	/*Link==0表示指向左右孩子指针,Thread==1表示指向前驱或后继的线索*/
typedef struct BiThrNode
{
     
	TElemType data;	/*结点数据*/
	struct BiThrNode *lchild, *rchild	/*左右孩子指针*/
	PointerTag LTag;
	PointerTag RTag;	/*左右标志*/
}BiThrNode, *BiThrTree;

线索化的实质就是将二叉链表中的空指针改为指向前驱或后继的线索。由于前驱和后继的信息只有在遍历该二叉树时才能得到,所以线索化的过程就是在遍历的过程中修改空指针的过程。

中序遍历线索化的递归函数代码如下:

BiThrTree pre; /*全局变量,始终指向刚刚访问过的结点*/
/*中序遍历进行中序线索化*/
void InThreading(BiThrTree p)
{
     
	if(p)
	{
     
		InThreading(p->lchild);	/*递归左子树线索化*/
		if(!p->lchild)	/*没有左孩子*/
		{
     
			p->LTag=Thread;	/*前驱线索*/
			p->lchild=pre;	/*左孩子指针指向前驱*/
		}

		if(!pre->rchild)	/*前驱没有右孩子*/
		{
     
			pre->RTag=Thread;	/*后继线索*/
			pre->rchild=p;	/*前驱右孩子指针指向后继(当前结点p)*/
		}

		pre=p;	/*保持pre指向p的前驱*/
		InThreading(p->rchild);	/*递归右子树线索化*/
	}
}

遍历的代码如下:

/*T指向头结点,头结点左链lchild指向根结点,头结点右链rchild指向中序遍历的最后一个结点。中序遍历二叉线索链表表示的二叉树T*/
Status InOrderTraverse_Thr(BiThrTree T)
{
     
	BiThrTree p;
	p = T->lchild;	/*p指向根结点*/
	while(p != T)	/*空树或遍历结束时,p==T*/
	{
     
		while(p->LTag==Link)	/*当LTag==0时循环到中序序列第一个结点*/
			p = p->lchild;
		printf("%c",p->data);	/*显示结点数据,可以更改为其他对结点操作*/
		while(p->RTag==Thread && p->rchild!=T)
		{
     
			p = p->rchild;
			printf("%c",p->data);
		}
		p = p->rchild;	/*p进至其右子树根*/
	}
	return OK;
}

如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构就是非常不错的选择。

6.10 树、森林与二叉树的转换

1、树转换为二叉树

将树转换为二叉树的步骤如下

  • 加线。在所有兄弟结点之间加一条连线。

  • 去线。对树中每个结点,只保留它与第一个孩子结点的连线,删除它与其他孩子结点之间的连线。

  • 层次调整。以树的根结点为轴心,将整棵树顺时针旋转一定的角度,使之结构层次分明。注意第一个孩子是二叉树结点的左孩子,兄弟转换过来的孩子是结点的右孩子。

数据结构和算法——学习笔记(C语言版)_第79张图片

2、森林转换为二叉树

森林是由如若干棵树组成的,所以完全可以理解为,森林中的每一棵树都是兄弟,可以按照兄弟的处理方法来操作。步骤如下:

  • 把每一棵树转换为二叉树

  • 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子,用线连接起来。当所有的二叉树连接起来后就得到了由森林转换来的二叉树。

数据结构和算法——学习笔记(C语言版)_第80张图片

3、二叉树转换为树

二叉树转换为树是树转换为二叉树的逆过程。步骤如下:

  • 加线。左孩子的n个右孩子结点都作为此结点的孩子,将这些右孩子结点用线连接起来。

  • 去线。删除原二叉树中所有结点与其右孩子结点的连线。

  • 层次调整。使之结构层次分明。

数据结构和算法——学习笔记(C语言版)_第81张图片

4、二叉树转换为森林

判断一棵二叉树能够转换成一棵树还是森林,只要看这棵二叉树的根结点有没有右孩子,有就是森林,没有就是一棵树,那么如果是转换成森林,步骤如下:

  • 从根结点开始,若右孩子存在,则把与右孩子结点的连线删除,再查看分离后的二叉树,若右孩子存在,则连线删除……,直到所有右孩子连线都删除为止,得到分离的二叉树。

  • 再将每棵分离后的二叉树转换成树即可。

数据结构和算法——学习笔记(C语言版)_第82张图片

5、树与森林的遍历

树的遍历分为两种方式:

  • 一种是先根遍历树,即先访问树的根结点,然后依次先根遍历根的每棵子树。

  • 另一种是后根遍历,即先依次后根遍历每棵子树,然后再访问根结点。

数据结构和算法——学习笔记(C语言版)_第83张图片

先根遍历序列为ABEFCDG,后根遍历序列为EFBCGDA。

森林的遍历也分为两种方式:

  • 前序遍历:先访问森林中第一棵树的根结点,然后再依次先根遍历根的每棵子树,再依次用同样方式遍历除去第一棵树的剩余树构成森林。

  • 后序遍历:是先访问森林中第一棵树,后根遍历的方式遍历每棵子树,然后再访问根结点,再依次同样方式遍历除去第一棵树剩余树构成的森林。

数据结构和算法——学习笔记(C语言版)_第84张图片

前序遍历序列为ABCDEFGHIJ,后序遍历序列为BCDAFEJHIG。

森林的前序遍历和二叉树的前序遍历结果相同,森林的后序遍历和二叉树的中序遍历结果相同。

6.11 赫夫曼树及其应用

1、赫夫曼树

2、赫夫曼树定义与原理

数据结构和算法——学习笔记(C语言版)_第85张图片

其中A表示不及格,B表示及格,C表示中等,D表示良好,E表示优秀。每个叶子的分支线上的数字就是五级分制的成绩所占比例数。

从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称作路径长度。

二叉树a中,根结点到结点D的路径长度就是4,二叉树b中根结点到结点D的路径长度为2。

树的路径长度就是从树根到每一结点的路径长度之和。

二叉树a的路径长度就为1+1+2+2+3+3+4+4=20。二叉树b的路径长度就为1+2+3+3+2+1+2+2=16。

结点的带权的路径长度为从该结点到树根之间的路径长度与结点上权的乘积。树的带权路径长度为树中所有叶子结点的带权路径长度之和。带权路径长度WPL最小的二叉树称作赫夫曼树或最优二叉树。

二叉树a的WPL=5×1+15×2+40×3+30×4+10×4=315,
二叉树b的WPL=5×3+15×3+40×2+30×2+10×2=220。

构造赫夫曼树的步骤:

  • 先把有权值得叶子结点按照从小到大的顺序排列成一个有序序列,即:A5,E10,B15,D30,C40。

  • 取头两个最小权值的结点作为一个新结点N1的两个子结点,注意相对较小的是左孩子。新结点的权值为两个叶子权值的和5+10=15。

数据结构和算法——学习笔记(C语言版)_第86张图片

  • 将N1替换A与E,插入有序序列中,保持从小到大排列。即:N115,B15,D30,C40。

  • 重复步骤2。

数据结构和算法——学习笔记(C语言版)_第87张图片

  • 将N2替换N1与B,插入有序序列中,保持从小到大排列。即:N230,D30,C40。

  • 重复步骤2 。

数据结构和算法——学习笔记(C语言版)_第88张图片

  • 将N3替换N2与D,插入有序序列中,保持从小到大排列。即:C40,N360。

  • 重复步骤2。

数据结构和算法——学习笔记(C语言版)_第89张图片

  • WPL=40×1+30×2+15×3+10×4+5×4=205。

3、赫夫曼编码

若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码称作前缀编码。

在这里插入图片描述

数据结构和算法——学习笔记(C语言版)_第90张图片

当我们接收到1001010010101001000111100,由约定好的赫夫曼树可知,1001得到第一个字母是B,接下来01意味着第二个字符是A。

规定赫夫曼树的左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,这就是赫夫曼编码。

第7章 图

7.1 图的定义

在图形结构中,结点之间的关系可以是任意的,图中任意两个数据元素之间都有可能相关。

图(Graph)是由顶点的有穷非空集合和顶点之间边的集合组成,通常表示为:G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。

线性表中的数据叫元素,树中的元素叫结点,在图中数据元素叫顶点(Vertex)。

线性表中可以没有数据元素,称为空表。树中可以没有结点,称为空树。同样,在图结构中不允许没有结点。在定义中,若V是顶点的集合,则强调了顶点集合V有穷非空。

线性表中,相邻的数据元素之间具有线性关系。树结构中,相邻两层的结点具有层次关系,而图中,任意两个顶点之间都有可能有关系,顶点之间的逻辑关系用边来表示,边集可以是空的。

1、各种图定义

无向边:若顶点vi到vj之间的边没有方向,则称这条边为无向边(Edge),用无序偶对(vi,vj)来表示。

数据结构和算法——学习笔记(C语言版)_第91张图片

如果图中任意两个顶点之间的边都是无向边,则称该图为无向图(Undirected graphs)。连接顶点A与D的边,可以表示称无序对(A,D),也可以写成(D,A)。

对于图7-2-2中的无向图G1来说,G1=(V1,{E1}),其中顶点集合V1={A,B,C,D};边集合E1={ (A,B),(B,C),(C,D),(D,A),(A,C)}

有向边:若从顶点vi到vj的边有方向,则称这条边为有向边,也称为弧(Arc)。用有序偶i,j>来表示,vi称为弧尾(Tail),vj称为弧头(Head)。

数据结构和算法——学习笔记(C语言版)_第92张图片

如果图中任意两个顶点之间的边都是有向边,则称该图为有向图(Directed graphs)。连接顶点A到D的有向边就是弧,A是弧尾,D是弧头,表示弧,注意不能写成

对于图7-2-3中的有向图G2来说,G2=(V2,{E2}),其中顶点集合V2={A,B,C,D};弧集合E2={,,,}。

无向边用小括号“()”表示,而有向边则是用尖括号“<>”表示。

数据结构和算法——学习笔记(C语言版)_第93张图片

在图中,若不存在顶点到其自身的边,且同一条边不重复出现,则称这样的图为简单图。数据结构中讨论的都是简单图。图7-2-4中的两个图就不是简单图。

数据结构和算法——学习笔记(C语言版)_第94张图片

在无向图中,如果任意两个顶点之间都存在边,则称该图为无向完全图。含有n个顶点的无向完全图有(n×(n-1))/2条边。

数据结构和算法——学习笔记(C语言版)_第95张图片

在有向图中,如果任意两个顶点之间都存在方向互为相反的两条弧,则称改图为有向完全图。含有n个顶点的有向完全图有n×(n-1)条边。

对于具有n个顶点和e条边数的图,无向图0<=e<=(n×(n-1))/2,有向图0<=e<=n×(n-1)。

有很少边或弧的图称为稀疏图,反之称为稠密图。

数据结构和算法——学习笔记(C语言版)_第96张图片

与图的边或弧相关的数叫做权。带权的图通常称为网。

数据结构和算法——学习笔记(C语言版)_第97张图片

假设有两个图G=(V,{E})和G’=(V’,{E’}),如果V’是V的子集且E’是E的子集,则称G’为G的子图。

2、图的顶点与边间关系

对于无向图G=(V,{E}),如果边(v,v’)∈E,则称顶点v和v’互为邻接点,即v和v’相邻接。

边(v,v’)依附于顶点v和v’,或者说(v,v’)与顶点v和v’相关联。

顶点v的度适合v相关联的边的数目。即为TD(v)。

对于有向图G=(V,{E}),如果弧∈E,则称顶点v邻接到顶点v’,顶点v’邻接自顶点v。弧和顶点v,v’相关联。

以顶点v为头的弧的数目称为v的入度,记为ID(v);以顶点v为尾的数目称为v的出度,记为OD(v);顶点v的度TD(v)=ID(v)+OD(v)。

路径的长度是路径上的边或弧的数目。

第一个顶点到最后一个顶点相同的路径称为回路或环。

序列中顶点不重复出现的路径称为简单路径。

除了第一个顶点和最后一个顶点之外,其余顶点不重复出现的回路,称为简单回路或简单环。

数据结构和算法——学习笔记(C语言版)_第98张图片

左侧的环因第一个结点和最后一个顶点都是B,且C,D,A,没有重复出现,因此是一个简单环。

右侧的环,由于顶点C的重复,它就不是简单环了。

3、连通图相关术语·

在无向图G中,如果从顶点v到顶点v’有路径,则称v和v’是连通的。如果对于图中任意两个顶点vi、vj∈E,vi和vj是都是连通的。则称G是连通图。

数据结构和算法——学习笔记(C语言版)_第99张图片

图1中,顶点A与顶点E或F无路径,不能算是连通图。图2中,顶点A、B、C、D相互都是连通的,所以它是连通图。

无向图中的极大连通子图称为连通分量。它强调:

  • 要是子图;

  • 子图要是连通的

  • 连通子图含有极大顶点数

  • 就有极大顶点数的连通子图包含依附与这些顶点的所有边。

数据结构和算法——学习笔记(C语言版)_第100张图片

图1是一个无向非连通图。但是它有两个连通,即分量图2和图3。而图4,尽管是图1的子图,但是它却不满足连通子图的极大顶点数,因此它不是图1的无向图的连通分量。

在有向图G中,如果对于每一对vi,vj∈V、vi≠vj,从vi到vj和从vj到vi都存在路径,则称G是强连通图。

有向图中的极大强连通子图称作有向图的强连通分量。

数据结构和算法——学习笔记(C语言版)_第101张图片

图1不是强连通图,因为顶点A到顶点D存在路径,而D到A不存在路径。图2是强连通图,而且图2是图1的极大强连通子图,即它是强连通分量。

一个连通图的生成树是一个极小的连通子图,它含有图中全部的n个顶点,但只有足以构成一棵树的n-1条边。

数据结构和算法——学习笔记(C语言版)_第102张图片

图1是一个普通图不是生成树,图2和图3,就满足n个顶点n-1条边且连通的定义,都是一棵生成树。

如果一个图有n个顶点和小于n-1条边,则是非连通图。如果它多于n-1条边,必定构成一个环。

不过有n-1条边并不一定是生成树,比如图4。

如果一个有向图恰好有一个顶点的入度为0,其余顶点的入度均为1,则是一棵有向树。入度为0其实就相当于树中的根结点,其余顶点入度为1就是说树的非根结点的双亲只有一个。

一个有向图的生成森林有若干棵有向树组成1,含有图中全部顶点,但只有足以构成若干棵不相交的有向树的弧。

数据结构和算法——学习笔记(C语言版)_第103张图片

图1是一棵有向图,去掉一些弧后,它可以分解为两棵有向树,如图2和图3,这两根就是图1有向图的生成森林。

4、图的定义与术语总结

图按照有无方向分为无向图和有向图,无向图由顶点和边构成,有向图有顶点和弧构成。弧有弧尾和弧头之分。

图按照边或弧的多少分为稀疏图和稠密图。如果任意两个顶点之间都存在边叫完全图,有向的叫有向完全图。若无重复的边或顶点到自身的边则叫简单图。

图中顶点之间有邻接点,依附的概念。无向图顶点的边数叫做度,有向图顶点分为入度和出度。

图上的边或弧上带权则称为网。

图中顶点间存在路径,两顶点存在路径则说明是连通的,如果路径最终回到起始点则称为环,当中不重复叫简单路径。若任意两顶点都是连通的,则图就是连通图,有向图则称强连通图。图中有子图,若子图极大连通则就是连通分量,有向的则称强连通分量。

无向图中连通且n个顶点n-1条边叫生成树。有向图中一顶点入度为0其余顶点入度为1的叫有向树。一个有向图由若干棵有向树构成生成森林。

7.2 图的抽象数据类型

ADT (Graph)
Data
	顶点的有穷非空集合和边的集合。
Operation
	CreateGraph(*G,V,VR):按照顶点集V和弧集VR的定义构造图GDestroyGraph(*G):图G存在则销毁。
	LocateVex(G,u):若图G中存在顶点u,则返回图中的位置。
	GetVex(G,v):返回图G中顶点v的值。
	PutVex(G,v,value):将图G顶点v赋值value。
	FirstAdjVex(G,*v):返回顶点v的一个邻接顶点,若顶点G中无邻接顶点返回空。
	NextAdjVex(G,v,*w):返回顶点v的相对于顶点w的下一个相邻结点,若w是v的最后一个邻接点则返回“空”。
	InsertVex(*G,v):在图G中增添新顶点v。
	DeleteVex(*G,v):删除图G中顶点v及相关的弧。
	InsertArc(*G,v,w):在图G中增添弧<v,w>,G是无向图,还需要增添对称弧<w,v>DeleteArc(*G,v,w):在图G中删除弧<v,w>,G是无向图,还需要删除对称弧<w,v>DFSTraverse(G):对图G中进行深度优先遍历,在遍历过程中对每个顶点调用。
	HFSTraverse(G):对图G中进行广度优先遍历,在遍历过程中对每个顶点调用。
endADT

7.3 图的存储结构

1、邻接矩阵

图的邻接矩阵存储方式是用两个数组来表示图。一个以为数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。

数据结构和算法——学习笔记(C语言版)_第104张图片

对称矩阵:n阶矩阵的元满足aij=aji(0<=i,j<=n)。即从矩阵的左上角到右下角的主对角线为轴,右上角的元素与左下角相对应的元全都是相等的。

数据结构和算法——学习笔记(C语言版)_第105张图片

顶点v1的入度为1,正好是第v1列的各数之和。顶点v1的出度为2,即第v1行的各数之和。

判断顶点vij是否存在弧,只需要查找矩阵中arc[i][j]是否为1即可。

要求vi的所有邻接点就是将矩阵i行元素扫描一遍,查找arc[i][j]为1的顶点。

每条边上带有权的图叫做网。

数据结构和算法——学习笔记(C语言版)_第106张图片

图的邻接矩阵存储的结构,代码如下:

typedef char VertexType	/*顶点类型应由用户定义*/
typedef int EdgeType	/*边上的权值类型应由用户定义*/
#define MAXVEX 100	/*最大顶点数,应由用户定义*/
#define INFINITY 65535	/*用65535来代表∞*/
typedef struct
{
     
	VertexType vexs[MAXVEX];	/*顶点表*/
	EdgeType arc[MAXVEX][MAXVEX]	/*邻接矩阵,可看作边表*/
	int numVertexes, numEdges;	/*图中当前的顶点数和边数*/
}MGragh;

无向网图的创建代码:

void CreateMGraph(MGraph *G)
{
     
	int i,j,k,w;
	printf("输入顶点数和边数:\n");
	scanf("%d,%d",&G->numVertexes,&G->numEdges);	/*输入顶点和边*/
	for( i=0; i<G->numVertexes; i++ )	/*读入顶点信息,建立顶点表*/
		scanf(&G->vexs[i]);
	for( i=0; i<G->numVertexes; i++ )
		for(j=0; i<G->numVertexes; j++ )
			G->arc[i][j]=INFINITY;	/*邻接矩阵的初始化*/
	for( k=0;k<G->numEdges;k++ )	/*读入numEdges条边,建立邻接矩阵*/
	{
     
		printf("输入边(vi,vj)上的下标i,下标j和权w:\n");
		scanf("%d,%d,%d", &i,&j,&w);	/*输入边(vi,vj)上的权w*/
		G->arc[i][j]=w;
		G->arc[j][i]=G->arc[i][j];	/*因为是无向图,矩阵对称*/
	}
}

2、邻接表

数组与链表相结合的存储方法称为邻接表。

邻接表的处理方法

1、图中顶点用一个一维数组存储,当然,顶点也可以用单链表来存储,不过数组可以较容易的读取顶点信息,更加方便。另外,对于顶点数组中,每个数组元素还需要存储指向第一个邻接点的指针,以便利于查找该顶点的边信息。

2、图中每个顶点vi的所有邻接点构成一个线性表,由于邻接点的个数不定,所以用单链表存储,无向图称为顶点vi的边表,有向图则称为顶点vi作为弧尾的出边表。

数据结构和算法——学习笔记(C语言版)_第107张图片

顶点表的各个结点由data和firstedge两个域表示,data是数据域,存储顶点的信息,firstedge是指针域,指向边表的第一个结点,即此结点的第一个邻接点。

边表有节点adjvex和next两个域组成。adjvex是邻接点域,存储某顶点的邻接点在顶点表中的下标,next则存储指向边表中下一个结点的指针。

数据结构和算法——学习笔记(C语言版)_第108张图片

若是有向图,为了便于确定顶点的入度或以顶点为弧头的弧,可以建立一个有向图的逆邻接表,即对每个顶点vi都建立一个连接为vi为弧头的表。

对于带权值的网图,可以在边表结点中在增加一个weight的数据域,存储权值信息即可。

数据结构和算法——学习笔记(C语言版)_第109张图片

typedef char VertexType;	/*顶点类型应由用户定义*/
typedef int EdgeType;	/*边上的权值类型应由用户定义*/

typedef struct EdgeNode	/*边表结点*/
{
     
	int adjvex;	/*邻接点域,存储该顶点对应的下标*/
	EdgeType weight;	/*用于存储权值,对于非网图可以不需要*/
	struct EdgeNode *next;	/*链域,指向下一个邻接点*/
}EdgeNode;

typedef struct VertexNode	/*顶点表结点*/
{
     
	VertexType data;	/*顶点域,存储顶点信息*/
	EdgeNode *firstedge;	/*边表头指针*/
}VertexNode, AdjList[MAXVEX];

typedef struct
{
     
	AdjList adjList;
	int numVertexes,numEdges;	/*图中当前顶点数和边数*/
}GraphAdjList;

无向图的邻接表创建代码如下:

/*建立图的邻接表结构*/
void CreateALGraph(GraphAdjList *G)
{
     
	int i,j,k;
	EdgeNode *e;
	printf("输入顶点数和边数:\n");
	scanf("%d,%d",&G->numVertexes,&G->numEdges);	/输入顶点数和边数*/
	for(i=0; i<G->numVertexes; i++)	/*读入顶点信息,建立顶点表*/
	{
     
		scanf(&G->adjList[i].data);	/*输入顶点信息*/
		G->adjList[i].firstedge=NULL;	/*将边表值为空表*/
	}

	for(k=0;k<G->numEdges;k++)	/*建立边表*/
	{
     
		printf("输入边(vi,vj)上的顶点序号:\n");
		scanf("%d,%d",&i,&j);	/*输入边(vi,vj)上的顶点序号*/
		e=(EdgeNode *)malloc(sizeof(EdgeNode));	/*向内存申请空间,生成边表结点*/
		e->adjvex=j;	/*邻接序号为j*/
		e->next=G->adjList[i].firstedge;	/*将e指针指向当前顶点的结点*/
		G->adjList[i].firstedge=e;	/*将当前顶点的指针指向e*/
		e=(EdgeNode *)malloc(sizeof(EdgeNode));	/*向内存申请空间,生成边表结点*/
		e->adjvex=i;	/*邻接序号为i*/
		e->next=G->adjList[j].firstedge;	/*将e指针指向当前顶点指向的结点*/
		G->adjList[j].firstedge=e;	/*将当前顶点的指针指向e*/	
	}
}

3、十字链表

把邻接表与逆邻接表结合起来,就是十字链表。

顶点表结点结构

在这里插入图片描述

firstin表示入边表头指针,指向该顶点的入边表中的第一个结点,firstout表示出边表头指针,指向该顶点的出边表的第一个结点。

边表结点结构

在这里插入图片描述

tailvex是指弧起点在顶点表的下标,headvex是指终点在顶点表中的下标,headlink是指入边表指针域,指向终点相同的下一条边,taillink是指边表指针域,指向起点相同的下一条边。如果是网,还增加一个weight域来存储权值。

数据结构和算法——学习笔记(C语言版)_第110张图片

十字链表的好处就是因为把邻接表和逆邻接表整合在了一起,这样既容易找到以vi为尾的弧,也容易找到以vi为头的弧,因而容易求得顶点的出度和入度。

4、邻接多重表

边表结点结构

在这里插入图片描述

ivex和jvex是与某条边依附的两个顶点在顶点表中下标。ilink指向依附顶点ivex的下一条边,jlink指向依附顶点jvex的下一条边。

数据结构和算法——学习笔记(C语言版)_第111张图片

数据结构和算法——学习笔记(C语言版)_第112张图片

邻接多重表与邻接表的差别,是在于同一条边在邻接表中用两个结点表示,而在邻接多重表中只有一个结点。这样对边的操作就方便多了,若要删除(v0,v2)这条边,只需要将右图的⑥⑨的链接指向改为^即可。

5、边集数组

边集数组是由两个一维数组构成。一个是存储顶点的信息;另一个是存储边的信息,这边数组每一个数据元素由下一条边的起点下标(begin)、终点下标(end)和(weight)组成。

数据结构和算法——学习笔记(C语言版)_第113张图片

定义的边数组结构

在这里插入图片描述

begin是存储起点下标,end是存储终点下标,weight是存储权值。

7.4 图的遍历

从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就叫做图的遍历。

1、深度优先遍历

深度优先遍历,也有称为深度优先搜索,简称DFS。

数据结构和算法——学习笔记(C语言版)_第114张图片

深度优先遍历其实就是一个递归过程,就像是一棵树的前序遍历。

邻接矩阵,代码如下:

typedef int Boolean;	/*Boolean是布尔类型,其值是TRUE或FALSE*/
Boolean visited[MAX];	/*访问标志的数组*/
/*邻接矩阵的深度优先递归算法*/
void DFS (MGraph G, int i)
{
     
	int j;
	visited[i] = TRUE;
	printf("%c", G.vexs[i]);	/*打印顶点,也可以其他操作*/
	for(j=0; j<G.numVertexes; j++)
		if(G.arc[i][j] == 1 && !visited[j])
			DFS(G, j);	/*对未访问的邻接顶点递归调用*/
}
/*邻接矩阵的深度遍历操作*/
void DFSTraverse (MGraph G)
{
     
	int i;
	for(i=0; i<G.numVertexes; i++)
		visited[i] = FALSE;	/*初始所有顶点状态都是未访问过状态*/
	for(i=0; i<G.numVertexes; i++)
		if(!visited[i])	/*对未访问过的顶点调用DFS,若是连通图,只会执行一次*/
			DFS(G, i); 
}

邻接表,代码如下:

/*邻接表的深度优先遍历算法*/
void DFS(GraphAdjList GL, int i)
{
     
	EdgeNode *p;
	visited[i] = TRUE;
	printf("%c ",GL->adjList[i].data);	/*打印顶点也可其他操作*/
	p = GL->adjList[i].firstedge;
	while(p)
	{
     
		if(!visited[p->adjvex])
			DFS(GL, p->adjvex);	/*对未访问的邻接顶点递归调用*/
	 	p = p->next;
	}
}
/*邻接表的深度遍历操作*/
void DFSTraverse(GraphAdjList GL)
{
     
	int i;
	for(i=0; i<GL->numVertexes; i++)
		visited[i] = FALSE;	/*初始所有顶点状态都是未访问状态*/
	for(i=0; i<GL->numVertexes; i++)
		if(!visited[i])	/*对未访问过的顶点调用DFS,若是连通图,只会执行一次*/
			DFS[G, i];
}

2、广度优先遍历

广度优先遍历,又称为广度优先搜索,简称BFS。

如果说图的深度遍历类似树的前序遍历,那么图的广度优先遍历就类似于树的层序遍历。

数据结构和算法——学习笔记(C语言版)_第115张图片

邻接矩阵,代码如下:

/*邻接矩阵的广度优先遍历算法*/
void BFSTraverse(MGraph G)
{
     
	int i,j;
	Queue Q;
	for(i=0; i<G.numVertexes; i++)
		visited[i] = FALSE;
	InitQueue(&Q);	/*初始化辅助用的队列*/
	for(i=0; i<G.numVertexes; i++)	/*对每一个顶点做循环*/
	{
     
		if(!visited[i])	/*若是未访问过就处理*/
		{
     
			visited[i] = TRUE; 	/*设置当前顶点被访问过*/
			printf("%c ", G.vexs[i]);	/*打印顶点,也可以进行其他操作*/
			EnQueue(&Q,i);	/*将此顶点入队列*/
			while(!QueueEmpty(Q))	/*若当前队列不为空*/
			{
     
				DeQueue(&Q,&i);	/*将队中元素出队列,赋值给i*/
				for(j=0; j<G.numVertexes; j++)
				{
     
					/*判断其他顶点若与当前顶点存在边且未访问过*/
					if(G.arc[i][j] == 1 && !visited[j])
					{
     
						visited[j] = TRUE;	/*将找到的此顶点标记为已访问*/
						printf("%c ", G.vexs[j]);	/*打印顶点*/
						EnQueue(&Q,j);	/*将找到的此顶点入队列*/
					}
				}
			}
		}
	}
		
}

邻接表,代码如下:

/*邻接表的广度优先遍历算法*/
void BFSTraverse(GraphAdjList GL)
{
     
	int i
	EdgeNode *p;
	Queue Q;
	for(i=0; i<GL->numVertexes; i++)
		visited[i] = FALSE;
	InitQueue(&Q);
	for(i=0; i<GL->numVertexes; i++)
	{
     
		if(!visited[i])
		{
     
			visited[i] = TRUE;
			printf("%c ", GL->adjList[i].data);	/*打印顶点,也可以进行其他操作*/
			EnQueue(&Q,i);
			while(!QueueEmpty(Q))
			{
     
				DeQueue(&Q,&i);
				p = GL->adjList[i].firstedge;	/*找到当前顶点边表链表头指针*/
				while(p)
				{
     
					if(!visited[p->adjvex])	/*若此顶点未被访问*/
					{
     
						visited[p->adjvex] = TRUE;
						printf("%c ",GL->adjList[p->adjvex].data);
						EnQueue(&Q,p->adjvex);	/*将此顶点入队列*/
					}
					p  = p->next;	/*指针指向下一个邻接点*/
				}
			}
		}
	}
		
}

7.5 最小生成树

把构造连通网的最小代价生成树称为最小生成树。

1、普利姆(Prim)算法

数据结构和算法——学习笔记(C语言版)_第116张图片

/*Prim算法生成最小生成树*/
void MiniSpanTree_Prim(MGraph G)
{
     
	int min, i, j, k;
	int adjvex[MAXVEX];	/*保存相关顶点下标*/
	int lowcost[MAXVEX];	/*保存相关顶点间边的权值*/
	lowcost[0]=0;	/*初始化第一个权值为0,即V0加入生成树。lowcost的值为0,在这里就是此下标的顶点已经加入生成树*/
	adjvex[0]=0;	/*初始化第一个顶点下标为0*/
	for(i=1; i<G.numVertexes; i++)	/*循环除下标为0外的全部顶点*/
	{
     
		lowcost[i]=G.arc[0][i];	/*将v0顶点与之有边的权值存入数组*/
		adjvex[i]=0;	/*初始化都为v0的下标*/
	}
	for(i=1; i<G.numVertexes; i++)
	{
     
		min=INFINITY;	/*初始化最小权值为∞,通常设置为不可能的大数字如32767,65535等*/
		j=1;k=0;
		while(j<G.numVertexes)	/*循环全部顶点*/
		{
     
			if(lowcost[j]!=0 && lowcost[j]<min)
			{
     
				/*如果权值不为0且权值小于min*/
				min=lowcost[j];	/*则让当前权值成为最小值*/
				k=j;	/*将当前最小值的下标存入k*/
			}
			j++;
		}
		printf("(%d,%d)",adjvex[k],k);	/*打印当前顶点边中权值最小边*/
		lowcost[k]=0;	/*将当前顶点的权值设置为0,表示此顶点已经完成任务*/
		for(j=1; j<G.numVertexes; j++)	/*循环所有顶点*/
		{
     
			if(lowcost[j]!=0 && G.arc[k][j]<lowcost[j])
			{
     
				/*若下标为k顶点各边权值小于此前这些顶点未被加入生成树权值*/
				lowcost[j]=G.arc[k][j];	/*将较小权值存入lowcost*/
				adjvex[j]=k;	/*将下标为k的顶点存入adjvex*/
			}
		}
	}
	
}

2、克鲁斯卡尔(Kruskal)算法

/*对边集数组Edge结构的定义*/
typedef struct
{
     
	int begin;
	int end;
	int weight;
}Edge;

数据结构和算法——学习笔记(C语言版)_第117张图片

/*kruskal算法生成最小生成树*/
void MiniSpanTree_Kruskal(MGraph G)	/*生成最小生成树*/
{
     
	int i, n, m;
	Edge edges[MAXEDGE];	/*定义边集数组*/
	int parent[MAXVEX];	/*定义一数组用来判断边与边是否形成环路*/
	/*此处省略将邻接矩阵G转化为边集数组edges并按权由小到大排序的代码*/
	for(i=0; i<G.numVertexes; i++)
		parent[i]=0;	/*初始化数组值为0*/
	for(i=0; i<G.numEdges; i++)	/*循环每一条边*/
	{
     
		n=Find(parent,edges[i].begin);
		m=Find(parent,edges[i].end);
		if(n!=m)	/*假设n与m不等,说明此边没有与现有生成树形成环路*/
		{
     
			parent[n]=m;	/*将此边的结尾顶点放入下标为起点的parent中,表示此顶点已经在生成树集合中*/
			printf("(%d,%d) %d", edges[i].begin,edges[i].end,edges[i].weight);
		}
	}
}

int Find(int *parent, int f)	/*查找连线顶点的尾部下标*/
{
     
	while(parent[f]>0)
		f=parent[f];
	return f;
}

对比这两个算法,克鲁斯卡尔算法主要针对边来展开,边数少时效率会非常高,所以对于稀疏图有很大的优势;而普里姆算法主要对于稠密图,即使边数非常多的情况会更好一些。

7.6 最短路径

在网图和非网图中,最短路径的含义是不同的。由于非1网图没有边上的权值,所谓的最短路径,其实就是指两顶点之间经过的边数最少的路径;而对于网图来说,最短路径,是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点。就地图来说,距离就是两顶点间的权值之和。而非网图完全可以理解为所有的边的权值都为1的网。

1、迪杰斯特拉(Dijkstra)算法

数据结构和算法——学习笔记(C语言版)_第118张图片

#define MAXVEX 9;
#define INFINITY 65535
typedef int Pathmatirx[MAXVEX];	/*用于存储最短路径下标的数组*/
typedef int ShortPathTable[MAXVEX];	/*用于存储到各点最短路径的权值和*/
/*Dijkstra算法,求有向网G的v0顶点到其余顶点v最短路径P[v]及带权长度D[v]*/
/*P[v]的值为前驱顶点下标,D[v]表示v0到v的最短路径长度和*/
void ShortestPath_Dijkstra(MGraph G, int v0, Pathmatirx *p, ShortPathTable *D)
{
     
	int v,w,k,min;
	int final[MAXVEX];	/*final[w]=1表示求得顶点v0至vw的最短路径*/
	for(v=0; v<G.numVertexes; v++)	/*初始化数据*/
	{
     
		final[v]=0;	/*全部顶点初始化为未知最短路径状态*/
		(*D)[v]=G.matirx[v0][v];	/*将与v0点有连线的顶点加上权值*/
		(*P)[v]=0;	/*初始化路径数组P为0*/
	}
	(*D)[v0]=0;	/*v0至v0路径为0*/
	final[v0]=1;	/*v0至v0不需要求路径*/
	/*开始主循环,每次求得v0到v顶点的最短路径*/
	for(v=1; v<G.numVertexes; v++)
	{
     
		min=INFINITY;	/*当前所知离v0顶点的最近距离*/
		for(w=0; w<G.numVertexes; w++)	/*寻找离v0点最近的顶点*/
		{
     
			if(!final[w] && (*D)[w]<min)
			{
     
				k=w;
				min=(*D)[w];	/*w顶点离v0顶点更近*/
			}
		}
		final[k]=1;	/*将目前找到的最近的顶点置为1*/
		for(w=0; w<G.numVertexes; w++)	/*修正当前最短路径及距离*/
		{
     
			/*如果经过v顶点的路径比现在这条路径的长短短的话*/
			if(!final[w] && (min+G.matirx[k][w]<(*D)[w]))
			{
     
				/*说明找到了更短的路径,修改D[w]和P[w]*/
				(*D)[w] = min + G.matirx[k][w];	/*修改当前路径长度*/
				(*p)[w]=k;
			}
		}
	}
}

2、弗洛伊德(Floyd)算法

typedef int Pathmatirx[MAXVEX][MAXVEX];
typedef int ShortPathTable[MAXVEX][MAXVEX];
/*Floyd算法,求网图G中各顶点v到其余顶点w最短路径P[v][w]及带权长度D[v][w]*/
void ShortestPath_Floyd(MGraph G, Pathmatirx *P, ShortPathTable *D)
{
     
	int v,w,k;
	for(v=0; v<G.numVertexes; ++v)	/*初始化D与P*/
	{
     
		for(w=0; w<G.numVertexes; ++w)
		{
     
			(*D)[v][w]=G.matirx[v][w];	/*D[v][w]值即为对应点间的权值*/
			(*P)[v][w]=w;	/*初始化P*/
		}
	}
	for(k=0; k<G.numVertexes; ++k)
	{
     
		for(v=0; v<G.numVertexes; ++v)
		{
     
			for(w=0; w<G.numVertexes; ++w)
			{
     
				if((*D)[v][w]>(*D)[v][k]+(*D)[k][w])
				{
     
					/*如果经过下标为k顶点路径比原两点间路径更短*/
					/*将当前两点间权值设为更小的一个*/
					(*D)[v][w]=(*D)[v][k]+(*D)[k][w];
					(*P)[v][w]=(*P)[v][k];	/*路径设置经过下标为k的顶点*/
				}
			}
		}
	}
}

求最短路径的显示代码:

for(v=0; v<G.numVertexes; ++v)
{
     
	for(w=v+1; w<G.numVertexes; w++)
	{
     
		printf("v%d-v%d weight: %d ", v,w,D[V][W]);
		k=P[v][w];	/*获得第一个路径顶点下标*/
		printf(" path: %d", v);	/*打印源点*/
		while(k!=w)
		{
     
			printf(" -> %d",k);	/*打印路径顶点*/
			k=P[k][w];	/*获得下一个路径顶点下标*/
		}
		printf(" -> %d\n",w);	/*打印终点*/
	}
	printf("\n");
}

如果你面临需要所以顶点至所有顶点的最短路径问题时,弗洛伊德(Floyd)算法应该是不错的选择。

7.7 拓扑排序

1、拓补排序介绍

在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,我们称为AOV网(Activity On Vertex Network)。AOV网中的弧表示活动之间存在的某种制约关系。AOV网中不能存在回路。

设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列v1,v2,……,vn满足若从顶点vi到vj有一条路径,则在顶点序列中顶点vi必在顶点vj之前,则我们称这样的顶点序列为一个拓扑序列。

拓扑排序,其实就是对一个有向图构造拓扑序列的过程。构造时会有两个结果,如果此网的全部顶点全部被输出,则说明它是不存在环(回路)的AOV网;如果输顶点数少了,哪怕是少了一个,也说明这个网存在环(回路),不是AOV网。

2、拓扑排序算法

对AOV网进行拓扑排序的基本思路是:从AOV网中选择一个入度为0的顶点输出,然后删去此顶点,并删除以此顶点为尾的弧,继续重复此步骤,直到输出全部顶点或AOV网中不存在入度为0的顶点为止。

由于拓扑排序的过程中,需要删除顶点,显然用邻接表会更加方便,因此我们需要为AOV网建立一个邻接表。考虑到算法过程中始终要查找入度为0的顶点,我们在原来顶点表结点结构中,增加一个入度域in,其中in就是入度的数字。

在这里插入图片描述

在这里插入图片描述

数据结构和算法——学习笔记(C语言版)_第119张图片

在拓扑排序算法中,涉及的结构代码如下。

typedef struct EdgeNode	/*边表结点*/
{
     
	int adjvex;	/*邻接点域,存储该顶点对应的下标*/
	int weight;	/*用于存储权值,对于非网图可以不需要*/
	struct EdgeNode *next;	/*链域,指向下一个邻接点*/
}EdgeNode;

typedef struct VertexNode	/*顶点表结点*/
{
     
	int in;	/*顶点入度*/
	int data;	/*顶点域,存储顶点信息*/
	EdgeNode *firstedge;	/*边表头指针*/
}VertexNode,AdjList[MAXVEX];

typedef struct
{
     
	AdjList adjList;
	int numVertexes,numEdges;	/*图中当前顶点数和边数*/
}graphAdjList,*GraphAdjList;
/*拓扑排序,若GL无回路,则输出拓扑排序序列并返回OK,若有回路则返回ERROR*/
Statue TopologicalSort(GraphAdjList GL)
{
     
	EdgeNode *e;
	int i,k,gettop;
	int top=0;	/*用于栈指针下标*/
	int count=0;	/*用于统计输出顶点的个数*/
	int *stack;	/*建栈存储入度为0的顶点*/
	stack=(int *)malloc(GL->numVertexes * sizeof(int));
	for(i=0;i<GL->numVertexes;i++)
		if(GL->adjList[i].in==0)
			stack[++top]=i;	/*将入度为0的顶点入栈*/
	while(top!=0)
	{
     
		gettop=stack[top--];	/*出栈*/
		printf("%d -> ",GL->adjList[gettop].data);	/*打印此顶点*/
		count++;	/*统计输出顶点数*/
		for(e=GL->adjList[gettop].firstedge;e;e=e->next)
		{
     
			/*对此顶点弧表遍历*/
			k=e->adjvex;
			if(!(--GL->adjList[k].in))	/*将k号顶点的邻接点的入度减1*/
				stack[++top]=k;	/*若为0则入栈,以便于下次循环输出*/
		}
	}
	if(count<GL->numVertexes)	/*如果count小于顶点数,说明存在环*/
		return ERROR;
	else
		return OK;
}

7.8 关键路径

拓扑排序主要是为解决一个工程是否顺序进行的问题,但有时我们还需要解决工程完成需要的最短时间问题。我们如果要对一个流程图获得最短时间,就必须要分析它们的拓扑关系,并且找到当中最关键的流程,这个流程的时间就是最短时间。

在表示工程的带权有向图中,用顶点表示时间,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网。我们称之为AOE网(Activity On Edge Network),我们把AOE网中没有入边的顶点称为始点或源点,没有出边的顶点称为终点或汇点。由于一个工程,总有一个开始,一个结束,所以正常情况下,AOE网中只有一个源点一个汇点。

数据结构和算法——学习笔记(C语言版)_第120张图片

如有在某顶点所代表的事件发生后,从该顶点出发的各活动才能开始。只有在进入某顶点的个活动都已经结束,该顶点所代表的事件才能发生。

尽管AOE网与AOV网都是用来对工程进行建模的,它们还是有很大的不同,主要体现在AOV网是顶点表示活动的网,它只描述活动之间的制约关系,而AOE网是用边表示活动的网,边上的权值表示持续的时间,AOE网是要建立在活动之间的制约关系没有矛盾的基础之上,再来分析完成整个工程至少需要多少时间,或者为缩短完成工程所需时间,应当加快哪些活动问题。

数据结构和算法——学习笔记(C语言版)_第121张图片

我们把路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径交关键路径,在关键路径上的活动叫关键活动。显然就图7-9-3的AOE图而言,“开始->发动机完成->部件集中到位->组装完成”就是关键路径,路径长度为5.5。

1、关键路径算法原理

只需要找到所有活动的最早开始时间和最晚开始时间,并且比较它们,如果相等就意味着此活动是关键活动,活动建的路径为关键路径,如果不等,则就不是。

为此我们需要定义如下几个参数。

1、事件的最早发生时间etv(earliest time of vertex):即顶点Vk的最早发生时间。

2、事件的最晚发生时间ltv(latest time of vertex):即顶点Vk的最晚发生时间,也就是每个顶点对应事件最晚需要开始的时间,超出此时间将会延误整个工期。

3、活动的最早开始时间ete(earliest time edge):即弧Vk的最早发生时间。

4、活动的最晚开工时间lte(latest time edge):即弧Vk的最晚发生时间,也就是不推迟工期的最晚开工时间。

我们是由1和2可以求得3和4,然后再根据ete[k]是否与lte[k]相等来判断ak是否为关键活动。

2、关键路径算法

与拓扑排序邻接表结构不同地方在于,这里弧链表增加了weight域,用来存储弧的权值。

数据结构和算法——学习笔记(C语言版)_第122张图片

求事件的最早发生时间etv过程,就是我们从头至尾找出拓扑序列的过程,因此,在求关键路径之前,需要先调用一次拓扑序列算法的代码来计算etv和拓扑序列列表。为此,我们首先在程序开始处声明几个全局变量。


int *etv,*ltv;	/*事件最早发生时间和最迟发生时间数组*/
int *stack2;	/*用于存储拓扑序列的栈*/
int top2;	/*用于stack2的指针*/

其中stack2是用来存储拓扑序列,以便后面求关键路径时使用。

下面是改进过的求拓扑序列的算法。


/*拓扑排序,用于关键路径计算*/
Status TopologicalSort(GraphAdjList GL)
{
     
	EdgeNode *e;
	int i,k,gettop;
	int top=0;	/*用于栈指针下标*/
	int count=0;	/*用于统计输出顶点的个数*/
	int *stack;	/*建栈将入度为0的顶点入栈*/
	stack=(int *)malloc(GL->numVertexes * sizeof(int));
	for(i=0;i<GL->numVetexes;i++)
		if(0==GL->adjList[i].in)
			stack[++top]=i;
	top2=0;	/*初始化为0*/
	etv=(int *)mal1oc(GL->numVertexes*sizeof (int));/*事件最早发生时间*/
	for (i=0; i<GL->numvertexes; i++)
		etv[i]=0;/*初始化为0*/
	stack2=(int *)malloc(GL->numVertexes*sizeof(int));/*初始化*/
	while (top!=0)
	{
     
		gettop=stack[top--];
		count++;
		stack2[++top2]=gettop;/*将弹出的顶点序号压入拓扑序列的栈*/
		for(e=GL->adjList[gettop].firstedge;e;e=e->next)
		{
     
			k-e->adjvex;
			if(!(--GL->adjList[k] .in))
				stack[++top]=k;
			if((etv[gettop]+e->weight)>etv[k])/*求各顶点事件最早发生时间值*/
				etv[k]=etv[gettop]+e->weight;
		}
	}
	if (count<GL->numvertexes)
		return ERROR;
	else
   		return OK;
	
}


/*求关键路径,GL为有向图,输出GL的各项关键活动*/
void CriticalPath(GraphAdjList GL)
{
     
	EdgeNode *e;
	int i,gettop,k,j;
	int ete,lte;	/*声明活动最早发生时间和最迟发生时间变量*/
	TopologicalSort(GL);	/*求拓扑序列,计算数组etv和stack2的值*/
	ltv=(int *)malloc(GL->numVertexes*sizeof(int))	/*事件最晚发生时间*/
	for(i=0;i<GL->numVertexes;i++)
		ltv[i]=etv[GL->numvertexes-1];	/*初始化ltv*/
	while(top2!=0)
	{
     
		gettop=stack2[top2--];	/*将拓扑序列出栈,后进先出*/
		for(e=GL->adjList[gettop].firstedge;e;e=e->next)
		{
     
			/*求各顶点时间的最迟发生时间ltv的值*/
			k=e->adjvex;
			if(ltv[k]-e->weight<ltv[gettop])	/*求各顶点事件最晚发生时间ltv*/
			ltv[gettop]=ltv[k]-e->weight;
		}
	}
	for(j=0;j<GL->numVertexes;j++)	/*求ete,lte和关键活动*/
	{
     
		for(e=GL->adjList[j].firstedge;e;e=e->next)
		{
     
			k=e->adjvex;
			ete=etv[j];	/*活动最早发生时间*/
			lte=ltv[k]-e->weight;	/*活动最迟发生时间*/
			if(ete==lte)
				printf(" length: %d ",GL->adjList[j].data,GL->adjList[k].data,e->weight);
		}
	}
}

7.9 总结回顾

图的存储结构,其中比较重要的是邻接矩阵和邻接表,它们分别代表着边集是用数组还是链表的方式存储。十字链表是邻接矩阵的一种升级,而邻接多重表则是邻接表的升级。边集数组更多考虑的是对边的关注。用什么存储结构需要具体问题具体分析,通常稠密图,或读存数据较多,结构修改较少的图,用邻接矩阵要更合适,反之则应该考虑邻接表。

数据结构和算法——学习笔记(C语言版)_第123张图片

图的遍历分为深度和广度两种,各有优缺点。

图的应用一共谈了三种应用:最小生成树、最短路径和有向无环图的应用。

最小生成树,两种算法:普拉姆算法和克鲁斯卡尔算法。普里姆算法像是走一步看一步的思维方式,逐步生成最小生成树。而克鲁斯卡尔算法则更有全局意识,直接从图中最短权值的边入手,找寻最后的答案。

最短路径的现实应用非常多,两种算法。迪杰斯特拉(Dijkstra)算法更强调单源顶点查找路径的方式,比较符合我们正常的思路,容易理解原理,但算法代码相对复杂。而弗洛伊德 (Fbyd)算法则完全抛开了单点的局限思维方式,巧妙地应用矩阵的变换,用最清爽的代码实现了多顶点间最短路径求解的方案,原理理解有难度,但算法编写很简洁。

有向无环图时常应用于工程规划中,对于整个工程或系统来说,我们一方面关心的是工程能否顺利进行的问题,通过拓扑排序的方式,我们可以有效地分析出一个有向图是否存在环,如果不存在,那它的拓扑序列是什么?另一方面关心的是整个工程完成所必须的最短时间问题,利用求关键路径的算法,可以得到最短完成工程的工期以及关键的活动有哪些。

第8章 查找

8.1 查找概论

查找表是由同一类型的数据元素(或记录)构成的集合。

关键字是数据元素中某个数据项的值,又称为键值,用它可以标识一个数据元素。也可以标识一个记录的某个数据项(字段),我们称为关键码。

若此关键字可以唯一的标识一个记录,则称此关键字为主关键字。对不同的记录,其主关键字均不相同。主关键字所在的数据项称为主关键码。

对于那些可以识别多个数据元素(或记录)的关键字,我们称为次关键字。次关键字也可以理解为是不以唯一标识一个数据元素(或记录)的关键字,它对应的数据项就是次关键码。

查找就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。

查找表按照操作方式来分有两大种:静态查找表和动态查找表。

静态查找表:只作查找操作的查找表,它的主要操作有:

(1) 查询某个“特定的”数据元素是否在查找表中。

(2) 检索某个“特定的”数据元素和各种属性。

动态查找表:在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。动态查找表的操作就是两个:

(1) 查找时插入数据元素。

(2) 查找时删除数据元素。

8.2 顺序表查找

针对线性表进行查找操作就是静态查找表。

顺序查找又叫线性查找,是最基本的查找技术,它的查找过程是:从表中第一个(或者最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果知道最后一个(或第一个)记录,其关键字和给定值比较都不等时,则表中没有所查的记录,查找不成功。

1、顺序查找算法

顺序查找的算法实现


/*顺序查找,a为数组,n为要查找的数组个数,key为要查找的关键字*/
int Sequential_Search(int *a,int n,int key)
{
     
	int i;
	for(i=1;i<=n;i++)
	{
     
		if(a[i]==key)
			return i;
	}
	return 0;
}

2、顺序表查找优化


/*有哨兵顺序查找*/
int Sequential_Search2(int *a,int n,int key)
{
     
	int i;
	a[0]=key;	/*设置a[0]为关键字值,我们称之为“哨兵”*/
	i=n;	/*循环从数组尾部开始*/
	while(a[i]!=key)
	{
     
		i--;
	}
	return i;	/*返回0则说明查找失败*/
}

8.3 有序表查找

1、折半查找

我们把这种每次取中间记录查找的方法叫做折半查找
数据结构和算法——学习笔记(C语言版)_第124张图片

折半查找技术,又称为二分查找。它的前提是线性表中的记录必须是关键码有序(通常是从小到大有序),线性表必须采用顺序存储。折半查找的基本思想是:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键词,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止。

/*折半查找*/
int Binary_Search(int *a,int n,int key)
{
     
	int low,high,mid;
	low=1;	/*定义最低下标为记录首位*/
	high=n;	/*定义最高下标为记录末位*/
	while(low<=high)
	{
     
		mid=(low+high)/2;	/*折半*/
		if(key<a[mid])	/*若查找值比中值小*/
			high=mid-1;	/*最高下标调整到中位下标小一位*/
		else if(key>a[mid])	/*若查找值比中值大*/
			low=mid+1;	/*最低下标调整到中位下标大一位*/
		else
			return mid;	/*若相等说明mid即为查找到的位置*/
	}
	return 0;
}

折半查找的时间复杂度为O(logn),不过折半查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不再变化,这样的算法已经比较好了,但对于需要频繁执行插入或删除操作的数据集来说,维护有序的排序会带来不小的工作量,不建议使用。

2、插值查找


/*插值查找*/
int Interpolation_Search(int *a,int n,int key)
{
     
	int low,high,mid;
	low=1;
	high=n;
	while(low<=high)
	{
     
		mid=low+ (high-low) * (key-a[low]) / (a[high]-a[low]);	/*插值*/
		if(key<a[mid])
			high=mid-1;
		else if(key>a[mid])
			low=mid+1;
		else
			return mid;
	}
	return 0;
}

插值查找是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式(key-a[low])/(a[high]-a[low]),时间复杂度是O(logn),但对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好得多。

3、斐波那契查找

斐波那契查找,它是利用了黄金分割原理来实现的。

数据结构和算法——学习笔记(C语言版)_第125张图片


/*斐波那契查找*/
int Fibonacci_Search(int *a,int n,int key)
{
     
	int low,high,mid,i,k;
	low=1;	/*定义最低下标为记录首位*/
	high=n;	/*定义最高下标为记录末位*/
	k=0;
	while(n>F[k]-1)	/*计算n位于斐波那契数列的位置*/
		k++;
	for(i=n;i<F[k]-1;i++)	/*将不满的数值补全*/
		a[i]=a[n];

	while(low<high)
	{
     
		mid=low+F[k-1]-1;	/*计算当前分隔的下标*/
		if(key<a[mid])	/*若查找记录小于当前分隔记录*/
		{
     
			high=mid-1;	/*最高下标调整到分隔下标mid-1处*/
			k=k-1;	/*斐波那契数列下标减一位*/
		}
		else if(key>a[mid])	/*若查找记录大于当前分隔记录*/
		{
     
			low=mid+1;	/*最低下标调整到分隔下标mid+1处*/
			k=k-2;	/*斐波那契数列下标减两位*/
		}
		else
		{
     
			if(mid<=n)
				return mid;	/*若相等则说明mid即为查找到的位置*/
			else
				return n;	/*若mid>n说明是补全数值,返回n*/
		}
	}
	return 0;
}

斐波那契查找算法的核心在于:

(1) 当key=a[mid]时,查找就成功;

(2) 当key

(3) 当key>a[mid]时,新范围是第m+1到第high个,此时范围个数为F[k-2]-1个。

数据结构和算法——学习笔记(C语言版)_第126张图片

8.4 线性索引查找

数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构。

索引就是把一个关键字与它对应的记录相关联的过程,一个索引由若干个索引项构成,每个索引项至少应包含关键词和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。

索引按照结构可以分为线性索引、树形索引和多级索引。

所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。

1、稠密索引

稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项。

数据结构和算法——学习笔记(C语言版)_第127张图片

对于稠密索引这个索引表来说,索引项一定是按照关键码有序的排列。

2、分块索引

稠密索引因为所影响与数据集的记录个数相同,所以代价空间很大。为了减少索引项的个数,我们可以对数据集进行分块,使其分块有序,然后再对每一块建立一个索引项,从而减少索引项个数。

分块有序,是把数据集的记录分成了若干块,并且这些块需满足两个条件:

(1) 块内无序:即每一块内的记录不要求有序。

(2) 块间有序:只有块间有序,才有可能在查找是带来效率。

对于分块有序的数据集,将每块对应的一个索引项,这种索引方法叫做分块索引。我们定义的分块索引的索引项结构分三个数据项:

  • 最大关键码,它存储每一块中的最大关键字,这样的好处就是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字要大;

  • 存储了块中的记录个数,以便于循环时使用;

  • 用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历。

数据结构和算法——学习笔记(C语言版)_第128张图片

在分块索引表中查找,就是分两步进行:

(1) 在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的,因此很容易利用折半、插值等算法找到结果。

(2) 根据块首指针找到对应的块,并在块中顺序查找关键码。因为块中可以是无序的,因此只能顺序查找。

3、倒排索引

在这里插入图片描述

数据结构和算法——学习笔记(C语言版)_第129张图片

如果在搜索框中填写“book”关键字。系统就先在这张单词表中有序查找“book”,找到后将它对应的文章编号1和2的文章地址返回,并告诉你,查到了两条记录,用时0.0001秒。由于单词是有序的,查找效率很高,返回的又只是文章的编号,所以整体速度都非常快。

在这张单词表就是索引表,索引项的通用结构是:

  • 次关键码,例如上面的“英文单词”;

  • 记录号表,例如上面的“文章编号”。

其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。这样的索引方法就是倒排索引。倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录。这种索引的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。

8.5 二叉排序树

二叉排序树,又称为二叉查找树,它或者是一棵空树,或者是具有下列性质的二叉树。

  • 若它的左子树不空,则左子树上的所有的结点的值均小于它的根结点的值;

  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;

  • 它的左、右子树也分别为二叉排序树。

构造一棵二叉排序树的目的,其实并不是为了排序,而是为了提高查找和插入删除关键字的速度。

1、二叉排序树查找操作


/*二叉树的二叉链表结点结构定义*/
typedef struct BiTNode	/*结点结构*/
{
     
	int data;	/*结点数据*/
	struct BiTNode *lchild,rchild;	/*左右孩子指针*/
}BiTNode,*BiTree;


/*递归查找二叉排序树T中是否存在key*/
/*指针f指向T的双亲,其初始调用值为NULL*/
/*若查找成功,则指针p指向该数据元素结点,并返回TRUE*/
/*否则指针p指向查找路径上访问的最后一个结点并返回FALSE*/
Status SearchBST(BiTree T, int key, BiTree f, BiTree *p)
{
     
	if(!T)	/*查找不成功*/
	{
     
		*p=f;
		return FALSE;
	}
	else if (key==T->data)	/*查找成功*/
	{
     
		*p=T;
		return TRUE;
	}
	else if(key<T->data)
		return SearchBST(T->lchild,key,T,p);	/*在左子树继续查找*/
	else
		return SearchBST(T->rchild,key,T,p);	/*在右子树继续查找*/
}

2、二叉排序树的插入操作


/*当二叉排序树T中不存在关键字等于key的数据元素时*/
/*插入key并返回TRUE,否则返回false*/
Status InsertBST(BiTree *T,int key)
{
     
	BiTree p,s;
	if(!SearchBST(*T,key,NULL,&P))	/*查找不成功*/
	{
     
		s=(BiTree)malloc(sizeof(BiTNode));
		s->data=key;
		s->lchild=s->rchild=NULL;
		if(!p)
			*T=s;	/*插入s为新的根结点*/
		else if(key<p->data)
			p->lchild=s;	/*插入s为左孩子*/
		else
			p->rchild=s;	/*插入s为右孩子*/
		return TRUE;
	}
	else
		return FALSE;	/*树中已有关键字相同的结点,不在插入*/
}

构建一棵二叉排序树


int i;
int a[10]={
     62,88,58,47,35,73,51,99,37,93};
BiTree T=NULL;
for(i=0;i<10;i++)
{
     
	InsertBST(&T,a[i]);
}

3、二叉排序树删除操作

根据对删除结点三种情况的分析:

  • 叶子结点

  • 仅有左或右子树的结点

  • 左右子树都有的结点

递归方式对二叉排序树T查找key,查找到时删除。


/*若二叉排序树T中存在关键字等于key的数据元素时,则删除该数据元素结点*/
/*并返回TRUE;否则返回FALSE*/
Statue DeleteBST(BiTree *T,int key)
{
     
	if(!*T)	/*不存在关键字等于key的数据元素*/
		return FALSE;
	else
	{
     
		if(key==(*T)->data)	/*找到关键字等于key的数据元素*/
			return Delete(T);
		else if(key<(*T)->data)
			return DeleteBST(&(*T)->lchild,key);
		else
			return DeleteBST(&(*T)->rchild,key);
	}
}


/*从二叉排序树中删除结点p,并重接它的左或右子树*/
Status Delete(BiTree *p)
{
     
	BiTree q,s;
	if((*p)->rchild==NULL)	/*右子树空则只需要重接它的左子树*/
	{
     
		q=*p;
		*p=(*p)->lchild;
		free(q);
	}
	else if((*p)->lchild==NULL)	/*只需重接它的右子树*/
	{
     
		q=*p;
		*p=(*p)=*rchild;
		free(q);
	}
	else	/*左右子树均不为空*/
	{
     
		q=*p;
		s=(*p)->lchild;
		while(s->rchild)	/*转左,然后向右到尽头(找待删结点的前驱)*/
		{
     
			q=s;
			s=s->rchild;
		}
		(*p)->data=s->data;	/*s指向被删结点的直接前驱*/
		if(q!=p)
			q->rchild=s->lchild;	/*重接q的右子树*/
		else
			q->lchild=s->lchild;	/*重接q的左子树*/
		free(s);
	}
	reutrn TRUE;
}

8.6 平衡二叉树(AVL树)

平衡二叉树,是一种二叉排序树,其中每一个结点的左子树和右子树的高度差至多等于1。是一种高度平衡的二叉排序树,要么它是一棵空树,要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。

将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF,平衡二叉树上所有结点的平衡因子只可能是-1,0,1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。

数据结构和算法——学习笔记(C语言版)_第130张图片

图2的59比58大,却是58的左子树;

图3结点58的左子树高度为2,而右子树为空,二者差大于了绝对值1;

距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,我们称为最小不平衡子树。

数据结构和算法——学习笔记(C语言版)_第131张图片

当插入新结点37时,距离它最近的平衡因子绝对值超过1的结点是58(即它的左子树高度2减去右子树的高度0),所以58开始以下的子树为最小不平衡树。

1、平衡二叉树实现原理

平衡二叉树构建的基本思想就是在构建二叉排序树的过程中,每当插入一个结点,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,是之称为新的平衡二叉树。

假设现在有一个数组a[10]={3,2,1,4,5,6,7,10,9,8}需要构建二叉排序树。

数据结构和算法——学习笔记(C语言版)_第132张图片

数据结构和算法——学习笔记(C语言版)_第133张图片

数据结构和算法——学习笔记(C语言版)_第134张图片

数据结构和算法——学习笔记(C语言版)_第135张图片

数据结构和算法——学习笔记(C语言版)_第136张图片

数据结构和算法——学习笔记(C语言版)_第137张图片

2、平衡二叉树实现算法

/*二叉树的二叉链表结点结构定义*/
typedef struct BiTNode	/*结点结构*/
{
     
	int data;	/*结点数据*/
	int bf;	/*结点的平衡因子*/
	struct BiTNode *lchild,*rchild	/*左右孩子指针*/
}BiTNode,*BiTree;
/*对以p为根的二叉排序树作右旋处理*/
/*处理之后p指向新的树根结点,即旋转处理之前的左子树的根结点*/
void R_Rotate(BiTree *p)
{
     
	BiTree L;
	L=(*p)->lchild;	/*L指向p的左子树根结点*/
	(*p)->lchild=L->rchild;	/*L的右子树挂接为p的左子树*/
	L->rchild=(*p);
	*p=L;	/*p指向新的根结点*/
}

数据结构和算法——学习笔记(C语言版)_第138张图片

/*对以p为根的二叉排序树作左旋处理*/
/*处理之后p指向新的树根结点,即旋转处理之前的右子树的根结点*/
void L_Rotate(BiTree *p)
{
     
	BiTree R;
	R=(*p)->rchild;	/*L指向p的右子树根结点*/
	(*p)->rchild=R->lchild;	/*L的左子树挂接为p的右子树*/
	R->lchild=(*p);
	*p=R;	/*p指向新的根结点*/
}
//左平衡旋转处理的函数代码

数据结构和算法——学习笔记(C语言版)_第139张图片

//右平衡旋转处理的函数代码
//主函数

当需要查找的集合本身没有顺序,在频繁查找的同时也需要经常插入和删除操作,显然我们需要构建一棵二叉排序树,但是不平衡的二叉排序树,查找效率是非常低的,因此需要在构建时,就让这棵二叉排序树是平衡二叉树,此时查找时间复杂度为O(logn),而插入和删除也为O(logn)。

8.7 多路查找树(B树)

多路查找树,其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。

1、2-3树

2-3树是这样的一棵多路查找树:其中的每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)。

一个2结点包含一个元素和两个孩子(或没有孩子),且与二叉树排序树类似,左子树包含的元素小于该元素,右子树包含的元素大于该元素。不过,与二叉排序树不同的是,这个2结点要么没有孩子,要么就有两个,不能只有一个孩子。

一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个3结点要么没有孩子,要么具有3个孩子。如果某个3结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。

数据结构和算法——学习笔记(C语言版)_第140张图片

2-3树的插入实现

2-3树的删除实现

2、2-3-4树

2-3-4树包括了4结点的使用。一个4结点包含小中大三个元素和四个孩子(或没有孩子),一个4结点要么没有孩子,要么具有4个孩子。如果某个4结点有孩子的话,左子树包含小于最小元素的元素;第二子树包含大于最小元素,小于第二元素的元素;第三子树包含大于第二元素,小于最大元素的元素;右子树包含大于最大元素的元素。

3、B树

B树是一种平衡的多路查找树,2-3树和2-3-4树都是B树的特例。结点最大的孩子数目称为B树的阶。2-3树是3阶的B树,2-3-4树是4阶B树。

4、B+树

8.8 散列表查找(哈希表)概述

1、散列表查找定义

散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。查找是,根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定在f(key)的位置上。

把这种对应关系f称为散列函数,又称哈希(Hash)函数。采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表。关键字对应的记录存储位置称为散列地址。

2、散列表查找步骤

(1)在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。

(2)在查找记录时,通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。由于存取用的是同一个散列函数,因此结果也是相同的。

散列技术既是一种存储方法,也是一种查找方法。散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联,因此,散列主要是面向查找的存储结构。

散列技术最适合的求解问题是查找与给定值相等的记录。

两个关键字key1≠key2,但却有f(key1)=f(key2),这种现象称为冲突,并把key1和key2称为这个散列函数的同义词。

8.9 散列函数的构造方法

好的散列函数:1.计算简单;2.散列地址分布均匀。

1、直接定址法

2、数字分析法

3、平方取中法

4、折叠法

5、除留余数法

6、随机数法

8.10 处理散列冲突的方法

1、开放定址法

开放定址法就是一旦反生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

fi(key)=(f(key)+di) MOD m (di=1,2,3……,m-1)

例如关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},表长为12,用散列函数f(key)=key mod 12。

当计算前5个数时,都是没有冲突的散列地址,直接存入

在这里插入图片描述

计算key=37时,发现f(37)=1,此时应用公式f(37)=(f(37)+1) mod 12=2,于是将37存入下标为2的位置。

在这里插入图片描述

到了key=48,计算得到f(48)=0,与12所在的0位置冲突了,f(48)=(f(48)+1) mod 12=1,此时又与25所在的位置冲突了。于是f(48)=(f(48)+2) mod 12=2,还是冲突……直到f(48)=(f(48)+6) mod 12=6时,才有空位,于是见48存入下标为6的位置。

把这种解决冲突的开放定址法成为线性探测法。在解决冲突的时候,碰到如48和37这种本来不是同义词却需要争夺一个地址的情况,称这种现象为堆积。

另外增加平方运算的目的是为了不让关键字都聚集在某一块区域。称这一种方法为二次探测法。

fi(key)=(f(key)+di) MOD m (di=12,-12,22,-22,……,q2,-q2,q<=m/2)

还有一种方法是,在冲突时,对于位移量di采用随机函数计算得到,称之为随机探测法。

fi(key)=(f(key)+di) MOD m (di是一个随机数列)

开放定址法只要在散列表未填满时,总是能找到不发生冲突的地址。

2、再散列函数法

对于散列表来说,事先准备多个散列函数

fi(key)=RHi(key) (i=1,2,3,……,k)

这里RHi就是不同的散列函数。每当发生散列地址冲突时,就换一个散列函数计算。

3、链地址法

将所有关键字为同义词的记录存储在一个单链表中,称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。

数据结构和算法——学习笔记(C语言版)_第141张图片

链地址法对于可能造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗。

4、公共溢出区法

为所有冲突的关键字建立一个公共的溢出区来存放。

数据结构和算法——学习笔记(C语言版)_第142张图片

在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行对比,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。

8.11 散列表查找实现

1、散列表查找算法实现




2、散列标查找性能分析

散列查找的平均查找长度取决于:

(1)散列函数是否均匀

(2)处理冲突的方法

(3)散列表的装填因子

第9章 排序

9.1 排序的基本概念与分类

假设含有n个记录的序列为{r1,r2,r3,……,rn}, 其相应的关键字分别为{k1,k2,……,kn},需确定1,2,……,n的一种序列p1,p2,……,pn,使其相应的关键字满足kp1<=kp2<=……<=kpn(非递减或非递增)关系,即使得序列成为一个按关键字有序的序列{rp1,rp2,……,rpn},这样的操作就称为排序。

在排序问题中,通常将数据元素称为记录。输入的是一个记录集合,输出的也是一个记录集合,所以说,可以将排序看成是线性表的一种操作。

1、排序的稳定性

由排序不仅是针对主关键字,那么对于次关键字,因为待排序的记录序列中可能存在两个或两个以上的关键字相等的记录,排序结果可能会存在不唯一的情况。

假设ki=kj (1<=i<=n,1<=j<=n,i≠j),且在排序前的序列中ri领先于rj (ii仍领先于rj,则称所用的排序方法时稳定的;反之,若可能使得排序后的序列中rj领先于ri,则称所用的排序方法是不稳定的。

2、内排序与外排序

根据在排序过程中待排序的记录是否全部被放置在内存中,排序分为:内排序和外排序。

内排序是在排序的整个过程中,待排序的所有记录全部被放置在内存中。外排序是由于排序的记录个数太多,不能同时防止在内存,整个排序过程需要在内外存之间多次交换数据才能进行。

对于内排序来说,排序算法的性能主要是受3个方面的形象:

(1)时间性能

在内排序中,主要进行两种操作:比较和移动。比较指关键字之间的比较,这是排序最起码的操作。移动指记录从一个位置到另一个位置,移动可以通过改变记录的存储方式来予以避免。高效率的内排序算法应该是具有尽可能少的关键子比较次数和尽可能少的记录移动次数。

(2)辅助空间

辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间。

(3)算法的复杂性

这里指的是算法本身的复杂度,而不是算法的时间复杂度。

根据排序过程中借助的主要操作,把内排序分为:插入排序、交换排序、选择排序和归并排序。

七种排序算法,按照算法的复杂度分为两大类,冒泡排序、简单选择排序和直接插入排序属于简单算法,而希尔排序、堆排序、归并排序、快速排序属于改进算法。

3、排序用到的结构与函数

顺序表结构

#define MAXSIZE 10;	/*用于要排序数组个数最大值,可根据需要修改*/
typedef struct
{
     
	int r[MAXSIZE+1]	/*用于存储要排序数组,r[0]用作哨兵或临时变量*/
	int length;
}SqList;

数组两元素交换

/*交换L中数组r的下标为i和j的值*/
void swap(SqList *L,int i,int j)
{
     
	int temp=L->r[i];
	L->r[i]=L->r[j];
	L->r[j]=temp;
}

9.2 冒泡排序

1、最简单排序实现

冒泡排序(Bubblle Sort)是一种交换排序,它的基本思想是:两两相邻记录的关键字,若果反序则交换,直到,没有反序的记录为止。

void BubbleSort(SqList *L)
{
     
	int i,j;
	for(i=1;i<L->length;i++)
	{
     
		for(j=i+1;j<L->length;j++)
		{
     
			if(L->r[i]>L->r[j])
			{
     
				swap(L,i,j);	/*交换L->r[i]和r[j]的值*/
			}
		}
	}
}

2、冒泡排序算法

/*对顺序表L作冒泡排序*/
void BubbleSort(SqList *L)
{
     
	int i,j;
	for(i=1;i<L->length;i++)
	{
     
		for(j=L->length-1;j>=i;j--)	/*注意j是从后往前循环*/
		{
     
			if(L->r[j]>L->r[j+1])	/*若前者大于后者*/
			{
     
				swap(L,j,j+1);	/*交换L->r[j]和r[j+1]的值*/
			}
		}
	}
}

3、冒泡排序优化

/*对顺序表L作改进冒泡算法*/
void BubbleSort(SqList *L)
{
     
	int i,j;
	Status flag=TRUE;	/*flag用来作为标记*/
	for(i=1;i<L->Length && flag;i++)
	{
     
		flag=FALSE;
		for(j=L->length-1;j>=i;j--)
		{
     
			if(L->r[j]>L->r[j+1])
			{
     
				swap(L,j,j+1);	/*交换L->r[j]和r[j+1]的值*/
				flag=TRUE;	/*如果有数据交换,则flag为true*/
			}
		}
	}
}

增加了对flag是否为true的判断。经过这样的改进,冒泡排序在性能上就有了一些提升,可以避免因已经有序的情况下的无意义的循环判断。

4、冒泡排序复杂度分析

当最好的情况,也就是要排序的表本身就是有序的,那么就是n-1次的比较,没有数据交换,时间复杂度为O(n)。当最坏的情况,即待排序表是逆序的情况,此时需要比较(n(n-1))/2次,并作等数量级的记录移动。依次总的时间复杂度为O(n2)

9.3 简单选择排序

选择排序的基本思想是每一趟在n-i+1(i=1,2,……,n-1)个记录中选取关键字最小的记录作为有序序列的第i个记录。

1、简单选择排序算法

简单选择排序法就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1<=i<=n)个记录交换之。

/*对顺序表L作简单选择排序*/
void SelectSort(SqList *L)
{
     
	int i,j,min;
	for(i=1;i<L->length;i++)
	{
     
		min=i;	/*将当前下标定义为最小值下标*/
		for(j=i+1;j<=L->length;j++)	/*循环之后的数据*/
		{
     
			if(L->r[min]>L->r[j])	/*如果有小于当前最小值的关键字*/
				min=j;	/*将此关键字的下标赋给min*/
		}
		if(i!=min)	/*若min不等于i,说明找到最小值,交换*/
			swap(L,i,min);	/*交换L->r[i]与L->r[min]的值*/
	}
}

2、简单选择排序复杂度分析

无论最好最差的情况,其比较次数都是一样多的,第i趟排序需要进行n-i次关键字的比较,此时需要比较(n(n-1))/2次。而对于交换次数而言,当最好的时候交换为0,最差的时候,也就是降序时,交换次数为n-1次,基于最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度依然为O(n2)。

尽管与冒泡排序同为O(n2),但简单选择排序的性能上还是要略优于冒泡排序。

9.4 直接插入排序

1、直接插入排序算法

直接插入排序算法的基本操作是将一个记录插入到已经排好序的有序表中,从而的到一个新的、记录数增1的有序表。

/*对顺序1表L作直接插入排序*/
void InsertSort(SqList *L)
{
     
	int i,j;
	for(i=2;i<L->length;i++)
	{
     
		if(L->r[i]<L->r[i-1])	/*需将L->r[i]插入有序子表*/
		{
     
			L->r[0]=L->r[i];	/*设置哨兵*/
			for(j=i-1;L->r[j]>L->r[0];j--)
				L->r[j+1]=L->r[j];	/*记录后移*/
			L->[j+1]=L->r[0];	/*插入到正确位置*/
		}
	}
}

2、直接插入排序复杂度分析

直接插入排序法的时间复杂度为O(n2)。同样的O(n2)时间复杂度,直接插入排序法比冒泡和简单选择排序的性能要好一些。

9.5 希尔排序

1、希尔排序原理

当记录本身就是基本有序的,或者记录数比较少时,直接插入排序算法的效率是很高的。所以对直接插入排序算法改进后可以增加效率。

将原本有大量记录数的记录进行分组。然后再这些子序列内分别进行直接插入排序,当整个序列都基本有序时,在再对全体记录进行依次直接插入排序。

所谓的基本有序,就是小的关键字基本在前面,大的基本在后面,不小不大的基本在中间,像{2,1,3,6,4,7,5,8,9}这样可以称为基本有序了。但像{1,5,9,3,7,8,2,4,6},这样的9在第三位,2在倒数第三位就谈不上基本有序。

分割待排序记录的目的是减少待排序记录的个数,并使序列向基本有序发展。而当分完组后达不到要求时,需要采用跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结构是基本有序而不是局部有序。

2、希尔排序算法

/*对顺序表L作希尔排序*/
void ShellSort(SqList *L)
{
     
	int i,j;
	int increment=L->length;
	do
	{
     
		increment=increment/3+1;	/*增量序列*/
		for(i=increment+1;i<=L->length;i++)
		{
     
			if(L->r[i]<L->r[i-increment])
			{
     
				/*需将L->r[i]插入有序增量子表*/
				L->r[0]=L->r[i];	/*暂存在L->r[0]*/
				for(j=i-increment;j>0 && L->r[0]<L->r[j];j-=increment)
					L->r[j+increment]=L->r[j];	/*记录后移,查找插入位置*/
				L->r[j+increment]=L->r[0];	/*插入*/
			}
		}
	}
	while(increment>1);
}

2、希尔排序复杂度分析

希尔排序是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。

增量序列的最后一个增量值必须等于1。由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法。希尔排序时间复杂度超越了O(n2)。

9.6 堆排序

堆排序,就是对简单选择排序进行的一种改进。

堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子的值,称为小顶堆。

数据结构和算法——学习笔记(C语言版)_第143张图片

1、堆排序算法

堆排序算法就是利用对进行排序的方法。它的基本思想是,将待排序的序列列成一个大顶堆,此时,整个序列的最大值就是堆顶的根结点,将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次小值,如此反复执行,便能得到一个有序序列。

数据结构和算法——学习笔记(C语言版)_第144张图片

数据结构和算法——学习笔记(C语言版)_第145张图片

实现算法需要解决两个问题:

(1)如何由一个无序序列构建成一个堆?

(2)如何在输出栈顶元素后,调整剩余元素成为一个新的堆?

/*对顺序表L进行堆排序*/
void HeapSort(SqList *L)
{
     
	int i;
	for(i=L->length/2;i>0;i--)	/*把L中的r构建成一个大顶堆*/
		HeapAdjust(L,i,L->length);
	
	for(i=L->length;i>1;i--)
	{
     
		swap(L,1,i);	/*将堆顶记录和当前未经排序子序列的最后一个记录交换*/
		HeapAdjust(L,1,i-1);	/*将L->r[1..i-1]重新调整为大顶堆*/
	}
}
/*已知L->r[s..m]中记录的关键字除L->r[s]之外满足堆的定义*/
/*本函数调整L->r[s]的关键字,使L->r[s..m]成为一个大顶堆*/
void HeapAdjust(SqList *L,int s,int m)
{
     
	int temp,j;
	temp=L->r[s];
	for(j=2*s;j<=m;j*=2)	/*沿关键字较大的孩子结点向下筛选*/
	{
     
		if(j<m && L->r[j]<L->r[j+1])
			++j;	/*j为关键字中较大的记录的下标*/
		if(temp>=L->r[j])
			break;
		L->r[s]=L->r[j];
		s=j;
	}
	L->r[s]=temp;	/*插入*/
}

2、堆排序复杂度分析

堆排序的时间复杂度为O(nlogn)。由于记录的比较与交换是跳跃式进行,依次堆排序也是一种不稳定的排序方法。

另外,由于初始构建堆所需的比较次数较多,因此,并不适合待排序序列个数较少的情况。

9.7 归并排序

将本无序的数组序列{16,7,13,10,9,15,3,2,5,8,12,1,11,4,6},通过两两合并排序后在合并,最终获得一个有序的数组。

数据结构和算法——学习笔记(C语言版)_第146张图片

1、归并排序算法

“归并”,在数据结构中的定义是将两个或两个以上的有序表组成一个新的有序表。

归并排序就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个元素,则可以看成是n个有序地子序列,每个子序列的长度为1,然后两两归并,得到n/2个长度为2或1的有序子序列;在两两归并,……,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。

/*对顺序表L作归并排序*/
void MergeSort(SqList *L)
{
     
	MSort(L->r,L->r,1,L->length);
}
/*将SR[s..t]归并排序为TR1[s..t]*/
void MSort(int SR[],int TR1[],int s,int t)
{
     
	int m;
	int TR2[MAXSIZE+1];
	if(s==t)
		TR1[s]=SR[s];
	else
	{
     
		m=(s+t)/2;	/*将SR[s..t]平分为SR[s..m]和SR[m+1..t]两部分*/
		MSort(SR,TR2,s,m);	/*递归将SR[s..m]归并为有序的TR2[s..m]*/
		MSort(SR,TR2,m+1,t);	/*递归将SR[m+1..t]归并为有序的TR2[m+1..t]*/
		Merge(TR2,TR1,s,m,t)	/*将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t]*/	
	}
}
/*将有序的SR[i..m]和SR[m+1,n]归并为有序的TR[i..n]*/
void Merge(int SR[],int TR[],int i,int m,int t)
{
     
	int j,k,l;
	for(j=m+1,k=i;i<=m && j<=n;k++)	/*将SR中记录由小到大归并入TR*/
	{
     
		if(SR[i]<SR[j])
			TR[k]=SR[i++];
		else
			TR[k]=SR[j++];
	}
	if(i<=m)
	{
     
		for(l=0;l<=m-i;l++)
			TR[k+1]=SR[i+1];	/*将剩余的SR[i..m]复制到TR*/
	}
	if(j<=n)
	{
     
		for(l=0;i<=n-j;l++)
			TR[k+1]=SR[j+1];	/*将剩余的SR[j..n]复制到TR*/
	}
}

2、归并排序复杂度分析

归并排序的时间复杂度为O(nlogn)。需要两两比较,不存在跳跃,因此,归并排序是一种稳定的排序算法。

也就是说,归并排序是一种比较占用内存,但却高效且稳定的算法。

3、非递归实现归并排序

/*对顺序表L作归并非递归排序*/
void MergeSort(SqList *L)
{
     
	int *TR=(int *)malloc(L->length*sizeof(int));	/*申请额外空间*/
	int k=1;
	while(k<L->length)
	{
     
		MergePass(L->r,TR,k,L->length);
		k=2*k;	/*子序列长度加倍*/
		MergePass(TR,L->r,k,L->length);
		k=2*k;	/*子序列长度加倍*/
	}
}
/*将SR[]中相邻长度为s的子序列两两归并到TR[]*/
void MergePass(int SR[],int TR[],int s,int n)
{
     
	int i=1;
	int j;
	while(i<=n-2*s+1)
	{
     
		Merge(SR,TR,i,i+s-1,i+2*s-1)	/*两两归并*/
		i=i+2*s;
	}
	if(i<n-s+1)	/*归并最后两个序列*/
		Merge(SR,TR,i,i+s-1,n);
	else	/*若最后只剩下单个子序列*/
		for(j=i;j<=n;j++)
			TR[j]=SR[j];
}

9.8 快速排序

快速排序其实就是前面认为最慢的冒泡排序的升级,它们都属于交换排序类。即它也是通过不断比较和移动交换来实现排序的,只不过它的实现,增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面,从而减少了总的比较次数和移动交换次数。

1、快速排序算法

快速排序的基本思想是,通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

/*对顺序表L作快速排序*/
void QuickSort(SqList *L)
{
     
	QSort(L,1,L->length);
}
/*对顺序表L中的子序列L->r[low..high]作快速排序*/
void QSort(SqList *L,int low,int high)
{
     
	int pivot;
	if(low<high)
	{
     
		pivot=Partition(L,low,high);	/*将L->r[low..high]一分为二,算出枢轴值pivot*/
		QSort(L,low,pivot-1);	/*对低子表递归排序*/
		QSort(L,pivot+1,high);	/*对高子表递归排序*/
	}
}

Partition函数要做的,就是先选取当中的一个关键字,比如选择第一个关键字50,然后想尽办法将它放到一个位置,使得左边的值都比它小,右边的值比它大,将这样的关键字称为枢轴。

/*交换顺序表L中子表的记录,使枢轴记录到位,并返回其所在位置*/
/*此时在它之前(后)的记录均不大(小)于它*/
int Partition(SqList *L,int low,int high)
{
     
	int pivotkey;
	pivotkey=L->r[low];	/*用子表的第一记录作枢轴记录*/
	while(low<high)	/*从表的两端交替向中间扫描*/
	{
     
		while(low<high&&L0>r[high]>=pivotkey)
			high--;
		swap(L,low,high);	/*将比枢轴记录小的记录交换到低端*/
		while(low<high&&L->r[low]<=pivotkey)
			low++;
		swap(L,low,high);	/*将比枢轴记录大的记录交换到高端*/
	}
	return low;	/*返回枢轴所在位置*/
}

2、快速排序复杂度分析

快速排序算法的时间复杂度为O(nlogn)。由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。

3、快速排序优化

(1)优化选取枢轴

固定选取:待排序的序列可能是基本有序的,总是固定选取第一个关键字(其实无论是固定选取哪一个位置的关键字)作为首个枢轴就变成了极为不合理的做法。

改进办法,随机选取:随机获取一个low与high之间的树rnd,让它的关键字Lr[rnd]与L.r[low]交换,此时就不容易出现这样的情况,不过是随机选取,随机到了依然很大还是很小的关键字还是不太合理。

再改进,三数取中法:取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数。

在Partition函数代码的第3行与第4行之间增加代码。

int pivotkey;

int m=low+(high-low)/2;	/*计算中间数组中间元素的下标*/
if(L->r[low]>L->r[high])
	swap(L,low,high);	/*交换左端与右端的数据,保证左端较小*/
if(L->r[m]>L->r[high])
	swap(L,high,m);	/*交换中间与右端的数据,保证中间较小*/
if(L->r[m]>L->r[low])
	swap(L,m,low);	/*交换中间与左端的数据,保证左端较小*/
/*此时L.r[low]已经为整个序列左中右三个关键字的中间值*/

pivotkey=L->r[low];	/*用子表的第一个记录作枢轴记录*/

(2)优化不必要的交换

/*快速排序优化算法*/
int Partition(SqList *L,int low,int high)
{
     
	int pivotkey;
	//这里省略三数取中代码
	pivotkey=L->r[low];	/*用子表的第一记录作枢轴记录*/
	L->r[0]=pivotkey;	/*将枢轴关键字备份到L->r[0]*/
	while(low<high)	/*从表的两端交替向中间扫描*/
	{
     
		while(low<high&&L0>r[high]>=pivotkey)
			high--;
		L->r[low]=L->r[high];	/*采用替换而不是交换的方式进行操作*/
		while(low<high&&L->r[low]<=pivotkey)
			low++;
		L->r[high]=L->r[low];	/*采用替换而不是交换的方式进行操作*/
	}
	L->r[low]=L->r[0];	/*将枢轴数值替换回L.r[low]*/
	return low;	/*返回枢轴所在位置*/
}

(3)优化小数组时的排序方案

如果数组非常小,快速排序反而不如直接插入排序来得更好(直接插入是简单排序中性能最好的)。其原因在于快速排序用到了递归操作。

#define MAX_LENGTH_INSERT_SORT 7	/*数组长度阈值*/
/*对顺序表L中的子序列L->r[low..high]作快速排序*/
void QSort(SqList *L,int low,int high)
{
     
	int pivot;
	if((low<high)>MAX_LENGTH_INSERT_SORT)
	{
     
		/*当high-low大于常数时用快速排序*/
		pivot=Partition(L,low,high);	/*将L->r[low..high]一分为二,算出枢轴值pivot*/
		QSort(L,low,pivot-1);	/*对低子表递归排序*/
		QSort(L,pivot+1,high);	/*对高子表递归排序*/
	}
	else	/*当high-low小于等于常数时用直接插入排序*/
		InsertSort(L);
}

(4)优化递归操作

#define MAX_LENGTH_INSERT_SORT 7	/*数组长度阈值*/
/*对顺序表L中的子序列L->r[low..high]作快速排序*/
void QSort(SqList *L,int low,int high)
{
     
	int pivot;
	if((low<high)>MAX_LENGTH_INSERT_SORT)
	{
     
		/*当high-low大于常数时用快速排序*/
		while(low<high)
		{
     
			pivot=Partition(L,low,high);	/*将L->r[low..high]一分为二,算出枢轴值pivot*/
			QSort(L,low,pivot-1);	/*对低子表递归排序*/
			low=pivot+1;	/*尾递归*/
		}
	}
	else	/*当high-low小于等于常数时用直接插入排序*/
		InsertSort(L);
}

(5)了不起的排序算法

TonyHoare发明的快速排序算法经过多次优化后,在整体性能上,依然是排序算法的王者。

9.9 总结回顾

数据结构和算法——学习笔记(C语言版)_第147张图片

数据结构和算法——学习笔记(C语言版)_第148张图片

你可能感兴趣的:(数据结构,算法,c语言,链表,队列)