C语言数据结构——二叉树

目录

0.前言

1.树的概念和结构

1.1树的概念

1.2树的相关概念

1.3树的表示

1.4树在实际中的应用

2.二叉树的概念及结构

2.1概念

2.2一些特殊的二叉树

2.3二叉树的性质

2.3.1二叉树的基本性质

2.3.2完全二叉树的性质

2.4二叉树的存储结构

2.4.1顺序存储

2.4.2链式存储

3.二叉树的顺序结构和实现

3.1二叉树的顺序结构

3.2堆的概念和结构

3.3堆的实现

3.3.1向下调整算法

3.3.2堆的创建

3.3.3建堆的时间复杂度分析

3.3.4堆的插入和删除

3.3.5堆的代码实现

3.4堆的应用

3.4.1堆排序

3.4.2TOP-K问题

4.二叉树的链式结构和实现

4.1二叉树的递归实现

4.2二叉树的遍历

4.2.1前序遍历、中序遍历和后序遍历

4.2.2层序遍历

4.3二叉树其他函数接口

4.4二叉树的创建与销毁

4.4.1 二叉树的创建

4.4.2 二叉树的销毁

5.结语


(图像由AI生成) 

0.前言

在计算机科学的广阔天地里,数据结构扮演着构建信息框架的基石角色,它不仅关乎于数据如何在电子空间中被存储,更影响着数据被处理和访问的效率。今天,我们即将踏上一段探索之旅,深入了解一种在计算机科学中极为重要且被广泛应用的数据结构——二叉树。我们将探索二叉树的概念、结构以及它在解决实际问题中的应用,揭开它神秘而又实用的面纱。此次探索不仅将加深我们对数据结构的认识,还会让我们更加熟悉如何通过C语言来实现和操作这一高效的数据结构,为我们未来解决更加复杂的问题打下坚实的基础。

1.树的概念和结构

1.1树的概念

在计算机科学中,树是一种抽象数据类型(ADT),用来模拟具有层次关系的数据结构。它以分支的方式展现数据之间的关系,类似于自然界中的树木,不过在计算机科学里,这种结构是倒置的,根节点位于顶部,而叶节点则在底部。树由节点(Node)组成,每个节点包含数据部分以及指向其他节点的链接,这些链接代表父子关系。最顶部的节点称为根节点(Root Node),它没有父节点。树可以分解为更小的部分,称为子树(Subtrees),每个子树本身也是一个树结构。

1.2树的相关概念

  • 节点的度(Degree):节点的子节点数量。例如,如果一个节点有两个子节点,则该节点的度为2。
  • 叶子节点(Leaf Node):没有子节点的节点称为叶子节点,也就是度为0的节点。
  • 分支节点(Branch Node):至少有一个子节点的节点,也就是度不为0的节点。
  • 子节点(Child Node)和父节点(Parent Node):在树结构中,如果一个节点直接连接到另一个节点,并位于其下方,则前者是后者的子节点,后者是前者的父节点。
  • 兄弟节点(Sibling Nodes):拥有相同父节点的节点互称为兄弟节点。
  • 树的度(Degree of Tree):树中各节点度的最大值。例如,如果树中某个节点有三个子节点,而其他节点的子节点数都不超过三,则该树的度为3。
  • 树的高度(Height of Tree):从根节点到最远叶子节点的最长路径上的边数。在某些定义中,树的高度等于这条路径上的节点数减一。
  • 树的深度(Depth of Tree):节点到根节点的路径长度。根节点的深度通常定义为0。
  • 树的层(Level):根节点定义为第1层,其子节点为第2层,依此类推。

1.3树的表示

在计算机科学中,树的表示方式有很多,每种方式都有其适用的场景和特点。其中,一种常见的表示方法是左孩子右兄弟表示法(Left-Child Right-Sibling Representation)。这种表示方法的核心思想是将一个多叉树转换成二叉树,从而简化节点的管理和树的操作。

在左孩子右兄弟表示法中,每个节点包含三个基本部分:

  1. 数据域:存储节点的数据。
  2. 左孩子指针(Left Child Pointer):指向节点的最左边的子节点。
  3. 右兄弟指针(Right Sibling Pointer):指向和该节点有同一父节点的下一个兄弟节点。

使用这种表示法,树中的每个节点都转化为了二叉树中的一个节点,其中“左孩子”指向节点的第一个子节点,“右兄弟”指向该节点的兄弟节点。这种方法的一个优点是,不管原始树的分支有多少,转换后的二叉树都可以使用统一的方式来处理,这在某些应用中可以极大地简化编程的复杂度。

