目录

一.树的概念及结构

1.树的概念

2.树的相关术语

3.树的表示

4.树在实际中的应用

二.二叉树的概念和结构

1.二叉树的概念

2.特殊的二叉树

2.1.满二叉树

2..2.完全二叉树

3.二叉树的性质

4.二叉树的存储结构

4.1.顺序存储

4.2.链式存储

三.堆的顺序结构和实现

1.二叉树的顺序结构

2.堆的概念及结构

3.堆的实现

3.1向上调整算法

3.2向下调整算法

3.3堆的构建

3.4堆的插入

3.5堆的删除

3.6堆的初始化

3.7堆的销毁

四.堆的应用

1.堆排序

2.TOP-K问题


一.树的概念及结构

1.树的概念

树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。

1.有一个特殊的节点,称为根节点,根节点没有前驱节点

2.除根节点外,其余节点被分为M(M>0)个互不相交的集合T1,T2,T3..Tm,其中每一个集合Ti(1<=i<=m)又是一颗结构与树类似的子树。每颗子树的根节点有且只有一个前驱,可以有0个或多个后继

3.因此,树是递归定义的

注意:树形结构中,子树之间不能有交集,否则就不是树形结构

2.树的相关术语

节点的度:一个节点含有的子树的个数称为该节点的度;如上图:A的度为6,D的度为1,E的度为2,B的度为0。

叶节点或终端节点:度为0的节点称为叶节点;如上图,B,C,H,I,K,L,M,N,P,Q为叶节点。

非终端节点或分支节点:度不为0的节点;如上图:A,D,E,F,G,J为分支节点。

双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点;如上图,A是B的父节点。

孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点;如上图,B,C,D,E,F,G是A的孩子节点。

兄弟节点:具有相同父节点的节点互称为兄弟节点;如上图,B,C互为兄弟节点。

树的度:一棵树中,最大的节点的度称为树的度;如上图,节点的度最大为6,故树的度为6。

节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推
树的高度或深度:树中节点的最大层次;如上图,树的高度为4。

堂兄弟节点:双亲在同一层的节点互为堂兄弟节点;如上图,H,I互为堂兄弟节点。

节点的祖先:从根到该节点所经分支上的所有节点;如上图,P的祖先为J,E,A。

子孙:以某节点为根的子树中任一节点都称为该节点的子孙;如上图,所有节点都是A的子孙。

森林:由m(m>0)棵互不相交的树的集合称为森林

3.树的表示

树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了既要保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法 孩子兄弟法即只让父节点只指向自己的一个孩子,然后被指向的孩子指向自己的兄弟节点,这样找到被指向的孩子就可以找到所有的孩子

