Bootstrap

C语言实现顺序结构二叉树-堆

在这里插入图片描述

🎯引言

欢迎来到HanLop博客的C语言数据结构初阶系列。在这个系列中,我们将深入探讨各种基本的数据结构和算法,帮助您打下坚实的编程基础。在本篇文章中,我们将讲解顺序结构的二叉树,特别是堆(Heap)。堆是一种特殊的完全二叉树,它满足堆属性,即每个节点的值都大于或等于(或小于或等于)其子节点的值。堆在许多算法中起着至关重要的作用,例如优先队列的实现和堆排序。在这篇文章中,我们将介绍堆的基本概念、堆的创建和操作方法,以及其在实际编程中的应用。通过一些实际的代码示例,您将更好地掌握堆在C语言中的实现和应用,从而为后续学习更复杂的数据结构和算法打下坚实的基础。

👓C语言实现顺序结构二叉树-堆

1.树的概念与结构

1.1概念与结构

在计算机科学中,树是一种重要的非线性数据结构。树由一系列节点组成,每个节点包含一个值以及指向其他节点的链接。树具有层级结构,即每个节点可以有多个子节点,但只有一个父节点(根节点除外)。树的这种层级结构使得它在许多应用中非常有用,例如文件系统、数据库索引和表达式解析等。

之所以将它取名为树,是因为它形似一颗现实中倒挂的树,图示:

在这里插入图片描述

1.2树的相关术语

在深入学习树的数据结构之前,了解相关术语是非常重要的。以下是一些关键术语及其定义:

  1. 根节点(Root Node):树的顶层节点,没有父节点。每棵树只有一个根节点。
  2. 子节点(Child Node):由另一个节点(父节点)指向的节点。
  3. 父节点(Parent Node):指向一个或多个子节点的节点。
  4. 兄弟节点(Sibling Node):具有相同父节点的节点。
  5. 叶子节点(Leaf Node):没有子节点的节点,位于树的最底层。
  6. 分支节点(Internal Node):至少有一个子节点的节点。
  7. 路径(Path):从一个节点到另一个节点所经过的节点序列。
  8. 边(Edge):连接两个节点的线,表示父子关系。
  9. 路径长度(Path Length):路径上所包含的边的数量。
  10. 深度(Depth):节点到根节点的路径长度。根节点的深度为0。
  11. 高度(Height):节点到叶子节点的最长路径长度。叶子节点的高度为0。
  12. 层级(Level):树中所有深度相同的节点组成的集合。根节点在第1层,根节点的子节点在第2层,以此类推。
  13. 子树(Subtree):以某个节点为根的其他所有节点及其连接的节点构成的树。
  14. 度(Degree):节点的度是其子节点的数量,树的度是所有节点中最大的度。
  15. 森林(Forest):由多棵互不相交的树组成的集合。
  16. 祖先节点(Ancestor Node):一个节点的所有前代节点
  17. 子孙节点(Descendant Node):一个节点的所有后代节点

注:树的高度或深度就是树中节点的最大层级

例子说明

考虑以下二叉树:

       A
      / \
     B   C
    / \   \
   D   E   F

在这棵树中:

  • 根节点是A。
  • B和C是A的子节点,A是B和C的父节点。
  • D和E是B的子节点,B是D和E的父节点。
  • F是C的子节点,C是F的父节点。
  • D、E和F是叶子节点,因为它们没有子节点。
  • A是第1层,B和C是第2层,D、E和F是第3层。
  • 节点B的深度是2,节点D的深度是3。
  • 节点D的高度是0,节点B的高度是1,节点A的高度是2。
  • 整棵树的高度是3。

2.二叉树

2.1概念与结构

二叉树是一种特殊的树形数据结构,每个节点最多有两个子节点,分别称为左子节点和右子节点。

图示:

在这里插入图片描述

二叉树的特点:

1. 每个节点最多有两个子节点

每个节点最多有两个子节点,分别称为左子节点和右子节点。这是二叉树区别于其他树结构的一个重要特点。

  1. 递归定义

