目录
1. 树的概念与结构
①树的相关概念
Ⅰ. 什么是树
Ⅱ. 树中的相关概念
②树的表示法
③树的应用
2. 二叉树的概念与结构
①二叉树的概念
②特殊二叉树
③二叉树性质及证明
④二叉树性质相关习题
3. 堆
①堆的概念及结构
②堆的实现
⑴堆的向上调整法
⑵堆的向下调整法
⑶堆的插入与删除
⑷堆的创建
⑸堆的接口与实现
③堆的应用
⑴堆排序
⑵Top-K问题
4. 二叉树的链式结构
①二叉树的遍历
⑴前序遍历
⑵中序遍历
⑶后序遍历
⑷层序遍历
②求节点个数以及高度等
⑴二叉树节点个数
⑵二叉树的高度
⑶二叉树第k层节点个数
⑷二叉树查找值为x的节点
1. 有一个 特殊的结点,称为根结点 ,根节点没有前驱结点2. 除根节点外,其余结点被分成 M(M>0) 个互不相交的集合 T1 、 T2 、 …… 、 Tm ,其中每一个集合 Ti(1<= i<= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有 0 个或多个后继3. 因此, 树是递归定义 的。
随便举例一棵树,如
注:树形结构中,子树之间不能有交集,否则就不是树形结构
我们以下面这个树为例
节点的度 :一个节点含有的子树的个数称为该节点的度;如上图: A 的为 6叶节点或终端节点(*) :度为 0 的节点称为叶节点;如上图: B 、 C 、 H 、 I... 等节点为叶节点非终端节点或分支节点 :度不为 0 的节点;如上图: D 、 E 、 F 、 G... 等节点为分支节点双亲节点或父节点(*):若一个节点含有子节点,则这个节点称为其子节点的父节点;如上图: A 是 B 的父节点孩子节点或子节点(*):一个节点含有的子树的根节点称为该节点的子节点;如上图: B 是 A 的孩子节点兄弟节点 :具有相同父节点的节点互称为兄弟节点;如上图: B 、 C 是兄弟节点树的度 :一棵树中,最大的节点的度称为树的度;如上图:树的度为 6节点的层次 :从根开始定义起,根为第 1 层,根的子节点为第 2 层,以此类推;如上图:树共有4层树的高度或深度(*):树中节点的最大层次;如上图:树的高度为 4堂兄弟节点 :双亲在同一层的节点互为堂兄弟;如上图: H 、 I 互为兄弟节点节点的祖先(*):从根到该节点所经分支上的所有节点;如上图: A 是所有节点的祖先子孙(*):以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是 A 的子孙森林 :由 m ( m>0 )棵互不相交的树的集合称为森林;
表示方法如下
typedef int DataType;
struct Node
{
struct Node* _firstChild1; // 第一个孩子结点
struct Node* _pNextBrother; // 指向其下一个兄弟结点
DataType _data; // 结点中的数据域
};
我们以如下一棵树为例
左孩子右兄弟表示为
windos中的文件也是以树的形式储存的
二叉树是一种树形结构,每个节点最多只能有两个子节点,并且左右子节点的顺序是确定的。一个二叉树可以为空,或者由一个根节点和左右两个子二叉树组成。其中左子树和右子树也分别是二叉树。
从上图中我们不难看出:
1. 二叉树不存在度大于 2 的结点2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
因此,对于任意的二叉树都是由以下几种情况复合而成的
1. 满二叉树 :一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K ,且结点总数是,则它就是满二叉树。2. 完全二叉树 :完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为 K的,有n 个结点的二叉树,当且仅当其每一个结点都与深度为 K 的满二叉树中编号从 1 至 n 的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
假设对于任意一棵具有 k + 1 个节点的二叉树,都满足N0=N2+1。
对于一颗具有 k+1 个节点的二叉树,我们可以将其分为根节点和左右两棵子树。设左子树有 n1 个节点,右子树有 n2 个节点。
那么该二叉树的 N0 就是左子树的 N0 与右子树的 N0 之和,即 N0=N01+N02。
同理 N2 也可以拆分为左右两个子树,即 N2=N21+N22。
根据二叉树的性质,除了根节点,每个节点的度数最多为 2。我们将 N0 表示为叶节点数目,那么左子树和右子树的 n0 和 n2 就可以表示为:
左子树:n01=N01+1,n21=N01
右子树:n02=N02+1,n22=N02
总结起来,我们得到以下三式:
N0=n01+n02=N01+N02+2
N2=n21+n22=N01+N02
代入 N0=N2+1,得到
N01+N02+2=N01+N02+1
因此,对于任意一颗具有 k+1 个节点的二叉树都成立。
1. 若 i>0 , i 位置节点的双亲序号: (i-1)/2 ; i=0 , i 为根节点编号,无双亲节点2. 若 2i+1,左孩子序号: 2i+1 , 2i+1>=n 否则无左孩子 3. 若 2i+2,右孩子序号: 2i+2 , 2i+2>=n 否则无右孩子
图示如下
证明
1. 若 i>0,则 i 位置结点的双亲序号为 (i−1)/2。
首先我们可以根据完全二叉树的定义,得出父节点编号一定小于当前节点编号,且父节点在当前节点的上一级。又因为当前节点是从上至下从左至右编号的完全二叉树中的第 i 个节点。那么它的父节点肯定是处于序号为 (i−1)/2 的位置,这就证明了该结论。
2. 若 2i+1
由完全二叉树的定义,一个节点的左子节点编号为 2i+1,右子节点编号为 2i+2。
证明:
当 2i+1
3. 若 2i+2
与第二个结论的证明类似,当 2i+2
综上所述,我们证明了对于具有 n 个结点的完全二叉树,编号为 i 的节点满足的三个性质,即若 i>0,则它的双亲节点序号为 (i−1)/2;若 2i+1
1. 某二叉树共有 399 个结点,其中有 199 个度为 2 的结点,则该二叉树中的叶子结点数为( )A 不存在这样的二叉树B 200C 198D 199
解:
∵N0 = N2 + 1
∴N0 = 200,因此答案选B
2. 在具有 2n 个结点的完全二叉树中,叶子结点个数为( )A nB n+1C n-1D n/2
解:叶子结点即度为0的结点,将其转化为计算N0大小即可
∵2*n = N0 + N1 + N2, N0 = N2 + 1,又∵在完全二叉树中度为1的节点取值仅有0/1,
∴当N1 = 0时,2*n = 2*N0+1有N0=(2*n-1)/2,无匹配选项;当N1=1时,2*n=2*N0+2有N0=n-1,因此答案选C
3. 一棵完全二叉树的节点数位为 531 个,那么这棵树的高度为( )A 11B 10C 8D 12
解:
∵2^10 = 1024,又∵一棵满二叉树的结点数为2^h - 1,
∴一棵高度为10的满二叉树的节点数为1023,而一棵高度为9的满二叉树的节点数为511
∵531∈[512,1023]
∴这是一棵高度为10的树
4. 一个具有 767 个节点的完全二叉树,其叶子节点个数为()A 383B 384C 385D 386
解:
∵767 = N0 + N1 + N2,N0 = N2 + 1
∴766 = 2*N0 + N1,又∵N0不能是小数,∴N1=0
∴N0=383,因此选A
堆是一种特殊的树状数据结构,具有以下两个特点:
- 堆是一棵完全二叉树。
- 堆中任何一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
这个定义中提到的"大于等于"或"小于等于",就是指堆的排序方式。如果父节点的值大于等于子节点的值,就称为"大根堆",反之则是"小根堆"。
如下图所示
根据上面的结我们可以定义堆的自定义类型如下
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
在知道了堆的结构后,当我们需要向堆中插入元素时,应该选择的是尾插,但是尾插之后不能保证此时依旧满足堆的性质,(以大根堆为例)如
此时受到影响的可能是最后一个节点的所有祖先,即
代码如下
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//大根堆
void AdjustUp(HPDataType* a, int child)
{
//当前节点为child
//通过child找到parent
int parent = (child - 1) / 2;
//用parent作判断条件不够严谨,因为如果child为0时,
//parent=(0-1)/2=-0.5(C语言向下取整,因此parent=0,while的判断条件没有改变)
//while (parent >= 0)
//当child=0时,已经没有必要再向上调整,循环结束
while(child > 0)
{
//因为是大根堆,因此当孩子大于父亲时,需要交换父子位置
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
那当我们从堆中取出或删除元素时,应该做到的是取出(删除)堆顶元素(即首元素),但是如果我们直接将该位置元素直接删除,那么就会导致1. 原来的父子关系被打乱;2. 依次挪动数据导致效率低下。如图所示
因此,我们在此最好的选择是将堆顶元素和最后一个元素交换,这样既解决了效率又保持了相对的父子关系,唯一需要进行调整的就是大根堆性质的所决定的父子关系
代码如下
void AdjustDown(HPDataType* a, int parent, int n)
{
// 通过父节点找到左右子节点
int lchild = 2 * parent + 1;
int rchild = 2 * parent + 2;
// 如果左孩子所处位置已经不在堆中就结束循环
// 表示当前父节点为叶子结点,无需向下调整
while (lchild < n)
{
// 假设左孩子为较大的孩子
int mchild = lchild;
// 当右孩子存在时,如果右孩子比左孩子大,就将右孩子改为较大的孩子
if (rchild < n && a[mchild] < a[rchild])
{
mchild = rchild;
}
// 不断向下调整
if (a[parent] < a[mchild])
{
Swap(&a[parent], &a[mchild]);
parent = mchild;
lchild = 2 * parent + 1;
rchild = 2 * parent + 2;
}
else
{
break;
}
}
}
由于堆是由数组模拟实现的,因此在堆中插入数据一般选择尾插(选择尾插有两个好处:1. 不会破坏原来的父子关系; 2. 效率高),它的代码实现如下
// 堆的插入
void HeapPush(Heap* hp, HPDataType x)
{
assert(hp);
// 检测数组是否需要进行扩容
if (hp->size == hp->capacity)
{
HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * hp->capacity * 2);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
hp->a = tmp;
hp->capacity *= 2;
}
hp->a[hp->size] = x;
hp->size++;
// 传入的是最后一个有效元素的下标,即size-1
AdjustUp(hp->a, hp->size - 1);
}
类似的,堆数据的取出(删除)一般选择将首元素与尾元素交换并将size-1表示有效元素的减少,
它的代码实现如下
// 堆的判空
int HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
// 堆的删除
void HeapPop(Heap* hp)
{
assert(hp);
// 若堆为空就不应该再被允许删除数据
assert(!HeapEmpty(hp));
Swap(&hp->a[0], &hp->a[hp->size - 1]);
hp->size--;
// 需要传入堆有效数据个数
AdjustDown(hp->a, 0, hp->size - 1);
}
堆的创建有两种形式:
第一种是创建一个空堆,不断向其中插入数据构成一个堆,其代码实现如下
// 堆的创建(一)
void HeapInit(Heap* hp)
{
assert(hp);
HPDataType* a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
if (a == NULL)
{
perror("malloc fail");
return;
}
hp->a = a;
hp->size = 0;
hp->capacity = 4;
}
第二种是对一个已经存在的数组建堆,这里有两种建堆方式,
一种是向上调整法建堆,代码实现如下
void HeapCreatUp(Heap* hp, HPDataType* a, int n)
{
assert(hp);
// 向上调整法建堆 -- 时间复杂度: O(N*logN)
// 从头开始向后(下)遍历,将每个元素都向上调整一次
for (int i = 0; i < n; i++)
{
AdjustUp(a, i);
}
}
此建堆方式的时间复杂度为O(N*logN),证明如下
另一种是向下调整法建堆,代码实现如下
void HeapCreatDown(Heap* hp, HPDataType* a, int n)
{
assert(hp);
// 向下调整法建堆 -- 时间复杂度: O(N)
// 第一个n-1是为了找到数组最后一个元素的下标
// 第二个-1是为了找到该元素父亲
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, i, n);
}
}
此建堆方式的时间复杂度为O(N),证明如下
接口如下
// 堆的创建
void HeapCreat(Heap* hp, HPDataType* a, int n);
// 堆的初始化
void HeapInit(Heap* hp);
// 堆的销毁
void HeapDestory(Heap* hp);
// 堆的插入
void HeapPush(Heap* hp, HPDataType x);
// 堆的删除
void HeapPop(Heap* hp);
// 取堆顶的数据
HPDataType HeapTop(Heap* hp);
// 堆的数据个数
int HeapSize(Heap* hp);
// 堆的判空
int HeapEmpty(Heap* hp);
实现如下
// 堆的创建
void HeapCreat(Heap* hp, HPDataType* a, int n)
{
assert(hp);
// 向上调整法建堆 -- 时间复杂度: O(N*logN)
// 从头开始向后(下)遍历,将每个元素都向上调整一次
for (int i = 0; i < n; i++)
{
AdjustUp(a, i);
}
// 向下调整法建堆 -- 时间复杂度: O(N)
// 第一个n-1是为了找到数组最后一个元素的下标
// 第二个-1是为了找到该元素父亲
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, i, n);
}
}
// 堆的初始化
void HeapInit(Heap* hp)
{
assert(hp);
HPDataType* a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
if (a == NULL)
{
perror("malloc fail");
return;
}
hp->a = a;
hp->size = 0;
hp->capacity = 4;
}
// 堆的销毁
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->a);
free(hp);
}
// 堆的插入
void HeapPush(Heap* hp, HPDataType x)
{
assert(hp);
// 检测数组是否需要进行扩容
if (hp->size == hp->capacity)
{
HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * hp->capacity * 2);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
hp->a = tmp;
hp->capacity *= 2;
}
hp->a[hp->size] = x;
hp->size++;
// 传入的是最后一个有效元素的下标,即size-1
AdjustUp(hp->a, hp->size - 1);
}
// 堆的判空
int HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
// 堆的删除
void HeapPop(Heap* hp)
{
assert(hp);
// 若堆为空就不应该再被允许删除数据
assert(!HeapEmpty(hp));
Swap(&hp->a[0], &hp->a[hp->size - 1]);
hp->size--;
// 需要传入堆有效数据个数
AdjustDown(hp->a, 0, hp->size - 1);
}
// 取堆顶的数据
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
return hp->a[0];
}
// 堆的数据个数
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
在发明了堆的结构后,它也被大量投入到其他应用中
详见堆排序
Top-k问题是一类需要找出前k个最大(最小)元素的问题,其中k通常是一个小于总数的正整数。
分析:
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都无法全部加载到内存中)。而最佳的方式就是用堆来解决,基本思路如下:1. 用数据集合中前K个元素来建堆求前k个最大(小)的元素,建小(大)堆2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。通俗来讲,即为了求k个最大(小)的树,先设定一个k个大小的小堆,将之后的每个元素与小堆的堆顶元素相比较,如果大于堆顶元素就替换其进堆,这样到最后就能保证堆里的元素始终大于堆顶,而堆顶又始终大于堆外的数据,即找到了最大的K个元素
代码实现
// TopK问题:找出N个数里面最大/最小的前K个问题。
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void AdjustDown(int* a, int parent, int n)
{
// 通过父节点找到左右子节点
int lchild = 2 * parent + 1;
int rchild = 2 * parent + 2;
// 如果左孩子所处位置已经不在堆中就结束循环
// 表示当前父节点为叶子结点,无需向下调整
while (lchild < n)
{
// 假设左孩子为较大的孩子
int mchild = lchild;
// 当右孩子存在时,如果右孩子比左孩子大,就将右孩子改为较大的孩子
if (rchild < n && a[mchild] > a[rchild])
{
mchild = rchild;
}
// 不断向下调整
if (a[parent] > a[mchild])
{
Swap(&a[parent], &a[mchild]);
parent = mchild;
lchild = 2 * parent + 1;
rchild = 2 * parent + 2;
}
else
{
break;
}
}
}
// 堆的创建
void HeapCreat(int* a, int n)
{
// 向下调整法建堆 -- 时间复杂度: O(N)
// 第一个n-1是为了找到数组最后一个元素的下标
// 第二个-1是为了找到该元素父亲
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, i, n);
}
}
// 此处假设寻找K个最大数据
void TestTopk(int* a, int n, int k)
{
// 1.建一个有k个数据的小堆
int* tmp = (int*)malloc(sizeof(int) * k);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
int i = 0;
for (i = 0; i < k; i++)
{
tmp[i] = a[i];
}
HeapCreat(tmp, k);
// 2.将这之后的n-k个数据都与堆顶元素比较
while (i != n)
{
if (a[i] > tmp[0])
{
Swap(&a[i], &tmp[0]);
AdjustDown(tmp, 0, k);
}
i++;
}
for (i = 0; i < k; i++)
{
printf("%d ", tmp[i]);
}
}
测试如下
二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉树中的节点进行相应的操作,并且每个节点只操作一次。遍历是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。
1. 递归结构:前序/中序/后序遍历2. 非递归结构:层序遍历
注:
深度优先遍历(DFS):如二叉树的前序遍历等,一般用递归实现
广度优先遍历(BFS):如二叉树的层序遍历,一般用队列实现
在这里,我们使用如下一颗二叉树来举例
模拟实现它的代码如下
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
BTNode* BuyNode(BTDataType n)
{
BTNode* a = (BTNode*)malloc(sizeof(BTNode));
if (a == NULL)
{
perror("malloc fail");
return NULL;
}
a->data = n;
a->left = NULL;
a->right = NULL;
}
BTNode* CreatBinaryTree()
{
BTNode* node1 = BuyNode(1);
BTNode* node2 = BuyNode(2);
BTNode* node3 = BuyNode(3);
BTNode* node4 = BuyNode(4);
BTNode* node5 = BuyNode(5);
BTNode* node6 = BuyNode(6);
node1->left = node2;
node1->right = node4;
node2->left = node3;
node4->left = node5;
node4->right = node6;
return node1;
}
前序遍历的形式是:根 左子树 右子树
对上面那棵树前序遍历有
从1处开始遍历,先访问根1,再访问{左子树2,此时将2视作新的根,先访问根2,[再访问左子树3,然后(3先访问左子树NULL,再访问右子树NULL),再访问右子树NULL],再访问右子树4,然后4[访问左子树5,5先(访问左子树NULL,再访问右子树NULL),再访问右子树6,6先(访问左子树NULL,再访问右子树NULL)]}
代码实现
// 二叉树前序遍历
void PreOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
printf("%d ", root->data);
PreOrder(root->left);
PreOrder(root->right);
}
运行和原图验证有
前序遍历的形式是:左子树 根 右子树
对上面那棵树前序遍历有
在遍历时,记住“遇到根不能访问,先访问它的左子树”即可
代码实现
// 二叉树中序遍历
void InOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
InOrder(root->left);
printf("%d ", root->data);
InOrder(root->right);
}
验证有
前序遍历的形式是:左子树 右子树 根
对上面那棵树前序遍历有
在遍历时,记住“遇到根和右子树都不能访问,先访问它的左子树,访问完左子树先访问右子树,再访问根”即可
代码实现
// 二叉树后序遍历
void PostOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->data);
}
验证有
层序遍历的形式为:从根节点开始向下的每一层从左到右依次遍历
对上面那棵树层序遍历有
在实现层序遍历时,我们通常需要使用队列这一数据结构,将根节点入队列中,这之后每次遍历到根节点后,使其出队列,并将其左右子节点入队列,其示意图如下
代码实现(在这里使用队列,将其typedef的内容改为struct BinaryTreeNode*)
// 层序遍历
void LevelOrder(BTNode* root)
{
Queue q;
QueueInit(&q);
QueuePush(&q, root);
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
printf("%d ", front->data);
if (front->left)
QueuePush(&q, front->left);
if (front->right)
QueuePush(&q, front->right);
}
QueueDestroy(&q);
}
int main()
{
BTNode* root = CreatBinaryTree();
LevelOrder(root);
return 0;
}
运行如下
在此依旧使用上述二叉树,即
// 二叉树节点个数
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
{
return 0;
}
return BinaryTreeSize(root->left) + BinaryTreeSize(root->right) + 1;
}
验证有
// 二叉树的高度
int BinaryHeight(BTNode* root)
{
if (root == NULL)
{
return 0;
}
// 记录左右的高度防止重复访问
int leftHeight = BinaryHeight(root->left) + 1;
int rightHeight = BinaryHeight(root->right) + 1;
return leftHeight > rightHeight ? leftHeight : rightHeight;
}
验证有
// 二叉树第k层节点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (root == NULL)
{
return 0;
}
if (k == 1)
{
if (root)
return 1;
}
return BinaryTreeLevelKSize(root->left, k-1)
+ BinaryTreeLevelKSize(root->right, k-1);
}
验证有
// 二叉树查找值为x的节点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->data == x)
return root;
BTNode* lval = BinaryTreeFind(root->left, x);
if (lval)
return lval;
BTNode* rval = BinaryTreeFind(root->right, x);
if (rval)
return rval;
return NULL;
}
验证有