二叉树基础知识和代码实现

前情提要

树的一些基本概念

节点:包含一个数据元素及若干指向子树分支的信息。

节点的度:一个节点拥有子树的数目称为节点的度。

叶子节点:也称为终端节点,没有子树的节点或者度为零的节点。

分支节点:也称为非终端节点,度不为零的节点称为非终端节点。

树的度:树中所有节点的度的最大值。

节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推。

树的深度(高度):树中所有节点的层次最大值称为树的深度。

父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。

子节点:一个节点含有的子树的根节点称为该节点的子节点。

兄弟节点:具有相同父节点的节点互称为兄弟节点。

堂兄弟节点:双亲在同一层的节点互为堂兄弟。

节点的祖先:从根到该节点所经分支上的所有节点。

子孙:以某节点为根的子树中任一节点都称为该节点的子孙。

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

1.二叉树的概念及其结构

1. 1 二叉树的概念

二叉树是n个有限元素的集合,该集合或者为空、或者由一个称为根(root)的元素及两个不相交的、被分别称为左子树和右子树的二叉树组成,是有序树。当集合为空时,称该二叉树为空二叉树。在二叉树中,一个元素也称作一个节点。

从图中可以看出;

1.二叉树不存在度大于2的节点。

2.二叉树有左右树之分,次序不能颠倒,因此二叉树是有序树。

1. 2 特殊的二叉树

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

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

满二叉树
​满二树

1. 3 二叉树的性质

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

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

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

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

1.4 二叉树的存储结构

1.顺序结构

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

2.链式结构

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

2.二叉树的顺序结构及实现

普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结 构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储。

 2. 1 堆的概念

如果有一个关键码的集合K = {k0,k1,k2,…},把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足:ki <=k2*k+1且 ki<=2*k+2(ki >=k2*k+1且 ki>=2*k+2) i = 0,1, 2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

 2. 2 性质

 1. 堆中的某个节点的值总不小于或者不大于其父节点

 2.堆是一颗完全二叉树。

 2. 3 堆的实现

 1.堆的向下调整算法

将需要调整的数,与其子节点中较小的数互换位置,然后再依次推类,直到其位于叶节点。(小堆)