二叉树具有递归的性质。每个子树本身也是一个二叉树。即,每个节点及其子节点形成的结构也可以看作是一棵二叉树。

  1. 子树有序

在二叉树中,节点的左子树和右子树是有序的。

2.2特殊的二叉树

2.2.1满二叉树

满二叉树的概念:

满二叉树(Full Binary Tree) 是一种特殊类型的二叉树,其中每个节点要么没有子节点(即叶子节点),要么有两个子节点。换句话说,在满二叉树中,所有非叶子节点都有两个子节点。

图示:
在这里插入图片描述

相关数学计算(根层次为1)

  1. 节点数量

    • 如果一棵满二叉树的高度为 h,则总节点数量 N 可以通过以下公式计算: N = 2 h − 1 N = 2^h - 1 N=2h1 则满二叉树深度为: h = ⌈ l o g 2 ( N + 1 ) ⌉ h = \lceil log_2 (N+1) \rceil h=log2(N+1)⌉

    • 解释:树的层数为 h,因为根节点层次从1开始,层数是 h(从第1层到第 h 层)。每层的节点数是 2 i − 1 2^{i-1} 2i1(第 i 层),因此:

      N = 2 0 + 2 1 + 2 2 + ⋯ + 2 h − 1 N = 2^0 + 2^1 + 2^2 + \cdots + 2^{h-1} N=20+21+22++2h1

      使用等比数列求和公式:

      N = 2 h − 1 2 − 1 = 2 h − 1 N = \frac{2^h - 1}{2 - 1} = 2^h - 1 N=212h1=2h1

  2. 叶子节点数量

    • 在满二叉树中,叶子节点的数量 L 与分支节点的数量 I 之间的关系为: L = I + 1 L = I + 1 L=I+1
    • 解释:由于每个非叶子节点都有两个子节点,因此总的子节点数量是 2 × I 2 \times I 2×I,而每个叶子节点都不产生新的子节点,因此树的叶子节点数是分支节点数加1。
  3. 每层节点数

    • 在满二叉树中,第 i 层的节点数为 2 i − 1 2^{i-1} 2i1 ,其中 i 从1开始。
    • 解释:第1层只有1个节点(根节点),第2层有2个节点,第3层有4个节点,以此类推。
2.2.2完全二叉树

完全二叉树详解(根层次从1开始)

完全二叉树(Complete Binary Tree) 是一种特殊类型的二叉树,具有以下特点:

  1. 层次性质:
    • 除了最后一层外,完全二叉树的每一层都是满的。
    • 最后一层的节点从左至右排列,没有空缺。

图示:

在这里插入图片描述

数学公式解释

  1. 节点数量
    • 完全二叉树中,如果高度为 h(根节点层次从1开始),则节点总数 N 介于 2 h − 1 和 2 h − 1 2^{h-1} 和 2^h −1 2h12h1之间。
    • 具体公式为: 2 h − 1 ≤ N ≤ 2 h − 1 2^{h-1} \leq N \leq 2^h −1 2h1N2h1其中,h 是完全二叉树的高度。
  2. 树的高度
    • 树的高度 h 与节点数量 N 的关系可以表示为: h = ⌈ l o g ⁡ 2 ( N + 1 ) ⌉ h=⌈log⁡2(N+1)⌉ h=log⁡2(N+1)⌉
    • 解释:这是因为在完全二叉树中,每一层的节点数是2的幂次。其中N看成是满二叉树时的节点个数,因为满二叉树和完全二叉树只是最后一层的节点个数有所不同,但高度都是相同的
  3. 最后一层的节点数
    • 假设最后一层是第 h 层,则最后一层的节点数 L 可以通过以下公式计算: L = N − ( 2 h − 1 − 1 ) L= N - (2^{h-1} - 1) L=N(2h11)
    • 解释:总节点数 N 减去满二叉树中 h−1 层的节点数,即为最后一层的节点数。

示例

