数据结构之二叉树 一

目录

一、树的概念及其结构

二、树的相关概念

 三、树的表示

(1)首先定义树的结构体

(2)兄弟孩子表示法表示此图​

 画法方式一:

​画法方式二:

 (3) 树在实际中的运用(表示文件系统的目录树结构)

 四、二叉树的概念及结构

(1)概念

 (2)特殊的二叉树

(3)二叉树的性质 

 (4)牛刀小试

五、二叉树的存储结构

(1)顺序存储

(2)链式存储

(3)链式存储的结构体定义

 六、二叉树的顺序结构及实现

(1)二叉树的顺序结构

(2) 堆的概念及结构

(3)牛刀小试

(4) 堆的实现

1.初始化:

2.销毁:

3.堆的插入​

4.向上调整

5.打印

 6.删除 ​

7.向下调整

七、TopK问题

(1)方法分析

(2)TopK问题相关练习


一、树的概念及其结构

树是一种非线性的数据结构,它由n(n>=0)个有限节点组成的一个具有层次关系的集合。把它叫做树是因为它看起来像一颗倒挂的树,也就是说它是根朝上,而叶朝下的。
(1)有一个特殊的结点,称为根节点,根节点没有前驱结点。
(2)除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、...、Tm,其中每一个集合Ti
(1<=i<=m)又是一颗结构与树类似的子树。每颗子树的根节点有且只有一个前驱,可以有0个或多个后继,因此,树是递归定义的。

 生活中的树:

数据结构中的树:

 注意点:树形结构中,子树之间不能有交集,否则就不是树形结构
(1)子树是不相交的;
(2)除了根节点外,每个结点有且仅有一个父节点;
(3)一颗N个结点的树有N-1条边

二、树的相关概念


节点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A的为6
叶节点或终端节点:度为0的节点称为叶节点; 如上图:B、C、H、I...等节点为叶节点
非终端节点或分支节点:度不为0的节点; 如上图:D、E、F、G...等节点为分支节点
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点
兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点
树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6
节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;
树的高度或深度:树中节点的最大层次; 如上图:树的高度为4
堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点
节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙
森林:由m(m>0)棵互不相交的树的集合称为森林;

 三、树的表示

树存储表示相对于顺序表就比较麻烦,既要保存数值,也要保存结点与结点之间的关系。
实际中,有很多种方法进行表示,比如:双亲表示法、孩子表示法、孩子双亲表示法等,然而我们在这里就运用常用的孩子兄弟表示法来进行表示。

(1)首先定义树的结构体
 

typedef int DataType;
typedef struct TreeNode
{
	struct TreeNode* firstchild;//firstchild只指向其第一个孩子节点
	struct TreeNode* NextBrother;//NextBrother指向其下一个的亲兄弟节点
	DataType data;//节点中的数据域
};

(2)兄弟孩子表示法表示此图

 画法方式一:



画法方式二:


 
(3) 树在实际中的运用(表示文件系统的目录树结构)

比如磁盘上就是以树形结构组织的

 四、二叉树的概念及结构

(1)概念

一颗二叉树是结点的一个有限集合,该集合:
1.为空 
2.由一个根节点加上俩棵分别称为左子树和右子树的二叉树

 从上图中可以看出:
1.二叉树不存在度大于2的结点
2.二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树

 上图便为现实中一棵标准的二叉树,也是一棵满二叉树

 (2)特殊的二叉树

1.满二叉树:一个二叉树,如果每一层的结点都达到最大值,则这个二叉树就是满二叉树。
即所有叶子结点都在最后一层,并且所有分支结点都有俩个孩子
2.完全二叉树:完全二叉树是效率很高的数据结构,是由满二叉树引导出来的。对于深度为k的,有n个结点的二叉树,当且仅当每一个结点都与深度为k的满二叉树中编号从1至n的结点对应时称之为完全二叉树。即前k-1层都是满的,最后一层不满,但是最后一层从左至右是连续的。要注意的是满二叉树是一种特殊的完全二叉树。

(3)二叉树的性质 