void AdjustDown(HPDataType* a, int parent, int n)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && a[child + 1] < a[child])
        //用来判断子节点中哪一个更小
        //并且限定child + 1的范围
		{
			child++;
		}
		if (a[parent] > a[child])
		{
			Swap(&a[parent], &a[child]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

2.堆的向上调整

例如插入一个数据,数据肯定位于数组最后一个位置,那么我们需要将其调整到正确位置。

void AdjustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (a[parent] > a[child])
		{
			Swap(&a[parent], &a[child]);
			child = parent;
			parent= (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

直接将插入数据与其父节点比较,如果其小于父节点,那么直接换位置即可,然后让其再次与父节点比较,以此类推。

3.堆的创建和初始化

首先我们选择使用顺序结构来实现,那么我们就需要在堆区开辟一个数组来存储数据,并且要标记数组的大小和它的容量,方便我们后面在数组满了的时候扩容。

然后我们进行堆的初始化,初始化中我们先为数组开辟空间,并且给size和capacity初始值。

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

4.堆的插入

先将数据先放到数组尾部然后不断向上调整到合适位置即可。

void HeapPush(HP* php, HPDataType x)
{
	assert(php);
	if (php ->size == php->capacity)
	{
		HPDataType* temp = (HPDataType*)realloc(php->a,sizeof(HPDataType) * php->capacity * 2);
		if (temp == NULL)
		{
			perror("realloc fail");
			return;
		}
		php->a = temp;
		php->capacity *= 2;
	}

	php->a[php->size] = x;
	php->size++;

	AdjustUp(php->a, php->size - 1);
}

从图中可以看出我们在使用HeapPush后的数据成功建立了一个堆。

5.堆的删除

如果我们数组直接全部向前直接挪动一个数据的位置,那么这很显然大概率可能不再是一个堆了,那么我们应该如何调整才能让它保持是一个堆呢?

我们的思路是将堆顶的数据和最后的一个数据交换位置,然后让size--,来让其不参与调整,最后只需要将堆顶数据往下调整到正确的位置即可。

void Swap(HPDataType* x, HPDataType* y)
{
	HPDataType temp = *x;
	*x = *y;
	*y = temp;
}
void AdjustDown(HPDataType* a, int parent, int n)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child+1 < n && a[child + 1] < a[child])
		{
			child++;
		}
		if (a[parent] > a[child])
		{
			Swap(&a[parent], &a[child]);
			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, 0, php->size);
}

6.查看堆顶数据、判断堆是否为空、堆的大小

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

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

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

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

2. 4 堆排序实现

如果我们需要排升序,那么我们需要建立大堆,然后将堆顶元素弹出后,调整堆至有序,再次弹出堆顶元素,直至堆为空。最后数组就是升序了。

1.向上建堆及其时间复杂度分析

void AdjustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (a[parent] > a[child])
		{
			Swap(&a[parent], &a[child]);
			child = parent;
			parent= (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}
void AdjustDown(HPDataType* a, int parent, int n)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child+1 < n && a[child + 1] < a[child])
		{
			child++;
		}
		if (a[parent] > a[child])
		{
			Swap(&a[parent], &a[child]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}
void HeapSort(int* a, int n) 
{
	//建堆 -- 向上调整建堆  时间复杂度O(N*logN)
	for (int i = 1; i < n; i++)
	{
		AdjustUp(a, i);
	}
	
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, 0, end);
		end--;
	}
	//时间复杂度O(N*logN)
}

上图 建堆时间复杂度计算

在我们向下调整排序的时候,每次将最大的数拿出去,并且换上小的数到堆顶,在依次向下调整,那么我们可以近似的认为每次调整都是logN次,那么有N个数据,其实数量级也在N*logN这个级别。

2.向下建堆及时间复杂度分析

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

	//建堆 -- 向下调整建堆 通过从最后非子叶的一层开始向下调整建堆  时间复杂度O(N)
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, i, n); 
	}
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, 0, end);
		end--;
	}
	//时间复杂度O(N*logN)
}

向下调整时间复杂度分析

3. 二叉树的链式结构实现

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

BTNode* CreateBT()
{
	BTNode* Node1 = BuyBTNode(1);
	BTNode* Node2 = BuyBTNode(2);
	BTNode* Node3 = BuyBTNode(3);
	BTNode* Node4 = BuyBTNode(4);
	BTNode* Node5 = BuyBTNode(5);
	BTNode* Node6 = BuyBTNode(6);

	Node1->left = Node2;
	Node1->right = Node4;

	Node2->left = Node3;

	Node4->left = Node5;
	Node4->right = Node6;
	return Node1;
}

二叉树的逻辑结构图

二叉树是:1.空树 2.非空:根节点,根节点的左子树,根节点的右子树。

3. 1 二叉树前序、中序及后续遍历

学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉 树中的节点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历 是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。

1. 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。

顺序总是根、左子树、右子树,所以先打印根部数据其次才是左子树,最后才是右子树。

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

图中红色的圆圈一二三是递归的顺序

2. 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。

顺序总是左子树、根、右子树,所以先走左子树,一直到左子树为空,然后返回,再打印数据,再递归右子树。

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

3.  后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。

顺序总是左子树、右子树、根,所以先走左子树,一直到左子树为空,然后返回,再走右子树,然后为空就返回,最后打印数据。

void PostOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("NULL ");
		return;
	}

	PostOrder(root->left);
	PostOrder(root->right);
	printf("%d ", root->data);
}

3. 2  二叉树的层序遍历

层序遍历:除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根节点所在 层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第2层 上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。

为了实现层序遍历我们需要借助队列来实现,队列的特点是先进先出(FIFO),那么我们就需要队列来存储二叉树中的左右子树,然后依次打印左右子树中的值即可。

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);
	}
	printf("\n");
	QueueDestroy(&q);
}

3. 3 二叉树的大小

如果root递归到空就返回0,如果不为空就记为1.

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

3. 4 二叉树的高度

