二叉树的介绍

1.树

1.树的概念

    树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。

    树中有一个特殊的结点,称为根结点,根节点没有前驱结点;除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继

    也就是说,对于任何一颗树,都可以看作是由子树两部分构成。子树再看作是由根和子树两部分构成,不断这样递推下去,直到根没有子树为止。

    因此,可以发现,树是递归定义的。注意:树形结构中,子树之间不能有交集,否则就不是树形结构。

1.2树的相关概念

    

结点的度:一个结点含有的子树的个数称为该结点的度; 如上图: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)棵互不相交的树的集合称为森林。

注意:结合c语言数组下标从0开始的习惯,有时候有人也会说从根结点开始定义,根为第0层。但如果树是空树的化,就会写为-1层,可以是可以,但有点怪,还是将根定义为第0层。

1.3树的表示

    树该怎么表示呢?树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系

    如果知道树的度是N,可以这样表示树:

//假设知道树的度是5

# define N 5
struct TreeNode
{
    struct TreeNode* children[N];  //指针数组
    int data;
};

    如果不知道度是多少,可以这样表示:

struct TreeNode
{
    SeqList s1;      //用线性表或链表 存的数据类型是struct TreeNode*
    int data;
};

但这样都比较麻烦,不太好。有人想出了一个特别厉害的表示方法:左孩子右兄弟表示法

typedef int DataType;
struct Node
{
 struct Node* _firstChild1; // 从左边开始第一个孩子结点
 struct Node* _pNextBrother; // 指向其下一个兄弟结点
 DataType _data; // 结点中的数据域
};

这个结构仅涉及了两个指针变量,但不管又有多少个孩子结点都可以找到。只要找到第一个孩子结点,就能像遍历链表一样找到兄弟结点。

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

点开一个目录会展示所有子目录,文件相当于叶子结点。

2.二叉树

2.1二叉树的概念

    一棵二叉树是结点的一个有限集合,该集合: 1. 或者为空 2. 由一个根节点加上两棵别称为左子树和右子树的二叉树组成。每个结点最多有两个孩子。

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

    注意:对于任意的二叉树都是由以下几种情况复合而成的:

2.2现实中的二叉树

2.3 特殊的二叉树

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

2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 通俗的说:有K层,前K-1层都是满的,最后一层可以不满,但要连续。要注意的是满二叉树是一种特殊完全二叉树

2.4 二叉树的性质

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

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

3. 对任何一棵二叉树, 如果度为0其叶结点个数为n0,,度为2的分支结点个数为n2,则有n0=n2+1,度为0的永远比度为2的多一个。

    先结合这几个例子看一些题目理解一下:

4. 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h=log2(n+1)。(ps: 是log以2为底,n+1为对数)。具有n个结点的满二叉树和完全二叉树的高度,都可近似看作h=logn(log以2为底n的对数)。

2.5二叉树的储存结构

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

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

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.parent = (child - 1) / 2。

    2.leftchild = parent * 2 + 1。

    3.rightchild = parent * 2 + 2。

    一般使用数组只适合表示完全二叉树或者满二叉树,否则会有空间的浪费。

2.6堆

    单纯将完全二叉树用数组的结构存起来,并没有什么意义。但存入数组后想象成树并加上这样其中之一限制条件:1.树中所有父亲都小于或等于孩子;2.树中所有父亲都大于或等于孩子。这样就会变成堆,会产生两种形式:小根堆和大根堆。

    堆的性质: 堆中某个节点的值总是不大于或不小于其父节点的值; 堆总是一棵完全二叉树。

    把二叉树弄成堆可以干嘛呢?可以排序,如堆顶的元素总是最大或最小的,选出来后再次筛选次大或这次小的。还可以解决TOP-K问题,选出最大的前几个或最小的前几个。

    那怎么实现堆呢?需要一个指针指向数组,size记录有效数据个数,capacity记录容量。

typedef int HPDataType;

typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
}HP;

2.6.1向上调整

    (大根堆举例)实现堆底层是一个数组,在数组中可以插入数据,并且对于数组来说尾插比较好,当插入数据时空间不够需要扩容。比如先插入一个20,把数组想象为树也是在最后插入。