考虑以下完全二叉树:

       A
      / \
     B   C
    / \ / \
   D  E F

在这棵树中:

  • 层次性质

    • 除了最后一层外,所有层的节点都是满的。
    • 最后一层的节点集中在最左边(D、E、F)。
  • 节点数量

    • 总节点数为6。
  • 树的高度

    • 使用公式 h = ⌈ l o g ⁡ 2 ( N + 1 ) ⌉ : h=⌈log⁡2(N+1)⌉: h=log⁡2(N+1)⌉

      h = ⌈ l o g ⁡ 2 ( 6 + 1 ) ⌉ = ⌈ l o g ⁡ 2 ( 7 ) ⌉ = 3 h=⌈log⁡2(6+1)⌉=⌈log⁡2(7)⌉=3 h=log⁡2(6+1)⌉=log⁡2(7)⌉=3

  • 最后一层的节点数

    • 使用公式 L = N − ( 2 h − 1 − 1 ) : L=N - (2^{h-1} - 1): L=N(2h11)

      L = 6 − ( 2 3 − 1 − 1 ) = 6 − 3 = 3 L= 6 - (2^{3-1} - 1) = 6 - 3 = 3 L=6(2311)=63=3

3.二叉树的存储结构

3.1顺序存储

顺序存储是一种利用数组存储二叉树节点的方法。节点的存储位置遵循一定的规则,使得树的结构可以通过数组的索引来表示。

特点

  • 空间利用率高:适用于完全二叉树或接近完全二叉树的情况,因为节点位置与数组索引一一对应。
  • 方便的节点访问:可以直接通过数组索引访问节点,时间复杂度为 O(1)。

存储规则

  • 根节点存储在数组的第一个位置(索引为0)。
  • 对于数组中位置为 i的节点:
    • 左子节点的位置为 2 i + 1 2i+1 2i+1(若根节点在索引0处)。
    • 右子节点的位置为 2 i + 2 2i+2 2i+2(若根节点在索引0处)。
    • 父节点的位置为 ⌊ i − 1 2 ⌋ \left\lfloor \frac{i-1}{2} \right\rfloor 2i1(若根节点在索引0处)。

图示:

在这里插入图片描述

优缺点

  • 优点:
    • 直接访问节点,速度快。
    • 适用于完全二叉树或接近完全二叉树的情况。
  • 缺点:
    • 对于非完全二叉树,会浪费存储空间,因为数组中会有空闲位置。
    • 插入和删除操作复杂,需要调整数组中节点的位置。

3.2链式存储

链式存储是一种利用链表存储二叉树节点的方法。每个节点包含数据和指向其左子节点和右子节点的指针。

特点

  • 灵活的内存使用:适用于任何形状的二叉树,不会浪费存储空间。
  • 插入和删除操作方便:只需要调整指针,不需要移动大量数据。

存储结构

每个节点包含以下内容:

  • 数据域:存储节点的数据。
  • 左指针域:指向左子节点。
  • 右指针域:指向右子节点。

示例

考虑以下二叉树:

       A
      / \
     B   C
    / \   \
   D   E   F

链式存储法的节点结构为:

struct TreeNode {
    char data;
    struct TreeNode *left;
    struct TreeNode *right;
};

优缺点

  • 优点:
    • 内存利用率高,适用于任何形状的二叉树。
    • 插入和删除操作灵活,只需调整指针。
  • 缺点:
    • 访问节点的时间复杂度为 O(h),其中 hhh 是树的高度。
    • 需要额外的存储空间来存储指针。

对比与选择

  • 顺序存储适用于完全二叉树或接近完全二叉树的情况,具有快速的节点访问速度,但在非完全二叉树中会浪费存储空间。
  • 链式存储适用于任何形状的二叉树,具有灵活的内存使用和方便的插入、删除操作,但节点访问速度较慢,需要额外的指针存储空间。

4.堆的实现

4.1堆的概念与结构

