二叉树与堆讲解

目录

1.树的概念及结构

1.树的概念

2.树的相关概念

3.树的表示

2.二叉树

1.概念

2.特殊的二叉树

1.满二叉树

2.完全二叉树

3.二叉树的性质

4.二叉树的存储结构

1.顺序结构

2.链式存储

3.堆

1.堆的概念及结构

2.堆的实现

1.堆的创建

2.堆的初始化(HPInit)

3.堆的销毁 (HPDestory)

4.堆的插入数据(HPPush)

向上调整算法(ADJustUp)

5.堆的删除堆顶数据 (HPPop)

向下调整算法 (ADJustDown)

6.堆的取堆顶元素 (HPTop)

7.判断堆是否为空 (HPEmpty)

3.Top k 问题

初阶版

高阶版

​编辑4.堆排序问题

5.向上调整建堆与向下调整建堆的时间复杂度

4.二叉树的链式存储

1.遍历

1.前序遍历

2.中序遍历

3.后序遍历

4.层序遍历

实践

2.求节点个数

3.求叶子节点个数

4.求树的高度

5.第k层节点个数

6.查找节点x

5.二叉树的销毁

6.层序遍历

7.判断是不是完全二叉树


1.树的概念及结构

1.树的概念

树是一种非线性的数据结构,它是根朝上,叶朝下的。有一个特殊的节点,根节点,根节点没有前驱节点。且树是递归定义的。

注意:树形结构中,子树之间不能有交集,否则就不是树形结构。

                                   树                                               非树

那么树与非树的区别有哪些?

(1)子树是不相交的。

(2)除了根节点外,每个节点有且仅有一个父节点。

(3)一颗N个节点的树有N-1条边。

2.树的相关概念

(1)节点的度:一个节点含有的子树的个数称为该节点的度。A的为3。

(2)叶节点或终端节点:度为0的节点称为叶节点。E F C G为叶节点。

(3)非终端节点或分支节点:度不为0的节点。A B D为分支节点。

(4)双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。A是B的父节点。

(5)孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点。B是A的子节点。

(6)兄弟节点:具有相同父节点的节点互称为兄弟节点。B和C是兄弟节点。

(7)树的度:一棵树中,最大的节点的度称为树的度。该树的度为3。

(8)节点的层次:从根开始定义起,根的子节点为第二层,以此类推。

(9)树的高度或深度:树中节点的最大层次。该树的高度为3。

(10)堂兄弟节点:双亲在同一层的节点互为堂兄弟。E和G为堂兄弟节点。

(11)节点的祖先:从根到该节点所经分支上的所有节点。A是所有节点的祖先。

(12)子孙:以某节点为根的子树中任一节点都称为该节点的子孙。B是A的子孙。

(13)森林:由m棵互不相交的树的集合称为森林。

3.树的表示

树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既要保存值域,也要保存节点和节点之间的关系。树有很多的表示方法:双亲表示法,孩子表示法,孩子双亲表示法以及孩子兄弟表示法。那么其中最简单的就是孩子兄弟表示法。顾名思义就是假设A为父节点,那么就在结构体中存储A的最左边的子节点,并存储子节点的一个兄弟节点,这样我们就可以通过这两个指针访问到A的所有子节点了。

typedef int DataType
struct Node
{
   struct Node*firstChild;
   struct Node*pNextBrother;
   DataType data;
};

2.二叉树

1.概念

一颗二叉树是节点的一个有限集合,该集合或者为空,或者由一个根节点加上两棵别称为左子树和右子树的二叉树组成。

由上图可以看出,二叉树不存在度大于二的节点,二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树。

对于任何一个二叉树都是由以下几种情况复合而成的。

2.特殊的二叉树

1.满二叉树

一个二叉树,如果每一层的节点数都达到最大值,则这个二叉树就是满二叉树,也就是说,如果一个二叉树的层数为k,且节点总数为2^k-1,则它就是满二叉树。

2.完全二叉树

