二叉树之堆及其应用

一.树与二叉树

1.树的定义

与线性表类似,树也是用来存储和管理数据的数据结构。不同的是,树并不是线性结构,其逻辑结构来源于现实中的树:

将其抽象化之后可以表示为: 

参照上图可以清晰地认识到树地结构。在数据结构中,树地相关术语参照现实世界的亲缘关系:

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

 2.二叉树的定义

二叉树是一种特殊的树,其最大度为2(即每个节点最多仅有两个孩子),任意的树都可以转化为相应的二叉树,在后续的学习中主要研究二叉树。其结构大致如下:

二叉树的左右子树有次序之分,不可交换顺序。因此二叉树是有序树。 

3.树的表示

对于一般的树而言,每个节点的孩子数量并不固定,若用其他方法表示会比较麻烦。在此仅介绍“左孩子右兄弟法”进行表示。

typedef int DataType;
struct Node
{
struct Node* firstChild1; // 第一个孩子结点
struct Node* pNextBrother; // 指向其下一个兄弟结点
DataType data; // 结点中的数据域
}

因此以此方式举例:

左边的树可以转化为右边的 链式结构,其中每个子树都仅存储其第一个孩子,其他的孩子以第一个孩子的兄弟节点表示。

4.二叉树的分类及性质

二叉树可以大致分为两类,一种是满二叉树,一种是完全二叉树。

满二叉树即高度为h的二叉树的每个层次节点均达到最大值(每个节点均有两个孩子)

完全二叉树即高度为h的二叉树的h-1层为满节点,h层不满

如图为高度h均为3的满二叉树和完全二叉树

对于二叉树,其性质有: 

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

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

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

4. 若规定根结点的层数为1,具有n个结点的满二叉树的深度,h=log2(n+1) . (ps: 是log以2为底,n+1为对数)

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

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

b. 若2i+1<n,左孩子序号:2i+1,2i+1>=n否则无左孩子

c. 若2i+2<n,右孩子序号:2i+2,2i+2>=n否则无右孩子

5.二叉树的存储

1.顺序存储。顺序存储一般基于顺序表(或数组)存储。建议仅将完全二叉树进行顺序存储。因为对于非完全二叉树,若想在顺序存储中正确表示树的结构,会产生大量的冗余空间,比如:

2.链式存储

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

typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* left; // 指向当前结点左孩子
struct BinTreeNode* right; // 指向当前结点右孩子
BTDataType data; // 当前结点值域
}
// 三叉链
struct BinaryTreeNode
{
struct BinTreeNode* parent; // 指向当前结点的双亲
struct BinTreeNode* left; // 指向当前结点左孩子
struct BinTreeNode* right; // 指向当前结点右孩子
BTDataType data; // 当前结点值域
}

其结构可表示为:
 

二.堆及其应用

1.堆的定义

堆是一种特殊的完全二叉树。分为大堆和小堆,大堆指根节点大于孩子节点的完全二叉树,小堆指根节点小于孩子节点的完全二叉树。由上可知堆作为一个完全二叉树,以数组进行存储

比如对于同一组数据[2,4,7,1,5,8,9]

小堆为:

     1
   /   \
  2     8
 / \   / \
4  5  7   9

大堆为:

     9
   /   \
  5     8
 / \   / \
1  4  2   7

2.堆的实现

在此分为两个文件Heap.h(接口声明)和Heap.c(接口实现)实现堆的结构

Heap.h

#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>


typedef int HPDataType;

//堆的自定义结构
typedef struct Heap
{
	HPDataType* a;//存储堆的数组
	int size;//有效数据的个数,是最后一个元素的后一个的下标
	int capacity;//当前数组的容量
}HP;

void Swap(HPDataType* p1, HPDataType* p2);//交换p1和p2两个数据的内容
void AdjustUp(HPDataType* a, int child);//向上调整算法
void AdjustDown(HPDataType* a, int n, int parent);//向下调整算法

void HPInit(HP* php);//初始化堆
void HPDestroy(HP* php);//销毁堆
void HPPush(HP* php, HPDataType x);//向堆中压入数据
void HPPop(HP* php);//弹出当前堆的根的值
HPDataType HPTop(HP* php);//表示当前堆的根的值
bool HPEmpty(HP* php);//堆是否为空

Heap.c

#include"Heap.h"
//初始化堆,仅仅作为声明该结构,初始数组大小和容量均为空
void HPInit(HP* php)
{
	assert(php);
	php->a = NULL;
	php->size = php->capacity = 0;
}

//销毁堆时,将数组置为空,大小和容量也为空
void HPDestroy(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->size = php->capacity = 0;
}

//交换两个值
void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