堆(Heap) 是一种特殊的完全二叉树,具有以下性质:

  1. 堆性质:
    • 对于任何一个节点 iii,堆中节点的值总是满足:其父节点的值小于等于(最小堆)或大于等于(最大堆)其子节点的值。

根据堆性质,堆分为两种类型:

  • 最小堆(Min-Heap):根节点的值是所有节点中最小的,每个父节点的值都小于等于其子节点的值。
  • 最大堆(Max-Heap):根节点的值是所有节点中最大的,每个父节点的值都大于等于其子节点的值。

堆的结构

堆是一种完全二叉树,因此它可以使用数组进行顺序存储。节点在数组中的位置遵循一定的规则:

  • 根节点存储在数组的第一个位置(索引0)。
  • 对于数组中位置为 i的节点:
    • 左子节点的位置为 2 i + 1 2i+1 2i+1(若根节点在索引0处)。
    • 右子节点的位置为 2 i + 2 2i+2 2i+2(若根节点在索引0处)。
    • 父节点的位置为 ⌊ i − 1 2 ⌋ \left\lfloor \frac{i-1}{2} \right\rfloor 2i1(若根节点在索引0处)。

示例:

在这里插入图片描述

  • 父节点=(1-1)/2=0
  • 左孩子节点=2*1+1=3
  • 右孩子节点=2*1+2=4

堆的实现:

4.2向上调增算法

(以小堆为列,父节点的值都小于其子节点)

当我们插入在堆的尾部插入一个小于根节点的数据时,我们怎么样把这个插入后的结构继续维持成小堆呢?

图解:

在这里插入图片描述

向上调整算法是为了在插入新元素后恢复堆的性质。以下是详细的代码解析:

// 向上调整算法
typedef int HeapDatatype;

void Swap(HeapDatatype* a1, HeapDatatype* a2)
{
	HeapDatatype temp = *a1;
	*a1 = *a2;
	*a2 = temp;
}

void AdjustUp(HeapDatatype* a, int child) {
    // 若孩子节点为 i,则父节点为 (i-1)/2
    int parent = (child - 1) / 2;

    // 当 child 不在根节点且 child 的值小于其父节点的值时,进行调整
    while (child > 0) {
        // 如果当前 child 的值小于其父节点的值,则交换这两个节点
        if (a[child] < a[parent]) {
            Swap(&a[child], &a[parent]);
            // 更新 child 和 parent 的位置,继续向上调整
            child = parent;
            parent = (child - 1) / 2;
        } else {
            // 如果当前 child 的值不小于其父节点的值,则调整完毕,跳出循环
            break;
        }
    }
}

代码解析

  • parent = (child - 1) / 2;:计算当前节点的父节点位置。
  • while (child > 0):只要 child 不是根节点(索引0)就继续调整。
  • if (a[child] < a[parent]):如果 child 的值小于其父节点的值,交换它们的位置。
  • child = parent; parent = (child - 1) / 2;:更新 child 和 parent 的位置,继续向上调整。
  • else { break; }:如果 child 的值不小于其父节点的值,则调整完毕,跳出循环。

4.3向下调整算法

在使用向下调整算法维持小堆的时候,必须保证根节点的左右子树都为小堆

图解:

在这里插入图片描述

向下调整算法是为了在删除堆顶元素后恢复堆的性质。以下是详细的代码解析:

// 向下调整算法
typedef int HeapDatatype;

void Swap(HeapDatatype* a1, HeapDatatype* a2)
{
	HeapDatatype temp = *a1;
	*a1 = *a2;
	*a2 = temp;
}

void AdjustDown(HeapDatatype* a, int n, int parent) {
    // 若父节点为 i,则左孩子为 2i+1
    int child = parent * 2 + 1;

    // 当 child 在堆的范围内时,继续调整
    while (child < n) {
        // 找到左右孩子节点中较小的那一个
        if (child + 1 < n && a[child + 1] < a[child]) {
            child++;
        }

        // 如果当前 child 的值小于其父节点的值,则交换这两个节点
        if (a[child] < a[parent]) {
            Swap(&a[child], &a[parent]);
            // 更新 parent 和 child 的位置,继续向下调整
            parent = child;
            child = parent * 2 + 1;
        } else {
            // 如果当前 child 的值不小于其父节点的值,则调整完毕,跳出循环
            break;
        }
    }
}