完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树引出来的。对于深度为k的,有n个节点的二叉树,当且仅当其每一个节点都与深度为k的满二叉树中编号从1至n的节点一一对应时应称为完全二叉树。要注意的是,满二叉树是一种特殊的完全二叉树。

3.二叉树的性质

1.若规定根节点的层数为1,则一颗非空二叉树的第i层上做多有2^(i-1)个节点。

2.若规定根节点的层数为1,则深度为h的二叉树的最大节点数为2^h-1。

3.对任何一颗二叉树,如果度为零,其叶节点个数为n0,度为2的分支节点个数为n2,则有n0=n2+1。

4.若规定根节点的层数为1,具有 n个节点的满二叉树的深度为log2(n+1)。

5.对于具有n个节点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的节点有:

    (1)若i>0,i位置节点的双亲序号: (i-1)/2。i=0,i为根节点编号,无双亲节点。

    (2)若2i+1<n,左孩子序号:2i+1。若2i+1>=n则无左孩子。

    (3)若2i+2<n,右孩子序号:2i+2。若2i+2>=n则无右孩子。

4.二叉树的存储结构

二叉树一般可以使用两种存储结构,一种顺序结构,一种链式结构。

1.顺序结构

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

2.链式存储

二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。通常方法是链表中每个节点由三个域组成,数据域和左右指针域,左右指针分别用来给出该节点左孩子和右孩子所在的链节点的存储地址。链式结构又分为二叉链和三叉链。

3.堆

1.堆的概念及结构

如果有一个含有n个元素的集合,把他的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中。将堆中某个节点的值总是小于等于其父节点的堆叫做最大堆或大根堆;将堆中某个节点的值总是大于等于其父节点的堆叫做最小堆或小根堆。且最大堆与最小堆总是一棵完全二叉树。

2.堆的实现

由于堆分为大堆和小堆,我们在讲解时以小堆为例,想要实现大堆时自己更改即可。

1.堆的创建

堆实际上就是一个完全二叉树,而完全二叉树的底层是数组,所以在创建堆的结构体时我们需要用数组来存储结构,为了防止空间不够用所以我们需要动态的创建,所以还需要size用来存储堆中有效数据个数,需要capacity来存储当前开辟空间大小。

typedef int HPDataType;

typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
}HP;
2.堆的初始化(HPInit)

与之前学习的顺序表相似,我们需要先给数组置为NULL,再将size与capacity的值设为0。

//堆的初始化
void HPInit(HP* php)
{
	assert(php);
	php->a = NULL;
	php->size = php->capacity = 0;
}
3.堆的销毁 (HPDestory)

与顺序表相似,我们先释放掉结构体内的数组,再将其置为NULL,再将size和capacity的值设为0.

//堆的销毁
void HPDestory(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->size = php->capacity = 0;
}
4.堆的插入数据(HPPush)

插入数据的前提是数组中含有足够的空间,所以与顺序表相似,我们需要先判断size是否等于capacity,若等于,则进行扩容的操作,若不等于,则证明数组中还有空间,可直接执行插入操作。扩容的操作与顺序表类似,在这里就不过多赘述,需要回顾的可自行去顺序表专题。扩容后,我们将数据插入到数组中下标为size的位置,再将size++。

向上调整算法(ADJustUp)

因为我们实现的是小堆,所以我们还需要实施移动数据的操作。小堆,就是父节点一定大于其两个子节点,所以当我们插入数据后,我们需要判断子节点的数据是否小于父节点,若小于父节点则需要将子节点与父节点交换位置,也就是向上调整,我们称之为向上调整算法。

那么我们上面已经讲过,已知子节点的下标为i,则我们可以通过下标(i-1)/2来访问到父节点。在交换父节点子节点后,我们需要更新子节点的位置,也就是需要把父节点的下标赋值给子节点,再求出下一个父节点的下标。那么循环结束的条件就是当子节点的下标等于0时,循环结束。,

//交换元素
void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p2;
	*p2 = *p1;
	*p1 = tmp;

}

//向上调整
void ADJustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (a[child] <= a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			return;
		}
	}
}

