目录
一、二叉树的概念
1.1二叉树
?1.2 二叉树的分类
?1.3二叉树的性质
?1.4二叉树的存储结构
二、堆
2.1堆的概念及结构
2.2堆的实现
2.3时间复杂度
三、堆的应用
3.1堆排序
3.2TopK问题
?
一、二叉树的概念
1.1二叉树
一棵二叉树是结点的一个有限集合,该集合:
1. 可以为空
2. 由一个根节点加上两棵别称为左子树和右子树的二叉树组成。
?
??从上图可以看出:
1. 二叉树不存在度大于2的结点;
2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树。
任意二叉树都应该是以下几种情况复合而成:
?现实中也是存在这种树的:
?1.2 二叉树的分类
二叉树分为两类:满二叉树和完全二叉树。
1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是 说,如果一个二叉树的层数为K,且结点总数是2^K-1,则它就是满二叉树。
2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
?
?1.3二叉树的性质
1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2^(i-1)个结点。
2. 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2^h-1个。
3. 对任何一棵二叉树,如果度为0,其叶结点个数为N0,?度为2的分支结点个数为N2,则有
N0=N2+1.
4. 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h=log(n+1);(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>=n,则无左孩子。
? ?3. 若2i+2<n,则有右孩子,若2i+2>=n,则无右孩子。
总结一下,我觉得比较重要的有这几点:
1、第K层的节点个数为2^(K-1).
2、一个满二叉树的节点个数为2^K-1。
3、双亲结点找左孩子:leftchild=parent*2+1; rightchild=parent*2+2.(leftchild,rightchild,parent均为下标)。
4、孩子找双亲结点:parent=(child-1)/2。
5、度为0的叶节点个数为N0,度为2的分支节点个数为N2,则有N0=N2+1。因为一个二叉树无非由三种节点组成,度为0的叶节点,度为1和度为2的分支节点组成。所以总的节点个数就是N=N0+N1+N2;掌握叶节点和度为2的分支节点的关系有助于解题。
6、完全二叉树的节点个数区间为:(设层数为K):[ 2^(k-1),2^k-1 ].
1.4二叉树的存储结构
1. 顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
?2. 链式存储
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,链式结构仅作简单介绍,并不会详细讲解。
二、堆
2.1堆的概念及结构
1、堆的概念
如果有一个集合,它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并且满足双亲结点小于孩子,或者双亲结点大于孩子,则成为大堆(或小堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
这里我们可以看出堆的性质:
1、堆中某个节点的值总是不大于或不小于其双亲节点的值。
2、堆总是一棵完全二叉树。
2.2堆的实现
在介绍了堆的概念以及它的存储模式之后,那么我们如何建堆呢?
通常建堆有两种模式:向上调整建堆和向下调整建堆。我们以建小根堆为例来介绍这两种建堆模式,先来介绍向上调整建堆的模式。
①向上调整建堆
向上调整建堆的基本模式与原理:
1、先将元素插入到堆的末尾,即最后一个孩子之后。
2、插入之后如果小根堆或者大根堆的性质遭到破坏,就将新插入的节点顺着双亲往上调整到合适位置。
向上调整原理图:
代码实现:
void AdjustUp(int* a, int child)
{
while (child>0)
{
int parent = (child - 1) / 2;
if (a[child] < a[parent])
{
//建小根堆
Swap(&a[child], &a[parent]);
child = parent;
}
else
{
break;
}
}
}
?②向下调整建堆
向下调整建堆和向上调整建堆不同的是,它需要左右子树必须是一个堆才能调整。
图解过程:
代码实现:
void AdjustDown(HPDataType* a, int size, int parent)
{
int maxchild = parent * 2 + 1;
while (maxchild<size)
{
if (maxchild+1<size&&a[maxchild] < a[maxchild + 1])
{
maxchild++;
}
if (a[parent] < a[maxchild])
{
Swap(&a[parent], &a[maxchild]);
parent = maxchild;
maxchild = parent * 2 + 1;
}
else
break;
}
}
2.3时间复杂度
向上调整建堆:
向下调整建堆:
通过比较我们发现向上调整和向下调整建堆各有优劣:
1、向上调整建堆不需要任何条件即可建堆,而向下调整建堆则需要左右子树本身就是大根堆或者小根堆。
2、向下调整建堆的时间复杂度远小于向上调整建堆,原因也很简单,向下调整建堆最后一层节点不需要向下调整,而这一部分的节点几乎占了一半的节点,而向上调整建堆仅仅是根节点没有调整,所以时间复杂度向下调整要优于向上调整。
我们是优先选择向下调整建堆的,但是它是有限制的,如果我们从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点,就可以调整成堆。
例如:
三、堆的应用
3.1堆排序
我们学习过比较简单的冒泡排序,今天主要想介绍一下堆排序。
大根堆和小根堆分别可以选出最大的数和最小的数,而且我们前面已经证实向下调整算法明显优于向上调整,所以我们的堆排序也以向下调整来写的一个算法。
那么我们堆排序的基本思想是什么呢?
1、如果我们以升序为例,选用小根堆,选出最小的数排在首位,剩下数据看作堆,这时候的问题就是双亲与子的关系就全乱了,只能重新建堆,如果每个数都这样排序,建堆,那么时间复杂度是0(N^2),也就和冒泡排序半斤八两了,显然堆排序的优势就荡然无存。
2、如果我们换一种思维,我们不能抛弃向下调整的优势,又要排出有序的数组,我们采用交换的方式,如果我们排升序,那么选用大根堆是极好的,我们怎么做呢?
①选出最大的数,然后和最后一个数交换,这时最后那个数来到首位
②对第一个数进行向下调整算法(但是最后一个数不参加向下调整),因为它的左右子树满足大根堆,是可以调整的,一次调整选出最大的数排在末尾,第二次就选出次大的数,依此类推。每个数进行向下调整,时间复杂度是0(N*logN)。大大优化。
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void AdjustDown(int* a, int size, int parent)
{
int maxchild = parent * 2 + 1;
while (maxchild < size)
{
if (maxchild + 1 < size && a[maxchild] < a[maxchild + 1])//右孩子要小于size
{
maxchild++;
}
if (a[parent] < a[maxchild])
{
Swap(&a[parent], &a[maxchild]);
parent = maxchild;
maxchild = parent * 2 + 1;
}
else
break;
}
}
void HeapSort(int* a, int n)
{
//向下调整建堆
for (int i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
int i = 1;
while (i < n)
{
Swap(&a[0], &a[n - i]);
AdjustDown(a, n - i, 0);
++i;
}
}
void PrintArray(int* a, int size)
{
for (int i = 0; i < size; i++)
{
printf("%d ", a[i]);
}
printf("\n");
}
int main()
{
int a[] = { 10,20,45,32,66,17,22,36,76 };
int N = sizeof(a) / sizeof(int);
HeapSort(a,N);
PrintArray(a,N);
return 0;
}
3.2TopK问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。 对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了。最佳的方式就是用堆来解决,基本思路如下:
1. 用数据集合中前K个元素来建堆
????????若求前K个最大的元素,则建小堆‘;
????????若求前K个最小的元素,则建大堆。
2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素。将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
简单来说就是,我们不将全部数据导入堆,因为这样不仅耗时而且对于内存是极大的占用。如果要求找前K个最大或者最小的数,我们只作一个小堆,把前K个元素导入堆。
如果求前K个最小的元素:我们建大根堆,根是这K个数里最大的数,如果有数比根还要小,就让这个数顶替根,然后调整根,求前K个最大的数与之类似。
基于这样一个理念,我从一个文件导入100000个随机数,选出其中10个最大或最小(自己调整)的数。
代码实现:
void Swap(HPDataType* a, HPDataType* b)
{
HPDataType tmp = *a;
*a = *b;
*b = tmp;
}
void AdjustDown(HPDataType* a, int size, int parent)
{
int minchild = parent * 2 + 1;
while (minchild < size)
{
if (minchild + 1 < size && a[minchild] > a[minchild + 1])//右孩子要小于size
{
minchild++;
}
if (a[parent] > a[minchild])
{
Swap(&a[parent], &a[minchild]);
parent = minchild;
minchild = parent * 2 + 1;
}
else
break;
}
}
void CreateDataFile(const char* filename, int N)
{
FILE* fin = fopen(filename, "w");
if (fin == NULL)
{
perror("fopen fail");
return;
}
else
{
srand(time(0));
for (int i = 0; i < N; ++i)
{
fprintf(fin, "%d ", rand() % 1000000);
}
}
fclose(fin);
}
void PrintTopK(const char* filename, int K)
{
assert(filename);
FILE* fout = fopen(filename, "r");
if (fout == NULL)
{
perror("fopen fail");
return;
}
int* maxHeap = (int*)malloc(sizeof(int) * K);
if (maxHeap == NULL)
{
perror("malloc fail");
return;
}
//先读K个数
for (int i = 0; i < K; ++i)
{
fscanf(fout, "%d", &maxHeap[i]);
}
for (int j = (K - 2) / 2; j >= 0; --j)
{
AdjustDown(maxHeap, K, j);
}
int val = 0;
while (fscanf(fout, "%d", &val) != EOF)
{
if (val > maxHeap[0])
{
maxHeap[0] = val;
AdjustDown(maxHeap, K, 0);
}
}
for (int i = 0; i < K; ++i)
{
printf("%d ", maxHeap[i]);
}
free(maxHeap);
fclose(fout);
}
int main()
{
const char* filename = "Data.txt";
int N = 100000;
int K = 10;
//CreateDataFile(filename, N);
PrintTopK(filename, K);
return 0;
}
|