插入20后满足堆的性质吗?满足,这里用公式找到20的父亲,发现值比父亲小,就满足。那如果再插入60呢?

插入60后发现不满足堆的性质了,因此这里要做出改变。因为插入之前是符合堆的性质的,插入后就不符合了,所以插入之后影响的是祖先的部分。 所以要算60的父亲和60比较,父亲小了就和60的位置交换,换了以后不能停止,因为不一定满足大堆的性质了,所以继续沿着祖先路线比较。这里就相当于有一个child和parent不断向上走,如果child小于parent就调整,最坏调到根就停止了。这个过程也叫向上调整,最多调整高度次,也就是logn次。也就是说每次插入一个数据调整为堆要logn次。向上调整的前提是已经是堆。

void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p1;
	*p1 = *p2;
	*p2 = 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
		{
			break;
		}
	}
}

2.6.2初始化

    初始化前结构不为空,需要断言,然后开一定数量空间就可以了。

void HeapInit(HP* php)
{
	assert(php);
	php->a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
	if (php->a == NULL)
	{
		perror("malloc is fail");
		return;
	}
	php->size = 0;
	php->capacity = 4;
}

    补充:有时候遇到下面的形式,表示拿一个数组初始化堆,这时候除了上面操作外还需要建成堆。

void HeapInitArray(HP* php, int* a, int n);

2.6.3销毁

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

2.6.4插入

    函数取名上我只用了push,因为只能在最后插入,不能随心所欲的插入。当空间不够时需要扩容,size指向最后一个数据的下一个位置,插入完成后向上调整,向上调在数组中从size-1的位置开始调。

void HeapPush(HP* php, HPDataType x)
{
	assert(php);
	if (php->size == php->capacity)
	{
		HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * php->capacity * 2);
		if (tmp == NULL)
		{
			perror("realloc is fail");
			return;
		}
		php->a = tmp;
		php->capacity *= 2;
	}
	php->a[php->size] = x;
	php->size++;
	AdjustUp(php->a, php->size - 1);
}

2.6.5删除

    堆删谁有意义呢?尾删很简单,但是没有意义。这里头删比较有意义,也就是删堆顶的元素,为什么呢?拿大根堆来说堆顶是最大的元素,最大的拿出来后就能拿出第二大的,依次类推,这就可以解决TOP-K问题。那怎么删最大的呢?如果像之前一样挪动数据,时间复杂度是O(N),效率比较低,而且剩下的数也不是堆了,关系全部乱了。

    那怎么办呢?让堆顶元素和最后一个元素交换,然后--size就可以了删了。

    删了后整体不是堆了,但不看最后一个元素,光看左子树和右子树还是大堆。

    这样就需要向下调整,怎么向下调整呢?向下时,从根开始,哪个儿子大就让根和大的比较,如果根还是大就不用调整了,如果根小就和大的儿子交换,依次类推比较。最坏情况是调到叶子结点。

    怎么判断到叶子结点呢?计算结点的孩子,如果超出数组范围就代表是叶子结点。向下调整最坏调整高度次,也就是logN次。

void AdjustDown(HPDataType* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (a[child + 1] > a[child])
		{
			child++;
		}
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}

}

这样就完成了向下调整,但有个问题,因为默认左孩子最大,然后进行筛选,但是如果右孩子不存在,a[child+1] > a[child]这里的比较就会出现越界。因此先检查再访问。最后堆为空时也不能继续删。

void AdjustDown(HPDataType* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && a[child + 1] > a[child])
		{
			child++;
		}
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}

}

void HeapPop(HP* php)
{
	assert(php);
	assert(!HeapEmpty(php));
	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	AdjustDown(php->a, php->size, 0);
}

2.6.6判空

bool HeapEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}

2.6.7返回堆顶

HPDataType HeapTop(HP* php)
{
	assert(php);
	return php->a[0];
}

2.6.8有效数据个数

int HeapSize(HP* php)
{
	assert(php);
	return php->size;
}

2.6.9堆的完整代码

//   Heap.h

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

typedef int HPDataType;

typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
}HP;


void HeapInit(HP* php);
void HeapDestroy(HP* php);
void HeapPush(HP* php, HPDataType x);
void HeapPop(HP* php);
bool HeapEmpty(HP* php);
HPDataType HeapTop(HP* php);
int HeapSize(HP* php);

