一文带你入门二叉树!

前一篇文章,我们讲到了stake(栈)和queue(队列)两个数据结构里的重要概念(C++初步也只需要学到这个程度即可),现在,我们即将步入数据结构里最最重要的启蒙级概念——树(tree)。

一、什么是树?

        这便是一棵简陋的"树"了。

        概念上来说,树是一种非线性结构,以有限层数构成有层次关系的集合。

        实际操作上来说,由于树是由链表作为底层原理实现的,不访问上一层无法访问到下一层,而这便是“层次关系”之说。

        注意:树不能有交集。

        上面第一行的三个全都不是树。

      树的相关概念

        结点的度:一个结点含有的子树数量(实际只需要看分叉几根就可以了,比如A是3)

        叶子结点:度为0的结点。

        分支结点:度不为0的结点。

        父结点:子树的根结点的上一结点。

        子结点:某一结点的子树的根节点。

        兄弟结点:具有相同父结点。

        树的度:整棵树结点的度的最大值。

        结点的层次:将树横向分开,可以有第一层,第二层,第三层,这便是层次。

      树的表示方法

        左孩子右兄弟表示法。

        

typedef int Datatype;
struct TreeNode{
    Datatype data;
    struct TreeNode*child;
    struct TreeNode*brother;
}

        此处的brother指向下一个兄弟结点。child指向其中一个孩子结点。

        这种表示法很好地避免了度为多个的情况,如果有多个孩子的情况下,用相同父结点的兄弟结点来访问,有多层时,用父结点来向下访问。有效规避了结构体定义多个变量而造成不必要的重复。

        这样便可以表示一个任意度的树了。

二、二叉树

        实际上我们研究的肯定不是一条线往下的,因为那就是链表,多做阐述没有意义。

        二叉树,是我们研究树的开始。

        二叉树,顾名思义,每个结点最多有两个子结点。用术语来讲,就是为二的树。

        (上图演示的是度为三的树)

     二叉树的表示方法

       在二叉树里,我们并不需要用左孩子右兄弟表示法,因为度为二,所以只需要left和right表示左右子结点即可(NULL表示没有该结点)。

typedef int Datatype;
struct BinaryTreeNode{
    Datatype data;
    struct BinaryTreeNode* left;
    struct BinaryTreeNode* right;
}

        (C++的话结构体里面不用再额外写struct)

        

        相当于由一个根结点,即可访问到树的任一结点。

      特殊的二叉树

        满二叉树:每一层结点均为该层最大值(2^(k-1),k为该层的层数),其结点一定为2^h-1个(h为二叉树的层数)。

        完全二叉树:设二叉树层数为h,则前h-1层为满二叉树,第h层的结点数量在[0,2^(h-1)]范围内。

        满二叉树一定是完全二叉树,而完全二叉树不一定是满二叉树。

(这可能就是程序员眼里的树吧)

      二叉树的性质

         不难从上述表述得知,如果将根结点的层数设为1,那么第k层的最大结点数为2^(k-1)个,最多结点数是2*h-1(h为层数)

        除此之外呢,还有一条极为重要的性质.

        N0=N2+1。

        其含义是任意二叉树,度为0的结点是度为2的结点数量加一。

        证明:

        所有结点数N=N0+N1+N2。

        从连接结点的边来看,由于结点被访问的唯一性,故N个结点,有N-1条边。

        N1产生1条边,N2产生2条边,N0不产生边。

        那么就有N-1=N1+2*N2。

        两式相减得到1=N0-N2,即N0=N2+1。

      二叉树的结构

        和栈与队列一样,二叉树同样可以用数组和链表表示。

        但是,完全二叉树由于其连续性(层序遍历的方式),可以用数组表示,非完全二叉树则完全不行,不能用数组表示出究竟是左孩子还是右孩子(在数组里储存空格,不方便访问)。

        因而用链表的形式便可以都表示出来,图如表示方式所示。

        

三、堆

        堆(heap)在这里是一种数据结构,形式是完全二叉树。

        没错,大名鼎鼎的堆排序就出自这里!

        

                                                        (swap函数就是交换两个值)

     

        短短数行代码就实现了对一个数组的排序,且时间复杂度为O(nlogn),效率实在太高。

        框框抛出一个重磅炸弹,还得知道炸弹是怎么制造出来的。

      什么是堆?

        如上述所言,堆的本质是完全二叉树,但与普通的完全二叉树不同的是,堆分为大堆和小堆,大堆的含义是父结点大于等于子结点,小堆的含义则是父结点小于等于子结点。

        

           如图为小堆