1. 满二叉树第一层有 2^0 = 1个结点,第二层有 2^1 = 2个结点,第三层有2^2 = 4个结点,第四层有2^3=8个结点,从上可以推出,第k层,有2^(k-1)个结点
2.假设树的高度是h的满二叉树,从2^0+2^1+2^2+....+2^(h-1) 可以推出该二叉树总共有2^h - 1个结点
3.假设一颗满二叉树有N个结点,则可以通过2^h -1 = N,推算出,h = log₂(N+1)
比如:10亿个结点满二叉树需要2^30  = 10亿多,所以h = 30;
4.对任何一个二叉树,如果度为0其叶结点个数为n0,度为2的分支结点个数为n2,则有n0 = n2+1;即度为0的永远比度为2的多一个
5. 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
1. 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点
2. 若2i+1=n否则无左孩子
3. 若2i+2=n否则无右孩子

 (4)牛刀小试


1. 某二叉树共有 399 个结点,其中有 199 个度为 2 的结点,则该二叉树中的叶子结点数为( )
A 不存在这样的二叉树  B 200  C 198  D 199

解析:
任何一个二叉树,n0 = n2+1,因为度为2的结点个数为199,所以度为0的节点个数为200个
所以选择B选项

2.在具有 2n 个结点的完全二叉树中,叶子结点个数为( )
A n  B n+1  C n-1  D n/2

解析:
假设度为2的结点的个数为x2,度为1的结点的个数为x1,度为0的结点的个数为x0
可以得出,x0+x1+x2 = 2n;又因为n0 = n2+1,则2*x0 + x1 -1 = 2n;
又因为完全二叉树度为1的节点要不是0个要不是1个,因为完全二叉树的特点是前k-1层都是满的,最后一层不满,但是最后一层从左至右是连续的。所以当x1 = 0时, 2 * x0 - 1 = 2n,此时得出x0不为整数,所以排除,当x0为1时2 * x0 = 2n;此时x0 = n
所以选择A选项

3. 一棵完全二叉树的节点数位为531个,那么这棵树的高度为( )

A 11  B 10  C 8  D 12

解析:

 高度为h的完全二叉树结点范围是多少?
[2^(h-1)-1+1,2^h-1]
假如h = 10,将h = 10 带入,[512,1023],符合提议,所以选择B选项

4.一个具有767个节点的完全二叉树,其叶子节点个数为()

A 383  B 384  C 385  D 386

解析:
假设度为0的节点为n0个,度为1的节点个数为n1,度为2的节点为n2个
n0 + n1 + n2 = 767;
n0 = n2 + 1;
2 * n0 + n1 -1 = 767;
n1只能为0或1
当n1等于1时,n0不为整数,排除
当n1等于0时,n0 = 768 / 2 = 384;
所以选择B选项

五、二叉树的存储结构


普通的二叉树的增删改查没有太大的意义,主要学习的是控制它的结构
二叉树一般可以使用俩种结构存储,一种是顺序结构,另一种是链式结构

(1)顺序存储

顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树(满二叉树是一种特殊的完全二叉树),因为不是完全二叉树会有空间的浪费。而现实使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一棵二叉树。

假设parent是父亲结点在数组中的下标,
leftchild = parent * 2 + 1;
rightchild = parent * 2 + 2;
假设孩子的下标是child,不管是左孩子还是右孩子
parent = (child - 1) / 2;

(2)链式存储

二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系,通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址。链式存储结构又分为二叉链和三叉链,三叉链就是不仅含有数据域和指向左右孩子结点的指针,还有指向双亲结点的指针,当前我们初阶学习的是二叉链,而在后面我们学到红黑树等高阶数据结构时会用到三叉链。

(3)链式存储的结构体定义
 

typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
   struct BinTreeNode* _pLeft; // 指向当前节点左孩子
   struct BinTreeNode* _pRight; // 指向当前节点右孩子
   BTDataType _data; // 当前节点值域
};
// 三叉链
struct BinaryTreeNode
{
   struct BinTreeNode* _pParent; // 指向当前节点的双亲
   struct BinTreeNode* _pLeft; // 指向当前节点左孩子
   struct BinTreeNode* _pRight; // 指向当前节点右孩子
   BTDataType _data; // 当前节点值域
};

 六、二叉树的顺序结构及实现