代码解析

  • child = parent * 2 + 1;:计算当前节点的左孩子位置。
  • while (child < n):只要 child 在堆的范围内就继续调整。
  • if (child + 1 < n && a[child + 1] < a[child]):如果右孩子存在且右孩子的值小于左孩子的值,选择右孩子。
  • if (a[child] < a[parent]):如果 child 的值小于其父节点的值,交换它们的位置。
  • parent = child; child = parent * 2 + 1;:更新 parent 和 child 的位置,继续向下调整。
  • else { break; }:如果 child 的值不小于其父节点的值,则调整完毕,跳出循环。

4.3堆的实现(小堆)

Heap.h源码

//Heap.h文件中
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <stdbool.h>
#include <time.h>

typedef int HeapDatatype;
typedef struct Heap
{
	HeapDatatype* arr;
	int size;
	int capacity;
}Heap;

//堆的初始化
void HeapInit(Heap* hp);
//堆的销毁
void HeapDestory(Heap* hp);
//交换数据
void Swap(HeapDatatype* a1, HeapDatatype* a2);
//堆的插入
void HeapPush(Heap* hp, HeapDatatype x);

//取堆顶的数据
HeapDatatype HeapTop(Heap* hp);
//判空
bool HeapEmpty(Heap* hp);

//求堆的存储的数量
int HeapSize(Heap* hp);

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

//向上调整算法
void AdjustUp(HeapDatatype* a, int child);

//向下调整算法  左右子树都是小堆 对堆顶进行向下调整算法 
void AdjustDown(HeapDatatype* a, int n, int parent);

Heap.c源码

//Heap.c
#include "Heap.h"
//堆的初始化
void HeapInit(Heap* hp)
{
	assert(hp);
	hp->arr = NULL;
	hp->capacity = hp->size = 0;
}

void Swap(HeapDatatype* a1, HeapDatatype* a2)
{
	HeapDatatype temp = *a1;
	*a1 = *a2;
	*a2 = temp;
}
//向上调整算法
void AdjustUp(HeapDatatype* a, int child)
{
	//若孩子节点为i  则parent=(i-1)/2
	//若父节点为i   则左孩子为 2i+1 友孩子为2i+2
	int parent = (child - 1) / 2;

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

//向下调整算法
void AdjustDown(HeapDatatype* 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[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;		
		}
		else
		{
			break;
		}
	}
}

//堆的插入
void HeapPush(Heap* hp, HeapDatatype x)
{
	assert(hp);

	//检查容量
	if (hp->size == hp->capacity)
	{
		int newcapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;

		HeapDatatype* temp = (HeapDatatype*)realloc(hp->arr,sizeof(HeapDatatype) * newcapacity);
		if (temp == NULL)
		{
			perror("malloc fail");
			exit(1);
		}

		hp->capacity = newcapacity;
		hp->arr = temp;
	}

	hp->arr[hp->size] = x;

	//插入完数据要保证堆的性质,进行向下或者向上调整算法
	AdjustUp(hp->arr, hp->size);
	hp->size++;
}
//判空
bool HeapEmpty(Heap* hp)
{
	assert(hp);
	return hp->size == 0;
}
//取堆顶数据
HeapDatatype HeapTop(Heap* hp)
{
	assert(hp);
	assert(!HeapEmpty(hp));

	return hp->arr[0];
}

//求堆的存储的数量
int HeapSize(Heap* hp)
{
	assert(hp);
	return hp->size;
}

//删除堆顶的数据
void HeapPop(Heap* hp)
{
	assert(hp);
	assert(!HeapEmpty(hp));

	//将堆顶数据和最后一个数据交换
	Swap(&hp->arr[0], &hp->arr[hp->size-1]);

	hp->size--;
	//对堆顶进行向下调整算法
	AdjustDown(hp->arr, hp->size, 0);
}