void AdjustUp(HPDataType* a, int child);
void AdjustDown(HPDataType* a, int n, int parent);
//  Heap.c


#include "Heap.h"


void HeapInit(HP* php)
{
	assert(php);
	php->a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
	if (php->a == NULL)
	{
		perror("malloc is fail");
		return;
	}
	php->size = 0;
	php->capacity = 4;
}

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

void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p1;
	*p1 = *p2;
	*p2 = 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
		{
			break;
		}
	}
}


void HeapPush(HP* php, HPDataType x)
{
	assert(php);
	if (php->size == php->capacity)
	{
		HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * php->capacity * 2);
		if (tmp == NULL)
		{
			perror("realloc is fail");
			return;
		}
		php->a = tmp;
		php->capacity *= 2;
	}
	php->a[php->size] = x;
	php->size++;
	AdjustUp(php->a, php->size - 1);
}

void AdjustDown(HPDataType* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && a[child + 1] > a[child])
		{
			child++;
		}
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}

}

void HeapPop(HP* php)
{
	assert(php);
	assert(!HeapEmpty(php));
	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	AdjustDown(php->a, php->size, 0);
}

bool HeapEmpty(HP* php)
{
	assert(php);
	return php->size == 0;
}

HPDataType HeapTop(HP* php)
{
	assert(php);
	return php->a[0];
}

int HeapSize(HP* php)
{
	assert(php);
	return php->size;
}
//   Test.c


#include "Heap.h"

void TestHeap()
{
	HP hp;
	HeapInit(&hp);
	HeapPush(&hp, 23);
	HeapPush(&hp, 15);
	HeapPush(&hp, 20);
	HeapPush(&hp, 54);
	HeapPush(&hp, 21);
	HeapPush(&hp, 2);
	int k = 0;
	scanf("%d", &k);
	while (!HeapEmpty(&hp) && k--)
	{
		printf("%d ", HeapTop(&hp));
		HeapPop(&hp);
	}
	HeapDestroy(&hp);
}

int main()
{
	TestHeap();
	return 0;
}

2.6.10堆排序

    堆的其中一个应用就是用来排序,前面实现的是堆,并没有排序。排序是给一些数,排完后按照所需顺序展示出来。现在比如有一个数组,我们要对里面的类容排序,难道我们要建堆插入堆,最后取堆顶的元素放回数组,虽然可以,但这样空间复杂度是O(N),而且也没有堆,难道还要写一个堆?这样比较麻烦。那能不能脱离堆的数据结构来排序呢?可以,这里可以先对数组直接建堆,将数组看作二叉树,然后从第二个数开始模拟尾插那样不断向上调,这里不用扩容,因为数组本来就是开好的,直接在数组中进行就可以了。比如现在排升序,那是建大堆还是小堆呢?答案是大堆,如果建了小堆,第一次选出了堆顶的数,因为它是最小的,选出后不再参与,接着把其余的看成堆选次小的,这时关系就全部乱了。这时又要重新建堆选择次小的,与其这样还不如重新遍历一遍。

    建成大堆后,选最大的数,和删除思路类似,第一个和最后一个交换,交换后最后一个就是最大的数,已经确定不再参与,然后其余的向下调整选次大的,刚好最后一个数的下标就是前面数的个数。

void HeapSort(int* a, int n)
{
	//向上调整 建堆
	for (int i = 1; i < n; i++)
	{
		AdjustUp(a, i);
	}

	//向下调整 排序
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		end--;
	}
}

    建堆除了向上调整建堆,能不能向下调整建堆?如果从根开始向下调整建堆不一定可以建堆成功,因为向下调整的前提条件是左右子树必须是大堆或小堆。

    那如果从最后开始调,不断往后倒,这样就可以完成向下调整建堆了。最后从叶子开始调没有意义,因为叶子可以看作大堆,也可以看作小堆,所以从倒数第一个非叶子开始调,也就是最后一个叶子的父亲。