typedef int DataType;struct Node{ struct Node* _firstChild1; // 第一个孩子结点 struct Node* _pNextBrother; // 指向其下一个兄弟结点 DataType _data; // 结点中的数据域};

4.树在实际中的应用

表示文件系统的目录树结构

二.二叉树的概念和结构

1.二叉树的概念

一颗二叉树是节点的一个有限集合,该集合:1.或者为空,2有一个根节点加上两颗分别称为左子树和右子树的二叉树组成

从上图可以看出:

1. 二叉树不存在度大于2的结点2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树

对于任意的二叉树都是由以下几种情况复合而成:

现实中的二叉树:

2.特殊的二叉树

2.1.满二叉树

一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是2的k次方-1 ,则它就是满二叉树。

2.2.完全二叉树

完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。

可以看到,满二叉树每一层都是满的,即除了叶子结点外,其他所有的节点有且只有两个孩子,完全二叉树则可以看做是满二叉树从最后一层从右往左删掉了若干个节点,由于是从右往左,故完全二叉树不可能存在一个节点只有右孩子而没有左孩子的情况

3.二叉树的性质

1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2的i-1次方个结点.

证明:最多的情况下即这一层类似于满二叉树的节点数,满二叉数从根节点开始,下一层的节点数是上一层的2倍,第一层为1个即2的0次方,第2层为2个即2的1次方,第3层为4个即2的2次方,以此类推,第i层为2的i-1次方

2. 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2的h次方-1

证明:深度h即由h层,由性质1得每一层的最大节点数为2的i-1次方,每一层的和:1+2+4+8+16+…+2的h-1次方,可以看出这是一个等比数列求和,由等比数列求和公式求得最大节点数为2的h次方-1

3. 对任何一棵二叉树, 如果度为0的叶结点个数为n0, 度为2的分支结点个数为n2,则有n0=n2+1

证明:从节点个数和边个数关系证明。除了根节点之外,其余n-1每个节点都有一个父节点,即每个节点上面都有一条边,有n-1个节点则总共有n-1条边,又度为1的节点下面有一条边,度为2的节点下面有两条边,度为0的节点即叶节点下面没有边

设度为2的节点数为n2,度为1的节点为n1,度为0的节点为n0,总节点数为n,则边数为n-1

有:n0+n1+n2=n(节点个数关系)

n1*1+n2*2=n-1(边个数关系)

联立上式即可得n0=n2+1

4. 若规定根节点的层数为1,具有n个结点的满二叉树的深度 h= log2(n+1)(ps :是log以2 为底,n+1为对数)

证明:由性质2可得深度为h的满二叉树的节点数n为2的h次方-1,即2的h次方-1=n,解得h=log2(n+1)

5. 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有

双亲节点:若i>0,i位置节点的双亲节点:(i-1)/2;i=0,即为根节点,无双亲节点;

左孩子:若2i+1<n,则其左孩子存在,左孩子编号为2i+1,否则无左孩子

右孩子:若2i+2<n.则其右孩子存在,右孩子编号为2i+2,否则无右孩子

4.二叉树的存储结构

二叉树一般有两种存储方式,一种为顺序结构,一种为链式结构

4.1.顺序存储

顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实使用中只有堆才会使用数组来存储,堆的结构和实现会在文章后面讲解。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树

从上图可以看出,二叉树的顺序存储即按照从上到下从左到右的顺序依次将节点的值放到数组中即可

4.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; // 当前节点值域};

可以看到,链式存储分为两种方式分别为二叉链和三叉链,二叉链即节点有两个指针域分别指向左孩子和右孩子,而三叉链在二叉链的基础上多了一个指针域指向父节点

三.堆的顺序结构和实现

1.二叉树的顺序结构

普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。

2.堆的概念及结构

如果有一个关键码的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:Ki <= K2i+1 且 Ki<=K2i+2 ,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

堆的性质:

1.堆中某个节点的值总是不大于或不大于其父节点的值

2.堆总是一颗完全二叉树

通过上图我们看到,堆在数组的存储中时按照从上到下从左至右的顺序存储在数组中的,因此我们可以总结出父节点和孩子节点下标的关系 :

1.通过父亲节点找孩子节点:

leftchild=parent*2+1

rightchild=parent*2+2
2.通过孩子节点找父节点

parent=(child-1)/2(左右孩子都符合)

3.堆的实现

堆的实现主要设计向上调整算法、向下调整算法、堆的构建、堆的插入、堆的删除等接口函数

下面依次来看这些接口函数,默认建立大堆

typedef int HPDataType;typedef struct Heap{HPDataType* a;int size;int capacity;}Heap;

3.1向上调整算法

以某一个孩子节点开始,让它与自己的父节点比较,当孩子节点大于父亲节点时,显然这不符合大堆的性质,于是需要将两个节点的值交换,原来的父节点变为孩子节点,之前的孩子节点往上成为父节点,新的父节点作为孩子节点依然需要与上一个父节点比较,直到所有节点都符合大堆的性质,原来的孩子节点再调整的过程中不断往上走,故称为向上调整,如图所示

代码设计和过程:

函数的参数为一个数组和孩子节点下标,首先通过孩子节点下标算出父节点的下标,接着进行两者的比较,如果孩子节点大于父节点则两者交换,并更新孩子节点,再算出新的父节点,以此循环直到符合大堆的性质或者孩子节点已经为堆顶则调整结束

void AdjustUp(HPDataType* a, int child){assert(a);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;//如果不需要调整则直接退出}}}

3.2向下调整算法

以某一个父亲节点开始,让它与自己的孩子节点比较,当父节点小于最大的孩子节点时,(为什么要与最大的孩子节点交换,如果将较小的孩子换上去成为父节点,此父节点依然小于最大孩子节点)显然这不符合大堆的性质,于是需要将两个节点的值交换,最大孩子节点变为父节点,之前的父节点往下成为孩子节点,新的孩子节点作为父节点依然需要与下一个孩子节点比较,直到所有节点都符合大堆的性质,原来的父亲节点再调整的过程中不断往下走,故称为向下调整,如图所示

向下调整算法有一个前提:左右子树必须是一个堆,才能调整

代码设计和过程:

函数的参数为一个数组以及数组的大小和父节点下标,首先通过父节点下标算出最大孩子节点的下标,接着进行两者的比较,如果父节点小于最大孩子节点则两者交换,并更新父节点,再算出新的最大孩子节点,以此循环直到符合大堆的性质或者父节点已经走到最后则调整结束

void AdjustDown(HPDataType* a, int n, int parent){assert(a);int child = parent * 2 + 1;//通过父节点算出最大孩子节点,假设最大孩子为左节点while (child<n){//如果右节点大于左节点,则将最大孩子设为右节点if (child+1 a[child]){child++;}if (a[child] >a[parent])//父节点小于最大孩子节点则调整{Swap(&a[child], &a[parent]);//将父节点与最大孩子节点交换parent = child;//更新父节点child = parent * 2 + 1;//再算出新的最大孩子节点}else{break;//不需要调整则直接退出}}}

3.3堆的构建

给定一个数组,我们可以看做是完全二叉树,我们可以通过向上调整和向下调整两种方式建堆,并对两种方法进行分析和比较

向上调整建堆:

从第二层开始(堆顶可以视为一个大堆),对每一个节点应用向上调整算法,直到最后一个节点应用完向上调整算法

时间复杂度:O(n*logn),证明如图所示

向下调整建堆:

从倒数第二层开始(每个叶节点可以视为一个大堆),对倒数第一个非叶子节点应用向下调整算法,直到最后堆顶应用完向下调整算法

时间复杂度:O(n),证明如下图所示

通过上述分析,向下调整建堆的时间复杂度更优,因此在后续的建堆过程中采用向下调整建堆

3.4堆的插入

堆的插入是首先将节点插入到数组即堆的尾部,再对该节点应用向上调整即可

过程如图所示

void HeapPush(Heap* hp, HPDataType x){assert(hp);if (hp->size == hp->capacity)//当前容量等于最大容量则进行扩容{int newcapacity = hp->capacity == 0 " />a = tmp;hp->capacity = newcapacity;}hp->a[hp->size] = x;//插入到数组尾部hp->size++;//当前容量加1AdjustUp(hp->a, hp->size-1);//对新节点采用向上调增算法}

3.5堆的删除

堆的删除过程:首先将堆顶与最后一个节点交换,然后当前容量-1,在对堆顶应用向下调整算法

删除过程如图所示:

3.6堆的初始化

堆的初始化即让指向数组的指针置为空,当前容量和最大容量置为0即可

void HeapInit(Heap* hp){assert(hp);hp->a = NULL;hp->size = hp->capacity = 0;}

3.7堆的销毁

堆的销毁即释放指针指向的空间,并让指针置为空,当前容量和最大容量置为0即可

void HeapDestory(Heap* hp){assert(hp);free(hp->a);hp->a = NULL;hp->size = hp->capacity = 0;}

全部代码:

#include"Heap.h"void HeapInit(Heap* hp){assert(hp);hp->a = NULL;hp->size = hp->capacity = 0;}void HeapCreate(Heap* hp, HPDataType* a, int n){assert(hp);HPDataType*tmp = (HPDataType*)malloc(sizeof(HPDataType) * n);if (tmp == NULL){perror("malloc fail");exit(-1);}hp->a = tmp;hp->size = hp->capacity = n;memcpy(hp->a, a, sizeof(HPDataType) * n);//已有一个数组,向下调整建堆for (int i = (n - 1 - 1) / 2; i >= 0; i--){AdjustDown(hp->a, n, i);}}void HeaPPrint(Heap* hp){assert(hp);for (int i = 0; i size; i++){printf("%d ", hp->a[i]);}printf("\n");}void Swap(HPDataType* a, HPDataType* b){HPDataType tmp = *a;*a = *b;*b = tmp;}void AdjustUp(HPDataType* a, int child){assert(a);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 AdjustDown(HPDataType* a, int n, int parent){assert(a);int child = parent * 2 + 1;//通过父节点算出最大孩子节点,假设最大孩子为左节点while (child<n){//如果右节点大于左节点,则将最大孩子设为右节点if (child+1<n&&a[child + 1] capacity)//当前容量等于最大容量则进行扩容{int newcapacity = hp->capacity == 0 " />a = tmp;hp->capacity = newcapacity;}hp->a[hp->size] = x;//插入到数组尾部hp->size++;//当前容量加1AdjustUp(hp->a, hp->size-1);//对新节点采用向上调增算法}void HeapPop(Heap* hp){assert(hp);assert(hp->size > 0);Swap(&hp->a[0], &hp->a[hp->size - 1]);hp->size--;AdjustDown(hp->a, hp->size, 0);}HPDataType HeapTop(Heap* hp){assert(hp);return hp->a[0];}int HeapSize(Heap* hp){assert(hp);return hp->size;}int HeapEmpty(Heap* hp){assert(hp);return hp->size == 0;}void HeapDestory(Heap* hp){assert(hp);free(hp->a);hp->a = NULL;hp->size = hp->capacity = 0;}void PrintTopK(int* a, int n, int k){for (int i = (k - 1 - 1) / 2; i >= 0; i--){AdjustDown(a, k, i);}for (int i = k+1; i  a[0]){Swap(&a[i], &a[0]);AdjustDown(a, k, 0);}}}void TestTopk(){int array[] = { 27,15,19,18,28,34,65,49,25,37 };int k = 5;int n = sizeof(array) / sizeof(int);PrintTopK(array, n, k);for (int i = 0; i < k; i++)printf("%d ", array[i]);}

四.堆的应用

1.堆排序

堆排序即利用堆的思想进行排序,总共分为两个步骤:

1.建堆

升序:建大堆

降序:建小堆

2.利用堆删除思想来进行排序

建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序

排序过程:建立大堆,大堆能够相对较大的元素在堆顶,首先让堆顶元素和最后一个元素交换,然后数组大小减1,再对堆顶元素应用向下调整(此时第二大的数就在堆顶了),第一轮就可以将最大的树放到数组最后面,依次进行,第二轮则可以将第二大的数放到数组的倒数第二个位置,直到数组的大小变为1即排序完成

代码:

void HeapSort(int* a, int n){//时间复杂度O(n)for (int i = (n - 1 - 1) / 2; i >= 0; i--){AdjustDown(a, n, i);}int end = n - 1;while (end>0){Swap(&a[0], &a[end]);AdjustDown(a, end, 0);end--;}}

2.TOP-K问题

topk问题即在n个数中最大或者最小的K个数,通常n数量很大

比如专业前几名,王者荣耀国服排名等

对于这种问题最容易想到的就是排序,但是当数据量非常大的时候排序就不可取了,最佳的方式就是用堆来解决,以下由两种思路,其中第一个有一定的局限性,故采用第二种思路最佳

思路1:建一个大小为n的堆,pop K次,依次取堆顶,这K次取到的元素便是最大或者最小的K个数

局限性:当N非常大的时候,比如100亿个整数,占用的空间就高达40G,这是加载不进内存的,只能放到磁盘

时间复杂度分析:O(n)

由于向下调整建堆算法为O(n),故建大小为n的堆的时间为n,然后pop K次,每次最快情况下需要移动堆的高度次,堆的高度近似为logn,故Pop过程时间为k*logn,总时间为n+k*logn

思路2:建立一个大小为K的堆,找最大的K个数则建小堆,反之则建大堆,让后面的N-K个数与堆顶元素,比堆顶的元素大则将堆顶的元素替换,再对堆顶进行向下调整,这样后面的数全部与堆顶元素比较完之后,最大的K个数便在堆中

时间复杂度分析:O(N)

建大小为K的堆时间为K,然后后面N-K个元素依次与堆顶,最坏的情况便是后面N-K个数升序排序,即每个数都会与堆顶元素替换并进行向下调整,比较过程时间为(N-K)*logK,总时间为K+(N-K)*logK

思路2代码:

void PrintTopK(int* a, int n, int k){for (int i = (k - 1 - 1) / 2; i >= 0; i--){AdjustDown(a, k, i);//建立大小为k的堆}for (int i = k+1; i  a[0])//后面的元素与堆顶比较,大于对顶元素则替换{Swap(&a[i], &a[0]);AdjustDown(a, k, 0);//替换之后再对堆顶元素进行向下调整}}}

好啦,关于堆的学习就先到这里,如果对您有所帮助,欢迎一键三连~