数据结构6:二叉树与堆

 本人能力有限,难免有叙述错误或者不详细之处!希望读者在阅读时可以反馈一下错误以及不够好的地方!感激不尽!

目录

 关于树:

树的逻辑结构:

二叉树的概念:

二叉树的性质:

二叉树的存储结构

顺序存储:

 链式存储:

 顺序存储的二叉树结构:堆

 堆的实现

 堆的初始化:

 堆的插入:

堆顶的删除:

向下调整的逻辑:

 堆的创建:

向上调整建堆:

向下调整建堆:

 向上建堆和向下建堆在效率上区别:

 堆排序:

TOPK问题:


 关于树:

树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。同现实中的树相同,其枝叶以树干为基点四散开来,每个树枝或者树叶相互链接,只要顺着树干一直往下,你就可以访问到整棵树的任何一片叶子。

 但是不同于一颗向上生长的树,数据结构中的树则是向下生长的。其型非常类似于树。


一些有关于树基本的概念:

根:其中,我们将A节点称之为根节点,毕竟这个结构非常直观,所有其他节点的数据的链接关系的源头都是这个A,也就是根节点,根节点没有前驱结点

节点的度:一个节点所含有的子节点的数量,如上图,A的度为3.

的度:  一棵树中,最大的节点的度称为树的度; 如上图:树的度为3

叶子节点或终端节点:也就是上图的KLGMIJ这几个节点,叶子无法再长出树枝,也就是树的终端。借用上面的概念,叶子节点也可以称之为度为0的节点

父亲节点和孩子节点:如上图,A是B的父节点,B是A的子节点,同族谱道理相同。

兄弟节点:源自同一父节点的子节点互相为兄弟节点,比如BCD互相为兄弟节点。

树的高度或深度:树中节点的最大层次; 如上图:树的高度为4

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


需要注意的是:

1.子树之间是不相交的,毕竟我们也不经常见树枝还能长到一块去连起来的树。(若是产生相交,则是另一种数据结构,称之为图)

2.除了根节点外,每个节点有且只有一个父节点,道理同上,会产生相交,也不符合我们对树的理解,一片叶子不可能长在两条树枝上

3.若一棵树的节点数量为N,则这棵树(也就是节点与节点之间的线)的数量为其节点的N-1

树的逻辑结构:

 

 这样的逻辑结构咱们其实还是很熟悉的,我们电脑内部的文件的访问方式其实就是树的一种,以Linux举例

二叉树的概念:

二叉树是树的一种,同它的名字一样,二叉,代表了它的每个节点的度至多为2,至少为0.

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


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

 

二叉树的性质:

二叉树带有一定的数学特性,以下则是一部分。

1.一颗根节点为1的非空二叉树,它的第N层上最多有2^(N-1)个节点,直观图如下

 2.一颗根节点为1的非空二叉树,它的最大节点总数为2^N - 1

3.对于任何一颗二叉树,它的叶子节点和分支节点存在如下关系:假设一颗二叉树的叶节点数量为N0,而度为2分支节点的数量为N1,则N0 = N1 +1 即叶节点的数量为分支节点数量加一,以上图的完全二叉树和满二叉树为例

 

 4.若规定根节点的层数为1,具有n个结点的满二叉树的深度, 是log  
   以2为底,n+1为对数)也就是特性1取对数。

5.对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,我们标记出来,会得到如下的二叉树样式,其各个节点会有一定数学与关系:

则对于序号为i的结点有:

1. 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点
2. 若2i+1<n,左孩子序号:2i+1,2i+1>=n否则无左孩子
3. 若2i+2<n,右孩子序号:2i+2,2i+2>=n否则无右孩子

二叉树的存储结构

 二叉树可以由两种方式实现,一种是链式结构,另一种是顺序结构.

顺序存储:

同顺序表相同,本体为数组,但是只适合用来存储完全二叉树,因为完全二叉树的数据存储是连续的,我们需要使用下标来定位数据,完全二叉树就不会产生空间上的浪费。

 链式存储:

同链表,我们使用节点的方式来表示一颗二叉树。相较于顺序存储,链式存储的方式则自由的多,大多数的二叉树都可以用链表的方式进行存储。

 顺序存储的二叉树结构:堆

在这里,我们所指的堆并不是操作系统的虚拟地址空间的堆,而是一种实现的数据结构。

堆的结构如下,其实就是上述的顺序存储的二叉树。

 但需要注意的是,我们实现的堆,它的本质依旧是个数组,我们仅仅只是将其的逻辑结构想象为一个二叉树,它真正的结构并不是个二叉树,简而言之,我们只是借助了下标之间的数学关系完成了类似二叉树的结构。

堆则分为大堆和小堆,同其名称相同,像是一个个数据堆在了一起。

 堆的实现

 对于一个堆来讲,它的本身的原型存储方法是数组,它的逻辑结构也就是我们认为的排序结构是类似一个土堆的,而它自身的存储结构其实是数组,借由下标的数学运算可以达成这个效果。

我们借用前文二叉树的节点计算方法

