深入理解二叉树:遍历、存储与算法实现

发布于:2025-05-16 ⋅ 阅读:(19) ⋅ 点赞:(0)

在之前的博客系列中,我们系统地探讨了多种线性表数据结构,包括顺序表、栈和队列等经典结构,并通过代码实现了它们的核心功能。从今天开始,我们将开启一个全新的数据结构篇章——树结构。与之前讨论的线性结构不同,树形结构以其独特的层次性和分支特性,为我们打开了算法世界的新大门。

1.树

1.1树的概念与结构

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

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

因此,树是递归定义的。

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

树形结构中,子树之间不能有交集,否则就不是树形结构。
• 子树是不相交的(如果存在相交就是图了)
•除了根结点外,每个结点有且仅有⼀个父结点
• ⼀棵N个结点的树有N-1条边

我们来看一些示例,它们都是一些非树型结构:
非树型

1.2树相关术语

与线性结构的直观简洁不同,树形结构展现出了更为丰富的层次关系和分支特性。为了准确描述和分析这种非线性的数据结构,我们引入了一系列专业术语:根节点、父节点、子节点、叶子节点、度、深度等。这些术语不仅帮助我们精确描述树的组成,也为后续讨论树的遍历和操作奠定了重要基础。下面我们来一一介绍,看下图:
在这里插入图片描述
父结点/双亲结点:若一个结点含有子结点,则这个结点称为其子结点的父结点; 如上图:A是B的父结点
子结点/孩子点:⼀个结点含有的子树的根结点称为该结点的子结点; 如上图:B是A的孩子结点
结点的度:⼀个结点有几个孩⼦,他的度就是多少;比如A的度为6,F的度为2,K的度为0
树的度:⼀棵树中,最大的结点的度称为树的度; 如上图:树的度为 6
叶子结点/终端结点:度为 0 的结点称为叶结点; 如上图: B、C、H、I… 等结点为叶结点
分支结点/非终端结点:度不为 0 的结点; 如上图: D、E、F、G… 等结点为分支结点
兄弟结点:具有相同父结点的结点互称为兄弟结点(亲兄弟); 如上图: B、C 是兄弟结点
结点的层次:从根开始定义起,根为第 1 层,根的⼦结点为第 2 层,以此类推;
树的高度或深度:树中结点的最大层次; 如上图:树的⾼度为 4
结点的祖先:从根到该结点所经分支上的所有结点;如上图: A 是所有结点的祖先
路径:⼀条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列;比如A到Q的路径为:A-E-J-Q;H到Q的路径H-D-A-E-J-Q
子孙:以某结点为根的子树中任⼀结点都称为该结点的子孙。如上图:所有结点都是A的⼦孙
森林:由 m(m>0) 棵互不相交的树的集合称为森林

理解这些树结构的基本概念不在于死记硬背,而在于培养实际应用能力。我们需要能够具备在树型结构中准确识别各个节点的父子关系,计算节点的度(子节点数量),区分内部节点和叶子节点等等能力,这些实操能力将为后续的代码实现打下坚实的基础。

1.3树的表示

掌握了树结构的基本概念和术语后,我们现在需要解决一个关键问题:如何在程序中有效地表示和存储树形结构?在计算机科学中,树的表示方法多种多样,每种方法都有其适用的场景和优缺点。常见的表示方式包括:双亲表示法(父指针表示法),孩子表示法,孩子兄弟表示法(二叉链表表示法)
我们这里简单介绍一下孩子兄弟表示法:

struct TreeNode
{
struct Node* child; // 左边开始的第⼀个孩⼦结点
struct Node* brother; // 指向其右边的下⼀个兄弟结点
int data; // 结点中的数据域
};

孩子兄弟表示法示意图

1.4树形结构实际运用场景

文件系统是计算机存储和管理文件的一种⽅式,它利用树形结构来组织和管理文件和文件夹。在文件系统中,树结构被广泛应用,它通过父结点和子结点之间的关系来表示不同层级的文件和文件夹之间的关联。
在这里插入图片描述

2.二叉树

2.1概念与结构

在树形结构中,我们最常用的就是⼆叉树,⼀棵二叉树是结点的⼀个有限集合,该集合由⼀个根结点加上两棵别称为左子树和右子树的⼆叉树组成或者为空。
在这里插入图片描述
从上图可以看出二叉树具备以下特点:

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