//堆的销毁
void HeapDestory(Heap* hp)
{
	assert(hp);
	free(hp->arr);
	hp->arr = NULL;
	hp->capacity = hp->size = 0;
}

函数实现详解:

堆的初始化

void HeapInit(Heap* hp) {
    assert(hp);
    hp->arr = NULL;
    hp->capacity = hp->size = 0;
}
  • 功能: 初始化堆结构。
  • 参数: Heap* hp 指向堆结构体的指针。
  • 操作:
    • 将数组指针设置为 NULL,表示当前没有分配任何内存。
    • 将堆的容量和大小初始化为0。

元素交换函数

void Swap(HeapDatatype* a1, HeapDatatype* a2) {
    HeapDatatype temp = *a1;
    *a1 = *a2;
    *a2 = temp;
}
  • 功能: 交换两个元素的值。
  • 参数: HeapDatatype* a1HeapDatatype* a2 指向要交换的两个元素。
  • 操作:
    • 使用一个临时变量 temp 进行交换操作,确保 a1a2 之间的值交换成功。

向上调整算法

void AdjustUp(HeapDatatype* a, int child) {
    int parent = (child - 1) / 2;

    while (child > 0) {
        if (a[child] < a[parent]) {
            Swap(&a[child], &a[parent]);
            child = parent;
            parent = (child - 1) / 2;
        } else {
            break;
        }
    }
}
  • 功能: 向上调整堆,保证堆的性质。
  • 参数: HeapDatatype* a 是数组指针,int child 是当前需要调整的孩子节点索引。
  • 操作:
    • 计算父节点索引 (child - 1) / 2
    • 当孩子节点存在且其值小于父节点时,交换孩子节点与父节点的值,并更新孩子和父节点的索引,继续向上调整。
    • 如果孩子节点不小于父节点,结束调整。

向下调整算法

void AdjustDown(HeapDatatype* 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[child] < a[parent]) {
            Swap(&a[child], &a[parent]);
            parent = child;
            child = parent * 2 + 1;
        } else {
            break;
        }
    }
}
  • 功能: 向下调整堆,保证堆的性质。
  • 参数: HeapDatatype* a 是数组指针,int n 是堆的大小,int parent 是当前需要调整的父节点索引。
  • 操作:
    • 计算左孩子节点索引 parent * 2 + 1
    • 在左右孩子节点中找到较小的一个孩子节点,如果孩子节点值小于父节点值,交换它们。
    • 更新父节点和孩子节点索引,继续向下调整。
    • 如果孩子节点不小于父节点,结束调整。

堆的插入

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

    if (hp->size == hp->capacity) {
        int newcapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
        HeapDatatype* temp = (HeapDatatype*)realloc(hp->arr, sizeof(HeapDatatype) * newcapacity);
        if (temp == NULL) {
            perror("malloc fail");
            exit(1);
        }

        hp->capacity = newcapacity;
        hp->arr = temp;
    }

    hp->arr[hp->size] = x;
    AdjustUp(hp->arr, hp->size);
    hp->size++;
}
  • 功能: 向堆中插入新元素。
  • 参数: Heap* hp 指向堆结构体的指针,HeapDatatype x 是要插入的元素。
  • 操作:
    • 检查是否需要扩展堆的容量,如果当前容量不足,分配新的内存空间。
    • 将新元素放在堆的末尾位置。
    • 通过向上调整算法恢复堆的性质。
    • 增加堆的大小。

判空

bool HeapEmpty(Heap* hp) {
    assert(hp);
    return hp->size == 0;
}
  • 功能: 判断堆是否为空。
  • 参数: Heap* hp 指向堆结构体的指针。
  • 操作:
    • 返回堆的大小是否为0,若为0则堆为空。

获取堆顶元素