void HeapSort(int* 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--;
	}
}

    向下调整一次和向上调整一次都是logN,那向下调整建堆和向上调整建堆有什么区别吗?拿结点最多的满二叉树来证明最坏情况。

    向下调整建堆:

    向上调整建堆:

    通过证明发现向下建堆比向上建堆效率更高。直观的看,满二叉树的最后一层结点数大约占了整个二叉树结点数的一半,向下调时结点多的时候调的次数少,向上调时结点多的时候调的次数多,所以向下建堆的效率高一些。

    那向下调整排序的时间复杂度是多少呢?可以这样看这个问题,每次交换一次就要向下调整一次,最后一层的结点数量占了整个二叉树结点数量的一半,所以交换了很多次也就调整了很多次。

综合下来时间复杂度是O(N*logN)。因此整个堆排序的时间复杂度综合下来是O(N*logN)。

2.6.11 TOP-K问题

    TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。

    求解TOP-K问题时很容易想到用排序来解决,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。

    从N个数中取最大的前K个,能不能说建一个大堆,每次都取堆顶元素,然后再Pop一下?虽然可以达到目的,但要考虑这样一种情况:如果N非常大,比如N是100亿,K == 50,100亿个整数占了大约40G的内存,内存中存不下,只能放磁盘文件,磁盘文件中不能随机访问,虽然有文件指针,但是性能慢,那怎么办?

    有这样一种方法:取前k个建一个小堆,继续遍历剩下的数据,如果这个数据比堆顶的数据大就代替它(和堆顶数据交换,然后向下调整),最后这个小堆就是最大的前K个。

    有没有可能说前k个数中比较大的那一个刚好在堆顶堵住其他的数进不来?这是不可能的,因为前k个建了一个小堆,大的数在堆的底部,其余数进来时不断向下调整为小堆,最后总会筛选出最大的k前个。

void PrintTopK(const char* file, int k)
{
	//建堆 用a中前k个元素建小堆
	int* topk = (int*)malloc(sizeof(int) * k);
	assert(topk);
	FILE* fout = fopen(file, "r");
	if (fout == NULL)
	{
		perror("fopen error");
		return;
	}
	//读出前k个建小堆
	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d", &topk[i]);
	}

	for (int i = (k - 2) / 2; i >= 0; i--)
	{
		AdjustDown(topk, k, i);
	}
	//将剩余的n-k个元素一次与对顶元素比较替换
	int val = 0;
	int ret = fscanf(fout, "%d", &val);
	while (ret != EOF)
	{
		if (val > topk[0])
		{
			topk[0] = val;
			AdjustDown(topk, k, 0);
		}
		ret = fscanf(fout, "%d", &val);
	}
	//此时堆中是前k个
	for (int i = 0; i < k; i++)
	{
		printf("%d ", topk[i]);
	}
	printf("\n");
	free(topk);
	fclose(fout);
}

void CreatNData()
{
	//造数据
	int n = 10000;
	srand((unsigned int)time(NULL));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}
	for (int i = 0; i < n; i++)
	{
		int x = rand() % 10000;  //产生不超过一万的随机数
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);
}

int main()
{
	CreatNData();
	//PrintTopK("data.txt", 10);
	return 0;
}

    这样就写好了,分开测试,先测试有没有成功生成文件。

    

    可以发现成功了(数据多没有截全),但最后打印我们不好观察哪10个数最大,不知道代码有没有问题,为了方便测试,因为生成的数不超过10000,我们自己里面修改10个数超过10000,最后打印的时候看看是不是这10个数就可以了。

int main()
{
	//CreatNData();
	PrintTopK("data.txt", 10);
	return 0;
}

    调用第二个函数观察。

    生成了正确的结果。

3.链式二叉树

    对于普通的随意的一颗二叉树,用数组的方式存会有很多的空间被浪费,因此用链式的形式存效果比较好,定义一个结点,里面有一个变量存值,还有两个指针变量指向左右孩子。

typedef int BTDataType;

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

    那普通的二叉树存数据有什么意义呢?其实没有意义,单纯用二叉树存不如用链表和数组存。以前的数据结构都要学习它的增删查改,但普通二叉树不学这些,因为没有意义。除非普通二叉树加一些条件变成搜索二叉树等才有意义。但现在还是要好好理解普通二叉树,这样可以为之后学其他树打基础。