//堆的插入
void HPPush(HP* php, HPDataType x)
{
	assert(php);
	if (php->size == php->capacity)
	{
		int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));
		if (tmp == NULL)
		{
			perror("reallloc file");
			exit(1);
		}
		php->a = tmp;
		php->capacity = newcapacity;
	}
	php->a[php->size] = x;
	php->size++;
	ADJustUp(php->a, php->size - 1);
}
5.堆的删除堆顶数据 (HPPop)

因为堆的底层是数组,所以很容易想到的是直接应用数组向前覆盖元素。但是当我们实践后发现这样操作会将堆中的父子关系打乱,这样堆就不成小堆了,所以该方法不可行。那么如果不想要将父子关系打乱,我们就需要只动堆顶数据,别的数据尽量不动,我们就可以想到将堆顶数据与数组的最后一个数据交换位置,这样,其他的堆中数据的父子关系并未改变,相当于在堆顶插入了一个数据,我们只需要将堆顶的数据向下调整即可。

向下调整算法 (ADJustDown)

当堆顶数据大于其子节点时,就需要应用向下调整算法。在最开始我们已知父节点的下标为i=0,那么我们上面已经讲解到,当已知父节点时,通过2*i+1即可访问到其左子节点。但是一个父节点含有两个子节点,我们需要挑选出最小的与父节点进行比较,这时我们就可以应用假设法。先假设左子节点最小,再将左子节点与右子节点进行比较,若右子节点小,则设右子节点为最小子节点,若左节点小则不做改变。如果父节点大于最小子节点,那么我们就将子节点与父节点进行交换,再将子节点的值赋值给父节点,再用新的父节点访问新的子节点。

那么循环结束的条件就是子节点的下标大于size。

//交换元素
void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p2;
	*p2 = *p1;
	*p1 = tmp;

}

//向下调整
void ADJustDown(HPDataType* a, int n, int parent)
{
	//假设法:假设左儿子小
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && a[child] > a[child + 1])
		{
			++child;
		}
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			return;
		}
	}
		
}

//堆的删除堆顶
void HPPop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	ADJustDown(php->a, php->size, 0);
}
6.堆的取堆顶元素 (HPTop)

直接返回下标为0的元素即可。

//获取堆顶元素
HPDataType HPTop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	return php->a[0];
}
7.判断堆是否为空 (HPEmpty)

直接返回判断size是否为0的值即可

//堆的判空
bool HPEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}

3.Top k 问题

初阶版

给出一个情景,当我们想要找出一个数组中最大或最小的k个数字,根据以前学过的冒泡排序我们可以得出Top k,但是冒泡排序的时间复杂度为O(N^2)。所以,当数组内元素较少时我们可以快速得出Top k,但是当元素很多的时候,时间消耗就会很大。所以我们可以应用堆的思想来实现Top k问题。例如,当我们想找出数组中最小的k个数字,我们可能会想到将数组内元素以小堆的形式排列,然后取堆顶元素,再将堆顶元素删除,再将剩余元素再按照小堆排放,重复以上操作,取出k个元素。但是我们会发现在删除堆顶数据后,剩余的元素的父子关系全部被打乱,这时就不是小堆了。所以此方法不可行。这个方法失败的原因是父子关系被打乱,那么有没有什么方法可以让父子关系不被打乱。那么我们就可以将堆顶数据与堆尾数据进行调换,取出堆低数据,再将堆顶数据进行向下调整算法,调整后堆顶数据就是次小数据,重复上述步骤直到取出k个数据。同理,想要找出最大的k个数字,我们就需要先实现大堆,再应用向下调整算法。所以我们需要调用ADJustDown和HPPop和HPTop函数。下面我们以取前k个最小值为例子,实现一下Top k问题。


//交换元素
void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p2;
	*p2 = *p1;
	*p1 = tmp;

}

//向下调整
void ADJustDown(HPDataType* a, int n, int parent)
{
	//假设法:假设左儿子小
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && a[child] > a[child + 1])
		{
			++child;
		}
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			return;
		}
	}
		
}