HeapDatatype HeapTop(Heap* hp) {
    assert(hp);
    assert(!HeapEmpty(hp));
    return hp->arr[0];
}
  • 功能: 获取堆顶元素。
  • 参数: Heap* hp 指向堆结构体的指针。
  • 操作:
    • 返回堆顶元素,堆顶元素始终是数组的第一个元素。

获取堆的大小

int HeapSize(Heap* hp) {
    assert(hp);
    return hp->size;
}
  • 功能: 获取堆中元素的数量。
  • 参数: Heap* hp 指向堆结构体的指针。
  • 操作:
    • 返回堆的大小。

删除堆顶元素

void HeapPop(Heap* hp) {
    assert(hp);
    assert(!HeapEmpty(hp));

    Swap(&hp->arr[0], &hp->arr[hp->size - 1]);
    hp->size--;
    AdjustDown(hp->arr, hp->size, 0);
}
  • 功能: 删除堆顶元素。
  • 参数: Heap* hp 指向堆结构体的指针。
  • 操作:
    • 将堆顶元素与最后一个元素交换。
    • 减少堆的大小。
    • 通过向下调整算法恢复堆的性质。

销毁堆

void HeapDestory(Heap* hp) {
    assert(hp);
    free(hp->arr);
    hp->arr = NULL;
    hp->capacity = hp->size = 0;
}
  • 功能: 释放堆所占用的内存,并将堆的指针和大小重置为初始状态。
  • 参数: Heap* hp 指向堆结构体的指针。
  • 操作:
    • 释放堆数组的内存。
    • 将堆的数组指针设为 NULL,容量和大小设为0。

5.堆的应用

5.1堆排序

堆排序是一种基于堆(通常是二叉堆)的排序算法,具有O(n log n)的时间复杂度。它包括两个主要步骤:建堆和排序。下面是详细的堆排序实现及解释。

堆排序代码

void HeapSort(int* arr, int n) {
    // 通过向下调整算法建堆
    // 向下调整建堆时间复杂度O(n)
    int i;
    for (i = (n - 1 - 1) / 2; i >= 0; i--) {
        AdjustDown(arr, n, i);
    }

    // 升序建大堆
    // 降序建小堆
    while (n > 0) {
        Swap(&arr[0], &arr[n - 1]);
        n--;
        AdjustDown(arr, n, 0);
    }
}

建堆阶段

for (i = (n - 1 - 1) / 2; i >= 0; i--) {
    AdjustDown(arr, n, i);
}
  • 目标: 构建一个大堆或小堆(看向下调整算法是如何写的)。
  • 操作:
    • 从最后一个非叶子节点开始,向上逐个节点进行向下调整。
    • 计算方式 (n - 1 - 1) / 2 确定最后一个非叶子节点索引。
    • 调用 AdjustDown 函数,从最后一个非叶子节点向根节点方向调整,确保每个子树都满足堆的性质。
    • 这个过程的时间复杂度是O(n)。

排序阶段

while (n > 0) {
    Swap(&arr[0], &arr[n - 1]);
    n--;
    AdjustDown(arr, n, 0);
}
  • 目标: 排序数组。
  • 操作:
    • 将堆顶元素(小堆最小值或大堆最大值)与当前堆的最后一个元素交换。
    • 减少堆的大小(忽略已经排序的最后一个元素)。
    • 对新的堆顶元素进行向下调整,恢复堆的性质。
    • 重复上述步骤,直到所有元素都排序完成。

5.2TOP-K问题

TOP-K问题是指从一个数据流或一个大的数据集合中,找出前K个最大或最小的元素。堆是一种有效的数据结构,可以很好地解决这个问题。使用小顶堆可以解决TOP-K最大值的问题,而使用大顶堆可以解决TOP-K最小值的问题。

算法思路

  1. 初始化一个大小为K的小顶堆:将前K个元素插入堆中。
  2. 遍历剩余元素:
    • 如果当前元素大于堆顶元素,则用当前元素替换堆顶元素,并进行堆的调整,使得堆顶仍然是堆中最小的元素。
  3. 最终堆中的K个元素即为前K个最大的元素

