C语言顺序结构的二叉树之堆排序

 更新时间:2026年01月06日 08:28:56   作者:落羽的落羽  
对于初学者来说,二叉树的深入理解将有助于掌握更多高级数据结构,例如堆、图等,这篇文章主要介绍了C语言顺序结构的二叉树之堆排序的相关资料,文中通过图文介绍的非常详细,需要的朋友可以参考下

一、堆

1. 概念与分类

上一期我们提到,二叉树的实现既可以用顺序结构,也可以用链式结构。本篇我们来学习顺序结构的二叉树,起个新名字——堆(heap)。
堆是完全二叉树,它的底层是顺序结构的数组,具有二叉树特性的同时,还有一些其他性质:

堆分为大堆和小堆(或称为大根堆、小根堆):

  • 大堆:大堆的每个结点的存储值都 >= 它的子结点的存储值。
  • 小堆:小堆的每个结点的存储值都 <= 它的子结点的存储值。

譬如,在一个大堆中,某一个父结点的值为20,则它的子结点的值一定<=20;在一个小堆中,某一个父结点的值为20,则它的子结点的值一定>=20。

左孩子和右孩子的值的大小关系不确定。

换句话说,一个堆一定是大堆或小堆。以下的二叉树,既不是大堆也不是小堆,它们就不是堆:

2. 结构与性质

定义数据结构堆:

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

上面的画图是用逻辑结构表示堆,用存储结构表示堆就要用到数组了,牢记二叉树结点的编号方式:从上到下,从左到右

不难推断,堆的数组中有下列性质:

  • 大堆的首元素(根结点)是整个堆的最大值,小堆的首元素(根结点)是整个堆的最小值。
  • 若子结点的下标为i,则它的父结点是(i-1)/2。
  • 若父结点的下标为i,则它的左孩子是2i+1,右孩子是2i+2。结点个数是n,2i+1 >= n 说明无左孩子,2i+2 >= n 说明无右孩子。

顺带给出堆的初始化和销毁方法,以及后面要用到的交换两个变量值的方法:

void HPInit(HP* php)
{
	assert(php);
	php->arr = NULL;
	php->size = php->capacity = 0;
}

void HPDestory(HP* php) 
{
	assert(php);
	if (php->arr != NULL)
		free(php->arr);
	php->arr = NULL;
	php->size = php->capacity = 0;
}

void Swap(HPDataType* px, HPDataType* py)
{
	HPDataType tmp = *px;
	*px = *py;
	*py = tmp;
}

3. 入堆

想要把一个新的数据插入堆,分为两步:

  1. 把它插入堆数组末尾
  2. 仅仅插入数据后,可能会破坏堆的性质,所以还要进行“向上调整”操作:将新插入结点顺着其双亲往上调整位置至满足大堆或小堆的位置。
    我们以下面一个小堆为例,插入一个新数据10,如果它小于其父结点(不符合小堆),两者交换。再和新父结点比较,如果小于交换……直到满足小堆:

所以,我们要知道向上调整算法:它有两个参数:要被调整的堆数组,要调整位置的结点的下标:

