【笔记整理 - 数据结构】树 图 KMP 排序 查找

层次遍历

辅助变量:1个队列

思路:根节点入栈,while(队列非空):出队,访问,左孩子入队、右孩子入队。

void level(BTNode* bt)
{
	int front, rear;//rear指向元素、front指向位置
	front = rear = 0;
	BTNode* que[MaxSize];
	BTNode* p;
	if (bt)
	{
		rear = (rear + 1) % MaxSize;
		que[rear] = bt;
		while (front != rear)
		{
			front = (front + 1) % MaxSize;
			p = que[front];
			//do something with p
			if (p->lchild)
			{
				rear = (rear + 1) % MaxSize;
				que[rear] = p->lchild;
			}
			if (p->rchild)
			{
				rear = (rear + 1) % MaxSize;
				que[rear] = p->rchild;
			}
		}
	}
}

非递归方式实现树的遍历

先序非递归遍历

辅助变量:1个栈、1个遍历用指针p。

思路:先将根节点入栈,while(栈非空):出栈,访问,右孩子入栈、左孩子入栈。

void NR_PreOrder(BTNode* bt)
{
	if (bt)
	{
		BTNode* Stack[MaxSize];
		int top = -1;
		BTNode* p;

		Stack[++top] = bt;
		while (top != -1)
		{
			p = Stack[top--];
			//do something with p
			if(p->rchild)
				Stack[++top] = p->rchild;
			if (p->lchild)
				Stack[++top] = p->lchild;
		}
	}
}

中序非递归遍历

思路不好直接转为代码,代码稍微花点精力记忆。

辅助变量:1个栈、1个遍历用指针p。

思路:

指针p从根节点开始沿着左分支一直找到树的最左节点,将路径上遇到的所有节点入栈

指针p走到头后必定会指向 nullptr,此时:出栈访问出栈节点,检查出栈节点是否有右子树

如果有右子树,指针p指向右子树,代码会重复上述步骤找到右子树的最左节点;

如果没有右子树,指针仍指向nullptr继续出栈、访问、检查

void NR_InOrder(BTNode* bt)
{
	if (bt)
	{
		BTNode* Stack[MaxSize];
		int top = -1;
		BTNode* p;

		p = bt;
		while (top != -1 || p)
		{
			while (p)
			{
				Stack[++top] = p;
				p = p->lchild;
			}
//只有当p将当前子树的最左结点入栈后才会执行下方代码
			if (top != -1)
			{
				p = Stack[top--];
				//do something with p
				p = p->rchild;
			}
		}
	}
}

后序非递归遍历

方法一:使用2个栈

后序遍历序列逆序相当于原二叉树镜像翻转后,再进行先序遍历得到的结果。

辅助变量:2个栈、1个遍历用指针p。

函数主体分2部分:一、基本上就是先序非递归遍历,将左右子树入栈顺序颠倒;二、出栈,直接得到结果。

void NR_PostOrder(BTNode* bt)
{
	if (bt)
	{
		BTNode* Stack1[MaxSize];
		int top1 = -1;
		BTNode* Stack2[MaxSize];
		int top2 = -1;
		BTNode* p;

		Stack1[++top1] = bt;
// 第一个while,以非递归方式执行树的镜像的先序遍历(改为先左节点入栈,再右节点入栈)
		while (top1 != -1)
		{
			p = Stack1[top1--];
			
			// 原本访问结点的操作改为入栈2
			Stack2[++top2] = p;
			
			if (p->lchild)
				Stack1[++top1] = p->lchild;
			if (p->rchild)
				Stack1[++top1] = p->rchild;
		}
// 栈2持续出栈并访问出栈结点
		while (top2 != -1)
		{
			p = Stack2[top2--];
			//do something with p
		}
	}
}
方法二:使用1个栈

整体结构与中序非递归遍历相同,“定位到最左侧结点”之后的处理不一样。

void NR_PostOrder_v2(BTNode* bt)
{
	BTNode* Stack[MaxSize];
	int top = -1;
	BTNode* p = bt;
	// 指针r用于标记最近访问过的结点
	BTNode* r = nullptr;
	while (p || top != -1)
	{
		// 沿着左分支一直找到树的最左结点,与中序遍历相同
		if (p)
		{
			Stack[++top] = p;
			p = p->lchild;
		}
		else
		{
			// 当p沿左分支走到头后,检查(不弹出)栈顶结点
			p = Stack[top];
			
// 如果栈顶结点有右子树,且没有被访问过,将右子树根结点入栈,进入下一轮while循环。否则访问栈顶结点,并用r标记。
			if (p->rchild && p->rchild != r)
				p = p->rchild;
			else
			{
				--top; // 栈顶结点出栈
				// do something with p
				r = p; // 指针r标记刚访问的结点p
				p = nullptr;// 指针p置为NULL,以让栈能继续弹出结点
			}
		}
	}
}

邻接表:1个顶点数组和多条链表。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XQAcvVQ9-1632298156300)(D:\!!C++\学习笔记\数据结构\数据结构.assets\image-20201126153653037.png)]

//边结点
typedef struct ArcNode
{
	
	int adjvex;		// 该边所指向的顶点(有向图) 或 连接的另一个顶点(无向图)
	struct ArcNode* nextarc;	// 同一起点的另一条边
	int info;		// 权值,可以忽略
}ArcNode;

//顶点结点
typedef struct VNode
{
	char data;		//顶点信息,如序号
	ArcNode* firstArc;	// 与该顶点邻接的第一条边(“第一条”是随便选的,没有严格定义)
}VNode;


typedef struct AGrahp
{
	VNode adjlist[MaxSize];
	int n, e;
};

DFS、BFS都能直接遍历连通图。对于非连通图的遍历,将两种算法放入一个for循环,对所有顶点调用一次即可。

DFS

可用于无向图和有向图。有向图中存在弧<vi,vj>,vj被看作vi的邻接点,但vi不被看做vj的邻接点。

代码的结构和思路都很简单,主要困难在于对图的结构不熟悉。

思路:

从一顶点v出发,将顶点v标记为已访问,然后选取与顶点v相邻且未被访问的任一顶点w,并访问它;将顶点w标记为已访问,再选取与w相邻且未被访问的顶点,重复上述操作。

当一个顶点的所有邻接点都被访问时,退回之前访问过的顶点,检查之前的顶点是否还有相邻点未被访问。

代码(邻接表):

DFS是个递归函数,只有1层while,即“当前函数只处理好这一层即可,余下的交给递归的其它层函数”

对于一层递归的DFS来说,只要对当前顶点v所有未被访问的邻接点调用一次DFS就行了。

bool visit[MaxSize];	// 用于标记哪个顶点已被访问

void DFS(AGraph& G, int v)
{
	ArcNode* p;
	
	visit[v] = true;
	// do someshing with vertex v
	
	// 对v的所有未被访问的邻接点递归调用DFS就完事了
	p = G.verlist[v].firstArc;
	while (p)
	{	//如果该边p的另一端顶点未被访问,则递归调用DFS
		if (visit[p->adjvex] == 0)
			DFS(G, p->adjvex);
		//检查下一条与当前顶点v连接的边
		p = p->nextarc;
	}
}

如果图G是非连通图,则要在DFS()外套上一个for循环,对图G中的所有顶点调用一次DFS()

因为图的邻接矩阵唯一,邻接表不唯一,所以基于邻接矩阵的DFS序列和BFS序列唯一,基于邻接表的DFS序列和BFS序列不唯一。

BFS

类似树的层次遍历。

思路:

从一顶点v出发,访问与之相邻的所有顶点w1wn,再依次访问w1,w2,……,wn~的所有邻接点,重复上操作,直到访问所有顶点。

**代码(邻接表):**需要1个队列

BFS是非递归函数,2层while循环。

注意:与树的层次遍历不同,先访问、标记,再入栈

bool visit[MaxSize];

void BFS(AGraph& G, int v)
{
	ArcNode* p;
	int que[MaxSize], front = 0, rear = 0;
	int j;
	
	//do someshing with vertex v
	visit[v] = 1;
	
	//先对顶点v执行操作,再将顶点序号入队
	rear = (rear + 1) % MaxSize;
	que[rear] = v;
	
	// while(队非空)
	while (front != rear)
	{	// 队头顶点出队
		front = (front + 1) % MaxSize;
		j = que[front];
		
		// p定位到刚出队顶点的第一条边
		p = G.verlist[j].firstArc;
		
		// 与DFS的while(p)结构完全一致
		while (p)
		{
			//如果邻接结点未被访问,则将其访问后入队,检查下一条边
			if (visit[p->adjvex] == 0)
			{
				//do someshing with vertex j
				visit[p->adjvex] = 1;
				
				rear = (rear + 1) % MaxSize;
				que[rear] = p->adjvex;
			}
			p = p->nextarc;
		}
	}
}

!!注意!!:必须先访问后才能将顶点入队。如果调换顺序,先入队,出队后才访问,会导致一些顶点会被重复访问

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lpjh0E3Z-1632298156304)(D:\!!C++\学习笔记\数据结构\数据结构.assets\image-20201127143026430.png)]

如图所示,从A开始遍历,如果出队了才访问顶点,D、E会被访问2次:当p遍历到A、C时,D都未被访问,都会被放入队列,使得D在队列中出现2次。因为D的错误又导致E的错误入队2次。

最小生成树

**最小生成树:**一个连通图的生成树图的极小连通子图,它包括图中的所有n个顶点,且只含n-1条边。
对于最小生成树,减去任一条边都会使它变成非连通图;在任一位置增加一条边都会形成一条回路。

Prim算法

思路:

从图中任取一个顶点,把它当做一棵树,从与这棵树相接的边中取一条最短(权值最小)边,并将这条边以及另一端的顶点并入树中,树中的节点数+1。继续从与这棵树相接的边中选取最短边……当图中所有顶点都并入树中后,得到的树就是最小生成树。

从任一顶点开始,不断地将新的顶点纳入集合中,最终形成树。

代码思路:

需要2个数组,vset[]lowcost[]

vset[i]=1表示顶点i已被并入树中;vset[i]=0表示顶点i未被并入树中。

lowcost[]数组存放从当前生成树到剩余各顶点最短边的权值,每次并入新的顶点后都要更新。

​ 1、从图中某一顶点v0开始,用v0到其它顶点的距离初始化lowcost[];

