一.前言
堆是一种特殊的数据结构,它可以用来实现堆排序以及Topk问题。而它又与二叉树有着密不可分的关系。而二叉树又是树的一种,所以为了了解堆这个数据结构,我们首先从树出发,循序渐进的理解堆。
二.树
我们要了解的树非我们日常生活中见到的树,此树非彼树。我们平常见到的树都像下图一样。
那在我们的数据结构中的树与之刚好相反,型如下图 。所以我们数据结构中的树就好像现实中的
树倒过来了一样。
2.1树的概念
树是一种非线性的数据结构,它由节点(node)和边(edge)组成,每个节点可以有零个或多个子节点,但每个子节点只能有一个父节点。树结构有一个特殊的节点,称为根节点,它是整个树的顶端节点,所有其他节点都从根节点开始通过边进行连接。除了根节点外,每个节点都有一个父节点。
树的每一个节点都可以分为根和子树。图中的B节点就有两个子树分别是:E->J和F。
注意:在树型结构中,子树之间不能相交
上图中前三种都不是树。
2.2树的相关概念
- 节点的度:一个节点含有子树的个数;如上图,A的度是6(A有6个子树)
- 叶节点或者终端节点:度为0的节点称为叶节点,也就是没有子树的节点。如上图中的B、C、H、I……等
- 分支节点或者非终端节点:度不为0的节点。
- 双亲结点或者父节点:如果一个节点有子节点,那么他就是这个子节点的父节点。如图:A是B的父节点。
- 孩子节点或者子节点:一个节点的子树的根就是一个子节点。如:B是A节点的子树,也是该子树的根,那么B就是A的子节点。通俗的来讲就是连在一起的就是父子节点,上面的是父亲,下面的是孩子。
- 兄弟节点:拥有同一个父亲节点的就是兄弟节点。如:B、C就是一对兄弟节点。
- 树的度:树的度取最大节点的度。如图:该树中最大节点是A,度为6,所以该树的度也是6。
- 节点的层次:树的根为第一层次,根的子节点为第二层次,以此类推。
- 树的高度或深度:树中节点的最大层次。
- 堂兄弟节点:父亲节点在同一层次的节点。
- 节点的祖先:从该节点一直延伸到根节点,都是该节点的祖先。
- 子孙:根下面的节点都是该根的子孙。
- 森林:由m(m>0)棵互不相交的集合称为森林。
带有颜色的都是比较重要的知识点,其他的了解即可。
2.3树的表示方法
树也是由一个一个的节点组成的。那我们怎样来描述一棵树呢?这比起顺序表和链表就要复杂很多了,因为它不仅要保存值,还要保存节点之间的关系。常用的表示方法有:孩子兄弟表示法、双亲表示法、孩子表示法以及孩子双亲表示法。我们这里简单介绍一下孩子兄弟表示法。
先给出孩子兄弟表示法的结构:
typedef int treedatatype;
typedef struct tree
{
treedatatype val;
struct tree* _leftchild;
struct tree* _rightborther;
}tree;
这种表示方法的意思就是:树的左指针指向自己的第一个孩子,右指针指向与自己相邻的兄弟。
如上图:A的做指针指向自己的第一个孩子B,A没有相邻的兄弟,所以A的brother指针指向NULL。B的child指针指向自己的第一个孩子D,brother指针指向自己相邻的兄弟C。然后D和C又有自己的孩子或者兄弟。以此类推,最后就可以将我们的树完整的描述出来。
该方法又被称为:“二叉树表示法”或者“二叉链表表示法”。
2.4树在实际中的应用
树常用作文件系统的目录结构。Linux操作系统就应用了文件目录树,目录树的起点是根目录,Linux文件系统中每一文件在此目录树中的文件名都是独一无二的,因为其包含从根目录开始的完整路径。
我们电脑中的文件夹就是一个树。 我们的每一个文件夹都可以看作一个节点,里面的文件夹就是它的子树。所以的这些文件夹就组成了一颗树。
我们的最终目的是了解堆这个数据结构,而它与我们的二叉树息息相关。那么二叉树又和我们的树有什么关联呢?
三.二叉树
3.1二叉树的概念
二叉树是一种特殊的树结构,每个节点最多有两个子节点,分别称为左子节点和右子节点。二叉树可以为空树(没有节点),也可以是具有多个节点的非空树。
从上面的图我们就可以很清楚的看出来二叉树的基本特征:
- 二叉树的每个节点最多有两个子节点
- 二叉树有左右子树之分,不可颠倒,所以二叉树是有序树。
二叉树可以为空,可以只有一个节点,也可以只有左子树,也可以只有右子树
3.2现实生活中的二叉树
3.3特殊的二叉树
- 满二叉树(Full Binary Tree):除了叶子节点之外,每个节点都有两个子节点的二叉树。
- 完全二叉树(Complete Binary Tree):除了最后一层可能不满外,所有层的节点都非常密集地排列在左侧。在完全二叉树中,叶子节点只能出现在最后一层或倒数第二层,而且最后一层的叶子节点都集中在左侧。
3.4二叉树的性质
二叉树的高度跟你定义的其实高度有关:
两种方式都可以,但是定义根为第一层更常用。
我们采用根为第一层的方式推二叉树高度和节点数之间的关系。
由图得出:
- 第i层的节点she数 = 2^(i-1)
- 高度为h的二叉树的结点个数为:2^h-1
- 有N个节点的二叉树的高度为:log(N+1)
当二叉树为完全二叉树或者满二叉树时,此时可以用数组存储该树,而此时父亲和儿子节点之前的关系就可以得出:
- 已知父亲节点为i:左儿子2*i+1,右儿子2*i+2
- 已知儿子节点为i:父亲节点(i-1)/ 2
3.5二叉树的存储方式
3.5.1二叉树的顺序存储
当二叉树为特殊的完全二叉树或者满二叉树时,这个时候我们可以利用数组的结构来存储我们的二叉树,这就是二叉树的顺序存储。
我们上面再了解二叉树性质时就知道了以数组存储时可以借助下标来找到自己的儿子或者父亲。我们验证:
我们看到,当以数组存储时,确实可以借助下标的运算找出父亲与儿子。
那么为什么 顺序存储的方式只能是完全二叉树或者满二叉树使用呢?
因为非完全二叉树采用数组存储为造成空间浪费
那么对于非完全二叉树有没有更好的存储方式呢?
答案是有的:采用链式存储
3.5.2二叉树的链式存储
二叉树的链式存储是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。通常的方法是链表中的每个节点由三个域组成,数据域和左右指针域,左右指针分别用来给出该节点左孩子和右孩子所在的链节点的储存地址。链式结构又分为二叉链和三叉链。
三叉链比二叉链多了一个指向父亲节点的指针。
下面给出链式存储的节点结构:
//二叉树的链式存储
//二叉链
typedef int BinarryTreeDataType;
typedef struct BinarryTreeNode
{
BinarryTreeDataType data;//数据
struct BinarryTreeNode* _leftchild;//左孩子
struct BinarryTreeNode* _rightchild;//右孩子
}BTNode;
//三叉链
typedef int BinarryTreeDataType;
typedef struct BinarryTreeNode
{
BinarryTreeDataType data;//数据
struct BinarryTreeData* _parent;//父亲
struct BinarryTreeNode* _leftchild;//左孩子
struct BinarryTreeNode* _rightchild;//右孩子
}BTNode;
链式存储比之前的链表来说要复杂很多,要掌握两个或者三个的指针的指向,非常容易出错。
四.堆
此堆非彼堆。我们这里说的堆是一种数据结构,而还有一个堆是操作系统那边的。
4.1堆的概念
堆是一种特殊的数据结构,也是一棵完全二叉树,遵循二叉树的顺序存储结构。堆的数据要像完全二叉树的顺序存储一样将数据存储到一个一维数组中,但是在存储的时候有一定的要求:堆中所有父节点的值大于等于子节点(大堆/大根堆),或者堆中所有父节点的值小于等于子节点(小堆/小根堆)。
需要注意的是:
a.堆中的大小关系只是在父亲和儿子之间,儿子和儿子之间没有大小关系。
b.小堆不一定是升序,大堆不一定是降序。
那我们怎么判断是不是堆呢?
- 首先堆肯定是完全二叉树,不是完全二叉树那么肯定不是堆。
- 堆不是大堆就是小堆,根据大小堆的要求,将数据拆成二叉树的形式,再进行判断
4.2堆的实现
4.2.1堆的设计
因为堆是完全二叉树要借助数组来存储数据,所以我们可以类比顺序表的设计方式来设计堆。
typedef int HeapDataType;
typedef struct Heap
{
HeapDataType* data;//存储数据的数组
int size;//数组大小
int capacity;//容量
}Heap;
4.2.2堆的初始化和销毁
我们在初始化的时候可以不用创建空间,在插入的时候再利用扩容的功能来申请空间。
//堆的初始化
void HeapInit(Heap* hp)
{
assert(hp);
hp->data = NULL;
hp->capacity = 0;
hp->size = 0;
}
在堆中,只有数组是动态申请的内存空间,所以当我们使用完之后应该将这块内存空间还给操作系统,防止内存泄露。
//堆的销毁
void HeapDestroy(Heap* hp)
{
assert(hp);
free(hp->data);
hp->data = NULL;
hp->capacity = 0;
hp->size = 0;
}
4.2.3堆的插入
我们往堆中插入数据的时候,插入的位置是数组的尾部,二叉树的最后一个叶子节点的后边,并且要保证堆依旧是堆。什么意思呢?插入数据前是堆,插入数据后也要是堆。
我们看,我们插入之前是一个小堆,但是插入之后就不满足小堆的条件了,但它也不是大堆,此时,该结构就不是堆了。而我们堆插入的要求是插入后堆依旧成立。
所以我们在插入之后还要进行调整,使之依旧是堆。那么要怎么调整呢?这里有一个算法:向上调整算法。我们可以利用该算法在插入之后调整使之依旧是堆。
那么什么是向上调整算法呢?
向上调整算法就是在插入之后将该节点与其父节点进行比较,如果之前是小堆,该结点的值小于父节点,那就交换,然后继续与其父节点进行比较,直到该节点换到堆顶或者该节点的值大于父节点。如果是大堆则比较方式相反。
综上所述,插入的时候可能不需要向上调整,可能需要调整1次,或者更多次。
//交换
void Swap(HeapDataType* a1, HeapDataType* a2)
{
HeapDataType tmp = *a1;
*a1 = *a2;
*a2 = tmp;
}
//向上调整
void AdjustUp(HeapDataType* a, int child)
{
assert(a);
//计算出第一次父亲节点的下标
int parent = (child - 1) / 2;
while (child > 0)
{
//小堆,如果child小于parent就交换
//大堆,如果child大于parent就交换
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
//堆的插入
void HeapPush(Heap* hp, HeapDataType x)
{
assert(hp);
//判断是否需要扩容
if (hp->size == hp->capacity)
{
int newcapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;
HeapDataType* new = (HeapDataType*)realloc(hp->data,sizeof(HeapDataType) * newcapacity);
if (new == NULL)
{
perror("realloc");
return;
}
hp->data = new;
hp->capacity = newcapacity;
}
//插入
//size指向的是最后一个数据的下一个位置
hp->data[hp->size++] = x;
//向上调整
//hp->size-1指的是插入节点的下标
AdjustUp(hp->data, hp->size - 1);
}
怎么确定向上调整算法中while循环的结束条件呢?
我们看该图中,child、parent一交换,child和parent指向的节点也就要发生变化。这里面变化的其实就是下标。当child交换到最后一次时,child为1,parent为0,一交换,child为0,parent也为0,此时向上调整已经完成了,所以当child==0时,就是已经交换完了最后一次。所以判断条件就是child>0。
4.2.4堆的删除
堆的插入是往最后一个子节点的后边插,而堆的删除可不是删的最后一个子节点,删除的是堆顶的数据。那要怎么删除呢?
我们根据上图的分析可以得出,堆的删除应该使用思路二。而思路二中比较麻烦的其实是向下调整算法。 什么是向下调整算法呢?
向下调整算法:与向上调整算法类似,从某个结点开始,与其子节点相比较,如果原先是大堆,该节点小于子节点中的某一个就交换,反之不交换;如果原先是小堆,该节点大于子节点中的某一个就交换,反之不交换。另外,如果该节点比子节点都大或者都小,就与最大或者最小的交换。
//向下调整算法
void AdjustDown(HeapDataType* a, int parent, int size)
{
assert(a);
//小堆,与左右孩子中较小的交换
//大堆,与左右孩子中较大的交换
//假设法
int child = parent * 2 + 1;
while (child < size)
{
if (a[child] > a[child + 1] && child + 1 < size)//此时为小堆
{
child += 1;
}
if (a[parent] > a[child])//此时为小堆
{
//交换
Swap(&(a[parent]), &(a[child]));
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//堆的删除
void HeapPop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);//堆不能为空
//交换堆顶数据和最后一个叶子的数据
Swap(&(hp->data[0]), &(hp->data[hp->size - 1]));
//删除最后一个叶子节点
hp->size--;
//使用向下调整算法,使之依旧为堆
AdjustDown(hp->data,0,hp->size);//参数分别为:存放数据的数据、从哪个位置开始向下调整、数据的个数
}
那么向下调整的循环条件又是怎么得出的呢?
向下调整的结束条件有两个:
不满足交换条件,走了else语句
当parent交换到最后一层之后,在计算child的时候就会越界,已经超过了数组的范围,所以证明此时调整完成。在使用假设法的过程中还要保证child+1不越界,因为二叉树可能没有右孩子。
4.2.5取堆顶的数据
堆在物理上是借助数组存储的,所以要返回堆顶的数据其实就是返回数组的首元素。
//取堆顶的数据
HeapDataType GetHeapTop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
return hp->data[0];
}
4.2.6判空
判空只需要判断size是否为空即可,空则返回1,非空返回-1.
//判空
int HeapEmpty(Heap* hp)
{
assert(hp);
if (hp->size == 0)
{
return 1;
}
else
{
return -1;
}
}
4.2.7堆的数据个数
堆得数据个数其实就是size的大小
//获取堆的数据个数
int GetHeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
五.堆的应用
5.1堆排序
堆排序是一种利用堆数据结构进行排序的算法。我们要如何实现堆排序呢?
堆排序的一般步骤是:
- 先建堆,升序建大堆,降序建小堆
- 借助堆删除的逻辑,将堆顶数据与最后一个子节点交换,然后向下调整,直到每一个数都交换了一次。
如何建堆呢?
5.1.1建堆
我们可以借助堆的插入方法来实现建堆。一组数据,我们可以一个一个的插入从而建成一个堆。但是如果借助这个办法的话我们建堆的空间复杂度就高了,因为我们插入方法需要扩容。
那有没有其他方法呢?
法一:在原数组上进行向上调整。
假设我们有一组数据,我们直接将其看成初始化的堆(有可能不是堆)。
我们接下来对这个数组进行向上调整,使之成为一个小堆。 向上调整时从第一个子节点开始向上调整,如果子节点小于其父亲节点,那就交换,然后继续向上调整;然后调整下一个子节点。直到将所有子节点都向上调整了一次。
这组数据之交换了一次就使之成为了小堆。
接下来就是采取与堆删除类似的逻辑,来实现堆排序:将堆顶数据和最后一个子节点的数据交换,然后对前size-1个元素进行向下调整,然后重新执行该逻辑。
为什么可以这样呢?因为我们建的是小堆,堆顶的数据是元素中最小的,我们将其与最后一个子节点的数交换之后,最小的数就到了最后一个位置,然后我们对前size-1个数进行向下调整使之仍然成为一个小堆,此时该数组中次小的数就到了栈顶,然后再与最后一个进行交换,在对size-2个数据进行向下调整。如此往复,次次小的就被放到了最后,以此类推。
大堆与之类似。
void HeapSort(int* a, int size)
{
assert(a);
//建堆
//升序,建大堆
//降序,建小堆
//向上调整建堆
int i = 0;
for (i = 1; i < size; i++)
{
AdjustUp(a, i);
}
//交换数据,并向下调整
int end = size - 1;
while (end)
{
//交换数据
Swap(&a[0], &a[end]);
//向下调整
AdjustDown(a, 0, end);
end--;
}
}
我们调试可以发现,该方法确实达到了目的:
法二:在原数组上进行向下调整。
假设我们有与上面相同的初始数据。
我们对其使用向下调整的方法使之成为小堆。 那么怎么实现的呢?
我们从倒数第一个非叶子节点开始向下调整,然后走到前一个非叶子节点,直到走到根。
我们要建小堆,那么如果非叶子节点比子节点都小就不用交换,反之就要与其中最小的交换。这样就把小的换到了上面。然后继续这样的一个一个非叶子节点的换,直到换到根。这样我们就可以把最小的放到了堆顶。
上图中倒数第一个非叶子节点是4,而4比两个子节点都小不用交换。然后走到6,6比5大,所以要和5交换。然后走到2,2比两个子节点都小,不用交换。此时已经建成了小堆。
建成之后然后再采用刚才排序的逻辑即可。与上面相同。
void HeapSort(int* a, int size)
{
assert(a);
//建堆
//升序,建大堆
//降序,建小堆
//向下调整建堆
int i = 0;
//size-1是倒数第一个节点,第一个非叶子节点一定是该节点的父亲,这里用到了已知子节点求父亲节点的公式
for (i = (size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, i, size);
}
//交换数据,并向下调整
int end = size - 1;
while (end)
{
//交换数据
Swap(&a[0], &a[end]);
//向下调整
AdjustDown(a, 0, end);
end--;
}
}
5.1.2堆排序中建大小堆的选择
我们再前面说到了如果我们要排升序,那就建大堆;如果要排降序,那就建小堆。
这是为什么呢?
我们从我们建堆之后的操作就可以得出答案了。如果是小堆,我们就将最小的数换到了最后面,接着是次小的,最终形成降序;如果是大堆,我们就将最大的换到了后面,接着是最大了,最终形成升序。为了方便我们这个算法的实现,所以我们选择了升序建大堆,降序建小堆。
5.1.3建堆的选择
我们在上面分别实现了向上调整建堆和向下调整建堆。两种都可以达到目标,那我们选择哪种方式呢?还是说两种方式选哪个都可以?
我们上面也提到了,这两种建堆方式都是在原数组的基础上建堆的,所以空间复杂度是相同的。那么我们就从时间复杂度上进行分析。
5.1.3.1向下调整建堆的时间复杂度
我们在使用向下调整建堆的时候,最后一层是不用调整的,最后一层自己就是一个堆,我们从倒数第一个非叶子节点开始调整,该节点肯定在倒数第二层。
由上图分析得出,向下调整建堆的时间复杂度为O(N)。
5.1.3.2向上调整建堆的时间复杂度
向上调整建堆类似于插入的过程,从第一个子节点开始向上调整,然后一个一个节点的向后走,直到最后一个叶子节点也被向上调整完后才结束,所以向上调整建堆第一层不需要移动,直接从第二层开始。
由上图分析得出,向上调整建堆的时间复杂度为:O(N*logN)
由上面的分析得出,虽然向上调整和向下调整的空间复杂度相同,但是向下调整的时间复杂度更优,所以我们建堆是应该借助向下调整建堆来实现。
5.1.4堆排序的时间复杂度
我们刚才分析了堆排序中建堆的时间复杂度,那么堆排序的时间复杂度是多少呢?
堆排序的时间复杂度由上面两部分共同决定,我们已经知道了建堆的时间复杂度为O(N),我们再来计算一下排序这一部分的时间复杂度。
我们排序中的向下调整的每一个的堆的大小是变化的。最后堆中只有一个根节点。
综上所述,堆排序的时间复杂度为O(N*logN)。
5.2TOP-K问题
TOP-K问题就是返回一组数据中最大或者最小的前k个数据,一般数据都比较大。假设我现在有一个文件中存了N个数据,要求出里面最大的十个数,要怎么找出里面最大的十个数呢?
思路一:建一个N个数据的大堆,Pop10次,就得到了最大的前十个数。该思路的空间复杂度为O(N),时间复杂度为O(k*logN)。如果数据多的话,空间复杂度就太大了,需要占用的空间也就越大,如果用户对空间由要求的话,该方法就不太实用了。
思路二:取这N个数据的前10个数据建一个小堆,然后取堆顶的数据和剩余的数据依次比较,如果比堆顶的数据大,那就交换,然后向下调整,接着继续比较,直到将文件中的数据全都比完。比完之后,此时那个大堆中存放的就是前10大的数。
为了验证这一方法,我们先构造10000个随机数。
//构造数据
void CreatData()
{
//打开文件
FILE* pf = fopen("data.txt", "w");
if (pf == NULL)
{
perror("fopen");
return;
}
//生成100000个随机数
srand((unsigned)time(NULL));
int n = 100000;
while (n--)
{
int x = rand() % 1000000;//防止随机数超过1000000
fprintf(pf, "%d\n", x);
}
//关闭文件
fclose(pf);
pf = NULL;
}
创建好数据后,接下来就是按照刚才分析的步骤开始写代码。
//TOP-K问题
void Top_k(int k)
{
//打开文件
FILE* pf = fopen("data.txt", "r");
if (pf == NULL)
{
perror("fopen");
return;
}
//取前十个数
int a[10] = { 0 };
int i = 0;
for (i = 0; i < 10; i++)
{
fscanf(pf, "%d", &a[i]);
}
//建成小堆
for (i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, i, k-1);
}
int x = 0;
while (fscanf(pf, "%d", &x) != EOF)
{
if (a[0] < x)
{
//交换
Swap(&a[0], &x);
//向下调整
AdjustDown(a, 0, k);
}
}
printf("前10个数为:");
for (i = 0; i < 10; i++)
{
printf("%d ", a[i]);
}
//关闭文件
fclose(pf);
pf = NULL;
}
写完之后我们就开始调式代码,但是数据太多,打印出来的到底是不是我们也不知道,但是我们生成的随机数都是小于1000000的,我们可以在文件里面修改10个数据,使其的值超过1000000,如果结果上是这些数的话,就说明我们写对了。
我们看到这个数一眼假,说明肯定出错了。
那么接下来我们调试的时候要怎么办呢?100000个数据太多了,不好弄。我们可以在生成随机数的时候只生成20个,然后自己将其修改的小一点,添加一个数据,在调试,看看问题出现在了那里。
经调试发现,是我们向下调整函数中出了问题,修改后看下图结果。
这样,我们就完成了我们的TOP-K问题了。