void AdjustUp(HPDataType* arr, int child)
{
	int parent = (child - 1) / 2; //找这个结点的父结点
	while (child > 0)
	{
		//调整的是小堆:  <
		//调整的是大堆:  >
 		if (arr[child] < arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else //新数据已经到了对的位置
		{
			break;
		}
	}
}

这样,我们就能实现入堆操作了:

void HPPush(HP* php, HPDataType x)
{
	assert(php);
	if (php->size == php->capacity) //空间不够则需增容
	{
		int newcapacity = php->capacity == 0 ? 5 : 2 * php->capacity;
		HPDataType* tmp = (HPDataType*)realloc(php->arr, newcapacity * sizeof(HPDataType));
		if (tmp == NULL)
		{
			perror("relloc fail!");
			exit(1);
		}
		php->arr = tmp;
		php->capacity = newcapacity;
	}
	php->arr[php->size] = x; //新数据插到末尾,即下标为size的位置
	AdjustUp(php->arr, php->size);
	php->size++;
}

测试:

我们来实现一个小堆,乱序给一些数:

将打印结果排列成堆的逻辑结构看看,发现确实是正确的小堆:

4. 出堆

我们所谓的出堆,出的并不是数组末尾数据,出的是第一个数据——堆的根结点。但是,直接除去数组首元素,再将后面元素的下标全体前挪,会使堆的所有结点位置关系“大乱套”,再想调整可就麻烦了。

因此,我们选择这样的出堆定元素方法:先将堆顶数据和堆的最后一个数据交换,size- -把数组末尾数据出掉,再对堆顶元素进行“向下调整”操作。相比刚才所有结点位置大乱套,这样只要调整一个结点的位置就好了。

向下调整算法和向上调整类似:它是比较结点和其较大或较小孩子的值,不断往下交换调整位置直至满足大堆或小堆:

向下调整算法有三个参数:要被调整的堆数组、要调整的结点的下标、堆的数据个数

void AdjustDown(HPDataType* arr, int parent, int n)
{
	int child = 2 * parent + 1;
	while (child < n)
	{
		//调整的是小堆:                  >
		//调整的是大堆:                  <
		if (child + 1 < n && arr[child] < arr[child + 1]) //找两个孩子中的较大/较小者
		{
			child++;
		}

		//调整的是小堆:  <
		//调整的是大堆:  >
		if (arr[child] > arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			parent = child;
			child = 2 * parent + 1;
		}
		else //调整完成
		{
			break;
		}
	}
}

这样,我们就能实现出堆操作了:

void HPPop(HP* php)
{
	assert(php);
	assert(php->size != 0); //堆不能为空,否则无数据可出
	Swap(&php->arr[0], &php->arr[php->size - 1]); //交换堆顶和堆尾数据
	php->size--; //将堆尾数据出掉
	AdjustDown(php->arr, 0, php->size);
}

测试:

我们来实现一个大堆,乱序给一些数,再进行一次出堆:

分析逻辑结构,可以看到大堆实现成功,出堆也没有问题:

二、堆排序

堆排序是一种排序方法,不是借助堆的数据结构,而是利用堆的思想进行排序:

一个n个元素的数组,假如想排升序,就将数组建成一个大堆,堆顶是最大值,将堆顶和堆尾交换,下标n-1处就是最大值了;我们再把前n-1个数据调整成大堆,此时堆顶就是第二大的数据,堆顶和堆尾交换,下标n-2处就是第二大值了……直至排序完成。

相反地,想排成降序就建小堆,道理是一样的,我们下面就以建成大堆为例。

堆排序的关键在于一开始建堆的方法,可以分为向下调整建堆向上调整建堆

void HPCreat_Down(int* arr, int n) //向下调整算法建堆
{
	//从尾结点的父结点开始往上遍历,每一个结点都进行向下调整
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(arr, i, n); 
	}
}

void HPCreat_Up(int* arr, int n) //向上调整算法建堆
{
	//从第一个结点开始往下遍历,每一个结点都进行向上调整
	for (int i = 0; i < n; i++)
	{
		AdjustUp(arr, i);
	}
}

//注:建的是大堆还是小堆,取决于AdjustUp和AdjustDown函数中的大于还是小于号,上面提到过

知道了建堆方式后,就能实现堆排序了:

void HeapSort(int* arr, int n)
{
	HPCreat_Down(arr, n);
	//或HPCreat_Up(arr, n);
	int end = n - 1;
	while (end > 0)
	{
		Swap(&arr[0], &arr[end]);
		AdjustDown(arr, 0, end); //对新堆顶进行向下调整,以保证堆的性质
		end--;
	}
}

测试:

很顺利。

关于两种建堆方式的时间复杂度:
推导需要用到数列相关定理公式,我就不再证明了,可以直接记住结果:

  • 向下调整建堆:T(n) = n - log2(n+1),O(n)
  • 向上调整建堆:T(n) = (n+1) [log2(n+1) - 2] + 2,O(n*logn)

可见,向下调整建堆算法更好一些。

三、堆排序的应用——TOP-K问题

TOP-K问题,即求n个数据中前K个最大值或最小值,一般情况下n都很大且n >> k。

我们能想到的最简单的方法就是排序了,但是如果数据量太大,数据不能一下子全部加载到内存中,排序就不可取了。最佳的方式就是用堆来解决,思路为:

  • 取前k个数据建堆,遍历剩下的n-k个数据跟堆顶比较。
  • 如果找的是前k个最大值,就建小堆。若第k+1个数比堆顶大,就用它替换堆顶,再调整堆,再比较第k+2个数和堆顶……遍历完,最后堆中的k个数就是n个数里面前的k个最大值了。
  • 如果找的是前k个最小值,就建大堆。若第k+1个数比堆顶小,就用它替换堆顶,再调整堆,再比较第k+2个数和堆顶……遍历完,最后堆中的k个数就是n个数里面前的k个最小值了。

应该很好理解吧。

举个栗子,我先来造十万个数据,保存到一个文本文件data.txt中

void CreatData()
{
	srand(time(0));
	FILE* file = fopen("data.txt", "w");
	if (file == NULL)
	{
		perror("fopen fail!");
		exit(2);
	}
	for (int i = 0; i < 100000; i++)
	{
		int x = (rand() + i) % 100000 + 1;
		fprintf(file, "%d\n", x);
	}
	fclose(file);
}

