数据结构(完结)

分节目录

数据结构Part1 绪论与线性表
数据结构Part2 栈和队列
数据结构Part3 串
数据结构Part4 树与二叉树
数据结构Part5 图
数据结构Part6 查找
数据结构Part7 排序
数据结构可视化:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html

第一章 绪论

1.基本概念:数据,数据元素,数据对象,数据类型(原子类型,结构类型,抽象数据类型),数据结构

2.数据结构三要素:逻辑结构(线性,非线性),存储结构(顺序存储,链式存储,索引存储,散列存储),数据的运算(算法)

3.算法特征:有穷性,确定性,可行性,输入,输出;算法目标:正确性,可读性,健壮性,效率与低存储量需求

4.效率度量:时间复杂度与空间复杂度

第二章 线性表

1. 线性表的定义与基本操作

1.1 概念

定义:由相同数据类型的有限序列。
表中元素具有:有限性,顺序性,单个元素,类型相同(所占空间大小相同),抽象性。
线性表是一种逻辑结构,顺序表与链表是指存储结构。

1.2 基本操作

// 基本操作
InitList(&L)     		//初始化表。构造一个空的线性表L,分配内存空间。
DestroyList(&L)			//销毁操作。销毁线性表,并释放线性表L所占用的内存空间。
ListInsert(&L,i,e)		//插入操作。在表L中的第i个位置上插入指定元素e。
ListDelete(&L,i,&e)		//删除操作。删除表L中第i个位置的元素,并用e返回删除元素。
LocateElem(L,e)			//按值查找操作。在表L中查找具有给定关键字值的元素。
GetElem(L,i)			//按位查找操作。获取表L中第i个位置的元素的值。
Length(L)				//求表长。返回线性表L的长度,即L中数据元素的个数。
PrintList(L)			//输出操作。按前后顺序输出线性表L的所有元素值。
Empty(L)				//判空操作。若L为空表,则返回true,否则返回false。

查->改/增/删,查的基本操作是表非空情况下的遍历。

2. 顺序表

2.1 概念

1.定义:线性表的顺序存储表示称为顺序表,表中元素的逻辑顺序与其物理顺序相同。

loc(A) + (i-1) * sizeof(ElemType)

2.静态分配的顺序表:静态数组,创建时固定表的大小。

3.动态分配的顺序表:动态数组,扩容时申请一篇更大的区域,复制过去。

2.2 操作

随机访问,存储密度高,适合查找,不适合增删;通常不可扩容,且扩容的代价大。

3. 链表

3.1 单链表

定义:线性表的链式存储表示称为单链表,分为带头节点不带头节点两种。

操作:头插法,逆序建立链表;尾插法,需要增加一个尾指针,正向建立链表。判断链表为空的条件,链表遍历。

难点:指定节点a前插入b,可以在指定节点a后插入b,然后交换a,b节点的数据元素。

3.2 双链表

每个节点既有next指针,也有prior指针。

3.3 循环链表

最后一个节点的不指向NULL而是指向头节点。

if(L->next == L)	//链表为空
if(p->next == L)	//p为尾节点,用于遍历

循环单链表:只需定义一个尾指针指向尾部,可以快速的对头尾进行操作,可用于队列的实现。

循环双链表:prior与next都指向L。

3.4 静态链表

借助数组来描述线性表的链式存储结构

addrdatanext
02
1b6
2a1
3d-1
4
5
6c3

初始化:头节点->next=-1,其余节点->next=-2,next保存下一个节点的实际位置的序号,尾节点的next为-1。

应用:FAT表(文件分配表),不支持指针的高级语言

第三章 栈和队列

栈和队列就是插入或删除操作收限制的线性表。

数据结构队首/栈顶队尾/栈底
插入&删除/
队列插入删除
双端队列插入&删除插入&删除
输出受限的双端链表插入&删除插入
输入受限的双端链表插入&删除删除

注意:

1.经过确定操作后,栈/队列的状态。
2.给定输入序列,求可能的输出序列。n个元素入栈可能的输出序列共有(2*n)C(n)/(n+1) [C:组合符号]
3.循环队列的判空问题,注意初始化时头尾指针的位置,是否需要/可以牺牲一个存储单元。

1. 栈的基本操作

InitStack(&S)		//初始化栈。构造一个空栈s,分配内存空间。
DestroyStack(&L)	//销毁栈。销毁并释放栈s所占用的内存空间。
Push(&S,x)			//进栈,若栈s未满,则将x加入使之成为新栈顶。
Pop(&S,&x)			//出栈,若栈s非空,则弹出栈顶元素,并用x返回。
GetTop(S,&x)		//读栈顶元素。若栈s非空,则用x返回俄顶元素
StackEmpty(S)		//判断一个栈s是否为空。若s为空,则返回true,否则返回false。

2. 队列的基本操作

lnitQueue(&Q)			//初始化队列,构造一个空队列Q。
DestroyQueue(&Q)		//销毁队列。销毁并释放队列Q所占用的内存空间。
EnQueue(&Qk)			//入队,若队列Q未满,将x加入,使之成为新的队尾。
DeQueue(&Q,&x)			//出队,若队列Q非空,删除队头元素,并用x返回。
GetHead(Q,&x)			//读队头元素,若队列Q非空,则将队头元素赋值给x。

3. 栈和队列的应用

3.1 栈在括号匹配中的应用

使用一个栈

遇到左括号则入栈,遇到右括号则出栈,可以记录错误的数量。

i.初始设置一个空栈,顺序读入括号序列;
ii.若是右括号,则弹出栈顶元素进行比较;
ii.若是左括号,则压入栈内;
iv.若括号序列读完后,栈为空栈,则完全匹配。

typedef struct
{
	char data[MaxSize];
	int top = 0;
} SqStack;

3.2 栈在表达式求值中的应用

表达式的组成:操作数(数字)、运算符(符号)、界限符(括号)

种类特点举例
中缀表达式界限符必要(a+b)*(c-d)
前缀表达式/波兰表达式界限符不必要*+ab+cd
后缀表达式/逆波兰表达式界限符不必要ab+cd+*

常用(常考)后缀表达式。

表达式类型转换方法(手操)

i.确定中缀表达式中各个运算符的运算顺序
ii.选择下一个运算符,按照「左操作数右操作数运算符」的方式组合成一个新的操作数
iii.如果还有运算符没被处理,就继续ii

中缀表达式:((15/ (7-(1+1)))*3)-(2+(1+1))
后缀表达式:15 7 1 1 + - / 3 * 2 1 1 + + -
前缀表达式:- * / 15 - 7 + 1 1 3 + 2 + 1 1
后/前缀表达式中运算符出现的次序与中缀表达式中运算符进行的顺序是一致的,但不唯一
中->后
在左优先原则的情况下,可保证运算顺序是唯一的
左优先原则:只要左边的运算符能先计算,就优先计算左边的运算符
中->前
在右优先原则的情况下,可保证运算顺序是唯一的
右优先原则:只要右边的运算符能先计算,就优先计算右边的运算符

表达式计算

后缀表达式:
1.初始设置一个空栈,从左往右顺序读入表达式;
2.若是操作数,压入栈内;
3.若是运算符$,则弹出栈顶两个数p,q(先弹出p),计算q $ p = r,r入栈;
4.当表达式序列读取结束后,栈中只有一个元素,即为表达式的值。

前缀表达式:
1.初始设置一个空栈,从右往左顺序读入表达式;
2.若是操作数,压入栈内;
3.若是运算符$,则弹出栈顶两个数p,q(先弹出p),计算p $ q = r,r入栈;
4.当表达式序列读取结束后,栈中只有一个元素,即为表达式的值。

中缀表达式转后缀表达式