注意:对于任意的二叉树都是由以下几种情况复合而成的:
在这里插入图片描述

2.2特殊的二叉树

2.2.1满二叉树

⼀个二叉树,如果每⼀个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果⼀个二叉树的层数为 K ,且结点总数是 2k − 1 ,则它就是满二叉树。
在这里插入图片描述

2.2.2完全二叉树!

完全二叉树(Complete Binary Tree)是一种特殊的二叉树,它的定义是基于满二叉树的概念。在完全二叉树中,除了最后一层,其他各层的节点数都达到了最大个数,而最后一层的节点则都连续集中在最左边。这种结构的二叉树,其深度为h时,除了第h层外,其余各层(1~h-1)的节点数都是满的,第h层的节点从左到右连续排列,但不一定是满的。

在这里插入图片描述

⼆叉树性质
根据满⼆叉树的特点可知:
1)若规定根结点的层数为 1 ,则⼀棵非空⼆叉树的第i层上最多有 2i−1 个结点
2)若规定根结点的层数为 1 ,则深度为 h 的⼆叉树的最⼤结点数是 2h − 1
3)若规定根结点的层数为 1 ,具有 n 个结点的满⼆叉树的深度 h = log2 (n + 1)( log以2为底, n+1 为对数)

2.3二叉树存储结构

二叉树⼀般使用两种结构存储,⼀种顺序结构,⼀种链式结构。

2.3.1顺序存储

