二叉树(堆 / 链式二叉树)

1.二叉树的表示方法

二叉树通常使用堆(底层是数组)表示,其次就是链式二叉树的结构,首先先拓展一种二叉树的结构,来了解树的结构,此方法为左孩子右兄弟表示法

实现原理:假设首节点仅有一个孩子节点,兄弟节点为NULL,首节点为父亲节点,指向其孩子节点,此孩子节点既有孩子节点又有兄弟节点,同理,兄弟节点也是有孩子节点和兄弟节点,最后一个兄弟节点指向空,类比D盘下有B / C / D三个文件夹,同等目录下利用兄弟节点即可全部引出,B文件夹中的数据通过B的孩子节点引出,并且利用第一个孩子节点将后面的兄弟节点全部引出,C / D同理,直到遍历到最后一个兄弟为NULL时结束,以此完成二叉树的结构

左孩子右兄弟结构体实现

struct TreeNode
{
	struct TreeNode* FirstChild;   // 指向的孩子节点
	struct TreeNode* pNextBrother; // 孩子节点指向的兄弟节点
	int data; 
};

左孩子右兄弟表示法示例图

2.二叉树的概念及基础

二叉树的区别:满二叉树和完全二叉树不是同等概念,满二叉树是指每层的节点数都是最大,完全二叉树可以不用每个节点数都是最大,但是叶子一定要连续(特殊的完全二叉树)

堆:为了更好的理解完全二叉树,我们引入了堆的概念,堆的底层是数组(面试题!!!),我们的增删查改都是在数组的基础上,其中引入了父节点(parent)和子节点(child)。

知道子节点求父节点: parent = (child - 1) / 2;
知道父节点求子节点:leftchild = parent * 2; / rightchild = parent * 2 + 1;

大堆:树的任何一个父亲都大于等于孩子
小堆:树的任何一个父亲都小于等于孩子

3.二叉树(堆的代码复现)

1.堆的结构体定义:

typedef int HPDataType;

typedef struct Heap
{
	HPDataType* a;  // 底层是一个数组
	int size;       // 当前数据个数
	int capacity;   // 容量
}HP;

2.堆的相关基础程序:

// 堆的初始化
void HeapInit(HP* php)
{
	assert(php);
	php->a = nullptr;
	php->size = php->capacity = 0;
}
// 堆的销毁
void HeapDestroy(HP* php)
{
	assert(php);
	// 将堆的对应数组析构
	free(php->a);
	php->a = nullptr;
	php->size = php->capacity = 0;
}

3.向上调整算法(重点)

向上调整算法是往数组中加入数据时,为了不破坏堆的结构使用的一种算法,其原理是:将新插入的值看作是子节点,求出其父节点后进行比较

大堆,则子节点大于父节点则两者交换,反之则不变(break跳出循环,小堆则是小于父节点则交换,反之不变

注意循环条件的判断,最坏情况下,当child == 0,说明child已经没有父节点,此时结束。

void Swap(int& x, int& y)
{
	int tmp = x;
	x = y;
	y = tmp;
}

// 建小堆 - 向上调整
void AdjustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;
	
	while (child > 0)
	{
		// 建大堆 - if(a[child] > [parent])
		if (a[child] < a[parent])
		{
			Swap(a[parent], a[child]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
			break;
	}
}

4.向下调整算法(重点)

向下调整的条件较为严苛,向上调整时,仅有一个节点时就可以建堆(建堆的依据!!!)

向上调整只有一个节点时可以作为父节点,新进入的作为子节点,进行向上调整,所以当有一个数组向上调整建堆是十分容易的,我们只需要将数组中第一个数据看作是父节点,从数组第二个数据开始作为子节点,依次向上插入即可

向下调整的则较为复杂,因为向下调整是需要通过父节点去找左子节点和右子节点,所以向下调整算法的前提是左子树和右子树都是大堆 / 小堆

向下调整若是建大堆,选出左右子节点中较大的和父节点进行比较,判断是否需要交换,其次循环结束条件是child > n,child大于n说明已经没有其它节点,此时则停止循环(n为数组的大小)

void AdjustDown(HPDataType* a, int size, int parents)
{
	int child = parents * 2 + 1;

	// 结束条件:当child > size / child + 1 > size时
	// 说明此时只是叶子或者没有此叶子
	while (child < size)
	{
		if (child + 1 < size && a[child] > a[child + 1])
		{
			// 建小堆 - 选择较小的
			++child;
		}

		if (a[parents] > a[child])
		{
			Swap(a[parents], a[child]);
			parents = child;
			child = parents * 2 + 1;
		}
		else
			break;
	}
}

5.建堆(利用向上 / 向下调整算法)

刚才提到,向上调整算法的条件并不苛刻,将数组的第一个元素看作父节点,其它元素看作子节点,进行向上调整即可形成堆。所以给定一个数组,想把它转成堆,定义一个for循环,从数组的第二个元素依次进行向上调整

// 向上调整建堆
for(int i = 1; i <= n; ++i)
{
	AdjustUp(php->a, i);
}

向下调整建堆的条件就较为苛刻,首先要确保左子树 / 右子树是大堆 / 小堆,我们可以借鉴一下向上调整建堆的思想

向上调整建堆是将数组首元素作为开始,那么向下调整的话我们就要将数组的尾元素作为开始,倒着调整,数组的最后几个元素是叶子节点,叶子节点是不需要处理的,所以我们把叶子节点(即数组最后几个元素)作为向下调整的依据,找到最后一个非叶子节点开始调整(即从最后一个父节点开始调整)

最后一个父节点(n - 1 - 1)/ 2,数组中父节点排在前,子节点排在后,顺序是相连的,找到最后一个节点后–,即可遍历到后续父节点

for(int i = (n - 1 - 1) / 2; i >=0; --i)
{
	AdjustDown(php->a, n, i);
	// n为数组大小
}

总结:两种建堆方式都可以直接将数组建成堆,思想有相通之处,先固定部分在变化

知识点 :1.子节点和父节点在数组中顺序是相连的,前面为父节点,后面为子节点 / 2.向下调整建堆的时间复杂度是O(N),向上调整建堆的时间复杂度是O(N * logN),所以建堆优先选择向下调整建堆

6.数据的插入和删除(交换的思想)

本模块主要介绍堆的交换思想,在堆的排序中应用很广泛,想要Pop头部的数据,我们不能直接从头部出数据

原因:直接从头部出数据后,进行一次向上调整或向下调整,父子间关系就全乱了,原本兄弟可能变成父子

为了保持部分堆的关系,我们采用交换的思想,将首部元素和尾部元素调换,从尾部将数据Pop后再执行向上 / 向下调整

// 插入数据
void Heapush(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, sizeof(HPDataType) * newCapacity);
		
		if (tmp == nullptr)
		{
			perror("relloc fail");
			return;
		}

		php->a = tmp;
		php->capacity = newCapacity;
	}

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

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

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

// 删除数据
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);
}