(1)二叉树的顺序结构

普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。

(2) 堆的概念及结构

如果有一个关键码的集合K = { , , ,…, },把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足: = 且 >= ) i = 0,1, 2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。(大堆和小堆不一定有序)
大堆:一个树及子树中,任何一个父亲结点的值都大于等于孩子结点的值
    小堆:一个树及子树中,任何一个父亲结点的值都小于等于孩子结点的值

堆 -- 逻辑结构:是通过我们想象出来的  -- 完全二叉树
堆 -- 物理结构:实实在在内存中存储的结构 -- 数组

(3)牛刀小试

1.下列关键字序列为堆的是:()

A 100,60,70,50,32,65
B 60,70,65,50,32,100
C 65,100,70,32,50,60
D 70,65,100,32,50,60
E 32,50,100,70,65,60
F 50,100,70,65,60,32

解析:
此题最好的方法就是画出来,比如:

此题选择A选项

(4) 堆的实现

1.初始化:

void HeapInit(HP* hp)
{
	assert(hp);
	hp->a = NULL;
	hp->size = hp->capacity = 0;
}

2.销毁:

void HeapDestroy(HP* hp)
{
	assert(hp);
	free(hp->a);
	hp->capacity = hp->size = 0;
}

3.堆的插入

void HeapPush(HP* hp, DataType x)
{
	assert(hp);
	//插入数据要先看容量是否足够,增容
	if (hp->size == hp->capacity)
	{
		size_t newcapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
		DataType* tmp = realloc(hp->a,sizeof(DataType) * newcapacity);
		if (tmp == NULL)
		{
			printf("realloc failed\n");
			exit(-1);
		}
		hp->a = tmp;
		hp->capacity = newcapacity;
	}
	//将值插入
	hp->a[hp->size] = x;
	hp->size++;
	//向上调整
	AdujustUp(hp->a,hp->size,hp->size-1);
}

4.向上调整

注意点:
堆插入数据对其他结点没有影响,只是可能会影响从他到根结点路径上结点的关系
三种情况:

void AdujustUp(DataType* a,int n,int child)
{
	assert(a);
	//先算出孩子结点的父亲结点
	int parent = (child - 1) / 2;
	while (child > 0)//child == 0时终止条件
		//如果终止条件是parent < 0,而最后一次进入if中parent还是等于0,最后是通过else条件出去的,虽然没错,但是不是按正常思路进行的
	{
		if (a[child] > a[parent])//如果孩子的值大于父亲的值,则向上调整
		{
			//如果child值比parent值大,将child下标指向parent下标
			DataType temp = a[child];
			a[child] = a[parent];
			a[parent] = temp;
			child = parent;
		    parent = (child -  1) / 2;
		}
		else//小于孩子则结束
		{
			break;
		}
	}
}

5.打印

void HeapPrint(HP* hp,int sz)
{
	int i = 0;
	for (i = 0;i < sz;i++)
	{
		printf("%d ",hp->a[i]);
	}
	printf("\n");
}

 6.删除 

 总结:插入结点向上调整:(只与插入结点到根节点这条路径相关,与其他结点无关)
            删除结点向下调整:尾结点与下标为0的结点交换,然后删除尾结点(即size--),然后下标为0的结点与它的孩子结点中数值较小的那个交换,然后继续向下调整,直到调整到叶子结点或者父亲结点<=孩子结点(小堆),或者父亲结点>=孩子结点(大堆)

//删除堆顶元素
void HeapPop(HP* hp)
{
	assert(hp);
	//堆为空就不要删了
	assert(!HeapEmpty(&hp));
	//交换堆顶和尾巴上的元素
	Swap(&hp->a[0],&hp->a[hp->size-1]);
	//删除掉最后一个元素
	hp->size--;

	//向下调整
	AdjustDown(hp->a,hp->size,0);//从0开始向下调
}

7.向下调整

向下调整,必须左右子树也是大堆或者小堆,然后左右子树进行比较,然后与根进行交换等