/* 定义树节点的结构体 */
typedef struct TreeNode {
    int data;                   // 节点存储的数据
    struct TreeNode *leftChild; // 指向最左边的子节点
    struct TreeNode *rightSibling; // 指向右边的兄弟节点
} TreeNode;

/* 创建新节点的函数 */
TreeNode* createNode(int data) {
    TreeNode* newNode = (TreeNode*)malloc(sizeof(TreeNode));
    if (newNode == NULL) {
        printf("Memory allocation failed.\n");
        exit(1); // 如果内存分配失败,则退出程序
    }
    newNode->data = data;
    newNode->leftChild = NULL;
    newNode->rightSibling = NULL;
    return newNode;
}

1.4树在实际中的应用

树结构在实际应用中非常广泛,一个典型的例子就是目录树的使用。几乎每个操作系统中都有文件系统,而文件系统的组织就是一个很好的目录树应用实例。

在目录树中,每个文件夹可以看作是一个节点,文件夹中的子文件夹和文件分别是其子节点。根目录位于树的顶部,表示为根节点。这种层级的方式使得文件的组织和查找变得非常高效。例如,当你在文件管理器中浏览文件时,实际上就是在遍历目录树的过程。

目录树不仅使得文件的存储结构更加清晰,也为文件的查找、添加、删除等操作提供了方便。通过目录树,用户可以很容易地理解文件系统的结构,而操作系统也可以高效地管理和维护庞大数量的文件。此外,许多应用程序也使用树形结构来管理内部的数据和状态,如XML文档、HTML文档、数据库索引等,这些都是树在实际中应用的例证。

2.二叉树的概念及结构

2.1概念

二叉树是一种特殊的树形数据结构,其中每个节点最多有两个子节点,通常称为左子节点和右子节点。这种结构使得二叉树在数据存储、组织和算法设计中非常高效,特别是在搜索、排序和视觉表现等方面。二叉树的定义简单而强大,为许多更复杂的数据结构和算法提供了基础,如二叉搜索树(BST)、平衡树(AVL树)、堆和哈希树(Merkle树)等。

2.2一些特殊的二叉树

在二叉树的众多变体中,满二叉树完全二叉树是两种特别重要的形式,它们在实际应用中具有独特的性质和优势。

  • 满二叉树(Full Binary Tree)
    满二叉树是一种每个节点都有0个或2个子节点的二叉树。这意味着,除了叶子节点外,每个节点都正好有两个子节点。在满二叉树中,没有一个节点只有一个子节点。满二叉树的一个重要性质是,如果树的高度为ℎ,则它正好有2^h-1个节点。这种性质使得满二叉树在某些算法设计中非常高效,如在构建哈夫曼编码树时。

  • 完全二叉树(Complete Binary Tree)
    完全二叉树是一种二叉树,其中每一层,除了可能的最后一层外,都被完全填满,并且所有节点都尽可能地集中在左侧。如果最后一层不满,那么这一层的节点从左到右紧密排列,没有空缺。完全二叉树的这一特性使得它特别适合数组存储,不会像一般的二叉树那样产生存储空间的浪费。这一特点在实现二叉堆等数据结构时尤其重要,因为可以通过数组轻松地表示和操作完全二叉树,从而实现高效的优先队列。

2.3二叉树的性质

二叉树作为一种基本的数据结构,拥有一系列重要的性质,这些性质对于理解二叉树的操作和应用至关重要。以下是一些关键性质:

2.3.1二叉树的基本性质

  1. 层级与节点数的关系
    若根节点的层数为1,则对于深度为k的任意二叉树,其最大节点数为2^k-1。这意味着每增加一层,节点的最大数量就翻倍。

  2. 叶子节点数与非叶子节点数的关系
    在非空的二叉树中,如果叶子节点的数量为L,度为2的节点数为D,则L=D+1。这一性质说明了在二叉树中,叶子节点总是比拥有两个子节点的节点多一个。

2.3.2完全二叉树的性质

