目录
一、树的概念及结构
1.树的概念
树是一种非线性的数据结构,它是由n个有限节点组成的一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂着的树,也就是说它是根朝上,叶子朝下的。
(1)有一个特殊的节点,称为根节点,根节点没有前驱节点
(2)除了根节点之外,其余节点被分为M个互不相交的集合T1,T2,T3.....Tn其中每一个集合Ti又是一棵结构与树一样的子树。每一棵子树的根节点有且只有一个前驱节点,其后可以有0个或多个后继节点,因此树是递归定义的。
像图二就是一颗树!
注意:树形结构中,子树之间不能够有交集,否则就不是树形结构而是图
1.2树的相关概念
节点的度:一个节点含有的子树的个数称为该节点的度,如上图的A节点的度就是6
叶子节点:度为0的节点称为叶子节点,也就是没有子树的节点,如上图的H,I等
分支节点:度不为0的节点称为分支节点,如上图的D,E等
父节点:若一个节点含有子节点,则这个节点被称为其子节点的父节点,如上图的A是B的父节点
子节点:一个节点含有的子树的根节点称为该节点的子节点,如上图的B是A的孩子节点
兄弟节点:具有相同父节点的节点互为兄弟节点,如上图的P,Q等
树的度:一棵树中,最大节点的度被称为该树的度,如上图树的度就是A节点的度,也就是6
节点的层次:从根节点开始,为第一个层,依次往下递增
树的高度或者深度:树中节点的最大层次,如上图树的高度就是4
堂兄弟节点:父节点在同一层的节点互为堂兄弟节点,如上图的H,I等
节点的祖先:从根到该节点路径上的所有节点都是该节点的祖先,如E是J的祖先,E也是P的祖先
子孙:以某一节点为根的子树中的所有节点都称为该树的子孙,如上图所有的节点都是A的子孙
森林:由m棵互不相交的树构成的集合称为森林
1.3树的表示
树的结构相对于线性表就复杂了许多,要存储数据也会变得比较麻烦,既要存储有效的数据,还需要存储节点间的关系,实际上树有很多种表示形式:父亲表示法,孩子表示法,孩子父亲表示法,以及孩子兄弟表示法。而这些结构中的王者是:左孩子右兄弟表示法。
我们来看看这种表示法的结构
typedef int Datatype
typedef struct TreeNode
{
struct TreeNode* firstchild; //指向第一个孩子
struct TreeNode* nextbrother; //指向下一个兄弟
Datatype data; //存放节点数据
}TreeNode;
他的优点就是不管你一个节点下有多少个孩子或者兄弟,我都只存储第一个孩子和下一个兄弟,如果我要访问某一个孩子或兄弟只需要通过指着一步步去遍历即可,这种结构出现让我们再也不会被未知的节点个数而头痛了。
1.4树在实际中的运用
树在实际中运用最多的地方就是目录树,无论是Windows还是Linux,他们的文件夹(目录)都是用树的形式来存储的,就比如我们在访问D盘的时候,其实就是因为D盘这个节点存储了第一个孩子和下一个兄弟,当我们双击D盘的时候就是通过第一个孩子把它所有的孩子都列举显示到屏幕上。
二、二叉树的概念及结构
2.1二叉树的概念
一棵二叉树是节点的一个有限集合,该集合只有两种情况:
(1)为空
(2)由一个根节点加上两棵别称为左子树和右子树的二叉树组成
从上图可以看出来:
(1)二叉树不存在度大于2的节点
(2)二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
注意:任何二叉树都是以下几种情况复合而成的:
2.2现实中的二叉树
2.3特殊的二叉树
(1)满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是 ,则它就是满二叉树。
(2)完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。简而言之,完全二叉树就是满二叉树后一行多了几个连续的节点,即只有最后一行不满。
2.4二叉树的性质
(1)若规定一棵树的根节点层次为1,则一棵非空二叉树的第i层上最多有个节点
(2)若规定一棵树的根节点层次为1,则深度为h的二叉树的最大节点数是
(3)对于任何一棵二叉树,如果度为0的叶子节点有n1个,度为2的分支节点个数为n2,
则n1=n2+1
(4)若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= .
(ps: 是log以2为底,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否则无右孩子
2.5二叉树的存储结构
二叉树一般可以用两种结构存储,一种是顺序结构,另一种是链式结构
(1)顺序结构:
顺序结构就是用数组来存储,但是一般数组只能适用于表示完全二叉树,因为不是完全二叉树的话会有大量的空间的浪费。而我们在现实使用中,只有堆才会使用顺序结构来存储。二叉树顺序存储在逻辑上是一棵二叉树,在物理上是一个数组。
(2)链式结构:
二叉树的链式结构是指,用链表来表示一棵二叉树,即用指针来表示节点之间的逻辑关系。通常使用的方法是,每个节点由三个区域组成:数据域和左右指针域,左右指针来指向左右孩子所在的节点,链式结构一般还有三叉链,我们以后在红黑树等会学习到三叉链
typedef int Datatype;
struct BinaryTreeNode
{
struct BinaryTreeNode* leftchild;
struct BinaryTreeNode* rightchild;
Datatype data;
};
typedef int Datatype
struct BinaryTreeNode
{
struct BinaryTreeNode* parent;
struct BinaryTreeNode* leftchild;
struct BinaryTreeNode* rightchild;
Datatype data;
};
三、二叉树的顺序结构及实现
3.1二叉树的顺序结构
普通的二叉树是不适合使用数组来实现的,因为可能会存在大量的空间浪费。而完全二叉树就不会存在这个问题,因为他的定义就是连续的没有空间间断的,所以他非常适合使用数组来存储。显示中,我们把堆(一种特殊的完全二叉树)使用顺序结构(数组)来存储,值得注意的是,这里说的堆和操作系统中的堆不是一码事,一个数一种数据结构,一个是操作系统管理内存的一块区域分段。
3.2堆的概念及结构
3.3堆的实现
3.3.1堆的向下调整算法
对于所有的数组,我们都可以看做是一个完全二叉树。我们通过根节点开始向下调整的算法可以把他调整成一个堆。向下调整算法的前提:左右子树必须是一个堆
int array[] = {27,15,19,18,28,34,65,49,25,37};
3.3.2堆的创建
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。根节点左右子树不是堆,我们怎么调整呢?这里我们从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,就可以调整成堆。
int a[] = {1,5,3,8,7,6};
3.3.3堆的插入
先插入一个10到数组中,然后通过向上调整算法,直到满足堆的条件
3.3.4堆顶元素的删除
因为堆中的父子关系已经确定了,如果和往常一样直接挪动覆盖的话,父子关系就会完全被破坏,从父子变成了兄弟等(因为堆只有父子间才满足大小关系,兄弟间无所谓),一旦这样再次建堆的时间复杂度会变得很高,而且挪动覆盖本身也是一个效率极低的事情。所以堆的删除数据是先把堆顶的元素和最后一个交换,这样就不会破坏原有的父子关系,然后再通过向下调整算法,把被稍稍破坏的堆重建起来,这样的时间复杂度相当理想!
3.3.5堆的实现代码
typedef int Datatype;
typedef struct Heap
{
Datatype* a;
int size;
int capacity;
}Heap;
void Swap(Datatype* p1,Datatype* p2)
{
Datatype tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void AdjustUp(Datatype* a,int child)
{
int parent = (child - 1) / 2;
//当child没有走到堆顶的时候一直走
while (child>0)
{
//如果孩子比父亲小,就交换(建小堆)
if (a[child]<a[parent])
{
Swap(&a[child],&a[parent]);
//迭代
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void AdjustDown(Datatype* a,int size,int parent)
{
int child = parent * 2 + 1;
//当孩子没有越界的时候一直调整
while (child<size)
{
//找到孩子中小的那一个(因为在建小堆)
//child+1是右孩子,如果右孩子越界了说明不存在,就不比较了
if (child+1<size&&a[child+1]<a[child])
{
child++;
}
//判断是否和父亲交换
if (a[child]<a[parent])
{
Swap(&a[child],&a[parent]);
//迭代
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapInit(Heap* heap)
{
assert(heap);
heap->a = NULL;
heap->capacity = 0;
heap->size = 0;
}
void HeapDestroy(Heap* heap)
{
assert(heap);
free(heap->a);
heap->a = NULL;
heap->size = 0;
heap->capacity = 0;
}
bool HeapEmpty(Heap* heap)
{
assert(heap);
return heap->size == 0;
}
int HeapSize(Heap* heap)
{
assert(heap);
return heap->size;
}
Datatype HeapTop(Heap* heap)
{
assert(heap);
assert(!HeapEmpty(heap));
return heap->a[0];
}
void HeapPush(Heap* heap,Datatype x)
{
assert(heap);
//判断是否需要扩容
if (heap->capacity==heap->size)
{
int newcapacity = heap->capacity == 0 ? 4 : heap->capacity * 2;
Datatype* tmp = (Datatype*)realloc(heap->a,sizeof(Datatype)*newcapacity);
if (tmp==NULL)
{
perror("realloc");
return;
}
heap->a = tmp;
heap->capacity = newcapacity;
}
//插入数据
heap->a[heap->size] = x;
heap->size++;
//向上调整
AdjustUp(heap->a,heap->size-1);
}
//堆的删除为了不破坏父子关系,都是把堆顶的元素拿到最后去,然后把剩下的n-1个元素重新建堆
void HeapPop(Heap* heap)
{
assert(heap);
assert(!HeapEmpty(heap));
//交换堆顶和最后一个元素
Swap(&heap->a[0],&heap->a[heap->size-1]);
heap->size--;
//向下调整重建堆
AdjustDown(heap->a,heap->size,0);
}
3.3.6堆的实际运用
(1)堆排序
堆排序是利用堆的思想来进行排序,他的效率相比于我们之前所学的冒泡排序要高许多,时间复杂度是N*logN,关于这个我们会在后面进行证明。
堆排序的步骤:
1.建堆
升序建大堆,降序建小堆(想一想为什么)
2.利用堆的删除的思想来进行排序,
建堆和堆的删除都用到了向下调整,因此只要掌握了向下调整就能够建堆!
我们当然可以用之前的堆的这种数据结构,但是这样做有两大问题:
1.空间复杂度高(又创建了一个堆)
2.还需要把堆里面的数据拷贝到原数组中,这也是一个很麻烦的事情
所以我们在这里就是直接在原数组上进行向下调整来把原数组建堆,然后HeapPop移动堆顶的数据到末尾去,再把它重新向下调整建堆,周而复始直到size减为0堆排序就完成了
void HeapSort(int* arr,int size)
{
//从第一个非叶子节点开始向下调整,因为叶子节点本身就可以看做一个堆
//i>=0是因为根节点也需要向下调整一次,
//i--是因为从第一个父亲开始往前面的每一个节点都是父节点
//建堆
for (int i=(size-1-1)/2;i>=0;i--)
{
AdjustDown(arr,size,i);
}
int end = size - 1;
while (end>0)
{
//把第一个数据拿到最后去
Swap(&arr[0], &arr[end]);
//拿出堆顶的数据后,原堆就被破坏了,需要重新建堆
//这里传end是因为end既是最后一个元素的下标也是前面元素的个数
AdjustDown(arr,end,0);
//每一次end都往前走一步,因为已经把最大的数据放到最后去了
end--;
}
}
(2)TopK问题
TopK问题:求数据中最大/最小的前K个数,一般数据量都非常大,比如专业前10,世界500强,全球富豪榜,游戏前100名玩家等。
对于TopK问题最简单最直接的方法就是排序,但是如果数据量非常大,排序就不太可取了,一是因为排序需要对每一个数都排序,这样时间成本会很高,二是当数据量极大的时候,不可能被一次性加载到内存中,(比如一次有100G的数据)而我们知道排序是一个程序,他需要加载到内存中才能运行,这样明显不行了。
那么最好的办法就是用堆来解决了!基本思路如下:
1.用前K个数据建堆,如果是前K个最大的数据,则建小堆(前K个最小的数据则是建大堆)
2.用剩余的N-K个元素依次与堆顶的数据进行比较,如果比堆顶的数据大就和堆顶的数据替换,并且向下调整
3.将剩余的N-K个元素比较完后堆中剩下的就是前K个最大的数据了
先用我们前面所学的文件操作来创建大量的随机数
void CreatDatas()
{
//创造很大量的数据
int n = 10000;
srand(time(0));
const char* file = "datas.txt";
FILE* fin = fopen(file,"w");
if (fin==NULL)
{
perror(file);
return;
}
for (size_t i=0;i<n;i++)
{
int x = rand() % 1000000;
fprintf(fin,"%d\n",x);
}
fclose(fin);
}
这是创建的数据文件内容,我们把其中的某5个数改掉了,看这种算法能不能找到最大的K个数
TopK的算法
void TopK(int K)
{
const char* file = "datas.txt";
FILE* fout = fopen(file,"r");
if (fout==NULL)
{
perror("fopen");
return;
}
int* kHeap = (int*)malloc(sizeof(int)*K);
if (kHeap==NULL)
{
perror("malloc");
return;
}
//提取前K个数据
for (int i=0;i<K;i++)
{
fscanf(fout,"%d",&kHeap[i]);
}
//把前K个数据建堆
for (int i=(K-1-1)/2;i>=0;i--)
{
AdjustDown(kHeap,K,i);
}
//找到最大的K个数据
int val = 0;
while (!feof(fout))//文件结束返回非0,没有结束返回0
{
fscanf(fout,"%d",&val);
if (val>kHeap[0])
{
kHeap[0] = val;
AdjustDown(kHeap,K,0);
}
}
//打印堆
for (int i=0;i<K;i++)
{
printf("%d ",kHeap[i]);
}
printf("\n");
}
可以看到他非常迅速的完成了查找最大的前K个数,完美的解决了内存不够的问题,同时效率还极高!
这就是TopK问题的解决办法了,希望你能有所收获哦!