//堆的删除堆顶
void HPPop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	ADJustDown(php->a, php->size, 0);
}


//获取堆顶元素
HPDataType HPTop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	return php->a[0];
}

void HeapTest01()
{
	int a[] = { 45,1325,463,533,24,26,968,134,153,576543 ,5,235,346,3252,457,131,46,35,135,};
	HP hp;
	HPInit(&hp);
	for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++)
	{
		HPPush(&hp, a[i]);
	}
	//找出最小的前k个
	//Top k问题
	int k = 0;
	scanf("%d", &k);
	while (k--)
	{
		printf(" %d", HPTop(&hp));
		HPPop(&hp);
	}
}
高阶版

上述方法中我们需要排序的数据的个数是较少的所以我们可以将所有的数据建成一个大堆,然后调用HPTop()和HPPop()函数来依次取出前K个数字,但是当我们的数据数目过于庞大时,例如找出中国14亿人中最有钱的10个人,这时我们再通过将所有数据都建大堆的方式,其空间复杂度就会过于大,当我们限制使用的空间时,这种方法势必就不能使用。所以当数据数目N过于大时,我们可以先将这些数据的前k个数字建小堆,再依次取出文件中的数字与堆顶的数据进行比较,当该数字大于堆顶数字时,我们就将该数字赋值给堆顶数字,并调用向下调整算法,将k个数字重新进行排序。那么下面我们来实现一下这个高阶版TOP K问题。那么在实现这个问题之前,首先我们需要很多的数据,我们不可能手动的输入这么多的数据,所以我们可以先创建一个文件再向这个文件中随机生成数字。在需要这些数字时我们将文件调出就可以了。

void CreatHeap()
{
	int n = 100000;
	srand(time(0));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen file");
		exit(1);
	}
	for (int i = 0; i < n; i++)
	{
		int x = rand() + i;//这样可以减少随机生成数字的重复率
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);
}

void HeapTest03()
{
	int k;
	printf("请输入k值:");
	scanf("%d", &k);
	//创建一个含k个元素的数组
	int* kminheap = (int*)malloc(sizeof(int) * k);
	if (kminheap == NULL)
	{
		perror("malloc file");
		exit(1);
	}
	const char* file = "data.txt";
	FILE* fout = fopen(file, "r");
	if (fout == NULL)
	{
		perror("fopen file");
		exit(1);
	}
	//取出文件中的前k个数据
	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d", &kminheap[i]);
	}
	//建含有K个节点的小堆
	for (int i = (k - 1 - 1) / 2; i > 0; i--)
	{
		ADJustDown(kminheap, k, i);
	}
	//取出剩下的N-K个数
	int x = 0;
	while (fscanf(fout, "%d", &x) > 0)
	{
		if (x > kminheap[0])
		{
			kminheap[0] = x;
			ADJustDown(kminheap, k, 0);
		}
	}
	printf("TOP K:");
	for (int i = 0; i < k; i++)
	{
		printf("%d ", kminheap[i]);
	}
}

4.堆排序问题

首先我们需要将排序的数组建堆,我们需要知道降序与升序分别需要建大堆还是小堆。可能很多人会认为降序应该建大堆,升序应该建小堆。但是事实则不然,比如我们会发现建小堆后,虽然从上到下是依次减小,但是兄弟节点的大小关系并不能确定。所以我们需要借用Top k问题的思想。降序建小堆,升序建大堆。以建小堆为例,我们可以选择向上调整算法与向下调整算法两种方法。若选择向上调整算法则我们需要应用循环,为了让整个调整的过程有序,那么我们需要从i=1的位置开始应用向上调整算法,一个数据一个数据的向上调整,若是从最后一个数据开始向上调整就会出现重复调整的情况,所以在应用向上调整算法建堆时我们开始的下标应为i=1。同理,若是应用向下调整算法,我们需要从(size-1-1)/2的位置开始向下调整。以降序为例,我们将堆顶数据与堆底数据进行交换,这样堆底数据就是最小的,将堆的有效数据个数减一,再将堆顶数据实施向下调整算法,这样堆顶数据就是次小数据了,再按照上述方法依次实现,最后数组中的数据就是降序排列。下面以降序为例来实现一下堆排序问题。

