1.二叉树的概念及结构
1.1树的概念
1.1.1概念
在了解二叉树前,咱们先来了解一下什么是树:树是一种非线性的数据结构,它是由n(n>=0)个有限节点组成的一个具有层次关系的的集合。把它叫做树是因 为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的.
其有一个特殊的结点,称为根节点,根节点没有前驱节点,除了根节点之外其余节点被分为M(M>0)个互不相交的集合,而每个集合又是一个结构与树类似的子树。每棵子树的根节点有且仅有一个前驱,可以有0个或者N个后继。因此,树是递归定义的。要注意的是,树形结构中子树不能有交集,不然就不是树。
1.1.2相关概念

以上图为例来说明一些数的基本概念:
结点的度:一个结点含有的子树的个数称为该结点的度; 如上图:A的为6
叶结点或终端结点:度为0的结点称为叶结点; 如上图:B、C、H、I...等结点为叶结点
双亲结点或父结点:若一个结点含有子结点,则这个结点称为其子结点的父结点; 如上图:A是B的父结点
孩子结点或子结点:一个结点含有的子树的根结点称为该结点的子结点; 如上图:B是A的孩子结点
堂兄弟结点:双亲在同一层的结点互为堂兄弟;如上图:H、I互为兄弟结点
树的度:一棵树中,最大的结点的度称为树的度; 如上图:树的度为6
结点的层次:从根开始定义起,根为第1层,根的子结点为第2层,以此类推
树的高度或深度:树中结点的最大层次; 如上图:树的高度为4
1.1.3树的表示
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间 的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法 等。我们这里就简单的了解其中最常用的孩子兄弟表示法,如下。
typedef int DataType;
struct Node
{
struct Node* firstChild1;//第一个孩子的结点
struct Node* pNextBrother;//指向下一个兄弟节点
DataType data;//结点中的数值
};

1.2二叉树的概念
了解了树的基本概念之后,我们来了解一下二叉树。
一颗二叉树是结点的一个有限的集合,该集合可以为空,或者由一个根节点和两棵左右子树组成。

由上图可以得出,二叉树不存在度大于2的结点,且有左右子树之分,次序不能颠倒,因此二叉树是有序树。
对于任何二叉树都是由以下几种情况符合而成:

1.3特殊二叉树
二叉树一共有两种特殊情况,分别称为满二叉树和完全二叉树。
满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是 说,如果一个二叉树的层数为K,且结点总数是,则它就是满二叉树。
完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K 的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对 应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。

1.4二叉树的性质
二叉树还有以下性质:
1. 若规定根结点的层数为1,则一棵非空二叉树的第i层上最多有2^(i-1)个结点.
2. 若规定根结点的层数为1,则深度为h的二叉树的最大结点数是 2^h-1.
3. 对任何一棵二叉树, 如果度为0其叶结点个数为 , 度为2的分支结点个数为 ,则有n0=n2+1.
4. 若规定根结点的层数为1,具有n个结点的满二叉树的深度,h=log(n+1).(是以2为底n+1的对数)
5. 对于具有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否则无右孩子
1.5二叉树的储存结构
二叉树一般有两种储存结构,一种是顺序结构,一种是链式结构。
顺序结构:即用数组来储存,但是一般只适合表示完全二叉树,否则就会产生空间上的浪费(如下图)。而现实使用中只有堆才会使用数组来储存(堆在之后有讲)。二叉树顺序储存在物理上是一个数组,在逻辑上是棵树。

链式结构:指用链表来表示一棵树,用链表来指示元素的逻辑关系。通常的表示方法是链表中有一个左指针、一个右指针和一个储存数据的变量。左右指针分别储存左右孩子节点的地址。链式结构分为二叉链和三叉链,在二叉树这部分咱们用到的是二叉链,三叉链会多一个指向父节点的指针。
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* left; // 指向当前结点左孩子
struct BinTreeNode* right; // 指向当前结点右孩子
BTDataType data; // 当前结点值域
}
2.二叉树的顺序结构及实现
2.1堆的概念及结构
前面咱们提到,顺序结构是指用数组来储存,而现实中一般只有堆会用数组储存,那么什么是堆呢?
实际上,这里的堆不是我们内存中的堆,而是一种数据结构,是一种二叉树。
堆的性质:堆中某个节点的值,总不大于(称为大堆结构)或不小于(称为小堆结构)其父节点的值,且堆总是一棵完全二叉树。