​ 2、将与v0相接的所有边当作候选边;

​ 3、从候选边中选出权值最小的边,将边另一端相接的顶点v并入树中;
用刚并入的顶点v更新lowcost[]:如果(v,vi)的权值比lowcost[i]小,则用(v,vi)的权值更新lowcost[i]。

​ 4、重复2、3步骤,直到图的所有顶点并入生成树中。

代码:

使用的是邻接矩阵。为了让下方代码能正常运行,矩阵中2个不相邻的顶点对应的矩阵值必须用INT_MAX一类的数值很大的值来表示。

void Prim(MGraph& g, int v0, int& sum)
{
	int lowcost[MaxSize], vset[MaxSize];
	int v, min;
	
	//初始化lowcost[]数组和vset[]数组。
	for (int i = 0; i < g.n; ++i)
	{
		lowcost[i] = g.edges[v0][i];
		vset[i] = 0;
	}
	
	vset[v0] = 1;// 将起始顶点v0并入生成树。
	sum = 0;// sum用于记录生成树的所有权值和。这道算法的唯一收获
	
	// 这个for循环只是为了让内部代码执行n-1次,i并不会在代码段中用到
	for (int i = 0; i < g.n - 1; ++i)
	{
		min = INF;//INF定义为一个比图中所有边权值都大的常量。
		
		// 在lowcost[]中找到“权值最小的边”。
		for (int j = 0; j < g.n; ++j) 
			if (vset[j] == 0 && lowcost[j] < min)
			{
				v = j; 				// 记录另一端的顶点序号
				min = lowcost[j]; 	// 记录最小权值
			}
		vset[v] = 1;// 将“权值最小的边”另一端的顶点并入生成树

		sum += min; // 统计最小生成树权值和
		
// 检查所有未并入生成树的顶点k,
// 进行确认:“新并入的顶点v是否为生成树提供了到剩余顶点k的更短路径。”
// 如果是,则更新lowcost[k]的值
		for (int k = 0; k < g.n; ++k)
			if (vset[k] == 0 && g.edges[v][k] < lowcost[k])
				lowcost[k] = g.edges[v][k];
	}
} 

最后的sum参数的值是这道算法的唯一收获,也许可以结合lowcost[]和原图信息G得到最小生成树?

时间复杂度为O(n2),只与图的顶点数n有关,与边数无关。所以普里姆算法适用于边稠密的图。

Kruskal算法

思路:

从图的所有边中选择权值最小的边,如果加入该边后不构成回路,将边的两端顶点合并为一棵树。当图的所有顶点被并入同一棵树后,就得到了最小生成树。

代码思路:

判断是否会产生回路需要用到并查集(树的双亲存储结构)

typedef struct // 带权值的边
{
	int a,b;
	int w;
}Road;

Road road[MaxSize];	// 存有图的所有边的权值

//v[]是顶点的并查集,初始状态顶点v[i] = i,即每个顶点自成一棵树
int v[MaxSize];		

int getRoot(int a)
{
	// 沿着父结点一直往上找到根结点
	while(a != v[a])
		a = v[a];
	return a;
}

代码:

void Kruskal(MGraph& g, int& sum, Road road[])
{
	int a, b;
	sum = 0;
	
	for (int i = 0; i < g.n; ++i)//初始化并查集
		v[i] = i;
		
	// 对road数组中的元素按边的权值从小到大排序
	sort(road, g.e);
	
	for (int i = 0; i < g.e; ++i)
	{	// 从权值最小边开始,获取边的两端顶点所在的树的根
		a = getRoot(road[i].a);
		b = getRoot(road[i].b);
		if (a != b)//如果边的两端顶点不属于同一棵树,将road[i].a所在的子树并入road[i].b所在子树。
		{
			v[a] = b;//顶点a不再作为根结点,父结点改为b
			sum += road[i].w;
		}
	}
}

并查集v[]的意义仅用来检查是否会构成回路,而v[]对应的树的结构长什么样根本无所谓,与最小生成树的结构无关。

时间复杂度为O(eloge),与边数e有关,适用于稀疏图。

并查集压缩路径

减少并查集的树的高度。图解看https://www.runoob.com/data-structures/union-find-compress.html

改进后的代码:

int getRoot(int a)
{
	// 沿着父结点一直往上找到根结点
	while(a != parent[a])
	{
		parent[a] = parent[parent[a]];
		a = parent[a];
	}
	return a;
}

最优的压缩方式:压缩成2层

int getRoot(int a)
{
	// 沿着父结点一直往上找到根结点
	if(a != parent[a])
		parent[a] = getRoot(parent[a]);
	return a;
}

KMP算法

思路

假设有字符串A作为匹配串

A:ababababca

有字符串B作为模式串

B:abababca

对于字符串B,有以下数据:

index:	0 1 2 3 4 5 6 7
B:		a b a b a b c a
value[]:0 0 1 2 3 4 0 1

value[i]的值是 0到i 范围内的字符串前缀集合后缀集合交集最长元素的长度

i = 0: 无;

i = 1: ab 前缀集合{a},后缀集合{b};

i = 2: aba 前缀集合{a、ab},后缀集合{ba、a};

i = 3: abab 前缀集合{a、ab、aba},后缀集合{bab、ab、b};

i = 4: ababa 前缀集合{a、ab、aba、abab},后缀集合{baba、aba、ba、a};

i = 5: ababab 前缀集合{a、ab、aba、abab、ababa},后缀集合{babab、abab、bab、ab、b};

…………

通过前后缀交集的最长元素,确定模式串从哪一位开始继续与主串的i位置进行匹配。

假设匹配进行到如下阶段:

            i
a b a b a b a b c d
a b a b a b c d
            j
/*=================================================*/
A[i]与B[j]的字符不匹配,这也就意味着:A中[i-j, i-1]区间的字符与B中[0, j-1]区间的字符完全匹配!

从上方B的数据中可得知,完全匹配的子字符串是“a b a b a b”,该子串的value[]值为4,意思是“a b a b a b”长度为4的前缀==长度为4的后缀。

!这也就意味着,A中[i-4, i-1]区间的字符串 == B中[j-4, j-1]区间的字符串 == B中[0, 3]区间的字符串!!!

所以可以直接快进到 “B中[0, 3]区间的字符串”与“A中[i-4, i-1]区间的字符串已经匹配完毕” ,直接从B[4]与A[i]开始进行匹配。
而不用像最简单暴力匹配那样,回溯i和j,从B[0]与A[i-j+1]开始匹配


从上一段可以得出:当遇到A[i]与B[j]的字符不匹配时,指针i不需要回溯至i-j+1,指针j也不需要回溯至0;
指针i的位置不需要移动,指针j直接从B的第五个字符(下标为4)开始与A的第i+1个字符(下标为i)开始匹配。

代码主体

代码1:(伪代码)

while (i < n1 && j < n2)
{
	if (haystack[i] == needle[j]) // 字符匹配
	{	
		++i;
		++j;
	}
	else if(j == 0) //不匹配且j==0,模式串的第一个字符就不匹配
		// j不变(仍等于0,从头开始匹配)
		++i;
	else //不匹配且j!=0
		// i不变
		j = value[j-1];
}

一共有3个判断条件。标准KMP算法中,将value[]的值都向右移动一位,最左侧的空缺用-1填补,得到next[]

index:	0 1 2 3 4 5 6 7
B:		a b a b a b c a
next[]:-1 0 0 1 2 3 4 0 

原来的value[7]不需要,因为只有当B[i]位置发送不匹配时,才需要用到value[i-1]的值,当B[7]匹配时,算法就已经结束了,绝不会也不能访问B[8]。

通过next[0] = -1的数据设置,将2个判断条件合并为1个(当 j == -1 时就意味着经历过第一个字符不匹配的情况了)。

为什么是-1?

-1是为了编程方便,-1++后刚好为0,指向第一个字符,把上方代码的前2个分支合并成了1个。

代码2:

while (i < n1 && j < n2)
{
    if (j == -1 || haystack[i] == needle[j])
    {
        ++i;
        ++j;
    }
    else
        j = next[j];
}

补充一点(可能会干扰理解):当遇到“不匹配且j==0”的情况时,代码1能立即移动指针i,而代码2要先将指针j置为-1,再到下一轮循环才会移动指针i

对于特殊情况会多一轮循环处理,但问题不大,最重要的是代码更易理解。

辅助函数求next[]

除非想深究原理,不然直接记代码就行了,基本上和KMP的代码主体一致。

void getNext(char *p, int *next)
{
	next[0] = -1;
	int i = 0, j = -1;

	while (i < strlen(p))
	{
		if (j == -1 || p[i] == p[j]) // p[i]与p[j]
		{
			++j;
			++i;
			next[i] = j; //仅多了这一行,其余与KMP主体基本一致
		}	
		else
			j = next[j];
	}
}

想要深究细节时再看

因为对于单个字符A[0],根本没有前缀后缀的说法。所以“next[1] = 0;”基本上也是固定的了,只是没有显式写出来。

“求next数组的过程完全可以看成字符串匹配的过程,以主问题模式串为子问题主字符串,以主问题模式串的**前缀**为子问题模式串,一旦字符串匹配成功,那么当前匹配成功的子串的长度就是next的值。”

帮助理解的图片。

img

img

img

img

img

排序

插入排序

直接插入排序

可将待排序列分为2部分:有序集合S无序集合T

排序的过程就是不断地将集合T中的元素取出,插入到集合S的适当位置。寻找合适位置的过程中会将集合S的元素挨个往后挤。

待排序列的结构:{S,T}。
算法刚开始集合S就有1个元素,是排序列的第1个元素。
例如:{5 | 1, 7, 6, 2, 9, 4, 2, 8, 2, 6}
	  S | T
void insertionSort(int arr[], int n)  
{  
	if (arr == nullptr || n <= 0)
        return;

	// i是集合T的左边界([),j是集合S的右边界(])
    int i, key, j;  
	// i从第二个元素开始检查
    for (i = 1; i < n; i++) // α
    {
        key = arr[i];  
        
		// j从集合S末尾开始向前检查
		// 如果当前集合S的元素arr[j]大于key,则将元素后移1位
        for ( j = i - 1; j >= 0 && arr[j] > key; --j) 
            arr[j + 1] = arr[j];  
            
		// 最终遇到j使得arr[j] < key,[j+1]才是要插入的位置
        arr[j + 1] = key;  
    }  
}  
性能分析