//初始时传入一个数组和孩子节点的下标
//对于一个完全二叉树,一个child节点的parent节点下标为(child-1)/2
//在此以建小堆为例,当child的值小于parent的值,交换两节点值
//更新child和parent的位置
void AdjustUp(HPDataType* a, int child)
{
	// 初始条件
	// 中间过程
	// 结束条件
	int parent = (child - 1) / 2;
	//while (parent >= 0)
	while (child > 0)
	{
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

//向堆中插入数据
//先判断当前数组大小是否够用,不够扩容
//在size处插入新值,更新size值
//对当前的树进行向上调整算法,保持堆的结构
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("realloc fail");
			return;
		}

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

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

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

//在进行向下调整时,要保证其子树为堆结构
//假设左孩子小,将其设为child
//若右孩子更小,将child更新为右孩子
//比较child和parent大小
void AdjustDown(HPDataType* a, int n, int parent)
{
	// 先假设左孩子小
	int child = parent * 2 + 1;

	while (child < n)  // 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;
		}
	}
}

// logN
//删除当前堆的根节点
//将当前的根节点和未节点进行交换,此时删去交换后的尾节点即可
//再将处于根节点的原尾节点向下调整至合适位置
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];
}

bool HPEmpty(HP* php)
{
	assert(php);

	return php->size == 0;
}

3.详细介绍向上调整算法和向下调整算法(建堆方法)

1.向上调整算法(AdjustUP)

void AdjustUp(HPDataType* a, int child)
{
	// 初始条件
	// 中间过程
	// 结束条件
	int parent = (child - 1) / 2;
	//while (parent >= 0)
	while (child > 0)
	{
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

我们对一组数据进行向上调整建立小堆[2,4,7,1,5,8,9]

for (int i = 1; i < n; i++)
	{
		AdjustUp(a, i);
	}

可以看到这里的调整是从下标为1开始的,即先直接插入下标为0处元素2。下图为当前插入值恰好大于其parent直接break的情况,以及插入值小于其parent而需要不断调整的步骤

 在进行思维整理时要注意在插入堆时i++指向下一个元素,而在调整元素时i--。(即循环插入数据,每次插入数据进行调整,此解释是为了防止读者不清楚i为什么一会儿++一会儿--)

2.向下调整算法(AdjustDown)

其实由以上方法我们发现向上调整算法建堆有不方便之处,这里尝试使用向下调整算法建堆

void AdjustDown(HPDataType* a, int n, int parent)
{
	// 先假设左孩子小
	int child = parent * 2 + 1;

	while (child < n)  // 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;
		}
	}
}

我们对一组数据进行向下调整建立小堆[2,4,7,1,5,8,9] 

for (int i = (n-1-1)/2; i >= 0; i--)
{
	AdjustDown(a, n, i);
}

由于原始数据并不是堆,而向下调整需要保证其子树均为堆的结构,于是我们可以倒着建堆。叶子节点并不需要调整,需要调整的起点应该是最后一个非叶子节点。

而对于有n=7个数据的数组 [2,4,7,1,5,8,9],最后一个元素9下标为n-1,第一个非叶子节点即最后一个元素的父节点,其下标为(n-1-1)/2。我们从这里开始调整堆即可。

4.基于向上调整算法的堆排序

void HeapSort(int* a, int n)
{
	// 降序,建小堆
	// 升序,建大堆
	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;
	}
}

排序,指的是对原有数据进行升序或者降序的排列方式。由以上知识我们得知,大堆的根为最大的值,在进行Pop操作之后的根节点为次大的值。然而对于堆排序与我们所想的略有不同,若要得到一个降序的结果,不能建大堆。原因是 :

  • 调整过程的效率损失:每次交换后,剩余元素需重新调整为大顶堆。由于原堆的结构被破坏(末尾已被放置最大值),调整时需从根节点向下遍历整个子树,而大顶堆的特性要求父节点必须大于子节点。此时,剩余元素中可能存在大量较小的值,导致调整路径较长,需频繁交换(例如,原大顶堆的子树可能因末尾元素的替换而需要多次递归调整) 
  • 时间复杂度劣化:调整操作的复杂度可能从 O(log n) 退化为接近 O(n),导致整体时间复杂度接近 O(n²),破坏了堆排序的效率优势 

 

因此我们选择降序建小堆,升序建大堆的方法。这里要注意一个细节 

上述代码的该部分:

int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}

 为了得到一个降序的数组,我们每次将小堆得到的最小值与下标为end的值进行交换,在进行调整后--end,再将次小值与下标为end的值交换。。这样我们就将小堆的根节点倒着存在了数组中,这样得到的就是降序的数组了!

下面给出一个升序建大堆的图解(代码与上述一致,需要修改的只有向上调整算法内部的if条件,小于改为大于即可)

 5.建堆的时间复杂度

因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个结点不影响最终结果)

 因此:建堆的时间复杂度为O(N)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值