//小堆
void AdjustDown(DataType* a, int n, int parent)
{
	assert(a);
	//只定义一个左孩子,右孩子可以用左孩子+1表示
	DataType child = parent * 2 + 1;

	while (child < n)//结束条件是左孩子结点不存在
	{
		//左孩子和右孩子比较,找出个小的
		//直接假如右孩子比左孩子小,但右孩子要在规定范围内,即数组的范围内
		if (child + 1 < n && a[child + 1] < a[child])
		{
			//如果右孩子比左孩子小,那么child直接指向右孩子的下标
			++child;
		}
		//如果小的孩子小于父结点,进行交换,并继续向下调整
		if (a[child] < a[parent])
		{
			//交换
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else//孩子都大于等于父结点,则没必要继续向下调整,因为已经是小堆,则结束
		{
			break;
		}
	}
}

效果:

七、TopK问题

(1)方法分析

在N个数中找最大的前K个,1000个数中找最大的前10个
方式1:先排降序,前10个最大。快排时间复杂度:o(N*logN)
方式2:N个数一次插入大堆,PopK次,每次去对顶的数据就是前k个。
时间复杂度:向上调整和向下调整最多调高度次
即完全二叉树最少的情况下: 2^(h-1) -1 + 1 = N; 即h = log₂N + 1;可以看成logn
完全二叉树最多的情况下: 2^h - 1 = N;即h = log₂(N+1);可以看成logn
N个数一次插入大堆:N*logn即N (下篇会证明)
PopK次: k * logn
一般k小于N,所以时间复杂度为o(N + logN * k)
方式3:假设N非常大,N是10亿,内存中存不下这些数,他们存在文件中的,k是100,方式1和方式2都不能用了(10亿个整数大概占4G的内存)

 时间复杂度:k个数形成小堆+(n-k)个数向下调整,h = logk --->  o(k+(n-k) * logk) -->o(n*logk)
代码验证:应用的是方式3,因为时间复杂度最小
 

void PrintTopK(int* a, int n, int k)
{
	HP hp;
	HeapInit(&hp);
	//要求堆中存放的是k个最大的数,所以建立k个数的小堆
	int i = 0;
	for (i = 0;i < k;i++)
	{
		HeapPush(&hp,a[i]);
	}
	//堆顶和后面的n-k个数比较,比堆顶大的与堆顶交换,向下调整或者删除堆顶,插入新数据
	for (i = k;i < n;i++)
	{
		if (HeapTop(&hp) < a[i])
		{
			//方式1:将堆顶替换,然后向下调整
			hp.a[0] = a[i];
			AdjustDown(hp.a,hp.size,0);
			//方式2:先删除堆顶,然后将新元素插入
		/*	HeapPop(&hp);
			HeapPush(&hp,a[i]);*/
		}
	}
	HeapPrint(&hp);
	HeapDestroy(&hp);
}
void TestTopk()
{
	int n = 10000;
	int* a = (int*)malloc(sizeof(int) * n);
	srand(time(0));
	for (size_t i = 0; i < n; ++i)
	{
		a[i] = rand() % 1000000;
	}
	a[5] = 1000000 + 1;
	a[1231] = 1000000 + 2;
	a[531] = 1000000 + 3;
	a[5121] = 1000000 + 4;
	a[115] = 1000000 + 5;
	a[2335] = 1000000 + 6;
	a[9999] = 1000000 + 7;
	a[76] = 1000000 + 8;
	a[423] = 1000000 + 9;
	a[3144] = 1000000 + 10;
	PrintTopK(a, n, 10);
}

运行结果:

(2)TopK问题相关练习

 剑指 Offer 40. 最小的k个数
因为c语言不能够直接应用堆,所以先建立堆相关的操作及函数

/**
 * Note: The returned array must be malloced, assume caller calls free().
 */
typedef int DataType;
typedef struct Heap
{
	DataType* a;
	int size;
	int capacity;
}HP;
void AdjustDown(DataType* a, int n, int parent);
void AdujustUp(DataType* a, int n, int child);
void Swap(DataType* px, DataType* py);
void HeapInit(HP* hp);
void HeapDestroy(HP* hp);
void HeapPush(HP* hp,DataType x);
void HeapPop(HP* hp);
int HeapEmpty(HP* hp);
DataType HeapTop(HP* hp);
void HeapInit(HP* hp)
{
	assert(hp);
	hp->a = NULL;
	hp->size = hp->capacity = 0;
}
void HeapDestroy(HP* hp)
{
	assert(hp);
	free(hp->a);
	hp->capacity = hp->size = 0;
}
DataType HeapTop(HP* hp)
{
	assert(hp);
	assert(!HeapEmpty(hp));
	return hp->a[0];
}
void Swap(DataType* px, DataType* py)
{
	DataType temp = *px;
	*px = *py;
	*py = temp;
}
void AdujustUp(DataType* a,int n,int child)
{
	assert(a);
	//先算出孩子结点的父亲结点
	int parent = (child - 1) / 2;
	while (child > 0)//child == 0时终止条件
		//如果终止条件是parent < 0,而最后一次进入if中parent还是等于0,最后是通过else条件出去的,虽然没错,但是不是按正常思路进行的
	{
		if (a[child] > a[parent])//如果孩子的值大于父亲的值,则向上调整
		{
			//如果child值比parent值大,将child下标指向parent下标
			Swap(&a[child],&a[parent]);
			child = parent;
		    parent = (child -  1) / 2;
		}
		else//小于孩子则结束
		{
			break;
		}
	}
}
void HeapPush(HP* hp, DataType x)
{
	assert(hp);
	//插入数据要先看容量是否足够,增容
	if (hp->size == hp->capacity)
	{
		size_t newcapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
		DataType* tmp = realloc(hp->a,sizeof(DataType) * newcapacity);
		if (tmp == NULL)
		{
			printf("realloc failed\n");
			exit(-1);
		}
		hp->a = tmp;
		hp->capacity = newcapacity;
	}
	//将值插入
	hp->a[hp->size] = x;
	hp->size++;
	//向上调整
	AdujustUp(hp->a,hp->size,hp->size-1);
}
int HeapEmpty(HP* hp)
{
	assert(hp);
	return hp->size == 0;
}
int HeapSize(HP* hp)
{
	assert(hp);
	return hp->size;
}

//大堆
void AdjustDown(DataType* a, int n, int parent)
{
	assert(a);
	//只定义一个左孩子,右孩子可以用左孩子+1表示
	DataType child = parent * 2 + 1;

	while (child < n)//结束条件是左孩子结点不存在
	{
		if (child + 1 < n && a[child + 1] > a[child])
		{
			//如果右孩子比左孩子小,那么child直接指向右孩子的下标
			++child;
		}
		//如果小的孩子小于父结点,进行交换,并继续向下调整
		if (a[child] > a[parent])
		{
			//交换
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else//孩子都大于等于父结点,则没必要继续向下调整,因为已经是小堆,则结束
		{
			break;
		}
	}
}
//删除堆顶元素
void HeapPop(HP* hp)
{
	assert(hp);
	//堆为空就不要删了
	assert(!HeapEmpty(hp));
	//交换堆顶和尾巴上的元素
	Swap(&hp->a[0],&hp->a[hp->size-1]);
	//删除掉最后一个元素
	hp->size--;
	//向下调整
	AdjustDown(hp->a,hp->size,0);//从0开始向下调
}
int* getLeastNumbers(int* arr, int arrSize, int k, int* returnSize)
{
    HP hp;
    HeapInit(&hp);
    *returnSize = k;
    //建立k个元素大根堆,然后拿后n - k个与堆顶元素比较,如果小于堆顶就替换
    int i = 0;
    if(k == 0)
    {
        return 0;
    }
    for(i = 0;i < k;i++)
    {
        HeapPush(&hp,arr[i]);
    }
   for(i = k;i < arrSize;i++)
   {
        if(arr[i] < HeapTop(&hp))
        {
            HeapPop(&hp);
            HeapPush(&hp,arr[i]);
        }
   }
   return hp.a;
   HeapDestroy(&hp);
}

运行结果:

本文为二叉树的第一篇,如有问题请评论区多多评论哈^_^

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值