1. 树概念及结构
1.1 树的概念
树是一种非线性的数据结构,它是由n(n>=0)个有限节点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一颗倒挂的树,也就是说它是根朝上,树叶朝下的。
有一个特殊的节点,称为根节点,根节点没有前驱节点。
除了根节点外,其余节点被分成M(M>0)个互不相交的集合,T1、T2、T3······,其中每个集合Ti(1<=i<=m)又是一颗结构与树类似的子树。每颗子树的根节点有且只有一个前驱,可以有0个或多个后继。
因此树是递归定义的。或者说,我们可以把一颗大树拆成一个根和若干子树,其中子树又可以拆成根和若干子树,以此类推。
注意:树形结构中,子树之间不能有交集,否则就不是树形结构。
1.2 树的相关概念
节点的度:一个节点含有的子树的个数称为节点的度。例如A的度为6
叶节点或终端节点:度为0的节点称为叶节点,例如B、C、H、I······等节点为叶节点
非终端节点或分支节点:度不为0的节点。例如D、E、F、G等节点为分支节点
父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。A是B的父节点
子节点:一个节点含有的子树的根节点称为该节点的子节点。B是A的子节点
兄弟节点:具有相同父节点的节点互称为兄弟节点。如上图B、C是兄弟节点
树的度:一棵树中最大的节点的度称为树的度。如上图树的度是6
节点的层次:从根开始定义起,根为第一层,根的子节点为第二层,以此类推
树的高度或深度:树中节点的最大层次。如上图树的高度是4
堂兄弟节点:父节点在同一层的节点互为堂兄弟。例如H、I互为堂兄弟节点
节点的祖先:从根到该节点所经分支上的所有节点。例如A是所有节点的祖先
子孙:以某节点为根的子树中任意节点都称为该节点的子孙。例如所有节点都是A的子孙
森林:由m(m>0)棵互不相交的树的集合称为森林
1.3 树的表示
树的结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既要保存值域,也要保存节点和节点之间的关系,实际中树有很多表示形式如:双亲表示法、孩子表示法、孩子双亲表示法、以及孩子兄弟表示法等。
这里就简单的了解其中最常用的孩子兄弟表示法(左孩子右兄弟表示法)
父节点无论有几个孩子,都只指向它的大儿子,然后大儿子再指向它右边的兄弟,兄弟再指向兄弟,以此类推······
、
2. 二叉树概念及结构
2.1 概念
一颗二叉树是节点的一个有限集合,该集合:
1. 为空
2. 由一个根节点加上两颗别称为左子树和右子树的二叉树组成
注意:
1. 二叉树不存在度大于2的节点
2. 二叉树的子树有左右之分,次序不能颠倒,因为二叉树是有序树
2.2 特殊的二叉树
满二叉树:
一个二叉树,如果每一个层的节点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为k,且总结点数是 2^k-1 则他就是满二叉树
完全二叉树:
完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为k的,有n个节点的二叉树,当且仅当其每一个节点都与深度为k的满二叉树中编号1至n的节点对应时称之为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树。
简单点来说,满二叉树就是n-1层都是满的,只有最后一层(第n层)都是叶子节点;完全二叉树就是n-1层都是满的,最后一层不一定满,但是从左到右的节点是连续的
2.3 二叉树的存储结构
二叉树一般可以使用两种结构存储,顺序结构(数组),链式结构(链表)
2.3.1 顺序存储
顺序存储只适合用于完全二叉树,因为如果不是的话会造成空间的浪费,而事实上,只有堆才使用顺序存储的方式。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树
我们分析左图,就可以看出来在存储时,父子节点下标之间是存在数学关系的
左孩子 = 父节点 * 2 + 1
右孩子 = 父节点 * 2 + 2
父节点 = (孩子 - 1)/2
这是顺序存储的优势,当然命中率高我们就不再提了
而右图就是它的缺点,当不是完全二叉树时,就要把那些空节点的位置留出来,不然父子节点的数学关系就失效了
2.3.2 链式存储
链式存储就是用链表直接模拟二叉树,因为孩子最多只有两个,所以我们不用担心空间浪费的问题,因此不必使用左孩子右兄弟法
链式存储又有两种方式,二叉链表,三叉链表,很好理解,我画图示意一下
3. 堆
3.1 堆的概念及结构
简单来讲,堆就是把数据按完全二叉树的逻辑存储到一个一维数组中。同时我们将 任意父节点<=孩子 的结构称作小根堆(小堆); 将 任意父节点>=孩子 的结构称作大根堆(大堆)
小根堆(小堆) 大根堆(大堆)
根据小堆、大堆的特点,堆可以用作堆排序和TOP-K问题
3.2 堆的实现
前面已经讲过很多次实现数据结构的流程了,之后便不再赘述。
刚刚我们已经确定好了堆的实现方法——数组
3.2.1 初始化和销毁
3.2.2 插入数据 O(N*logN)
这里我们实现一个小根堆,如果要弄大根堆的话道理是一样的,只需要在插入数据时略微改动即可
插入数据的话肯定是尾插数组,那么思考一下尾插数组相当于在二叉树逻辑的什么位置插入数据,答案显然是下一个位置,哈哈好吧这是句废话
观察上图,我们要插入一个数据50,那么就是插在数组下标是6的位置,同时二叉树逻辑中也是插在6的位置,它们的对应关系现在显而易见了吧
现在来到下一步,我们插入了之后发现现在的二叉树不满足小根堆了,因为56大于了它的右儿子50,所以我们要进行调整。
此时用到向上调整算法:就是说50这个数据的插入只影响它的祖先,并不会影响兄弟或者叔叔什么的,因此我们只对比它的父亲,如果50小于它的父亲,就将它们换位置,如此交换到50的父亲终于小于它为止。
那么父节点的下标规律我们之前也已经总结过了,现在实现插入数据:
向上调整算法:O(logN)
这里如果我们想要得到大根堆,只需要把交换两个数据的判断条件改成大于号> 就行了,这样就会在孩子大于父亲的时候进行交换,如此形成在同一条线上的上大下小结构
3.2.3 取堆顶
取堆顶就是取到最小或者最大值
现在问题来了,如果我们想取次小的数据,或者取次次小的数据怎么办,我们可以保证堆顶是最小的,次小的出现在第二层,但是次次小的我们无法保证是出现在第二层还是第三层。
这样下去次次次小的怎么取,次次次次小的又怎么取,依次类推,那么这个问题的解决办法就是删去堆顶的数据,让次小的数据来到堆顶,并且这个上来的过程不能破坏堆的结构
3.2.4 删除堆顶的数据 O(logN)
首先错误示范:直接将数组首元素删去,其他元素向前提,这种方法是删掉了堆顶元素,但是仅此而已,这么做破坏了堆的结构,兄弟变父子,叔侄变兄弟,简直倒反天罡!堆的大小结构全都乱套了,那么我们可以将这个堆重新放进堆中再整理一次,就像第一次把数据录进堆中一样。但是这种方式的时间复杂度是 O(N) 不好。
正确方法:
1. 收尾数据交换,删除尾部数据
2. 向下调整算法 O(logN)
这么做不会破坏原堆的结构,同时大大缩减时间复杂度
那么向下调整算法是怎么弄?
首先根它的两个儿子比较,与较小的交换位置,如此循环下去。那么怎么判断该停了呢,是比自己的两个儿子都小,或者是换到了叶子位置,那么又如何判断到没到叶子位呢,就是判断自己还有没有左儿子,没有左儿子就说明自己没儿子了
这里参数n是用来判断有没有向下调整到了叶子,如果到了就不用再调了,从参数parent开始向下调整
3.2.5 判断是否是空堆
到此如何建立一个堆及其各种功能的实现就算是完成了
3.2.6 完整代码
Heap.h
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
void HPInit(HP* php);
void HPDestroy(HP* php);
void HPPush(HP* php, HPDataType x);
//取堆顶
HPDataType HPTop(HP* php);
//删除堆顶的数据
void HPPop(HP* php);
//判断是否是空堆
bool HPEmpty(HP* php);
Heap.c
#include"heap.h"
void HPInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = 0;
php->capacity = 0;
}
void HPDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = 0;
php->capacity = 0;
}
//交换
void Swap(HPDataType* px, HPDataType* py)
{
HPDataType tmp = *px;
*px = *py;
*py = tmp;
}
//向上调整
void AdjustUp(HPDataType* a, 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 HPPush(HP* php, HPDataType x)
{
assert(php);
if (php->capacity == php->size)
{
size_t newcapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
HPDataType* tmp = realloc(php->a, sizeof(HPDataType) * newcapacity);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
php->a = tmp;
php->capacity = newcapacity;
}
php->a[php->size] = x;
php->size++;
//向上调整
AdjustUp(php->a, php->size - 1);
}
//取堆顶
HPDataType HPTop(HP* php)
{
assert(php);
return php->a[0];
}
//向下调整
void AdjustDown(HPDataType* a,int n,int parent)
{
//假设法,选出小的孩子
int child = parent * 2 + 1;//假设左孩子小
while (child < n)//当child>=size时出数组了说明是叶子
{
if (child + 1 < n && 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 HPPop(HP* php)
{
assert(php);
assert(php->size > 0);
//首尾数据交换,删尾数据
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
//向下调整
AdjustDown(php->a, php->size, 0);
}
//判断是否是空堆
bool HPEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
3.2.7 向下调整算法处理数组 O(N)
这里是说如果给你一整个无序的数组,让你整理成堆备用,我们该怎么处理。
比较容易想到的方案就是用 HPPop() 将数组中的元素一个一个的插入到堆中去,这个插入的过程中,我们使用了向上调整算法,整个方法的时间复杂度是O(N*logN)
在此我提供一种新的处理方案,使用向下调整算法:
我们直接将数组视为一个无序的堆,然后从最后一个非叶子节点开始向下调整,这个节点调整完后再调整前一个节点,直到所有节点全部调整完毕,或者说调到根,根再向下调整,根调整完,整个堆也就调整完了
那么为什么要这么调整呢,然后一个无序的堆我们是无法进行向上调整的,向上调整的要求是:该节点上面的堆是有序的(不包括该节点)。因此我们只能考虑向下调整,向下调整的要求是:该节点下面的堆是有序的(不包括该节点)。叶子节点们下面已经没有堆了,所以不需要再进行调整,因此我们从最后一个非叶子节点开始调整,而最后一个非叶子节点就是最后一个节点的父亲。
上代码:
最后我们还可以计算出这种方案的时间复杂度是 O(N)
我们回头看这两种处理方法,它们的做法是相似的,那为什么时间复杂度有差距,其原因在于满二叉树中一半的节点都会存储在最后一层中,而向下调整算法正好不用处理最下一层,倒数第二层中的每个节点也都最多只用处理一次,这样形成了一个 多数据*少调整次数 的结构。反观向上调整算法,是 多数据*多调整次数 的结构。
3.3 堆的应用
3.3.1 堆排序 O(N*logN)
堆排序即利用堆的思想来进行排序,要注意:
排升序:建大堆
排降序:建小堆
这里我们用排降序举例子
可能有的同学觉得,排降序不应该排大根堆,然后找到数组中最大的数,放到第一位,之后删除它,找到第二大的数,如此下去,但是我们要注意,当我们将最大的数放到第一位的时候它处在堆根或者说数组的首元素,此时我们将它删除那堆的结构就被破坏了,那处理后面的数据又要重新建堆,每删一个数就要重新建一次堆,时间复杂度极大
因此我们要转换思路,排小根堆,将找到的最小值与数组尾部的值交换,再删除,之后向下调整找到第二小的值,再交换,如此反复,处理整个数组。说白了就是排小根堆,每次找到最小值落底。
代码如下:
这里我要提醒一下,排大根堆还是排小根堆取决于 AdjustDown() 函数中的条件,这里面的条件我们按需调整
3.3.2 TOP-K 问题
TOP-K 问题:即求数据集合中前 K 个最大或最小元素,一般情况下数据量都比较大。
对于这类问题我们第一思路就是排序,但是由于数据量非常大,排序就不太可能了(可能数据都不能一下子全部加载到内存中)。所以最佳的方式就是用堆来解决,基本思路如下:
1. 用数据集合前 K 个元素来建堆
前K个最大元素,则建大堆
前K个最小元素,则建小堆
2. 用剩余的N-K个元素依次与堆顶元素来比较,判断是否需要替换堆顶元素
简单点来讲,这个方案就是先挑出K个元素,之后继续扫描数据,判断是否要替换掉之前那K个元素中最大(或最小)的元素,最后结果就是剩下所有数据中的top-k个数据
首先我们先搞一堆数据(10000个)写在文件里
再实现我们上面的思路
最后我们来测试一下能不能跑通