//向下调整
void ADJustDown(HPDataType* a, int n, int parent)
{
	//假设法:假设左儿子小
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && a[child] > a[child + 1])
		{
			++child;
		}
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			return;
		}
	}
		
}


void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p2;
	*p2 = *p1;
	*p1 = tmp;

}

//向上调整
void ADJustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (a[child] <= a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			return;
		}
	}
}

//堆排序
void HPSort(HPDataType* a, int n)
{
	//建堆---向上调整算法建堆
	//for (int i = 1; i < n; i++)
	//{
	//	ADJustUp(a, i);
	//}

	//建堆---向下调整建堆
	
	for (int i =( n - 1 - 1)/2 ; i >= 0; i--)
	{
		ADJustDown(a, n, i);
	}
	//降序,建小堆
	//升序,建大堆
	int end = n - 1;
	while (end>0)
	{
		Swap(&a[0], &a[end]);
		ADJustDown(a, end,0);
		--end;
	}
}

void HeapTest02()
{
	int a[] = { 3,8,4,9,5,1,6,2,7 };
	HPSort(a, sizeof(a) / sizeof(a[0]));
}

5.向上调整建堆与向下调整建堆的时间复杂度

向下调整建堆的时间复杂度为O(N),向上调整建堆的时间复杂度为O(N*logN)。所以,可知向下调整建堆更加快速。

4.二叉树的链式存储

我们应用堆来存储完全二叉树,而堆是基于数字实现的,因为完全二叉树在找父节点和子节点时有规律可循。但是普通的二叉树是不能用数组实现的,因为我们并不能依据数组的下标去访问到其父子节点,所以我们只能应用链表来存储。那么存储时分为二叉链表和三叉链表。二叉链表是除数据外存储其左子节点和右子节点。三叉链表是除数据外存储其左节点,右节点和父节点。

在实际应用中二叉树的链式存储并没有太大的意义,学习它就是为了学习搜索二叉树,搜索二叉树是一种特殊的二叉树,规律为所有的节点的左子树的所有值都比其小,右子树的所有值都比其大。

例如搜索二叉树在查找43时,第一层可以避免查找左子树,第二层可以避免查找右子树,以此类推。这样的方法类似于二分查找,但是它要比二分查找要高级很多。二分查找的要求很高,首先它需要数据都存储在数组中,其次在查找前还需要将所有数据都先排序,且它的插入删除操作都很麻烦,所以还是搜索二叉树比较好。那么在后期,我们可以通过搜索二叉树引出AVL树和红黑树,统称二叉树平衡搜索树,最终引出B树系列又称多叉平衡搜索树。

1.遍历

1.前序遍历

首先我们需要将二叉树看成递归结构,访问顺序为:根,左子树,右子树。所以前序遍历也叫做前根遍历。以该二叉树为例,假设空树为N,则访问顺序为:1,2,3,N,N,N,4,5,N,N,6,N,N。任何一棵子树的访问都要符合前序的规则,拆到空树为止,拆成根,左子树,右子树。

2.中序遍历

访问顺序为:左子树,根,右子树。所以中序遍历也叫做中根遍历。同上,访问顺序为:N,3,N,2,N,1,N,5,N,4,N,6,N。

3.后序遍历

访问顺序为:左子树,右子树,根。所以后序遍历也叫后根遍历。同上,访问顺序为:N,N,3,N,2,N,N,5,N,N,6,4,1。

4.层序遍历

访问顺序为:逐层访问。可知,访问顺序为:1,2,4,3,5,6。

我们将前序遍历,中序遍历,后序遍历统称为深度优先遍历。将层序遍历称为广度优先遍历。

实践
#include<stdio.h>
#include<stdlib.h>


typedef int BTDataType;

typedef struct BinaryTreeNode
{
	BTDataType data;
	struct BinaryTreeNode* left;
	struct BinaryTreeNode* right;
}BTNode;