3.1二叉树的遍历

    因为二叉树是一种非线性、递归的结构,因此有前序、中序、后序、层序这样几种遍历。凡是见到一颗二叉树,我们都要把这颗二叉树拆分为三个部分:根、左子树、右子树,子树也是继续拆分,空树不能拆分,这也体现了递归。

    前序遍历:根  左子树  右子树。

    中序遍历:左子树  根  右子树。

    后序遍历:左子树  右子树  根。

    在学习二叉树的基本操作前,需先要创建一棵二叉树,然后才能学习其相关的基本操作。由于现在对二叉树结构掌握还不够深入,为了降低学习成本,此处手动快速创建一棵简单的二叉树,快速进入二叉树操作学习,等二叉树结构了解的差不多时,反过头再来研究二叉树真正的创建方式。这里就以上图例子手动创建。

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


BTNode* CreatTree()
{
	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;
}

int main()
{
	BTNode* root = CreatTree();
	return 0;
}

3.2前序遍历

    前序遍历就是先访问根,再访问左子树,再访问右子树。访问就用打印来表示。访问根是就直接访问,访问左子树时不断递归访问左,访问右子树时不断递归访问右,遇到空就返回。

void PreOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("NULL ");
		return;
	}
	printf("%d ", root->data);
	PreOrder(root->left);
	PreOrder(root->right);
}

3.3中序遍历

    中序遍历就是先访问左子树,再访问根,再访问右子树。访问左树时不断递归访问左,访问根直接访问,访问右树时不断递归访问右,遇到空返回。

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

3.4后序遍历

    后续遍历就是先访问左子树,再访问右子树,再访问根。访问左子树不断递归访问左,访问右子树不断递归访问右,访问根直接访问,遇到空返回。

void PostOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("NULL ");
		return;
	}
	PostOrder(root->left);
	PostOrder(root->right);
	printf("%d ", root->data);
}

3.5画图理解

    前序、中序、后序用递归实现起来非常简洁,下面通过画递归展开图举前序例子来更加深刻的理解一下递归过程。

    其实本质是函数压栈和出栈的过程,为什么可以在不同的栈中跳来跳去,比如明明在访问3过了一会又去访问6,因为每个结点的指针都保存在了栈中所以可以不断返回。也可以发现递归的顺序是一样的,都是往左遍历,只是访问根的时机不同。

3.6结点个数

    怎么获得结点个数呢?最能想到的就是用前、中、后序任意一种顺序遍历一遍,遇到的结点不为空的结点就++一下。


void TreeSize(BTNode* root)
{
    int size = 0;
	if (root == NULL)
		return;

	++size;
	TreeSize(root->left);
	TreeSize(root->right);
}

    但不能用局部变量计数,因为局部变量只在作用域内有效,所以使用全局变量。

int size = 0;
void TreeSize(BTNode* root)
{
	if (root == NULL)
		return;

	++size;
	TreeSize(root->left);
	TreeSize(root->right);
}

    那用全局变量有什么问题吗?当多次调用时会出现累加效果,所以使用前还需要手动置0,这样比较麻烦。还能想到将size定义为静态的,这样也不可以,因为静态的只有第一次调用时初始化,以后不再会执行了,再次调用时也没办法初始化,没办法修改访问。

    还能想到一种办法是用指针变量传参,这样不管怎么递归函数里面都有指向计数变量的指针让它++。

 void TreeSize(BTNode * root, int* psize)
{
	if (root == NULL)
		return;

	++(*psize);
	TreeSize(root->left, psize);
	TreeSize(root->right, psize);
}

int main()
{
    //....树的手动建立省略
    int psize = 0;
    TreeSize(root, &psize);
    return 0;
}

    这里最优的办法是分治算法,怎么理解分治呢?我们可以假想这样一个情景:假设每一层下面都有两个分级。

    现在需要院长统计一个院有多少人,包括自己。院长不会挨着跑到每个宿舍向宿舍长统计人数,然后挨着统计导员人数,再挨着统计系主任人数,最后把自己加上。而是问系主任有多少人,系主任再问导员,导员再问宿舍长,然后加自己不断往回报人数。这就是分治算法的思想,就如同上级指挥下级那样。

    现在统计一颗二叉树的结点个数,可以先把左子树叫来返回结点个数,再把右子树叫来返回结点个数,最后加上根部自己的就统计出来了。

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

    之前的遍历就相当于自己挨着统计,而这种方式相当于上级指挥下级,效率比较高。