实际上的储存结构

       堆的实现

          那么怎么实现堆呢?

          虽然堆实际上是用数组实现的,但我们仍需要从二叉树的想法来思考(因为二叉树的遍历是递归遍历,在后面会讲)

           打乱一下刚刚那个堆的顺序。

           

        现在这个堆既不是大堆也不是小堆,实际上称为二叉树更为合理。

        我们要排序,所以得是一个大堆\小堆(否则就是毫无逻辑关系的一堆数据,没有意义)

      向下调整与向上调整

        胡乱的调整不是我们想要的,对于计算机我们应该始终保持一种方法,因而产生了向下调整和向上调整两种办法。

        这两种办法本质没有区别,都是通过交换父结点和子结点使得堆变成大堆\小堆,只是调整的方向一个是自上而下,一个是自下而上。

void AdjustDown(HPDataType* a, int n, int parent) {
	int child = parent * 2 + 1;
	while (child<n-1)
	{
		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;
	}
}
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;
		}
	}
}

        乍看之下,两个办法没有区别,毕竟都是反复交换得到的。

        但按照代码其实可以知道,降序建的小堆,而升序建的大堆(这个实际上取决于你的代码中间的判断语句)

        实际上,无论怎么写代码,向下调整都更具有优势。

        从最坏情况下来看,设层数为h。

        向下调整:第1层需要调整h-1次,第k层需要调整h-k次。

        向上调整:第1层需要调整0次,第k层需要调整k次。

        那么相乘后加起来,便可知道两种情况分别是排序不等式的两个极端(向下调整为倒序和最小,向上调整为顺序和最大)

        故推荐使用向下调整作为建堆的手段。

      堆排序

        有了向下调整,我们就可以开始堆排了。

void HeapSort(HPDataType* a, int n) {
	for (int i = (n-2)/2; i>0; i--) {
		AdjustDown(a, n,i);
	}//建堆
	while (n-1 > 0) {
		Swap(&a[0], &a[n-1]);
		AdjustDown(a, n-1, 0);
		n--;
	}
}

        整段代码需要讲解的点有两个。

        一、adjustdown为什么是从叶子结点上一层开始排的?

        原因在于如果始终由根结点开始,不知道要经过多少次才能排到根是符合要求的,所以从子树看起,如果子树都排好了,那么根结点也是易排的。

        二、swap的含义(此处的排序为由大到小,如果需要由小到大,建大堆后重复操作即可)

        由于之前说过,adjustdown降序建小堆,那么根结点一定为最小值,所以将其和末尾交换,末尾的值是确定正确的,那么再对其他结点重复这一操作,就可以得到一个由大到小的数组。

        没错,就是这么简单。

      堆的其他功能

        和栈与队列一样,堆同样有一些功能。

void HeapInit(Heap* php);
// 堆的销毁
void HeapDestory(Heap* hp);
// 堆的插入
void HeapPush(Heap* hp, HPDataType x);
// 堆的删除
void HeapPop(Heap* hp);
// 取堆顶的数据
HPDataType HeapTop(Heap* hp);
// 堆的数据个数
int HeapSize(Heap* hp);
// 堆的判空
int HeapEmpty(Heap* hp);

        (HPDataType为自己设定的类型)

        代码实现如下,但在堆排序之下黯然失色。

void HeapInit(Heap* hp) {
	assert(hp);
	hp->a = NULL;
	hp->size = hp->capacity = 0;
}
// 堆的销毁
void HeapDestory(Heap* hp) {
	assert(hp);
	free(hp->a);
	hp->a = NULL;
	hp->size = hp->capacity = 0;
	free(hp);
	hp = NULL;
}
void HeapPush(Heap* hp, HPDataType x) {
	assert(hp);
	if (hp->size == hp->capacity) {
		int newcapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;
		HPDataType* tmp = (HPDataType*)realloc(hp->a, newcapacity * sizeof(HPDataType));
		if (tmp == NULL) {
			perror("realloc failed");
			return;
		}
		hp->a = tmp;
		hp->capacity = newcapacity;
	}
	hp->a[hp->size++] = x;
	AdjustUp(hp->a,hp->size-1);
}
// 堆的删除
void HeapPop(Heap* hp) {
	assert(hp);
	assert(hp->size > 0);
	Swap(&hp->a[0], &hp->a[hp->size - 1]);
	hp->size--;
	AdjustDown(hp->a,hp->size,0);
}
// 取堆顶的数据
HPDataType HeapTop(Heap* hp) {
	assert(hp);
	assert(hp->size > 0);
	return hp->a[0];
}
// 堆的数据个数
int HeapSize(Heap* hp) {
	assert(hp);
	return hp->size;
}
// 堆的判空
int HeapEmpty(Heap* hp) {
	assert(hp);
	if (hp->size == 0)
		return 0;
	else
		return -1;
}

      Top-K问题

        排序算法不可避免的一件事是,算法的复杂度一定是O(nlogn),我们要知道一个最大值还算OK,只需要遍历一遍即可,那如果我们要k个呢?难道我们要去设k个变量吗?k个变量之前还需要进行比较,实在麻烦。

        而堆,为我们提供了一个新思路。

        我们建一个k个元素的数组,并且将前k个填入堆,构成小堆,自然根结点的部分为最小值了。此时,我们将后续遍历的值不断与第一个元素(因为它一定是整个堆的最小值)比较,并且将放入数组的值向下调整保证时刻为小堆。

        这样,我们就用近乎O(n)的思路,解决了一个可能原本需要O(nlogn)的问题。

        而且这个方法在面对数据量巨大的时候更有奇效。

        我们不可能将所有的数据都存储在我们某个文件里,一定会出现需要从别的文件里得到数据的时候。

        那当我们调用的时候,发现内存根本无法容纳海量数据的时候,排序就变成了无稽之谈。

        而利用刚刚的思路,我们实际上只需要k+1个元素的内存,优势显而易见。