BTNode* BinaryTreeCreat();
BTNode* BuyNode(int x);
void PrevOrder(BTNode*root);
void InOrder(BTNode* root);
void TailOrder(BTNode* root);


int main()
{
	//前序遍历
	BTNode* root = BinaryTreeCreat();
	printf("前序遍历\n");
	PrevOrder(root);
	printf("\n");

	//中序遍历
	printf("中序遍历\n");
	InOrder(root);
	printf("\n");

	//后序遍历
	printf("后序遍历\n");
	TailOrder(root);
	printf("\n");
	return 0;
}


BTNode* BinaryTreeCreat()
{
	BTNode* node1 = BuyNode(1);
	BTNode* node2 = BuyNode(2);
	BTNode* node3 = BuyNode(3);
	BTNode* node4 = BuyNode(4);
	BTNode* node5 = BuyNode(5);
	BTNode* node6 = BuyNode(6);

	node1->left = node2;
	node1->right = node4;
	node2->left = node3;
	node4->left = node5;
	node4->right = node6;
	return node1;
}

BTNode* BuyNode(int x)
{
	BTNode* node = (BTNode*)malloc(sizeof(BTNode));
	if (node == NULL)
	{
		perror("malloc fail");
		exit(1);
	}
	node->data = x;
	node->left = NULL;
	node->right = NULL;
	return node;
}

//前序遍历
void PrevOrder(BTNode*root)
{
	if (root == NULL)
	{
		printf("N ");
		return;
	}
	printf("%d ", root->data);
	PrevOrder(root->left);
	PrevOrder(root->right);
}

//中序遍历
void InOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("N ");
		return;
	}
	InOrder(root->left);
	printf("%d ", root->data);
	InOrder(root->right);
}

//后序遍历
void TailOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("N ");
		return;
	}
	TailOrder(root->left);
	TailOrder(root->right);
	printf("%d ", root->data);

}

2.求节点个数

想要求子节点个数,我们可以应用上面讲解的遍历的方法。我们可以遍历所有的节点,同时定义一个变量size用来计数,最后返回size。

那么最容易想出来的方法就是

//错误示范1
int TreeSize(BTNode* root)
{
	int size = 0;
	if (root == NULL)
	{
		return 0;
	}
	else
	{
		++size;
		TreeSize(root->left);
		TreeSize(root -> right);
	}
	return size;
}

我们会发现,执行过后得出的size大小为1,这是因为size为局部变量,每次调用该函数后size的值都会进行更新,所以这种方法不可行。

那么我们可能又会想出将size设置为静态局部变量

//错误示范2
int TreeSize(BTNode* root)
{
	static int size = 0;
	if (root == NULL)
	{
		return 0;
	}
	else
	{
		++size;
		TreeSize(root->left);
		TreeSize(root->right);
	}
	return size;
}

执行过后我们会发现得出的子节点数是正确的。但是,当我们多次调用这个函数去计算子节点数我们会发现它的个数在叠加,这是因为静态变量只会初始化一次。所以这个方法不可行。

我们又会想出,可以将size设置为全局变量或者全局静态变量,这样我们只需要在调用这个函数之前将size置为0就可以了。

这样就可以将这个问题很好的解决了。我们还可以思考一下,应用全局变量的本质就是将一个可以作用于整个程序的变量进行重复++。所以我们还可以定义一个指针,在每次调用函数时,将指针也一起传递过去,更改指针指向的变量,这样就可以发挥与全局变量同样的效果了。

//正确示范2
int size = 0;
printf("TreeSize:%d\n", TreeSize(root,&size));
size = 0;
printf("TreeSize:%d\n", TreeSize(root, &size));
size = 0;
printf("TreeSize:%d\n", TreeSize(root, &size));


//正确示范2

int TreeSize(BTNode* root, int* psize)
{
	if (root == NULL)
	{
		return 0;
	}
	else
	{
		++(*psize);
	}
	TreeSize(root->left, psize);
	TreeSize(root->right, psize);
	return *psize;
}

