赞
踩
在计算机科学中,数据结构是算法设计的基石,而二叉树(Binary Tree)作为一种基础且广泛应用的数据结构,具有重要的地位。无论是在数据库索引、内存管理,还是在编译器实现中,二叉树都扮演着关键角色。本文将带你深入了解二叉树的基本概念、性质,以及如何在C语言中实现一个完整的二叉树。
首先,我们将探讨二叉树的定义和基本特性,包括其节点、子节点、以及树的高度和深度等基本概念。接着,我们会逐步介绍如何使用C语言构建二叉树,涉及节点的定义、树的初始化、节点的插入与删除等基本操作。最后,我们还将讨论一些常见的二叉树遍历算法,如前序遍历、中序遍历和后序遍历,并提供相应的代码示例。
通过这篇文章,你不仅能掌握二叉树的基本理论,还能学会如何在C语言中灵活运用这一数据结构,为解决实际编程问题提供有力的工具和方法。让我们一起开启这段探索二叉树世界的旅程吧!
本篇文章重点在4. 二叉树的链式结构及实现,有需要的小伙伴可以直接点击目录进行跳转
在这一部分我会简单的介绍一下树的相关概念,不会太详细,因为这篇文章着重介绍的是二叉树,所以默认读者是对树这种结构有一定了解了,如果给您带来不便,还请见谅。
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
注意:树型结构中,子树之间不能有交集,否则就不是树形结构
重点
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既要保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法。
typedef int DataType;
struct Node
{
struct Node* firstChild1; // 第一个孩子结点
struct Node* pNextBrother; // 指向其下一个兄弟结点
DataType data; // 结点中的数据域
};
一棵二叉树是结点的一个有限集合,该集合或者为空,或者由一个根结点加上两棵别称为左子树和右子树的二叉树组成。
二叉树的特点:
注意:对于任意的二叉树都是由以下几种情况复合而成的:
K
,且结点总数是(2^k)-1
,则它就是满二叉树。K
的,有n
个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n
的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。对于完全二叉树的概念介绍大家可能还不能深刻理解什么是完全二叉树,我来给大家总结一下,我们可以对着下面的图片进行理解。
假设树的高度为h
,前h-1
层都是满的,最后一层不满,但是最后一层一定是从左往右连续分布的,不能有空节点。这就是完全二叉树。
1
,则一棵非空二叉树的第i
层上最多有2^(i-1)
个结点.h
的二叉树的最大结点数是(2^h)-1
.0
其叶结点个数为n0
, 度为2
的分支结点个数为n2
,则有n0 = n2 +1
的关系.下边附上性质3的证明
/*
* 假设二叉树有N个结点
* 从总结点数角度考虑:N = n0 + n1 + n2 ①
*
* 从边的角度考虑,N个结点的任意二叉树,总共有N-1条边
* 因为二叉树中每个结点都有双亲,根结点没有双亲,每个节点向上与其双亲之间存在一条边
* 因此N个结点的二叉树总共有N-1条边
*
* 因为度为0的结点没有孩子,故度为0的结点不产生边; 度为1的结点只有一个孩子,故每个度为1的结
点* * 产生一条边; 度为2的结点有2个孩子,故每个度为2的结点产生两条边,所以总边数为:
n1+2*n2
* 故从边的角度考虑:N-1 = n1 + 2*n2 ②
* 结合① 和 ②得:n0 + n1 + n2 = n1 + 2*n2 - 1
* 即:n0 = n2 + 1
*/
n
个结点的满二叉树的深度为h
,节点数与深度的关系为:h=log2(n+1)
. (ps:关系式是log以2为底,n+1为对数)n
个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有结点从0
开始编号,则对于序号为i
的结点有:i>0
,i
位置结点的双亲序号:(i-1)/2
;i=0
,i
为根结点编号,无双亲结点2i+1<n
,左孩子序号:2i+1
,2i+1>=n
否则无左孩子2i+2<n
,右孩子序号:2i+2
,2i+2>=n
否则无右孩在讲二叉树的存储结构之前,我先为大家简单的说明下这两种存储结构(顺序结构、链式结构)。
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* left; // 指向当前结点左孩子
struct BinTreeNode* right; // 指向当前结点右孩子
BTDataType data; // 当前结点值域
}
现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
用数组来存储就避不开一个问题,当你拥有父节点的下标时,你如何快速的找到左右子节点的下标?
这张图总结的父子节点下标关系,是大家必须要掌握的,因为后边在顺序存储结构实现阶段会频繁用到
接下来我们将通过堆来继续学习顺序存储结构
堆的概念实在是有点晦涩难懂,在这里我斗胆带大家通过堆的性质和图片实例简单的了解一下什么是堆。
堆主要有两种类型:大根堆和小根堆。
堆的性质:
根据上面的信息我们可以看出,最大堆的根节点是所有节点的中最大的,小根堆同理。每个节点都比自己的左右子节点要大或者小。希望大家现在可以对“堆”清晰的认识了。
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a; //堆是基于数组结构来实现的
int size; //数组下标
int capacity; //数组容量
}HP;
//堆的初始化
void HPInit(HP* php) //传入数组结构的指针
{
assert(php);
php->a = NULL; //置空
php->size = php->capacity = 0; //归零
}
//堆的销毁
void HPDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
//数据交换
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向上调整——可以将数组恢复成堆的顺序——小根堆 void AdjustUp(HPDataType* a, int child) //传入数组的地址和新插入的叶节点的下标 { int parent = (child - 1) / 2; //无论是奇偶子节点,均可以通过数组的下标“-1再/2”来获得父节点的下标 //while (parent >= 0) 为了防止数组的越界访问,我们不采取这个循环条件 while (child > 0) { if (a[child] < a[parent]) //如果子节点小于父节点 { Swap(&a[child], &a[parent]); //使用交换函数,交换父子的数据 child = parent; //子节点位置换到父节点上 parent = (child - 1) / 2; //然后父节点通过转换方法重新指向变换后的子节点的父节点 } else { break; //如果子节点大于父节点就不需要调整了 } } }
示例:先插入一个10到数组的尾上,在进行向上调整算法,直到满足堆。
//堆的插入 void HPPush(HP* php, HPDataType x) { assert(php); //传入的堆不能为空 if (php->size == php->capacity) //堆的空间如果不足,则进行扩容 { int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2; HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType)); if (tmp == NULL) { perror("realloc fail"); return; } php->a = tmp; php->capacity = newcapacity; } php->a[php->size] = x; //将数据x插入,数组下标为size的空间中 php->size++; AdjustUp(php->a, php->size - 1); //利用向上调整将插入新数据之后的数组恢复为堆 }
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根结点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整。
int array[] = {27,15,19,18,28,34,65,49,25,37};
//向下调整——可以将数组恢复成堆的顺序——小根堆 void AdjustDown(HPDataType* a, int n, int parent) //传入数组的地址和数组的总大小还有根的下标 { //传入根位置的下标是因为需要调整的元素的位置就是根位置 // 先假设左孩子小——利用假设法 int child = parent * 2 + 1; //第一次先找到根节点下面的子节点 while (child < n) // 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; //如果最小的节点都大于父节点了,就说明不需要调了 } } }
堆的创建
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过向下调整算法,把它构建成一个堆。从倒数的第一个非叶子结点的子树开始调整,一直调整到根结点的树,就可以调整成堆。
int a[] = {1,5,3,8,7,6};
图片中的是调整为大根堆,思路是一样的,代码只有些许不同
删除堆是删除堆顶的数据,将堆顶的数据跟最后一个数据一换,然后删除数组最后一个数据,再进行向下调
整算法。
//堆顶的删除
void HPPop(HP* 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 HPTop(HP* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
//堆的判空
bool HPEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
//堆的数据个数
int HPSize(HP* php)
{
assert(php);
return php->size;
}
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化,使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个结点不影响最终结果):
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
//堆排序 0(N*logN) void HeapSort(int* a, int 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; } }
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
粗略讲解图
为了方便测试Top-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) ; fprintf(fin, "%d\n", x); } fclose(fin); }
Top-k方法实现
//Top-k方法 void Topk() { int k; printf("请输入k>:"); scanf("%d", &k); int* kminheap = (int*)malloc(sizeof(int) * k); if (kminheap == NULL) { perror("malloc fail"); return; } const char* file = "data.txt"; FILE* fout = fopen(file, "r"); if (fout == NULL) { perror("fopen error"); return; } // 读取文件中前k个数 for (int i = 0; i < k; i++) { fscanf(fout, "%d", &kminheap[i]); } // 建K个数的小堆 for (int i = (k - 1 - 1) / 2; i >= 0; i--) { AdjustDown(kminheap, k, i); } // 读取剩下的N-K个数 int x = 0; while (fscanf(fout, "%d", &x) > 0) { if (x > kminheap[0]) { kminheap[0] = x; AdjustDown(kminheap, k, 0); } } printf("最大前%d个数:", k); for (int i = 0; i < k; i++) { printf("%d ", kminheap[i]); } printf("\n"); }
学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉树中的结点进行相应的操作,并且每个结点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,也是在二叉树上进行其它运算的基础。
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:
这3种遍历方式均是深度优先遍历
由于被访问的结点必是某子树的根,所以N(Node)、L(Left subtree)和R(Right subtree)又可解释为根、根的左子树和根的右子树。NLR、LNR和LRN分别又称为先根遍历、中根遍历和后根遍历。
下面主要分析前序递归遍历,中序与后序都是同理,大家可以自行分析。
前序遍历递归图解
因为二叉树的递归结构,可能会有些绕,我也尽量能配图的我就配图帮助大家理解,在二叉树中,大家自己的反复画图,和逻辑推理也是不能少的,只有足够的耐心才能掌握二叉树。
// 二叉树前序遍历
void BinaryTreePrevOrder(BTNode* root)
{
//先判空
if (root == NULL)
{
return;
}
//打印根节点
printf("%c",root->data);
//向下递归此根节点的左子树和右子树
BinaryTreePrevOrder(root->left);
BinaryTreePrevOrder(root->right);
}
后面在实现结构的时候我们会经常遇到这段代码
//先判空
if (root == NULL)
{
return;
}
它的作用,我大概总结有以下几点,希望大家能够牢记
//开头的判空可以防止空指针的解引用
//递归遍历都需要先判空,因为这是“归”的条件
//当“递”到叶子节点的子节点的时候,一定为空,这时候需要return往回归
// 二叉树中序遍历
void BinaryTreeInOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
BinaryTreeInOrder(root->left);
printf("%c", root->data);
BinaryTreeInOrder(root->right);
}
// 二叉树后序遍历
void BinaryTreePostOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
BinaryTreePrevOrder(root->left);
BinaryTreePrevOrder(root->right);
printf("%c", root->data);
}
// 通过前序遍历的数组"ABD##E#H##CF##G##"构建二叉树 //定义一个构造二叉树函数,将前序遍历的字符串用节点全部构建出来 //传入字符串和一个下标变量(此变量需要地址来改变,防止同时传给左右子树相同下标导致数组中数据覆盖) BTNode* BinaryTreeCreate(char* a, int* pi) { //首先先判空 if (a[*pi] == '#') { (*pi)++; return NULL; } //前序遍历保存根节点 BTNode* root = (BTNode*)malloc(sizeof(BTNode)); root->data = a[(*pi)++]; //对左右子树进行递归 root->left = BinaryTreeCreate(a, pi); root->right = BinaryTreeCreate(a, pi); return root; }
// 二叉树销毁 使用后序遍历的思想从后往前销毁每一个节点
void BinaryTreeDestory(BTNode* root)
{ //要销毁二叉树链表就得把链表的头结点的指针的地址传入
if (root == NULL)
return;
BinaryTreeDestory(root->left);
BinaryTreeDestory(root->right);
free(root);
}
// 二叉树节点个数
int BinaryTreeSize(BTNode* root)
{
return root == NULL ? 0 : BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 1;
} //子树节点个数等于左右节点个数+根节点(1)个数
//二叉树叶子节点个数 int BinaryTreeLeafSize(BTNode* root) { //先判空 if (root == NULL) { return 0; } //如果左右子节点都为空就是叶子结点,则返回1. if (root->left == NULL && root->right == NULL) { return 1; } return BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right); } //通过+运算和return可以达到统计的功能
// 二叉树第k层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{ //参数部分的k没有传地址,是因为需要左右子树记录向下递的层数相同。
if (root == NULL)
{
return 0;
}
//k--,减到1的时候就是第k层了,返回1
if (k == 1)
{
return 1;
}
return BinaryTreeLevelKSize(root->left, k - 1) + BinaryTreeLevelKSize(root->right, k - 1);
} //通过+运算和return对k层节点数进行统计并返回顶层 //每次递下去,对k-1,可以达到对应层k为1的效果
BTNode* BinaryTreeFind(BTNode* root, BTDataType x) { //用前序遍历思想进行比较,找到就返回地址 if (root == NULL) { return NULL; } //进行比较,判断此节点是否为x,不是就向下递归 if (root->data == x) { return root; } //递归函数将归回来的结果传给变量进行判断,这样才能将结果递归回去。 BTNode* ret1 = BinaryTreeFind(root->left, x); if (ret1) //if的结果只有可能是NULL和值为x的节点指针root { return ret1; } BTNode* ret2 = BinaryTreeFind(root->right, x); if (ret2) { return ret2; } //如果下边都没有那就向上返回NULL,找到了就返回节点地址 return NULL; }
层序遍历
层序遍历:除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根结点所在层数为1,层序遍历就是从所在二叉树的根结点出发,首先访问第一层的树根结点,然后从左到右访问第2层上的结点,接着是第三层的结点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。
在实现层序遍历的时候我们需要用到队列结构,如果有对队列结构还不熟悉的同学,可以先点击链接跳转,学习过后再来看,可能也会有更多的收货。
//层序遍历 //层序遍历不像二叉树一样递归遍历,而是用队列结构通过循环先进先出进行遍历 //运用上一层根节点出带动下一层子节点入 void BinaryTreeLevelOrder(BTNode* root) { Queue Q; QueueInit(&Q); //先初始化队列结构,实现先进先出的功能,进行一个层序遍历 if (root) //根不为空的时候,就入对列开始遍历 { //如果为空,直接跳到函数的最后销毁 QueuePush(&Q, root); } while(!QueueEmpty(&Q)) //队列不为空就一直循环 { BTNode* front = QueueFront(&Q);//需要创建一个指针,指向队顶的元素,防止Pop出队顶元素,找不到其子节点 QueuePop(&Q); //排出上一层的子节点 printf("%c", front->data);//并打印 if (front->left) //带入下一层的节点(只要不为空) { QueuePush(&Q, front->left); } if (front->right) { QueuePush(&Q, front->right); } } QueueDestroy(&Q); }
// 判断二叉树是否是完全二叉树 //当所有非空节点都出队列的时候,判定队列是否为空,即可判断其是否为完全二叉树 bool BinaryTreeComplete(BTNode* root) { Queue Q; QueueInit(&Q); //先初始化队列结构,实现先进先出的功能,进行一个层序遍历 if (root) //根不为空的时候,就入对列开始遍历 { //如果为空,直接跳到函数的最后销毁,并返回false QueuePush(&Q, root); } while (!QueueEmpty(&Q)) //队列不为空就一直循环 { BTNode* front = QueueFront(&Q);//需要创建一个指针,指向队顶的元素,防止Pop出队顶元素,找不到其子节点 QueuePop(&Q); //排出上一层的子节点 //空节点也入队列,所以需要if语句来判断何时结束循环 if (front == NULL)//如果前面的非空节点都Pop掉了,后面就应该全部是空节点了 { break; //所以break跳出循环,进入下一个循环来判断 } //带入下一层的节点 QueuePush(&Q, front->left); QueuePush(&Q, front->right); } while (!QueueEmpty(&Q)) { BTNode* front = QueueFront(&Q); QueuePop(&Q); //如果进入了if语句,就说明此节点为非空节点,也就证明了此二叉树不是完全二叉树 if (front) { QueueDestroy(&Q); return false; } } //如果队列剩下的节点均为空节点,就可以销毁队列,并返回true了 QueueDestroy(&Q); return true; }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。