数据结构:树、堆

一、树的概念和结构

1.树的概念

       树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。

  • 有一个特殊的节点,称为根节点,根结点没有前驱结点。
  • 除根节点外,其余节点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i<= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继
  • 因此,树是递归定义的。

       注意:树形结构中,子树之间不能有交集,否则就不是树形结构。

  • 结点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A的度为6。
  • 叶节点或终端节点:度为0的节点称为叶节点; 如上图。
  • 非终端节点或分支节点:度不为0的节点; 如上图:D、E、F、G...等节点为分支节点。
  • 双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点。
  • 孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点。
  • 兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点。
  • 树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6。
  • 节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推。
  • 树的高度或深度:树中节点的最大层次; 如上图:树的高度为4。
  • 堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点。
  • 节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先。
  • 子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙。
  • 森林:由m(m>0)棵互不相交的树的集合称为森林。

2.数的表示结构:左孩子右兄弟法

       它的好处是:不管你有多少个孩子,都可以使用两个指针表示。

 

       每一个节点都有两个指针,其中一个指针指向它最左边的孩子另一个指针指向它的兄弟。

二、二叉树的概念和结构

1.概念

       一棵二叉树是结点的一个有限集合,该集合:

  • 或者为空
  • 由一个根节点加上两棵别称为左子树和右子树的二叉树组成

       从上图可以看出:

  1. 二叉树不存在度大于2的结点
  2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
     

       注意:对于任意的二叉树都是由以下几种情况复合而成的:


 

2.特殊的二叉树

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

       完全二叉树的最后一层不一定满,但是从左到右必须是连续的。满二叉树是完全二叉树,但是完全二叉树不一定是满二叉树。

3.二叉树的性质

  • 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2的i-1次方个结点。
  • 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2的h次方-1。
  • 对任何一棵二叉树, 如果度为0其叶结点个数为n0 , 度为2的分支结点个数为n2 ,则有n0 =n2 +1。
  • 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= .log2(n+1)。(ps: log2(n+1)是log以2为底,n+1为对数)。
  • 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
    • 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点
    • 若2i+1<n,左孩子序号:2i+1,2i+1>=n否则无左孩子
    • 若2i+2<n,右孩子序号:2i+2,2i+2>=n否则无右孩子

4.二叉树的存储

       第一种存储方式使用数组存储。它一层一层依次往数组里面存。它的物理结构是数组,逻辑结构是二叉树。

       父子存储的下标位置规律:leftchild = parent*2+1,rightchild = parent*2+2。parent = (child-1)/ 2 .

       当这个树是满二叉树或者完全二叉树时,适合用数组存储,但当它不是满二叉树也不是完全二叉树时,就不适合用数组存储。

       它不适合使用数组存储,它适合使用链式存储。

三、堆的概念和结构

1.概念

       堆是一个完全二叉树它分为小堆和大堆。小堆中,任何一个父亲<=孩子,大堆则相反。

小堆
大堆

       它没有规定兄弟之间的大小关系,也没有规定叔侄之间的大小关系。所以不能认为他是有序的。

2.堆的实现

2.1 堆的底层结构

       我们采用数组作为堆的底层结构。它的底层和顺序表的一样。

typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
}HP;

2.2 堆的初始化和销毁

       初始化:

//初始化
void HPInit(HP* php)
{
	assert(php);
	php->a = NULL;
	php->capacity = 0;
	php->size = 0;
}

       销毁:

//销毁
void HPDestory(HP* php)
{
	assert(php);
	free(php->a);
	php->capacity = 0;
	php->size = 0;
}

2.3 交换数据

       在之后我们会经常交换数组中两个数据的位置,我们先实现一个交换函数。

//交换函数
void Swap(HPDataType* px, HPDataType* py)
{
	HPDataType tmp = *px;
	*px = *py;
	*py = tmp;
}

       它接收两个数据的地址,并在函数内部完成交换元素。