对于有n个节点的完全二叉树,以下性质尤其重要:

  1. 节点的层次与序号关系
    如果我们按照从上到下、从左到右的顺序给树中的每个节点编号,从1到n,则对于任意节点i(1≤i≤n):

    • 该节点的父节点序号为⌊i/2⌋(如果i>1)。
    • 如果i≤n,则节点i的左子节点序号为2i;否则,节点i没有左子节点。
    • 如果2i+1≤n,则节点i的右子节点序号为2i+1;否则,节点i没有右子节点。
  2. 高度与节点数的关系
    一棵有n个节点的完全二叉树的高度为\lfloor \log_2 n \rfloor + 1。这个性质说明了完全二叉树的高度与节点数的对数成正比,这是完全二叉树在许多算法中(如二分搜索)效率高的原因之一。

  3. 最底层节点的分布
    在完全二叉树的最底层,节点从左到右紧密排列,这一特点在用数组存储树结构时尤其有用,因为它确保了没有空间的浪费。

  4. 完全二叉树与满二叉树
    每一层都完全填满的完全二叉树是满二叉树。这意味着,在完全二叉树中,只有最后一层可能没有完全填满,并且所有节点都集中在左侧。

2.4二叉树的存储结构

二叉树的存储方式主要分为两类:顺序存储和链式存储。这两种方法各有其特点和适用场景。

2.4.1顺序存储

顺序存储方式指使用数组来表示二叉树,每个元素代表树中的一个节点。这种方法特别适合于存储完全二叉树,因为它可以使数组中的每个位置都得到充分利用,从而避免空间浪费。

在顺序存储结构中,若将根节点放在数组的第0位置,那么对于数组中位置为i的任意节点(数组下标从0开始),其左右子节点和父节点的位置关系可以这样表示:

  • 左子节点的位置为2i+1(如果存在)。
  • 右子节点的位置为 2i+2(如果存在)。
  • 父节点的位置为 ⌊(i−1)/2⌋(如果i不是根节点)。

顺序存储的优势在于可以快速访问到任意节点,尤其是在进行层次遍历时。但其主要缺点是,对于非完全二叉树,这种存储方式会造成数组空间的浪费。

2.4.2链式存储

链式存储方式通过节点间的指针连接来表示二叉树的结构。每个节点包含数据部分及两个指针,分别指向左子节点和右子节点。

链式存储的节点结构通常定义如下:

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

链式存储的优点是存储结构灵活,能有效地表示各种形态的二叉树,包括非完全二叉树,而且能够高效地进行节点的插入和删除操作。其主要缺点是相对于顺序存储,访问节点可能需要更多的时间,因为需要通过指针进行跟踪定位。

3.二叉树的顺序结构和实现

3.1二叉树的顺序结构

二叉树的顺序结构指的是使用数组来表示二叉树,其中数组的每个位置都对应树中的一个节点。这种表示方式尤其适合完全二叉树,因为它可以充分利用数组空间,避免浪费。在顺序结构中,节点的位置与它们在树中的关系紧密相关,遵循以下规则:

  • 对于数组中的任意位置i的节点,其左子节点位于2i+1(如果i从0开始计数),右子节点位于2i+2,父节点位于⌊(i−1)/2⌋。
  • 这种存储方式使得访问节点的父节点或子节点变得非常快速,仅需简单的计算即可。
  • 顺序结构在进行树的遍历、查找等操作时特别高效,尤其是对完全二叉树的层次遍历。

3.2堆的概念和结构

堆(Heap)是一种特殊的完全二叉树,它满足某种特定顺序的要求,主要有两种类型:最大堆和最小堆。

  • 最大堆:在最大堆中,每个节点的值都大于或等于其子节点的值。这意味着根节点是所有节点中的最大值。
  • 最小堆:在最小堆中,每个节点的值都小于或等于其子节点的值,这意味着根节点是所有节点中的最小值。

堆的结构保证了根节点总是存储最大(或最小)值,这一特性使得堆成为实现优先队列的理想选择。优先队列是一种特殊的队列,其中每个元素都有一定的优先级,优先级最高的元素总是第一个被删除。

堆的结构

  • 堆通常使用数组来顺序存储,利用完全二叉树的性质,有效地利用空间,快速定位到任意节点的父节点或子节点。
  • 堆的基本操作包括插入(上浮)和删除(下沉)操作,这些操作都需要保持堆的特性。插入一个新元素时,通常先添加到数组的末尾,然后通过上浮调整其位置;删除元素通常指删除根节点,然后通过下沉调整新的根节点,保持堆的性质。

3.3堆的实现