1)最坏情况:

整个序列是逆序的,即条件“arr[j] > key”始终成立。时间复杂度为O(n2)。

2)最好情况:

整个序列有序,条件“R[j] > temp”始终不成立。算法主体成了单层循环,时间复杂度为O(n)。

平均条件下的时间复杂度:O(n2)。

空间复杂度:O(1)

Sorting In Place: Yes (一步到位)

Stable: Yes (稳定)

Online: Yes (联机算法)

用途:当数组元素很少时;或当数组元素已经几乎有序时。

折半插入排序

通过折半查找直接定位到集合S中的插入位置,减少比较次数。相对于普通的插入排序最糟糕的O(n2)次比较,折半插入的比较次数减少到O(logn)。

待排序列结构仍是{S,T}

即使定位到插入位置又怎样?该移动多少元素还是要移动多少,只不过是“一边判断一边移动”和“定位后直接移动”的区别而已。

运行另一个函数的开销要比多次arr[j] > key判断要小吗?

int binarySearch(int arr[], int key, int low, int high)
{
	int mid;
	// 寻找插入位置,循环结束后的high + 1为插入位置
	while (low <= high)
	{
		mid = (low + high) / 2;
		if (key == arr[mid])
			// 为了保证算法稳定,在mid+1位置后插入
			return mid + 1; 
		else if (key < arr[mid])
			high = mid - 1;
		else 
			low = mid + 1;
	}
	return high + 1;//经过简单的例子推算,high+1位置为插入位置
}

// i是集合T的左边界([),j是集合S的右边界(])
void BinaryInsertSort(int arr[], int n)
{
    if (arr == nullptr || n <= 0)
        return;
        
	int i, j, key, position;
	for (i = 1; i < n; ++i)
	{
		key = arr[i];
		
		position =  binarySearch(arr, key, 0, j);
		// 移动集合S元素,将新元素插入正确位置
		for (j = i - 1; j >= position; --j)	
			arr[j + 1] = arr[j];
			
		arr[position] = key;
	}
}

希尔排序

代码注释看起来很复杂,其实很简单

又叫缩小增量排序,本质仍是插入排序。将待排序列按某个增量分成多个子序列(增量为n,就会分出n个子序列),分别对每个子序列进行直接插入排序,最后得到整个有序序列。

希尔排序是不稳定的。

希尔排序无法清晰的划出集合S和集合T

直接插入排序就是增量为1的希尔排序

(https://blog.csdn.net/wyl1813240346/article/details/81904878):

“若一个最小的值在后面,那么排序这个最小值就需要一步步移动,将其移动到序列的最前面,那么有没有什么方法减少这种移位?"

"我们不希望一步一步移动,而是给它设置一个增量让其大步移动,希尔排序由此产生。"

void ShellSort(int arr[], int length)
{
    if (arr == nullptr || length <= 0)
        return;
    int increment = length;
    int key;
    while (increment > 1)
    {
        increment = increment / 3 + 1;
        
/*
可将整个待排序列看作被增量划分为多个子序列,对每个子序列轮流进行直接插入排序(见下方例子说明)。i++表示轮转处理子序列
初始化int i = increment就相当于定位到了子序列的集合T中的第一个元素
循环结束的i++:轮流处理每个子序列
*/
		int i, j;
        for (i = increment; i < length; i++)
        {// if集合T第1个元素无法直接并入集合S,执行直接插入排序
            if (arr[i - increment] > arr[i])
            {
                key = arr[i];
                // 与直接插入排序完全相同,仅修改了跨度
                for (j = i - increment; j >= 0 && arr[j] > key; j -= increment)
                    arr[j + increment] = arr[j];
/*这一段for循环与直接插入排序中的第二层for循环结构完全一样,仅针对被增量分割的子序列做了调整:对应直接插入排序中的-1、+1改为了-increment、+increment。*/
                arr[j + increment] = key;
            }
        }
    }
}

假设有一个序列已经经过了一趟希尔排序的序列为

13 27 49 55 04 49 38 65 97 76


新的一趟希尔排序增量为3,得到3个子序列

子序列1:13 55 38 76

子序列2: 27 04 65

子序列3: 49 49 97


根据第一个for循环的判断条件,子序列的处理顺序为1-2-3-1-2-3-1-2-3-1

将每个子序列单独拿出来看,都基本上等于一个直接插入排序的操作。

希尔排序的空间复杂度受增量影响较大,难分析。

交换类排序

通过一系列“交换”动作完成。将待排序列分为有序集合S和无序集合T两部分,待排序列结构R={T,S}(补充:不是固定的,冒泡排序的特点在于“交换操作”的方式,R到底是{T,S}或{S,T}都取决于具体需求,增序、减序)

冒泡排序

默认是将结果处理为增序排列。

待排序列结构为{T,S}
补充:也可以将最小值移动到第一位,成{S,T}结构。其实没有严格的{T,S}或{S,T}规定,
  • 每趟排序从前往后两两比较相邻元素的值,如果是逆序,则交换它们,直到整个序列元素都被比较一次,一趟冒泡排序完成。

  • 相当于每次将集合T中的最大值移动到集合T的最后一位,然后T长度-1,将当前T中处于最后一位元素并入集合S中。

**结束条件:**一趟排序过程中没有发生关键字交换。

void BubbleSort(int arr[], int n)//本例R是{T,S}结构
{
    if (arr == nullptr || n <= 0)
        return;
	int flag, temp;
	
	// i为集合T的右区间(]),j为集合T的左区间([)
	// i>=1:当集合T只剩下1个元素时,排序结束。
	for (int i = n - 1; i >= 1; --i)
	{
		flag = 0;
		for (int j = 0; j < i; ++j)
			if (arr[j] > arr[j + 1])
			{
				// 交换
				temp = arr[j];
				arr[j] = arr[j + 1];
				arr[j + 1] = temp;
				// 标记
				flag = 1;
			}
		if (flag == 0)
			return;
	}
}
性能分析

1) 最坏情况

待排序列逆序,每次内层循环中的if判断条件“R[j - 1] > R[j]”始终成立,基本操作总执行次数为(n-1+1)(n-1)/2,时间复杂度为O(n^2)。

2)最好情况

待排序列有序,内层循环if判断条件始终不成立。时间复杂度为O(n)。

平均时间复杂度:O(n2)

空间复杂度:O(1)。

Sorting In Place: Yes

Stable: Yes

快速排序(分治算法)

每一趟选取当前所有子序列中的一个关键字(通常是第一个)作为枢轴,将子序列中比枢轴小的移到枢轴前边,比枢轴大的移到枢轴后边。一趟排序结束后整个待排序列被分为2个更短的子序列,再对子序列进行排序。

枢轴的选取可能是:第一个元素、最后一个元素、随机元素、中间元素。

快速排序也无法清晰划分集合S和集合T


代码

// 使得数组中枢轴左侧的元素都小于枢轴,右侧的元素都大于枢轴
int partition(int arr[], int low, int high)
{//将第一个元素作为枢轴
	int key = arr[low];
	int i = low, j = high;
	while(i < j)
	{	
		// 从后往前,遇到小于枢轴的元素时停止
		while(i < j && arr[j] >= key)
			--j;
		// 从前往后,遇到大于枢轴的元素时停止
		while(i < j && arr[i] <= key)
			++i;
		// 交换i、j定位的元素,开始新的一轮循环
		if(i < j)
		{
			int temp = arr[j];
			arr[j] = arr[i];
			arr[i] = temp;
		}
	}
	return j; // 当i、j相遇时停止,j所指的位置就是枢轴最终位置
}

void QuickSort(int arr[], int low, int high)
{
    if (arr == nullptr)
        return;
	int key;
	int i = low, j = high;
	if (low < high)
	{	
	// 确定枢轴位置并使得左侧元素都小于枢轴,右侧元素都大于枢轴
		key = partition(arr, low, high);
		// 将枢轴放置到指定位置
		int temp = arr[low];
		arr[low] = arr[key];
		arr[key] = temp;
		// 递归处理左半部分和右半部分
		QuickSort(arr, low, key - 1);
		QuickSort(arr, key + 1, high);
	}
}

性能分析

1)最好情况

最理想的状态就是每次枢轴都将待排序列平衡划分。

时间复杂度为O(nlogn),待排序列越接近无序,算法效率越高。

2)最坏情况

时间复杂度为O(n2),待排序列越接近有序,算法效率越低。

空间复杂度:最坏情况O(n),平均情况O(logn)。因为是递归算法,需要栈的辅助。


提高算法效率

当递归过程中划分得到的子序列规模较小时,可以直接用直接插入法处理后续工作;

或尽量选取一个可以将数据中分的枢轴元素。如从头、尾、中间取三个元素,再取三个元素的中间值作为枢轴。

补充:之后会出现多个时间复杂度为O(nlogn)的排序算法,在这些算法中,基本操作的执行次数多项式的最高次项为X* nlogn,快速排序的X最小,在同级别算法中最快,所以叫做最快排序。

快速排序算法不会产生有序子序列,每趟排序都会将一个元素(枢轴元素)放到最终位置上。

选择类排序

简单选择排序

遍历集合T,选出最小/最大值元素,将其与头元素/尾元素交换,然后将其并入集合S中。

同样是没有严格的{S,T}或{T,S}结构规定。

代码:{S,T}结构,每次从集合T中选出最小值与集合T首元素交换

void swap(int *xp, int *yp)
{
    int temp = *xp;
    *xp = *yp;
    *yp = temp;
}
 
void selectionSort(int arr[], int n)
{
    int i, j, min_idx;
 
    for (i = 0; i < n-1; i++)
    {
        min_idx = i;
        for (j = i+1; j < n; j++)
        if (arr[j] < arr[min_idx])
            min_idx = j;
            
        swap(&arr[min_idx], &arr[i]);
    }
}

时间复杂度:O(n2)

空间复杂度:O(1)

堆排序

待排序列结构为{T,S},其中T为堆。

堆是什么?

定义:一种数据结构,可看做是一种完全二叉树,且满足条件:任何一个非叶子节点的值都不大于(或不小于)其左右孩子的节点的值。
若父亲节点的值比孩子结点大,这样的堆称为
大顶堆
;反之,称为小顶堆

根据堆的定义,代表堆的二叉树的根结点的值是最大值或最小值,因此将一个无序序列整理为一个堆,就可以轻松找出这个序列的最大或最小值。

