今天和大家分享的是二叉树的实现,关于遍历二叉树部分均采用递归的方式实现,最后附上部分OJ题供大家练习。
文章目录
- 一、树的概念及结构
- 1.1 树的概念
- 1.2 树的相关概念
- 1.3 树的表示
- 二、二叉树的概念及结构
- 2.1 概念
- 2.2 二叉树的性质
- 2.3 二叉树的存储结构
- 2.3.1 二叉树的顺序结构
- 2.3.2 二叉树的链式结构
- 三、二叉树的顺序结构及实现
- 3.1 二叉树的顺序结构
- 3.2 堆的概念及结构
- 3.3 堆的实现
- 3.3.1 堆的向下调整算法
- 3.3.2 堆的向上调整算法
- 3.3.3 堆的创建
- 3.3.4 堆的插入
- 3.3.5 堆的删除
- 3.3.6 堆的应用
- 堆排序
- TOP_K 问题
- 四、二叉树的链式结构及实现
- 4.1 二叉树的遍历
- 4.1.1 DFS 深度优先遍历(递归)
- 前序遍历(先根遍历)
- 中序遍历(中根遍历)
- 后序遍历(后根遍历)
- 4.1.2 BFS 广度优先遍历(队列)
- 层序遍历
- 4.2 判断二叉树是否为完全二叉树
- 4.3 二叉树的节点个数
- 4.4 二叉树叶子节点的个数
- 4.5 二叉树第 k 层的节点个数
- 4.6 二叉树的高度
- 4.7 二叉树创建和销毁
一、树的概念及结构
1.1 树的概念
树是一种非线性的数据结构,其理论模型像一棵倒立的树,因此得名。我们通常将最上方的结点成为根结点,它是没有先驱结点的。而其余结点都有且仅有一个先驱结点,也就是说,树结构中不存在通路,也就是不存在交集。
如下图,是几种树结构。
但是下面几种,则不是树结构
1.2 树的相关概念
我们用以下图片来讲解树的相关概念
节点的度:一个节点含有的子树的个数称为该节点的度。如上图:A的为2,B的度为3,D的度为0。
叶节点或终端节点:度为0的节点称为叶节点。 如上图:D、H、E、F、G为叶节点。
非终端节点或分支节点:度不为0的节点被称为分支结点。如上图:A、B、C为分支节点。
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。如上图:A是B的父节点,B是D、H、E的父节点。
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点。 如上图:B是A的孩子节点,D、H、E都是B的孩子节点。
兄弟节点:具有相同父节点的节点互称为兄弟节点。 如上图:B、C是兄弟节点,D、H、E是兄弟结点,但E、F不是兄弟结点。
树的度:一棵树中,最大的节点的度称为树的度。如上图:B的度最大,为3,因此树的度为3。
节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推。如上图:A为第一层,B、C为第二层,其余为第三层。
树的高度或深度:树中节点的最大层次。如上图:树的高度为3。
堂兄弟节点:双亲在同一层的节点互为堂兄弟。如上图:D、H、E和F、G互为堂兄弟节点。
祖先:从根到该节点所经分支上的所有节点。如上图:A是所有节点的祖先,B是D、H、E的祖先。
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙,D、H、E为B的子孙。
森林:由m(m>0)棵互不相交的树的集合称为森林。一棵树的情况也是森林,如上图就是一个森林。
1.3 树的表示
此处讲的是树,而树的每一个结点不一定确定,因此我们最常用的是孩子兄弟表示法,如下。
typedef int Datatype;
struct Node
{
struct Node* first_Child; // 第一个孩子结点
struct Node* next_Brother; // 指向下一个兄弟结点
Datatype data; // 数据域
}
二、二叉树的概念及结构
2.1 概念
二叉树,故名思意就是分叉只有两个,即每一个树结点最多存在两个孩子结点。同时需要注意,这两个孩子结点有左右之分(如下图),因此二叉树也是有序树。
2.2 二叉树的性质
- 若规定根节点层数为 1,则一棵非空二叉树的第 i 层上最多有 2(i-1) 个结点。
- 若规定根节点层数为 1,则深度为 h 的二叉树的最大结点数是 2h-1。
- 对任何一棵二叉树, 如果度为 0 其叶结点个数为 n0, 度为 2 的分支结点个数为 n2,则有 n0=n2+1。
- 若规定根节点层数为 1,具有 n 个结点的满二叉树的深度,h= log2(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 则无右孩子。
2.3 二叉树的存储结构
二叉树一般有两种储存方式,一种是顺序结构,另一种则是链式结构。但是前者的使用条件有些限制。
2.3.1 二叉树的顺序结构
顺序结构的应用场景通常局限于完全二叉树,主要原因在于,非完全二叉树用顺序结构储存会存在空间的浪费,以及不便于查找是否存在结点。
2.3.2 二叉树的链式结构
相比之下二叉树更适合链式结构,一方面是空间利用率高,另一方是更符合其逻辑结构。二叉树其实就像有分叉的链表,分叉处便是决定左右孩子处。
三、二叉树的顺序结构及实现
3.1 二叉树的顺序结构
上文我们已经说到,普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费,而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆,使用顺序结构的数组来存储,需要注意的是这里的堆和地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
3.2 堆的概念及结构
堆是一种数据结构,是一种特殊的二叉树。堆首先是一种完全二叉树,其次,每个结点的孩子结点均不大于或不小于其父结点。
设存在集合 K = {k0,k1,k2,…,kn},将 K 按照完全二叉树的顺序依次存入一维数组,且满足( ki <= k2i+1 , ki <= k2i+2,i = 0,1,2,…,n),则称为小堆(小根堆,最小堆),若上述不等式为 >= ,则称为大堆(大根堆,最大堆)。
3.3 堆的实现
为了方便大小堆的创建,使用了预处理和必要的重定义,内容如下:
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
#define judge <
// < 为小堆,> 为大堆
3.3.1 堆的向下调整算法
对于一个结点,当其左右子树均为同一类型堆(大堆或小堆)时,可以对该节使用向下调整算法,首先我们需要找出较小的孩子结点,然后使该结点和较小的孩子结点交换,这样就使得该节点和其左右子树共同成为堆。
3.3.2 堆的向上调整算法
相比于向下调整算法,向上调整算法对子树和父结点没有要求,仅需不断比较然后前移交换即可。
3.3.3 堆的创建
我们通过读取一个数组的内容来创建一个堆。这里主要用两种方法进行创建,一种是向上调整,一种是向下调整。
向上调整的思路:我们每插入一个数字就向上调整,这样就保证了已经插入的所有数据组成堆,插入的时间复杂度为 O(log2n),建堆的时间复杂度为 O(n*log2n);
向下调整的思路:我们先将数组全部插入堆中,然后按照其逻辑结构,从第一个非叶子结点的结点开始向下调整,插入的时间复杂度为O(log2n),建堆时间复杂度为 O(n)。
void HeapCreate(Heap* hp, HPDataType* a, int size)
{
hp->_a = (HPDataType*)malloc(sizeof(HPDataType) * size * 2);
hp->_size = 2 * size;
hp->_capacity = 0;
int adWay(0); // 0为向下调整, 1为向上调整
switch (adWay)
{
case 0:
hp->_capacity = size;
for (int i = 0; i < size; i++)
hp->_a[i] = a[i];
for (int i = (size - 2) / 2; i >= 0; i--)
AdjustDown(hp, i);
break;
case 1:
for (int i = 0; i < size; i++, hp->_capacity++)
{
hp->_a[i] = a[i];
AdjustUp(hp, i);
}
break;
}
}
3.3.4 堆的插入
我们已经知道了向下调整算法和向上调整算法,但是对于一个已知的堆,插入数据如果使用向下 调整的话,那么不仅需要将数组整体后移,同时数组也不一定保持堆结构,还需要重新建堆,这样一来时间复杂度就上升了,因此我们通常采用向上调整算法。
向上调整思路:由于原本堆结构完好,我们只需再最后插入新的数据,将其向上调整即可,不会破坏原本的堆结构。
void HeapPush(Heap* hp, HPDataType x)
{
assert(hp);
if (hp->_capacity == hp->_size)
{
hp->_size = hp->_size == 0 ? 10 : 2 * hp->_size;
hp->_a = (HPDataType*)realloc(hp->_a, sizeof(HPDataType) * (hp->_size));
if (hp->_a == nullptr)
{
perror("realloc fail!\n");
exit(0);
}
}
hp->_a[hp->_capacity] = x;
AdjustUp(hp, hp->_capacity);
hp->_capacity++;
}
3.3.5 堆的删除
通常意义上,堆的删除是指删除堆顶元素,常用的解决思路是覆盖删除法,其思路为,将堆的最后一个元素覆盖到堆顶元素,然后对新的堆顶元素采用向下调整算法即可。
void HeapPop(Heap* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
exchange(hp->_a[hp->_capacity - 1], hp->_a[0]);
hp->_capacity--;
AdjustDown(hp, 0);
}
3.3.6 堆的应用
堆作为一种有序二叉树,其主要作用就是解决排序类问题。
堆排序
堆排序实际上就是一个建堆和出堆的过程,在这个过程我们需要注意的是升序建大堆,降序建小堆。
// 堆的判空
int HeapEmpty(Heap* hp)
{
assert(hp);
return hp->_capacity == 0;
}
// 取堆顶的数据
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
return hp->_a[0];
}
void HeapSort(int* arr, int size)
{
Heap x;
HeapCreate(&x, arr, size);
for (int i = 0; i < size; i++)
{
arr[i] = HeapTop(&x);
HeapPop(&x);
}
}
TOP_K 问题
TOP_K 问题:在一个集合中,找出前 K 大(小)的元素。
这类问题我们需要注意的是:找大建小,找小建大。
以找出前 K 大的元素为例:先用集合的前 K 个元素建立小堆,此时堆顶元素是堆内最小的元素,然后遍历集合剩下的其他元素,如果遍历元素小于堆顶元素,则覆盖堆顶元素并向下调整,遍历完之后,堆内元素就是 TOP_K 元素了。
- 集合元素个数较小时,直接全部元素建堆即可
void TopK_small(int* a, int n, int k)
{
Heap x;
HeapCreate(&x, a, n);
HeapPrint(&x);
for (int i = 0; i < k; i++)
{
printf("%d ", HeapTop(&x));
HeapPop(&x);
}
printf("\n");
}
- 集合元素较多,需要存放在文件中时,只能建 K 个元素的小堆
void PrintTopK_big(const char* FILE_Name, int n, int k)
{
HPDataType p(0);
Heap x;
FILE* data = fopen(FILE_Name, "r");
if (data == nullptr)
{
perror("FILE open fail!\n");
exit(0);
}
HeapCreate(&x, NULL, 0);
for (int i = 0; i < k; i++)
{
fscanf(data, "%d", &p);
HeapPush(&x, p);
}
while(fscanf(data, "%d", &p) != EOF)
{
if (p > HeapTop(&x))
{
x._a[0] = p;
AdjustDown(&x, 0);
}
}
HeapPrint(&x);
HeapDestory(&x);
}
四、二叉树的链式结构及实现
二叉树的很多问题都可以用递归实现,因此遇到问题首要的想法是能不能转换为左右子树问题求解。
4.1 二叉树的遍历
二叉树的核心内容就是遍历,学好遍历我们才能掌握如何按照某种顺序创建二叉树。
4.1.1 DFS 深度优先遍历(递归)
深度优先的意思就是,遍历的顺序主要先探索到某一个子树的最深结点,再依次探索其它子树。
前序遍历(先根遍历)
前序遍历:遇到每个结点,先打印出该节点的值,再去遍历左右子树
void BinaryTreePrevOrder(BTNode* root)
{
if (root)
{
cout << root->_data << " ";
BinaryTreePrevOrder(root->_left);
BinaryTreePrevOrder(root->_right);
}
else
{
cout << "NULL ";
}
}
中序遍历(中根遍历)
中序遍历:遇到每个结点,先进入左子树,直到遍历完左子树时,打印出该节点的值,再去遍历右子树
void BinaryTreeInOrder(BTNode* root)
{
if (root)
{
BinaryTreeInOrder(root->_left);
cout << root->_data << " ";
BinaryTreeInOrder(root->_right);
}
else
{
cout << "NULL ";
}
}
后序遍历(后根遍历)
后序遍历:遇到每个结点,先遍历左子树,直到没有左子树时,遍历右子树,当左右子树都遍历完时,打印该节点的值
void BinaryTreePostOrder(BTNode* root)
{
if (root)
{
BinaryTreePostOrder(root->_left);
BinaryTreePostOrder(root->_right);
cout << root->_data << " ";
}
else
{
cout << "NULL ";
}
}
4.1.2 BFS 广度优先遍历(队列)
广度优先的意思就是,优先遍历完同一深度的所有结点,再遍历其他深度。
层序遍历
我们用队列的方式遍历每一层,我们先将二叉树的根节点入队,随后我们遍历队内元素,对于每个元素,我们将其左右孩子结点入队,同时将该结点出队,直到队内无其他元素。
// QueueInit 队列初始化
// QueuePush 元素入队尾
// QueueFront 获取队首元素
// QueuePop 队首元素出队
void BinaryTreeLevelOrder(BTNode* root)
{
int size = root ? 1 : 0;
Queue* arr = (Queue*)malloc(sizeof(Queue));
QueueInit(arr);
QueuePush(arr, root);
BTNode* rec = NULL;
while (size)
{
rec = QueueFront(arr);
if (rec)
{
cout << rec->_data << " ";
if (rec->_left)
{
size++;
QueuePush(arr, rec->_left);
}
else
{
QueuePush(arr, NULL);
}
if (rec->_right)
{
size++;
QueuePush(arr, rec->_right);
}
else
{
QueuePush(arr, NULL);
}
}
if (!rec)
cout << "NULL ";
if (rec)
size--;
QueuePop(arr);
}
cout << endl;
}
4.2 判断二叉树是否为完全二叉树
判断完全二叉树的方法需要用到我们上面学到的层序遍历,我们依次遍历每一层,如果二叉树为完全二叉树,那么在第一次遇到空结点之后,就不会再有非空结点了。
int BinaryTreeComplete(BTNode* root)
{
Queue* arr = (Queue*)malloc(sizeof(Queue));
QueueInit(arr);
QueuePush(arr, root);
int flag = 0;
BTNode* rec = NULL;
while (!QueueEmpty(arr))
{
rec = QueueFront(arr);
if (rec->_left)
{
if (flag)
return 0;
QueuePush(arr, rec->_left);
}
else
flag = 1;
if (rec->_right)
{
if (flag)
return 0;
QueuePush(arr, rec->_right);
}
else
flag = 1;
QueuePop(arr);
}
return 1;
}
4.3 二叉树的节点个数
这里我们就需要用到之前提到的转换为左右子树求解问题,一棵二叉树的结点数可以分为左右子树的结点数加上自身。
int BinaryTreeSize(BTNode* root)
{
if (!root)
return 0;
return root == NULL ? 0 : BinaryTreeSize(root->_right) + BinaryTreeSize(root->_left) + 1;
}
4.4 二叉树叶子节点的个数
叶子结点的个数为左右子树中叶子结点的个数
int BinaryTreeLeafSize(BTNode* root)
{
if (!root)
return 0;
return (root->_left == NULL && root->_right == NULL) ? 1 :
((root->_left == NULL ? 0 : BinaryTreeLeafSize(root->_left)) + (root->_right == NULL ? 0 : BinaryTreeLeafSize(root->_right)));
}
4.5 二叉树第 k 层的节点个数
我们同样是转换为左右子树问题,不过需要注意的是,这里每次递归的子树求解问题发生了一定改变,我们求第 k 层,就是求相对于第二层的第 k-1 层的节点数。 具体的代码如下。
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (!root || k <= 0)
return 0;
if (k == 1)
return 1;
return BinaryTreeLevelKSize(root->_left, k - 1) + BinaryTreeLevelKSize(root->_right, k - 1);
}
4.6 二叉树的高度
同样的,二叉树的高度也可以转换为左右子树求解问题,一颗二叉树的高度为自身加上左右子树中最高的高度。
需要注意的是,这里要先计算高度,再用三目运算符,不然会重复计算一次左右子树高度,会浪费一定时间。
int BinaryTreeHigh(BTNode* root)
{
if (!root)
return 0;
int leftHigh = BinaryTreeHigh(root->_left);
int rightHigh = BinaryTreeHigh(root->_right);
return 1 + (leftHigh > rightHigh ? leftHigh : rightHigh);
}
4.7 二叉树创建和销毁
这里讲解按照前序遍历的方式创建二叉树,给出一个字符串,用 # 代表空结点,按照前序遍历的方式创建二叉树。
BTNode* BinaryTreeCreate(BTDataType* a, int size, int* returnsize)
{
if (a[*returnsize] == '#')
{
(*returnsize)++;
return NULL;
}
else if (*returnsize < size)
{
BTNode* new_node = (BTNode*)malloc(sizeof(BTNode));
if (!new_node)
perror("malloc fail\n");
new_node->_data = a[*returnsize];
(*returnsize)++;
new_node->_left = BinaryTreeCreate(a, size, returnsize);
new_node->_right = BinaryTreeCreate(a, size, returnsize);
return new_node;
}
return NULL;
}
二叉树的销毁则更加简单,只需要遍历整棵树,然后 free 掉每一个结点即可。需要注意的是需要传入二级指针,不然无法 free 掉树的根节点。
void BinaryTreeDestory(BTNode** root)
{
if (!(*root))
return;
if ((*root)->_left)
BinaryTreeDestory(&((*root)->_left));
if ((*root)->_right)
BinaryTreeDestory(&((*root)->_right));
free(*root);
*root = NULL;
}