本文相关练习题目及答案
【C语言】二叉树练习题
结点是数据结构中的基础,是构成复杂数据结构的基本组成单位。
本系列文章中提及的结点专指树的结点。例如:结点A在图中表示为:
树(Tree)是n(n>=0)个结点的有限集。n=0 时称为空树。在任意一颗非空树中:
1)有且仅有一个特定的称为根(Root)的结点;
2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、…、Tn,其中每一个集合本身又是一棵树,并且称为根的子树。
此外,树的定义还需要强调以下两点:
1)n>0时根结点是唯一的,不可能存在多个根结点,数据结构中的树只能有一个根结点。
2)m>0时,子树的个数没有限制,但它们一定是互不相交的。
示例树:
一棵普通的树:
一个节点含有的子树的个数称为该节点的度;
下图标注了所示树的各个结点的度。
结点子树的根结点为该结点的孩子结点。相应该结点称为孩子结点的双亲结点。
图2.2中,A为B的双亲结点,B为A的孩子结点。
同一个双亲结点的孩子结点之间互称兄弟结点。
图2.2中,结点B与结点C互为兄弟结点。
从根开始定义起,根为第一层,根的孩子为第二层,以此类推。
树中结点的最大层次数称为树的深度或高度。图2.1所示树的深度为4。
二叉树是n(n>=0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树组成。
图3.1展示了一棵普通二叉树:
由二叉树定义以及图示分析得出二叉树有以下特点:
1)每个结点最多有两颗子树,所以二叉树中不存在度大于2的结点。
2)左子树和右子树是有顺序的,次序不能任意颠倒。
3)即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。
满二叉树:在一棵二叉树中。如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。
满二叉树的特点有:
叶子只能出现在最下一层。出现在其它层就不可能达成平衡。
非叶子结点的度一定是2。
在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。
每一层的节点都是满的,k层的节点数为 2 k − 1 2^{k-1} 2k−1,总的节点数 2 k − 1 2^k-1 2k−1
如果一个满二叉树有N个节点,那么它的高度(层数)为 l o g 2 ( N + 1 ) log_2{(N+1)} log2(N+1)
完全二叉树:若一棵二叉树前k-1层都是满的,最后一层可以不满,但是从左到右都是连续的,则这棵二叉树称为完全二叉树。
特点:
1)叶子结点只能出现在最下层和次下层。
2)最下层的叶子结点集中在树的左部。
3)倒数第二层若存在叶子结点,一定在右部连续位置。
4)如果结点度为1,则该结点只有左孩子,即没有右子树。
5)同样结点数目的二叉树,完全二叉树深度最小。
注:满二叉树一定是完全二叉树,但反过来不一定成立。
(1) 若 i = 0,则该结点是二叉树的根,无双亲, 否则,编号为 [ ( i − 1 ) / 2 (i-1)/2 (i−1)/2] 的结点为其双亲结点;
(2) 若 2 ∗ i + 1 2*i+1 2∗i+1 > n,则该结点无左孩子, 否则,编号为 2 ∗ i + 1 2*i+1 2∗i+1 的结点为其左孩子结点;
(3) 若 2 i + 2 2i+2 2i+2 > n,则该结点无右孩子结点, 否则,编号为 2 i + 2 2i+2 2i+2 的结点为其右孩子结点。
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
物理结构是一个数组,逻辑结构是完全二叉树
堆的概念及特性
如果有一个关键码的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储,在一个一维数组中,并满足: K i < = K 2 ∗ i + 1 Ki <= K2*i+1 Ki<=K2∗i+1 且 K i < = K 2 ∗ i + 2 Ki<= K2*i+2 Ki<=K2∗i+2 ( K i > = K 2 ∗ i + 1 Ki >= K2*i+1 Ki>=K2∗i+1 且 K i > = K 2 ∗ i + 2 Ki >= K2*i+2 Ki>=K2∗i+2) i = 0 , 1 , 2 … , i = 0,1,2…, i=0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
大根堆:树中所有父节点大于等于孩子节点
小根堆:树中所有父节点小于等于孩子节点
堆的性质:
堆中某个节点的值总是不大于或不小于其父节点的值;
堆总是一棵完全二叉树。
父子间关系表示
假设父节点的下标是parent
leftchild = parent*2 + 1
rightchild = parent*2 + 2
找父亲: parent = (child - 1)/ 2,用右节点不需要减2,会自动取整
特点:左子树和右子树恰好都是堆,才能调整
三个参数,数组的指针 节点总数(用于判断循环条件) 父节点的下标
思路:
选出左右孩子中小的那一个
小的这个孩子跟父亲比
a. 如果小的孩子比父亲小,则跟父亲交换,并且把原来孩子的下标作为父亲下标继续往下调整,直到父亲下标走到叶子节点
b. 如果小的孩子比父亲大,无需处理,调整完成,整个数已经是小堆
它的时间复杂度(运算次数)等于树的深度,就为 l o g N logN logN
void swap(int* a, int* b)
{
int tmp = *b;
*b = *a;
*a = tmp;
}
//向下调整算法--排大堆方式
void adjustdown(int* a, int size, int parent)
{
int child = parent * 2 + 1;//找到左孩子节点
while (child < size)
{
//判断左右孩子节点哪一个更大,右孩子大,则下标+1
if ( child+1 < size && a[child + 1] > a[child])
{
++child;
}
//孩子节点比父节点大就换到上面来
if (a[child] > a[parent])
{
swap(&a[child], &a[parent]);
}
else
{
break;
}
parent = child;
child = parent * 2 + 1;
}
}
如果左右子树不是小堆,怎么办?
从倒数的第一个非叶子节点(最后一个节点(下标n-1)的父亲),从后往前,按编号,依次作为子树去向下调整。父亲节点下标:i = (n-1 -1)/2
//建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
adjustDown(arr, size, i);
//arr是要调整的顺序表,size是节点个数,i是当前要调整的下标
}
排升序建大堆。降序建小堆
排升序为什么不能建小堆呢?建堆选出最小的数花时间N,建好堆后剩下的数父子关系全乱了,又要重新花时间N去建堆,效率太低
用大堆的思路:大堆排序后,将最大的数和最后一位数交换,紧接着选次大的,通过控制数组的大小就能不把最后一个数看作堆里面的,向下调整就能选出次大的(父子间的关系不变,左右子树仍然是大堆排列)。(小堆排序同理)
void HeapSort(int* a, int size)
{
//如果不满足向下调整算法的使用条件,要先进行调整
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
adjustDown(a, size, i);
}
int end = size - 1;
while (end > 0)
{
//堆排序后,将最大的数和最后一位数交换,紧接着选次大的,不把最后一个数看作堆里面的
swap(&a[0], &a[end]);
//end比总结点数小1,用end,刚好略去后面的数
//从0节点开始向下调整
AdjustDown(a, end, 0);
--end;
}
}
构建堆数据结构
typedef int HeapDateType;
typedef struct Heap
{
HeapDateType* arr;
int size;
int capacity;
}Heap;
// 堆的构建
void HeapCreate(Heap* hp, HeapDateType* a, int n)
{
assert(hp);
hp->arr = (HeapDateType*)malloc(sizeof(HeapDateType) * n);
if (hp->arr == NULL)
{
printf("malloc failed\n");
exit(-1);
}
//传进来的数组拷贝进去
memcpy(hp->arr, a, sizeof(HeapDateType) * n);
hp->size = hp->capacity = n;
//建堆
for (int i = (hp->size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(hp->arr, hp->size, i);
}
}
堆的销毁
// 堆的销毁
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->arr);
hp->arr = NULL;
hp->size = hp->capacity = 0;
}
堆中插入数据
插入一个新的数据,需要对原数据结构进行调整,因为新加入的数据是加在末尾,所以需要向上进行调整找到合适的位置。
//向上调整算法--大堆
void AdjustUp(int* a, int size, int child)
{
//找到父节点
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 HeapPush(Heap* hp, HeapDateType x)
{
assert(hp);
//先判断是否需要增容
if (hp->size == hp->capacity)
{
HeapDateType* tmp = (HeapDateType*)realloc(hp->arr, sizeof(HeapDateType) * (hp->capacity) * 2);
if (tmp == NULL)
{
printf("realloc failed\n");
exit(-1);
}
hp->arr = tmp;
hp->capacity *= 2;
}
//增容后,将数据加入
hp->arr[hp->size] = x;
++(hp->size);
//插入新的数据后要调堆,向上调整
AdjustUp(hp->arr, hp->size, hp->size - 1);//size-1才是新加入数据的下标,作为孩子节点传入
}
堆的删除
把第一个数挪到最后,size减一,然后将挪上来的数向下调整
void HeapPop(Heap* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
swap(&hp->arr[0], &hp->arr[hp->size - 1]);
hp->size--;
AdjustDown(hp->arr, hp->size, 0);
}
取堆顶的数据
HeapDateType HeapTop(Heap* hp)
{
assert(hp);
return hp->arr[0];
}
堆的数据个数
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
堆的判空
int HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
打印堆
void HeapPrint(Heap* hp)
{
for (int i = 0; i < hp->size; i++)
{
printf("%d ", hp->arr[i]);
}
printf("\n");
int sum = 0;
int level = 1;
for (int i = 0; i < hp->size; i++)
{
printf("%d ", hp->arr[i]);
++sum;
if (sum == level)
{
printf("\n");
sum = 0;
level *= 2;
}
}
printf("\n\n");
}
运行:
void test1()
{
int arr[] = { 27,15,19,18,28,34,65,49,25,37 };
Heap hp;
int n = sizeof(arr) / sizeof(arr[0]);
HeapCreate(&hp, arr, n);
HeapPrint(&hp);
HeapPush(&hp, 90);
HeapPrint(&hp);
HeapPush(&hp, 30);
HeapPrint(&hp);
HeapPop(&hp);
HeapPrint(&hp);
printf("堆中元素个数:%d\n", HeapSize(&hp));
printf("取堆顶元素:%d\n", HeapTop(&hp));
HeapDestory(&hp);
}
int main()
{
test1();
return 0;
}
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。
用数组表示树只适合表示完全二叉树(堆),如果不是完全二叉树,数组内会有很多空间被浪费
完全二叉树或者普通树都适合用链式结构进行存储,即用一个个节点进行存储。
四种遍历顺序–访问根的时机不同
typedef char BTDateType;//定义节点保存的数据类型
typedef struct BinaryTreeNode//树节点类型
{
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
BTDateType date;
}BTNode;
//创建一个树节点
BTNode* BTNodeCreate(BTDateType x)
{
BTNode* newNode = (BTNode*)malloc(sizeof(BTNode));
if (newNode == NULL)
{
exit(-1);
}
newNode->date = x;
newNode->left = NULL;
newNode->right = NULL;
return newNode;
}
⼆叉树的前序遍历顺序是根-左-右
//前序遍历
void PrevOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return ;
}
printf("%c ", root->date);
PrevOrder(root->left);
PrevOrder(root->right);
}
以下图二叉树为例
前序遍历的过程为:
root 指向NULL的时候返回。
⼆叉树的中序遍历顺序是左-根-右
//中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
InOrder(root->left);
printf("%c ", root->date);
InOrder(root->right);
}
⼆叉树的后序遍历顺序是左-右-根
//后序遍历
void PostOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%c ", root->date);
}
广度优先需要把下一步所有可能的位置全部遍历完,才会进行更深层次的遍历,层序遍历就是一种广度优先遍历。
深度优先是先遍历完一条完整的路径(从根到叶子的完整路径),才会向上层折返,再去遍历下一个路径,前中后序遍历就是一种深度优先遍历。
广度优先遍历,特点:借助队列先进先出的性质,上一层出的时候,带入下一层的子节点
广度优先实现层序遍历,队列里边放的是树节点的地址
思路:先创建队列并初始化,如果传入的root不为空,root入队。当队列不为空的时候,先保存队列的头节点,然后Pop删除头节点,打印保存的头结点的值,如果保存的头结点的左右指针不为空,则依次Push入队。注意:需要使用队列数据结构
void TreeLevelOrder(BTNode* root)
{
Queue q;//创建一个队列
queue_init(&q);//初始化队列
//如果root不为空则入队
if (root)
{
queue_push(&q, root);
}
//队列不为空
while (!queue_empty(&q))
{
BTNode* front = queue_front(&q);
queue_pop(&q);
printf("%c ", front->date);
if (front->left)
{
queue_push(&q, front->left);
}
if (front->right)
{
queue_push(&q, front->right);
}
}
queue_destory(&q);
}
完全二叉树每一层从左到右必须是连续的,借助层序遍历,当出现空时,如果后面全是空就是完全二叉树,如果是后面有非空,那么就不是完全二叉树(说明按照层序走,非空节点不连续)
//判断是否是完全二叉树,层序遍历思想,然后找到一个NULL后,看后面有没有非空
bool BinaryTreeComplete(BTNode* root)
{
Queue q;//创建队列
queue_init(&q);//初始化队列
//如果root不为空则入队
if (root)
{
queue_push(&q, root);
}
//队列不为空
while (!queue_empty(&q))
{
BTNode* front = queue_front(&q);
queue_pop(&q);
//为空的时候跳出
if (front == NULL)
{
break;
}
queue_push(&q, front->left);
queue_push(&q, front->right);
}
//看后面有没有非空,检查的时候不需要再插入节点,因为这时候NULL所在的这一层节点都已经在队列里面了
while (!queue_empty(&q))
{
BTNode* front = queue_front(&q);
queue_pop(&q);
//为空的时候跳出
if (front)
{
return false;
}
}
queue_destory(&q);
//前面没找到NULL就为真
return true;
}
要用后序遍历销毁,否则节点被销毁后不能找到孩子节点的地址
//二叉树的销毁
void BinaryTreeDestory(BTNode* root)
{
if (root == NULL)
{
return;
}
BinaryTreeDestory(root->left);
BinaryTreeDestory(root->right);
free(root);
}
思路1:遍历计数的方式,传一个变量地址进去,保证都加在一个变量上
思路2:拆解成 空为0 ;非空 = 等于根节点(1)加上左右子树节点的个数
//思路2
int BTreeSize(BTNode* root)
{
return root == NULL ? 0 : BTreeSize(root->left) + BTreeSize(root->right) + 1;
}
空树 return 0
叶子 return 1
非空且不是叶子 return 左子树的叶子节点个数 + 右子树的叶子节点个数
//求叶子结点的个数
int BTreeLeafSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
//符合if条件,说明是叶子节点
if (root->left == NULL && root->right == NULL)
{
return 1;
}
//不符合,寻找左右子树的节点
else
{
return BTreeLeafSize(root->left) + BTreeLeafSize(root->right);
}
}
思路:树的第K层节点个数 等于 左子树的K-1层 + 右子树的K-1层
//求树的第K层节点个数
int TreeKLevelSize(BTNode* root, int k)
{
//走到指定层数后,k的值为1,空节点为0,其余的为1
if (root == NULL)
{
return 0;
}
if (k == 1)
{
return 1;
}
return TreeKLevelSize(root->left, k - 1) + TreeKLevelSize(root->right, k - 1);
}
1、 root == NULL return NULL
2、 root不是我们要找的节点,先到左子树找,再到右子树找
3、左右都没有,当前树没有找到返回NULL
//查找树里面值为X的那个节点
BTNode* TreeFind(BTNode* root, BTDateType x)
{
if (root == NULL)
{
return NULL;
}
//判断自己是不是
if (root->date == x)
{
return root;
}
//分别到左右子树中去找
BTNode* lret = TreeFind(root->left, x);
if (lret != NULL)//找到了就直接返回, 不往下一层一层找了
{
return lret;
}
BTNode* rret = TreeFind(root->right, x);
if (rret != NULL)
{
return rret;
}
//左右边都没找到
return NULL;
}