四、二叉树的实现

        让我们重新回到二叉树的主题。

        提二叉树的实现前,我们必须先提到二叉树的遍历方式。

        (假装已经创建出了一个二叉树)

      深度优先遍历(DFS)

        二叉树的常规遍历分为前序,中序,后序三种。

        其中前中后代表的是根在遍历过程中的优先级。

        前序:根,左孩子,右孩子。

        中序:左孩子,根,右孩子。

        后序:左孩子,右孩子,根。

        

        还是回到这个比较简单的图,我将答案展示在下方,可以对照一下,看看思路正确与否。

        前序:7->3->5->1->4->6->2

        中序:5->3->1->7->6->4->2

        后序:5->1->3->6->2->4->7

        三种遍历方式没有高下之分。

        而实际上前序应该是:

        7 3 5 NULL NULL 1 NULL NULL 4 6 NULL NULL 2 NULL NULL

        毕竟计算机不知道什么时候结束,所以在探测左孩子和右孩子的时候,一定要到NULL才会停下。

// 二叉树前序遍历 
void BinaryTreePrevOrder(BTNode* root) {
	if (root == NULL)
		return;
	printf("%c", root->data);
	BinaryTreePrevOrder(root->left);
	BinaryTreePrevOrder(root->right);
}

            中序和后序可以自己去试一下~,答案如下:

        (留白以让大家好遮住答案)

// 二叉树中序遍历
void BinaryTreeInOrder(BTNode* root) {
	if (root == NULL)
		return;
	BinaryTreeInOrder(root->left);
	printf("%c", root->data);
	BinaryTreeInOrder(root->right);
}
// 二叉树后序遍历
void BinaryTreePostOrder(BTNode* root) {
	if (root == NULL)
		return;
	BinaryTreePostOrder(root->left);
	BinaryTreePostOrder(root->right);
	printf("%c", root->data);
}

        由于二叉树的分叉,我们难以顺着一个方向一直找,因为那样就等于找不到原先的结点了,不利用递归的话,代码难以书写。

        可以说,二叉树频繁地使用到了递归的想法。(二叉树的大部分基础题都是递归实现的)

     层序遍历(广度优先遍历BFS)

void BinaryTreeLevelOrder(BTNode* root) {
	Queue q;
	QueueInit(&q);
	if (root)
		QueuePush(&q, root);
	while (!QueueEmpty(&q)) {
		BTNode* front = QueueFront(&q);
		QueuePop(&q);
		printf("%c", front->data);
		if (front->left)
			QueuePush(&q, front->left);
		if (front->right)
			QueuePush(&q, front->right);
	}
	QueueDestroy(&q);
}

        讲到层序遍历,我们就需要扯到一点之前说过的栈与队列了。

        DFS深搜是为了找到每一条路径(相当于一条道走到黑才往回走),而BFS就是为了找到最短路径了。

        层序遍历的实质是队列,当访问一个结点的时候,我们将其子结点放入队列中,直到所有子结点都完全遍历完(队列清空),整棵树也就一层一层地遍历完了。

      二叉树的创建

        说了这么多,终于要到二叉树的创建了。

        创建二叉树并不如创建数组那样简单,往往我们创建二叉树是为了达成某种目的,如找最短路径、枚举每一条可能路径等等。

// 通过前序遍历的数组"ABD##E#H##CF##G##"构建二叉树
BTNode* BinaryTreeCreate(BTDataType* a,  int* pi) {
	if (a[*pi] == '#') {
		(*pi)++;
		return NULL;
	}
	BTNode* root = (BTNode*)malloc(sizeof(BTNode));
	root->data = a[(*pi)++];
	root->left = BinaryTreeCreate(a, pi);
	root->right = BinaryTreeCreate(a, pi);
	return root;
}

        以什么方式遍历出结果的二叉树,就以该种遍历方式创建即可,同样是把对每个结点的操作作为根,放在left和right的前、中、后即可。

        

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值