二叉树(一)———树的概念 + 堆与堆排序

目录

1.树的概念及结构

1.1树的概念

1.2树的一些常用概念

[1.3 树的表示](#1.3 树的表示)

2.二叉树的概念及结构

2.1概念

2.2二叉树的性质

3.二叉树的线性结构------堆

[3.1 堆](#3.1 堆)

3.2堆排序

建堆

堆排序

其实对的排序的大思路还是选择排序,每一次选出最小(最大)的数排到后面。

topK问题


1.树的概念及结构

1.1树的概念

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

树有一个特殊的结点称为根节点,根节点没有前驱节点。

除根结点外,其余的结点被分成M(M>0)个互不相交的集合,其中每一个集合又是一棵结构与树类似的子树,每棵子树的根节点有且只有一个前驱节点,可以有0个或多个后继。前驱可以成为父节点,后继可以称为子节点

上面的每一个用笔圈出来的都可以称为子树,叶子节点也是子树,只是他的子树是空树,0个后继。

从图中我们就能栈的,树是递归定义的,他与我们之前实现的数据结构有很大的不同。

注意:树的结构中,子树之间不能有交集,否则就不是树形结构。这是什么意思呢? 每一个节点只与他的父节点(有且只有一个)与他的子节点(可以是0个也可以是多个)有交集,在上面的图中的表现出来的就是 比如以B节点为根节点的B子树,他只能与他的父节点A 与他的子节点E和F有联系,不能与其他子树的节点产生交集。

比如下图中的结构就不是树

这种结构被称为图,是一种比树更复杂的结构。

1.2树的一些常用概念

**节点的度:一个节点含有的子树的个数就是称为该节点的度;**比如上图中A的度为3,B的度为2

**叶节点或终端节点:度为0的节点称为叶节点;**如上图E,F,G,H,I,J都是叶节点

非终端节点或分支节点:度不为0的节点;树结构中除叶节点外的其他节点都是分支节点

**双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点;**如上图,A是B、C、D的父节点

**孩子节点或子节点:一个节点含有的子树的节点称为该节点的字节子节点;**如图B、C、D是A的子节点,E、F是B的子节点,G是C的子节点。

**兄弟节点:具有相同父节点的节点互称为兄弟节点;**比如B、C、D就是写的节点,他们的父节点都是A。E、F也是兄弟节点,他们的父节点都是B。

**树的度:一棵树中,最大的节点的度称为树的度;**比如上图中树的度就是3 ,因为最大的节点的度就是A的度(3)或者D的度(3)。

结点的层次:从根开始定义起,根为第一层,根的子节点为第二层,以此类推。有的人也会把根看成第0层,根的子节点为第1层,以此类推,这跟数组的下标有点类似。

具体哪种表示方法更好,我觉得是把根节点看成第1层更好,因为有一种树叫做空树,空树就是根节点也为空,没有任何节点。如果按右边黑色的表示方法,则空树为第一层,那么空树的高度就是1,这是不合理的。如果根节点定义为第0层,那么空树的高度就是0,这样更加符合常理。

**树的高度或深度:树中结点的最大层次;**如上图,数的高度为3.

**节点的祖先:从根到该节点所经分支上的所有节点;**比如上图中A、B都是E和F的祖先,A是所有节点的祖先。

**子孙:以某节点为根的子树中任意节点都称为该节点的子孙;**如上图:E和F是B的子孙,所有节点(除A外)都是A的子孙。

**森林:由m(m>0)棵互不相交的树的集合称为森林;**并查集就是森林。

1.3 树的表示

数的表示就是树在内存中如何存储的问题。树的表示是很麻烦的,因为我们不知道每个节点有几个子节点,这就导致我们不知道结构体要定义几个指针

复制代码
struct TreeNode
{
    int data;
    struct TreeNode* child1;
    struct TreeNode* child2;
    struct TreeNode* child3;
    struct TreeNode* child4;
        ......不确定子结点的个数

};

除非明确说明了数的度为N,这时候我们就可以用指针数组来存储子结点的地址,同时还要用一个size来表示子结点的个数

复制代码
#define N 10/假设树的度为10

struct TreeNdoe
{
    int data;
    struct TreeNode* childenodearray[N];
    size_t size;
};

这样的话,树的每一个的节点都是一个顺序表的结构了,而且是静态的顺序表,数的度是固定的。这时候我们就又能想到能改进为用动态的数组来存储子节点的指针,也就是动态的顺序表,就又要引进一个容量。

复制代码
struct TreeNode
{
    int data;
    struct TreeNode** ChildeNodeArray;
    int size;
    int capacity;

};

这样实现起来就十分的复杂了。于是有人就发明了一种很巧妙的定义方法。

复制代码
struct TreeNode
{
    int data;
    struct TreeNode* LeftChild;
    struct TreeNdoe* NextBrother;
};

这种结构每一个节点都只要保存他的值,他的第一个(最左边)子节点的指针和他的右边相邻的兄弟节点的指针,节点的其他子节点都能通过第一个子节点的nextbrother指针来找到。 这样实现就能够通过递归的方式遍历所有的节点。

如何遍历这种结构呢?对于每一个节点都要先遍历他的左孩子结点,当左孩子结点返回之后再遍历他的右兄弟节点,兄弟节点返回之后就返回到上一层递归。递归的结束条件是什么呢?就是当子树为空树时,也就是当前节点为空节点时返回。这里的遍历递归的实现也很简单

复制代码
void CheckTree(TreeNode* root)
{
    if(root==NULL)
    return;
    CheckTree(root->LeftChild);
    CheckTree(root->NextBrother);
}

这样一个简单的代码就能遍历上面的结构定义的数。

经典的树结构就是我们的文件系统,文件系统有根目录和子目录,都是用树的结构来实现的。

2.二叉树的概念及结构

2.1概念

二叉树就是度为二的树,每个节点最多有两个子节点,最少可以没有子节点。

两种特殊的二叉树

满二叉树:如果一个二叉树每一层都是满的,那么这个二叉树就是满二叉树,也就是说,如果一棵二叉树的层数为k,且他的节点总数是2^k-1,这棵树就是满二叉树。

完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树引出来的。假设一颗二叉树有k层,他的前k-1层都是满的,他的最后一层不一定是满的,但是要求最后一层的节点是从左到右连续的。满二叉树就是一种特殊的完全二叉树。完全二叉树就是满二叉树的最后一层缺少最右边的n(0<=n<2^(k-1))个节点,最后一层至少有一个节点。

2.2二叉树的性质

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

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

深度为 h 的满二叉树的节点个数就是一个等比数列求和 2^0+2^1+2^2+... ...+ 2^(h-1)=2^h-1.

3.对任一二叉树,如果度为0的叶子节点的个数为 N0, 度为2的分支节点的个数为N2,则有N0=N2+1。

这个性质要怎么理解呢?我们知道二叉树的节点的度只有0、1和2,空数的节点为0,如果只有一个根节点,那么这个节点就是度为0的节点。这时候如果我们在根节点后面链接一个子节点,这时候根节点的度就变为了1 ,而其子节点的度为0,度为0的节点数没变。如果在此基础上继续链接增加度为1的节点,度为0的节点永远都是 1 ,因此我们可以得出结论,增加度为1 的节点不会影响度为0的节点个数。

度为2的节点是由度为1的节点再增加一个子节点而来的,在将度为1的节点变成度为2的节点的同时,加的这个子节点又是一个度为0的节点,所以每将一个度为1的节点变成度为2的节点,都会增加一个度为0的节点。

由于度为0的节点初始就有一个,而后每增加一个度为2的节点,度为0的节点也会相应的增加一个,所以我们就能得出结论,在一棵非空二叉树中,度为0的节点数比度为2的节点数多1.

4.若规定根节点的层数为1,具有n个结点的满二叉树的深度

我们前面求了一棵高度为h的满二叉树的节点个数为 2^n-1 ,反过来知道满二叉树的节点个数就可以求满二叉树的深度,也可以以此求完全二叉树的深度

3.二叉树的线性结构------堆

3.1 堆

堆的逻辑结构是一棵完全二叉树,堆分为大堆和小堆(大根堆和小根堆),小根堆就是任何一个节点的值小于等于子节点的值,大根堆就是任何一个节点的值大于等于其子节点的值。

堆可以用来排序,堆排序的时间复杂度为O(NlogN),同时,堆也可以用来解决 topK 问题(求一堆数据的前k大或者前k小),但是要注意一个点,对只能保证父节点和子节点的大小关系,不能保证兄弟节点之前的关系。

对于一个这样的堆我们应该如何实现呢?首先看到这种数据之前存在链接关系的数据的存储第一部想到的就是链表,但是真的用得上链表吗?我们讲到堆是完全二叉树的结构,这意味着堆的数据是可以连续存储的,那是不是意味着只要我们想办法解决了堆在数组中存储时的父子关系,我们就能用数组来存储了呢?我们从上至下从左至右将这些数据依次存进数组中,

我们知道父子节点的坐标,如何求得他们的不变的关系呢?

将父子关系标记出来之后我们就能发现左孩子的下标是 leftchild=2*parent+1,右孩子的下标则是leftchild+1 = 2*parent+2;比如 18的左孩子 23 和 右孩子 26 ,他们的下标分别是 0 、1 、2 。那么如何通过子节点的下标来推算父节点的坐标呢?如果是左孩子 parent =(child-1)/2,如果是右孩子,则parent=(child-2)/2,如何将他们用统一公式来处理呢?首先我们可以了解到一点,左孩子的下标永远都是奇数,右孩子的下标永远都是偶数(前面通过父节点推子节点得出),这时候我们就能用到之前很容易犯错的整数乘法,当是右孩子时,他的下标减一再除2 和减二再除2 整数乘法得出的结果是一样的,所以我们只要知道了子节点的坐标是 child ,不管是左孩子还是右孩子,都能用 (child-1)/2 来求他的父节点。

知道了子节点和父节点在数组中的下标的关系后,我们就能用一个数组来存储一个堆。在对数组进行操作时我们要想象成对堆进行操作。而对堆的操作都要通过转换成数组下标的方式进行。操作的时候理解成树,在代码实现的时候要理解成数组。

小堆和大堆都只能保证找到最小的数或者最大的数(堆顶元素),如果要找前几大或者前几小就要进行一些逻辑上的迭代。在进行插入和删除堆顶元素的操作时都要保持堆的结构。

对于堆的实现我们就用动态数组来存储了,我们先把常规的初始化和销毁以及扩容函数写出来

复制代码
#define CAPACITY 4


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

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

//销毁
void HPDestroy(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
}

//扩容
void HPCheckCapacity(HP* php)
{
	assert(php);
	if (php->size == php->capacity)
	{
		int* new = (int*)realloc(php->a, sizeof(int) * (php->capacity + 4));
		if (new == NULL)
		{
			perror("realloc fail");
			exit(-1);
		}
		php->a = new;
		php->capacity += 4;
	}
}

插入数据

我们先假设已经有一个堆存储在数组中了,这时候如果要再插入一个元素的话我们要怎么插入呢?首先肯定是插入到数组的末尾,

假设这就是我们已经存在的 小堆 ,如果我们这时候要插入一个15,这时候要如何调整堆的元素呢?

首先,小堆的性质就是父节点的值比子节点的值要小。插入之后如何保证父结点的值还是要比子节点的值小呢?这时候我们就要**拿新插入的数据和他的父节点的值相比,**当大小关系符合小堆时,我们就不用操作了,但是当新插入的数据比他的父节点的值小时,就比如上面新插入的15 比他的父节点26小 ,这时候我们就要将26与15进行交换位置了。

这时候就结束了吗?当然还没有,我们还要将15与他的父节点的值相比较,如果15还是小与他的父节点的的值,我们还要接着调换,直到满足小堆的结构。

这时候15已经到堆顶了,就不需要再调整了。从这里我们就能看出来,新插入的值我们要不断地与他的父节点进行比较,直到它满足堆的结构才停下。这个过程新插入的元素是在不断向上替换的,我们称这个过程为向上调整。具体函数的代码如何实现呢?

复制代码
//向上调整
void AdjustUp(HP* php, int size);

//插入
void HPPush(HP* php, int x)
{
	assert(php);
	HPCheckCapacity(php);
	php->a[php->size] = x;
	php->size++;
	AdjustUp(php->a, php->size);
}

首先插入函数的代码很好实现,重点是向上调整函数的实现。向上调整函数的参数我们只需要数组和数组的大小就行了。首先新插入的元素的下标是child=size-1,他的父节点的下标就是parent=(child-1)/2。这时候我们就要对这两个值进行比较,如果不满足堆结构,就要对这两个元素进行交换,这时候要对child的值进行迭代更新 , 然后继续与父结点的值去比较,如果已经满足条件了,就不用再向上调整了。所以向上调整的过程是一个循环。那么循环结束的条件是什么呢?

首先第一次进来 child 的值为6 ,parent 的值为 2 ,判断完之后交换,然后对 child 进行迭代,child的值更新为2 ,这时 parent 的值就是 0 了,当parent为0还要继续比较吗?当然还要比较,这一次比较完交换之后,child 就更新为 0 了,当child变为 0 的时候,他已经是堆的堆顶元素了,不用再向上比较了,所以循环结束的条件是child为0就跳出循环。

复制代码
void Swap(int* px, int* py)
{
	int tmp = *px;
	*px = *py;
	*py = tmp;
}

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

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

我们将交换的逻辑单独用一个函数来实现,因为后面还有一个向下调整也需要交换。

这样我们就把插入的逻辑实现了,插入逻辑实现了的化,那么如果我们不断插入数据,其实也是一种建堆的过程了。当我们上面图中的数据按顺序放进数组后,再插入一个15 ,我们来看他的效果

这个顺序与我们上面画的过程是一样的,所以插入数据时向上调整的函数是没问题的。这时候我们向上调整的是小堆。

如果要对大堆进行插入,向上调整的函数就是把前面的函数交换时的判断条件的小于换成大于就行了。

复制代码
//向上调整 小堆
void AdjustUp(int* a, int size)
{
	assert(a);
	int child = size - 1;
	int parent;

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

//向上调整 大堆
void AdjustUp(int* a, int size)
{
	assert(a);
	int child = size - 1;
	int parent;

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

删除堆顶

删除堆顶元素要怎么样才能继续保持堆结构呢?首先肯定不能像之前顺序表的头删一样数据覆盖,

如果我们直接从后往前覆盖的化,那么原来的堆的结构就被彻底的打乱了,建堆就白建了。那么如何在删除栈顶元素的情况下尽量小的破坏堆的结构呢?我们知道,删除一个数据之后堆的节点就会少一个,哪些节点对堆的结构的影响最小呢?这时候我们就能想一下,如果不是往前覆盖,那么我们就要拿一个数据来放在堆顶,然后再对堆进行调整保证他还是小堆或者大堆结构。这个用来调换的最佳选择就是最后的节点。因为最后的节点的消失不会影响他的父节点的关系,为什么是拿最后元素与栈顶替换而不是直接覆盖栈顶元素呢?首先,这也达到了删除一个元素的目的,其次,这样做就把最大或者最小的元素放到了最后面,虽然不是我们的有效数据了,但是我们可以利用这个过程不断把最大或者最小的元素放到最后面,这就相当于一种排序了。

在完成替换之后,跟之前的插入一样,我们还是要进行调整来保证堆的结构,这时候堆顶就是父节点了,他要不断跟子节点的较小值比较,如果子节点的较小值比父节点小,就完成替换并迭代更新,当子节点的较小值比父节点大是,就停下来。前面我们也说了,已知父节点的下标我们是能够求出子节点的下标的。这个调整的过程我们称为向下调整。向下调整的逻辑与向上调整的逻辑大差不差,主要还是要注意跳出循环的条件。

复制代码
//删除栈顶
void HPPop(HP* php)
{
	assert(php);
	assert(php->a);
		
	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	AdjustDown(php->a, 0, php->size);
}

//向下调整  小堆
void AdjustDown(int* a, int begin, int size)
{
	assert(a);
	int parent = begin;
	int minchild;
	while (parent <= size - 1)//下标最大是size-1
	{
		minchild = parent * 2 + 1;
		if (minchild > size - 1)
		{
			break;
		}
		if (minchild+1 <=size - 1)
		{
			if (a[minchild] > a[minchild + 1])//左节点比右节点大,就用右节点跟父节点相比
			{
				minchild++;
			}
		}
		if (a[minchild] < a[parent])
		{
			Swap(&a[minchild], &a[parent]);
			parent = minchild;
		}
		else
		{
			break;//不用调整 跳出循环
		}
	}
}

而大堆的向下调整也是只要修改小于号为大于号就行了。

复制代码
//向下调整  大堆
void AdjustDown(int* a, int begin, int size)
{
	assert(a);
	int parent = begin;
	int minchild;
	while (parent <= size - 1)//下标最大是size-1
	{
		minchild = parent * 2 + 1;
		if (minchild > size - 1)
		{
			break;
		}
		if (minchild + 1 <= size - 1)
		{
			if (a[minchild] < a[minchild + 1])//左节点比右节点大,就用右节点跟父节点相比
			{
				minchild++;
			}
		}
		if (a[minchild] > a[parent])
		{
			Swap(&a[minchild], &a[parent]);
			parent = minchild;
		}
		else
		{
			break;//不用调整 跳出循环
		}
	}
}

我们也可以轻松得出向上调整和向下调整算法的时间复杂度都是logN。

这就是堆的两个接口,既然堆的实现我们能做到了,那么堆排序是怎么做的呢?

3.2堆排序

当给你一个数组,让你对其进行堆排序的话,要怎么做呢?其实堆排序最关键的不是建好堆之后的排序过程,而是建堆的过程。

建堆

当我们拿到一个数组之后,最容易理解的建堆方法就是遍历一遍数组,如何调用上面我们实现的插入接口将这些元素不断插入建堆。 这种方法虽然能建堆,但是多开辟了一块空间,空间复杂度为O(N)。

既然我们有了数组,我们能不能不开辟额外空间,就对原数组操作建堆呢?前面的插入和删除能保持堆结构的原因就是向上调整和向下调整算法,我们是不是可以利用这两个算法进行建堆呢?

向上调整建堆

向上调整的前提就是原本的数据就是堆,然后我们要在堆的后面插入一个新的数据,然后对其向上调整.我们是不是也可以采取遍历的方式建堆呢? 首先,只有一个数据时就是堆, 我们可以将数组第一个数据看成一个已经建好的堆。

然后怎么操作就很简单了,我们把第二个数据再插入到堆的后面,

然后对其向上调整,调整完之后数组前两个数据就又是堆了,再插入第三个数据,

然后再对其向上调整... ...以此类推,当我们吧整个数组看成不断插入的一个堆时,最后他就调整成了一个堆。

我们可以来算一下一棵高度为 h 的满二叉树通过向下调整算法调整成堆的时间复杂度。

我们知道每一层的节点个数,同时我们也能推算出来,第一层的节点不需要向上调整,第二层的节点每一个节点最大的向上调整次数为 1 次,第三层的节点的的每一个节点的最大的调整次数为 2 次... ...到第 i 层,每一个节点向上调整的最大次数为 i-1 次。所以我们可以算出高度为 h 的满二叉树向上调整建堆最多操作次数为

用错位相减法我们可以求出

省去常数 我们就能知道向上调整建堆的时间复杂度为 O(N logN);

复制代码
CreatHeapAdjustUp(int* a, int size)
{
	assert(a);
	int i = 2;
	for (i = 2; i <= size ; i++)
	{
		AdjustUp(a, i);
	}
}

int main()
{
	int a[9] = { 32,42,12,23,8,65,79,56,93 };

	CreatHeapAdjustUp(a, 9);

	for (int i = 0; i < 9; i++)
	{
		printf("%d ", a[i]);
}
	return 0;
}

要注意我们这里向上调整的代码实现的时候 size 是数组的元素个数 ,所以我们 i 是从 2 开始的,因为一个元素的时候就是根节点,不用调整,直到数组的大小为 size 时,这是最后一个元素插入的向上调整。

这个结果是可以验证的,大家可以自己那这个数组按这个过程建堆对比一下结果,我对比过了这个结果是没问题的,所以说,向上调整建堆是可行的,但是他的时间复杂度是O(N logN);

向下调整建堆

既然向上调整可以建堆,那么按理来说向下调整也是可以的,那么向下调整建堆的过程是怎么样的呢? 我们先把数组想象成一棵二叉树

向下调整建堆的前提就是 结点的两个子树都是堆 ,那么我们的思路就是,从最后一层开始往上遍历,这样当遍历到某一层时 ,他的下面的节点就都已经是堆了 。同时,我们说过 ,只有一个节点的话他就是一个堆 ,所以我们其实不用去遍历所有的叶子节点,而要从第一个非叶子节点开始遍历,直到遍历到堆顶。 最后一个非叶子节点的下标怎么算呢? 他是最后一个节点的父节点,而最后一个节点的下标是 size-1 , 所以最后一个非叶子节点的下标为 (size-1-1)/2 。我们只要从这个节点开始遍历,到下标为0,不断向下调整建堆 ,在遍历到下标为1 时, 32的左右子树都是堆了,再进行一次向下调整就能建好堆了。

用向下调整将一棵完全二叉树变为堆的时间复杂度为多少呢?

这样算出来的向上调整的总次数为

其实但从图中我们就可以看出来向下调整建堆的效率比向上调整建堆的效率要高的多了,因为向上调整的时候是 层数越大的时候(节点越多),要调整的次数越多 。而向下调整则是层数越大的时候(节点越多),需要调整的次数越少,而且他的最后的叶子节点都不需要调整,而前面的向上调整则只有 根节点不用调整。

所以向下调整建堆的执行次数为 :N-log(N+1) ,当N很大时 ,log(N+1)是要远小于N的,所以时间复杂度为 O(N) 。所以向下调整建堆的效率是要高得多的。

复制代码
CreatHeapAdjustDown(int* a, int size)
{
	assert(a);
	int end = (size - 1 - 1);// 最后一个非叶子节点的下标
	for (; end >= 0; end--)
	{
		AdjustDown(a, end, size);
	}
}

int main()
{
	int a[9] = { 32,42,12,23,8,65,79,56,93 };

	//CreatHeapAdjustUp(a, 9);
	CreatHeapAdjustDown(a, 9);

	for (int i = 0; i < 9; i++)
	{
		printf("%d ", a[i]);
}
	return 0;
}
堆排序

堆排序要怎么做呢?就拿我们前面建好的小堆来说。

我们建小堆排的是升序还是降序呢?首先毋庸置疑他肯定是能够排升序的,排升序的过程我们可以创建一个新数组,如何把栈顶元素放进数组第一个位置中,如何删除堆顶元素,这时候第二小的元素又到了堆顶,我们再把他放到第二个位置............以此类推。但是还是之前的问题,用小堆排升序的话就有了额外的空间开销。

小堆其实正确的用法是排降序的,为什么呢? 前面我们实现的删除堆顶元素不知大家还是否记得,未满十八堆顶元素和最后一个节点元素调换位置了,也就是说,最小的数据到了数组的最后面,这时候size-1了,前 n-1 个元素还是小堆,这时候再删除堆顶元素,我们就可以把第二小的数据排到数组的倒数第二位............以此类推,我们不用额外的空间就能排成降序。

复制代码
void HeapSort(int* a, int size)//小堆排降序
{
	assert(a);
	int n = size;
	while (n)
	{
		Swap(&a[0], &a[n- 1]);//交换
		//向下调整
		n--;
		AdjustDown(a, 0, n);
	}
}

int main()
{
	int a[9] = { 32,42,12,23,8,65,79,56,93 };

	//CreatHeapAdjustUp(a, 9);
	CreatHeapAdjustDown(a, 9);//小堆
	HeapSort(a, 9);

	for (int i = 0; i < 9; i++)
	{
		printf("%d ", a[i]);
}
	return 0;
}

这样我们就能利用小队排降序了。

按这样的逻辑来,我们也能建大堆排升序。

复制代码
int main()
{
	int a[9] = { 32,42,12,23,8,65,79,56,93 };

	//CreatHeapAdjustUp(a, 9);
	//CreatHeapAdjustDown(a, 9);//小堆
	CreatHeapAdjustDown(a, 9);
	for (int i = 0; i < 9; i++)
	{
		printf("%d ", a[i]);
	}
	printf("\n");
	HeapSort(a, 9);

	for (int i = 0; i < 9; i++)
	{
		printf("%d ", a[i]);
	}
	return 0;
}

排升序--建大堆

排降序--建小堆

其实对的排序的大思路还是选择排序,每一次选出最小(最大)的数排到后面。
topK问题

topk问题是求一堆数据(N)中的前 k 大或者前 k 小,一般这种情况数据量都很大。

这时候建堆是最好的解决方案。那么我们是建大堆还是建小堆呢?如果我们要求的是前 k 大的数据。

如果我们要建大堆的话,就是要将全部 N 个数据都来建堆,这时候不断取堆顶元素放到一个数组中然后再删除堆顶元素,这样重复 k 次就能够找出前 k 大了,但是我们前面说过这种情况一般数据量都很大,可能堆区(堆的数组是动态开辟的,在堆区)的内存用完了都存不下所有的数据,这时候该怎么办呢?

其实更好的思路是建小堆,而且是建 k 个数据的小堆,我们先取出前 k 个数据建一个小堆出来,如何用剩下的 N-k 个数据依次和堆顶元素比较,如果数据比堆顶元素大,这时候就把这个数据放到堆顶,然后向下调整,这k个数中 最小的数据这时候又到了堆顶,这样到最后,堆里的 k 个数就是前k 大的数了。

为了验证这个思路,我们用代码实现出来,要存储大量数据,我们用数组肯定是不合适了,所以测试这个逻辑的时候我们用文件来操作。

创建文件

首先将100000个数据存到一个文件中,为了保证数据的范围,同时保证数据本身是无序的,我们用随机数的方式来生成数据,随机数%10000,我们就能得到10000以内的随机数,如何存十万个一万以内的数到文件中,但是我们不知道他们的前 k 大的数是多少,怎么来验证我们的程序对不对呢?很简单,在用程序写好文件之后,我们再手动在文件中去添加 k 个大于一万的数,这样我们就能直到 数据中的前k大,就能验证程序了。

复制代码
int main()
{
	FILE* pf = fopen("content.txt", "w");
	srand((unsigned int)time(NULL));//设置随机数生成的起点
	int i = 100000;
	int data = 0;
	while (i--)
	{
		data = rand() % 10000;
		fprintf(pf, "%d ", data);
		if (i % 10 == 0)
		{
			fprintf(pf, "\n");//每10个数打印一个换行
		}
	}

	return 0;
}

这样我们就将十万个一万以内的随机数写入到文件中了,这时候我们再手动去文件中添加10(假设我们的 k =10)个10000以上的数。

建堆求解

首先我们要malloc一个 k 个数据的数组,然后先把文件中的前 k 个数据放到数组中,再利用我们前面实现的向下调整建堆函数来建一个小堆 。

复制代码
//求前k大的数据
void test(char*fname,int k)
{
	int* a = (int*)malloc(sizeof(int) * k);
	assert(a);

	FILE* pf=fopen(fname, "r");
	int i = 0;
	int readdata;
	//读取前k个数据
	for (i = 0; i < k; i++)
	{
		fscanf(pf, "%d", &readdata);
		a[i] = readdata;
	}
	//前k个数据建小堆
	CreatHeapAdjustDown(a, k);

	//继续读取文件中剩下的数据与栈顶元素比较
	while (fscanf(pf, "%d", &readdata) != EOF)
	{
		if (readdata > a[0])
		{
			a[0] = readdata;
			//向下调整
			AdjustDown(a, 0, k);
		}
	}

	//最后打印结果
	for ( i = 0; i < k; i++)
	{
		printf("%d ", a[i]);
	}
}



int main()
{
	//FILE* pf = fopen("content.txt", "w");
	//srand((unsigned int)time(NULL));//设置随机数生成的起点
	//int i = 100000;
	//int data = 0;
	//while (i--)
	//{
	//	data = rand() % 10000;
	//	fprintf(pf, "%d ", data);
	//	if (i % 10 == 0)
	//	{
	//		fprintf(pf, "\n");//每10个数打印一个换行
	//	}
	//}

	char* fname = "content.txt";
	int k = 10;
	test(fname,10);

	return 0;
}

这就是求前k大的数据时的代码了。

如果是求前k小的数据,我们就用前k个数据建大堆,然后在读取剩下的数据,如果读到的数据比栈顶元素小,就替换栈顶元素,然后向下调整,知道读完数据,最后堆里面的就是前k小的数据了。

链式二叉树的详解在下一篇文章推出

相关推荐
jolimark8 分钟前
C语言自学攻略:小白入门三步走
c语言·编程入门·学习路线·实践项目·自学攻略
cen__y1 小时前
Linux12(Git01)
linux·运维·服务器·c语言·开发语言·git
社交怪人1 小时前
【算平均分】信息学奥赛一本通C语言解法(题号2071)
c语言·开发语言
卢锡荣2 小时前
单芯通吃,盲插标杆 —— 乐得瑞 LDR6020,Type‑C 全场景互联 “智慧芯”
c语言·开发语言·计算机外设
Mr. zhihao2 小时前
深入解析redis基本数据结构
数据结构·数据库·redis
念何架构之路3 小时前
Go语言加密算法
数据结构·算法·哈希算法
AI科技星3 小时前
《数学公理体系·第三部·数术几何》(2026 年版)
c语言·开发语言·线性代数·算法·矩阵·量子计算·agi
失去的青春---夕阳下的奔跑3 小时前
560. 和为 K 的子数组
数据结构·算法·leetcode
kkeeper~3 小时前
0基础C语言积跬步之字符函数与字符串函数(上)
c语言·开发语言
m0_629494734 小时前
LeetCode 热题 100-----25.回文链表
数据结构·算法·leetcode·链表