除了上面两种方法,我们还可以进一步应用递归的思想。子节点数量是由左子树+右子树+1组成的,我们再将左右子树拆分,其个数也是左子树+右子树+1组成的,这也可以看作是递归,那么结束的条件就是子树为NULL。

//正确示范3
int TreeSize(BTNode* root)
{
	return root == NULL ? 0 : TreeSize(root->left) + TreeSize(root->right) + 1;
}

3.求叶子节点个数

求叶子节点,我们仍然可以按照递归的思想。当该二叉树为空树时,我们返回0,当该节点的左右子树均为NULL,那我们就返回1,当该节点左子树或右子树还有子节点,那么就继续访问,将其相加,就可以得出叶子节点的个数。q

4.求树的高度

根据前面所学,我们可以想出用递归的方法求出树的高度。我们可以将问题拆分,就会变成,如果是空树,则返回零;如果不是空树,则返回左子树或右子树中高度最大的+1。依次递归。那么非常容易想出的一个方法:

int TreeHeight(BTNode* root)
{
	if (root == NULL)
	{
		return 0;
	}
	return TreeHeight(root->left) > TreeHeight(root->right) ? 
		TreeHeight(root->left) + 1 : TreeHeight(root->right) + 1;
}

当我们自行运行的时候发现这个程序是正确的,但是,当我们做下面这道题时会发现. - 力扣(LeetCode)

当求一个高度很大的树的高度时,会超出时间限制

这是因为,当我们最开始比较左右子树大小时已经求过一遍大小,但是由于这个数字没有被存储,所以在下面加一时就又要再求一遍。当求下一层时,就又需要再重新求一遍,所以会导致超出时间限制。

所以我们可以创建一个局部变量用来存储每个子树的大小,这样就可以节省时间,防止超出时间限制。

//正确示范
int TreeHeight(BTNode* root)
{
	if (root == NULL)
	{
		return 0;
	}
	int leftHeight = TreeHeight(root->left);
	int rightHeight = TreeHeight(root->right);
	return leftHeight > rightHeight ?
		leftHeight + 1 : rightHeight + 1;

}

5.第k层节点个数

第k层节点个数可以视为相对于第1层的第k层节点个数,也就是相对于第2层的第k-1层节点个数,同理可得是相对于第k层的第1层的节点个数。通过上述分析可知这是一个递归问题。那么我们将这个问题拆分,首先我们需要分为三种情况,第一种是该树为空树,则返回0;第二种是该树只有1层,则返回1;第三种是该树层数大于1层,则方法是从根节点开始向下访问,直到访问到第k层。那么刚才说过第k层节点个数可以视为相对于第k层节点的第1层节点个数之和。所以由此可以知道递归的规律。

//求第k层节点个数
int TreeLevelKSize(BTNode* root, int k)
{
	if (root == NULL)
	{
		return 0;
	}
	if (k == 1)
	{
		return 1;
	}
	return TreeLevelKSize(root->left, k - 1) + TreeLevelKSize(root->right, k - 1);
}

6.查找节点x

在二叉树中寻找节点x,利用的方法仍然是递归遍历整个二叉树。那我们依据前序遍历,最容易写出的代码是这样的:

//查找节点(错误版)
BTNode* TreeFind(BTNode* root, BTDataType x)
{
	if (root == NULL)
	{
		return NULL;
	}
	if (root->data == x)
	{
		return root;
	}
	TreeFind(root->left, x);
	TreeFind(root->right, x);
}

这串代码出错在其为递归,但是最后却没有返回值,若均不符合条件,没有返回值将会报错。

那么我们就需要用一个东西来存储这个地址,若该节点符合要求,就返回该地址。若所有节点都不符合要求就最后返回NULL。

//查找节点(正确版)
BTNode* TreeFind(BTNode* root, BTDataType x)
{
	if (root == NULL)
	{
		return NULL;
	}
	if (root->data == x)
	{
		return root;
	}
	BTNode* ret1 = TreeFind(root->left, x);
	if (ret1 != NULL)
	{
		return ret1;
	}
	BTNode* ret2 = TreeFind(root->right, x);
	if (ret2 != NULL)
	{
		return ret2;
	}
	return NULL;
}

