本文重点:
- 树及树的表示法
- 二叉树的性质
- 堆的概念及堆的模拟实现
- TopK问题
引言:
从这篇文章开始,我们正式进入一个新的数据结构的学习,那就是树。我们先前学过的诸如顺序表、链表、栈以及队列都是属于线性的数据结构。这里我说的线性数据结构并不是数据的空间存储,而是强调的是逻辑上是连续的!而树的逻辑结构就不是线性的了。所以说对于一般的树型结构,我们没有必要研究对树的增删查改,只有一些特别的树的结构研究增删查改才有意义,这一点在后续的博客中会陆续登场。
正文:
一.什么是树
下面就是数据结构中经典的树型结构
下面结合这张图片来讲几个和树的概念:
节点的度:每个节点子树的个数,以节点A为例说明,节点A有两颗子树,所以节点A的度就是2,对于一颗树而言,最大的节点的度就是这颗树的度,以这棵树为例,由于最大的节点的度是2,所以这课树的度就是2
树的深度(高度):树中节点的最大层次,以这颗树为例,这颗树的最大层次是3,所以这棵树的深度就是3,空树默认深度是0。
叶子节点(终端节点):度是0的节点,同样以这棵树为例,图里的D、E、F、G就是叶子节点。
孩子节点:当前节点的子树的根节点,如图中A的孩子节点就是B,C,A也被叫做是B,C的父节点。
祖先节点:从一颗树的根节点沿着一个分支到达当前节点分支上的所有节点都是这个节点的祖先节点,如D的祖先节点就是A,B。
二.树的表示法:
树的一些基本的概念就介绍到这里,接下来我们看一看怎么来表示树。
树的结构比较复杂,不仅要保存对应的值,同时还要保证父子节点之间的关系。并且由于子树数量的不确定更加给我们表示树结构带来更大的困难。假设说我们采取链式的结构表示的化,我们可以这样表示:
struct Tree { int val; SeqList sl; };
采取顺序表来记录子树`
但是这并不是很好用:一是存储的代价变大了,二是虽然存下了子树的节点但是建立链接方式还是一件十分困难的事情. 那么还有什么更好的表示方法?
接下来有人创新性地提出了一个孩子兄弟表示法的表示方法,父节点永远指向第一个孩子,找到第一个孩子指针后,再用孩子指针来寻找兄弟,这样无形之中就把整棵树给连接起来了具体的表示方法如下:
struct TreeNode
{
int val;//值域
struct TreeNode* child;//左孩子
struct TreeNode* rbrother;//右兄弟
};
接下来我们画一个度是3的树:
以1为例,通过child指针找到了节点2,接下来就可以通过2的兄弟指针去找到3,同理就可以用3的指针连接到4。虽然1节点并没有链接到3,4两个孩子节点,但是由于2,3,4通过兄弟指针链接起来,也就间接地把1,3,4链接在了一起(物理上并没有建立连接!)
孩子兄弟表示法是目前最好的表示树的结构的方法,不过一般的树对于我们没有太大的研究价值。我们研究的是数据结构中非常特殊的一种树---->二叉树
二叉树
1.二叉树的结构
2.二叉树的性质
3.满二叉树和完全二叉树
4.堆
什么是二叉树
二叉树就是度是2的数,下图就是一棵经典的二叉树:
从树的定义来看,二叉树就是度为2的树
二叉树的结构分为:根, 左子树 ,右子树,还有两类特殊的二叉树:满二叉树,完全二叉树,这两个二叉树有什么特别之处呢?
满二叉树:每一层的节点个数都是满的二叉树,下图就是一个满二叉树:
完全二叉树:设一课二叉树的高度是h,如果前h-1层是满的并且最后一层的节点是依次连续的,那么这样的一棵二叉树就是完全二叉树,如下图就是一颗完全二叉树:
注意:如果是下面这种情况,那么就不是完全二叉树
不难看出,满二叉树是特殊的完全二叉树!
那么对于二叉树,有如下的一些性质:
1.假设根节点所在层为1,那么第i层最多有2^(i-1)个节点
2.假设根节点所在层为1,那么深度为h的二叉树的节点总数最多为2^h-1。
3.节点数为N的满二叉树的深度是h=log2(N+1)。
4.度为0的节点个数比度为1的节点个数多1个,即N0=N2+1.
5.如果采取把一个数组看作完全二叉树,设父节点的下标为parent,子节点的下标为child,那么parent和child满足如下的关系:
parent=(child-1)/2
child=2*parent+1
这个性质非常重要,堆的实现中我们会频繁地利用这两个公式。
接下来,我们来看几道有关上面几个性质应用的选择题。
- 某二叉树共有 399 个结点,其中有 199 个度为 2 的结点,则该二叉树中的叶子结点数为( 200)
解:根据性质4,不难得出N0=N2+1=199+1=200
2.在具有 2n 个结点的完全二叉树中,叶子结点个数为(n)
解:那么这道题同样利用了,上面的性质4,这里我们设度为1的节点有n1个,度为0的节点有n0个,度为2的节点有n2,显然有n0=n2+1,那么根据题目不难得出:n0+n1+n2=2n---->2n0+n1-1=2n.
这个方程有两个未知数能解得答案,但是不要忘记这是一颗完全二叉树,事实上在完全二叉树里面度为1的节点只可能是0个或者是1个!那么根据上面的等式显然n1=1,所以可以得到n0=n
堆
1.堆的概念及其分类
2.向上调整算法
3.向下 调整算法
4.堆的实现
5.TOPK问题
一.堆的分类:
首先和栈一样,这里的堆和系统的堆区是两个概念。那么数据结构的这个堆的物理存储结构是一个数组,但我们要把这个数组用完全二叉树的眼光来看待这个数组。
首先,堆有大堆和小堆的区别,我下面的例子都以小堆为例(包括堆的实现代码),大堆则是把符号改过来即可。
小堆:每一个父节点的值都小于它的左右孩子的堆就叫做小堆,下图就是一个经典的小堆
堆的第一个元素称为堆顶,对于小堆来说,堆顶就是整个堆里面最小的数。
2,3向上调整和向下调整算法
那么多数的情况下,数组里的数都是随机的,我们怎么能够把这课数整理成一个堆呢?这里就要用两个算法:向上调整算法和向下调整算法,我们对上图的小堆进行打乱,并使用这两个算法进行建堆。
首先打乱后的完全二叉树的结构如下:
建堆算法一:向上调整算法
从字面上来解析,就是从下面开始,如果孩子节点比父亲小,那么就交换父子节点的位置,如果还不是小堆就继续调直到根节点,如果已经是小堆了就可以不用在继续调。我们一左子树为例:
接着发现还不是堆,继续接着调整:
可以看到左子树已经是堆了就可以停止了,右子树的执行逻辑和这个类似就不在赘述了,那么我们就可以写出对应的代码逻辑:
//交换
void Swap(HPDataType* pa, HPDataType* pb)
{
HPDataType tmp = *pa;
*pa = *pb;
*pb = tmp;
}
//向上调整算法
void AdjustUp(HPDataType* a, size_t child)
{
assert(a);
size_t 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;
}
}
}
这里的HPDataType是对int的typedef,有利于提高代码的可维护性。
讲完向上调整算法,理解向下调整算法也就不难了!和向上调整类似,不过向下调整是从父节点的角度出发,对父节点进行类似的下调,当调到叶子节点就不再下调了,同样以左子树进行向下调整举例说明:
发现子树还不是一个堆,那么还要继续向下调整:
向下调整算法的代码如下:
//向下调整算法
void AdjustDown(HPDataType* a, size_t size, size_t root)
{
size_t parent = root;
size_t child = 2 * root + 1;
//向下调整算法
while (child<size)
{ //取出左右孩子小的那一个,然后和父节点进行比较
if (child + 1 < size && a[child + 1] < a[child])
{
++child;
}
//向下调整
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
//不用调整了
else
{
break;
}
}
}
这就是堆结构两个最核心的调整算法,堆的每次插入和删除都要进行调整来保证堆的结构的完整性!
4.堆的实现
堆的常见接口如下:
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
#include<time.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
size_t size;
size_t capacity;
}Heap;
//初始化堆
void HeapInit(Heap* hp);
//插入堆
void HeapPush(Heap* hp, HPDataType x);
//删除堆元素
void HeapPop(Heap* hp);
//交换
void Swap(HPDataType* pa, HPDataType* pb);
//向上调整算法
void AdjustUp(HPDataType* a, size_t child);
//堆判空
bool HeapEmpty(Heap* hp);
//堆元素的个数
size_t HeapSize(Heap* hp);
void HeapPrint(Heap* hp);
//释放堆
void HeapDestroy(Heap* hp);
//向下调整算法
void AdjustDown(HPDataType* a, size_t size, size_t root);
//堆排序
void HeapSort(HPDataType* a,size_t size);
//取堆顶的元素
HPDataType HeapTop(Heap* hp);
接口实现代码:
#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"
//初始化堆
void HeapInit(Heap* hp)
{
assert(hp);
hp->a = NULL;
hp->size = hp->capacity = 0;
}
//交换
void Swap(HPDataType* pa, HPDataType* pb)
{
HPDataType tmp = *pa;
*pa = *pb;
*pb = tmp;
}
//向上调整算法
void AdjustUp(HPDataType* a, size_t child)
{
assert(a);
size_t 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 HeapPush(Heap* hp, HPDataType x)
{
assert(hp);
if (hp->size == hp->capacity)
{
size_t newCapacity = hp->capacity == 0 ? 2 : hp->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * newCapacity);
if (NULL == tmp)
{
printf("realloc fail\n");
exit(-1);
}
else
{
hp->a = tmp;
hp->capacity = newCapacity;
}
}
hp->a[hp->size++] = x;
AdjustUp(hp->a, hp->size - 1);
}
//向下调整算法
void AdjustDown(HPDataType* a, size_t size, size_t root)
{
size_t parent = root;
size_t child = 2 * root + 1;
//向下调整算法
while (child<size)
{ //取出左右孩子小的那一个,然后和父节点进行比较
if (child + 1 < size && a[child + 1] < a[child])
{
++child;
}
//向下调整
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
//不用调整了
else
{
break;
}
}
}
//删除堆元素
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);
}
//堆判空
bool HeapEmpty(Heap* hp)
{
return hp->size == 0;
}
//堆元素的个数
size_t HeapSize(Heap* hp)
{
return hp->size;
}
void HeapPrint(Heap* hp)
{
for (size_t i = 0; i < hp->size; i++)
{
printf("%d ", hp->a[i]);
}
printf("\n");
}
//取堆顶的元素
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
return hp->a[0];
}
//释放堆
void HeapDestroy(Heap* hp)
{
assert(hp);
free(hp->a);
hp->a = NULL;
hp->size = hp->capacity = 0;
}
注意,在进行删出的时候,我们只能删除堆顶的元素,具体的执行逻辑就是把堆顶的元素和最后一个元素交换,size自减,然后再对整个堆结构进行调整,记住我们只能删除堆顶的元素!!!
5.TOPK问题
生活中常常面对一些需要选出前K个最大或者最小的数据,如:王者荣耀福建省前10李白,全国前10马超等等,这时候就可以利用堆的性质来解决TOPK问题。
选前K大的数,建立小堆,前K个数建堆,从第K+1个数开始,如果这个数大于当前的堆顶,那么就删除堆顶,在把这个数入进去,始终保持堆的数量是K,最后把堆里的数据全部取出就可以了
具体代码如下:
void PrintTopK(int* a, int n, int k)
{
Heap hp;
HeapInit(&hp);
for (int i = 0; i < n; ++i)
{
//取前K个数建堆
if (i < k)
{
HeapPush(&hp, a[i]);
}
else
{ //大于就删除堆顶,入堆
if (a[i] > HeapTop(&hp))
{
HeapPop(&hp);
HeapPush(&hp, a[i]);
}
}
}
//依次取出堆里的数据就是最大的前K个数
while (!HeapEmpty(&hp))
{
int top = HeapTop(&hp);
printf("%d ", top);
HeapPop(&hp);
}
}
测试代码如下:
void TestTopk()
{
int n = 10000;
//随机生成10000个数
int* a = (int*)malloc(sizeof(int) * n);
assert(a);
srand((unsigned)time(NULL));
for (size_t i = 0; i < n; ++i)
{ //确保其他位置的数的值小于10000
a[i] = rand() % 10000;
}
//随机给定最大的10个数
a[13] = 10000;
a[333] = 10001;
a[2491] = 10002;
a[3541] = 10003;
a[2322] = 10004;
a[1949] = 10005;
a[2022] = 10006;
a[2035] = 10007;
a[371] = 10008;
a[1395] = 10009;
a[240] = 10010;
PrintTopK(a, n, 10);
free(a);
a = NULL;
}
程序的运行结果如下:
可以看出,确实选出了前10大的数字,证明我们的代码没有错误!注意:选出的前10个大的数不一定是有序的,但是肯定是所有数据里面最大的10个,同时,TOPK问题采用堆是最好的方法(没有之一)
还有一种排序算法也是基于堆这种数据结构,这种排序叫做堆排序,效率非常高。我会在排序的博客中介绍
同时还有向下调整算法和向上调整算法的时间复杂度的计算也会在排序的博客中一并提到,敬请期待!
下篇预告:
1.链式二叉树
2.链式二叉树的表示法
3.二叉树的前中后序遍历思想
4.求二叉树的叶子节点个数
5.第K层的节点个数
6.二叉树的层序遍历
若有不足之处,还望读者及时指出,希望大家可以共同进步。