**堆排序过程:**输出堆顶元素后,通常将堆底元素(完全二叉树中序号最大的元素)送入堆顶,堆的结构一定被破坏,对被破坏的堆重新调整为大顶堆,再输出堆顶元素。
重复上述操作,直到堆中仅剩下一个元素为止。

代码:

void heapify(int arr[], int n, int i)
{
	// 堆的下标从0开始
    int largest = i; // 假设root为当前最大值
    int l = 2 * i + 1; // root左子树下标
    int r = 2 * i + 2; // root右子树下标
 
 	// 用largest锁定root、左右孩子,3者中最大的那一个
    if (l < n && arr[l] > arr[largest])
        largest = l;
    if (r < n && arr[r] > arr[largest])
        largest = r;
 
    // 如果root的任一孩子比root大
    if (largest != i) 
    {
        swap(arr[i], arr[largest]);
// root进行一次交换后,可能堆还未成型,以交换的子树下标为root继续递归处理
        heapify(arr, n, largest);
    }
}
 
// main function to do heap sort
void heapSort(int arr[], int n)
{
// 从完全二叉树的最后一个(按编号)非叶子结点开始生成堆
    for (int i = n / 2 - 1; i >= 0; i--)
        heapify(arr, n, i);
 
// 将堆顶元素(最大值元素)交换到待排序列尾部,然后i--将其并入集合S
    for (int i = n - 1; i > 0; i--) {
        swap(arr[0], arr[i]);
 		// 修复结构被破坏的堆
        heapify(arr, i, 0);
    }
}

归并类排序

二路归并排序

和快速排序一样同属于分治算法。

可以将递归函数的延伸看作是一棵树,从根结点不断向下伸展,直到作为叶子结点的mergeSort函数arry[begin, end]只能定位到1个元素,执行流开始“收缩”,执行merge函数对“已经有序的子集”进行有序合并。

代码:

2部分:一个有序合并函数,和一个典型、简单的递归函数。

merge(arr, l, m, r)假设arr[l, m]arr[m + 1, r]是有序的,将二者合并为一个更大的有序序列。

// 将数组array[]视为array1[left, mid]和array2[mid+1, right]两部分。

void merge(int array[], int const left, int const mid, int const right)
{
	int i1 = left; 		// array1的索引
	int i2 = mid + 1;	// array2的索引
	int j = 0;			// temparr的索引
	// 申请临时空间
	int *temparr = new int[right - left + 1];
	
	// 比较array1和array2,从较小元素开始放有序入临时数组
	while (i1 <= mid && i2 <= right)
		(array[i1] < array[i2]) ? (temparr[j++] = array[i1++]) : (temparr[j++] = array[i2++]);
		
	// 处理array1和array2中可能剩余的部分
	if (i1 <= mid)
		temparr[j++] = array[i1++]
	if (i2 <= mid)
		temparr[j++] = array[i2++]
		
	// 将结果传回数组
	for (int i = left; i <= right; ++i)
		array[i] = temparr[i];
	
	// 释放临时空间
	delete[] temparr;
}
 
// 简单粗暴的递归函数范例
void mergeSort(int array[], int const begin, int const end)
{
	// 当begin和end只能定位到1个元素时,递归的延伸停止
    if (begin >= end)
        return; 

	// 算出mid值,将序列一分为二
	// 防止溢出的计算方式
    auto mid = begin + (end - begin) / 2;
    // 对左、右子序列分别递归调用自身
    mergeSort(array, begin, mid);
    mergeSort(array, mid + 1, end);
    
    // 假设经过了递归调用后左右子序列都已经有序了,再将2者有序合并
    merge(array, begin, mid, end);
}

时间复杂度:O(nlogn)

空间复杂度:O(n)

基数类排序

(前置)计数排序

https://www.geeksforgeeks.org/counting-sort/

计数排序不是基于比较的排序。

计数排序处理的待排序列要有明确的区间范围。统计范围内的元素出现次数,再通过一些算法的计算,得出各元素在输出序列中的位置。

例子:(结合了代码的思路)

待排序列元素取值范围:[0, 9]
输入(记为arr[]):
1, 4, 1, 2, 7, 5, 2
排序结果(为了方便对照,记为out[]):
1, 1, 2, 2, 4, 5, 7


1)  使用数组count[]统计各个元素出现的次数
Index:  0  1  2  3  4  5  6  7  8  9
Count:  0  2  2  0  1  1  0  1  0  0

2)  修改数组count[],每个元素与之前的所有元素累加
Index:  0  1  2  3  4  5  6  7  8  9
Count:  0  2  4  4  5  6  6  7  7  7
对比排序结果,可知此时count[i]的值就是对应元素i的最后一个实例在out[]中的位置

3) 从后向前遍历arr[](为了保证算法稳定),将结果计入out[]中。
(因为遍历的使arr[]数组,所以在输入中未出现的元素3、6、8、9不会干扰结果。)

代码:

#include <iostream>
#include <cstring>
#include <limits.h>
#include <vector>

using namespace std;

void countSort(vector<int>& arr);

int main() 
{
	//*
	
	vector<int> arr = {1, 4, 1, 2, 7, 5, 2};
	countSort(arr);
	for(int i: arr)
		cout << i << " ";
	
    return 0;
	//*/
}


void countSort(vector<int>& arr)
{
/* 
	确定待排序列元素的取值上限,用于生成容器count;
	容器count的下标对应容器arr中的元素值,所以求得最大值后range要+1
//*/
	int range = INT_MIN;
	for(int i: arr)
		range = (i > range) ? i : range;
	++range;
 
 	// 用于记录返回结果
	int i;
	vector<int> output(arr.size(), 0);
	vector<int> count(range, 0);
 
    // 将arr[i]作为count[]的索引,统计各个元素出现次数
    for (i = 0; arr[i]; ++i)
        ++count[arr[i]];
 
    // 累加
    for (i = 1; i < range; ++i)
        count[i] += count[i - 1];

    // 从后向前遍历arr[],使算法稳定
    for (i = arr.size() - 1; i >= 0; --i)
    {
        output[count[arr[i]] - 1] = arr[i];
        --count[arr[i]];
    }
    
    // 将排序结果存入arr[]
    for (i = 0; arr[i]; ++i)
        arr[i] = output[i];
}

时间复杂度:O(n+k),k为元素取值范围上限

空间复杂度:O(n+k)

基数排序

https://www.geeksforgeeks.org/radix-sort/

使用需求:

基于比较的排序方法(合并排序、堆排序、快速排序等)表现最好的时间复杂度是O(nlogn),它们无论如何优化都不可能小于这个数量级。

计数排序(Counting Sort)属于线性时间排序O(n+k),k为数值的取值范围上限。如果数值的取值范围是[1, n2],那么使用基数排序就会得到O(n2)的时间复杂度。

如果还想让排序时间规模保持在O(n2),就得使用基数排序(Radix Sort)。

基数排序的思路是从低位到高位开始处理,使用计数排序作为子例程。

例子:

待排序列
170, 45, 75, 90, 802, 24, 2, 66

根据最低位(个位)排序
170, 90, 802, 2, 24, 45, 75, 66

根据十位排序
802, 2, 24, 45, 66, 170, 75, 90

根据最高位排序
2, 24, 45, 66, 75, 90, 170, 802

基数排序的时间复杂度是O(d(n+b)) ,其中,n为数量规模;b是元素的进制位,例如十进制b=10,十六进制b=16;d的规模是O(logbk),k为元素取值范围的上限。

所以基数排序的时间复杂度应该是O(logbk(n+b))。

分析:相对于基于比较的排序的优势。

先将k的最大值限制为nc,c为常数,使得时间复杂度为O(nlogbn),这时与基于比较排序相比仍没有优势。

如果将b的取值增大,大到b=n时,时间复杂度就为线性规模O(n)。

自己的理解:元素的进制为越大,基数排序相对于基于比较的排序优势越大。

代码:

需要先了解计数排序。

默认待排序列都是十进制数。

思路:从最低位开始,对待排序列的元素执行计数排序,直到最高位。

int getMax(vector<int>& arr)
{
    int mx = INT_MIN;
    for(int i: arr)
		mx = (i > mx) ? i : mx;
    return mx;
}

// 计数排序。exp为当前处理的位数
// 整体结构不变,为了配合基数排序做了一些修改
void countSort(vector<int>& arr, int exp)
{
	int i;
	int n = arr.size();
	// 用于记录返回结果
	vector<int> output(arr.size(), 0);
	// 已确定要处理十进制数,count容器长度为10
	vector<int> count(10, 0);
 
    // exp=1时按个位上的数字排序,exp=10对应十位,以此类推
    for (i = 0; i < n; ++i)
        ++count[(arr[i] / exp) % 10];
 
    // 累加
    for (i = 1; i < 10; ++i)
        count[i] += count[i - 1];

    // 从后向前遍历arr[],使算法稳定
    for (i = n - 1; i >= 0; --i)
    {
        output[count[(arr[i] / exp) % 10] - 1] = arr[i];
        --count[(arr[i] / exp) % 10];
    }
    
    // 将排序结果存入arr[]
    for (i = 0; i < n; i++)
        arr[i] = output[i];
}

// 基数排序
void radixsort(vector<int>& arr)
{
	// 根据最大值来确定元素的最高位范围
    int m = getMax(arr);
 
 	// exp为10的倍数
    for (int exp = 1; m / exp > 0; exp *= 10)
        countSort(arr, exp);
}

搜索

代码都未经测试

静态查找

仅获取数据,不执行额外操作。

动态查找

查找数据的同时执行删除、插入操作。

无序查找

被查找的数组是否有序不影响结果。

有序查找

被查找的数组必须有序。

平均查找长度(ASL):在查找运算中,主要的时间耗费在关键字的比较上,使用平均查找长度来衡量查找算法的性能。

ASL = ∑(1/n*ci)。i的取值从1到n

线性查找

就是从头到尾遍历。

ASL = 1/n * n(n+1)/2 = n(n+1)/2

时间复杂度O(n)

二分查找

在折半插入中提到了。

有序查找。