堆是一种特殊的完全二叉树,它可以作为最大堆或最小堆来组织数据。最大堆中任意节点的值都不小于其子节点的值,而在最小堆中任意节点的值都不大于其子节点的值。堆的实现主要涉及到两个基本操作:向下调整和堆的创建。

3.3.1向下调整算法

向下调整是在堆中调整节点以保持堆结构和性质的过程。以下是最小堆的向下调整算法的非递归实现,这个过程确保节点的值不大于其子节点的值。

void Swap(int* a, int* b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

void AdjustDown(int* a, int n, int root) {
    int parent = root; // 初始化为需要调整的节点
    int child = 2 * parent + 1; // 初始化为parent的左子节点
    while (child < n) {
        // 在两个子节点中找到较小的一个
        if (child + 1 < n && a[child + 1] < a[child]) {
            child++;
        }
        // 如果子节点小于父节点,交换它们
        if (a[child] < a[parent]) {
            Swap(&a[parent], &a[child]);
            // 移动到下一层继续调整
            parent = child;
            child = 2 * parent + 1;
        } else {
            // 如果父节点已经是最小的,结束调整
            break;
        }
    }
}

3.3.2堆的创建

创建堆的过程涉及将一个不满足堆性质的完全二叉树调整为堆。一种有效的方法是从最后一个非叶子节点开始,向前逐个应用AdjustDown方法。

void HeapInit(Heap* php, HPDataType* a, int n)
{
	php->_a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if(php->_a == NULL)
	{
		assert(0);
		return;
	}
	memcpy(php->_a, a, sizeof(HPDataType) * n);
	php->_size = n;
	php->_capacity = n;
	//建堆:从最后一个非叶子节点开始向下调整
	for (int i = (n - 2) / 2; i >= 0; i--)
	{
		AdjustDown(php->_a, php->_size, i);
	}
}

3.3.3建堆的时间复杂度分析

对于一个含有n个节点的完全二叉树,建堆的过程开始于最后一个非叶子节点,逆序地对每个节点执行向下调整操作。最后一个非叶子节点的索引为⌊n/2⌋−1。

  • 对于树的最底层的非叶子节点,向下调整的最大深度为1。
  • 对于倒数第二层的非叶子节点,向下调整的最大深度为2。
  • 以此类推,对于根节点(如果需要调整的话),向下调整的最大深度为​\left \lfloor log_2 n \right \rfloor(这里假设树的高度为log_2 n,即对于完全二叉树,高度与节点数的对数成正比)。

在每一层向下调整的过程中,操作的成本与当前节点的深度成正比。但是,树中每一层的节点数是按照指数级递减的。具体来说,最底层最多有⌊n/2⌋个节点,倒数第二层最多有⌊n/4⌋个节点,以此类推,直到根节点。

这意味着,虽然树顶部的节点(接近根节点的节点)在向下调整时可能需要更多步骤,但是这样的节点数量相对较少。相反,树的底部有更多的节点,但每个节点需要的调整步骤较少。这个权衡导致了建堆操作的总成本低于直觉上的预期。

通过对这些操作进行综合分析,可以得到建堆过程的总时间复杂度为O(n)。这个结论可能有些意外,因为初看起来,对每个节点执行向下调整操作的时间复杂度似乎是O(logn),并且有大约n/2个这样的操作,直觉上总的时间复杂度应该是O(nlogn)。然而,由于在树的不同层次上节点数量的指数级差异,以及向下调整操作的成本随树深度增加而线性增长,实际上总的时间复杂度是O(n)。

3.3.4堆的插入和删除

堆(特别是二叉堆)的插入和删除操作是维护堆性质(最大堆或最小堆)的关键。以下介绍如何在二叉堆中执行插入和删除堆顶元素的操作,并分析这些操作的实现。

插入操作(HeapPush)

插入操作的目标是将一个新元素添加到堆中,同时保持堆的性质不变。步骤如下:

  1. 检查容量:首先检查堆的当前大小是否达到了其容量上限,如果是,则扩大堆的存储数组,通常是将容量加倍。
  2. 添加元素:将新元素添加到数组的末尾,这是最快的添加方法,但可能会破坏堆的性质。
  3. 向上调整(AdjustUp):从添加的元素开始,向上调整堆,直到父节点的值不再小于(对于最大堆)或不再大于(对于最小堆)这个新插入的节点。
void HeapPush(Heap* php, HPDataType x) {
    assert(php);
    if (php->_size == php->_capacity) {
        php->_capacity *= 2;
        HPDataType* tmp = (HPDataType*)realloc(php->_a, sizeof(HPDataType) * php->_capacity);
        if (tmp == NULL) {
            assert(0);
            return;
        }
        php->_a = tmp;
    }
    php->_a[php->_size++] = x;
    AdjustUp(php->_a, php->_size, php->_size - 1);
}

删除操作(HeapPop)

删除操作通常指删除堆顶元素,即数组的第一个元素,因为在最大堆中这是最大的元素,在最小堆中这是最小的元素。步骤如下:

  1. 交换元素:将堆顶元素与数组中的最后一个元素交换。这步操作将要删除的元素移动到数组的末尾。
  2. 减小堆大小:递减堆的大小,从而逻辑上删除了最后一个元素(之前的堆顶元素)。
  3. 向下调整(AdjustDown):从新的堆顶元素开始,向下调整堆,恢复堆的性质。
void HeapPop(Heap* php) {
    assert(php);
    assert(php->_size > 0);
    Swap(&php->_a[0], &php->_a[php->_size - 1]);
    php->_size--;
    AdjustDown(php->_a, php->_size, 0);
}

3.3.5堆的代码实现

//heap.h
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<string.h>
typedef int HPDataType;
typedef struct Heap
{
	HPDataType* _a;
	int _size;
	int _capacity;
}Heap;

void Swap(HPDataType* a, HPDataType* b);//交换
void AdjustDown(HPDataType* a, int n, int root);//向下调整
void AdjustUp(HPDataType* a, int n, int child);//向上调整
void HeapInit(Heap* php, HPDataType* a, int n);//初始化
void HeapDestory(Heap* php);//销毁
void HeapPush(Heap* php, HPDataType x);//插入
void HeapPop(Heap* php);//删除
HPDataType HeapTop(Heap* php);//取堆顶元素

//heap.c
#define _CRT_SECURE_NO_WARNINGS 1
#include"heap.h"
void Swap(HPDataType* a, HPDataType* b)
{
	HPDataType tmp = *a;
	*a = *b;
	*b = tmp;
}
//前提:除了root之外,其他位置已经满足小堆的性质
void AdjustDown(HPDataType* a, int n, int root)//向下调整
{
	int parent = root;
	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 AdjustUp(HPDataType* a, int n, 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;
	}
}
void HeapInit(Heap* php, HPDataType* a, int n)
{
	php->_a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if(php->_a == NULL)
	{
		assert(0);
		return;
	}
	memcpy(php->_a, a, sizeof(HPDataType) * n);
	php->_size = n;
	php->_capacity = n;
	//建堆:从最后一个非叶子节点开始向下调整
	for (int i = (n - 2) / 2; i >= 0; i--)
	{
		AdjustDown(php->_a, php->_size, i);
	}
}
void HeapDestory(Heap* php)//销毁
{
	assert(php);
	free(php->_a);
	php->_a = NULL;
	php->_size = php->_capacity = 0;
}
void HeapPush(Heap* php, HPDataType x)//插入
{
	assert(php);
	if (php->_size == php->_capacity)
	{
		php->_capacity *= 2;
		HPDataType* tmp = (HPDataType*)realloc(php->_a, sizeof(HPDataType) * php->_capacity);
		if (tmp == NULL)
		{
			assert(0);
			return;
		}
		php->_a = tmp;
	}
	php->_a[php->_size++] = x;
	AdjustUp(php->_a, php->_size, php->_size - 1);
}
void HeapPop(Heap* php)//删除堆顶元素
{
	assert(php);
	assert(php->_size > 0);
	Swap(&php->_a[0], &php->_a[php->_size - 1]);
	php->_size--;
	AdjustDown(php->_a, php->_size, 0);
}
HPDataType HeapTop(Heap* php)//取堆顶元素
{
	assert(php);
	assert(php->_size > 0);
	return php->_a[0];
}

3.4堆的应用

3.4.1堆排序

堆排序是一种利用堆数据结构所设计的排序算法。它可以视为改进的选择排序:利用最大堆(或最小堆)快速找到最大(或最小)的元素,并通过堆的调整过程保持排序进度。堆排序可以分为两个主要阶段:

  1. 建立堆:将待排序的数组构造成一个最大堆(为了得到升序结果)。这一步骤确保了堆的根节点是当前未排序部分的最大值。
  2. 堆调整排序:不断地移除堆顶元素(最大值),并将其放到数组的尾部,然后对剩下的元素重新调整为最大堆,直到所有元素都被排序。
void heapSort(int* arr, int n) {
    // 第1步:从无序数组建立最大堆
    for (int i = n / 2 - 1; i >= 0; i--) {
        AdjustDown(arr, n, i); // 对每个非叶子节点执行向下调整,建立最大堆
    }
    
    // 第2步:逐一取出堆顶元素并调整堆
    for (int i = n - 1; i > 0; i--) {
        Swap(&arr[0], &arr[i]); // 将当前根(最大值)移动到数组末尾
        AdjustDown(arr, i, 0);  // 对剩余的堆执行向下调整,恢复最大堆性质
    }
}

在此代码中,AdjustDown是之前定义的向下调整函数,用于维持最大堆的性质;Swap是用于交换两个元素位置的函数。

时间复杂度分析

  • 建堆阶段的时间复杂度为O(n),这是因为向下调整每个节点来建立堆的过程,虽然最坏情况下每个调整的时间复杂度为O(logn),但由于树的层级结构导致底层节点多而顶层节点少,平均下来建堆的时间复杂度为线性。
  • 排序阶段的时间复杂度为O(nlogn),每次调整堆的时间复杂度为O(logn),总共需要进行n−1次调整。

因此,堆排序的总时间复杂度为O(nlogn)。与快速排序和归并排序相比,堆排序的最大优点是,它不需要额外的存储空间(是原地排序算法),而且在最坏的情况下仍然能保持O(nlogn)的时间复杂度,使其在特定场景下非常有用。

3.4.2TOP-K问题

TOP-K问题是指从一个大数据集中找出最大或最小的K个元素的问题。这个问题在很多领域都非常常见,比如找出最热门的商品、最活跃的用户、成绩最好的学生等。堆(特别是最小堆和最大堆)是解决TOP-K问题一个非常高效的工具。

使用最小堆解决TOP-K问题

当我们想要找出所有元素中最大的K个元素时,可以使用最小堆来解决。算法步骤如下:

  1. 初始化堆:先将数据集中的前K个元素建立一个最小堆。这个最小堆的堆顶元素是这K个元素中最小的,也是当前最大的K个元素中最小的。
  2. 遍历剩余数据:遍历数据集中剩余的元素。对于每个元素,比较它与最小堆的堆顶元素(即当前最大K个元素中最小的那个)。
  3. 调整堆:如果当前元素比堆顶元素大,说明它应该属于最大的K个元素之一。此时,将堆顶元素替换为当前元素,并对堆进行调整,保持最小堆的性质。如果当前元素不比堆顶元素大,则不做任何操作,继续遍历下一个元素。
  4. 得到结果:遍历完所有元素后,最小堆中的K个元素就是整个数据集中最大的K个元素。

示例代码

以找出最大的K个元素为例,以下是一个使用最小堆解决TOP-K问题的示例代码片段(使用C语言标准库中的堆操作函数):

// 使用已提供的接口解决TOP-K问题

void findTopK(HPDataType* nums, int numsSize, int k) {
    Heap heap;
    // 初始化堆
    HeapInit(&heap, nums, k);

    // 遍历数组中剩余的元素
    for (int i = k; i < numsSize; i++) {
        // 如果当前元素比堆顶元素大,则替换并调整堆
        if (nums[i] > HeapTop(&heap)) {
            HeapPop(&heap);          // 移除堆顶元素
            HeapPush(&heap, nums[i]);// 插入新元素
        }
    }

    // 输出最大的K个元素
    for (int i = 0; i < k; i++) {
        printf("%d ", heap._a[i]);
    }

    // 销毁堆,释放资源
    HeapDestory(&heap);
}

4.二叉树的链式结构和实现

4.1二叉树的递归实现

二叉树的链式结构通常通过每个节点包含数据和指向左右子节点的指针来实现。这种结构使得使用递归方法来操作二叉树变得非常自然和高效,包括创建、遍历、搜索、添加和删除节点等操作。

定义二叉树节点

首先,我们需要定义二叉树节点的数据结构:

typedef struct TreeNode {
    int data;                   // 节点存储的数据
    struct TreeNode* left;      // 指向左子节点的指针
    struct TreeNode* right;     // 指向右子节点的指针
} TreeNode;

创建二叉树

创建二叉树的一个简单方式是通过递归函数来实现。假设我们有一系列值,我们可以将它们逐一插入到二叉树中:

TreeNode* createTreeNode(int data) {
    TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
    if (node == NULL) {
        // 处理内存分配失败的情况
        return NULL;
    }
    node->data = data;
    node->left = NULL;
    node->right = NULL;
    return node;
}

TreeNode* insertTreeNode(TreeNode* root, int data) {
    // 如果当前节点为空,创建一个新节点并返回
    if (root == NULL) {
        return createTreeNode(data);
    }

    // 递归地插入到左子树或右子树
    if (data < root->data) {
        root->left = insertTreeNode(root->left, data);
    } else {
        root->right = insertTreeNode(root->right, data);
    }

    return root;
}

4.2二叉树的遍历

遍历二叉树是指按照某种顺序访问树中的每个节点,确保每个节点被访问一次。根据访问节点的顺序不同,二叉树遍历可以分为四种主要类型:前序遍历、中序遍历、后序遍历和层序遍历。

4.2.1前序遍历、中序遍历和后序遍历

这三种遍历方法都是深度优先遍历的变体,即它们按照深度的优先顺序遍历树的节点。

  • 前序遍历:首先访问根节点,然后递归地进行左子树的前序遍历,最后递归地进行右子树的前序遍历。顺序为:根节点 -> 左子树 -> 右子树。

void preorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    printf("%d ", root->data);       // 访问根节点
    preorderTraversal(root->left);   // 递归遍历左子树
    preorderTraversal(root->right);  // 递归遍历右子树
}
  • 中序遍历:首先递归地进行左子树的中序遍历,然后访问根节点,最后递归地进行右子树的中序遍历。这种遍历方式对于二叉搜索树来说特别有用,因为它会按照升序访问所有节点。顺序为:左子树 -> 根节点 -> 右子树。

void inorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    inorderTraversal(root->left);    // 递归遍历左子树
    printf("%d ", root->data);       // 访问根节点
    inorderTraversal(root->right);   // 递归遍历右子树
}
  • 后序遍历:首先递归地进行左子树的后序遍历,然后递归地进行右子树的后序遍历,最后访问根节点。这种遍历方式常用于先处理子节点再处理父节点的场景,如计算一个目录下所有文件所占空间的大小。顺序为:左子树 -> 右子树 -> 根节点。

void postorderTraversal(TreeNode* root) {
    if (root == NULL) return;
    postorderTraversal(root->left);  // 递归遍历左子树
    postorderTraversal(root->right); // 递归遍历右子树
    printf("%d ", root->data);       // 访问根节点
}

4.2.2层序遍历

层序遍历是宽度优先遍历的一种,即按照树的层次从上到下、从左到右遍历树的所有节点。这种遍历方式需要用到队列这种数据结构来实现。

typedef struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;

void levelOrderTraversal(TreeNode* root) {
    if (root == NULL) return;

    TreeNode* queue[1000]; // 假设树的节点数不超过1000
    int front = 0, rear = 0;
    
    // 根节点入队
    queue[rear++] = root;

    while (front < rear) {
        TreeNode* node = queue[front++]; // 节点出队

        printf("%d ", node->data); // 访问节点
        
        // 如果左子节点存在,左子节点入队
        if (node->left != NULL) {
            queue[rear++] = node->left;
        }
        // 如果右子节点存在,右子节点入队
        if (node->right != NULL) {
            queue[rear++] = node->right;
        }
    }
}