二叉树的高度(深度)就是统计最深的即可,那么我们就需要比较左右子树的大小,然后来寻找看谁更大,然后就取较大的这一边即可。因此我们需要先让左子树递归到NULL类似于前序遍历,如果左右子树都为零(root为NULL返回0

),那么我们记该节点为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;
	//时间复杂度颇高,比较时候调用TreeHight后面返回值的时候也会调用 O(N^2)
	int leftHeight = TreeHeight(root->left);
	int rightHeight = TreeHeight(root->right);
	return leftHeight > rightHeight ? leftHeight + 1 : rightHeight + 1;
}

3. 5 判断该层的节点数

当前第k层的节点个数 = 左子树的第 k - 1 层的节点个数 + 右子树第 k -1 层的节点个数

当k递归等于1的时候,就是我们根的第k层(选的的参考系不一样)。那么如果指针不为空说明有节点,如果为空则没有节点。

int TreeLevel(BTNode* root, int k)
{
	if (root == NULL)
		return 0;
	if ( k == 1)
		return 1;
	return TreeLevel(root->left,k-1) + TreeLevel(root->right, k-1);
}

 3. 6 二叉树查找

查找相同值类似于前序遍历,先比较相同值然后再遍历左子树,最后遍历右子树。

BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
	if (root == NULL)
		return NULL;
	if (root->data == x)
		return root;
	BTNode* lret = BinaryTreeFind(root->left, x);
//此处用lret保存,避免再次递归返回。
	if (lret)
		return lret;
	BTNode* rret = BinaryTreeFind(root->right, x);
	if (rret)
		return rret;
	return NULL;
}

3. 7 判断两个二叉树是否完全相同

思路:通过比较是否有不同的地方,如果有直接返回false即可,如果整体都没有问题,那么最后返回true。

需要注意的就是,我们其实是和前序遍历的顺序一样,一直递归到NULL,判断相同地方的值是否相同,如果不同直接返回false,那么这个false将在递归中一直返回直到返回给主函数。true只在p、q均为空或者两者的值相同的时候才可以得到。

bool isSameTree(BTNode* p, BTNode* q) {
	if (p == NULL && q == NULL)
		return true;
	if ((p != NULL && q == NULL) || (p == NULL && q != NULL))
		return false;
	if (p->data != q->data)
		return false;
	bool lret = isSameTree(p->left, q->left);
	if (lret == 0)
		return lret;
	bool rret = isSameTree(p->right, q->right);
	if (rret == 0)
		return rret;
	return true;
}

3. 8 判断是否为完全二叉树

完全二叉定义:一棵深度为k的有n个结点的二叉树,对树中的结点按从上至下、从左到右的顺序进行编号,如果编号为i(1≤i≤n)的结点与满二叉树中编号为i的结点在二叉树中的位置相同,则这棵二叉树称为完全二叉树。

思路:如果我们用队列来装在这个二叉树(遍历),那么数据和数据之间不应该有间隔(NULL就间隔开)。因为我们是从上到下、从左到右依次标记,那么我们就需要用层序遍历来存储BTNode指针。

如果是完全二叉树,那么队列中再判断有无空的时候应该全部都为空,如果不是,那么其中肯定有非空的BTNode指针。

bool BinaryTreeComplete(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root)
	{
		QueuePush(&q, root);
	}
	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);
		if (front == NULL)
		{
			
			break;
		}
		else
		{
			QueuePush(&q, front->left);
			QueuePush(&q, front->right);
		}
	}
	//判断是否是完全二叉树,如果NULL后面仍然有非空节点,那么就不是。
	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);
		//判断是否有非空,有就返回false
		if (front != NULL)
		{
			QueueDestroy(&q);
			return false;
		}
	}
	//我们是使用链表实现,按道理说没有数据不需要在释放queue了
	// 但万一其原实现是使用数组,我们就仍然需要destroy
	// 因此我们使用destroy可以不去思考实现怎么样的
	QueueDestroy(&q);
	return true;
}

3. 9 二叉树销毁

类似于二叉树后序遍历,先遍历左右子树,最后销毁自身。

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

  • 18
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值