7.堆排序(TopK问题)

上模块的Pop我们说到堆排序用到了调换的思想,我们正常思想建大堆是降序,建大堆不断读取Top数据然后Pop,但我们提到过,直接Pop会影响堆的关系,所以我们Top的数据换到堆的尾部,不把最后一个数据看作是堆中的元素,再进行向下调整,这样建大堆的话,最大的数据就到了尾部,数组变成升序

面试重点: 建大堆 - 升序 / 建小堆 - 降序
时间复杂度:向下调整建堆O(N) + 堆排序O(N * logN) = O(N * logN) ,(奴推荐向上调整建堆)所以堆排序的时间复杂很优秀!!!

HPDataType HeapTop(HP* php)
{
	assert(php);
	assert(!HeapEmpty(php));

	return php->a[0];
}
// 堆排序
void HeapSort(HPDataType* a, int n)
{
// O(N)
	for(int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		// parent = (child - 1) / 2
		AdjustDown(a, n, i);
	}
// O(N * logN)
	int end = n - 1;
	while(end > 0)
	{
		Swap(a[0], a[end]);
		//进行一次向下调整
		AdjustDown(a, end, 0);
		--end;
	}	
}

与堆排序密切相关的就是TopK问题

K = 10,即建一个只有10个数的小堆,不断向内插入数据并进行向下调整

优势:解决了空间的问题,10亿个数只需要一个10个元素的数组 / 时间复杂度低

TopK问题

总结:以上便是用堆实现二叉树的全部内容,重点在于:1.向上 / 向下调整算法,2.两种算法建堆的方式,3.两种算法的时间复杂度分析,4.堆排序及TopK问题,5.调换的思想

4.链式二叉树

相较于堆的二叉树,链式二叉树重点主要在于对于三序(或四序(层序))的使用,创建 / 遍历 / 销毁都是对几种序都应用(重点:递归 - 深度理解递归 - 子问题 + 返回条件)

1.简易创建一个链式二叉树

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

BTNode* BuyNode(BTDataType x)
{
	BTNode* node = (BTNode*)malloc(sizeof(BTNode));
	if (node == NULL)
	{
		perror("malloc fail");
		return NULL;
	}

	node->data = x;
	node->left = NULL;
	node->right = NULL;

	return node;
}

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


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

	return node1;
}

2.三序遍历

前序创建 / 中序遍历 / 后序销毁

重点:递归深层次的思想:我们以第一个为分析对象,如果第一个对象为NULL(注意只有void可以直接return,其他的都要有返回值),我们就打印一个N并返回,如果有data,我们就读取值,当我们完成此步后就可以将此步骤作为子问题向下递归(此结论适用于大部分递归)

// 前序遍历
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 PostOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("N ");
		return;
	}

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

3.层序遍历

利用队列,根出队列时带进叶节点,依次遍历得出最后结果

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

4.链式二叉树题目链接

LeetCode100.相同的树
LeetCode965.单值二叉树
LeetCode101.对称二叉树
LeetCode572. 另一棵树的子树
牛客.前序构建二叉树
链式二叉树题目

链式二叉树总结:利用三序遍历深度理解递归的子问题+返回条条件

链式二叉树拓展:1.二叉树节点个数,2.二叉树叶子节点个数,3.二叉树第K层节点个数,(思想重点)4.二叉树查找值为x的节点(注意事项:递归返回值不是直接返回,一层一层的返回,直到整个程序执行完毕)

递归执行时创造一个栈,一次递归创造一次栈,只有递归中的返回条件满足,return回上一层,此层的栈才会销毁,递归从用第一个程序开始,只有再次回到此程序才算递归结束(递归很有可能有栈溢出的风险),注意递归的两要素:子问题 + 返回条件!!!

End!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值