int binarySearch(int arr[], int key, int low, int high)
{
	int mid;
	// 寻找插入位置,循环结束后的high + 1为插入位置
	while (low <= high)
	{
		mid = (low + high) / 2;
		if (key == arr[mid])
			// 为了保证算法稳定,在mid+1位置后插入
			return mid + 1; 
		else if (key < arr[mid])
			high = mid - 1;
		else 
			low = mid + 1;
	}
	return high + 1;//经过简单的例子推算,high+1位置为插入位置
}

时间复杂度O(log2n)

插值查找

二分查找的改进,也要求数组有序。

在基于二分查找的分析下,每次的比较节点都是中间位置,通过改进可以取相对key值有关联关系的节点,

mid=low+(key-a[low])/(a[high]-a[low])*(high-low);

取得的比值会明显更加接近key所在位置,可以提高查找效率。

式子的理解:

乍一看很麻烦,但从数学角度看就很简单了。作一个坐标系,横坐标为数组索引,纵坐标为数组元素值,即x=i,y=a[i]。每次循环都得到2个端点:(low, a[low])(high, a[high])

(a[high]-a[low])*(high-low)就相当于这2个端点的斜率k,通过纵坐标差值key-a[low]乘上斜率来估算key对应的横坐标位置。

时间复杂度O( log2(log2n) )

斐波那契查找

是基于比较的查找,类似于二分查找,通过不断分割区间缩小查找范围。

有序查找。

相对于插值查找,斐波那契查找只需要运行加减运算,数据量较大时优势明显。

斐波纳契数列:F(n) = F(n-1) + F(n-2), F(0) = 0, F(1) = 1

有序查找,数组必须有序。

1、先求得斐波那契数列;

2、如果数组大小不等于斐波那契数,就用序列的最大值将数组长度扩展至大于数组长度的最近的斐波那契数

3、根据公式F(n) = F(n-1) + F(n-2)将数组区间[left, right](初始值为[0, 数组长度-1])分割成2份,得到“中点”mid = left + F(n-1) - 1,左侧区间的最后一个元素。

以上式子求得的mid值中 F(n)的结构是{F(n-1), F(n-2)}

F(n)的结构是{F(n-2), F(n-1)}还是{F(n-1), F(n-2)},似乎对结果没影响,修改一下求mid的公式就行了。

4、和二分查找类似的比较过程:

a[mid] == key,找到目标值,返回mid

a[mid] < key,目标值在右侧区间,left = mid+1, n = n-2

a[mid] > key,目标值在左侧区间,right = mid, n = n-1

left > right,查找失败。

代码:

int fibonacciSearch(vector<int> arr, int key)
{
	// 根据arr[]的长度构建斐波那契数列
	int length = arr.size();
	vector<int> fiboArray = {0, 1};
	// 迭代器rit始终定位到fiboArray[]的最后一个有效元素
	auto rit = fiboArray.rbegin();
	// 持续增长fiboArray[],直到最后一个元素的值不小于arr[]长度
	while(*rit < length)
	{
		int new_i = *rit + 	*(rit + 1);
		fiboArray.push_back(new_i);
		rit = fiboArray.rbegin();
	}
	// 记录当前斐波那契数列F(n)的n值
	int n = fiboArray.size() - 1;
	
	// 用arr[]的最大值填充arr[]
	auto arr_max = arr.rbegin();
	while (arr.size() < *rit)
		arr.push_back(*arr_max);
	
	// 开始进行区间分割
	// F(n)被分割为{F(n-1), F(n-2)}
	int left = 0, right = arr.size();
	while (left <= right && n >= 0)
	{
		int mid = left + fiboArray[n - 1] - 1;
		// 找到目标值
		if (arr[mid] == key)
		{
			// 如果mid大于原长度,说明mid位置为填充的元素
			if (mid >= length)
				return length - 1;
			else
				return mid;
		}
		// 目标值在标记点左则,更新右边界
		else if (arr[mid] > key)
		{
			right = mid;
			n = n - 1;
		}
		// 目标值在标记点右则,更新左边界
		else if (arr[mid] < key)
		{
			left  = mid + 1;
			n = n - 2;
		}
	}
	// 查找失败
	return -1;
}

时间复杂度O(log2n)

二叉排序树

Binary Sort Tree,又称为二叉查找树(Binary Search Tree)。

二叉排序树在普通的二叉树的基础上,遵循以下规则:

对于任意结点Node

  • 若存在左子树,则左子树的所有结点值都小于结点Node的值;
  • 若存在右子树,则右子树的所有结点值都大于结点Node的值;
  • 左子树、右子树也都为二叉排序树。

补充:BST中不能有重复值。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BYwIOIXi-1632298156321)(https://media.geeksforgeeks.org/wp-content/uploads/BSTSearch.png)]

struct BTNode {
    int data;
    BTNode *left, *right;
};

代码

二叉排序树的搜索代码:

BTNode* BSTSearch(BTNode* bt, int key)
{
	if (bt == nullptr)
		return nullptr;
	else
	{
		if (bt->data == key)
			return bt;
		else if (key < bt->data)
			return BSTSearch(bt->lchild, key);
		else
			return BSTSearch(bt->rchild, key);
	}
}
插入

二叉排序树的构建

方法一:参考自计算机考研辅导书

将一个新结点插入二叉排序树,结点应该插入的位置就是在二叉排序树中针对该结点的值进行查找失败的位置。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nelS8Kd9-1632298156323)(E:\!!C++_为了工作\wps1.png)]

所以只需要对查找代码进行修改:

// 新增了一个形参pre
int BSTInsert(BTNode* bt, int key, BTNode* pre = nullptr)
{
	// 在二叉排序树中查找失败的位置,或者还未生成二叉排序树
	if (bt == nullptr)
	{
		// 用key的值创建一个结点实例
		bt = new BTNode;
		bt->data = key;
		bt->lchild = bt->rchild = nullptr;
		
		// 生成二叉排序树的根结点
		if (pre == nullptr)
			pre = bt;
		// 如果之前比较的结点值小于key,则表示新结点为右孩子
		else if (pre->key < key)
			pre->rchild = bt;
		// 如果之前比较的结点值大于key,则表示新结点为左孩子
		else 
			pre->lchild = bt;
			
		return 1;
	}
	else
	{
		if (bt->data == key)
			return 0;
		// 递归调用中,将当前检查的结点bt作为形参pre的实参
		else if (key < bt->data)
			return BSTSearch(bt->lchild, key, bt);
		else
			return BSTSearch(bt->rchild, key, bt);
	}
}

// 从数组key[]中获取值构建二叉排序树bt。n为数组key[]长度
void CreateBST(BTNode* bt,int key[], int n)
{
	bt = nullptr;
	for (int i = 0; i < n; ++i)
		BSTInsert(bt, key[i]);
}

方法二:充分利用递归函数的便利

BTNode* Insert(BTNode* bt, int key)
{
	if (bt == nullptr)
	{
		// 用key的值创建一个结点实例
		bt = new BTNode;
		bt->data = key;
		bt->lchild = bt->rchild = nullptr;		
		return bt;
	}
	
	if (key > bt->data)
		bt->right = Insert(bt->right, key)
	else
		bt->left = Insert(bt->left, key)
	return bt
}

void CreateBST(BTNode* bt,int key[], int n)
{
	bt = nullptr;
	for (int i = 0; i < n; ++i)
		bt = Insert(bt, key[i]);
}
删除

删除二叉排序树结点:

要删除的结点p分3种情况:

  1. p为叶子结点,直接删除;

  2. p为单分支结点,删除p,将p的唯一一个孩子结点接在p与双亲结点连接的指针上;

  3. p为双分支结点,可以将对p的删除转化为情况1或情况2:

    ​ 沿着左子树找到左子树中的最大值结点(中序遍历中p的前驱结点),或沿着右子树找到右子树中的最小值结点(中序遍历中p的后继结点),

    ​ 将找到的结点记为r,用r的值替换p的值,然后将r删除。结点r一定是叶子结点或单分支结点

// 找到右子树bt中的最小值结点
BTNode* minValueNode(BTNode* bt)
{
    BTNode* current = bt;
 
    while (current && current->left != NULL)
        current = current->left;
 
    return current;
}

BTNode* deleteNode(BTNode* root, int key)
{
    if (root == NULL)
        return root;
 	// 左右分支递归调用,直到定位到要删除的结点
    if (key < root->data)
        root->left = deleteNode(root->left, key);
    else if (key > root->data)
        root->right = deleteNode(root->right, key);
    // 删除操作
    else {
    	// 要删除叶子结点,直接删除
        if (root->left == NULL && root->right == NULL)
        	delete root;
            return NULL;
       // 要删除单分支结点,用孩子结点替代root
        else if (root->left == NULL) {
            struct BTNode* temp = root->right;
            delete root;
            return temp;
        }
        else if (root->right == NULL) {
            struct BTNode* temp = root->left;
            delete root;
            return temp;
        }
 		
 		// 要删除的结点有2个孩子结点
 		// 在右子树中找到最小值结点
        BTNode* temp = minValueNode(root->right);
        // 替换结点root的值
        root->data = temp->key;
        // 再执行一次递归调用
        root->right = deleteNode(root->right, temp->key);
    }
    return root;
}

性能分析

在二叉排序树中比较关键字的次数不超过树的高度,意味着树的高度越矮越好。

随机情况下,时间复杂度为O(log2n);

最坏情况下,使用有序的数组来构建二叉排序树,得到的二叉排序树实际上是个链表,时间复杂度退化为O(n)。

平衡二叉树(AVL树)

(Balanced Binary Tree或Height- Balanced Tree,又称为AVL树,AVL名称源于发明者)

平衡二叉树是能进行自我平衡点二叉排序树,所有结点的左右子树的高度差的绝对值不能大于1。

平衡因子:左子树高度减去右子树高度的差。对于平衡二叉树,平衡因子的取值只有-1、0、1三个值。

因为是BST的改进,所以搜索方式一致。重点在于如何保持平衡。

平衡调整(插入)

https://www.geeksforgeeks.org/avl-tree-set-1-insertion/

AVL树的建立过程和建立二叉排序树的过程类似,但每次插入新的结点都得进行检查,若失去平衡则要进行平衡调整。

先要找出插入新结点后失去平衡的最小子树,然后再调整这棵子树。当失去平衡的最小子树被调整为平衡后,整个二叉排序树就会成为一棵平衡二叉树。

失去平衡的最小子树:距离插入结点最近,且以平衡因子绝对值大于1的结点作为根的子树。又称最小不平衡子树

2种平衡调整的基本操作:左旋转、右旋转。