2.3 堆插入数据

       插入数据时,假如此时堆的类型为小堆,那么插入数据时,可能会影响的是祖先。如果孩子的值比父亲小,那么就将它俩交换。然后再将它与它的父亲相比,以此类推。所以我们需要实现一个向上调整算法。

       那么如何通过孩子找到它的父亲呢?上文我们提到过,父子存储得下标位置关系为

       leftchild = parent*2+1

       rightchild = parent*2+2

       parent = (child-1)/ 2 .

       我们以这个小堆为例:

       假如我们要插入一个值为60,那么插入的地方就应该是70的右边。然后发现它比它的父亲56大,那么它俩就不交换。

       假如要插入一个50,50比56小,那就交换。

        假如插入一个5,它比50,10都要小,那么就让5成为根。

       我们需要实现一个向上调整算法来实现上述操作。

//向上调整
void AdjustUp(HPDataType* a, int child)
{
	int parent = (child - 1) / 2;//先找出父亲的下标
	//while(parent>=0)
	while (child>0)//交换的终止条件是child>0,但是也可以使用parent>=0。因为算到最后parent始终为0,child也为0,程序走到break结束。
	//但是这种方法不太好,是一种巧合。于是我们就使用判断child是否大于零来结束程序。
	{
		if (a[child] < a[parent])//如果孩子小于父亲,就交换,同时将父亲和孩子的下标改变,使它们的身份互换。
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (parent - 1) / 2;
		}
		else
		{
			break;
		}
	}
}

       当程序进入while循环时,会一直判断这个数与它的父亲的大小关系,并完成交换,知道最小的数交换到根为止。

       接下来就是数据的插入。

//插入数据
void HPPush(HP* php, HPDataType x)
{
	assert(php);
	if (php->size == php->capacity)//如果空间不够就扩容
	{
		size_t newcapacity = php->capacity==0 ? 4 : 2 * php->capacity;
		HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newcapacity);
		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);//此时size指向数组的下一个元素,传参时需要将它减1.
}

       当我们想要从小堆换成大堆时,只需改变向上调整函数里面的while循环里面的if语句中的判断条件中的负号即可。当负号为‘>’时,此时为大堆,当负号为‘<’时,此时为小堆。

2.4 取堆顶

//取堆顶
HPDataType HeapTop(HP* php)
{
	assert(php);
	return php->a[0];
}

2.5 删除堆顶元素

       现在有两种删除堆元素的方法,第一种是挪动删除法,但是挪动之后堆的结构被破坏了,需要重新调整,而重新调整又要浪费好长的时间,而且它的时间复杂度为O(n)。那就需要一种全新的算法来实现了。下面就介绍一种非常厉害的一种算法。

       这个算法的过程是先将堆顶与堆尾的值互换,然后再删去堆尾的值,然后再调整使这个结构保持为堆。当两个值互换完之后,这个堆的结构并没有被破坏,只有它们的值中有一些问题,它可能不满足大堆或者小堆。接下来要做的就是向下调整,使这个结构变成大堆或者小堆。它会将自己与自己的孩子进行比较,并且是与较小的孩子比较。然后交换两个值,如此反复直到它没有子孙或者满足堆的要求为止。

       交换的函数我们已经实现过了,我们下面要实现的是向下调整算法。

//向下调整
void AdjustDown(HPDataType* a, int n, int parent)
{
	int child = parent * 2 + 1;//先假定左孩子比较小
	while (child < n)//每次交换结束,这个值的下标都会增长,当增长到size时,也就是来到了数组尾,那么就代表交换结束。
	{
		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;
		}
	}
}

       我们在删除堆顶元素函数中调用它们。

//出堆顶
void HeapPop(HP* php)
{
	assert(php);
	assert(php->size);
	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	AdjustDown(php->a, php->size, 0);
}//先交换,在调整。

2.6给定一串数组建堆

       假如给定我们一个数组,让我们给它建成一个堆, 那该要怎么建呢?它与之前的方式不同,之前的是插入一个调整一次,这个是直接给定了一串数组。

1.向上调整建堆

       代码如下:

//建堆
void HPInitArray(HP* php, HPDataType* a, int n)
{
	assert(php);
	//申请一块大小为n的空间
	php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if (php->a == NULL)
	{
		perror("malloc fail");
		return;
	}
	//将数组中的值拷贝到a所指的空间里面
	memcpy(php->a, a, sizeof(HPDataType) * n);
	php->size = n;
	php->capacity = n;
	//然后调用size次向上调整,将数组调整为堆
	for (int i = 0; i < php->size; i++)
	{
		AdjustUp(php->a, i);
	}
}

       时间复杂度分析:

       由代码可知,主要占用时间的是代码里面的for循环,我们分析for循环的时间复杂度,就是这个建堆算法的时间复杂度。

       当拷贝完成后,数组里面存储的元素集合还不能构成堆,我们假设树的高度为 h ,它先从第一层开始调整,然后再从第二层,依次往下调整,最坏的情况下,每层调整都要交换 h-1 次。并且每一个节点都要进行调整。所以每一层的调整次数为节点数*(层数-1)次。第一层不需要调整,而第二层需要调整 2的1次方 * 1 次,第三层需要调整(2的2次方 * 2)次,以此类推。当调整到h层时,总的调整次数为每一层调整次数的累加值,我们求出这个累加值即可。利用高中时学的数列的错位相减法,我们就可以求得调整次数前 h 项和。我们就得到了一个调整次数F(h)关于层数h的函数关系式F(h) = 2^h*(h-2)+2。但是时间复杂度一般与n相关,我们需要把h换成n。n就是节点数,他们之间的关系为h = logN + 1.(第h层有2^(h-1)个节点)我们就得到了F(N)关于N的函数关系式F(N) = ( N + 1 ) * ( logN - 1 ) + 2.即建堆向上调整的时间复杂度为N * logN.

2.向下调整建堆

       我们从最后一个非叶子节点开始调。因为调整是针对节点的,假如我们从第一个节点开始调,调整到靠下面的层数时,就会多花费很长的时间。代码如下:

//建堆
void HPInitArray(HP* php, HPDataType* a, int n)
{
	assert(php);
	//申请一块大小为n的空间
	php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if (php->a == NULL)
	{
		perror("malloc fail");
		return;
	}
	//将数组中的值拷贝到a所指的空间里面
	memcpy(php->a, a, sizeof(HPDataType) * n);
	php->size = n;
	php->capacity = n;
	//然后调用size次向上调整,将数组调整为堆
	/*for (int i = 0; i < php->size; i++)
	{
		AdjustUp(php->a, i);
	}*/
	for (int i = (php->size - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(php->a, php->size, i);
	}
}

       它的时间复杂度分析法跟上一个类似,它的时间复杂度为N-log(N)。

       那么为什么两种方法会有差异呢?先思考一个问题,满二叉树最后一个节点占所有的多少呢?几乎是一半。向上调整,节点多,那么调整次数多。向下调整,最后一层节点不需要调整,是一个节点数量多,调整数量少。所以向下调整的效率快一些。

       因此,一个一个的push是不如向下调整建堆的。

3.堆排序

       给我们一个数组,我们可以对堆进行排序。我们可以将堆中的元素进行连续的pop和打印堆顶元素,就可以得到一串有序的数字,这一串数字就是排序过的堆中的元素。因为每一次pop,都是取出堆中最小的值,然后经过调整,堆中次要小的值被调为堆顶元素,然后再pop,一直这样进行,堆中的元素就可以有序地输出。但这还不足以称为堆排序。下面就要详细地介绍堆排序。排序的目的不只是为了将数字打印到屏幕上,而是建立一个有序的数组。这才可以称为排序。

//堆排序
int* HeapSort(HP* php, int* a,int n)
{
	HPInitArray(php, a, n);
	int i = 0;
	while (!HPEmpty(php))
	{
		a[i++] = HeapTop(php); 
		HeapPop(php);
	}
	return a;
}

       这就是堆排序的代码,但是他有两个缺陷:1.每次使用堆排序时,我们都要先写一个堆出来,因为没有现成的取堆顶等函数让我们调用。2.它的空间复杂度为O(n)。那我们就直接对传过来的数组建堆。然后调用向下调整建堆。那我们应该建大堆还是建小堆呢?答案是建大堆。因为如果要建小堆,那么面临的问题是如何选出次小的数,要是使用剩下的数再建堆,那么又面临着一个问题,我们不能通过向下调整建堆,因为它的关系全都乱了,就会出现兄弟变父子,父子变兄弟。只能重新建堆。但是如果一直这样建堆,那么堆排序的优势就没有了,它的时间复杂度就会很大。因此我们就要建大堆。我们要使数组升序,具体是怎么做呢?我们可以结合堆的删除操作,我们将堆中最大的数(也就是堆顶的数)放在数组尾,将数组尾的数放在堆顶,然后将数组尾的数忽略掉(但不是删除,只是原理相似)然后向下调整一下,就可以将次大的数放在堆顶。然后再将堆顶的数放在堆尾,如此循环往复,这样数组中的元素就会变得有序。

//堆排序
void HeapSort02(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;
	}
}

       这个向下调整函数它需要的参数不需要接受一个结构体指针,说明它不是为了堆而实现的。我们在实现堆排序的时候,只需再实现一个向下调整,一个交换函数即可,不需要再手搓出一个堆的数据结构。当我们想要变成降序时,只需调整向下调整函数, 让他变成建小堆即可。