2.2堆的实现
接下来咱们将要逐步实现堆。堆的基本结构如下,一个典型的顺序表结构,那么初始化和销毁也和顺序表类似,在此不再赘述。
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
2.2.1堆的插入(向上调整算法)
以建小堆为例,如果咱们需要插入一个数据到现有的堆中(也就是插入到最后),如果这个数据的值比其父节点小,那么这个堆就不再是堆,此时我们需要调整最后一个叶子节点的位置,使得二叉树仍为堆。于是,我们需要一个向上调整算法,来帮咱们实现这一步。
//传入堆和,子节点的位置
void ArrUp(HPDataType* a, int child)
{
assert(a);
//小堆
//根据性质求父节点的位置
int parent = (child - 1) / 2;
//最坏的情况,从最后一个节点调整到了根节点,此时child为0,结束循环
while (child > 0)
{
//父节点和子节点比较,大于则交换
if (a[parent] > a[child])
{
//封装的两数交换函数,传入堆和需要交换的位置
Swap(a, parent, child);
//此时孩子到了父亲的位置,
child = parent;
//找到新的父节点
parent = (child - 1) / 2;
}
//父节点小于子节点,调整完毕,退出循环
else
{
break;
}
}
}
这时候我们就可以进行插入操作,操作和顺序表的插入相同,只是在最后加上了向上调整的操作。
void HeapPush(Heap* ph, HPDataType x)
{
assert(ph);
if (ph->size == ph->capacity)
{
int Doublecapacity = ph->capacity == 0 ? 4 : 2 * ph->capacity;
ph->capacity = Doublecapacity;
HPDataType* ptr = (HPDataType*)realloc(ph->a, sizeof(HPDataType) * Doublecapacity);
if (ptr == NULL)
{
perror("realloc fail");
return;
}
ph->a = ptr;
}
(ph->a)[ph->size++] = x;
//向上排序
ArrUp(ph->a, ph->size-1);
}
2.2.2堆的删除(向下调整算法)
堆的删除,指的是删除根节点,那根节点缺的这块谁给咱补啊!于是就有了一个操作,把根节点和最后一个叶子节点交换,此时再把此时的大小-1(size-1)这时候根节点被删除但是仍然存在了,但这也同样不是堆了口牙,这时咱们需要一个向下调整算法,把根节点的异端调到下面去。
以小堆为例,
void ArrDown(HPDataType* a, int size,int parent)
{
assert(a);
//小堆
//找到左孩子
int child = parent * 2 + 1;
//向下调整的过程中孩子节点的位置在变大,最坏情况调整到最后一个位置,孩子越界的时候跳出循环
while (child < size)
{
//如果在不越界的情况下,右孩子比左孩子小,选择右孩子作为比较的对象
if (child + 1 < size && a[child + 1] < a[child])
{
child++;
}
//父节点大于孩子节点
if (a[parent] > a[child])
{
//两数交换
Swap(a, parent, child);
//父亲来到孩子的位置
parent = child;
//寻找新的孩子,并在上一个if选择出小的那个
child = parent * 2 + 1;
}
//父节点小于孩子节点,排好了,退出循环
else
break;
}
}
这样,我们就得到了重新获得堆的办法,这时候就可以进行删除,
void HeapPop(Heap* ph)
{
assert(ph);
Swap(ph->a,ph->size-1,0);
ph->size--;
//向下调整
ArrDown(ph->a, ph->size,0);
}
2.2.3取根数据/取数据个数/判空
取数据、取个数、判空这一块过于简单,一笔带过。
//取顶数据
HPDataType HeapTop(Heap* ph)
{
assert(ph);
assert(ph->size > 0);
return ph->a[0];
}
//取数据个数
int HeapSize(Heap* ph)
{
assert(ph);
return ph->size;
}
//判空
int HeapEmpty(Heap* ph)
{
assert(ph);
return ph->size == 0 ? 1 : 0;
}
2.3应用
2.3.1建堆
在前面堆的实现,我们可以通过输入数据来得到一个堆,那如果直接给咱们一个数组的数据呢?虽然说咱们可以通过遍历的方式传入数据来建立一个堆,那么有没有什么不用创建新变量的方法,直接改动原数组的方式来获得一个堆呢?
答案是有的,且就在咱们上面提到的向下调整算法中。因为数组本身就是一个完全二叉树,那么,我们就可以从最后一个非叶子节点开始,向前遍历向下调整算法,子树从小到大被调整成堆结构,最终调整到根,以至于所有的结点被调整,就形成了堆。
以建小堆为例,
//最后一个叶子节点是n-1,最后一个叶子节点的父节点是(n-1)-1/2
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
//传入数组,数组大小,和i
ArrDown(a, n, i);
}
2.3.2堆排序
有人就要问了,叽里咕噜说了这么多,堆有什么用呢?
你看哈,这个堆顶必然是所有数据中最大/最小的,再加上我们删除操作实际上不会删除数据,而是相当于把数据隐藏起来了,那咱们建了个小堆,进行一次删除,最小的元素在最后,此时堆顶是第二小的数字,反复操作,不就实现了降序排序吗?于是就有了堆排序。
void HeapSort(int* a, int n)
{
assert(a);
//降序建小堆
//先把数组排成堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
ArrDown(a, n, i);
}
//pop掉,再进行排序
while(--n)
{
//交换首尾
Swap(a, n , 0);
//在减少了元素的基础上重新调整成堆
ArrDown(a, n , 0);
}
}
2.3.3TopK问题
TopK问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如游戏前百名、富豪榜等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但如果数据量非常大,排序就不太可取了,那么咱们可以用堆来解决这个问题,思路如下:
1.用需要找的K个数量的元素建堆:
如果找前K个最小的元素,建大堆;如果找前K个最大的元素,建小堆。
2.用剩余的N-K与堆顶进行比较:
建大堆,比根小的替换根,重新调整堆,比根大的跳过。
建小堆,比根大的替换根,重新调整堆,比根小的跳过。
当N-K个元素比较完时,剩下在堆里的元素就是最大/最小的前K个元素。
接下来,咱们造点数据实现一下这个过程:
//生成数据
void CreateNDate()
{
// 造数据
int n = 10000;
//产生随机数
srand((unsigned int)time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
//写入一万个数据
for (size_t i = 0; i < n; ++i)
{
int x = rand() % 1000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
为了使结果醒目,所以咱事先在文件中修改了五个值,使其大于其他的数据.
void test03()
{
int k = 0;
scanf("%d", &k);
int* a = (int*)malloc(sizeof(int) * k);
if (a == NULL)
{
perror("malloc fail");
return;
}
FILE* fout = fopen("data.txt", "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
int x = 0;
//读取k个数据放入数组
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &x);
a[i] = x;
}
//建堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
ArrDown(a, k, i);
}
//开始比较
while (fscanf(fout, "%d", &x) > 0)
{
if (x > a[0])
{
a[0] = x;
ArrDown(a, k, 0);
}
}
fclose(fout);
fout = NULL;
print(a, k);
}
运行结果如下,因为只是找前5个元素,所以并没有进行排序。

3.二叉树链式结构及实现
前情提要:用二叉链来实现二叉树。以下是基本结构。
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* left; // 指向当前结点左孩子
struct BinTreeNode* right; // 指向当前结点右孩子
BTDataType data; // 当前结点值域
}
3.1二叉树的遍历
二叉树遍历,指按照某种特定的规则,依次对二叉树的结点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历和层序遍历:
1. 前序遍历——访问根结点的操作发生在遍历其左右子树之前,即访问顺序:根 左子树 右子树。
2. 中序遍历——访问根结点的操作发生在遍历其左右子树之中(间),即访问顺序:左子树 根 右子树。
3. 后序遍历——访问根结点的操作发生在遍历其左右子树之后,即访问顺序:左子树 右子树 根。
4.层序遍历——从第一层开始向下逐层从左向右访问每一个节点。
3.1.1前序遍历
以下图为例,前序遍历的经过就是:根节点1->1的左子树根2->2的左子树根3->3的左NULL(->回到3)->3的右NULL(->回到3->回到2)->2的右NULL(->回到2->回到1)->1的右子树根4->4的左子树根5->5的左NULL(->回到5)->5的右NULL(->回到5->回到4)->4的右子树根6->6的左NULL(->回到6)->6的右NULL(->回到6->回到4->回到1)->结束.
要注意的是,空节点是遍历的一部分,如果我们用N来表示空的话,我们可以将这棵树的前序遍历表示为:1 2 3 N N N 4 5 N N 6 N N.当然在实际表达中咱们不会加上N,但是这更有利于咱们理解前序遍历。