顺序结构存储就是使用数组来存储,⼀般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。
在这里插入图片描述
现实中我们通常把堆(⼀种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,⼀个是数据结构,⼀个是操作系统中管理内存的⼀块区域分段。

2.3.2链式存储

二叉树的链式存储结构通过链表形式动态表示结点间的逻辑关系,其核心设计思想如下:

  1. 结点结构设计

    • 每个链式结点由三个域构成:
      • 数据域:存储当前结点的元素值
      • 左指针域:指向左子结点的内存地址
      • 右指针域:指向右子结点的内存地址
    • 通过指针的动态链接实现树形拓扑结构,形成"结点-子树"的递归关系
  2. 链式类型划分

    • 二叉链
      • 仅含左右子结点指针
      • 空间效率高,满足基础算法实现需求
    • 三叉链
      • 增加父结点指针域
      • 便于逆向溯源,应用于红黑树等复杂数据结构
        在这里插入图片描述

3.实现顺序结构二叉树

3.1堆的概念与结构

堆通常采用顺序存储结构(如数组)实现,其在逻辑上表现为一棵完全二叉树,同时满足关键有序性:每个结点的值总保持大于等于(大顶堆)或小于等于(小顶堆)其子节点的值。这种双重要求既继承了完全二叉树的层级结构特性,又通过父子结点间的有序性约束,赋予堆快速获取极值、动态调整高效的核心优势,成为优先队列等场景的理想数据结构载体。
在这里插入图片描述

对于具有 n 个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有结点从0 开始编号,则对于序号为 i 的结点有:

  1. 若 i>0 , i 位置结点的双亲序号: (i-1)/2 ; i=0 , i 为根结点编号,无双亲结点
  2. 若 2i+1<n ,左孩⼦序号: 2i+1 , 2i+1>=n 否则无左孩子
  3. 若 2i+2<n ,右孩⼦序号: 2i+2 , 2i+2>=n 否则无右孩子

3.2堆的实现及堆排序算法

该代码实现了小根堆(Min-Heap)的所有基本操作,具备插入、删除、调整等核心功能。

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

typedef int HPDataType;

// 堆结构定义(数组存储)
typedef struct Heap
{
    HPDataType* arr;    // 动态数组指针
    int size;           // 当前元素个数
    int capacity;       // 数组容量
}HP;

/* 堆基础操作 */
void HPInit(HP* php)    // 初始化空堆
{
    assert(php);
    php->arr = NULL;
    php->capacity = php->size = 0;
}

// 元素交换函数
void Swap(int* a, int* b)
{
    int temp = *a;
    *a = *b;
    *b = temp;
}

/* 核心调整算法 */
// 向上调整(插入时使用)时间复杂度O(logN)
void AdjustUp(HPDataType* a, int child)
{
    assert(a);
    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 HPPush(HP* php, HPDataType x)
{
    assert(php);
    // 容量检查(2倍扩容策略)
    if (php->capacity == php->size)
    {
        int newcapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
        HPDataType* temp = (HPDataType*)realloc(php->arr, 
                            newcapacity * sizeof(HPDataType));
        if (temp == NULL)
        {
            perror("realloc fail");
            exit(1);
        }
        php->arr = temp;
        php->capacity = newcapacity;
    }
    // 插入尾部并调整
    php->arr[php->size++] = x;
    AdjustUp(php->arr, php->size-1);  // 从新插入位置开始调整
}

// 判断堆是否为空
bool HPEmpty(HP* php)
{
    assert(php);
    return php->size == 0;
}

// 向下调整(删除堆顶时使用)时间复杂度O(logN)
void AdjustDown(HPDataType* a, int n, int parent)
{
    assert(a);
    int child = parent * 2 + 1;  // 左孩子索引
    
    while (child < n)  // 在有效范围内调整
    {
        // 选择较小的子节点(小堆特性,大堆则选较大值)
        if (child + 1 < n && a[child] > a[child + 1])
            child++;
        
        // 小堆条件判断(父节点 > 子节点时交换)
        if (a[child] < a[parent]) 
        {
            Swap(&a[child], &a[parent]);
            parent = child;          // 向下移动指针
            child = parent * 2 + 1;  // 更新左孩子索引
        }
        else
            break;  // 满足堆条件时退出
    }
}

// 删除堆顶元素
void HPPop(HP* php)
{
    assert(php);
    assert(!HPEmpty(php));
    
    // 交换首尾元素并删除尾部
    Swap(&php->arr[0], &php->arr[php->size - 1]);
    php->size--;
    
    // 从根节点开始向下调整
    AdjustDown(php->arr, php->size, 0);
}

/* 辅助功能 */
int HPSize(HP* php)  // 获取当前元素数量
{
    assert(php);
    return php->size;
}

void HPDestroy(HP* php)  // 销毁堆释放内存
{
    assert(php);
    if (php->arr)
        free(php->arr);
    php->arr = NULL;
    php->capacity = php->size = 0;
}

HPDataType HPTop(HP* php)  // 获取堆顶元素
{
    assert(php && php->size > 0);
    return php->arr[0];
}

/* 建堆方法*/
void HPInitArray(HP* php, HPDataType* a, int n)
{
    for (int i = 0; i < n; i++)
    {
        HPPush(php, a[i]);  // 逐个插入方式建堆
    }
}

在这里我们着重介绍一下向上调整算法(AdjustUp)向下调整算法(AdjustDown)这两个核心函数。

3.2.1向上调整算法

向上调整算法用于在插入新元素后,维护堆的有序性。
当向堆的尾部插入一个新元素时,可能破坏堆的父子节点大小关系(如小根堆中父节点应 ≤ 子节点),该算法通过逐层向上比较与交换,确保堆结构恢复有效状态。
示例:
在这里插入图片描述
如果我们要用向上调整算法来建立一个堆并将它排序,代码非常简单,如下:

//向上调整算法建堆时间复杂度为: O(n ∗ log2 n)
void HeapSort(int* a, int n)
{
	// a数组直接建堆 O(N)
	for (int i = 0; i < n; i++)
	{
		AdjustUp(a, n, i);
	}
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}
}

注意:数据降序需用小堆,升序则用大堆
究其原因是小堆每次调整完将最小值放在数组最后,大堆调整完将最大值放在最后。

3.2.2向下调整算法

删除堆是删除堆顶的数据,将堆顶的数据根最后⼀个数据⼀换,然后删除数组最后⼀个数据,再进行向下调整算法。
在这里插入图片描述

向下调整算法有⼀个前提:左右子树必须是⼀个堆,才能调整。
• 将堆顶元素与堆中最后⼀个元素进行交换
• 删除堆中最后⼀个元素
• 将堆顶元素向下调整到满足堆特性为止

在这里插入图片描述
同样的,用向下调整算法来建立一个堆并将它排序,代码也非常简单,如下:

//堆排序整体算法复杂度为O(N*logN)
//向下调整算法建堆时间复杂度为: O(n)
void HeapSort(int* a, int n)
{
	// a数组直接建堆 O(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;
	}
}