5.二叉树的销毁

在销毁二叉树中我们应用到了递归和遍历的知识。我们一共学了四种遍历,前序和中序遍历都有的一个缺点就是,他们会在子树销毁完毕之前先将根节点销毁,这样就会导致找不到剩余的子树,所不能用前序和中序的思想。所以我们采用后序,先将两棵子树销毁再销毁根节点。

首先我们需要判断该节点是否为空,若为空则返回,若不为空则应用函数的递归依次访问该节点的左右子树,并在访问完两棵子树后释放掉该节点。

代码如下:

//二叉树的销毁
void TreeDestory(BTNode* root)
{
	//应用后序遍历
	if (root == NULL)
	{
		return;
	}
	TreeDestory(root->left);
	TreeDestory(root->right);
	free(root);
}

6.层序遍历

层序遍历时我们需要应用到队列的知识。层序遍历的基本思想是一层带一层,也就是,当访问完第一层时,取出第一层,根据第一层的节点访问第一层的下一层节点。

以该图为例,首先将1存入队列,第一层结束。将1取出,通过访问1的左右节点,访问到2,4,将2,4存入队列,第二层结束。将2取出,访问2的左右节点,访问到3,将3存入队列,将4取出,访问4的左右节点,访问到5,6,将5,6存入队列,第三层结束。将3取出,左右节点没有,不用存入,将5取出,左右节点没有,不用存入,将6取出,左右节点没有,不用存入。

此时队列中没有节点,表明这个二叉树已经层序遍历完毕,就可以结束了。

所以根据上面所说,我们可以得出,首先我们需要先创建一个队列,然后将二叉树的根节点先存到队列中。然后我们应用while循环来依次遍历下面的节点,循环的结束条件就是当队列为空时就结束。在循环中我们首先需要先将队列的首元素取出并储存,然后删除队列中的该元素,接着根据这个元素访问它的左右节点。

代码如下:

//层序遍历
void TreeLevelOrder(BTNode* root)
{
	Queue q;
	QueueInit(&q);

	if (root != NULL)
	{
		QueuePush(&q, root);
	}
	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);
		printf("%d ", front->data);

		if (front->left)
		{
			QueuePush(&q, front->left);
		}
		if (front->right)
		{
			QueuePush(&q, front->right);
		}

	}

	QueueDestory(&q);
}

7.判断是不是完全二叉树

完全二叉树与普通二叉树的区别就是,完全二叉树的叶子节点是从左到右依次排列的,而普通二叉树没有规律。所以由此我们就可以知道完全二叉树的判断方法了。我们应用层序遍历,当我们访问到最后一层时应当是先是连续的非空节点,再是连续的空节点,若已经出现空节点后还出现了非空节点,就证明这个二叉树不是完全二叉树。

所以与层序遍历不同的是,层序遍历时我们不将空节点存入队列,但是判断完全二叉树时无论是不是非空节点我们都要将其存入队列。判断该节点是不是空节点,如果是空节点,则跳出这个循环进入下一个循环,并在该循环中判断出队列的节点是不是空节点,如果是空节点则返回false。若到最后都是连续的空节点,那就返回true。

代码如下:

//判断树是不是完全二叉树
bool TreeComplete(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root != NULL)
	{
		QueuePush(&q,root);
	}
	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);
		if (front == NULL)
		{
			break;
		}
		QueuePush(&q, front->left);
		QueuePush(&q, front->right);
	}
	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);
		if (front != NULL)
		{
			return false;
		}
		QueuePush(&q, front->left);
		QueuePush(&q, front->right);
	}
	QueueDestory(&q);
	return true;
}

二叉树的基础内容就讲完啦,二叉树的升级版,比如红黑树,二叉搜索树,AVL树等会在后续讲解哦~

上一篇:IMS核心网和5GC的区别与联系


下一篇:Linux 常用打包和压缩格式命令(tar tar.gz tar.bz2 tar.xz zip)