这种方法的时间复杂度是O(N log K),其中N是数据集合的大小,K是需要找出的前K个元素。

小顶堆实现TOP-K问题

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

// 交换函数
void Swap(int* a1, int* a2) {
    int temp = *a1;
    *a1 = *a2;
    *a2 = temp;
}

// 向下调整算法
void AdjustDown(int* arr, int n, int parent) {
    int child = parent * 2 + 1;

    while (child < n) {
        // Find the smaller child
        if (child + 1 < n && arr[child + 1] < arr[child]) {
            child++;
        }

        if (arr[child] < arr[parent]) {
            Swap(&arr[child], &arr[parent]);
            parent = child;
            child = parent * 2 + 1;
        } else {
            break;
        }
    }
}

// Function to find top K elements using min-heap
void TopK(int* arr, int n, int k) {
    if (k <= 0 || n <= 0) return;

    // Step 1: 初始化一个大小为K的小顶堆。
    int* heap = (int*)malloc(sizeof(int) * k);
    
    //Step 2:使用前K个元素构建初始堆。
    for (int i = 0; i < k; ++i) {
        heap[i] = arr[i];
    }
    for (int i = (k - 2) / 2; i >= 0; --i) {
        AdjustDown(heap, k, i);
    }
	
    // Step 3: 遍历剩余元素,如果当前元素大于堆顶元素,用当前元素替换堆顶元素并进行堆调整。
    for (int i = k; i < n; ++i) {
        if (arr[i] > heap[0]) {
            heap[0] = arr[i];
            AdjustDown(heap, k, 0);
        }
    }

    // Step 4:最终堆中包含前K个最大的元素,输出这些元素。
    printf("Top %d elements are: ", k);
    for (int i = 0; i < k; ++i) {
        printf("%d ", heap[i]);
    }
    printf("\n");

    free(heap);
}

int main() {
    int arr[] = {3, 2, 1, 5, 6, 4, 8, 9, 10, 7};
    int n = sizeof(arr) / sizeof(arr[0]);
    int k = 4;

    TopK(arr, n, k);

    return 0;
}

TopK函数

void TopK(int* arr, int n, int k) {
    if (k <= 0 || n <= 0) return;

    int* heap = (int*)malloc(sizeof(int) * k);
    for (int i = 0; i < k; ++i) {
        heap[i] = arr[i];
    }

    for (int i = (k - 2) / 2; i >= 0; --i) {
        AdjustDown(heap, k, i);
    }

    for (int i = k; i < n; ++i) {
        if (arr[i] > heap[0]) {
            heap[0] = arr[i];
            AdjustDown(heap, k, 0);
        }
    }

    printf("Top %d elements are: ", k);
    for (int i = 0; i < k; ++i) {
        printf("%d ", heap[i]);
    }
    printf("\n");

    free(heap);
}
  • 功能: 找到数组中前K个最大的元素。
  • 参数:
    • int* arr: 数组指针。
    • int n: 数组的大小。
    • int k: 要找的前K个元素。
  • 操作:
    • 初始化一个大小为K的小顶堆。
    • 使用前K个元素构建初始堆。
    • 遍历剩余元素,如果当前元素大于堆顶元素,用当前元素替换堆顶元素并进行堆调整。
    • 最终堆中包含前K个最大的元素,输出这些元素。

🥇 结语

通过本篇文章的学习,相信您已经对顺序结构二叉树——堆有了深入的了解。我们探讨了堆的基本概念、如何在C语言中创建和操作堆,以及其在实际编程中的应用。掌握堆的实现和应用不仅可以提升您的数据结构知识,还能为解决实际问题提供强有力的工具。希望这些知识能为您在编程道路上提供帮助,并为后续学习其他复杂的数据结构和算法打下坚实的基础。感谢您关注HanLop博客,期待在下一篇文章中继续与您探讨更多有趣且实用的编程知识。

;