因为向下调整算法建堆的时间复杂度要小于向上调整算法,所以在堆排序实际应用中我们大多采用向下调整算法建堆,并且向上调整算法并不适合后续的排序算法,所以整体来说向下调整算法更加优秀。

3.3.TOK-K问题

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

最佳的方式就是用堆来解决,基本思路如下:
1)用数据集合中前K个元素来建堆,求前k个最大的元素,则建小堆,求前k个最小的元素,则建大堆
2)用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素

代码实现:

void CreateNDate()
{
	// 造数据
	int n = 100000;
	srand(time(0));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}
	for (int i = 0; i < n; ++i)
	{
		int x = (rand() + i) % 1000000;
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);
}


void TOPk()
{
	int k = 0;
	printf("请输入k:");
	scanf("%d", &k);

	const char* file = "data.txt";
	FILE* fout = fopen(file, "r");
	if (fout == NULL)
	{
		perror("fopen fail!");
		exit(1);
	}
	int* minHeap = (int*)malloc(k * sizeof(int));
	if (minHeap == NULL)
	{
		perror("malloc fail!");
		exit(2);
	}

	//从文件中读取前K个数据
	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d", &minHeap[i]);
	}

	//建堆---小堆
	for (int i = (k - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(minHeap, k, i);
	}

	int x = 0;
	while (fscanf(fout, "%d", &x) == 1)
	{
		//读取到的数据跟堆顶的数据进行比较
		//比堆顶值大,交换入堆
		if (x > minHeap[0])
		{
			minHeap[0] = x;
			AdjustDown(minHeap, k, 0);
		}
	}

	for (int i = 0; i < k; i++)
	{
		printf("%d ", minHeap[i]);
	}

	fclose(fout);
}

执行过程中CreateData函数创建一份写有十万个数据的文件,用到了创建随机数的三个函数rand,srand,time,这些知识都在前面的博客内容中有所提到,不了解的读者可以去看看这两篇博客。
猜数字小游戏
文件操作手册
然后我们动态创建一个数组,将文件中前k个数据存入数组中,并建立小堆,然后将文件中其他数据与小堆堆顶进行比较,如果大于堆顶数据,则替换,并重新向下调整,保证堆顶是堆内最小数据,直至遍历完所有数据,最后打印堆内数据为最后结果。

4.实现链式结构二叉树

4.1链式二叉树的基本结构

用链表来表示⼀棵二叉树,即用链式结构表示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 ,其结构如下:

typedef int BTDataType;
// ⼆叉链
typedef struct BinaryTreeNode
{
	struct BinTreeNode* left; // 指向当前结点左孩⼦
	struct BinTreeNode* right; // 指向当前结点右孩⼦
	BTDataType val; // 当前结点值域
}BTNode;

⼆叉树的创建方式比较复杂,为了更好的步入到⼆叉树内容中,我们手动先创建⼀棵链式⼆叉树:

BTNode* CreateNode(BTDataType x)
{
	BTNode* newnode = (BTNode*)malloc(sizeof(BTNode));
	if (newnode == NULL)
	{
		perror("malloc fail!");
		exit(1);
	}
	newnode->data = x;
	newnode->left = newnode->right = NULL;
	return newnode;
}
int main()
{
	BTNode* node1 = CreateNode(1);
	BTNode* node2 = CreateNode(2);
	BTNode* node3 = CreateNode(3);
	BTNode* node4 = CreateNode(4);
	BTNode* node5 = CreateNode(5);
	BTNode* node6 = CreateNode(6);

	node1->left = node2;
	node1->right = node3;
	node2->left = node4;
	node2->right = node5;
	node3->left = node6;
	return 0;
}

在这里插入图片描述

这是上面代码创建的二叉树示意图。

回顾二叉树的概念,二叉树分为空树和非空二叉树,非空二叉树由根结点、根结点的左子树、根结点的右子树组成的。
在这里插入图片描述
根结点的左子树和右子树分别又是由子树结点、子树结点的左子树、子树结点的右子树组成的,因此二叉树定义是递归式的,后面链式二叉树的许多操作中基本都是借助该概念帮助实现的

4.2二叉树的四种遍历方式