在层序遍历中,首先根节点入队。然后,在队列不为空的条件下,不断从队列中取出一个节点访问,接着将其左右子节点(如果存在)依次入队。这个过程一直持续到队列为空,即所有节点都被访问完毕。

4.3二叉树其他函数接口

二叉树是一种重要的数据结构,不仅支持高效的数据搜索、插入和删除操作,还可以通过递归或迭代的方式实现各种复杂的树操作。以下是一些在二叉树上常见的操作的详细介绍和实现。

求二叉树节点个数

计算二叉树中节点的总数是基于这样一个观察:一个树的总节点数等于其根节点数(1个)加上左子树的节点数加上右子树的节点数。这个过程自然适合递归实现,因为每个非叶节点都可以看作是子树的根节点。

// 计算二叉树的节点总数
int getSize(TreeNode* root) {
    // 基本情况:空树的节点数为0
    if (root == NULL) return 0;
    // 递归计算左右子树的节点数,并加1(根节点自身)
    return 1 + getSize(root->left) + getSize(root->right);
}

求二叉树叶子节点个数

二叉树的叶子节点是指没有子节点的节点。计算叶子节点的数量可以通过检查每个节点是否有子节点来完成。如果一个节点既没有左子节点也没有右子节点,那么它就是一个叶子节点。

