数据结构与算法 - 树-堆 相关算法的时间复杂度分析 #向上调整 #向下调整 #向上调整建堆 #向下调整建堆 #堆排序

文章目录

前言

一、堆的结构分析

二、堆的插入 - 向上调整  

三、向下调整算法

四、建堆算法时间复杂度的分析

(一)、向上调整算法建堆

(二)、向下调整算法建堆

(三)、向上、向下调整算法调整建堆的区别

五、堆排序

总结


前言

路漫漫其修远兮,吾将上下而求索;


一、堆的结构分析

的逻辑结构为完全二叉树并在此基础上增加了父节点与子节点之间大小关系的限制:

  • 大堆:完全二叉树,父节点必须大于等于子节点
  • 小堆:完全二叉树,父节点必须小于等于子节点;

当堆插入数据的时候(数据放在末尾)会进行向上调整;

由上图可知,无论是满二叉树还是完全二叉树,其高度的量级均可以认为是 logN (大O渐进表示法)


二、堆的插入 - 向上调整  

在堆的接口函数 - 插入的实现中,会将数据放在堆的末尾,然后再将此数据与其父节点进行比较,不符合既定堆的规定,便与其父节点进行交换,然后就继续向上进行比较,当比较到根节点或者父子节点间的大小符合当前堆的规定,那么便停止调整

此调整算法为向上调整算法,图解如下:

从上图中可以清楚地看到,插入的一个节点最多调整其高度次;假设一棵树的总节点数为N,而满二叉树完全二叉树的高度均可以认为是 logN(大O渐进表示法);

注:向上调整算法的前提:前面的数据已然为堆

代码如下:

大堆:

//向上调整
void AdjustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;

	while (child)
	{
		//大堆
		if (a[parent] < a[child])
		{
			Swap(&a[parent], &a[child]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

小堆:

//向上调整
void AdjustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;

	while (child)
	{
		//小堆
		if (a[parent] > a[child])
		{
			Swap(&a[parent], &a[child]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

三、向下调整算法

当执行删除逻辑的时候,即将堆根中的数据与尾数据进行交换,然后删除尾数据再对堆根上的数据进行向下调整;

注:向下调整算法的前提:该根节点的左右子树必须都为堆,才能进行调整;

具体过程如下图所示:

同理,向下调整算法最坏的情况:从堆根一直调整到叶节点,即高度次,可以认为是 logN

代码如下:

大堆:

//向下调整
void AdjustDown(HPDataType* a, int n, int parent)
{
	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 AdjustDown(HPDataType* a, int n, int parent)
{
	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;
		}
	}
}

四、建堆算法时间复杂度的分析

建堆分为向上调整建堆向下调整建堆

堆的逻辑类型:1、满二叉树   2、完全二叉树

因为时间复杂度是分析最坏的情况,因为满二叉树的每一层节点均为满的,故而此处分析满二叉树

(一)、向上调整算法建堆

向上调整建堆的逻辑:遍历每一个节点(除了根节点,因为根节点没有前继节点),让该节点与其祖先节点进行比较(比较的次数与该树的层数有关);

代码如下:

//向上调整建堆
for (int i = 1; i < sz; i++)//根节点无需进行调整,因为根节点无祖先
{
	AdjustUp(a, i);
}

//注:sz 为数组中数据的个数

图解分析如下:

综上,向上调整算法的时间复杂度为 O(NlogN) 

(二)、向下调整算法建堆

向下调整算法建堆的逻辑分析:

  • 所要调整的节点的子树必须为堆
  • 因为在建堆的过程,其数据是乱的,向下调整建堆不是删除逻辑中的调整堆根中的数据,而应该从数据后面开始调整,而叶节点没有孩子其本身又可以看作一个堆,所以向下调整建堆从最后一个非叶子节点开始;最后一个非叶子节点其实就是最后一个叶节点的父节点

注:在堆的删除逻辑中,会将堆根的数据与最后一个数据进行交换,然后删除最后一个数据,再对堆根进行向下调整;直接对堆根进行向下调整的原因在于,将堆根中的数据与尾数据进行交换并不会影响到堆的结构即根节点的左右子树仍然为堆,故而可以对堆根中的数据直接进行向下调整;而此处的向下调整建堆在物理层面,该数组中的数据是乱的(不符合堆中父子节点的大小关系),那么就不可以从下标为0的数据开始向下调整,而应该从后面开始,因为叶节点无子节点,其本身就为一个堆无需进行向下调整,故而向下调整应该是从二叉树的最后一个非叶节点开始(最后一个节点的父节点);

代码如下:

//向下调整建堆
for (int i = (sz - 1 - 1) / 2; i >= 0; i--)
{
	AdjustDowm(a, n, i);
}

//注:sz 为数组中数据的个数

图解分析如下:

综上,向下调整算法建堆的时间复杂度为 O(N)

(三)、向上、向下调整算法调整建堆的区别

  • 向上调整建堆是将本节点与其祖先节点进行比较,而节点所在的层数越大,那一层的节点数越多,该层一个节点所要向上调整的次数也就越多,即节点数量多的层*调整次数多节点数量少的层*调整次数少
  • 而向下调整是将本节点与其子孙节点进行比较,而层数越大,该层的节点数量越多,调整的次数少;层数越小,该层的节点数量越少,调整的次数越多,即节点数量多的层*调整次数少节点数量少的层*调整次数多

综上,从建堆算法来看,向下调整建堆算法更优

五、堆排序

堆排序的逻辑:

  • 堆中的结构规定了父子节点之间的大小关系,堆根中的数据是该堆中最大或者最小的数据;
  • 如果排降序建大堆,显然堆根中的数据(下标为0的空间中的数据是所有数据中最大的一个),倘若将堆根中的数据“定”好了,再利用堆的特点去处理后面的数据,首先是需要重新建堆,因为打乱了原先父子节点之间的关系,父子变成兄弟,即直接"去除"堆根中的数据会破坏堆的结构,则需要重新建堆,建堆的时间复杂度为 O(N^2*logN) 或者 O(N^2) [注:因为会有N次建堆,而每次建堆都需要向上或者向下调整建堆],效率不太高;
  • 又联想到堆接口函数中“删除”的逻辑,将堆根中的数据与尾数据进行交换,然后再进行向下调整,相当于确定好所要排序数据后方数据的位置,那么排降序,建小堆;排升序,建大堆。此方法的时间复杂度为O(NlogN), 相较于前面的方法,效率明显高了很多;

具体分析如下:

代码如下:

   int arr[] = { 23,45,1,32,67,34,14,17,88,79 };
   int sz = sizeof(arr) / sizeof(arr[0]);
   //堆排序
   //建堆算法 

   //向上调整建堆 - 不调整根节点,因为根节点没有祖先节点
   /*for (int i = 1; i < sz; i++)
   {
       AdjustUp(arr, i);
   }*/

   //向下调整建堆 - 不调整叶节点 - 因为叶节点没有孩子节点
   //从最后一个非叶子节点开始向下调整建堆
   for (int i = (sz - 1 - 1) / 2; i >= 0; i--)
   {
       AdjustDown(arr, sz, i);
   }

   //排降序 - 建小堆
   //排升序 - 建大堆
   int n = sz - 1;//最后一个数据的下标
   while (n)//sz个数据需要调整 sz -1 次
   {
       //将堆根的数据与尾数据进行交换,然后伪删
       Swap(&arr[0], &arr[n]);
       //进行向下调整
       AdjustDown(arr, n, 0);
       n--;
   }

   //打印
   for (int i = 0; i < sz; i++)
   {
       printf("%d ", arr[i]);
   }
   printf("\n");

从上文中我们得知,利用向上调整建堆的时间复杂度为:O(NlogN) , 利用向下调整建堆的时间复杂度为O(N);

堆排序中,首先是需要将堆根中的数据与尾数据进行交换,伪删尾数据,然后再对堆根中的数据进行向下调整;如果有N个数据,就需要调整(N-1) 次 , 而每次向下调整的时间复杂度为O(logN), 那么整体的时间复杂度为O(NlogN);

注:此处可以简单的逻辑分析其时间复杂度,因为是对堆根进行调整,当数据多的时候,向下调整的次数也多,即每层节点个数多*需要向下调整的次数多;大体分析是这样的;

当然,你也可以简单分析一下占比大的“群体”,例如最后一层的数据,如果想要将最后一层的数据全部都调整好:

  • 最后一层的数据个数(假设有h层):2^(h-1) 
  • 最后一层所要向下调整的次数: h-1

--> 那么最后一层数据所要调整的次数 = 2^(h-1)*(h-1);

假设节点总数为N,最后一层的数据个数大约为N/2, N/2 = 2^(h-1)  那么最后一层数据所要调整的次数 = \frac{N}{2}*{log}_2(N+1) 用大O渐进表示法就为:O(NlogN) 


总结

实现堆的接口函数中:

  • 向上调整算法的时间复杂度:O(logN)
  • 向下调整算法的时间复杂度:O(logN)

建堆算法:

  • 向上调整建堆的时间复杂度:O(NlogN)
  • 向下调整建堆的时间复杂度:O(N)

堆排序:

其中建堆有两种方式,无论使用那种建堆的方式,堆排序的时间复杂度均为:O(NlogN)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值