T1, T2 and T3 are subtrees of the tree 
rooted with y (on the left side) or x (on 
the right side)           
     y                               x
    / \     Right Rotation          /  \
   x   T3   - - - - - - - >        T1   y 
  / \       < - - - - - - -            / \
 T1  T2     Left Rotation            T2  T3
Keys in both of the above trees follow the 
following order 
 keys(T1) < key(x) < keys(T2) < key(y) < keys(T3)
So BST property is not violated anywhere.

记法:

左旋——让当前子树根结点的右孩子结点替代自己;剩余的节点根据BST规则调整;

右旋——让当前子树根结点的左孩子结点替代自己;剩余的节点根据BST规则调整。

将新插入的结点设为w

  1. 先执行普通的BST插入操作,将插入结点记为w

  2. w开始向上找到第1个失衡的结点,记为zy为w-z路径上z的孩子结点,x为w-z路径上y的孩子结点;

  3. zyx的分布有4种情况

        z      z      z      z                      
       /      /        \      \               
      y      y          y      y               
     /        \        /        \      
    x          x      x          x       
    

    所谓的LL、LR、RL、RR表示的就是z、y、x的分布情况。

针对4种情况的调整

a) Left Left Case

z为根结点执行1次右旋。

T1, T2, T3 and T4 are subtrees.
         z                                      y 
        / \                                   /   \
       y   T4      Right Rotate (z)          x      z
      / \          - - - - - - - - ->      /  \    /  \ 
     x   T3                               T1  T2  T3  T4
    / \
  T1   T2

b) Left Right Case

y为根结点执行1次左旋,再以z为根结点执行1次右旋。

     z                               z                           x
    / \                            /   \                        /  \ 
   y   T4  Left Rotate (y)        x    T4  Right Rotate(z)    y      z
  / \      - - - - - - - - ->    /  \      - - - - - - - ->  / \    / \
T1   x                          y    T3                    T1  T2 T3  T4
    / \                        / \
  T2   T3                    T1   T2

c) Right Right Case \

z为根结点执行1次左旋。

  z                                y
 /  \                            /   \ 
T1   y     Left Rotate(z)       z      x
    /  \   - - - - - - - ->    / \    / \
   T2   x                     T1  T2 T3  T4
       / \
     T3  T4

d) Right Left Case

y为根结点执行1次右旋,再以z为根结点执行1次左旋。

   z                            z                            x
  / \                          / \                          /  \ 
T1   y   Right Rotate (y)    T1   x      Left Rotate(z)   z      y
    / \  - - - - - - - - ->     /  \   - - - - - - - ->  / \    / \
   x   T4                      T2   y                  T1  T2  T3  T4
  / \                              /  \
T2   T3                           T3   T4

代码

class Node
{
    public:
    int key;
    Node *left;
    Node *right;
    int height;
};

int max(int a, int b)
{
    return (a > b)? a : b;
}
 
int height(Node *N)
{
    if (N == NULL)
        return 0;
    return N->height;
}

Node* newNode(int key)
{
    Node* node = new Node();
    node->key = key;
    node->left = NULL;
    node->right = NULL;
    node->height = 1; 
    return(node);
}
 
// 右旋
Node* rightRotate(Node *z)
{
	// 记录关键结点
    Node *y = z->left;
    Node *T2 = y->right;
 
    // 执行右旋操作
    y->right = z;
    z->left = T2;
 
    // 更新高度
    z->height = max(height(z->left),
                    height(z->right)) + 1;
    y->height = max(height(y->left),
                    height(y->right)) + 1;
 
    return y;
}
 
// 左旋
Node *leftRotate(Node *z)
{
	// 记录关键结点
    Node *y = z->right;
    Node *T2 = y->left;
 
    // 执行左旋操作
    y->left = z;
    x->right = T2;
 
    // 更新高度
    z->height = max(height(z->left), height(z->right)) + 1;
    y->height = max(height(y->left), height(y->right)) + 1;
 
    return y;
}
 
// 获取左子树减右子树的高度差,根据结果判断哪一侧失衡
int getBalance(Node *N)
{
    if (N == NULL)
        return 0;
    return height(N->left) - height(N->right);
}
 
// AVL树的插入操作
Node* insert(Node* node, int key)
{
	/* 1、BST插入操作 */
    if (node == NULL)
        return(newNode(key));
    if (key < node->key)
        node->left = insert(node->left, key);
    else if (key > node->key)
        node->right = insert(node->right, key);
    // 如果要插入的值已存在,直接退出
    else
        return node;
	 /* 2、平衡调整 */
 	// 更新高度
    node->height = 1 + max(height(node->left), height(node->right));
 
    int balance = getBalance(node);
 
// “balance > 1”:左子树较高
// “key < ...”新结点最终插入到了左子树中
 	// LL结构,执行1次右旋
    if (balance > 1 && key < node->left->key)
        return rightRotate(node);
 
 	// RR结构,执行1次左旋
    if (balance < -1 && key > node->right->key)
        return leftRotate(node);
 	
 	// LR结构,node->left执行1次左旋、node执行1次右旋
    if (balance > 1 && key > node->left->key)
    {
        node->left = leftRotate(node->left);
        return rightRotate(node);
    }
 
 	// RL结构,node->right执行1次右旋、node执行1次左旋
    if (balance < -1 && key < node->right->key)
    {
        node->right = rightRotate(node->right);
        return leftRotate(node);
    }
 
    return node;
}

平衡调整(删除)

先执行普通的BST删除操作,然后进行平衡调整。整体结构与插入操作一致。

B树

https://www.geeksforgeeks.org/introduction-of-b-tree-2/

为什么需要B树?

B树也属于自我平衡搜索树(self-balancing search tree)。

大部分自我平衡搜索树(如AVL和红黑树)都假设所有待搜索的数据都已读入内存里。如果待搜索的数据集合非常大,无法一次性将所有数据读入内存,那么针对硬盘的IO操作会严重影响搜索速度。

使用B树是为了减小读取硬盘的次数。大部分针对树的操作都会导致O(h)规模的硬盘读取(h为树的高度),B树尽可能地将多个元素塞入一个结点里,以此减少树的高度。

通常来说,一个B树结点的大小等于一个硬盘的分块大小。

搜索、插入、删除的时间复杂度都是O(log2n)。

B树的特征

一个m阶的B树拥有一下特征:

  1. 所有结点最多能有m个分支。
  2. 除了根结点root,所有非叶子结点最少要有⌈m/2⌉个分支。
  3. 如果根结点root不是叶子结点,那么root最少要有2个分支。
  4. 拥有k个分支的结点,含有k-1个关键字
  5. 所有叶子结点都在同一层。
  6. (为了方便理解下一条,自己做的补充)每个结点的结构可视为分支1、关键字1、分支2、关键字2、...、分支m、关键字m的结构。
  7. 结点内的所有关键字递增排序,关键字k1、k2之间的分支所指向的孩子结点包含所有处于区间[k1, k2]内的关键字。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Quuz0jv4-1632298156326)(E:\!!C++_为了工作\output253.png)]

代码实现

创建
#include<iostream>
using namespace std;

class BTreeNode
{
	int *keys; 		// 指向结点的关键字数组
	BTreeNode **childs; 	// 指向孩子结点指针数组
	int m;	  		// B树的阶
	int n;			// 当前结点的关键字数
	bool leaf;		// true:当前结点是叶子结点。反之不是
	
public:
	// 构造函数
	BTreeNode(int _m, bool _leaf); 
	
	// 遍历以当前结点为根结点的子树
	void traverse();

	// 在以当前结点为根结点的子树中搜索k
	BTreeNode *search(int k);
	
friend class BTree;
};

// 意义不明
class BTree
{
	BTreeNode *root; 
	int m; 
public:
	BTree(int _m)
	{ root = NULL; m = _m; }

	void traverse()
	{ if (root != NULL) root->traverse(); }

	BTreeNode* search(int k)
	{ return (root == NULL) ? NULL : root->search(k); }
};

// m-1个关键字,m个分支
BTreeNode::BTreeNode(int _m, bool _leaf)
{
	m = _m;
	leaf = _leaf;

	keys = new int[m - 1];
	childs = new BTreeNode*[m];

	n = 0;
}
遍历
void BTreeNode::traverse()
{
	int i;
	for (i = 0; i < n; i++)
	{
		if (leaf == false)
			childs[i]->traverse();
		cout << " " << keys[i];
	}

	// 遍历最后一个孩子结点
	if (leaf == false)
		childs[i]->traverse();
}
搜索
BTreeNode *BTreeNode::search(int k)
{
	int i = 0;
	
	// 先在自己的关键字队列中查找
	while (i < n && k > keys[i])
		i++;
	if (keys[i] == k)
		return this;
	if (leaf == true)
		return NULL;
	
	// 要查找的关键字在keys[i-1]与keys[i]之间,对应childs[i]
	return childs[i]->search(k);
}
插入

类似BST。将插入值设为k,在B树中从根结点开始,用关键字k进行搜索直到定位到一个叶子结点,将关键字k插入该结点。

B树结点关键字数量收到m的限制,要保证插入的结点有足够的空间。如果被插入结点没有剩余空间,则要对其进行分割操作。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SL6Q5Orh-1632298156327)(E:\!!C++_为了工作\BTreeSplit-1024x321.jpg)]

结点y被分为2部分,且其中一个关键字80移动到了父节点x中。

流程

  1. 将根结点设为x

  2. x不是叶子结点时,作如下处理:

…a) 在x的孩子结点中找到下一个要访问的结点,记为y

…b) 如果y未满,将x指向*y

…c) 如果y已满,执行2个操作(不分先后):

…一) 将其分割成2份,记为y1y2,将x指向其中之一:如果k小于y中间的关键字,x指向*y1;否则指向*y2

…二) 将y的一个关键字移动到父结点x中。

  1. 循环执行步骤2,直到x为叶结点时停止。因为一路上已经执行了一系列分割操作,所以不用考虑叶子结点是否已满,直接将k插入结点x中即可。

注意

在前往下一个孩子结点之前,如果发现当前结点已满,就要立即执行分割操作,而不是将关键字插入到叶子结点后再向上调整。

这么做的好处是:一路上经过的结点都只会访问一次。