还是上面我们自己创建的二叉树,我们分别用不同的遍历方式来遍历,看看都是怎么个顺序。
首先我们来介绍三种遍历方式,它们是一体的,分别是前序遍历,中序遍历,后序遍历。遍历规则为:

  1. 前序遍历(亦称先序遍历):访问根结点的操作发生在遍历其左右⼦树之前,访问顺序为:根结点、左子树、右子树
  2. 中序遍历:访问根结点的操作发生在遍历其左右子树中间,访问顺序为:左子树、根结点、右子树
  3. 后序遍历:访问根结点的操作发生在遍历其左右子树之后,访问顺序为:左子树、右子树、根结点
//前序遍历
void PreOrder(BTNode* root)
{
	if (root == NULL)
	{
		return;
	}
	printf("%d ", root->data);
	PreOrder(root->left);
	PreOrder(root->right);
}

//中序遍历
void InOrder(BTNode* root)
{
	if (root == NULL)
	{
		return;
	}
	InOrder(root->left);
	printf("%d ", root->data);
	InOrder(root->right);
}

//后序遍历
void PostOrder(BTNode* root)
{
	if (root == NULL)
	{
		return;
	}
	PostOrder(root->left);
	PostOrder(root->right);
	printf("%d ", root->data);
}

前序遍历遵循"根节点-左子树-右子树"的顺序访问二叉树。以上文创建二叉树为例,遍历从根节点1开始:首先访问并打印根节点1,接着遍历左子树,访问节点2并打印,继续深入节点2的左子树,访问节点4并打印(节点4没有子节点,该分支遍历结束),返回处理节点2的右子树,访问节点5并打印(节点5没有子节点),最后处理根节点的右子树,访问节点3并打印,继续访问节点3的左子树节点6并打印(节点6没有子节点,且节点3没有右子树),程序结束。
最终前序遍历结果为:1 2 4 5 3 6
中序遍历与后续遍历一样按照自身规则一步一步递归实现,这里不再重复赘述,最后中序遍历结果为:4 2 5 1 6 3,后序遍历结果为:4 5 2 6 3 1,大家可以自己试着遍历。

还有一种遍历方式是层序遍历,我们设二叉树的根结点所在层数为1,从所在二叉树的根结点出发,首先访问第一层的树根结点,然后从左到右访问第二层上的结点,接着是第三层的结点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。

实现层序遍历我们需要用到之前学过的一种数据结构:队列。在实现层序遍历代码中用到了之前队列博客里的封装函数,大家可以去这篇博客里看一下:
栈与队列,要注意:typedef struct BinaryTreeNode* QDataType;

void LevelOrder(BTNode* root)
{
	Queue pq;
	QueueInit(&pq);
	if(root)
		QueuePush(&pq, root);
	while (!QueueEmpty(&pq))
	{
		BTNode* front = QueueFront(&pq);
		printf("%d ", front->data);
		QueuePop(&pq);
		if(front->left)
			QueuePush(&pq, front->left);
		if(front->right)
			QueuePush(&pq, front->right);
	}
	QueueDestroy(&pq);
}

实现过程:
初始化队列:首先创建一个先进先出(FIFO)的队列结构,用于临时存储待访问的节点指针。

根节点入队:若二叉树非空,将根节点指针存入队列,作为遍历的起始点。

循环处理队列:

  1. 取出队首节点:通过队列接口获取当前队首元素(即最早入队的节点)

  2. 访问节点数据:输出该节点的数值信息(或其他处理操作)

  3. 节点出队:将该节点从队列中移除

  4. 子节点入队:若当前节点存在左孩子,将其左孩子指针入队;同理处理右孩子

终止条件:当队列为空时,表明所有节点均已访问完毕,遍历结束。

资源释放:最终销毁队列结构,释放系统资源。

该算法通过队列的先进先出特性,确保节点总是按照层级顺序被处理:首先访问根节点,接着是第二层的左右子节点,然后是第三层的节点,以此类推。

4.3链式二叉树其他函数封装

/* 计算二叉树节点总数 */
int BinaryTreeSize(BTNode* root)
{
    // 递归终止条件:空树节点数为0
    if (root == NULL) return 0;
    
    // 当前节点(1) + 左子树节点数 + 右子树节点数
    return 1 + BinaryTreeSize(root->left) + BinaryTreeSize(root->right);
}

/* 计算二叉树叶子节点数 */
int BinaryTreeLeafSize(BTNode* root)
{
    // 递归终止条件:空树没有叶子节点
    if (root == NULL) return 0;
    
    // 当前节点是叶子节点的条件:左右子树均为空
    if (root->left == NULL && root->right == NULL) return 1;
    
    // 左子树叶子数 + 右子树叶子数
    return BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right);
}