3.7树的高度

    求树的高度相当于左子树把高度报给我,右子树把高度报给我。这样当前树的高度就等于左右子树中高度大的加1。不断递归思考。

    有时候容易写成上图这样,这样不好,想当与往回报告的时候没有认真听而且每个人也没有记住又反复报了好几次。正确如下:

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;
}

3.8第k层结点个数

    根的第k层的结点个数 = 左子树的第k-1层个数 + 右子树的第k-1层个数。不断递归思考,当k==1相当于第k层。当结点为空说明该层没有结点。层数不存在第0层。

int TreeLevel(BTNode* root, int k)
{
	assert(k > 0);
	if (root == NULL)   //注意顺序
		return 0;
	if (k == 1)
		return 1;
	return TreeLevel(root->left, k - 1) + TreeLevel(root->right, k - 1);
}

3.9二叉树查找值为x的结点

    二叉树中查找值为x的结点,有多个一样的值返回第一个。当前结点如果不是,那就先从左树找一找,找不到再到右数找,没有就返回空。

    也就是说,根为空就返回空。根不为空的话如果根中找到了x,则返回根结点地址,否则从左边找,再从右边找。找到了就返回,找不到就返回空。递归中返回的时候是一层一层返回。

BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
	if (root == NULL)
		return NULL;
	if (root->data == x)
		return root;
	BTNode* lret = BinaryTreeFind(root->left, x);
	if (lret)
		return lret;
	BTNode* rret = BinaryTreeFind(root->right, x);
	if (rret)
		return rret;
	return NULL;
}

3.10层序遍历

    之前的遍历都是通过递归来进行的,那层序遍历如何走?这里有一个非常简单的思路:用队列完成,树不为空就先让根进队列,队列不为空时让根出队列,然后将根的孩子带进队列。也就是一层带一层。

    以上图举例,1出来时把1的孩子带进去,2出来时把2的孩子带进去,4出来时把4的孩子带进去,没有孩子时不用带,知道队列为空的时候代表遍历完成。

    队列中存的是1,2这样的值吗?不是,如果是值就不能找到孩子将孩子带入了,所以应该存结构,但结构太大了,因此只需要存结构指针就可以了。

void LevelOrder(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root)
		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);
	}
	QueueDestroy(&q);
}

    (这里把队头的值赋值给了front,所以可以pop)。

3.11判断二叉树是不是完全二叉树

    判断二叉树是不是完全二叉树,最开始想到这样的思路:先用前面的函数算出树的高度,再用前面的函数算出树的结点个数,看看是否匹配。这种方法对满二叉树可以用,但对完全二叉树不能用,因为可能是这样的树:

    那怎么判断呢?其实还是用队列的思路,与之前不一样的地方是遇到空也要入队列。下面画图来举例:

    对于完全二叉树,根不为空入1,队列不为空就出1再入1的孩子2、4,然后再出2,入2的孩子3、6,然后出3,入3的孩子NULL、NULL,这样依次类推,当队列中开始出NULL的时候,如果队列里的其他值都是NULL则说明是完全二叉树。这也基于完全二叉树按照层序走,非空结点一定是连续的。对于其他树来说,当队列开始出NULL的时候,仍然有非空值存在。

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)
		{
			QueueDestroy(&q);
			return false;
		}
	}

	QueueDestroy(&q);
	return true;
}

    最后需要说的是有人认为判断的时候每次都会pop,判断条件出来直接返回true就可以了。这样不太好,因为万一实现队列的时候有带有哨兵位等情况,所以放心使用接口就行,逻辑不会错。

3.11销毁

    销毁时就遍历销毁,这里建议用后序遍历销毁,因为其他顺序还要涉及保存下一个结点的问题。

void TreeDestroy(BTNode* root)
{
	if (root == NULL)
		return;
	TreeDestroy(root->left);
	TreeDestroy(root->right);
	free(root);
}

    最后外面手动置空。

4.二叉树题目练习

4.1

    单值二叉树。