如果不这么做,最糟糕的情况是:从根结点到最后插入关键字的叶子结点的路径上,所有结点都是满的,插入关键字后所有结点都要进行分割。鉴于二叉树通常没有“指向父结点的指针”,从下往上的调整会非常麻烦。

代码

class BTreeNode
{
	int *keys; 		// 指向结点的关键字数组
	int m;	  		// B树的阶,在构造结点时需要用到
	BTreeNode **childs; 	// 指向孩子结点指针数组
	int n;			// 当前结点的关键字数
	bool leaf;		// true:当前结点是叶子结点。反之不是
	
public:
	// 构造函数
	BTreeNode(int _m, bool _leaf); 
	
	// 遍历以当前结点为根结点的子树
	void traverse();

	// 在以当前结点为根结点的子树中搜索k
	BTreeNode *search(int k);
	
	// 插入新关键字
	void insertNonFull(int k);
 
 	// 分割结点
    void splitChild(int i, BTreeNode *y);
	
friend class BTree;
};

class BTree
{
	BTreeNode *root; 
	int m; 
public:
	BTree(int _m)
	{ root = NULL; m = _m; }

	void traverse()
	{ if (root != NULL) root->traverse(); }

	BTreeNode* search(int k)
	{ return (root == NULL) ? NULL : root->search(k); }
	
	void insert(int k);
};



void BTree::insert(int k)
{
    if (root == NULL)
    {
        root = new BTreeNode(m, true);
        root->keys[0] = k;  
        root->n = 1;  
    }
    else
    {
    	// 若根结点已满,分割根结点
        if (root->n == m-1)
        {
            BTreeNode *s = new BTreeNode(m, false);
            s->childs[0] = root;
            s->splitChild(0, root);
// 根据k值与s->keys[0]的大小判断将关键字k沿着哪条分支执行插入操作
            int i = 0;
            if (s->keys[0] < k)
                i++;
            s->childs[i]->insertNonFull(k);
 
            root = s;
        }
        // 根结点未满,直接插入
        else 
            root->insertNonFull(k);
    }
}
 
// 对非root结点执行插入操作
void BTreeNode::insertNonFull(int k)
{
    int i = n - 1;
 
 	// 如果当前结点是叶子结点,用直接插入排序插入关键字k
    if (leaf == true)
    {
        while (i >= 0 && keys[i] > k)
        {
            keys[i+1] = keys[i];
            i--;
        }
        keys[i+1] = k;
        n = n+1;
    }
    else 
    {
    	// 定位到childs[i+1],确认下一步要往哪条分支走
        while (i >= 0 && keys[i] > k)
            i--;
 		
 		// 如果孩子结点已满,分割;重新定位childs[i+1]
        if (childs[i+1]->n == m-1)
        {
            splitChild(i+1, childs[i+1]);
            if (keys[i+1] < k)
                i++;
        }
        childs[i+1]->insertNonFull(k);
    }
}
 
/*
*  分割结点。splitChild作为成员函数,假设从对象x调用函数,y是要被分割的结点,i是该结点在x中的下标
*  关于下标的计算相当繁琐。未经检验
*/
void BTreeNode::splitChild(int i, BTreeNode *y)
{
    BTreeNode *z = new BTreeNode(y->m, y->leaf);
    // 新结点z的关键字数量
    z->n = (m-1)/2;
 
 	// 将y结点后半部分关键字移动到z结点中
    for (int j = 0; j < (m-1)/2; j++)
        z->keys[j] = y->keys[j + m/2];
    // 如果y结点不是叶子结点,还要移动后半部分的分支
    if (y->leaf == false)
        for (int j = 0; j < m/2; j++)
            z->childs[j] = y->childs[j + (m+1)/2];
 
 	// 刷新结点n的关键字数量
    y->n -= (m-1)/2;
 
 	// 用直接插入法在x中插入新结点z,z排在y的后面
    for (int j = n; j >= i+1; j--)
        childs[j+1] = childs[j];
    childs[i+1] = z;
    
 	// 使用直接插入法在x中插入y中的关键字>keys[(m-1)/2]
    for (int j = n-1; j >= i; j--)
        keys[j+1] = keys[j];
    keys[i] = y->keys[(m-1)/2];
 
 	// x中的关键字数加1
    n = n + 1;
}
删除

执行删除时要保证删除后节点内的关键字不少于⌈m/2⌉-1。

https://en.wikipedia.org/wiki/B-tree#Insertion

将要删除的关键字设为k,k所在结点设为x,x的父结点记为p。

在叶子结点上执行删除操作

如果k处于结点x中,且x是叶子结点,直接将其删除。

如果删除关键字k后结点x结构被破坏,就重新平衡(Rebalancing after deletion)。

在非叶子结点上执行删除操作

将其转换为叶子结点上的删除操作。

每个非叶子结点的两个子树之间都有一个分割值,需要在两个子树中找到一个关键字来替代该分割值。候选关键字是左子树中的最大关键字右子树中的最小关键字,它们都是叶子结点。

选择一个新的分割值k0(左右子树中都可以),将其从所在的子树中移除,然后在x中替换要被删除的关键字k。

如果原来k0所在的叶子结点因为关键字被删除而不满足B树定义,重新平衡。

Rebalancing after deletion

重新平衡操作从叶子结点开始向上调整,直到所有结点都符合B树定义。

平衡操作分2种情况:

1、结点x的兄弟节点有富余的关键字。在x结点、p结点、x的兄弟节点之间移动关键字,为x结点补足关键字数量。这一操作称为旋转(rotation)

2、结点x的兄弟结点都只含有最小数量的关键字,则将x与其中一个兄弟节点合并。这一操作会使得x的父结点失去1个关键字和1个分支。

具体操作:

x的左兄弟结点记为l,x的右兄弟结点记为r

旋转

1、如果r含有多余关键字,执行左旋(rotate left)。

​ 在p中将xr的分割值复制到x中;

​ 将r中的第一个关键字移动到p中,覆盖原分割值。

2、如果l含有多余关键字,执行右旋(rotate left)。

​ 在p中将lx的分割值复制到x中;

​ 将l中的最后一个关键字移动到p中,覆盖原分割值。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kGY8KlEW-1632298156329)(E:\!!C++_为了工作\wps121.png)]

合并

lr都没有多余关键字,执行合并。

以合并lx为例。

p中,将lx的分割值移入l中(排在l的keys数组末尾);

x中所有元素移入l中(将x中的keys数组和child数组拼接到l中对应数组的末尾)

p中,将指向x的指针删除。

​ 如果p是根结点,且删除了分割值后keys数组为空,则将合并lx后的结点作为新的根结点。

​ 如果p不是根节点,删除了分割值后,p的关键字个数也小于最低要求,则对p也执行平衡操作。

可理解为将分割值作为粘合剂将lx连接在一起。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K21ikNCU-1632298156330)(E:\!!C++_为了工作\wps2.png)]

代码

https://www.geeksforgeeks.org/delete-operation-in-b-tree/

B+树

B树的改进。B+树的分支节点与叶子结点结构不同。

分支节点

结构:<P1, K1, P2, K2, ….., Pc-1, Kc-1, Pc>。K值有序递增。

分支节点不存储数据,只起到索引的作用。索引值就是对应子树中的取值上限。假设有K1 < x <K2,那么应该沿着P2指针向下查找。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qY6pwNNx-1632298156331)(E:\!!C++_为了工作\diagram-I-1.jpg)]

叶子结点

结构:<<K1, D1>, <K2, D2>, ..., <Kc-1, Dc-1>, Pnext>

Di为数据指针,指向键Ki对应的数据。Pnext指向下一个叶子结点,将所有叶子结点连成一个链表。

所有叶子结点都处于同一层。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pSQiYJCA-1632298156332)(E:\!!C++_为了工作\diagram-II.jpg)]

红黑树

红黑树是个特殊的BST,也属于自平衡二叉树,每个结点都有一个额外的位(bit),那个位通常被解释为颜色(红或黑)。结点的颜色通常用于保证在执行插入、删除操作期间,树保持平衡。

注意红黑树的结点只需要1bit额外空间来存储颜色信息,其余部分与典型的二叉搜索树一致。

红黑树将代表搜索结束的NULL作为叶子结点。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aPXQNbmw-1632298156334)(E:\!!C++_为了工作\Red-black_tree_example.svg.png)]

红黑树的特征

  1. 每个结点都有颜色,红或黑。
  2. 所有叶子结点都是黑色。
  3. 红色结点不能有红色孩子结点。
  4. 从任一结点向下通往叶子结点的路径上,黑色结点的数量都是相同的。

红黑树的优势:

相对于普通的BST,红黑树的搜索、插入、删除操作的时间复杂度始终为O(log2n)。

与AVL树相比,AVL树更平衡,但进行插入、删除操作时,AVL树要执行更多的旋转操作。如果储存的数据经常进行修改,就使用红黑树。

红黑树的特性对平衡的影响

理解红黑树的平衡的一个简单地例子: “a chain of 3 nodes”不可能出现在红黑树中。例子:

     (r)30          (b)30            (b)30       
       / \            /  \             /  \
  (b)20   NIL     (b)20   NIL      (r)20   NIL
    / \             / \              /  \   
(r)10  NIL     (r)10  NIL        (r)10  NIL  
违反特性4        违反特性4          违反特性3


      (r)20                        (b)20
       /   \                        /   \
  (b)10  (b)30                (r)10   (r)30
    /  \   /  \                 /  \    /  \
  NIL NIL NIL NIL             NIL  NIL NIL NIL

补充:似乎

红黑树的高度h<=2log2(n + 1).

插入

AVL树中使用旋转来实现平衡。红黑树中使用2种方法:变色和旋转。

变色就是修改结点的颜色,注意叶子结点(NULL)始终是黑色。旋转操作与AVL树的旋转相同。

流程

像普通的BST那样插入新的结点,将新结点标记为红色。
if (新结点是root)
	将颜色改为黑色
else // 检查父结点的颜色
	if (父结点为黑色)
		结构未被破坏,不做修改
	else // 父结点为红色,检查叔结点(父结点的双亲结点)的颜色
		if (叔结点也是红色)
			将父结点和叔结点改为黑色,祖父结点改为红色(除非祖父节点是root)。将祖父节点视为新结点重复执行同样的操作
		else // 叔结点是黑色或不存在
			对g、p结点执行旋转操作(4种情况)