初始化一个栈,用于保存暂时还不能确定运算顺序的运算符。从左到右处理各个元素,直到末尾。可能遇到三种情况:
i.遇到操作数。直接加入后缀表达式。
ii.遇到界限符。遇到“(”直接入栈;遇到“)”则依次弹出栈内运算符并加入后缀表达式,直到弹出“(”为止。注意:“(”不加入后缀表达式。
iii.遇到运算符。依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到“(”或栈空则停止。
iv.之后再把当前运算符入栈。按上述方法处理完所有字符后,将栈中剩余运算符依次弹出,并加入后缀表达式。

注意:括号不需要加入后缀表达式,注意比较各运算符的优先级。答题时可采用伪代码或模拟入栈出栈操作的过程(表格)

*中缀表达式求值(机算)

使用两个栈

i.初始化两个栈,操作数栈和运算符栈若扫描到操作数,压入操作数栈
ii.若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)

两个栈分别对应着**“中缀转后缀”“后缀表达式求值(机算)”**两个算法。

3.3 栈在递归中的应用

递归函数就是函数调用的过程(自己调用自己)。

递归的适用范围:将问题转换为属性相同但是规模更小的问题,即an+1 = f(an)

函数调用栈:函数调用时,需要用一个栈存储调用返回地址、实参、局部变量三类内容。

递归调用时,函数调用栈可称为“递归工作栈”每进入一层递归,就将递归调用所需信息压入栈顶每退出一层递归,就从栈顶弹出相应信息。

递归算法会创造较大的函数调用栈,存在栈溢出的可能。

递归改非递归:通过栈模拟递归。

注意:重点是理解递归的逻辑,和递归转非递归的逻辑,不是代码实现。

3.4 队列的层次遍历中的应用

树的层次遍历

i.根节点入队;
ii.若队为空(所有节点已经处理完毕),则遍历结束。否则执行iii;
iii.队列第一个节点出队,并将其子节点从左至右依次入队,返回ii。

图的广度优先遍历(Breadth First Search,bfs)与这个类似,注意要标记已经遍历过的节点。

3.5 队列在操作系统中得应用

进程排序:多个进程争抢有限得系统资源时,可以采用先来先服务(First Come First Service, FCFS)的策略。

4.特殊矩阵的压缩存储

4.1 数组的存储结构

一维数组:各数组元素大小相同,物理上连续存放,数组下标默认从0开始。

ElemType a[MaxSize]; 	//ElemType型一维数组,数组大小为MaxSize

二维数组:

行优先,b[i][j]的存储地址= LOC+ (i*N + j)* sizeof(ElemType)
列优先,b[i][j]的存储地址= LOC+ (j*M + i)* sizeof(ElemType)

ElemType a[xSize][ySize]; 	//ElemType型一维数组,数组大小为xSize*ySize

4.2 特殊矩阵

描述矩阵时行号和列号默认从1开始。

对称矩阵:上三角与下三角数据相同,可以只存储主对角线+下三角区的数据。
按照行优先的原则将元素存入一维数组B中。n阶对称矩阵的一位数组大小为(1+n)*n/2。
构建一个映射函数将a[i][j]映射到数组B[k]上。
k = { i ( i + 1 ) 2 + j − 1 , i ≥ j j ( j − 1 ) 2 + i − 1 , i < j 大 概 是 这 样 不 用 记 k= \begin{cases} \cfrac {i(i+1)}{2} +j-1, & i\ge j\\ \cfrac {j(j-1)}{2} +i-1, & i\lt j \end{cases} 大概是这样不用记 k=2i(i+1)+j1,2j(j1)+i1,iji<j
三角矩阵:上三角区或下三角区全是常量,对称矩阵压缩+1个位置存放常量

三对角矩阵:又称带状矩阵,沿对角线呈条带状。当|i - j| > 1时,a[i][j] = 0
共需存储3*n-2个元素。k = 2*i+j-3
a[i][j] -> k:前i-1行共3*(i-1)-1个元素,a是第i行第j-i+2个元素,a[i][j] 是第2*i+j-2个元素
k -> a[i][j]:前i-1行共3*(i-1)-1个元素,前i行共3*i-1个元素,则3*(i-1)-1<k+1<=3*i-1,则i = [(k + 2) / 3] + 1
要注意边界问题的处理。

稀疏矩阵:非零元素个数远远少于矩阵元素个数,零元素过多。
可采用一个三元组**<行,列,值>**保存,但是这样会失去随机查找的特性。
或采用十字链表法:向下域指向第j列的第一个元素,向右域指向第i行的第一个元素
在这里插入图片描述

第四章 串

1. 串的定义和基本操作

1.1 概念

串:即字符串(String),是由零个或多个字符组成的有限序列、一般记为S = “a1a2a3······an”,n>=0。是一种特殊的线性表(串的数据类型只能属于字符集),数据元素之间呈现线性关系。

注意:字符串用双引号括起来:java,c,c++,python;字符串用单引号括起来:python。

空串:串长度为零的串叫做空串

子串:串中任意个连续的字符组成的子序列,空串是任意串的子串。

1.2 基本操作

串的基本操作是对”字串“为对象进行操作的。

假设有串T="",S=" iPhone 11 Pro Max?",W="Pro"
StrAssign(&T,chars)			//赋值操作。把串T赋值为chars;
StrCopy(&T,S)				//复制操作。由串S复制得到串T;
StrEmpty(S)					//判空操作。若s为空串,则返回TRUE,否则返回FALSE;
StrLength(S)				//求串长。返回串s的元素个数;
ClearString(&S)				//清空操作。将s清为空串,不释放空间;
DestroyString(&S)			//销毁串。将串s销毁(回收存储空间);
Concat(&T,S1,S2)			//串联接。用T返回由S1和S2联接而成的新串
SubString(&Sub,S,pos,len)	//求子串。用sub返回串s的第pos个字符起长度为len的子串。
Index(S,T)					//定位操作。若主串S中存在与串T值相同的子串,则返回它在主串S中第一次出现位置;否则函数值为0。
StrCompare(S,T)				//比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<0。自行定义大小规则,从第一个字符开始一次比较 。
//注意:存储空间扩展
//执行基本操作Concat(&T, S,W)后,T="iPhone 11 Pro Max?Pro"
//执行基本操作SubString(&T ,S,4,6)后,T="one 11"
//执行基本操作Index(s, W)后,返回值为11

不同编码规则下每个字符所占的空间不同,考试默认为1B。

附录:ASCII码表:

decocthexchdecocthexchdecocthexchdecocthexch
0000NULL (空)324020(空格)6410040@9614060` (锐音符)
1101SOH (标题开始)334121!6510141A9714161a
2202STX (正文开始)344222"6610242B9814262b
3303ETX (正文结束)354323#6710343C9914363c
4404EOT (传送结束)364424$6810444D10014464d
5505ENQ (询问)374525%6910545E10114565e
6606ACK (确认)384626&7010646F10214666f
7707BEL (响铃)394727'7110747G10314767g
81008BS (退格)405028(7211048H10415068h
91109HT (横向制表)415129)7311149I10515169i
10120aLF (换行)42522a*741124aJ1061526aj
11130bVT (纵向制表)43532b+751134bK1071536bk
12140cFF (换页)44542c,761144cL1081546cl
13150dCR (回车)45552d-771154dM1091556dm
14160eSO (移出)46562e.781164eN1101566en
15170fSI (移入)47572f/791174fO1111576fo
162010DLE (退出数据链)48603008012050P11216070p
172111DC1 (设备控制1)49613118112151Q11316171q
182212DC2 (设备控制2)50623228212252R11416272r
192313DC3 (设备控制3)51633338312353S11516373s
202414DC4 (设备控制4)52643448412454T11616474t
212515NAK (反确认)53653558512555U11716575u
222616SYN (同步空闲)54663668612656V11816676v
232717ETB (传输块结束)55673778712757W11916777w
243018CAN (取消)56703888813058X12017078x
253119EM (媒介结束)57713998913159Y12117179y
26321aSUB (替换)58723a:901325aZ1221727az
27331bESC (退出)59733b;911335b[1231737b{
28341cFS (文件分隔符)60743c<921345c\1241747c|
29351dGS (组分隔符)61753d=931355d]1251757d}
30361eRS (记录分隔符)62763e>941365e^1261767e~
31371fUS (单元分隔符)63773f?951375f_1271777fDEL (删除)

2. 串的存储结构

2.1 顺序存储

定长字符串:定义一个静态数组来存储定长的串,与线性表相同,只不过数据类型为字符类型(char)。

可变长字符串:定义一个基地址指针,使用动态数组的方式实现(堆分配存储)。

插入和删除不方便,字符串末尾有一个’\0’(对应的ASCII码为0),没有length变量,所占空间为字符串长度加一。

define MAXLEN 255
typedef struct{
	char ch[MAXLEN];		//创建一个定长的字符串数组
	int length;				//串的长度
}SString;
typedef struct{
	char *ch;				//按串长分配存储区,ch指向串的基地址
	int length;				//串的长度
}HString;

//使用完毕后需要手动free
HString S;
S.ch = (char *) malloc(MAXLEN * sizeof(char));		
S.length = 0;

2.2 链式存储

不具备随机存储的特性。

typedef struct StringNode{
	char ch[4];		//每个节点一般存多个字符,提高存储密度,结尾可以特殊字符填充,如'\0'。
	struct StringNode *next;	
}StringNode, *String;

所以说为什么想不开要用链表存字符串呢,搞不懂哦。

2.3 基于顺序存储实现基本操作(模式匹配)

define MAXLEN 255
typedef struct{
	char ch[MAXLEN];		//创建一个定长的字符串数组
	int length;				//串的长度
}SString;
SubString(&Sub,S,pos,len)	//求子串。用sub返回串s的第pos个字符起长度为len的子串。
bool SubString(SString &sub, SString S, int pos, int len){
	//子串范围越界
	if (pos+len-1 > S.length)
		return false;
	for (int i=pos; i<pos+len; i++)
		sub.ch[i-pos+1] = S.ch[i];
    sub.length = len;
	return true;
}
StrCompare(S,T)				//比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<0。自行定义大小规则,从第一个字符开始一次比较 。
int strcompare(SString S, SString T) {
	for (int i=1; i<=S.length && i<=T.length; i++){
		if (S.ch[i] !=T.ch[i])
            return S.ch[i]-T.ch[i];
    }
	//扫描过的所有字符都相同,则长度长的串更大
	return S.length-T.length;
}
Index(S,T)					//定位操作。若主串S中存在与串T值相同的子串,则返回它在主串S中第一次出现位置;否则函数值为0。
int Index(SString S, SString T){
    int i = 1, n = StrLength(S), m = StrLength(T);
    SString sub;
    while(i <= n - m +1){
        SubString(sub, S, i, m);
        if(StrCompare(sub, T)!=0) ++i;
        else return i;
    }
    return 0;
}

串的模式匹配算法:在主串中找到与模式串相同的子串,并返回其所在位置(Index(S, T))。

朴素模式匹配:依次检查子串,时间复杂度为O(nm)。

int Index(SString s, sstring T){
	int k = 0;
	int i=k, j=0;
	while(i < s.length && j < T.length){
		if(S.ch[i]==T.ch[j]){
			++i;
			++j;		//继续比较后继字符
		} eise {
			k++;		//检查下一个子串
			i=k;
            j=1;
		}
	}
	if(j>T.length)	return k;
    else			return 0;
}

模式串长度为m,主串长度为n,(n>>m)

时间复杂度匹配成功匹配失败
最好O(m)O(n-m+1)=O(n-m)≈O(n)
最坏O((n-m+1)*m)≈O(nm)O((n-m+1)*m)≈O(nm)
*KMP算法(重点)

主串指针不回溯,只有字串指针回溯,不用一位一位得比较,而是记下回退几个能对上,关键在于求字串数组回溯得个数next[i];

请添加图片描述

int IndexKMP(SString S, SString T, int next[]){
	int i = 0,j = 0;
	while( i < s.length && j < T.length){
		if(j==-1 || s.ch[i]==T.ch[j] ){
			++i;
            ++j; 		//继续比较后继字符
        } else j=next[j]; 	//模式串向右移动
	}
    if(j > T.length)
        return i-T.length;	//匹配成功
    else return -1;
}

求模式串的next数组

void Getnext(int next[],String t)
{
   int j=0,k=-1;
   next[0]=-1;
   while(j < t.length-1)
   {
      if(k == -1 || t[j] == t[k])
      {
         j++;k++;
         next[j] = k;
      }
      else k = next[k];		//核心代码
   }
}

重点来看看 k = next[k] 这句,首先要明确得是,k表示的是一个用来生成next数组得游标,是可以向左移动来取数的,而j才是用来标记next数组最新下标的,是只能向右移动的,其次是next数组的值的含义,next[i]表示当模式串扫描到第i个位置与主串不同时,模式串游标移动到的位置。因此这句话相当于是创建了一个新串ss来做模式串,之前的模式串做主串。这里的ss是在动态变动的,而这句话表示得是表示的是字符串t0到k-1子串(构建的新字串ss)与j-k到j-1子串(长度均为k)是相同的。

请添加图片描述

改进的kmp算法,改进点依然在next数组上。按照原方案,当主串i位置与模式串j位置不同时,应该进行移动,使得主串的i位置与模式串的**next[j]**位置对应,然而若这两个位置无法对应,那么这次移动就是无意义的,因此需要在构建next数组时添加一个判断。

请添加图片描述

void Getnext(int next[],String t)
{
   int j=0,k=-1;
   next[0]=-1;
   while(j < t.length-1)
   {
      if(k == -1 || t[j] == t[k])
      {
         j++;k++;
         if(t[j]==t[k])				//当两个字符相同时,就跳过
            next[j] = next[k];
         else
            next[j] = k;
      }
      else k = next[k];
   }
}

请添加图片描述

第五章 数与二叉树

1.树

1.1 树的概念

概念:根节点,前驱,后继,子树,空树,路径与路径长度
节点的度:孩子节点的个数;
树的度:各节点度的最大值;
深度:从上往下数,默认从1开始;
节点的高度:从下往上数;
树得高度:树中根节点的最大深度;
描述节点关系:父节点、子节点、兄弟节点,
有序树和无序树:从左至有是有次序的,不可以交换。
森林:由m(m>=0)颗互不相交的树的集合。

定义:树是n(n>=0)个节点的有限集合,n=0时,称为空树。在任意一颗非空树中满足以下特点,
i.有且只有一个特定的节点称为根节点。
ii.当n>1时,其余节点可分为m(m>0)个互不相交的有限集合T1T2······Tm,每个集合的本身又是一颗树,并且称之为根节点的子树。
iii.根节点没有前驱节点,除根节点外的所有节点有且只有一个前驱节点。
iv.书中所有节点都有0个或多个后继节点

1.2 树的性质

考点1:节点数 = 总度数+1,(子节点+根节点)
考点2:度为m的树和m叉树的区别。

度为m的树m叉树
任意节点的度<=m任意节点的度<=m
至少有一个节点的度 = m允许所有节点的度都 < m
一定是非空树,至少有m+1个节点可以是空树

考点3:度为m的树第i层至多有mi-1个节点(i>1)
考点4:高度为h的m叉树至多有(m^h-1)/(m-1)个节点。(等差数列求和)
考点5:高度为h的m叉树至少有h个节点。高度为h,度为m的树至少有h+m-1个节点。
考点6:有n个节点的m叉树的最小高度为[ logm(n(m-1)+1)]+1,即所有节点都有尽可能多的(m个)孩子 []为向下取整。

2.二叉树(重点)

2.1 二叉树的概念

二叉树是n (n>=0)个节点的有限有序集合:
i.或者为空二叉树,即n = 0。
ii. 或者由一个根节点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树。

2.2 特殊的二叉树

1)满二叉树:一颗高为h,且含有2h-1个节点的二叉树

特点:
i.只有最后一层有叶子节点;
ii.不存在度为1得节点;
iii.按层序从1开始变好,节点i的左孩子为2i,有孩子为21+1,节点i的父节点为[i/2]。

2)完全二叉树:一颗高为h,且含有n个节点的二叉树当且仅当其每个节点都与高度为h的满二叉树中编号为1~n的节点一一对应时,称为满二叉树。

特点:
i.只有最后两层有叶子节点;
ii.最多只有一个度为1的节点;
iii.按层序从1开始变好,节点i的左孩子为2i,有孩子为21+1,节点i的父节点为[i/2];
iv. i<=[n/2]为分支节点,i>[n/2]为叶子节点。

3)二叉排序树:左子树上所有节点的关键字均小于根节点的关键字,右子树上所有节点的关键字均大于根节点的关键字,且左子树和右子树又各是一棵二叉排序树。

4)平衡二叉树:树上任一及诶但的左子树和右子树的深度之差不超过1。

2.3 树的性质

考点1:设非空二叉树中度为0、1和2的节点个数分别为n0、n1和n2,则n0= n2+ 1。叶子节点比二分支节点多一个
n = n0 + n1 + n2 = n1+ 2n2 +1 -> n0= n2+ 1

考点2:二叉树的第i层最多有2i-1个节点,m叉树的第i层最多有2m-1个节点。

考点3:高度为h的m叉树至多有2h-1个节点。

考点4:具有n个(n >0)节点的完全二叉树的高度h为 [log2n」+ 1或 [log2(n+1)」+ 1。

考点5:对于完全二叉树,可以由的节点数n = 2k推出度为0、1和2的节点个数为n0 = k、n1 = 0 或 1 和n2 = k-1。

2.4 二叉树的存储结构

2.4.1 顺序存储

定义一个长度为MaxSize的数组t,按照从上至下,从左至右的顺序依次存储完全二叉树中得各个节点。

define MaxSize 180
struct TreeNode{
	ElemType value;		//节点中的数据元素
	bool isEmpty;		//节点是否为空
};
TreeNode t[MaxSize];
for(int i=0; i<MaxSize; i++)
	t[i].isEmpty=true;	//初始化标记所有节点为空

若从t[1]开始储存,这样可以使数组下标与节点在二叉树中的序号一致,则有以下几点

i所在的层次[log2n] + 1 或 [log2(n+1)] + 1
i 的父节点[i/2]
i 的左孩子2i
i 的右孩子2i+1

当存储一个普通树的话,可以使用节点将其补充为一个完全二叉树。但是这样会造成大量节点空间被浪费。

2.4.2 链式存储
struct ElemType{
	string name;			//节点中的数据元素
	int age;
};
typedef struct BiTNode{
    ElemType data;						//数据域
    struct BiTNode *lchild, *rchild;	//左、右孩子指针
    //若需要频繁的查找父节点,可添加一个指针,也称三叉链表
    struct BiTNode *parent;				
}BiTNode, *BiTree;
BiTree root = NULL;						//定义了一个空树
//创建根节点并赋值,数据域为{"lc", 18},左右子节点为空
root = new BiTree[1]{{{"lc", 18}, NULL, NULL, NULL}};   
cout<<root->data.name<<":"<<root->data.age<<endl; 

2.5 二叉树的遍历(重点)

2.5.1 递归遍历

考虑到树的这种递归定义的特性(根节点,左子树,右子树),显然可以使用递归的方法进行遍历,根据根节点被访问的次序分为先序遍历,中序遍历,后序遍历三种

先序遍历:左右(NLR)

空间复杂度O(h),第一次路过这个节点的时候就要访问这个节点

void ProOrder(BiTree T){
    if(T!=NULL){
        visit(T);
        ProOrder(T->lchild);
        ProOrder(T->rchild);
    }
}

中序遍历:左右(LNR)

第二次路过这个节点的时候就要访问这个节点

void InOrder(BiTree T){
    if(T!=NULL){
        ProOrder(T->lchild);
        visit(T);
        ProOrder(T->rchild);
    }
}

后序遍历:左右(LRN

第三次路过这个节点的时候就要访问这个节点

void PostOrder(BiTree T){
    if(T!=NULL){
        ProOrder(T->lchild);
        ProOrder(T->rchild);
        visit(T);
    }
}

递归的方法求树的深度

int treeDepth(BiTree T){
	if(T == NULL){
        return 0;
    } else {
        int l = treeDepth(T->lchild)
		int l = treeDepth(T->lchild)
        return l > r ? l+1 :r+1;
    }
}
2.5.2 层序遍历

算法思想:

i:初始化一个辅助队列,一般使用链队列,并让根节点入队;
ii:若队列非空,则队头节点处队,访问该节点,并将其左,右子节点依次入队;
iii:重复ii,直到队列为空。

typedef struct LinkNode{
    BiTNode *data;						//保存指向节点的指针,节省空间
    struct LinkNode *Next;
}LinkNode;								//链式队列节点
typedef struct{
    LinkNode *front, *rear;				//队头,队尾节点
}LinkQueue;
//层序遍历
void Level0rder(BiTree T){
	LinkQueue Q;
	InitQueue(Q);						//初始化辅助队列
	BiTree p;
	EnQueue(Q,T);						//将根节点入队
	while(!IsEmpty(Q)){					//队列不空则循环
		DeQueue(Q, p);					//队头节点出队
		visit(p);						//访问出队节点
		if(p->lchild !=NULL)
			EnQueue(Q,p->lchild);		//左孩子入队
        if(p->rchild !=NULL)
			EnQueue(Q, p->rchild);		//右孩子入队
	}
}
2.5.3 由遍历序列构建二叉树

中序遍历的根节点在左右子树的中间,可用于划分左右子树

1)前序+中序

前序:根节点 左子树的前序遍历序列 右子树的前序遍历序列
中序:左子树的中序遍历序列 根节点 右子树的前序遍历序列

同色的序列长度相等,递归的寻找根节点

2)后序+中序

后序:左子树的前序遍历序列 右子树的前序遍历序列 根节点
中序:左子树的中序遍历序列 根节点 右子树的前序遍历序列

同色的序列长度相等,递归的寻找根节点

3)层序+中序

层序:根节点 左子树的根 右子树的根节点 ······
中序:左子树的中序遍历序列 根节点 右子树的前序遍历序列

先找根节点,然后通过中序遍历划分左右子树,在左右子树中重复这一步骤。

3.线索二叉树(重点)

3.1 基本概念

由n个节点组成的的二叉树,共有n+1个空链域。

3.2 三种线索二叉树

中序线索二叉树

请添加图片描述

//二叉树的节点(链式存储)
typedef struct BiTNode{
	ElemType data;
	struct BiTNode *lchild, *rchild;
    int ltag,rtag;	//左、右线索标志,ltag=1时表示lchild为线索指针指向前驱,rtag同理
}ThreadBNode, *ThreadTree;

先序线索二叉树

请添加图片描述

后序线索二叉树

请添加图片描述

手算画出线索二叉树
i.确定线索二叉树类型——中序、先序、or后序;
ii.按照对应遍历规则,确定各个节点的访问顺序,并写上编号;
iii.将n+1个空链域连上前驱、后继。

3.2 二叉树线索化

3.2.1 线索化的方法

寻找一个节点的前驱

土办法:设置两个指针a,b,令b指向a所指节点的后继节点,两个指针同步遍历。当b指向节点p时,a指向的节点即为p的前驱节点。

BiTNode *p;					//p指向目标节点
BiTNode * pre = NULL;		//指向当前访问节点的前驱
BiTNode * final = NULL;		//用于记录最终结果
void visit(BiTNode *q){
    if(q==p)	final = pre;
    else pre == q;
}
//由中序遍历修改的找中序前驱节点
void FindInOrderPreP(BiTree T){
    if(T!=NULL){
        ProOrder(T->lchild);
        visit(T);
        ProOrder(T->rchild);
    }
}

*二叉树线索化的方法,重点在手算而不是代码。

中序线索化遍历

//全局变量pre,指向当前访问节点的前驱
ThreadNode *pre = NULL;
void visit(){
    if(q->lchild==NULL){//左子树为空,建立前驱线索
		q->lchild=pre;
		q->ltag=1;
	}
	if(pre !=NULL&&pre->rchild==NULL){
		pre->rchild=q;//建立前驱节点的后继线索
    	pre->rtag=1;
	}
	pre=q;
}
//中序线索化遍历,一边遍历一遍线索化
void InThread(ThreadTree T){
    if(T != NULL){
        InThread(T->lchild);	//中序遍历左子树
        visit(T);				//访问根节点
        InThread(T->rchild);	//中序遍历右子树
    }
}
//中序线索化二叉树T
void CreateInThread(ThreadTree T){
    pre=NULL;					// pre初始为NULL
	if(T !=NULL){				//非空二叉树才能线索化
		InThread(T);			//中序线索化二叉树
		if (pre->rchild==NULL)	//处理遍历的最后一个节点
            pre->rtag=1;		
	}
}

先序线索化

//先序遍历二叉树,一边遍历一遍线索化
void PreThread(ThreadTree T, ThreadTree &pre){
	if(T!=NULL){
    	if(T->lchild == NULL){				//左子树为空,建立前驱线索
           	T->lchild = pre;
            T->ltag = 1;
        }
        if(pre != NULL && pre->rchild == NULL){
            pre-rchild = T;					//建立前驱节点的后继线索
            pre->rtag = 1;
        }
        pre = T;							//标记当前节点成为刚刚访问过的节点
        if(T->ltag == 0)
            PreThread(T->lchild, pre);		//递归,线索化右子树
		PreThread(T->rchild, pre);			//递归,线索化左子树
    }//if(T!=NULL)
}
//先序线索化二叉树T
void CreatePreThread(ThreadTree T){
    ThreadTree pre=NULL;			//pre初始为NULL
	if(T !=NULL){					//非空二叉树才能线索化
		InThread(T, pre);			//中序线索化二叉树
		if (pre->rchild==NULL)		//处理遍历的最后一个节点
            pre->rtag=1;	
	}
}

后序线索化

//先序遍历二叉树,一边遍历一遍线索化
void PostThread(ThreadTree T, ThreadTree &pre){
	if(T!=NULL){
        PostThread(T->lchild, pre);			//递归,线索化右子树
		PostThread(T->rchild, pre);			//递归,线索化左子树
    	if(T->lchild == NULL){				//左子树为空,建立前驱线索
           	T->lchild = pre;
            T->ltag = 1;
        }
        if(pre != NULL && pre->rchild == NULL){
            pre-rchild = T;					//建立前驱节点的后继线索
            pre->rtag = 1;
        }
        pre = T;							//标记当前节点成为刚刚访问过的节点        
    }//if(T!=NULL)
}
//先序线索化二叉树T
void CreatePostThread(ThreadTree T){
    ThreadTree pre=NULL;			//pre初始为NULL
	if(T !=NULL){					//非空二叉树才能线索化
		InThread(T, pre);			//中序线索化二叉树
		if (pre->rchild==NULL)		//处理遍历的最后一个节点
            pre->rtag=1;	
	}
}

核心:
中序/先序/后序遍历算法的改造,当访问一个节点时,连接该节点与前驱节点的线索信息
用一个指针 pre记录当前访问节点的前驱节点

易错点:
最后一个节点的rchild, rtag 的处理
先序线索化中,注意处理原地循环的问题,当ltag==0时,才能对左子树先序线索化

3.2.2 线索二叉树找前驱/后继

指定节点为*p,后继为next,前驱为pre

1)中序线索化

后继:
i.若p->rtag == 1,则next = p->rchild;
ii.若p->rtag == 0,则next为以p为根节的的右子树中最左下角的节点;

//找到以P为根的子树中,第一个被中序遍历的节点
ThreadNode *Firstnode(ThreadNode *p){
	//循环找到最左下节点(不一定是叶节点)
    while(p->ltag==0) p=p->lchild;
    return p;
}
//在中序线索二叉树中找到节点p的后继节点
ThreadNode *Nextnode(ThreadNode *p){
	//右子树中最左下节点
	if(p->rtag==0) return Firstnode( p->rchild);
    else return p->rchild;	//rtag==1直接返回后继线索
}

前驱:
i.若p->ltag == 1,则pre = p->lchild;
ii.若p->ltag == 0,则next为以p为根节的的左子树中最右下角的节点;

//找到以P为根的子树中,第一个被中序遍历的节点
ThreadNode *Lastnode(ThreadNode *p){
	//循环找到最左下节点(不一定是叶节点)
    while(p->rtag==0) p=p->rchild;
    return p;
}
//在中序线索二叉树中找到节点p的后继节点
ThreadNode *Prenode(ThreadNode *p){
	//左子树中最右下节点
	if(p->ltag==0) return Lastnode(p->lchild);
    else return p->lchild;	//rtag==1直接返回后继线索
}
//对中序线索二叉树进行逆向中序遍历
void RevInorder(ThreadNode *T){
    ThreadNode *p=Lastnode(T);
	for(p; p!=NULL; p=Prenode(p))
		visit(p);
}

2)先序线索化

后继:
i.若p->rtag == 1,则next = p->rchild;
ii.若p->rtag == 0(则p必有右孩子),若有左孩子,则next = p->lchild,若没有左孩子,则next = p->rchild;

rtag\ltagltag = 0ltag = 1
rtag = 0p->lchildp->rchild
rtag = 1p->rchildp->rchild
//在中序线索二叉树中找到节点p的后继节点
ThreadNode *Nextnode(ThreadNode *p){
	//右节点指针不指向后继,且有左孩子
    if(p->rtag==0 && p->ltag==0) return p->lchild;
    else return p->rchild;	
}

前驱:
i.若p->ltag == 1,则pre = p->lchild;
ii.若p->ltag == 0(则p必有左孩子),p的前驱不在p的子树内,可以采用双指针从头遍历的方式:
a.若能找到p的父节点pf,且p是左孩子,则pre = pf;
b.若能找到p的父节点pf,且p的左兄弟为空,p是右孩子,则pre = pf;
c.若能找到p的父节点pf,且p的左兄弟非空,p是右孩子,则从pf开始遍历。
若有右节点就向右,若只有左节点就向左,到叶子节点停止,此时的叶子节点即为pre。
d.若p是根节点,则p没有前驱节点。

//找到以Pf为根的子树中,最后一个被遍历的节点
ThreadNode *Lastnode(ThreadNode *pf){
    while(pf->rtag==0 || pf->ltag==0){
        if(pf->ratg==1) pf=pf->lchild;
        else pf = pf->rchild;
    }
    return pf;
}
//在先序线索二叉树中找到节点p的后继节点
ThreadNode *Prenode(ThreadNode *p, ThreadNode *pf){
	if(p == root) return NULL;				//情况d
    if(pf->ltag == 1 || pf->lchild == p)	//情况a,b
        return pf;
    if(pf->ltag == 0 && pf->rchild == p)	//情况c
        return Lastnode(pf);
}

3)后序线索化

后继:

i.若p->rtag == 1,则pre = p->rchild;
ii.若p->rtag == 0(则p必有左孩子),p的后继不在p的子树内,可以采用双指针从头遍历的方式:
a.若能找到p的父节点pf,且p是右孩子,则next = pf;
b.若能找到p的父节点pf,且p的右兄弟为空,p是左孩子,则next= pf;
c.若能找到p的父节点pf,且p的右兄弟非空,p是左孩子,则从pf开始遍历。
若有左节点就向左,若只有右节点就向右,到叶子节点停止,此时的叶子节点即为next。
d.若p是根节点,则p没有后继节点。

//找到以P为根的子树中,第一个被后序遍历的节点
ThreadNode *Firstnode(ThreadNode *pf){
    while(pf->rtag==0 || pf->ltag==0){
        if(pf->latg==1) pf=pf->rchild;
        else pf = pf->lchild;
    }
    return pf;
}
//在先序线索二叉树中找到节点p的后继节点
ThreadNode *Nextnode(ThreadNode *p, ThreadNode *pf){
	if(p == root) return NULL;				//情况d
    if(pf->rtag == 1 || pf->rchild == p)	//情况a,b
        return pf;
    if(pf->rtag == 0 && pf->lchild == p)	//情况c
        return Firstnode(pf);
}

前驱:
i.若p->ltag == 1,则pre = p->lchild;
ii.若p->ltag == 0(则p必有左孩子),若有右孩子,则pre = p->rchild,若没有右孩子,则pre = p->lchild;

rtag\ltagltag = 0ltag = 1
rtag = 0p->rchildp->lchild
rtag = 1p->lchildp->lchild
//在后序线索二叉树中找到节点p的前驱节点
ThreadNode *Prenode(ThreadNode *p){
    //有右孩子,且左孩子节点指针不指向前驱
	if(p->rtag==0 && p->ltag==0) return p->rchild;
    else return p->rchild;	
}

4.一般树与森林

4.1 一般树的存储结构

概念回顾:空树、非空树。对任意非空树,有一个根,每个节点只有一个”双亲“节点,递归定义略。

4.1.1 双亲表示法

顺序存储每个节点中保存指向双亲的“指针”(静态链表)。根节点的指针域记为-1,其余节点的指针域指向父节点的位置。

#define MAX_TREE_SIZE 100			//树中最多节点数
typedef struct{						//树的节点定义
	ElemType data;					//数据元素
	int parent;						//双亲位置域
}PTNode;
typedef struct{						//树的类型定义
	PTNode nodes [MAX_TREE_SIZE];	//双亲表示
	int n;							//节点数
}PTree;

增加节点:在数组的结尾添加一个节点的数据元素与双亲位置域;

删除节点:将要删除节点的指针域记为-2,并递归的查找其子树内的节点将其指针域全部置为-2;然后用尾部的有效节点来覆盖当前节点。
??是删除单个节点还是产出这个节点后的所有节点??

查询:递归查询,不方便。

4.1.2 孩子表示法

顺序存储各个节点,每个节点中保存孩子链表头指针(邻接矩阵)。

请添加图片描述

struct CTNode {
	int child;						//孩子节点在数组中的位置
    struct CTNode *next;			//下一个孩子
};
typedef struct {
	ElemType data;
	struct CTNode *firstChild;		//第一个孩子
}CTBox;
typedef struct {
	CTBox nodes [MAX_TREE_SIZE];
    int n, r;						//节点数和根的位置
}CTree;
4.1.3 孩子兄弟表示法

二叉链表,按层存储,可以实现普通树与二叉树的转换。

请添加图片描述

//树的存储—孩子兄弟表示法
typedef struct CSNode{
	ElemType data;								//数据域
	struct cSNode *firstchild, *nextsibling;	//第一个孩子和右兄弟指针
}CSNode,*CSTree;
4.1.4 森林和二叉树的转换

将森林中的每棵树都用孩子兄弟表示法表示后,将树的根节点用右兄弟指针连接。

请添加图片描述

4.2 树和森林的遍历

4.2.1 树的遍历

1)先根遍历。若树非空,先访问根节点,再依次对每棵子树进行先根遍历。树的先根遍历序列与这棵树相应二叉树的先序序列相同。也称为树的深度优先遍历。


//树的先根遍历
void PreOrder(TreeNode *R){
	if (R!=NULL){
		visit(R);				//访问根节点
    	while( R还有下一个子树T )
			Pre0rder(T);		//先根遍历下一棵子树
	}
}

2)后根遍历。若树非空,先依次对每棵子树进行后根遍历,再访问根节点。树的后根遍历序列与这棵树相应二叉树的中序序列相同。也称为树的深度优先遍历。

//树的先根遍历
void PreOrder(TreeNode *R){
	if (R!=NULL){
    	while( R还有下一个子树T )
			Pre0rder(T);		//先根遍历下一棵子树
        visit(R);				//访问根节点
	}
}

3)层次遍历(用队列实现)
i.若树非空,则根节点入队
ii.若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
iii.重复ii直到队列为空

也称为树的广度优先遍历。

4.2.2 森林的遍历

1)先序遍历

i.若森林为非空,则按如下规则进行遍历:访问森林中第一棵树的根节点。
ii.先序遍历第一棵树中根节点的子树森林。
iii.先序遍历除去第一棵树之后剩余的树构成的森林。

*等同于依次对各个子树进行先根遍历。(上面递归过程不用记)

2)中序遍历

若森林为非空,则按如下规则进行遍历:
中序遍历森林中第一棵树的根节点的子树森林。访问第一棵树的根节点。
中序遍历除去第一棵树之后剩余的树构成的森林。

*等同于依次对各个子树进行后根遍历。(上面递归过程不用记)

森林二叉树
先根遍历先序遍历先序遍历
后根遍历中序遍历中序遍历

注意:对森林和普通树的算法题,先用孩子兄弟表示法将其转化为二叉树。

5.树与二叉树的应用

5.1 二叉排序树(BST)

二叉排序树,又称二叉查找树(BST,Binary Search Tree)一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:(左<根<右)
i.左子树上所有节点的关键字均小于根节点的关键字;
ii.右子树上所有节点的关键字均大于根节点的关键字;
iii.左子树和右子树又各是一棵二叉排序树。

作用:二叉排序树可用于元素的有序组织、查找特定值

//二叉排序树的节点
typedef struct BSTNode{
    int key; 
    struct BSTNode *lchild, *rchild;
}BSTNode, *BSTree;

1)二叉排序树的查找

若树非空,目标值与根节点的值比较:若相等,则查找成功;若小于根节点,则在左子树上查找,否则在右子树上查找。
查找成功,返回节点指针;查找失败,返回NULL。

//在二叉排序树中查找值为num的节点
BSTNode *BST_Search(BSTree T,int num){
	while(T != NULL && num != T->key)	//若树空或等于根节点值,则结束循环
		if(num < T->key) T=T->lchild;	//小于,则在左子树上查找
		else T=T->rchild;				//大于,则在右子树上查找
	}
	return T;
}
//在二叉排序树中查找值为key 的节点(递归实现)
BSTNode *BSTSearch(BSTree t,int key){
    if(T==NULL) return NULL;				//查找失败
	if(key==T->key) return T;				//查找成功
	else if(key < T->key)
		return BSTSearch(T->lchild,key);	//在左子树中找
    else return BSTSearch(T->rchild,key);	//在右子树中找
}
//递归实现的最坏空间复杂度为O(h)

2)二叉排序树的插入

若原二叉排序树为空,则直接插入节点;否则,若关键字k小于根节点值,则插入到左子树,若关键字k大于根节点值,则插入到右子树

//在二叉排序树插入关键字为k的新节点
int BST_Insert(BSTree &T, int k){
	while(T != NULL){
        if(T->key == k) return -1;//树中存在相同关键字的节点,插入失败
        if(num < T->key) T=T->lchild;	//小于,则在左子树上查找
		else T=T->rchild;				//大于,则在右子树上查找
    }
    T=(BSTree)malloc(sizeof(BSTNode));
    T->key=k;
    T->lchild=T->rchild=NULL;
    return 1;							//返回1,插入成功
}
//在二叉排序树插入关键字为k的新节点(递归实现)
int BST_Insert(BSTree &T, int k){
	if(T==NULL){				//原树为空,新插入的节点为根节点
		T=(BSTree)malloc(sizeof(BSTNode));
		T->key=k;
		T->lchild=T->rchild=NULL;
        return 1;				//返回1,插入成功
	}
	else if( k==T->key)			//树中存在相同关键字的节点,插入失败
		return -1;
	else if(k<T->key)			//插入到T的左子树
		return BST_Insert(T->lchild,k );
	else						//插入到T的右子树
		return BST_Insert(T->rchild,k);
}
//递归实现的最坏空间复杂度为O(h)

3)二叉排序树的构造

//按照str[]中的关键字序列建立二叉排序树
void Creat_BST(BSTree &T,int str[] ,int n){
	T=NULL;				//初始时T为空树
	int i=0;
	while(i<n){			//依次将每个关键字插入到二叉排序树中
		BST_Insert(T,str[i] );
		i++;
	}
}

4)二叉排序树的删除

i.若被删除节点z是叶节点,则直接删除,不会破坏二叉排序树的性质。
ii.若节点z只有一棵左子树或右子树,则让z的子树成为z父节点的子树,替代z的位置。
iii.若节点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
进行中序遍历,可以得到一个递增的有序序列。

查找长度:在查找运算中,需要对比长键字的次数称为查找长度,反映了查找操作时间复杂度。
查找成功的平均查找长度ASL (Average Search Length)
ASL =∑ (层数*本层节点数)/总节点数
查找失败 的平均查找长度ASL (Average Search Length)
ASL =∑ (节点所在的层数*节点的度数)/总节点数

5.2 平衡二叉树(重点)

平衡二叉树(Balanced Binary Tree),简称平衡树(AVL树)――树上任一节点的左子树和右子树的高度之差不超过1。节点的平衡因子=1, 0, -1

节点的平衡因子=左子树高-右子树高。
当插入节点导致及诶但不平衡时,只需调整最小不平衡子树。

LL:在A的左孩子的左子树中插入导致不平衡

请添加图片描述

LL平衡旋转(右单旋转):
由于在节点A的左孩子(L)的左子树(L)上插入了新节点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。
将A的左孩子B向右上旋转代替A成为根节点,将A节点向右下旋转成为B的右子树的根节点,而B的原右子树则作为A节点的左子树。

//实现f向右下旋转,p向右上旋转
//其中f是p的父节点,p为左孩子,gf为f的父节点
f->lchild = p->rchild;
p->rchild = f;
gf->lchild/rchild = p;

RR:在A的右孩子的右子树中插入导致不平衡

请添加图片描述

RR平衡旋转(左单旋转):
由于在节点A的右孩子(R)的右子树(R)上插入了新节点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。
将A的右孩子B向左上旋转代替A成为根节点,将A节点向左下旋转成为B的左子树的根节点,而B的原左子树则作为A节点的右子树。

//实现f向左下旋转,p向左上旋转
//其中f是p的父节点,p为右孩子,gf为f的父节点
f->rchild = p->lchild;
p->lchild = f;
gf->lchild/rchild = p;

LR:在A的左孩子的右子树中插入导致不平衡

请添加图片描述

先左旋C,再右旋C。

LR平衡旋转(先左后右双旋转):
由于在A的左孩子(L)的右子树(R)上插入新节点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。
先将A节点的左孩子B的右子树的根节点C向左上旋转提升到B节点的位置,然后再把该c节点向右上旋转提升到A节点的位置。

RL:在A的右孩子的左子树中插入导致不平衡

请添加图片描述

RL平衡旋转(先右后左双旋转):
由于在A的右孩子(R)的左子树(L)上插入新节点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。
先将A节点的右孩子B的左子树的根节点C向右上旋转提升到B节点的位置,然后再把该C节点向左上旋转提升到A节点的位置。

假设以nk表示深度为k的平衡树中还有的最少节点树。
则有:n0 = 0, n1 = 1, n2 = 2, 且nk = nk-1 + nk-2 + 1;
可以证明含有n个节点的平衡二叉树的最大深度为o(logzn),平衡二叉树的平均查找长度为O(log2n)

5.3 哈夫曼树和哈夫曼编码(重点)

节点的权:有某种现实含义的数值(如表示节点的重要性等);
节点的带权路径长度:从树的根到该节点的路径长度(经过的边数)与该节点上权值的乘积;
树的带权路径长度:树中所有叶节点的带权路径长度之和 (WPL, Weighted Path Length)。

哈夫曼树:在含有n个带权叶节点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。

给定n个权值分别为w, w2…, w,的结点,构造哈夫曼树的算法描述如下:
i.将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
ii.构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
iii.从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
iv.重复步骤ii和iii直至F中只剩下一棵树为止。

性质:
i.每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大;
ii.哈夫曼树的结点总数为2n -1;
iii.哈夫曼树中不存在度为1的结点;
iv.哈夫曼树并不唯一,但WPL必然相同且为最优。

请添加图片描述

固定长度编码: 每个字符用相等长度的二进制位表示
可变长度编码:允许对不同字符用不等长的二进制位表示

前缀编码:若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码,即所有字符都是叶子节点。

由哈夫曼树得到哈夫曼编码―-字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树。

第六章 图

1.图得基本结构

1.1 图的基本概念

1)图G (Graph)由顶点集V(Vertex)和边集E(Edge)组成,记为G=(V,E),其中V(G)表示图G中顶点的有限非空集;
E(G)表示图G中顶点之间的关系(边)集合。若V={v1,v2 … , vn},则用|V|表示图G中顶点的个数,也称图G的阶,E= {(u, v)| u∈U, v∈V},用|E|表示图G中边的条数。

注意:线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集,U可以是空集。

2)简单图:不存在重复边;不存在顶点到自身的边。(只考简单图)
多重图:图G中某两个结点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则G为多重图

3)无向图与有向图

i.无向图:若E是无向边(简称边)的有限集合时,则图G为无向图。边是顶点的无序对,记为(v, w)或(w, v),因为(w, v) = (v, w),其中v、w是顶点。可以说顶点w和顶点v互为邻接点。边(v,w)依附于顶点w和v,或者说边(v, w)和顶点v、w相关联。

度:顶点v的度是指依附于该顶点的边的条数,记为TD(v);所有定点的度的和为顶点个数的二倍。

ii.有向图:若E是有向边(也称弧)的有限集合时,则图G为有向图。弧是顶点的有序对,记为<v, w>,其中v、w是顶点,v称为弧尾,w称为弧头,<v, w>称为从顶点v到顶点w的弧,也称邻按到w,或w邻接自v。<v, w> ≠ <w, v>

入度是以顶点v为终点的有向边的数目,记为ID(v);
出度是以顶点v为起点的有向边的数目,记为OD(v)。
入度之和=出度之和=顶点个数

4)顶点-顶点关系描述

路径:顶点a到顶点b之间的一条路径是指一个顶点序列;
回路:第一个顶点和最后一个顶点相同的路径称为回路或环;
简单路径:在路径序列中,顶点不重复出现的路径称为简单路径;
简单回路:除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路;
路径长度:路径上边的数目;
点到真的距离:从顶点u出发到顶点v的最短路径若存在,则此路径的长度称为从u到v的距离若从u到v根本不存在路径,则记该距离为无穷;
连通:无向图中,若从顶点v到顶点w有路径存在,则称v和w是连通的;若图G中任意两个顶点都是连通的,则称图G为连通图,否则称为非连通图。
强连通:有向图中,若从顶点v到顶点w和从顶点w到顶点v之间都有路径,则称这两个顶点是强连通的;若图中任何一对顶点都是强连通的,则称此图为强连通图。

常考考点:

对于n个顶点的无向图G,若G是连通图,则最少有n-1条边,若G是非连通图,则最多可能有C2n-1条边。
对于n个顶点的有向图,若G是强联通图,则最少有n条边,形成一个回路。

5)研究图的局部

设有两个图G= (V,E)和G’= (v’ ,E’),若v是v的子集,且E’是E的子集,则称G’是G的子图。

若有满足v(G’)= v(G)的子图G’,则称其为G的生成子图。

无向图的极大连通子图称为连通分量,极大连通子图要包含尽可能多得顶点与尽可能多得边。
有向图中的极大强连通子图称为有向图的强连通分量

生成树:连通图的生成树是包含图中全部顶点的一个极小连通子图。n个顶点,n-1条边。

生成森林:在非连通图中,连通分量的生成树构成了非连通图的生成森林。

6)边的权、带权图/网

边的权:在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
带权图/网:边上带有权值的图称为带权图,也称网。
带权路径长度:当图是带权图时,一多路径上所有边的权值之和,称为该路径的带权路径长度。

7)几种特殊形态的图

无向完全图:无向图中任意两个顶点之间都存在边,v = n, e = C2n
有向完全图:有向图中任意两个顶点之间都存在方向相反的两条弧,v=n,e=2C2n
稀疏图与稠密图:一般来说|E<|V| log|V|时,可以将G视为稀疏图,否则为稠密图;
树:不存在回路,且连通的无向图,v=n,e=n-1;森林有k颗树,则e=v-k
有向树:一个顶点的入度为0其余顶点的入度均行1的有向图,称为有向树。

1.2 图的存储

1.2.1 邻接矩阵

表示方法唯一。

请添加图片描述

无向图是对称矩阵,可以只保存上三角区。

#define MaxVertexNum 100					//顶点数目的最大值
typedef struct{
	char Vex[MaxVertexNum];					//顶点表
	bool Edge[MaxVertexNum][MaxVertexNum];	//邻接矩阵,边表
    int Vexnum,arcnum;						//图的当前顶点数和边数/弧数
}MGraph;
//一维数组与二维数组的对应:(i,j)->k
//k = i * n +j -> i = k / n, j = k % n;

无向图:第i个结点的度=第i行(或第i列)的非零元素个数; O(n);
有向图:第i个结点的出度=第i行的非零元素个数;
入度=第i列的非零元素个数;
第i个度=第i行、第i列的非零元素个数之和

存储带权图:

#define MaxVertexNum 100			//顶点数目的最大值
#define INFINITY 0x3f3f3f3f			//宏定义常量"无穷大" 0xc0c0c0c0为无穷小
typedef char VertexType;			//顶点的数据类型
typedef int EdgeType;				//带权图中边上权值的数据类型
typedef struct{
	VertexType Vex [MaxVertexNum];	//顶点
	EdgeType Edge [MaxVertexNum][MaxVertexNum];//边的权
    int vexnum,arcnum;				//图的当前顶点数和弧数
}MGraph;
//一般只想自己的权记为0;

适合存储稠密图,空间复杂度之和顶点个数有关。

性质:
设图G的邻接矩阵为A(矩阵元素为0/1,n阶),则An的元素A[i][j]等于由顶点i到顶点j的长度为n的路径的数目。举证乘法例如:从1到4得路径长度为2得路径树数如下所示:

在这里插入图片描述

考点:

i.如何计算指定顶点的度、入度、出度(分无向图、有向图来考虑);
ii.时间复杂度如何?如何找到与顶点相邻的边(入边、出边)?时间复杂度如何;
iii.如何存储带权图;
iv.空间复杂度为O(|v|2),适合存储稠密图;
v.无向图的邻接矩阵为对称矩阵,如何压缩存储;
vi.设图G的邻接矩阵为A(矩阵元素为0/1),则An的元素A[i][j]等于由顶点i到顶点j的长度为n的路径的数目。

1.2.2 邻接表

顺序存储+链式存储,类似与树的“孩子兄弟”表示法
表示方法不唯一

在这里插入图片描述

#define MaxVertexNum 100			//顶点数目的最大值
//"边/弧""
typedef struct(ArcNode{
	int adjvex;					//边/弧指向哪个结点
	struct ArcNode *next;		//指向下一条弧的指针
	//infoType info;			//边权值
}ArcNode;
//顶点
typedef struct VNode {
    VertexType data;		//顶点信息
    ArcNode *first;			//第一条边/弧
}VNode, AdjList[MaxVertexNum];
//用邻接表存储的图
typedef struct {
    AdjList vertices;
    int vexnum, arcnum;
}ALGraph;

在无向图中边节点的数目是2e,整体空间复杂度为O(v+2e);在有向图中边节点的数目是e,整体空间复杂度为O(v+e)
无向图的度和有向图的入度只需遍历该节点的链表即可,求有向图的入度需要遍历整张邻接表。

1.2.3 十字链表

在这里插入图片描述

可以沿着tlink指针找出度,沿着hlink找入度,十字链表法只用于存储有向图。

1.2.4 邻接多重表

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ejkEYUB3-1632153425338)(D:\cskaoyan\图片\临接多重表.png)]

空间复杂度:O(v+e),删除边和节点等的操作很方便,邻接多重表只适合用于存储无向图。

总结:

在这里插入图片描述

6.3 基本操作

有向图用邻接矩阵存储,无向图用邻接表存储。

图的基本操作:
Adjacent(G,x,y):判断图G是否存在边<x, y>或(x, y)。
Neighbors(G,x):列出图G中与结点x邻接的边。
lnsertVertex(G,x):在图G中插入顶点x。
DeleteVertex(G,x):从图G中删除顶点x。
AddEdge(G,x,y):若无向边(x,y)或有向边<x, y>不存在,则向图G中添加该边。
RemoveEdge(G,x,y):若无向边(x,y)或有向边<x, y>存在,则从图G中删除该边。
Get_edge_value(G,x,y):获取图G中边(x, y)或<x, y>对应的权值。
set_edge_value(G,x,yv):设置图G中边(x, y)或<x, y>对应的权值为v。
//注意有向图与无向图的实现区别
FirstNeighbor(G,x):求图G中顶点x的第一个邻接点,若有则返回顶点号。若x没有邻接点或图中不存在x,则返回-1。
NextNeighbor(G,x,y):假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1。

2.图的遍历

2.1 广度优先遍历(BFS)

树的层次遍历就是一种广度优先遍历。

广度优先遍历(Breadth-First-Search, BFS)要点:
i.找到与一个顶点相邻的所有顶点:FirstNeighbor(G,x),NextNeighbor(G,x,y)
ii.标记哪些顶点被访问过:定义访问标记数组来记录是否被已经被访问。
iii.需要一个辅助队列

bool visited [MAX_VERTEX_NUM];//访问标记数组
void BFSTraverse(Graph G){		//对图G进行广度优先遍历
	for(i=0; i<G.vexnum; ++i)
		visited[i]=FALSE;		//访问标记数组初始化
	InitQueue(Q);				//初始化辅助队列Q
	for(i=0; i<G.vexnum; ++i)	//从0号顶点开始遍历
		if(!visited[i])			//对每个连通分量调用一次BFS
			BFS(G,i);			//vi未访问过,从vi开始BFS
}
//广度优先遍历
void BFS(Graph G,int v){			//从顶点v出发,广度优先遍历图G
	visit(v);						//访问初始顶点v
	visited[v]=TRUE;				//对v做已访问标记
	Enqueue(Q,v);					//顶点v入队列Q
	while(!isEmpty(Q)){
	DeQueue(Q,v);					//顶点v出队列
	for(w=FirstNeighbor(G,v);w>=0; w=NextNeighbor(G,v,w)){
			//检测v所有邻接点
			if(!visited[w]){		//w为v的尚未访问的邻接顶点
				visit(w);			//访问顶点w
				visited[w]=TRUE;	//对w做已访问标记
        		EnQueue(Q,w);		//顶点w入队列
        	}//fi
    	}//rof
	}//elihw
}

对于无向图,调用BFS函数的次数=连通分量数

空间复杂度:O(v),时间复杂度:邻接矩阵需要O(v2),邻接表需要O(v+e)

广度优先生成树(森林):根据广度优先的过程产生的。

重点:求广度优先遍历序列

2.2 深度优先遍历(DFS)

树的先根遍历就是一种深度优先遍历。

bool visited [MAX_VERTEX_NUM] ;		//访问标记数组
void DFSTraverse(Graph G){		//对图G进行深度优先遍历
	for(i=0; i<G.vexnum; ++i)
		visited[i]=FALSE;		//访问标记数组初始化
	for(i=0; i<G.vexnum; ++i)	//从0号顶点开始遍历
		if(!visited[i])			//对每个连通分量调用一次DFS
			DFS(G,i);			//vi未访问过,从vi开始DFS
}

void DFs(Graph G,int v){			//从顶点v出发,深度优先遍历图G
	visit(v);						//访问顶点v
	visited[v]=TRUE;				//设已访问标记
	for(w=FirstNeighbor(G,v); w>=0; w=NextNeighor(G,v,w)){
		if (!visited[w]){			//w为u的尚未访问的邻接顶点
			DFS(G,w);
		}//fi
    }//rof
}

空间复杂度:最好O(1),最坏O(v),时间复杂度:邻接矩阵需要O(v2),邻接表需要O(v+e)

重点:求深度优先遍历序列

3.图的应用(重点)

3.1 最小生成树

对于一个带权连通无向图G =(V, E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设R为G的所有生成树的集合,若T为R中边的权值之和最小的生成树,则7称为G的最小生成树(Minimum-Spanning-Tree,MST)。

注意:
i.最小生成树可能有多个,但边的权值之和总是唯一且最小的
ii.最小生成树的边数=顶点数-1。砍掉一条则不连通,增加一条边则会出现回路
iii.如果一个连通图本身就是—棵树,则其最小生成树就是它本身
iv.只有连通图才有生成树,非连通图只有生成森林

3.1.1 通用算法:
GenericMst(G){
	T = Ø;
    while(T未形成一颗生成树){
        找到一条最小代价边(u,v);
            if((u,v)加入T后不会形成环)
                T = T ∪ (u,v);
    }
}
3.1.2 Prim算法(普里姆)∶

(选点)从某一个顶点开始构建生成树;每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。

时间复杂度为:O(v2),适合与边稠密图。

void Prim(G,T){
	T = Ø;						//初始化空树
    U = {w};					//添加任一顶点w
    while((V-U) != Ø){			//若树中不含全部的顶点
        设(u,v),满足u∈U, v∈(V-U),且是权值最小的边;
        T = T ∪ {(u,v)};		//将此边加入生成树中
        U = U ∪ {v};			//顶点归于树
    }
}

需要定义两个数组:
isJoin[v]标记各节点是否已加入树,
lowCost[v]各节点加入树得最低代价,若不能直接加入则记为(∞)

可以继续优化,mark一下

3.1.3 Kruskal算法(克鲁斯卡尔)

(选边)每次选择—条权值最小的边,使这条边的两头连通〈原本已经连通的就不选)
直到所有结点都连通。

时间复杂度:O(e*log2e),适合边稀疏图。

void Kruskal(V,T){
	T = V;						//初始化树T,仅含有顶点
    numS = n;					//连通分量数
    while(numS > 1){			//若连通分量大于1
        从E中取出权值最小的边(v,u);
        if(v和u属于T中不同的连通分量){
        	T = T ∪ {(u,v)};		//将此边加入生成树中
        	numS = numS - 1;		//连通分量减一
        }
    }
}

需要对所有的顶点按照权值排序,适用并查集判断是否属于同一连通分量。

*五-1.9.4 并查集(树得应用)

并查集是一种简单的集合表示,主要用于解决一些元素分组的问题。它管理一系列不相交的集合,主要有三种操作:

i.初始化:Initial(S):将集合S中的每个元素都初始化为只有一个单元素的子集合,即一个元素为一组
ii.合并:Union(S, Root1, Root2):把集合S中的子集合Root2并入子集合Root1中。要求Root1与Root2互不相交,否则不能执行。一般情况下必定满足。
iii.查找:Find(S, x):查找集合S中单元素x所在的子集合,并返回该子集合的名字。

//简单的并查集可以由数组进行表示
#define maxSize 100
int S[maxSize]		//集合元素数组(双亲指针数组)
void Initial(int S[]){
    for(int i = 0; i < maxSize; i++){
        S[i] = -1;	//每个元素独自一组
    }
}
int Find(int S[], int &x){
    while(S[x] >= 0)	//循环寻找x的根
        x = S[x];
    return x;			//根的S[]小于0
}
void Union(int S[], int Root1, int Root2){
    S[Root2] = Root1;	将Root2的根连接在Root1根下面
}

3.2 最短路径问题

3.2.1 单源最短路径问题:
BFS算法(无权图)
bool visited [MAX_VERTEX_NUM];//访问标记数组
void BFSTraverse(Graph G){		//对图G进行广度优先遍历
	for(i=0; i<G.vexnum; ++i)
		visited[i]=FALSE;		//访问标记数组初始化
	InitQueue(Q);				//初始化辅助队列Q
	for(i=0; i<G.vexnum; ++i)	//从0号顶点开始遍历
		if(!visited[i])			//对每个连通分量调用一次BFS
			BFS(G,i);			//vi未访问过,从vi开始BFS
}
//广度优先遍历
void BFS(Graph G,int v){			//从顶点v出发,广度优先遍历图G
	//visit(v);						//访问初始顶点v
    for(i=0; i<G.vexnum ;++i){		//d[i]表示从u到i结点的最短路径
        d[i]=0x3f3f3f3f;			//初始化路径长度
		path[i]=-1;					//最短路径从哪个顶点过来
	}
	d[v] = 0;
	visited[v]=TRUE;				//对v做已访问标记
	Enqueue(Q,v);					//顶点v入队列Q
	while(!isEmpty(Q)){				//BFS算法核心
	DeQueue(Q,v);					//顶点v出队列
	for(w=FirstNeighbor(G,v);w>=0; w=NextNeighbor(G,v,w)){
			//检测v所有邻接点
			if(!visited[w]){		//w为v的尚未访问的邻接顶点
				//visit(w);			//访问顶点w
                d[w] = d[v] + 1;	//路径长度加一
                path[w] = v;		//最短路径应从u到w
				visited[w]=TRUE;	//对w做已访问标记
        		EnQueue(Q,w);		//顶点w入队列
        	}//fi
    	}//rof
	}//elihw
}
Dijkstra算法(带权图,无权图)(重点)

提出“goto有害理论”―一操作系统,虚拟存储技术
信号量机制PV原语一―操作系统,进程同步
银行家算法――操作系统,死锁
解决哲学家进餐问题――操作系统,死锁
Dijkstra最短路径算法――数据结构大题、小题

算法思路:与Prime算法类似,贪心思想

两个辅助数组:
dist[]:记录从源点v0到其他各顶点的当前最短路径长度;
path[i]:表示从源点到顶点i之间的最短路径的前驱节点

若从V0开始,令final[0]=ture; dist[0]=0; path[0]=-1;
其余顶点final[k]=false; dist[k]=arcs[0][k];
path[k]=arcs[0][k]<INF? 0 ∶-1
循环遍历所有顶点,找到还没确定最短路径,且dist最小的顶点Vi,令final[i]=ture。
并检查所有邻接自Vi的顶点,对于邻接自Vi的顶点Vj,
若final[i]==false且dist[i]+arcs[i][j]< dist[j],
则令dist[j]=dist[i]+arcs[i][i]; path[j]=i。
(注: arcs[i][j]表示Vi到Vj的弧的权值)
#include <iostream>
#define Max 503
#define INF 0x3f3f3f3f								//宏定义无穷大
using namespace std;
typedef struct MGraph {								//定义图
	int vex, arc;									//顶点数,边数
	int arcs[Max][Max];								//邻接矩阵
}MGraph;

int dist[Max], path[Max];							//dist保存最短路径总权值、path通过保存路径的前驱结点来保存路径
bool final[Max];									//已找到最短路集合
void Dijkstra(MGraph &G)							//迪杰斯特拉算法
{
	for (int i = 1; i <= G.vex; i++)				
	{
		dist[i] = G.arcs[0][i];						//初始化dist数组
		path[i] = dis[i] < INF ? 0 : -1;			//初始化路径数组
	}
	final[0] = true;									
	dist[0] = 0;									//起点初始化
	for (int i = 1; i < G.vex; i++)					//遍历G.vex-1次
	{
		int mins = INF, u = 0;
		for (int j = 1; j < G.vex; j++)				//找到当前没加入集合的最短路的后驱点
		{
			if (!final[j] && mins > dist[j]) {
				mins = dist[j];
				u = j;
			}
		}
		final[u] = true;							//将该点加入集合
		for (int j = 1; j <= G.vex; j++)			//遍历所有其他点对其最短路进行更新(松弛操作)
		{
			if (!final[j] && dist[j] > dist[u] + G.arcs[u][j]) {
				dist[j] = dist[u] + G.arcs[u][j];	//更新最短路径值
				path[j] = u;						//修改j的前驱为u
			}
		}
	}
}
void find(int x)									//递归输出最短路径
{
	if (path[x] == 1) {
		cout << 1;
	}
	else {
		find(path[x]);
	}
	cout << " -> " << x;
	return;
}
void input(MGraph &G)								//输入图
{
	cin >> G.vex >> G.arc;
	for (int i = 1; i <= G.vex; i++)				//初始化邻接矩阵
		for (int j = 1; j <= G.vex; j++)
			G.arcs[i][j] = INF;

	for (int i = 1; i <= G.arc; i++)			
	{
		int u, v, w;
		cin >> u >> v >> w;
		G.arcs[u][v] = w;
	}
}
void output(MGraph &G)								//输出
{
	//cout << "起点 v1 到各点的最短路程为: \n";
	for (int i = 1; i < G.vex; i++)
	{
		cout << dist[i] << " ";
	}
	cout << dis[G.vex] << endl;
	/*for (int i = 2; i <= G.vex; i++)
	{
		cout << "起点 v1 到 v" << i << " 的路径为: ";
		find(i);
		cout << endl;
	}*/
}
int main()
{
	MGraph G;
	input(G);
	Dijkstra(G);
	output(G);
	return 0;
}

时间复杂度O(v2)

注意:主要考手算,若路径中存在负权值,则这个算法不适合。

3.2.2 各顶点间的最短路径:
Floyd算法(带权图,无权图)

Floyd算法(Floyd-Warshall算法)
堆排序算法

for*3算法:

使用动态规划思想,将问题的求解分为多个阶段
对于n个顶点的图G,求任意一对顶点Vi->Vj之间的最短路径可分为如下几个阶段:#初始:不允许在其他顶点中转,最短路径是?
0:若允许在V0中转,最短路径是?
1:若允许在V0、V1中转,最短路径是?
2:若允许在V0、V1、V2中转,最短路径是?

n-1:若允许在V0、V1、V2…Vn-1中转,最短路径是?

辅助矩阵:
A(i):目前来看,各顶点间的最短路径长度
path(i):两个顶点之间的中转点

即: 若A(k-1)[i][j] > A(k-1)[i][k] + A(k-1)[k][j]
则A(k)[i][j] = A(k-1)[i][k] + A(k-1)[k][j],path(k)[i][j] = k
否则:A(k)与path(k)保持原值。

初始化:A(-1):不经过任何点中专,即为图的邻接矩阵;path(-1):两个顶尖之间的中转点,全为-1;

AV0V1V2pathV0V1V2
V00613V0-1-1-1
V11004V1-1-1-1
V250V2-1-1-1

经过三轮后,角标i表示第i轮变化的位置

AV0V1V2pathV0V1V2
V006102V0-1-11
V19304V12-1-1
V251110V2-10-1
//......准备工作,根据图的信息初始化矩阵A和 path (如上图)
for (int k=0; k<n; k++){					//考虑以vk作为中转点
	for (int i=0; i<n; i++){				//遍历整个矩阵,i为行号,j为列号
    	for (int j=0; j<n; j++){
			if (A[i][j]>A[i][k]+A[k][j]){	//以 vk为中转点的路径更短
                A[i][j]=A[i][k]+A[k][j];	//更新最短路径长度
				path[i][j]=k;				//中转点
			}}}}
void find(int x, int y)							//输出路径
{
	if (path[x][y] == -1) {						//如果x,y之间无节点,则结束查找
		return;
	}
	else {										//如果x,y之间具有节点t,则查找t, y
		int t = path[x][y];
		find(t, y);			
		cout << "->" << t;
	}
	return;
}

void wayoutput(MGraph &G, int p)
{
	for (int i = 0; i < G.vex; i++)			
		{
			cout << "从 v"<< p <<" 到 v" << i << "的最短路径长度为:" << A[p][i] << endl;
			cout << "路径为: "<<p;
			find(p, i);
			cout << "->" << i << endl;
		}
}

时间复杂度O(v3)

注意:主要考手算,路径中存在负权值,这个算法也适合,但是若图中含有带负权值的回路,则不适合。

总结BFS算法Dijkstra算法Floyd算法
无权
带权
带负权
带负权回路
时间复杂度O(|V|2)或O(|V|+|E|)O(|V|2)O(|V|3)
通常用于求无权图的单元最短路径求带权图的单元最短路径求带权图中各顶点间的最短路径

3.3 有向无环图

有向无环图∶若一个有向图中不存在环,则称为有向无环图,简称DAG图(Directed Acyclic Graph)

3.3.1 描述表达式

求用有向无环图描述表达式最少需要多少节点,步骤
i.把各个操作数不重复的排成一排
ii.标出各个运算符的生效顺序(先后顺序有点出入无所谓)
iii.按顺序加入运算符,注意“分层”
iv.从底向上逐层检董同层为运算符是否可以合并

例:(a * b) * (a * b) * (a * b) * c

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NUDZwqum-1632555893501)(D:\cskaoyan\图片\有向无环图.png)]

注意:每个操作数最多出现一次

3.3.2 拓扑排序

AOV网(Activity on vertex Network,用顶点表示活动的网)∶
用DAG图〈有向无环图)表示一个工程。顶点表示活动,有向边<Vi,Vj>表示活动Vi必须先于活动Vj进行

拓扑排序:找到做事的先后顺序

定义1:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
①每个顶点出现且只出现一次。
②若顶点4在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。

定义2:拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面。每个AOV网都有一个或多个拓扑排序序列。

拓扑排序的实现(必须是有向无环图):
i.从AOV网中选择一个没有前驱的顶点并输出。
ii.从网中删除该顶点和所有以它为起点的有向边。
iii.重复i和ii直到当前的AOV网为空或当前网中不存在无前驱的顶点为止。

#define MaxvertexNum 100		//图中顶点数目的最大值
typeder struct ArcNode{			//边表结点
	int adjvex;					//该弧所指向的顶点的位置
	struct Arcode *nextarc;		//指向下一条弧的指针 
    //InfoType info;			//网的边权值
}ArcNode;
typedef struct vNode{			//顶点表结点
	vertexType data;			//顶点信息
	ArcNode *firstarc;			//指向第一条依附该顶点的弧的指针
}VNode, AdjList[MaxvertexNum];
typedef struct{
	AdjList vertices;			//邻接表
	int vexnum, arcnum;			//图的顶点数和弧数
}Graph;							//Graph是以邻接表存储的图类型
int indefree[MaxvertexNum]		//当前顶点的入度
int print[MaxvertexNum]			//记录拓扑排序序列
bool Topologicalsort ( Graph G){
    InitStack(S);				//初始化栈,存储入度为0的顶点
	for(int i=0 ; i<G.vexnum;i++)
		if (indegree[i]==0 )
			Push (S,i);			//将所有入度为0的顶点进栈
		int count=0;			//计数,记录当前已经输出的顶点数
		while(!IsEmpty(s) ){	//栈不空,则存在入度为0的顶点
            Pop(S,i);			//栈顶元素出栈
			print[count++]=i;	//输出顶点i
		for( p=c.vertices [ i].firstarc;p;p=P->nextarc ) {
		//将所有i指向的顶点的入度减1,并且将入度减为o的顶点压入栈S
            v=p->adjvex;
            indegree[v] = indegree[v] - 1;
			if(indegree[v] == 0)
				Push (S,v);		//入度为0,则入栈
        }//rof
	}//elihw
	if (count<G.vexnum)
        return false;			//排序失败,有向图中有回路
	else
        return true;			//拓扑排序成功

类似的可以实现逆拓扑排序,此时使用邻接矩阵或逆邻接表储存图比较方便。

void DFSTraverse(Graph G){		//对图G进行深度优先遍历
	for(v=0; v<G.vexnum; ++v)
		visited[v]=FALSE;		//初始化已访问标记数据
	for(v=0; v<G.vexnum; ++v)	//本代码中是从v=0开始遍历
		if(!visited[v])
			DFS(G,v);
}
void DFS(Graph G,int v){		//从顶点v出发,深度优先遍历图G
	visit(v);					//访问顶点v
	visited[v]=TRUE;			//设已访问标记
	for(w=FirstNeighbor(G,v ); w>=0; w=NextNeighor(G,v,w)){
		if(!visited [w]){		//w为u的尚未访问的邻接顶点
			DFS(G,w);
		}//fi
    }//rof
    print(v);					//输出顶点
}
3.3.3 关键路径

在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)

AOE网具有以下性质:
i.只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
ii.只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。另外,有些活动是可以并行进行的;
iii.在AOE网中仅有一个入度为O的顶点,称为开始顶点(源点),它表示整个工程的开始;也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。

从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。

活动a的最早开始时间e(i):指该活动弧的起点所表示的事件的最早发生时间
活动a的最迟开始时间l(i):它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
活动a的时间余量iHN)-e(i):表示在不增加完成整个工程所需总时间的情况下,活动a可以拖延的时间。
若一个活动的时间余量为零,则说明该活动必须要如期完成,d(i)=0即l(i)=e(i)的活动a是关键活动由关键活动组成的路径就是关键路径.

步骤:
i.求所有事件的最早发生时间ve( ),当前活动前的活动最快结束的时间
按照拓扑排序序列,依次求各个顶点的ve( ):
ve(源点) = 0; ve(k) = Max{ve(j) + Weight(Vj, Vk)} Vj为Vk的任意前驱

ii.求所有事件的最迟发生时间vl( ),当前活动最晚开始的时间
按逆拓扑排序序列,依次求各个顶点的vl( ):
vl(汇点) = ve(汇点); vl(k) = Min{vl(j) - Weight(Vk, Vj)} Vj为Vk的任意后继

iii.求所有活动的最早发生时间e( ),活动弧的起点事件的最早开始时间
若边<Vk, Vj>表示活动ai,则有e(i)=ve(k)

iv.求所有活动的最迟发生时间I( ),活动弧的终点的最迟发生时间与该活动所需时间的差
若边<Vk, Vj>表示活动ai,则有l(i)=vl(j) - Weight(Vk, Vj)

v.求所有活动的时间余量d( ), d( ) = 0的活动为关键活动(边)
d(i) = l(i) - e(i), d(i) = 0 的活动对应的顶点构成一条路径

若关键活动耗时增加,则整个工程的工期将增长;缩短关键活动的时间,可以缩短整个工程的工期;
当缩短到一定程度时,关键活动可能会变成非关键活动;
可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。

第七章 查找

1.查找的基本概念

1.1 基本概念

查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找
查找表(查找结构):用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成
关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。

1.2 基本操作

a.查找符合条件的数据元素;b.插入、删除某个数据元素

只需要进行a操作的是静态查找表,仅关注查找速度即可
需要进行a,b两周操作的是动态查找表,除了查找速度,也要关注插入、删操作是否方便实现。

1.3 查找算法的评价标准

查找长度:在查找运算中,需要对比关键字的次数称为查找长度
平均查找长度(ASL,Average Search Length):所有查找过程中进行关键字的比较次数的加权平均值,分为查找成功的ASL,与查找失败的ASL。

2.顺序查找与折半查找(二分)

2.1 顺序查找

顺序查找又称为线性查找,通常用于线性表(数组,链表),顺序遍历整个表。

2.1.1 算法实现
typedef struct{			//查找表的数据结构(顺序表)
	ElemType *elem;		//动态数组基址
	int TableLen;		//表的长度
}SSTable;
//顺序查找
int Search_Seq(ssTable ST,ElemType key){
	int i;
	for(i=0; i<ST.TableLen && ST.elem[i] !=key; ++i);
    //查找成功,则返回元素下标;查找失败,则返回-1
	if(i==ST.TableLen) return -1;	//查找失败
    else return i;					//查找成功
}
//顺序查找
int Search_Seq_flag(sSTable sT,ElemType key){
	ST.elem[0]=key;					//“哨兵",存放在零号位置,数据从1开始存储
	int i;
	for(i=ST.TableLen; ST.elem[i]!=key; --i); //从后往前找
         return i;					//查找成功,则返回元素下标;查找失败,则返回0
}

查找成功ASL = (n+1)/2;查找失败ASL = n+1;时间复杂度:O(n)

2.1.2 算法优化

查找判定树分析ASL:
—个成功结点的查找长度=自身所在层数;
—个失败结急的查找长度=其父节点所在层数;
默认情况下,各种失败情况或成功情况都等概率发生。

当序列为有序表时:

与当前元素进行比较,判断后面是否有可能出现查找目标。
查找失败ASL=(1+2+···+n+n)/(n+1)=n/2+n/(n+1),查找成功ASL不变,时间复杂度不变。

当序列内元素被查找概率不等时:

被查概率大的放在靠前位置。
查找成功ASL = Σ(i*pi),查找失败ASL不变,时间复杂度不变。

2.2 折半查找(二分)

折半查找,又称“二分查找”,仅适用于有序的顺序表。检查中间位置的元素与所查找元素的大小,判断在左侧还是右侧。

typedef struct{				//查找表的数据结构(顺序表)
	ElemType *elem;			//动态数组基址
	int TableLen;			//表的长度
}SSTable;
//折半查找
int Binary_search( ssTable L,ElemType key){
	int low=e, high=L.TableLen-1,mid;
	while(low<=high){
		mid=(low+high)/2;	//取中间位置
		if(L.elem[mid]=-key)
			return mid;		//查找成功则返回所在位置
		else if(L.elem[mid]>key)
			high=mid-1;		//从前半部分继续查找
		else
			low=mid+1;		//从后半部分继续查找
	}
	return-1;				//查找失败,返回-1
}

利用查找判定树求查找成功ASL 与查找失败ASL;
树高为⌈log2 (n+1)⌉时间复杂度为log2 n

如果当前low和high之间有奇数个元素,则 mid分隔后,左右两部分元素个数相等
如果当前low和high之间有偶数个元素,则mid分隔后,左半部分比右半部分少一个元素
即:右子树结点数-左子树结点数=0或1,是平衡二叉排序树,且只有最下面一层是不满的

失败节点:n+1个(等于成功节点的空链域数量)

2.3 分块查询

块内无序,块间有序

在这里插入图片描述

//索引表
typedef struct{
	ElemType maxValue;
	int low, high;
}Index;

//顺序表存储实际元素
ElemType List[100];

对索引表二分查找,若索引表中不包含目标关键字,则折半查找索引表最终停在 low>high,要在low所指分块中查找。

若索引表采用顺序查找,ASL = ?
若索引表采用折半查找,ASL = ?

假设,长度为n妁查找表被均匀地分为b块,每块s个元素,n = s*b
设索引查找和块内查找的平均查找长度分别为Li、Ls,则分块查找的平均查找长度为ASL=Li+Ls

若索引表采用顺序查找,Li = (b+1)/2,Ls = (s+1)/2
则ASL = (b+s+2)/2=(s2+2*s+n)/2*s, 当s=√n时,ASL最小=√n + 1

若查找表是“动态查找表”,可以采用链式存储的方式(邻接表)

在这里插入图片描述

3.B树和B+树(重点/难点)

回顾:二叉查找树(BST)

3.1 m叉查找树

每个节点最多有m个分叉,m-1个关键字;最少有两个分叉,1个关键字.

//m=5
struct Node {
	ElemType keys[4];			//最多4个关键字
	struct Node * child[5];		//最多5个孩子
	int num;					//当前节点实际有多少个数据
};

如何提高查找效率(降低树的高度)

i.规定除了根节点外,任何结点至少有[m/2]个分叉,即至少含有[m/2]-1个关键字;
ii.规定对于任何一个结点,其所有子树的高度都要相同。

满足以上两个条件的m阶查找树也称为B树

3.2 B树

3.2.1 B树的基本概念

B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m阶B树或为空树,或为满足如下特性的m叉树:

1)树中每个结点至多有m棵子树,即至多含有m-1个关键字。
2)若根结点不是终端结点,则至少有两棵子树。
3)除根结点外的所有非叶结点至少有⌈m/2⌉棵子树,即至少含有⌈m/2⌉个关键字。
4)所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。
5)所有非叶结点的结构如下:

nP0K1P1K2P3……KnPn

其中,Ki (i = 1, 2…, n)为结点的关键字,且满足K1<K2<…< Kn,即递增;
Pi (i = 0,1…, n)为指向子树根结点的指针,且指针Pi-1所指子树中所有结点的关键字均小于Ki,Pi所指子树中所有结点的关键字均大于Ki;
n(⌈m/2⌉-1≤n≤m -1)为结点中关键字的个数。

在这里插入图片描述

3.2.2 B树的查找效率

问:含n个关键字的m阶B树,最小高度、最大高度是多少?(不包括最后一层的叶子节点)

最小高度:让每个结点尽可能的满,有m-1个关键字,m个分叉,则有n ≤ (m-1)(1+m+m2+m3+···+mh-1) = mh-1,因此h≥logm(n+1);

最大高度:让各层的分叉尽可能的少,即根节点只有2个分叉,其他结点只有⌈m/2⌉个分叉各层结点至少有:第一层1、第二层2、第三层2⌈m/2⌉ …第h层2(⌈m/2⌉)h-2
第h+1层共有叶子结点(失败结点)2(⌈m/2⌉)h-1个,
n个关键字的B树必有n+1个叶子结点,则n+1≥2(⌈m/2⌉)h-1,即h ≤ log⌈m/2⌉(n+1)/2 + 1

核心:
1)根节点的子树数∈[2, m],关键字数∈[1, m-1]。其他结点的子树数∈[⌈m/2⌉, m];关键字数∈[⌈m/2⌉-1, m-1];
2)对任一结点,其所有子树高度都相同;
3)关键字的值:子树0<关键字1<子树1<关键字2<子树2<…(类比二叉查找树左<中<右);
4)含n个关键字的m阶B树,logm(n+1) ≤ h ≤ log⌈m/2⌉(n+1)/2 + 1。

具体的查找方式类比二叉查找树即可

3.2.3 B树的插入(超难点)

核心要求:
1)除了根节点外,其他结点的关键字数n∈[⌈m/2⌉-1, m-1];
2)关键字的值:子树0<关键字1<子树1<关键字2<子树2<…(类比二叉查找树左<中<右)。

新元素一定是插入到最底层“终端节点”,用“查找”来确定插入位置;
在插入key后,若导致原结点关键字数超过上限,则从中间位置([m/2])将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置([m/2])的结点插入原结点的父结点。若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度加1。

3.2.4 B树的删除(超难点)

i.若被删除关键字在终端节点。则直接删除该关键字〈要注意节点关键字个数是否低于下限「m/2⌉-1);
ii.若被删除关键字在非终端节点,则用直接前驱或直接后继来替代被删除的关键字,
直接前驱:当前关键字左侧指针所指子树中“最右下”的元素;
直接后继:当前关键字右侧指针所指子树中“最左下”的元素;
iii.若删除后低于下限,则有以下三种情况:
1)右兄弟够借,则用当前结点的后继、后继的后继依次顶替空缺
2)左兄弟够借,则用当前结点的前驱、前驱的前驱依次顶替空缺
3)左(右)兄弟都不够借,则需要与父结点内的关键字、左(右)兄弟进行合并。合并后导致父节点关键字数量-1,可能需要继续合并。

3.3 B+树

3.3.1 B+树的基本概念

在这里插入图片描述

—棵m阶的B+树需满足下列条件:
1)每个分支结点最多有m棵子树(孩子结点)。
2)非叶根结点至少有两棵子树,其他每个分支结点至少有「m/2⌉棵子树。
3)结点的子树个数与关键字个数相等。
4)所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来
5)所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针。

3.3.2 B+树的操作

查找:从根节点往下朝找,直到找到最后一层叶子节点才能确定成功或失败。
从p指针开始查找,依次遍历顺序查找。

3.3.3 B+树与B树的比较
区别B树B+树
n个关键字对应n+1个子树对应n个子树
根节点关键字数n∈[1, m-1]n∈[1, m]
其他节点关键字数n∈[⌈m/2⌉-1, m-1]n∈[⌈m/2⌉, m]
关键字位置各结点中包含的
关键字是不重复的
叶结点包含全部关键字,非叶结点中
出现过的关键字也会出现在叶结点中
节点内容结点中都包含了关键字
对应的记录的存储地址
叶结点包含信息,所有非叶结点仅起索引作用,
非叶结点中的每个索引项只含有对应子树的最大关键字
和指向该子树的指针,不含有该关键字对应记录的存储地址。
查找方式不支持顺序查找。查找成功时,可能停在
任何一层结点,查找速度“不稳定”
支持顺序查找。查找成功或失败都会到达
最下一层结点,查找速度“稳定”

B+树的优点:在B+树中,非叶结点不含有该关键字对应记录的存储地址。可以使一个磁盘块可以包含更多个关键字,使得B+树的阶更大,树高更矮,读磁盘次数更少,查找更快。
关系型数据库的“索引”(如MySQL)一般适用B+树。

4.散列表(hash)

4.1 基本概念

散列表(Hash Table),又称哈希表。是一种数据结构,特点是:数据元素的关键字与其存储地址直接相关。
同义词:若不同的关键字通过散列函数映射到同一个值,则称它们为“同义词”
冲突:通过散列函数确定的位置已经存放了其他元素,则称这种情况为“冲突”
查找长度:在查找运算中,森要对比关键字的次数称为查找长度,可以为零;
装填因子=表中记录数/散列表长度,装填因子会直接影响散列表的查找效率

拉链法(又称链接法、链地址法)处理“冲突”:把所有“同义词”存储在一个链表中。
例:数据元素关键字分别为{19,14,23,1,68,20,84,27,55,11,10,79},散列函数H(key)=key%13

在这里插入图片描述

ASL成功=(1*6+2*4+3+4)/12=1.75;12表示数据表中一共有12各元素
ASL失败=(0*7+4+2+2+1+2+1)/13=0.92;13表示散列表的长度,数值与装填因子相同

4.2 常见的散列函数

设计目标:让不同关键字的冲突尽可能地少,散列查找是典型的“用空间换时间”的算法,只要散列函数设计的合理,则散列表越长,冲突的概率越低。

1)除留余数法:H(key)=key%p,散列表表长为m,取一个不大于m但最接近或等于m的质数p

2)直接定址法:H(key) = key 或 H(key) = a*key + b,其中,a和b是常数。这种方法计算最简单,且不会产生冲突。
它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。

3)数字分析法:选取数码分布较为均匀的若干位作为散列地址
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等﹔而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。

4)平方取中法:取关键字的平方值的中间几位作为散列地址。
具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。

4.3 开放定址法(重点)

开放定址法:是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为︰
H i = ( H ( k e y ) + d i ) % m H_i = ( H(key) + d_i) \% m Hi=(H(key)+di)%m
i = 0,1,2…, k (k≤m- 1) ,m表示散列表表长;di为增量序列;i可理解为“第i次发生冲突”

注意:采用“开放定址法"时,删除结点次能简单地将被删结点的空问置为空,否则将截断在它之后填入散列表的同义词结点的查找路径,可以做一个“删除标记”,进行逻辑删除;

4.3.1 线性探测法(常考)

di=0,1,2,3, …, m-1;即发生冲突时,每次往后探测相邻的下一个单元是否为空。

例:数据元素关键字分别为{19,14,23,1,68,20,84,27,55,11,10,79},散列函数H(key)=key%13

在这里插入图片描述

H(25) = 25 % 13 = 12,哈希函数值域[0,12]
H=(H(key)+1)%16 = 13,冲突处理函数值域[0,15]

ASL成功=(1+1+1+2+4+1+1+3+3+1+3+9)/12 = 2.5
ASL失败=(1+13+12+11+10+9+8+7+6+5+4+3+2)/13=7

线性探测法很容易造成同义词)非同义词的“聚集(堆积)"现象,严重影响查找效率。

4.3.2 平方探测法(常考)

当di= 02,12,-12,22,-22, …, k2, -k2时,称为平方探测法,又称二次探测法其中k≤m/2。
散列表长度m必须是一个可以表示成4*j+ 3的素数才能探测到所有位置。(完全剩余系相关)

比起线性探测法更不易产生“聚集(堆积)”问题,

在这里插入图片描述

4.3.3 伪随机序列法

di取一串随机数序列用于处理冲突。

4.4 再散列法

再散列法(再哈希法)︰除了原始的散列函数H(key)之外,多准备几个散列函数,当散列函数冲突时,用下一个散列函数计算一个痴地址,直到不冲突为止
H i = R H i ( K e y ) i = 1 , 2 , 3 … … , k H_i = RH_i(Key)\qquad i=1,2,3……,k Hi=RHi(Key)i=1,2,3,k

第八章 排序

数据结构可视化:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html

1.排序的基本概念

1.1 基本概念

排序(Sort),就是重新排列表中改元素,使表少的元素满足按关键字有序的过程。
输入∶n个记录R1, R2… Rn,对应的关字为k1, k2,…, kn
输出∶输入序列的一个重排R1,R2 …,Rn’,使得有k2’≤k2’≤…≤kn’(也可递减)

排序算法的指标:

时间复杂度,空间复杂度
算法的稳定性:若待排序表中有两个元素Ri和Rj,其对应的关键字相同即keyi= keyj,且在排序前Ri在Rj的前面,若使用某一排序算法排事后。R仍然在R的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。

内部排序:数据都在内存中,关注时/空间复杂度;
外部排序:数据太多,无法全部放入内存,关注时/空间复杂度与读/写磁盘的次数。

2.插入排序

2.1 直接插入排序

2.1.1 算法实现

算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。

//直接插入排序
void InsertSort(int *A, int n){
	int i, j, temp;
	for(i=1; i<n; i++){							//将各元素插入已排好序的序列中
		if(A[i]<A[i-1]){						//若A[i]关键字小于前驱
			temp=A[i];							//用temp暂存A[i]
			for(j=i-1; j>=0 && A[j]>temp; --j){	//检查所有前面已排好序的元素
                A[j+1]=A[j];					//所有大于temp的元素都向后挪位
            }
            A[j+1]=temp;						//复制到插入位置
		}
    }
}
//带哨兵的直接插入排序
void InsertSort(int *A, int n){
	int i,j;
	for( i=2;i<=n; i++){				//依次将A[2]~A[n]插入到前面已排序序列
		if(A[i]<A[i-1] ){				//若A[i]关键码小于其前驱,将A[i]插入有序表
			A[0]=A[i];					//复制为哨兵,A[0]不存放元素
			for(j=i-1; A[0]<A[j]; --j){	//从后往前查找待插入位置
                A[j+1]=A[j];			//向后挪位
            }
            A[j+1]=A[O];				//复制到插入位置
		}
    }
}
2.1.2 性能分析
平均时间复杂度O(n)备注
最好的时间复杂度O(n)全部有序
最坏的时间复杂度O(n2)全部逆序
稳定性稳定相等不移动

2.2 折半插入排序

算法思想:先用折半查找找到应该插入的位置,再移动元素。

当low>high时折半查找停止,应将[low, i-1]内的元素全部右移,并将A[0]复制到 low所指位置。
当A[mid]==A[0]/temp时,为了保证算法的“稳定性”,应继续在mid所指位置右边寻找插入位置

//折半插入排序
void InsertSort(int *A ,int n){
	int i, j, low, high, mid;
	for(i=2; i<=n; i++){		//依次将A[2]~A [n]插入前面的已排序序列
		A[0]=A[i];				//将A[i]暂存到A[0]
		low=1; high=i-1;		//设置折半查找的范围
		while(low<=high){		//折半查找(默认递增有序)
			mid=(low+high)/2;	//取中间点
			if(A[mid]>A[0]){
				high=mid-1;		//查找左半子表
            }else low=mid+1;	//查找右半子表
        }
		for(j=i-1;j>=high+1;--j){
			A[j+1]=A[j];		//统一后移元素,空出插入位置
        }
		A[high+1]=A[0];			//插入操作
	}
}

2.3 希尔排序

2.3.1 算法思想

希尔排序︰先追求表中元素部分有序,再逐渐逼近全局有序;先将待排序表分割成若干形如L[i,i + d,i+ 2*d…, i + k*d]的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量重复上述过程,直到d=1为止。

在这里插入图片描述

出题方式:给定序列与d,求操作后的状态

//希尔排序
void ShellSort(int *A, int n){
	int d, i,j; //A[0]只是暂存单元,不是哨兵,当j<=0时,插入位置已到
    for(d=n/2; d>=1; d=d/2){	//步长变化
		for(i=d+1; i<=n; ++i){
			if(A[i]<A[i-d] ){	//需将A[i]插入有序增量子表
				A[0]=A[i];		//暂存在A[0]
				for(j= i-d; j>0 && A[0]<A[j]; j-=d){
                	A[j+d]=A[j];	//记录后移,查找插入的位置
            	}
            	A[j+d]=A[0];		//插入
			}
    	}
    }
}
2.3.2 性能分析

仅适用与顺序表

空间复杂度O(1)常数个
最好的时间复杂度未知可达O(n1.3)
最坏的时间复杂度O(n2)d取1
稳定性不稳定相等可能移动

3.交换排序

基于“交换”的排序︰根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置

3.1 冒泡排序

3.1.1 算法思想

从后往前(或从前往后)两两比较相邻元素的值,若为逆序〈即A[i-1]>A[i]),则交换它们,直到序列比较完。称这样过程为“—趟”冒泡排序。没趟排序确定一个元素的位置,若一趟排序中未发生交换,则排序结束。

//交换
Void swap(int &a, int &b){
    int temp = a;
    a = b;
    b = temp;
}
//冒泡排序
void BubbleSort(int *A, int n){
    for(int i = 0; i < n-1; i++){
        bool flag = false;				//标记本趟冒泡是否发生变化
        for(int j = n-1; j > i; j--){	//一趟冒泡排序
            if(A[j-1] > a[j]){			//若为逆序
                swap(A[j-1] , a[j]);	//交换
                flag = true;
            }
           if(flag == false)	return;	//本趟遍历后未发生交换,表示已经有序
        }
    }
}
3.1.2 性能分析

仅适用与顺序表

空间复杂度O(1)常数个
最好的时间复杂度O(n)有序
最坏的时间复杂度O(n2)逆序
稳定性稳定相等不移动

注意:一次交换有三次操作,一次比较有一次操作。

3.2 快速排序

3.2.1 算法思想

在待排序表L[1…n]中任取一个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分L[1…k-1]和L[k+1…n],使得L[1…k-1]中的所有元素小于pivot,L[k+1…n]中的所有元素大于等于pivot,则pivot放在了其最终位置L(k)上,这个过程称为一次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。

//用第一个元素将待排序序列划分成左右两个部分
int Partition(int *A, int low, int high){
	int pivot=A[low];			//第一个元素作为枢轴
	while(low<high){			//用low、high搜索枢轴的最终位置
		while(low<high && A[high]>=pivot){	--high;}
		A[low]=A[high];			//比枢轴小的元素移动到左端
        while(low<high&&A [low]<=pivot){	++low;}
		A[high]=A[low];			//比枢轴大的元素移动到右端
	}
	A[low]=pivot;				//枢轴元素存放到最终位置
	return low;					//返回存放枢轴的最终位置
}
//快速排序
void QuickSort(int *A, int low, int high){
	if(low<high){							//递归跳出的条件
		int pivotpos=Partition(A,low,high); //划分
        QuickSort(A,low , pivotpos-1);		//划分左子表
        QuickSort(A, pivotpos+1,high);		//划分右子表
	}
}
3.2.2 性能分析
最好的空间复杂度O(log2 n)递归层数
最坏的空间复杂度O(n)本身有序
最好的时间复杂度O(n*log2 n)每次都均匀划分
最坏的时间复杂度O(n2)本身有序(顺/逆)
平均时间复杂度O(n*log2 n)最快
稳定性O(log2 n)不稳定

快速排序算法优化思路:尽量选择可以把数据中分的枢轴元素。
例如:选头、中、尾三全位置的元素,取中间值作为枢轴元素;或者随机选一个元素作为枢轴元素。

4.选择排序

选择排序︰每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列。

4.1 简单选择排序

4.1.1 算法思想

假设排序表为L[1…n],第i趟从L[i…n]中选择关键字最小的元素与L[i]交换,每一堂排序可以确定一个元素的位置。

//交换
Void swap(int &a, int &b){
    int temp = a;
    a = b;
    b = temp;
}
//简单选择排序
void Selectsort(int *A,int n){
    for(inti=0; i<n-1; i++){		//—共进行n-1趟
		int min=i;					//记录最小元素位置
		for(int j=i+1; j<n; j++){	//在A[i...n-1]中选择最小的元素
			if(A[j] < A[min]){
                min=j;				//更新最小元素位置
            }
        }
		if(min!=i) 
            swap(A[i],A[min]);		//封装的swap()函数共移动元素3次
}
4.1.2 性能分析
空间复杂度O(1)常数个
时间复杂度O(n2)不变
稳定性不稳定

注意:一次交换有三次操作,一次比较有一次操作。

4.2 堆排序(难点,常考)

4.2.1 堆

若n个关键字序列L[ 1…n]满足下面某一条性质,则称为堆(Heap) :
大根堆(大顶堆)︰L(i) ≥ L(2i) 且 L(i )≥ L(2i+1) (1 ≤ i ≤ n/2 )
小根堆(小顶堆)︰L(i) ≤ L(2i) 且 L(i) ≤ L(2i+1) (1 ≤ i ≤ n/2 )

回忆:完全二叉树的线性存储

将序列在逻辑上看作是一个完全二叉树的线性存储:
大根堆:完全二叉树中,根 >= 左,右;小根堆:完全二叉树中,根 <= 左,右。

4.2.2 建立大根堆

算法思想:把所有非终端结点都检查一遍,是否满足大根堆的要求,如果不满足,则进行调整。检查当前结点是否满足根≥左、右,若不满足,将当前结点与更大的一个孩子互换;若元素互换破坏了下一级的堆,则采用相同的方法继续往下调整(小元素不断“下坠")

//建立大根堆
void BuildMaxHeap(int A[] ,int len){
for(int i=len/2; i>0; i--)			//从后往前调整所有非终端结点
	HeadAdjust(A,i,len);
}
//将以k为根的子树调整为大根堆
void HeadAdjust(int A[] ,int k,int len){
	A[0]=A[k];						//A[0]暂存子树的根结点
	for(int i=2*k; i<=len; i*=2){	//取key较大的子结点向下筛选
		if(i<len&&A[i]<A[i+1])
			i++;					//取key较大的子结点的下标
		if(A[0]>=A[i]) break;		//筛选结束
		else{
			A[k]=A[i];				//将A[i]调整到双亲结点上
			k=i;					//修改k值,以便继续向下筛选
		}
	}
	A[k]=A[0];						//被筛选结点的值放入最终位置
}
4.2.3 基于大根堆排序

堆排序( 基于大根堆)︰每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换),并将待排序元素序列再次调整为大根堆(小元素不断“下坠”),最终形成一个递增序列。

//堆排序的完整逻辑
void HeapSort(int A[] ,int len){
	BuildMaxHeap(A,len);		//初始建堆
	for(int i=len; i>1; i--)	//n-1趟的交换和建堆过程
		swap(A[i],A[1]);		//堆顶元素和堆底元素交换
		HeadAdjust(A,1,i-1);	//把剩余的待排序元素整理成堆
	}
}
4.2.4 性能分析

考虑**BuildMaxHeap()**函数,一个结点,每“下坠”一层,最多只需对比关键字2次,若树高为h,某结点在第i层,则将这个结点向下调整最多只需要“下坠" h-i层,关键字对比次数不超过2(h-i)。

n个结点的完全二叉树树高h=[log2 n] +1;
第i层最多有2i-1个结点,而只有第1~(h-1)层的结点才有可能需要“下坠"调整
将整棵树调整为大根堆,关键字对比次数不超过4n;
∑ i = 1 h − 1 2 i − 1 ∗ 2 ( h − i ) = ∑ i = 1 h − 1 2 i ( h − i ) = ∑ j = 1 h − 1 2 h − j ∗ j ≤ 2 n ∑ j = 1 h − 1 j 2 j ≤ 4 n \sum_{i=1}^{h-1} 2^{i-1}*2(h-i) = \sum_{i=1}^{h-1} 2^{i}(h-i)=\sum_{j=1}^{h-1} 2^{h-j}*j\le2n\sum_{j=1}^{h-1}\frac {j} {2^j}\le4n i=1h12i12(hi)=i=1h12i(hi)=j=1h12hjj2nj=1h12jj4n
建堆的过程,关键字对比次数不超过4n,建堆时间复杂度=O(n)

根节点最多“下坠”h-1层,每下坠一层
而每“下坠”一层,最多只需对比关键字2次,因此每一趟排序复杂度不超过O(h)= O(log2 n)
共n-1趟,总的时间复杂度为O(nlog2 n)

空间复杂度O(1)常数个
时间复杂度O(nlog2 n)不变
稳定性不稳定
4.2.5 在堆中插入和删除

插入:新元素放于表尾,然后调用HeadAdjust函数使新元素上升至无法上升为止;

删除:用表尾元素代替被删除元素,然后调用HeadAdjust函数使该素下降至无法下降为止;

注意:调整的过程中对比关键字的次数。

5.归并排序和基数排序

5.1 归并排序

归并:把两个或多个已经有序的序列合并成一个,合并n个序列称为n路归并,每选出一个元素需要对比关键字n-1次

5.1.1 算法思想

将两个有序序列合并为一个有序序列

int *B=(int *)malloc(n*sizeof(int));	//辅助数组B
//A [ low...mid ]和A[ mid+1...high]各自有序,将两个部分归并
void Merge(int A[], int low, int mid, int high){
	int i,j,k;
	for( k=low ; k<=high; k++)
		B[k]=A[k];			//将A中所有元素复制到B中
	for(i=low,j=mid+1,k=i; i<=mid&&j<=high; k++){
		if(B[i]<=B[j])
			A[k]=B[i++];	//将较小值复制到A中
		else
            A[k]=B[j++];
	}//for
	while(i<=mid)	A[k++]=B[i++];
	while(j<=high)	A[k++]=B[j++];
}
void MergeSort(in A[], int low,int high){
	if(low<high){
		int mid=(lowthigh)/2;			//从中间划分
		MergeSort(A, low, mid);			//对左半部分归并排序
        MergeSort(A, mid+1, high);		//对右半部分归并排序
    	Merge(A, low, mid, high);		//归并
	}//if
}
5.1.2 性能分析

二路归并的“归并树”:形态上就是一颗倒立的二叉树

空间复杂度O(n)复制一份
时间复杂度O(nlog2 n)不变
稳定性稳定

5.2 基数排序

5.2.1 算法思想

假设长度为n的线性表中每个节点aj的关键字由d元组(kd-1j, kd-2j, kd-3j, …, k1j, k0j)
其中,0 ≤ kij ≤r-1 (0 ≤ j < n, 0 ≤ i ≤ d-1), r称为基数

基数排序得到递减序列的过程如下
初始化:设置r个空队列,Qr-1,Qr-2,…,Q0
按照各个关键字位 权重递增的次序(个、十、百),对d个关键字位分别做“分配”和“收集”
分配:顺序扫描各个元素,若当前处理的关键字位=x,则将元素插入Q队尾
收集:把Qr-1,Qr-2,…,Q0各个队列中的结点依次出队并链接

常考:手算

在这里插入图片描述

typedef struct LinkNode{
    ElemType data;
    struct LinkNode *next;
}LinkNode, *LinkList;

typedef struct{					//链式队列
    LinkNode *front, *rear;		//队列的对头和队尾指针
}LinkQueue;
5.2.2 性能分析
空间复杂度r个辅助队列
时间复杂度O(d(n+r))一趟分配O(n)
一趟收集O®
总共d趟分配、收集
稳定性稳定

基数排序擅长解决的问题:
i.数据元素的关键字可以方便地拆分为d组,且d较小
ii.每组关键字的取值范围不大,即r较小
iii.数据元素个数n较大

6.外部排序

6.1 外存&内存之间的数据交换

操作系统以“块”为单位对磁盘存储空间进行管理,如:每块大小1KB,各个磁盘块内存放着各种数据;
磁盘的读/写是以“块”为单位的,数据读入内存后才能被修改,修改完了再写回磁盘。

6.2 外部排序的方法

外部排序:数据元素太多,无法依次全部读入内存进行排序,通常采用归并排序的方法。使用“归并排序”的方法,最少只需在内存中分配3块大小的缓冲区即可对任意一个大小的文件进行排序。

步骤:
i.根据内存缓冲区大小,将外存上的文件分成若干个长度为l的子文件,依次读入内存并利用内部排序算法进行排序,将得到的有序子文件重新写会外存中,称这些有序子文件为归并段顺串
ii.对这些归并段进行逐趟归并,使归并段逐渐由小到大,直至得到整个有序文件位置。
注意:设内存中设置了两个输入缓冲区input_1, input_2,一个输出缓冲区output,且长度均为n。当归并段长度大于n时,假设归并段A[2n],B[2n]。
首先将A[1, n]与B[1, n]分别读入input_1, input_2,并将归并的结果存入output:
当output满时将output中得内容全部写入到外存中;当input_1或input_2有一个为空时,假设input_1为空,需要将A[n+1, 2n]读入input_1后,再继续进行归并。
在这里插入图片描述

优化的方向:减少归并趟数,对r个初始归并段,做k路归并,则归并树可用k叉树表示,若树高为h,则归并趟数=h-1 = 向上取整(logk r),由此可知k叉树第h层最多有kh-1个结点则r≤kh-1,(h-1)最小= 向上取整(logk r)

因此优化的方法有两个:增大k(多路归并),减小r(增加初始归并段的长度)

多路归并带来的负面影响:
i.在k路归并中,需要开辟k个输入缓冲区,内存开销增加;
ii.每挑选一个关键字需要对比关键字(k-1)次,内部归并所需时间增加。

6.3 多路平衡归并与败者树

k路平衡归并:
i.最多只能有k个段归并为一个;
ii.每一趟归并中,若有m个归并段参与归并,则经过这一趟处理得到[m/k]个新的归并段;

败者树:可视为—个完全二叉树(多了一个根结点 )。k个叶结点分别是当前参加比较的元素,非叶子结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。

在这里插入图片描述

对于k路归并,第一次构造败者树需要对比关键字k-1次,有了败者树,选出最小元素,只需对比关键字「log2 k⌉次(败者树的高度)

实际上败者树只右上方灰色的结点构成,下方绿色节点即为每个归并段当前队段首的元素。

6.4 置换选择排序

设初始待排文件为FI,初始归并段输出文件为FO,内存工作区为WA,FO和WA的初始状态为空,WA可容纳w个记录。置换-选择算法的步骤如下:
i.从FI输入w个记录到工作区WA。
ii.从WA中选出其中关键字取最小值的记录,记为MINIMAX记录。
iii.将MINIMAX记录输出到FO中去。
iv.若FI不空,则从FI输入下一个记录到WA中。
v.从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小关键字记录,作为新的MINIMAX记录。
vi.重复iii~v,直至在WA中选不出新的MINIMAX记录为止,由此得到一个初始归并段,输出一个归并段的结束标志到FO中去。
vii.重复ii~vi,直至WA为空。由此得到全部初始归并段。

在这里插入图片描述

6.5 最佳归并树

二路归并的情况下:选最小的两个归并段进行归并(哈夫曼树)。

归并树得带全路径长度WPL=读/写磁盘的次数

多路归并二路归并类似,不过要注意的是,对于k叉归并,若初始归并段的数量无法构成严格的k叉归并树,则需要补充几个长度为0的“虚段”,再进行k叉哈夫曼树的构造。

k叉的最佳归并树一定是一棵严格的k叉树,即树中只包含度为k、度为0的结点。设度为k的结点有n个,度为0的结点有n0个,归并树总结点数=n 则:

初始归并段数量 + 虚段数量 = n0 (叶子节点的数量)
{ n = n 0 + n k , 只有度数为0和k的节点 k n = n − 1 , 树的度数和为n-1 − > n 0 = ( k − 1 ) n k + 1 − > n k = n 0 − 1 k − 1 \begin{cases} n = n_0 + n_k, & \text {只有度数为0和k的节点} \\ kn=n-1, & \text{树的度数和为n-1} \end{cases}\quad->\quad n_0=(k-1)n_k+1\quad->\quad n_k=\frac{n_0-1}{k-1} {n=n0+nk,kn=n1,只有度数为0k的节点树的度数和为n-1>n0=(k1)nk+1>nk=k1n01
i.若(初始归并段数量-1)%(k-1)= 0,说明刚好可以构成严格k叉树,此时不需要添加虚段
ii.若(初始归并段数量-1) %(k-1)= u ≠ 0,则需要补充(k-1)- u个虚段。

  • 4
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
浙大数据结构课后习题包括两部分。一部分是课程中给出的思考题,另一部分是每周的编程作业。 对于思考题,除去那些一眼就能看出来答案的或者过于简单的问题,解题思路都会被提供。这些思考题通常与课程内容相关,旨在帮助学生巩固知识和培养解决问题的能力。 对于每周的编程作业,解答和构建程序的思路也会一并给出。这些编程作业涵盖了不同的数据结构算法,比如线性表、堆栈和二叉树等。一些例子包括:合并两个有序链表序列、一元多项式的乘法与加法运算、反转链表和判断一个序列是否是给定堆栈的弹出序列等。 通过完成这些习题和编程作业,学生可以进一步加深对数据结构的理解,并提升自己的编程能力。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [数据结构浙江大学 全部思考题+每周练习答案(已完结)](https://blog.csdn.net/tiao_god/article/details/104987342)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* [MOOC浙大数据结构课后题记录——PTA数据结构题目集(全)](https://blog.csdn.net/qq_45890533/article/details/107131440)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值