目录
一、树
1.树的概念
要了解二叉树,那么首先就要知道什么是“树”。
数据结构中的树与现实中的树在物理结构上有着极强的相似性。
从上图中我们可以看到,现实中的树从跟开始往上分为了许多的枝丫,每个枝丫又分出了其他的多个枝丫,且每个枝丫之间相互独立,互不交集。
数据结构中的树在物理结构上便可以看成一棵“倒立”的树。从根节点A开始往下,分为多个子节点,每个子节点又向下分出其他的子节点,每个子节点之间相互独立,互不交集。
这样我们便能够得出树的定义:
(1)树是一种非线性的数据结构,它是由( n>= 0)个有限节点注定一个具有层次关系的集合。把它叫做数是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的
(2)有一个特殊的节点,称为根节点,根节点没有前缀节点
(3)除根节点以外,其余节点被分成M(M > 0)个互不相交的集合T1、T2……Tm,其中每一个集合Ti(1 <= i <= M)又是一颗结构与树类似的子树,每棵子树的根节点有且只有一个前驱节点,可以由0个或多个后继。
(4)树是递归定义的
2.树的特殊词汇含义(以下图为例)
(1)节点的度:一个节点含有的子节点的个数称为该节点的度。如上图,A的度为6
(2)叶节点或终端节点:度为0的节点称为叶节点。如上图,B、C、H、I、P、Q等节点为叶节点
(3)非终端节点或分支节点:度不为0的节点。如上图,D、E、F、G等节点为分支节点
(4)双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。如上图,A是B、C等的父节点,D是H的父节点
(5)孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点。如上图,B是A的子节点
(6)兄弟节点:具有相同父节点的节点互称为兄弟节点。如上图,I、J是兄弟节点
(7)节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推(有些地方会将根定义为第0层,题目若为特别声明,则一般视为第1层,不推荐从0开始)
(7)树的高度或深度:树中节点的最大层次,如上图,树的高度为4
(8)堂兄弟节点:双亲在同一层的节点互为堂兄弟节点。如上图,H、I互为堂兄弟节点
(9)节点的祖先:从根到该节点所经分支上的所有节点。如上图,A是所有节点的祖先
(10)子孙:以某节点为根的子树中任一节点都成为该节点的子孙。如上图,所有节点都是A的子孙
(11)森林:有m(m > 0)棵互不相交的树的集合称为森林
3.树的表示
树是一个非线性结构,要表示起来相对比较麻烦,在一般情况是采用以链表为存储方式的“孩子兄弟表示法”,简单来说就是采取父节点找兄弟节点的第一个节点,兄弟节点找下一个兄弟节点的方式,即“父节点——>子节点中的第一个兄弟节点——>其他兄弟节点——>以兄弟节点为父节点的其他子节点”
如果以图的方式展现,那么上图的树在逻辑结构上的大致就如下图所示
在物理结构上就是一般是如下所示,结构体中除数据域外,还有两个结构体指针,分别用于指向左边的第一个子节点和下一个兄弟节点。
typedef int DataType;
struct Node
{
DataType data;//节点中的数据域
struct Node* FirstChild;//左边第一个孩子节点
struct Node* PNextBrother;//指向其下一个兄弟节点的节点
};
因为普通的树一般情况下在存储数据和查找数据上几乎没有优势,因此这里就不写出普通的树的代码实现(但要注意的是,并不是说普通的树在实际中就没有应用,比如现在的电脑中的文件存储目录系统实际就是以树的形式形成的)
二、二叉树
1.二叉树的概念
二叉树属于树的一种,顾名思义就是最多只有两个子节点的树,如下图所示:
一棵二叉树是节点的一个有限集合,该集合:
(1)或者为空
(2)由一个根节点加上两棵别称为左子树和右子树的二叉树组成
二叉树的特点:
(1)二叉树不存在度大于2的节点
(2)二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
(3)对于任意的二叉树都是由以下几种情况复合而成:
2.二叉树的性质
(1)若规定根节点的层数为1,则一棵非空二叉树的第i层最多有2 ^(i - 1)个节点
(2)若规定根节点的层数为1,则深度为h的二叉树的最大节点数是2 ^ h - 1
(3)对任何一棵二叉树,如果度为0的叶节点个数为n0,度为2的分支节点个数为n2,则有n0 = n2 + 1
(4)若规定根节点的层数为1,具有n个节点的满二叉树的深度,h = log2 (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则无右孩子
(6)对任何一棵二叉树,如果度为0的节点有i0个,度为1的节点有i1个,度为2的节点有i2个,以此往前推,则总结点的个数n = i0 + i1 + i2 + ……+in
3.二叉树的链式存储
二叉树一般采用链表的形式存储。在结构体中分为数据域和左右指针域。数据域中存储要存放的数据,左右指针域中存储着左子节点的指针和右子节点的指针。而链式结构又分为二叉链和三叉链。当前的这种方法属于二叉链,三叉链则是多一个指针指向父节点
4.二叉树的遍历方式
二叉树的遍历方式有4种,分别是前序遍历,中序遍历,后序遍历和层序遍历
在遍历时,要注意到叶节点的左右子节点为空,以上图为例,在遍历中应该看到其中的空节点,即把图修改为如下所示:
(1)前序遍历
前序遍历是从根节点开始向下遍历,依次遍历左节点,右节点。即“根节点——>左节点——>右节点”。以上图为例,前序遍历为A->B->D->NULL->NULL->F->E->NULL->NULL->NULL->C->G->NULL->H->NULL->NULL->I->NULL->NULL
(2)中序遍历
中序遍历的遍历顺序是“左节点——>根节点——>右节点”,以上图为例遍历顺序为:“NULL->D->NULL->B->NULL->E->NULL->F->NULL->A->NULL->G->NULL->H->NULL->C->NULL->I->NULL”
(3)后序遍历
后序遍历的遍历顺序是“左节点——>右节点——>根节点”,以上图为例遍历顺序为:“NULL->NULL->D->NULL->NULL->E->NULL->F->B->NULL->NULL->NULL->H->G->NULL->NULL->I->C->A”
(4)层序遍历
层序遍历,顾名思义就是一层一层的遍历,以上图为例遍历顺序为:“A->B->C->D->F->G->I->NULL->NULL->E->NULL->NULL->H->NULL->NULL->NULL->NULL->NULL->NULL”
此处的NULL只是为了方便看到遍历的读取顺序才写出来,在实际中遇到NULL时无需访问。
在二叉树实现中,使用的是函数递归的形式,如果使用迭代的方式的话代码则会非常复杂,而函数递归则相对简单
5.代码实现
要注意,二叉树的数据因为其存在无序性,并且每个子节点都可能为空,因此普通二叉树的增删查改没有意义。所以在以下的代码实现中并不会写增删查改
(1)头文件包含及函数声明
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include "Queue.h"
typedef char BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
BTNode* BinaryTreeCreate(BTDataType* a, int* pi);//输入一组数据构建二叉树
void BinaryTreePrevOrder(BTNode* root);//二叉树前序遍历(根节点->左节点->右节点)
void BinaryTreeInOrder(BTNode* root);//二叉树中序遍历(左节点->根节点->右节点)
void BinaryTreePostOrder(BTNode* root);//二叉树后序遍历(左节点->右节点->根节点)
void BinaryTreeLevelOrder(BTNode* root);//二叉树层序遍历(一层一层的逐层遍历,需要用到队列)
int BinaryTreeSize(BTNode* root);//二叉树节点个数
int BinaryTreeLeafSize(BTNode* root);//二叉树叶节点个数
int BinaryTreeHigh(BTNode* root);//二叉树高度
int BinaryTreeLevelKSize(BTNode* root, int k);//二叉树第k层节点个数
BTNode* BinarryTreeFind(BTNode* root, BTDataType x);//二叉树查找值为x的节点
int BinarryTreeComlete(BTNode* root);//判断二叉树是否是完全二叉树
void BinaryTreeDestory(BTNode** root);// 二叉树销毁
(2)构建二叉树
此处需要手动输入数据,如“ABD##E#H##CF##G##”,自行构建好二叉树的形式
BTNode* BinaryTreeCreate(BTDataType* a, int* pi)//输入一组数据以前序遍历的方式构建二叉树(a是传入的数组,
{ //pi是控制传入的数组下标)
assert(a);
BTNode* root = (BTNode*)malloc(sizeof(BTNode));
if (a[*pi] == '#')
{
root = NULL;
++(*pi);
return root;
}
else
{
root->data = a[*pi];
++(*pi);
}
root->left = BinaryTreeCreate(a, pi);
root->right = BinaryTreeCreate(a, pi);
return root;
}
(3)二叉树前序遍历
void BinaryTreePrevOrder(BTNode* root)//二叉树前序遍历(根节点->左节点->右节点)
{
if (root == NULL)
{
printf("NULL ");
return;
}
printf("%c ", root->data);
BinaryTreePrevOrder(root->left);
BinaryTreePrevOrder(root->right);
}
(4)二叉树中序遍历
void BinaryTreeInOrder(BTNode* root)//二叉树中序遍历(左节点->根节点->右节点)
{
if (root == NULL)
{
printf("NULL ");
return;
}
BinaryTreeInOrder(root->left);
printf("%c ", root->data);
BinaryTreeInOrder(root->right);
}
(5)二叉树后序遍历
void BinaryTreePostOrder(BTNode* root)//二叉树后序遍历(左节点->右节点->根节点)
{
if (root == NULL)
{
printf("NULL ");
return;
}
BinaryTreePostOrder(root->left);
BinaryTreePostOrder(root->right);
printf("%c ", root->data);
}
(6)二叉树层序遍历
二叉树的层序遍历在逻辑上非常简单,仅仅只是逐层遍历。但是二叉树的逻辑结构是一棵“倒挂的树”,每个节点之间相互独立,互不交集。因此在实现时为了达到每个数都依次进入,可以使用队列。
以该二叉树为例,那么我们需要将这个二叉树中的每一个节点都插入到队列当中。要注意的是,插入队列的是结构体指针,而不是结构体中的数据。
通过二叉树的知识也可以了解到,二叉树的结构体中存在两个指针,分别指向其左右子节点。因此在插入一个节点后要用结构体指针将父节点的两个子节点带入队列中。
以A为例,A先插入队列,然后获取队列中队头的数据并用一个指针指向改空间,再将队头数据删除,A的左右节点指针将B和C的地址放入队列,如下图所示,A带B和C,B带D和E,这样依次带动各自的子节点,便可将整个二叉树的节点依次放入队列
当然,上图只是为了方便表现插入的方式,在代码实现时,只需插入不为空的节点指针即可
此处因为使用的是c语言实现,库中没有队列函数,因此使用的是自己写的队列,在下面的代码中没有写明。如果使用c++等库中有队列函数的语言时直接引用函数即可
void BinaryTreeLevelOrder(BTNode* root)//二叉树层序遍历(一层一层的逐层遍历,需要用到队列)
{
Queue qn;
QueueInit(&qn);
if (root)
QueuePush(&qn, root);
while (!QueueEmpty(&qn))
{
BTNode* front = QueueFront(&qn);//用一个front结构体指针指向头节点
QueuePop(&qn);
printf("%c ", front->data);
if (front->left)
QueuePush(&qn, front->left);
if (front->right)
QueuePush(&qn, front->right);
}
QueueDistory(&qn);
}
(7)二叉树节点及二叉树叶节点个数
采用后序遍历的方式,先遍历左子树,再遍历右子树
int BinaryTreeSize(BTNode* root)//二叉树节点个数
{
if (root == NULL)
{
return 0;
}
return BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 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);
}
(8)二叉树高度
采用后序遍历的方式,从下至上计算每个子树的高度,返回每个子树中较大的下属子树的高度
int BinaryTreeHigh(BTNode* root)//二叉树高度
{
if (root == NULL)
{
return 0;
}
int lefthigh = BinaryTreeHigh(root->left);
int righthigh = BinaryTreeHigh(root->right);
return lefthigh > righthigh ? lefthigh + 1 : righthigh + 1;
}
(9)二叉树第k层节点
int BinaryTreeLevelKSize(BTNode* root, int k)//二叉树第k层节点个数
{
assert(k >= 0);
if (k == 0 || root == NULL)
{
return 0;
}
if (k == 1)
{
return 1;
}
return BinaryTreeLevelKSize(root->left, k - 1) + BinaryTreeLevelKSize(root->right, k - 1);
}
(10)二叉树查找值为x的节点
采用前序遍历的方式,找到x便返回。但是由于普通二叉树数据的无序性,该函数在这里并没有什么意义
BTNode* BinarryTreeFind(BTNode* root, BTDataType x)//二叉树查找值为x的节点
{
BTNode* find = NULL;
if (root == NULL)
{
return find;
}
if (root->data == x)
{
find = root;
}
if (find == NULL)
{
find = BinarryTreeFind(root->left, x);
if (find != NULL)
{
return find;
}
find = BinarryTreeFind(root->right, x);
}
return find;
}
(11)判断二叉树是否为完全二叉树
此处要采取层序遍历的方式,将二叉树的数据逐层放入队列中,逐个遍历数据,遍历到空的时候便跳出队列遍历,然后进入下一个循环继续遍历。
如果在遍历队列时遇到不为空的节点时便可判断不是完全二叉树,返回false,反之若遍历完队列后没有遇到不为空的节点则是完全二叉树,返回true
int BinarryTreeComlete(BTNode* root)//判断二叉树是否是完全二叉树
{
Queue qn;
QueueInit(&qn);
if (root)
QueuePush(&qn, root);
while (!QueueEmpty(&qn))//层序遍历,遇到NULL则跳出循环
{
BTNode* front = QueueFront(&qn);//用一个front结构体指针指向头节点
QueuePop(&qn);
if (front == NULL)
{
break;
}
QueuePush(&qn, front->left);
QueuePush(&qn, front->right);
}
while (!QueueEmpty(&qn))//继续层序遍历,如果队列后面的数据全为NULL,则返回true,否则返回false
{
BTNode* front = QueueFront(&qn);
QueuePop(&qn);
if (front != NULL)
{
QueueDistory(&qn);
return false;
}
}
QueueDistory(&qn);
return true;
}
(12)二叉树销毁
void BinaryTreeDestory(BTNode* root)// 二叉树销毁
{
if (root == NULL)
{
return;
}
BinaryTreeDestory(root->left);
BinaryTreeDestory(root->right);
free(root);
root->left = NULL;
root->right = NULL;
root->data = 0;
root = NULL;
}
三、完全二叉树
1.特殊的二叉树
(1)满二叉树:一个二叉树,如果每一层的节点数都达到最大值,则这个二叉树就是满二叉树。也就是,如果一个二叉树的层数为K,且节点总数是2^K - 1,则它就是满二叉树
(2)完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个节点的二叉树,当且仅当其每一个节点都与深度为K的满二叉树中编号为1至n的节点一一对应时称之为完全二叉树,要注意的是满二叉树是一种特殊的完全二叉树(完全二叉树的节点个数范围为[2^(K - 1), 2^K - 1])
2.完全二叉树(堆)实现
完全二叉树在数据结构中又被叫做堆,因此在下面的代码实现中都叫做堆
在代码实现前,我们要了解建堆的两个方法,一个是向下调整法,另一个则是向上调整法
1.向下调整法:从根节点开始往下调整,要使用向下调整法的前提是左右子树必须是一个堆,逻辑结构如下图所示:
2.向上调整法:在一个堆的末尾插入数据时,就采用向上调整法。这一方法是从最后一个节点开始,逐层向上比较父节点大小,满足条件则交换,直到条件不满足时结束,逻辑结构如下图所示:
(1)头文件包含及函数声明
在堆中因为除了最后一层的节点不满外,其他层的节点都是满的,在k层之前都不必考虑节点为空的情况,因此在堆中一般是采用数组的方式进行存储
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int HDataType;
typedef struct Heap
{
HDataType* data;//数据存储空间
int size;//数据个数
int capacity;//数据存储容量
}Heap;
void HeapInit(Heap* php);//数据初始化
void HeapPrint(Heap* php);//数据打印
void HAdjustUp(HDataType* data, int child);//向上调整法(仅适用于完全二叉树)
void HeapPush(Heap* php, HDataType x);//插入数据
void HeapJustDown(HDataType* data, int pose, int parent);//向下调整法(仅适用于完全二叉树)
void HeapPop(Heap* php);//删除堆顶数据
HDataType HeapTop(Heap* php);//获取堆顶数据
int HeapSize(Heap* php);//获取堆中元素个数
bool HeapEmpty(Heap* php);//判断堆中是否为空
void HeapDistory(Heap* php);//数据销毁
void PrintHeapK(Heap* php, int child, int k);//找出前n个最大或最小的数
void HeapRestore(Heap* php);//堆在使用PrintHeapK之后如需要还原堆可使用
void HeapSort(int* a, int n);//堆排序,升序建大堆,降序建小堆
(2)结构体初始化及堆销毁
void HeapInit(Heap* php)//结构体初始化
{
php->data = NULL;
php->size = php->capacity = 0;
}
void HeapDistory(Heap* php)//数据销毁
{
assert(php);
free(php->data);
php->data = NULL;
php->size = php->capacity = 0;
}
(3)数据交换
该函数在下面的很多函数中都需要用到,最好单独写一份,避免代码重复
void swap(HDataType* child, HDataType* parent)//数据交换
{
HDataType cur = *child;
*child = *parent;
*parent = cur;
}
(4)向上调整法实现
堆是完全二叉树,不用考虑中间的节点存在空的情况,同时采用的是数组的形式存储,因此父节点的下标可以用(child - 1) / 2来计算,child就是子节点的下标
void HAdjustUp(HDataType* data, int child)//向上调整法(仅适用于二叉树)
{
assert(data);
int parent = (child - 1) / 2;
while (data[child] < data[parent])//"<"建小堆,">"建大堆,若要修改应与HAdjustDown函数注释处同时修改
{
swap(&data[child], &data[parent]);
child = parent;
parent = (child - 1) / 2;
if (child == 0)
{
break;
}
}
}
(5)插入数据
因为采用的是动态数组存储的形式,因此使用realloc()函数来控制数组的开辟
void HeapPush(Heap* php, HDataType x)//插入数据
{
assert(php);
if (php->size == php->capacity)
{
int newcapacity = (php->capacity == 0) ? 4 : 2 * php->capacity;
HDataType* tmp = (HDataType*)realloc(php->data, newcapacity * sizeof(HDataType));
if (tmp == NULL)
{
perror("realloc fail");
exit(-1);
}
php->data = tmp;
php->capacity = newcapacity;
}
php->data[php->size] = x;
++php->size;
int child = php->size - 1;
HAdjustUp(php->data, child);
}
(6)向下调整法实现
此处定义了minchild和maxchild来区分子节点之间的大小关系。在二叉树中,根据其特性,左子节点的下标为parent * 2 + 1,右子节点的下标为parent * 2 + 2
void HeapJustDown(HDataType* data, int pose, int parent)//向下调整法
{ //单独使用时手while中第一个if的第二个“>”和第二个if中的
assert(data); //“>”控制,都为“>”时建大堆,反之为小堆
int minchild = parent * 2 + 1;
int maxchild = parent * 2 + 2;
if (pose <= 2)
{
minchild = 0;
maxchild = 1;
}
while (minchild < pose)
{
if (maxchild < pose && data[minchild] > data[maxchild])//此处的第二个“>”和下面的if中的“>”
{ //要同时修改,修改时应与HAdjustUp函数中的
swap(&minchild, &maxchild); //注释处同时修改
}
if (data[parent] > data[minchild])
{
swap(&data[parent], &data[minchild]);
}
parent = minchild;
minchild = parent * 2 + 1;
maxchild = parent * 2 + 2;
}
}
(7)删除堆顶数据
一般来讲,二叉树作为一种存储数据的方式,不会对其内部的数据删除,而是从堆顶数据开始删除。因此,将堆顶数据与堆尾数据交换,然后删除堆尾数据并调整堆即可。当然,如有必要也可以自行写一个查找函数找到堆中需删除的数据下标,传入即可
void HeapPop(Heap* php)//删除堆顶数据
{
assert(php);
assert(!HeapEmpty(php));
swap(&php->data[0], &php->data[php->size - 1]);
--php->size;
HeapJustDown(php->data, php->size, 0);
}
(8)获取堆顶数据,获取堆中数据个数及判断堆是否为空
HDataType HeapTop(Heap* php)//获取堆顶数据
{
assert(php);
assert(!HeapEmpty(php));
return php->data[0];
}
int HeapSize(Heap* php)//获取堆中元素个数
{
assert(php);
return php->size;
}
bool HeapEmpty(Heap* php)//判断堆中是否为空
{
assert(php);
return php->size == 0;
}
(9)找前n个最大或最小的数
每找一次都需要将堆顶的数据与堆尾交换,并--child这一堆尾下标,用向下调整法重新找堆顶
void PrintHeapK(Heap* php, int child, int k)//找出前n个最大或最小的数
{
assert(php);
assert(!HeapEmpty(php));
while (k && child)
{
printf("%d ", php->data[0]);
swap(&php->data[0], &php->data[child]);
HeapJustDown(php->data, child, 0);
--k;
--child;
}
printf("\n");
}
(10)堆排序
传入一组随机排序的数据,将其按照升序或降序的方式排序
要注意的是,在堆排序中,升序建大堆,降序建小堆。因为堆排序的逻辑是将堆顶的数据与堆尾的数据依次交换。这样,大堆排序后,堆尾的数据是最大的值并依次堆顶缩小;小堆排序后,堆尾的数据是最小的值并依次堆顶增大
同时,因为传入的是一组随机排序的数据,在排序之前要将其建为大堆或小堆。采取向下调整法的方式。在采取向下调整法的时候,就是要将一棵总的二叉树的视为由不同的子二叉树构成,对每一棵二叉树进行向下调整。简单来说,就是要从最后一个有子节点的父节点开始调整。如下图所示:
在对该图中的数据建堆时,要先将二叉树1中28视为堆顶,向下调整;再将二叉树2中的18视为堆顶,向下调整;再将二叉树3中的19视为堆顶,向下调整;这样依次扩大自二叉树的包含范围,进而从下至上将各个子二叉树建堆,直至到总的二叉树的堆顶调整结束
选数时,依次将堆顶与堆尾的数据相交换,如有10个数据,把最大或最小值放到下标为9的堆尾处,然后从堆顶向下调整堆。调整完后将堆顶的数据与下标为8的堆尾处交换,向下调整。直到堆尾的下标为0
void HeapSort(int* a, int n)//堆排序,升序建大堆,降序建小堆
{
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
HeapJustDown(a, n, i);
}
//选数
int i = 1;
while (i < n)
{
swap(&a[0], &a[n - i]);
HeapJustDown(a, n - i, 0);
++i;
}
}