要执行旋转操作时,新结点x和它的父结点p都是红色,根据它们在各自的父结点中的分支,有4种情况:LL、LR、RR、RL。操作过程参考AVL树。(如图所示,结点g执行旋转后要变色)

LL:G右旋、变色。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mQD43xuu-1632298156335)(E:\!!C++_为了工作\output244.png)]

LR:P左旋,G右旋、变色。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F2aFnx3v-1632298156337)(E:\!!C++_为了工作\output245.png)]

RR:G左旋、变色。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-00HBgTX3-1632298156338)(E:\!!C++_为了工作\output246.png)]

RL:P右旋,G左旋、变色。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-guAJKxZL-1632298156339)(E:\!!C++_为了工作\output247.png)]

代码

https://www.geeksforgeeks.org/c-program-red-black-tree-insertion/

删除

https://www.geeksforgeeks.org/red-black-tree-set-3-delete-2/

在删除操作中,通过检查兄弟节点的颜色来决定具体操作。

删除操作对红黑树结构的影响在于:黑色的结点被删除,导致一些从根到叶子结点的路径上的黑色高度降低,无法满足特性4。

流程

执行标准的BST删除操作。要被删除的结点要么是单分支结点,要么是叶子结点(BST中的叶子结点),然后执行平衡操作。将要被删除的结点记为v,要替换v的孩子结点记为u

  1. uv其中一个是红色:在u替换了v后,将u标记为黑色。

  2. uv都是黑色:v是BST中的叶子结点,u是NULL,删除v后检查原来的v的兄弟节点s

… a) 如果s是黑色,且至少有一个孩子结点r为红色,根据它们各自在其父结点中的分支位置,可分为LL、LR、RR、RL4种情况,根据情况执行相应的旋转操作。

如果s的2个孩子结点都为红色,则优先作为LL、RR处理?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X30QrYb1-1632298156341)(E:\!!C++_为了工作\rbdelete13New.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l2XmUbMO-1632298156342)(E:\!!C++_为了工作\rbdelete14.png)]

… b) 如果s和它的2个孩子结点都是黑色,将s标记为红色。如果父结点是黑色,对父结点p重复执行步骤2,否则将父结点标记为黑色即可。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5GhaQRA0-1632298156343)(E:\!!C++_为了工作\rbdelete15.png)]

… c) 如果s是红色,对父结点执行旋转操作、变色。p指向的结点不变,依照步骤 2.a 或 2.b 处理。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jtMNVIUw-1632298156345)(E:\!!C++_为了工作\rbdelete161-1024x704.png)]

哈希表(散列表)

之前介绍的线性表和树表的查找中,记录在表中的位置与记录的关键字之间不存在确定关系,因此在查找时需要进行一系列的关键字比较,这类查找方法建立在“比较”的基础上,查找的效率取决于比较的次数。

通过一个函数在关键字和存储数据的地址之间简历对应关系,以此形成的表就是哈希表。

哈希函数:一个函数,把查找表中的关键字映射成该关键字对应的地址,记为Hash(key)=Address。Address可以是数组下标、索引、内存地址等。

冲突:哈希函数将多个不同的关键字映射到同一地址。这些发成碰撞的不同关键字称为同义词

​ 一方面,设计哈希函数应尽量减少冲突;另一方面,冲突不可避免,还应该设计好处理冲突的方法。

哈希表:根据关键字而直接进行访问的数据结构。建立了关键字和存储地址之间的一种直接映射关系。

理想情况下,对哈希表进行查找的时间复杂度为O(1),即与表中元素个数无关。

构造哈希函数的注意事项

1)哈希函数的定义域必须包括全部需要存储的关键字值域范围依赖于哈希表的大小或地址范围。

2)哈希函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,减少冲突发生。

3)哈希函数应尽量简单,能在较短时间内计算出任一关键字对应的哈希地址。

常用的哈希函数

1.直接定址法

哈希函数为线性函数H(key) = a * key + b,a和b是常数。

这种方法计算简单,不会产生冲突。适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间浪费。

2.除留余数法

最常用。假定哈希表表长为m,取一个不大于m但最接近或等于m的质数p,哈希函数为H(key) = key % p

除留余数法的关键是选好p,使得计算结果等概率地映射到哈希空间上的任一地址。

3.数字分析法

设关键字是r进制数,而r个数码在各位上出现的频率不一定相同,选取数码分布较为均匀的若干位作为哈希地址。

这种方法适合于已知的关键字集合,若关键字更换,则需要重新构造新的哈希函数。

4.平方取中法

关键字的平方值的中间几位作为哈希地址。具体取多少视情况而定。

这种方法得到的哈希地址与关键字的每位都有关,因此使得哈希地址分布比较均匀,适用于关键字每位的取值都不够均匀或小于哈希地址所需的位数。

5.折叠法

将关键字分割成数位相同的几部分,取这几部分的叠加和作为哈希地址。具体叠加方法又可分为移位法和分界法。

例如:key = 12320324111220

移位法,截取的数字位数对齐		分界法:来回折叠
 123						  123
+203					 	 +302
+241						 +241
+112						 +211
+020						 +020
------						 ------
 699						  879

关键字位数很多,关键字中每位数字分布大致均匀时,可采用折叠法。

处理冲突的方法

假设已经选定哈希函数H(key),Hi表示发生冲突后第i次探测的哈希地址。

开放定址法

指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为Hi = (H(key) + di) % m式中,i = 0、1、2、3、……、m-1;m表示哈希表表长;di为增量序列。

取定增量序列后,对应的处理方法就是确定的。通常有4种取法:

1)线性探测法

di = 0、1、2、……、m-1

方法特点是:冲突发生时,顺序查看表中的下一个单元,直到找出一个空闲单元或插遍全表。

线性探测法会使第i个哈希地址的同义词存入第i+1个哈希地址,使得本应存入第i+1个哈希地址的元素争夺第i+2个哈希地址……从而造成大量元素在相邻的哈希地址上聚集(堆积),大大降低查找效率。

2)平方探测法(二次探测法)

di = 0、12、-12、22、-22、……k2、-k2

其中k≤m/2,哈希表长度m必须是一个可以表示成4k+3的素数。

平方探测法能较好地处理冲突,避免出现堆积问题。缺点是不能探测到哈希表上的所有单元,至少能探测到一半单元。

3)再哈希法(双哈希法)

di=Hash2(key)

需要两个哈希函数,当第一个哈希函数H1(key)得到的地址发生冲突时,利用第二个哈希函数H2(key)计算该关键字的地址增量。具体函数形式如下:Hi = (H1(key) + i * H2(key)) % m。i是冲突的次数,初始为0。

初始探测位置H0= H(key) % m

4)伪随机序列法

di=伪随机数序列

在开放定址的情形下,不能随便物理删除表中的已有元素,若删除元素则会截断其他具有相同哈希地址的元素的查找地址。

通常在删除时做一个删除标记,进行逻辑删除。需要定期维护哈希表,把删除标记的元素物理删除。

拉链法

​ 把所有的同义词存储在一个线性链表中,线性链表有其哈希地址唯一标识。查找、插入、删除主要在同义词链中进行。拉链法适用于经常执行插入、删除操作的情况。链表的头结点就是第一个定位到该位置的元素。

一些扩展

回溯法

以八皇后问题为例。

简单记一些内容

很多人认为回溯和递归是一样的,其实不然。在回溯法中可以看到有递归的身影,但是两者是有区别的。

回溯法从问题本身出发,寻找可能实现的所有情况。和穷举法的思想相近,不同在于穷举法是将所有的情况都列举出来以后再一一筛选,而回溯法在列举过程如果发现当前情况根本不可能存在,就停止后续的所有工作,返回上一步进行新的尝试。

递归是从问题的结果出发,例如求 n!,要想知道 n!的结果,就需要知道 n*(n-1)! 的结果,而要想知道 (n-1)! 结果,就需要提前知道 (n-1)*(n-2)!。这样不断地向自己提问,不断地调用自己的思想就是递归。

回溯和递归唯一的联系就是,回溯法可以用递归思想实现。

结合图片

img

八皇后算法

重点是eight_queen函数。

#include <stdio.h>
int Queenes[8]={0},Counts=0;
int Check(int line,int list){
    //遍历该行之前的所有行
    for (int index=0; index<line; index++) {
        //挨个取出前面行中皇后所在位置的列坐标
        int data=Queenes[index];
        //如果在同一列,该位置不能放
        if (list==data) {
            return 0;
        }
        //如果当前位置的斜上方有皇后,在一条斜线上,也不行
        if ((index+data)==(line+list)) {
            return 0;
        }
        //如果当前位置的斜下方有皇后,在一条斜线上,也不行
        if ((index-data)==(line-list)) {
            return 0;
        }
    }
    //如果以上情况都不是,当前位置就可以放皇后
    return 1;
}
//输出语句
void print()
{
    for (int line = 0; line < 8; line++)
    {
        int list;
        for (list = 0; list < Queenes[line]; list++)
            printf("0");
        printf("#");
        for (list = Queenes[line] + 1; list < 8; list++){
            printf("0");
        }
        printf("\n");
    }
    printf("================\n");
}

void eight_queen(int line){
    //在数组中为0-7列
    for (int list=0; list<8; list++) {
        //对于固定的行列,检查是否和之前的皇后位置冲突
        if (Check(line, list)) {
            //不冲突,以行为下标的数组位置记录列数
            Queenes[line]=list;
            //如果最后一样也不冲突,证明为一个正确的摆法
            if (line==7) {
                //统计摆法的Counts加1
                Counts++;
                //输出这个摆法
                print();
                //每次成功,都要将数组重归为0
                Queenes[line]=0;
                return;
            }
            //继续判断下一样皇后的摆法,递归
            eight_queen(line+1);
            //不管成功失败,该位置都要重新归0,以便重复使用。
            Queenes[line]=0;
        }
    }
}
int main() {
    //调用回溯函数,参数0表示从棋盘的第一行开始判断
    eight_queen(0);
    printf("摆放的方式有%d种",Counts);
    return 0;
}
自己的理解

就比如说八皇后算法:

一个初始条件n=0

每一个n都有8个分支可以走(for (int list=0; list<8; list++));

8个分支中满足条件的分支(if (Check(line, list))),递归调用函数(以n+1为条件);

8个分支中不满足条件的分支,表示该路径错误,直接返回;

最后当某条分支的n达到规定值N(8)以后,表示得到了答案,执行相应操作(print())。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值