https://leetcode-cn.com/problems/univalued-binary-tree/icon-default.png?t=O83Ahttps://leetcode-cn.com/problems/univalued-binary-tree/    要想看二叉树每个节点是不是都具有相同的值,想到的一个思路就是遍历一遍,每次和根结点的值比较看是不是一样的。但遍历所涉及的点比较麻烦,二叉树的问题解决中很少直接遍历。那可以用这样的方法:根结点不为空的话和左子树比一下看是不是一样,再和右子树比一下看是不是一样,如果都一样就说明一样。

    这里还是分治的思想,可以这样举例子:假设一个院的所有人做了一个测试,合格是一个标志,不合格是一个标志。现在没人知道哪个标志对应的什么,那就看看大家的标志是不是都是一样的,院长就叫来系主任比较,系主任叫导员比较……最后都一样往回报结果。

    对应到代码就是如果根为空,则符合单值二叉树。如果根不为空,根的左孩子也不为空并且左孩子的值不等与根的值,则不是单值二叉树。同理,如果根不为空,根的右孩子也不为空并且右孩子的值不等与根的值,则不是单值二叉树。若上述情况都不存在,说明根和左右子树的根的值一样,那就继续比较左孩子的左右子树与左孩子根结点是否一样;右孩子的左右子树右左孩子根结点是否一样。依次内推。

    下面画图更好的理解一下:

4.2

    检查两颗树是否相同。

https://leetcode-cn.com/problems/same-tree/icon-default.png?t=O83Ahttps://leetcode-cn.com/problems/same-tree/    比较两颗树是不是相同,就是根和根比较,左子树和左子树比较,右子树和右子树比较,如果都一样则说明相同。比较的时候要考虑空指针问题,如果结点都为空说明一样,如果一个为空一个不为空,则不一样,如果都不为空就比较看看。(其实就是把一个结点的情况处理好了,左右子树也是结点,套用就行)

4.3

    二叉树的前序遍历。

https://leetcode-cn.com/problems/binary-tree-preorder-traversal/icon-default.png?t=O83Ahttps://leetcode-cn.com/problems/binary-tree-preorder-traversal/

    题目要求返回数组,数组如果建在局部变量中,每次栈销毁也就没有了,所以题目提示说malloc一个数组,这样建的数组在堆区上。那开多大空间呢?可以用前面说的TreeSize直接一次性开好空间。那参数中的returnSize是什么呢?这里是想得到数组的个数,但是c语言不能返回两个值,所以用指针变量指向外部值即可。

    现在准备工作完成了,可以开始前序遍历了,但是如果在这个函数中递归显然不可以,因为每次递归都会调用malloc,所以再写一个函数完成递归。

    这里要注意的是,传i的时候传地址,因为愿意期望i不断向后走,否则每次都是局部变量栈帧销毁的时候也会销毁。下图是传错造成的结果:

4.4

    另一颗树的子树。

https://leetcode-cn.com/problems/subtree-of-another-tree/icon-default.png?t=O83Ahttps://leetcode-cn.com/problems/subtree-of-another-tree/    看一颗树中有没有另一个树,如果根不为空。其实就是从根结点比较看两个数是不是相同,不相同就在从左子树的结点比较看是否相同,还不相同就从右子树的结点比较看一不一样。有其中之一一样则证明有,反之没有。

4.5

    二叉树的构建及遍历。

https://www.nowcoder.com/practice/4b91205483694f449f94c179883c1fef?tpId=60&&tqId=29483&rp=1&ru=/activity/oj&qru=/ta/tsing-kaoyan/question-rankingicon-default.png?t=O83Ahttps://www.nowcoder.com/practice/4b91205483694f449f94c179883c1fef?tpId=60&&tqId=29483&rp=1&ru=/activity/oj&qru=/ta/tsing-kaoyan/question-ranking    已经知道先序遍历的结果,我们要通过先序还原二叉树,然后再用中序遍历出来。那怎么通过先序还原二叉树呢?先手动画一遍:

先序的顺序是根、左子树、右子树,因此创建时不断遍历数组,没有遇到空的话先给根创建值,再创建左子树,最后创建右子树,创建好后返回根结点的地址。遇到空则直接返回空即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值