// 计算二叉树的叶子节点数
int getLeafSize(TreeNode* root) {
    // 基本情况:空树没有叶子节点
    if (root == NULL) return 0;
    // 如果节点是叶子节点,返回1
    if (root->left == NULL && root->right == NULL) return 1;
    // 否则,递归计算左右子树的叶子节点数
    return getLeafSize(root->left) + getLeafSize(root->right);
}

求二叉树第K层节点个数

二叉树的第K层节点数可以通过递归地计算每个子树的第K-1层节点数来实现。当到达第K层时(基于递归的深度),每次递归返回1,代表找到了一个第K层的节点。

// 计算二叉树第K层的节点数
int getKLevelSize(TreeNode* root, int k) {
    // 基本情况:空树没有节点
    if (root == NULL || k < 1) return 0;
    // 当k为1时,当前节点就是第K层的一个节点
    if (k == 1) return 1;
    // 递归计算左右子树的第K-1层的节点数
    return getKLevelSize(root->left, k - 1) + getKLevelSize(root->right, k - 1);
}

在二叉树中查找值为x的节点

查找二叉树中值为x的节点可以通过递归搜索整个树来完成。如果当前节点的值匹配x,返回当前节点。如果没有找到,递归地在左子树和右子树中查找。

// 在二叉树中查找值为x的节点
TreeNode* find(TreeNode* root, int x) {
    // 基本情况:如果树为空,返回NULL
    if (root == NULL) return NULL;
    // 如果当前节点的值等于x,返回当前节点
    if (root->data == x) return root;
    // 否则,先在左子树中查找
    TreeNode* leftResult = find(root->left, x);
    if (leftResult != NULL) return leftResult;
    // 如果左子树中没有找到,再在右子树中查找
    return find(root->right, x);
}

