目录
1. 树概念和结构
1.1 树的概念
树是一种非线性结构,由n(n>=0)个有限节点组成的一个具有层次关系的结合,把它叫做树是因为看起来像一颗倒挂的树,根在上,叶子朝下
- 有一个特殊的结点,称为根结点,根结点没有前驱节点
- 除根结点外,其余结点被分为M(M>0)个互不相交的结合T1、T2,…Tm,每个集合Ti(1<= i <=m)又是一颗结构和树类似的子树,子树的根节点有且只有一个前驱,可以有0个或多个后继
- 因此,树是递归定义的
注意: 树形结构中,子树之间不能有交集,否则就不是树结构
1.2 树的相关概念
树是用人类亲缘关系描述的
节点的度:一个节点含有子树的个数称为节点的度,A是6
树的度:一个树中,最大的结点的度称为树的度,6
节点的层次:从根节点开始定义,根为第1层,根的子节点为第2层,以此类推
树的高度和深度:树中节点的最大层次,4
叶结点或终端结点: 度为0的节点称为叶结点,H,I,P,Q等都是
双亲结点或父节点:若一个结点含有子节点,则这个节点称为其子节点的父节点,A是B的父节点
孩子节点或子节点:一个节点含有子树的根节点称为子节点,B是A的孩子节点
非终端结点或分支节点:度不为0的节点,D,E,F等
兄弟节点:相同父节点的节点互称为兄弟节点,B是C的兄弟节点
堂兄弟节点:双亲在同一层的节点,H,I互为堂兄弟节点
节点的祖先:从根节点到该节点分支上的所有节点。A是所有节点的祖先
子孙:以某节点为根的子树中任一节点都称为子孙,所有节点都是A的子孙
森林: 由m(m>0)棵互不相交的树的集合称为森林,并查集
1.3 树的表示
树结构相对线性表比较复杂,既要存储值域,也要保存节点的关系。有很多表示法
1.如果明确了孩子的度,那么可以定义
2.顺序表存储孩子
3.双亲表示法(每个位置只存双亲的指针或下标)
上面A是根节点,双亲的下标是-1。B和C都是A的孩子节点,A的下标是0,所以B和C存0
4.左孩子右兄弟表示法
A存储孩子B,没有兄弟存空,B存孩子E,然后存邻近的兄弟C,由C找到它的兄弟D。由第一个孩子B不断找到A所有的孩子
文件系统就是一个森林,以D盘为例
2. 二叉树概念和结构
2.1 概念
一颗二叉树是结点的一个有限集合,该集合
1.或者为空
2.由一个根节点加上两棵别称为左子树和右子树的二叉树组成
从上图可以看出:
二叉树不存在度大于2的结点
二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序数
注意:对于任意的二叉树都是以下几种情况复合而成的:
2.2 现实中的二叉树
2.3 特殊的二叉树
满二叉树:一个二叉树,如果每一层的结点数都达到最大值,则这个二叉树就是满二叉树,如果一个二叉树的层数为k,且结点总数是2k-1,则它是满二叉树
完全二叉树:完全二叉树是效率很高的数据结构,由满二叉树出来的,有n个结点的二叉树,当且仅当其每一个结点都与深度为k的满二叉树中编号从1至n的结点一一对应时称为完全二叉树。满二叉树是一种特殊的完全二叉树
2.4 二叉树的性质
-
若规定根节点的层数为1,则一颗非空二叉树的**第i层上最多有2(i-1)**个结点
-
若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2h-1
证明:
F(h) = 20 + 21+…2n-1
2F(h) = 2^1 + …2n
错位相减得: F(n) = 1 + 2n -
对任何一颗二叉树,如果度为0其叶结点个数n0,度为2的分支节点个数为n2,则有n0=n2+1
-
若规定根节点的层数为1,具有n个结点的满二叉树的深度,h=log2(n+1)(ps: log2(n + 1)是log以2为底,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否则无右孩子
- 三叉树总结点个数的计算公式:(3h-1)/2 , 同理, 四叉树是(4h-1)/3
- n个结点的二叉树有n-1条边
高度为h的满二叉树共有 2n-1个结点,反过来,如果二叉树共有N个节点,那么它的高度就是log2(N+1)
高度为h的完全二叉树是一个范围,2h-1 — 2n-1
第一题利用性质4,n0 = n2 +1
第二题顺序表只适合完全二叉树
第三题在具有2n个结点的完全二叉树中,叶子结点个数为n个,因为二叉树中叶子结点比度为2的结点(有2个分叉)的个数多1,完全二叉树中度为1的结点要么为0,要么为1,因此叶子结点数为n个,度为1的结点为1个,度为2的结点为n-1个
第四题根据叶子总节点公式,2n-1,9层最多512个,题干中更多,所以高度为10
第五题对于一个完全二叉树,度为1的节点只能是0或1,度为0的结点是度为2的结点加1,所以总数量N=n2+1+n2或N=n2+1+n2+1,解出n2是383,所以n0就是384
2.5 二叉树的存储结构
二叉树一般用两种结构,一种顺序结构,一种链式结构
1.顺序存储
使用数组存储,一般使用数组只适合表示完全二叉树,不是完全二叉树会有空间的浪费。现实中只有堆才会使用数组来存储,二叉树顺序存储物理上是一个数组,逻辑上是一颗二叉树
父子间下标关系
以B节点为例,它的孩子左孩子下标是 parent * 2 +1,就是3的D,右孩子是parent * 2 +2,就是4的E。孩子找父亲=(child-1)/2,以3的D为例,减下来下标1的B就是父亲
结论: 完全二叉树才适合用数组存储
2.链式存储
用链表表示一颗二叉树,通常链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储位置。链式结构分为二叉链和三叉链,当前用的是二叉链
typedef int BTDataType;
//二叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pLeft; //指向当前节点左孩子
struct BinTreeNode* _pRight; //指向当前节点右孩子
BTDataType _data; //当前节点值域
};
//三叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pParent; //指向当前节点左孩子
struct BinTreeNode* _pLeft; //指向当前节点左孩子
struct BinTreeNode* _pRight; //指向当前节点左孩子
BTDataType _data; //当前节点值域
};
3. 二叉树的顺序结构实现
普通的二叉树不适合用数组来存储,因为可能存在大量的空间浪费。完全二茶树更适合顺序结构。通常把堆使用顺序结构的数组来存储,这个堆和操作系统地址里的堆是两回事,这个是数据结构
3.1 堆的概念和结构
如果有一个关键码的集合K ={k0,k1…kn-1},把它所有元素按完全二叉树的顺序存储方式来存储在一个一维数组中,并满足:Ki <=K2i+1且Ki<=Kii +2,则称为小堆,大堆相反。根结点最大的堆叫做最大堆或大根堆,最小的反之
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值
- 完全二叉树
根据大堆的性质,只有A选项可以
3.2 堆的插入,向上调整
前提是插入新数据之前,已经是堆。插入第一个数时,肯定满足堆。先将该数插入到末尾,然后比较自己的所有祖先路径,如果顺序不对就交换。每插入一个数都这样调整
3.3 代码
头文件
#pragma once
#include <stdbool.h>
//数据
typedef int DATATYPE;
//结构
typedef struct _Heap
{
DATATYPE* ary;
int size;
int capacity;
}Heap;
//函数
//初始化
void Init(Heap* heap);
//向上调整
void AdjustUp(DATATYPE* ary, int child);
//向下调整
void AdjustDown(DATATYPE* ary, int len, int parent);
//插入
void Push(Heap* heap, DATATYPE data);
//删除
void Pop(Heap* heap);
//根结点数据
DATATYPE Top(Heap* heap);
//空
bool Empty(Heap* heap);
//大小
int Size(Heap* heap);
//销毁
void Destory(Heap* heap);
初始化向上调整插入
void Init(Heap* heap)
{
assert(heap);
heap->ary = NULL;
heap->size = 0;
heap->capacity = 0;
}
//从孩子的位置开始调整
void AdjustUp(DATATYPE* ary, int child)
{
assert(hp);
int parent = (child - 1) / 2;
//while (parent >= 0)
while (child > 0) //判断孩子,因为双亲不可能小于0,当孩子到0结点
//就已经调整完毕
{
//父节点大于子节点,调整
if (ary[parent] > [child])
{
int temp = ary[parent];
ary[parent] = ary[child];
ary[child] = temp;
}
else
{
break;
}
child = parent;
parent = (child - 1) / 2;
}
}
void Push(Heap* heap, DATATYPE data)
{
assert(heap);
if (heap->size == heap->capacity)
{
int newsize = heap->capacity == 0 ? 4 : heap->capacity * 2;
DATATYPE* temp = (DATATYPE*)malloc(sizeof(DATATYPE) * newsize);
if (temp == NULL)
{
perror("malloc fail");
return;
}
heap->ary = temp;
heap->capacity = newsize;
}
heap->ary[heap->size] = data;
heap->size++;
AdjustUp(heap->ary, heap->size - 1);
}
void Destory(Heap* heap)
{
assert(heap);
free(heap->ary);
heap->ary = NULL;
heap->size = 0;
heap->capacity = 0;
}
向上调整思路
每插入一个数据就进行调整,如果它的下标不是0,就进行循环判断,和自己的父亲下标对比,如果不满足条件则交换,满足退出循环,直到满足堆条件或孩子下标已经调整到根节点的位置
调试
数组后加一个,号,再写上数量,可以查看数组前几个元素
3.4 堆的删除,向下调整
堆的删除不是删除末尾的数据,而是删除堆顶的数据,可以不断找出最大或最小的数
以前我们删除数据是删除下标,后面的数据挪动覆盖。在堆里这样删除会打乱建立的父子结构。所以要用另一种删除方法,将顶和最后一个数据交换,删除最后一个数据,这样堆的关系并不会改变
70交换上去后,比自己的子结点大了,不符合小堆的条件,所以需要从根节点调整70,找到该放的位置,这里就用到向下调整
向下调整
前提是调整节点的左右子树都是堆,前面的删除并没有破坏堆的结构。以小堆示例,让父节点和左右孩子结点里较小的比较,如果比它大,就交换位置,更新父节点和孩子节点的下标,继续从新的左右孩子中挑小的比较,直到下标超出数组范围,就结束
3.5 代码
向下调整需要确保左右孩子的比较不越界
#include "Heap.h"
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
void Init(Heap* heap)
{
assert(heap);
heap->ary = NULL;
heap->size = 0;
heap->capacity = 0;
}
void Swap(DATATYPE* d1, DATATYPE* d2)
{
int temp = *d1;
*d1 = *d2;
*d2 = temp;
}
//从孩子的位置开始调整
void AdjustUp(DATATYPE* ary, int child)
{
assert(ary);
int parent = (child - 1) / 2;
//while (parent >= 0)
while (child > 0) //判断孩子,因为双亲不可能小于0,当孩子到0结点
//就已经调整完毕
{
//父节点大于子节点,调整
if (ary[child] < ary[parent])
{
Swap(&ary[child], &ary[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void AdjustDown(DATATYPE* ary, int len, int parent)
{
assert(ary);
int child = parent * 2 + 1;
while (child < len)
{
//选出左右子结点较小的
//需要检查越界风险
if (child + 1 < len && ary[child + 1] < ary[child])
child++;
//和父节点比较交换
if (ary[child] < ary[parent])
{
Swap(&ary[child], &ary[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void Push(Heap* heap, DATATYPE data)
{
assert(heap);
if (heap->size == heap->capacity)
{
int newsize = heap->capacity == 0 ? 4 : heap->capacity * 2;
DATATYPE* temp = (DATATYPE*)realloc(heap->ary, sizeof(DATATYPE) * newsize);
if (temp == NULL)
{
perror("realloc fail");
return;
}
heap->ary = temp;
heap->capacity = newsize;
}
heap->ary[heap->size] = data;
heap->size++;
//调整
AdjustUp(heap->ary, heap->size - 1);
}
void Pop(Heap* heap)
{
assert(heap);
assert(!Empty(heap));
Swap(&heap->ary[0], &heap->ary[heap->size - 1]);
heap->size--;
AdjustDown(heap->ary, heap->size, 0);
}
DATATYPE Top(Heap* heap)
{
return heap->ary[0];
}
bool Empty(Heap* heap)
{
return heap->size == 0;
}
int Size(Heap* heap)
{
return heap->size;
}
void Destory(Heap* heap)
{
assert(heap);
free(heap->ary);
heap->ary = NULL;
heap->size = 0;
heap->capacity = 0;
}
主文件
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "Heap.h"
int main()
{
Heap hp;
Init(&hp);
int ary[] = { 65,100,70,32,50,60 };
for (int i = 0; i < 6; i++)
{
Push(&hp, ary[i]);
}
while (!Empty(&hp))
{
printf("%d ", Top(&hp));
Pop(&hp);
}
Destory(&hp);
return 0;
}
上面的并不是排序,只是将堆顶的数据依次打印
4.堆的应用
4.1 堆排序
建堆插入数据后不断出堆顶,覆盖原数组
void Sort(int* ary, int len)
{
Heap hp;
Init(&hp);
for (int i = 0; i < len; i++)
{
Push(&hp, ary[i]);
}
int i = 0;
while (!Empty(&hp))
{
ary[i++] = Top(&hp);
Pop(&hp);
}
Destory(&hp);
}
//待排序数组
int ary[] = { 7,8,3,5,1,9,5,4 };
Sort(ary, 8);
for (int i = 0; i < 8; i++)
{
printf("%d ", ary[i]);
}
上面需要先拷贝一份数据来建堆,太麻烦,每次挑选最小的将数据拷贝过去,费空间。可不可以直接用原数组建堆。可以直接将原数组,从第一个数开始,不断向上调整,就会形成一个堆
1.建堆
void Sort(int* ary, int len)
{
for (int i = 1; i < len; i++)
{
AdjustUp(ary, i);
}
}
用原数组向下调整建堆后,将堆顶数据和堆最后一个数据交换,然后将最后一个数分出堆,也就是堆的大小缩小。这时,如果是小堆会将最小的数换到最后一个,就是降序
- 升序:建大堆
- 降序:建小堆
堆删除排序
推的删除会将堆顶数据和最后一个交换,这时,将向下调整的范围减少1个,最后一个数据排出堆外,剩下的数据中选出次小的和当前堆的最后一个交换,再缩小堆范围。代码以小堆为例,排降序
void Sort(int* ary, int len)
{
//向上调整建堆
for (int i = 1; i < len; i++)
{
AdjustUp(ary, i);
}
//不断挑出次小的数交换
int end = len - 1; //数量减1,因为第一轮堆顶已经选出来了
while (end > 0)
{
swap(&ary[0], &ary[end]);
AdjustDown(ary, end, 0);
end--;
}
}
向下调整建堆
建堆不仅可以向上调整,也可以向下调整,从第一个非叶子结点开始,也就是最后一个节点的父节点,挨着往上一个个调整。数组长度为len,最后一个节点下标就是len-1,然后用父节点公式求出第一个调整的结点
void Sort(int* ary, int len)
{
//向下调整建堆,len是个数,-1下标然后套公式
for (int i = (len - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(ary, len, i);
}
}
top-k是求数据结合前k个最大的元素或者最小的元素,一般情况下数据量都比较大
比如:世界500强、游戏排行榜前几名、富豪榜等
对于top-k问题,能学到最简答的方式就是排序,如果数据量非常大,排序就不太可能了,最佳的方式就是用堆解决,数据过大就需要存到磁盘上
1.用数据集合前k个元素来建堆
- 前k个最大的元素,则建小堆
- 前k个最小的元素,则建大堆
2.用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素进堆
全部比完后,堆里的数据就是最大的前k个
生成一些随机数来进行排出最大的前k个
fprintf写的时候可以加换行等,读的时候不需要
void HeapTop(int* ary, int k)
{
//向下建堆
for (int i = (k - 2) / 2; i >= 0; i--)
{
AdjustDown(ary, k, i);
}
}
void SortData()
{
/*FILE* fp = NULL;
fp = fopen("data.txt", "w");
srand((unsigned int)(NULL));
for (int i = 0; i < 10000; i++)
{
int n = rand() % 100000;
fprintf(fp, "%d\n", n);
}*/
FILE* fp = NULL;
fp = fopen("data.txt", "r");
if (fp == NULL)
{
perror("malloc fail");
return;
}
//读k个建堆
int* ary[10] = {0};
for (int i = 0; i < 10; i++)
{
fscanf(fp, "%d", &ary[i]);
}
HeapTop(ary, 10);
//读入数据替换堆
while (!feof(fp))
{
int n = 0;
fscanf(fp, "%d", &n);
if (n > ary[0])
{
ary[0] = n;
AdjustDown(ary, 10, 0);
}
}
//打印
for (int i = 0; i < 10; i++)
{
printf("%d\n", ary[i]);
}
fclose(fp);
}
手动修改数字大小验证结果,需先屏蔽写入的功能
4. 复杂度
4.1 向下调整
log(N)可以忽略不计,最终就是O(N)
4.2 向上调整
最终结果是N*logN
4.3 堆排序复杂度
向下调整建堆的复杂度是O(N),选数的复杂度和向上调整建堆很像,所以是O(NlogN),整体的O((NlogN)+N)