/* 计算第K层节点数 */
int BinaryTreeLevelKSize(BTNode* root, int k)
{
    // 递归终止条件1:空树
    if (root == NULL) return 0;
    
    // 递归终止条件2:到达目标层级
    if (k == 1) return 1;
    
    // 左子树第k-1层节点数 + 右子树第k-1层节点数
    return BinaryTreeLevelKSize(root->left, k-1) 
         + BinaryTreeLevelKSize(root->right, k-1);
}

/* 计算二叉树深度/高度 */
int BinaryTreeDepth(BTNode* root)
{
    // 递归终止条件:空树深度为0
    if (root == NULL) return 0;
    
    // 分别计算左右子树深度
    int leftDepth = BinaryTreeDepth(root->left);
    int rightDepth = BinaryTreeDepth(root->right);
    
    // 取较大值 + 当前层(1)
    return (leftDepth > rightDepth ? leftDepth : rightDepth) + 1;
}

/* 查找值为x的节点 */
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
    // 递归终止条件1:空树或找到目标节点
    if (root == NULL) return NULL;
    if (root->data == x) return root;
    
    // 先在左子树查找
    BTNode* leftResult = BinaryTreeFind(root->left, x);
    if (leftResult) return leftResult;
    
    // 左子树未找到则在右子树查找
    BTNode* rightResult = BinaryTreeFind(root->right, x);
    if (rightResult) return rightResult;
    
    // 整棵树未找到返回NULL
    return NULL;
}

/* 销毁二叉树(二级指针版) */
void BinaryTreeDestory(BTNode** root)
{
    // 递归终止条件:空树
    if (*root == NULL) return;
    
    // 后序方式递归释放
    BinaryTreeDestory(&(*root)->left);
    BinaryTreeDestory(&(*root)->right);
    
    // 释放当前节点并将指针置NULL
    free(*root);
    *root = NULL;
}

/* 判断是否完全二叉树 */
bool BinaryTreeComplete(BTNode* root)
{
    Queue pq;
    QueueInit(&pq);
    
    // 根节点入队开始层序遍历
    if (root) QueuePush(&pq, root);
    
    // 第一阶段:遇到第一个NULL节点时停止
    while (!QueueEmpty(&pq))
    {
        BTNode* front = QueueFront(&pq);
        QueuePop(&pq);
        
        // 遇到空节点则跳出循环
        if (front == NULL) break;
        
        // 无论子节点是否NULL都入队
        QueuePush(&pq, front->left);
        QueuePush(&pq, front->right);
    }
    
    // 第二阶段:检查队列剩余节点
    while (!QueueEmpty(&pq))
    {
        BTNode* front = QueueFront(&pq);
        QueuePop(&pq);
        
        // 如果还有非NULL节点,则不是完全二叉树
        if (front != NULL) {
            QueueDestroy(&pq);
            return false;
        }
    }
    
    QueueDestroy(&pq);
    return true;
}

关键点与注意点:

  1. 递归实现:

    多数函数(节点计数、深度计算、查找等)采用递归实现,需确保:

    1. 明确的递归终止条件(如root == NULL)

    2. 正确的递归分解(如左子树+右子树+当前节点)

    注意:递归深度可能导致栈溢出(极端深度的树)

  2. 遍历方式选择:

    1. 销毁二叉树必须使用后序遍历(先释放子树再释放根)

    2. 层级相关操作(如K层节点数)适合前序/深度优先

    3. 完全二叉树判断必须用层序遍历

  3. 完全二叉树检测:

    核心逻辑:层序遍历中遇到NULL后,后续不得出现非NULL节点

    需注意:

    1. 所有子节点(包括NULL)都要入队

    2. 队列中存储的是节点指针,需处理NULL值

  4. 内存管理:

    1. 销毁函数使用二级指针(BTNode**),确保外部指针置NULL

    2. 队列操作后必须调用Destroy,避免内存泄漏

    3. 查找函数返回节点指针,注意不要误修改原树结构

这一系列函数完整实现了二叉树的核心操作,涵盖了节点统计、结构分析和内存管理。递归思想贯穿多数实现,需特别注意终止条件和资源释放。


网站公告

今日签到

点亮在社区的每一天
去签到