4.4二叉树的创建与销毁

在二叉树的操作中,创建和销毁是基础而重要的环节,它们确保了二叉树的正确初始化和资源的合理释放,避免了内存泄漏等问题。

4.4.1 二叉树的创建

创建二叉树通常指的是根据一定的数据构建出二叉树的过程。这个过程可以根据实际需求有很多变化,比如根据数组创建二叉搜索树、根据层序遍历的结果创建普通二叉树等。这里以最简单的形式展示如何动态创建一个二叉树节点。

创建节点

typedef struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;

TreeNode* createTreeNode(int data) {
    // 为新节点分配内存
    TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
    if (node == NULL) {
        // 如果内存分配失败,返回NULL
        return NULL;
    }
    // 初始化节点的数据和指针
    node->data = data;
    node->left = NULL;
    node->right = NULL;
    return node;
}

实现原理:创建二叉树的节点涉及到内存的动态分配。每个节点包含数据以及指向左右子节点的指针。通过malloc函数为新节点分配内存,然后初始化节点的数据和左右指针为NULL,最后返回新创建的节点的指针。

4.4.2 二叉树的销毁

销毁二叉树指的是释放构成二叉树的所有节点所占用的内存,确保不会出现内存泄漏。

递归销毁二叉树

void destroyTree(TreeNode* root) {
    if (root == NULL) {
        // 如果当前节点为空,则没有什么可以做的,直接返回
        return;
    }
    // 递归销毁左子树
    destroyTree(root->left);
    // 递归销毁右子树
    destroyTree(root->right);
    // 释放当前节点占用的内存
    free(root);
}

实现原理:销毁二叉树的过程是后序遍历的一种应用。首先递归地销毁左子树和右子树,然后释放当前节点占用的内存。这种顺序确保了在释放当前节点之前,其子节点已被正确释放,避免了野指针和内存泄漏的问题。

5.结语

在本博客中,我们深入探索了C语言中的二叉树,覆盖了其基础概念、存储结构、以及如何通过代码实现二叉树的各种操作。我们学习了二叉树的遍历方法、堆的应用以及如何解决TOP-K问题。同时,我们也了解了二叉树的创建与销毁,强调了正确管理内存的重要性。通过这些知识,我们不仅加深了对二叉树结构的理解,还掌握了将理论应用到实践中解决问题的能力。希望这些内容能够帮助你在数据结构与算法的学习道路上更进一步。

  • 16
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值