有了上面的思路,我们可以写出前序遍历的函数
//传入一个根节点
void BinaryTreePrevOrder(BTNode* root)
{
if (root == NULL)
{
//可不打印这一项,打印会更清晰
printf("N ");
return;
}
printf("%d ", root->data);
//递归,先递归左子树,后右子树
BinaryTreePrevOrder(root->left);
BinaryTreePrevOrder(root->right);
}
3.1.2中序遍历
仍然以这棵树为例,中序遍历的经过是:(根1->根1的左子树根2->2的左子树根3)->3的左NULL->回到3->3的右NULL(->回到3)->回到2->2的右NULL(->回到2)->回到1(->1的右子树根4->4的左子树根5)->5的左NULL->回到5->5的右NULL(->回到5)->回到4(->4的右子树6)->6的左NULL->回到6->6的右NULL(->回到6->回到4->回到1)->结束。
带上空来表示: N 3 N 2 N 1 N 5 N 4 N 6 N

实现中序实际上就是变更遍历的顺序,如下。
void BinaryTreeInOrder(BTNode* root)
{
if(root==NULL)
{
printf("N ");
return;
}
BinaryTreeInOrder(root->left);
printf("%d ", root->data);
BinaryTreeInOrder(root->right);
}
3.1.3后序遍历
还是老树,简单捋一下后序遍历的经过:(1->2->3)->左NULL(->3)->右NULL->3(->2)->右NULL->2(->1->4->5)->左NULL(->5)->右NULL->5(->4->6)->左NULL(->6)->右NULL->6->4->1->结束
表示为: N N 3 N 2 N N 5 N N 6 4 1