左孩子的节点: 2i+1

右孩子的节点: 2i+2

父亲节点: (i-1)/2

typedef struct Heap
{
	HPDataType* a; // 数组
	int size;	   // 数组当前数据量
	int capacity;  // 数组容量
}Heap;


// 堆的构建
void HeapInit(Heap* hp);
void HeapPrint(Heap* hp);

//调整为堆
void Adjustup(Heap* hp, int child);
void Adjustdown(Heap* hp, int child);

//交换函数
void Swap(HPDataType* a, HPDataType* b);

// 堆的销毁
void HeapDestory(Heap* hp);

// 堆的插入
void HeapPush(Heap* hp, HPDataType x);

// 堆的删除
void HeapPop(Heap* hp);

// 取堆顶的数据
HPDataType HeapTop(Heap* hp);
// 堆的数据个数
int HeapSize(Heap* hp);

// 堆的判空
bool HeapEmpty(Heap* hp);

 堆的初始化:

 同顺序表相同:

void HeapInit(Heap* hp)
{
	assert(hp);

	hp->a = NULL;
	hp->size = hp->capacity = 0;

}

 堆的插入:

由于堆的创建稍微有些难以理解,我们就先从插入下手,假设已经有了一个堆,那么我们每一次插入都会插到数组的最后一个位置,那么为了实现堆的逻辑结构,我们就要对这个插入的数字进行调整,让它符合整个堆的结构。

Adjust函数,假设传入的就是这个数字的下标,那么根据之前的数学逻辑推理,它的父亲就是它自身减一除以二,当它的父亲小于它自身的时候我i们就要交换他们两个的值,但是仅仅只是交换了他们的值,他们的下标还没有交换,还要再交换他们之间的下标,也就是把当前父亲的下标给到孩子这个变量,这个时候孩子变量指向的就是之前的父亲,求取现在这个孩子变量的父亲就相当实现了向上调整。

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

 每一次插入,基本上的逻辑和顺序表差不多,已经创建好了调整函数的话,只需要在每一次插入的时候调整一次就好了。

void HeapPush(Heap* hp, HPDataType x)
{
	assert(hp);

	if (hp->size == hp->capacity)
	{
		int newcapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(hp->a,newcapacity * sizeof(HPDataType));
		if (tmp == NULL)
		{
			perror("realloc failed!");
			exit(-1);
		}
		hp->a = tmp;
		hp->capacity = newcapacity;

	}

	hp->a[hp->size] = x;
	hp->size++;
	Adjustup(hp->a, hp->size - 1);

}

 那么我们随便创建一个数组,插入试试

 这段数组直接打印出来啥也看不出来,怎么验证它是一个大堆呢?只需要照着下标一个个列出来就好了。

堆顶的删除:

堆顶的删除会有一些不同,因为堆的实现在是数组,所以当我们要删除堆顶的数据的时候会直接破坏掉整个堆的逻辑结构,也就是通常的逻辑我们可能会想要直接覆盖,但是牵一发而动全身,整个堆都会被我们毁掉。

那么,怎么办呢?

既然牵一发而动全身,那么我们先不去打乱原本有的堆结构,将堆顶的数据也就是首元素和末尾元素进行一次对调,对调完之后直接删除队尾元素,也就是将size-1

现在这个堆依旧不成立,但是总体上其他的部分却依然是个堆,这个时候我们只需要将新换上来的元素向下调整就好了。

向下调整的逻辑:

由于此时的堆底下的数据被置换上来了,以大堆为例,堆顶此时的数据有可能会小于它的孩子,那么和向上调整的逻辑差不多,我们将此时的堆顶与它的左右孩子相比较,如果小于那么就置换、

但是这里有个问题就是左孩子与右孩子之间的大小问题,按照我们正常的逻辑来说我们可以直接比对左右孩子然后找小的那个交换,但是这样会显得有些麻烦,所以在这里我们利用一下数组的物理结构特性来帮助我们找到小的那个孩子。

我们首先默认左孩子的值是最小的,然后将其与它+1后的数值比对,由于数组的特性以及堆的结构特性,+1之后所得到的值必定是左孩子的兄弟(除去数组越界情况)那么我们只需要将这两个值比对就可以了。

void Adjustdown(HPDataType* a, int parent, int size)
{
	int  leftchild = 2 * parent + 1;


	while ( leftchild < size)
	{

		if (leftchild + 1 < size && a[leftchild] >a[ leftchild + 1])
		{
			leftchild += 1;
		}


		if (a[parent] > a[leftchild])
		{
			Swap(&a[parent], &a[leftchild]);
			parent = leftchild;
			leftchild = 2 * parent + 1;

		}
		else
		{
			break;
		}

	}

}

那么。堆顶的删除就是顺序表的头删了,只不过在头删之前我们就应该先把堆顶和最后一个数据交换。

// 堆的删除
void HeapPop(Heap* hp)
{
	assert(hp);

	Swap(&hp->a[0], &hp->a[hp->size-1]);
	hp->size--;

	Adjustdown(hp->a, 0, hp->size);


}

 堆的创建:

向上调整建堆:

我们前面所创建堆的逻辑是基于插入所创建的,同我们打斗地主的时候差不多,发牌完毕理牌的时候摸一张排一下顺序。同理,每次插入数据就向上调整一次。

那么,其实我们直接拿着数组建堆也是可以的,也就是用向上调整建堆。

void HeapBuildup(int* a, int n)
{
	for (int i = 1; i < n; ++i)
	{
		Adjustup(a, i);
	}
	for (int i = 0; i < n; ++i)
	{
		printf("%d ", a[i]);
	}

}

向下调整建堆:

 既然向上调整建堆是可行的,那么向下调整建堆是否也是可以的呢?

前文所述,向下调整的机制建立在开始调整的节点位置的左右两个子树都是堆才能成立,而我们将一个本身无序的数组直接像向上调整建堆一样拿过去直接调是不成立的。那么换个思考方向,既然向下调整需要一个堆,那么我们能否直接线调出小堆或者再进一步,直接去寻找堆?

对于单个的节点而言,一个节点其本身就是堆。

那么中奖咯!我们发现,叶子节点本身就是个堆,那么我们可以直接从叶子节点反着往下调,不过从叶子节点直接向下调整没有意义,它没有子节点,为了提高效率,我们从倒数第一个叶子节点的父节点开始进行调整。每一次迭代,下标都减一。

 为了产生区别,建了个小堆。

 向上建堆和向下建堆在效率上区别:

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

层数越多,成为叶子节点的数据也会越多,向上调整会遍历所有的数据,而向下调整则会省去最后一层的所有叶子节点,在这一方面,向下调整就优于向上调整了。

 堆排序:

那么现在我们已经对堆的创建已经有所了解了,接下来就尝试以下利用堆来排序。

首先,根据我们对排序的基本需求,我们可以得到两种:1.升序  2.降序

那么问题来了,升序的时候建什么堆?降序呢?

我们根据大小堆的基本数据分布可能会第一时间觉得升序用小堆,降序用大堆,但在这里是反过来的。

对于堆排序:升序建大堆,降序建小堆。

在这里的实际逻辑结构置换原理同堆顶的删除相同。

我们在讨论向下调整的时候其实发现了,向下调整不仅可以调整,在调整的时候它可以借助未破坏的左右小堆结构来帮助保持堆的结构,那么我们借助它的这个特性来应用到升序中。

如果我们建立一个大堆,交换其最后一个元素与首元素,由于大堆的堆顶一定是这个数组的最大值,这个时候其实最大的值就到了最后一个元素的位置。这个时候虽然整体的堆结构被破环,但是左右子树的堆结构在忽略掉交换的数的情况下依旧完好,那么我们将当前处于堆顶位置的元素向下调整,并且每一次交换堆顶与末尾元素之后忽略已经到达末尾的元素,这样子轮番调整,直到最后就成功的变成了升序的数组。

那么小堆降序也是同理。

void HeapSort(int* a, int n)
{
	HeapBuilddown(a,  n);

	for (int i = n - 1; i >= 0; --i)
	{
		Swap(&a[0], &a[i]);
		Adjustdown(a, 0, i);

	}
}

TOPK问题:

假如我们希望找到这座城市里最棒的10家奶茶店,我们只需要对排序好了的堆POP10次可以了。

 但是作为程序员一定要讨论极端情况,当我们寻找的目标非常非常大的时候,单纯的POP和排序的效率将会变得很差。比如我们想排序全国的奶茶店前100名的时候,这个数据量就非常夸张了,排序运算是在内存中的,数据量过大就爆了。

那么,为了解决这样的问题,我们依然可以借用堆的性质来帮助排序。

和上述一样,求前最大K个的数值接就建小堆,求前K最小K个的数值就建大堆。

以求出前100名为例,我们建立一个小堆

但是这里的小堆会稍有不同,我们先直接取出整个数组中前K个数值,将这K个值变成小堆。剩余的N-K个数据,每一个数据都与我们当前的小堆堆顶进行比较,比堆顶大就直接存放入堆,然后向下调整。

1. 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆

2..用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素

将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素

这样,我们就好像创建了一个滤网,每一次进来一个更大的数,整个滤网口子就会产生变化,也就是堆顶的数据大小,这样子当全部的数据遍历完毕的时候,整个堆里就是前K个最大的数据,但是要注意的是,这里的堆存放的数据还是尚未排序的。

void TopK(int* a,int k,int size)
{
	HeapBuilddown(a, k);

	for (int i = k; i < size; ++i)
	{
		if (a[i] > a[0])
		{
			a[0] = a[i];
			Adjustdown(a, 0, k);
		}

	}
	//HeapSort(a, k);

}

 我们测试一组数据

 当然可以在TOPK内内再调用一次堆排序,使其升序或者降序


 至此,二叉树中的堆结构就记述到这了,感谢阅读!希望对你有点帮助!

猜你喜欢

转载自blog.csdn.net/m0_53607711/article/details/127761096