4.TOP-K问题

       TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:

       1. 用数据集合中前K个元素来建堆

              前k个最大的元素,则建小堆

              前k个最小的元素,则建大堆

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

       将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。它的特点是块、节省空间。时间复杂度为K + (N - K) * logK。

       假如说现在有100亿个数据,我们现在要找出前十个最大的值,那我们就建一个十个数的小堆,让后续数据跟堆顶数据比较,如果比堆顶的数据大,那么就让这个数据替代堆顶进堆。因为堆是小堆,当有比最小的数据大的数据,就让它替换堆顶,并对堆进行向下调整,使堆中最小的值重新放到堆顶,然后再让外部数据跟对顶比较,如此操作之后,当遍历完后续数据,堆中就保留着最大的十个值,其中堆顶是十个值中最小的那个。下面是代码实现:

//造数据
void CreateNData()
{
	int n = 10000;
	srand(time(0));
	FILE* fin = fopen("Test.txt", "w");//打开文件
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}
	//向文件里面写n个随机数
	for (int i = 0; i < n; i++)
	{
		int x = (rand() + i) % n;
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);
}

       这个代码的意思是造n个数据。在主函数中调用,就可以看到它造了10000个数据。

void topk()
{
	int k = 0;
	printf("请输入一个值\n");
	scanf("%d", &k);
	FILE* fout = fopen("Test.txt", "r");
	if (fout == NULL)
	{
		perror("fopen error");
		return ;
	}
	int val = 0;
	//建k个数的小堆
	int* minheap = (int*)malloc(sizeof(int) * k);
	if (minheap == NULL)
	{
		perror("malloc fail");
		return ;
	}
	//从文件里面读取数据放到小堆中
	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d", &minheap[i]);
	}
	//向下调整为小堆
	for (int i = (k - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDownSmall(minheap, k, i);
	}
	int x = 0;
	while (fscanf(fout, "%d", &x) != EOF)
	{
		//读取剩余数据,比堆顶的值大,就替换它进堆
		if (x > minheap[0])
		{
			minheap[0] = x;
			AdjustDownSmall(minheap, k, 0);
		}
	}
	//我们也可以调用堆排序来对筛选出来的十个最大值进行排序
	HeapSort(minheap, k);
	//打印堆中数据
	for (int i = 0; i < k; i++)
	{
		printf("%d ", minheap[i]);
	}
	fclose(fout);
	return;
}

       这就是TOP-K的算法。当我们想要改变生成的随机数的个数时,只需在CreateNData()函数中修改n的值即可。其中我将向下调整分为了两个函数,分为向下调整建小堆AdjustDownSmall()和向下调整建大堆AdjustDownBig()。

  • 27
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值