实现同理
void BinaryTreePostOrder(BTNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
BinaryTreePostOrder(root->left);
BinaryTreePostOrder(root->right);
printf("%d ", root->data);
}
3.1.4层序遍历
层序遍历就不是用递归来实现了,而是需要利用到咱们队列的先进先出的特性(队列可参见往篇)。思路如下:
根节点入队列,取队列第一个结点,让这个结点的左右孩子节点入队列,释放第一个节点(此时左孩子成为第一个节点),再取第一个节点,让这个结点的左右孩子节点入队列,释放第一个节点(此时根节点的右孩子称为第一个节点),再取第一个节点(此时第二层已经遍历完毕),左右孩子节点入队列,释放第一个节点(此时第三层节点都在队列中)……
如此,当队列中没有数据的时候,循环结束,咱们也实现了层序遍历。
void BinaryTreeLevelOrder(BTNode* root)
{
Queue q;
//初始化
QueueInit(&q);
//把根节点入队列
QueuePush(&q, root);
//不为空时循环
while(!QueueEmpty(&q))
{
//取第一个节点
BTNode* front = QueueFront(&q);
//释放队头
QueuePop(&q);
//打印可以略去
if (front == NULL)
printf("N ");
//如果不为空,左右子节点入队列
else
{
printf("%d ", front->data);
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
}
//销毁
QueueDestroy(&q);
}
可能会有人认为,把队头释放了,front不就成了野指针了吗?可别混为一谈了,这里的队列实际上是个链表,释放的是链表的结点,而并非二叉树的结点,咱只是将结点的指针入队列,并不会释放二叉树的结点,就好比是用罐子装了食物,然后不用罐子装了,食物也不会出现问题。
3.2二叉树的结点个数和高度等
这部分基本上是递归,咱们需要需要注意递归的结束条件。
3.2.1二叉树的高度
咱们假设,二叉树根的高度为1,然后咱们可以用前序递归来获得二叉树的高度,
int BinaryTreeLevel(BTNode* root)
{
if (root == NULL)
return 0;
int left = BinaryTreeLevel(root->left) + 1;
int right = BinaryTreeLevel(root->right) + 1;
return left > right ? left : right;
}
用老图来分析一下:先走左子树,走到3时,3左右两边为空,返回值为1,回到2时,左边得到返回值1,+1得到2,右边返回1,将2返回到1,故left=3;走右子树,先到5,与3同理,返回1给4,同理6返回1给4,所以4返回1+1给1最终right=3,所以该树的层数为3.
3.2.2二叉树结点个数
统计节点结束就好比统计学校人数,1个校长发号施令,2个副校长让主任去统计,主任又让辅导员上报学生人数,层层加起来就是总人数。
节点个数就是根加上左右子树节点的个数,左右子树又可以看成一棵新的树,分成新的左右子树和根,最终分到只剩下一个,相加就得到了节点个数。
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
return 0;
return BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 1;
}
3.2.3二叉树叶子结点的个数
要统计叶子结点的个数,咱们要知道叶子结点的特点是左右为空,所以与统计结点个数的函数类似遍历,但是只有当递归到的那个结点左右为空才能返回1。最终相加结果就是叶子结点的个数。
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);
}
3.2.4二叉树第k层结点个数
要计算第k层的结点个数,咱们需要先到达第k层,在那一层的每一个节点都返回一个1.
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (root == NULL)
return 0;
if (k == 0)
return 1;
return BinaryTreeLevelKSize(root->left, k - 1) + BinaryTreeLevelKSize(root->right, k - 1);
}
3.2.5查找值为x的结点
前序遍历,需要注意结束条件和返回值可能为空的情况。如果实际上有多个结果,返回一个就够了,就说找没找到吧。
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->data == x)
return root;
BTNode*left = BinaryTreeFind(root->left, x);
//返回值不为空,说明找到了
if (left != NULL)
return left;
BTNode* right = BinaryTreeFind(root->right, x);
if (right != NULL)
return right;
return NULL;
}
3.2.6判断是否为完全二叉树
对于完全二叉树咱们知道,除了最后一层外,其他层都是满的,而且最后一层是连续的,也就是说,除了最后一层外出现不满的情况以及最后一层不连续的情况,就可以认为这不是完全二叉树,而这两种情况都有一个特点,按层看先是访问元素,访问空,然后又访问到了元素。所以我们可以用层序遍历来判断,而这就又要用到咱们的队列。
int BinaryTreeComplete(BTNode* root)
{
Queue q;
//初始化
QueueInit(&q);
//根入队列
QueuePush(&q, root);
//不为空时继续循环
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
//如果出现了空,需要判断之后是否会出现元素,退出当前循环
if (front == NULL)
break;
else
{
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
}
//循环结束若没有返回0,说明是完全二叉树
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
//出现了非空元素说明不是完全二叉树
if (front != NULL)
return 0;
}
QueueDestroy(&q);
return 1;
}
3.3二叉树的创建和销毁
在前面的情况,咱们都是假设有一个建立好的二叉树,那么咱们怎么创建一个二叉树呢?这里咱给出一个数组"ABD##E#H##CF##G##",接着将要通过前序遍历来构建二叉树。
//创建一个节点并初始化
BTNode* BuyNewNode(BTDataType x)
{
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode));
if (newnode == NULL)
{
perror("malloc fail");
return NULL;
}
newnode->data = x;
newnode->left = NULL;
newnode->right = NULL;
return newnode;
}
//前序创建二叉树,传入数组,数组大小,和一个指针(*pi=0)用于控制循环,保证传址,*pi的内容可以变化
BTNode* BinaryTreeCreatePre(BTDataType* a, int n, int* pi)
{
if (a == NULL)
return NULL;
if (a[*pi] == '#')
{
(*pi) += 1;
return NULL;
}
//越界时,退出循环
if (n <= *pi)
{
return NULL;
}
BTNode* root = BuyNewNode(a[(*pi)++]);
root->left = BinaryTreeCreatePre(a, n, pi);
root->right = BinaryTreeCreatePre(a, n, pi);
return root;
}
二叉树销毁时也类似。
void BinaryTreeDestory(BTNode** root)
{
if (*root == NULL)
return;
//保存左右子树
BTNode* left = (*root)->left;
BTNode* right = (*root)->right;
free(*root);
root = NULL;
BinaryTreeDestory(&left);
BinaryTreeDestory(&right);
}
以上就是C语言部分的二叉树,基本上由递归来实现,至于非递归的内容,咱们C++见
414

被折叠的 条评论
为什么被折叠?