最后来实现TOP_K算法(以找前k个最小值为例):

void Top_K()
{
	int k;
	printf("请输入K:");
	scanf("%d", &k);
	FILE* file = fopen("data.txt", "r");
	if (file == NULL)
	{
		perror("fopen fail!");
		exit(2);
	}

	int* maxHeap = (int*)malloc(sizeof(int) * k);
	if (maxHeap == NULL)
	{
		perror("malloc fail!");
		exit(1);
	}
	for (int i = 0; i < k; i++)
	{
		//先将前k个数存到maxHeap中
		fscanf(file, "%d", &maxHeap[i]);
	}
	
	HPCreat_Down(maxHeap, k); //找前k个最小值,建大堆

	//遍历剩下的数
	int x;
	while (fscanf(file, "%d", &x) != EOF) //fscanf文件读取结束会返回EOF
	{
		//谁小谁当堆顶
		if (x < maxHeap[0])
		{
			maxHeap[0] = x;
			AdjustDown(maxHeap, 0, k);
		}
	}	
	fclose(file);

	//处理完成,打印结果
	for (int i = 0; i < k; i++)
	{
		printf("%d ", maxHeap[i]);
	}
}

测试:

可以看到,每次输入一个k,都能找到前k个最小值,只不过不是按大小顺序输出的。顺带一提,这个算法的时间复杂度O(n) = k + (n - k)log2k

总结

到此这篇关于C语言顺序结构的二叉树之堆排序的文章就介绍到这了,更多相关C语言顺序结构堆排序内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • 深入理解c++模板中的class与typename

    深入理解c++模板中的class与typename

    在c++Template中很多地方都用到了typename与class这两个关键字,而且好像可以替换,是不是这两个关键字完全一样呢?下面这篇文章主要给大家介绍了关于c++模板中class与typename的相关资料,需要的朋友可以参考下。
    2017-07-07
  • dev-c++创建lib(静态链接库)文件的实现步骤

    dev-c++创建lib(静态链接库)文件的实现步骤

    本文主要介绍了dev-c++创建lib(静态链接库)文件的实现步骤,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-06-06
  • 简单举例说明C++中break和continue语句的用法

    简单举例说明C++中break和continue语句的用法

    这篇文章主要介绍了简单举例说明C++中break和continue语句的用法,是C++入门学习中的基础只是,需要的朋友可以参考下
    2015-09-09
  • C语言实现与电脑玩剪刀石头布游戏

    C语言实现与电脑玩剪刀石头布游戏

    这篇文章主要为大家详细介绍了如何通过C语言实现和电脑玩剪刀石头布游戏,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2022-11-11
  • C语言输入一个数判断是否为素数的多种方法

    C语言输入一个数判断是否为素数的多种方法

    素数是只能被1和它自己本身整除,不能被其他自然数整除的大于1的正整数,下面这篇文章主要给大家介绍了关于C语言输入一个数判断是否为素数的多种方法,文中通过实例代码介绍的非常详细,需要的朋友可以参考下
    2023-04-04
  • C++利用用埃式筛法求解素数

    C++利用用埃式筛法求解素数

    埃拉托斯特尼筛法,简称埃氏筛或爱氏筛,是一种由希腊数学家埃拉托斯特尼所提出的一种简单检定素数的算法。本文将利用这一算法实现求解素数,感兴趣的可以了解一下
    2023-01-01
  • C++实现冒泡排序的多种方式详解

    C++实现冒泡排序的多种方式详解

    冒泡排序是最基础的排序算法之一,它的核心思想是通过相邻元素的比较和交换,将较大的元素逐步冒泡到数组的末尾,今天我们来分析三种不同的冒泡排序实现方式,每种都有其独特之处,需要的朋友可以参考下
    2025-10-10
  • C语言编写多功能日历

    C语言编写多功能日历

    之前看到本站给大家分享了一则C语言实现的简单日历,就手痒了,也想自己写一个,既然有简单了,那咱写个稍微复杂点的,多功能的吧。呵呵,玩笑玩笑
    2015-03-03
  • C++直接初始化与复制初始化的区别深入解析

    C++直接初始化与复制初始化的区别深入解析

    这篇文章主要介绍了C++直接初始化与复制初始化的区别深入解析,是很多C++初学者需要深入了解的重要概念,需要的朋友可以参考下
    2014-09-09
  • 深入解析C++11 lambda表达式/包装器/线程库

    深入解析C++11 lambda表达式/包装器/线程库

    这篇文章主要介绍了C++11 lambda表达式/包装器/线程库的相关知识,本文通过示例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-05-05

最新评论