目录
- 一、树的概念与结构
- 1.1 树的概念
- 1.2 树的相关概念
- 1.3 树的表示及实际应用
- 二、二叉树概念及结构
- 2.1 二叉树的概念
- 2.2 特殊的二叉树
- 2.2.1 满二叉树
- 2.2.2 完全二叉树
- 2.3 二叉树的存储结构
- 三、二叉树的顺序结构与实现
- 3.1 堆的概念及结构
- 3.2 堆的实现
- 3.2.1 堆的创建与销毁
- 3.2.2 堆的插入与向上调整算法
- 3.2.3 堆的删除与向下调整算法
- 3.2.4 其他操作
- 3.3 堆的应用
- 3.3.1 建堆
- 3.3.2 堆排序
- 3.3.3 TOP-K问题
一、树的概念与结构
上图是程序员界一棵经典的“圣树”——二叉树。非常有意思的一张图。当学完本篇以及下一篇树相关的文章后,大家可以返回来看看这到底是棵什么树。
本篇的重点是下面几张图,我们需要对下面几张图里的结构了如指掌。
1.1 树的概念
在开始之前,先给出树的定义。
树:树是一种非线性的数据结构,它是由n(n ∈ N+)个有限结点组成的一个具有层次关系的集合。当n = 0时,称为空树。
树的结构简单来讲就是一种分支结构,由上至下逐层分支延伸,我们可以将树分成根与根的分支。把它叫做树的原因也在于此,这种结构看起来就像是一棵倒挂的树,根在上,而叶在下。
对于任意一棵具有n个结点的非空树:
- 有且仅有一个特定结点称为根结点(root);
- 除根结点,每个结点都有且仅有一个前驱结点,形象化地表示为双亲结点/父结点,简称双亲;
- 一棵具有n个结点的非空树,一定具有n - 1条边(除根结点外,每个孩子结点都有一条边连向双亲结点);(孩子结点对应双亲结点,表示双亲的后继结点)
- 当n > 1时,其余结点可分为m (m > 0)个互不相交的有限集T1,T2,…,Tm,其中每一个集合本身又是一颗树,并且称为根的子树(SubTree);(根的子树也有根,子树的根又有子树,可以重复划分下去)
- 若存在多个子树,子树之间互不相交,相交则不是树形结构。
对于一棵树而言,如上图,它的根结点一定没有双亲结点(A没有双亲结点),但有零或多个后继/分支(A有B、C、D三个后继)。
对于一棵子树而言,如上图,它的根结点有且仅有一个双亲结点(例如分支1就是A的一棵子树,该子树的根结点是B,B有一个父结点A),但有零或多个后继/分支(B有E、F两个后继)。
这种树分化更多的子树的形式很像递归,而事实上本篇的重点之一——二叉树的链式结构实现中也确确实实有许多递归定义的函数。
1.2 树的相关概念
1.1 树的概念
是对树的整体介绍,目的是带大家有个基础的了解。1.2 树的相关概念
则是更为详细的树相关的概念与术语等。
如下:
-
结点的度:一个结点含有的子树的个数称为该结点的度; 如上图,A的度为6(重要);
-
叶结点(终端结点):度为0的结点称为叶结点; 如上图,B、C、H、I…等结点为叶结点(重要);
-
分支结点(非终端结点):度不为0的结点; 如上图:D、E、F、G…等结点为分支结点;
-
双亲结点或父结点:若一个结点含有子结点,则这个结点称为其子结点的父结点; 如上图:A是B的父结点(重要);
-
孩子结点或子结点:一个结点含有的子树的根结点称为该结点的子结点; 如上图:B是A的孩子结点(重要);
-
兄弟结点:具有相同父结点的结点互称为兄弟结点; 如上图:B、C是兄弟结点;
-
树的度:一棵树中,最大的结点的度称为树的度; 如上图:树的度为6(重要);
-
结点的层次:从根开始定义起,根为第1层,根的子结点为第2层,以此类推(重要);
-
树的高度或深度:树中结点的最大层次; 如上图:树的高度为4(重要);
-
堂兄弟结点:双亲在同一层的结点互为堂兄弟;如上图:H、I互为兄弟结点;
-
结点的祖先:从根到该结点所经分支上的所有结点;如上图:A是所有结点的祖先;
-
子孙:以某结点为根的子树中任一结点都称为该结点的子孙。如上图:所有结点都是A的子孙;
-
森林:由m (m > 0)棵互不相交的树的集合称为森林。
1.3 树的表示及实际应用
树的结构相对线性表的结构就比较复杂了,要存储表示起来就比较麻烦了,既要保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。
这里给出其中最常用的孩子兄弟表示法作为示例(孩子表示法也很常用,但在树的下一篇可以见到)。
typedef int DataType;
struct Node
{
struct Node* firstChild1;// 第一个孩子结点
struct Node* pNextBrother;// 下一个兄弟结点(一般从左至右)
DataType data;// 当前结点存储的值
};
数据的存储情况如下:
采用树形结构具体有哪些应用呢?
看下图
最常见的就是文件资源管理系统,树形结构在操作系统设计和文件管理中经常出现。
同时,在数据库中,树形结构通常也会用于数据库设计和数据管理。
二、二叉树概念及结构
2.1 二叉树的概念
二叉树就是每个结点至多只有两棵子树的树(即二叉树任一结点的度小于等于2),通常称二叉树的两棵子树为左子树与右子树。
从上图可以看出
- 二叉树不存在度大于2的结点;
- 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树;
- 二叉树的结点结构包含一个数据域和两个分别指向左右子树的指针。
注意,对于任意二叉树,只存在以下情况:
二叉树是一种递归的数据结构,可以是空树(即没有任何结点),也可以由根结点及其左右子树组成(左右子树也可以为空)。
二叉树有多种类型,包括:二叉排序树、完全二叉树、满二叉树、平衡二叉树等。
本篇的重点为二叉树,满二叉树与完全二叉树。
2.2 特殊的二叉树
2.2.1 满二叉树
满二叉树:一个二叉树,如果每一层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是2k - 1,则它就是满二叉树。
由上图可以看出:
- 对于任意满二叉树,除叶结点度为0外,任一结点的度都为2,即满二叉树没有度为1的结点;
- 满二叉树由上至下,结点数量逐层扩大2倍;
- 第k层的结点数为2k-1,前k层的结点总数为2k - 1。(重要)
现在再在满二叉树中引入编号概念:
约定从1号——根结点开始,自上而下,自左向右进行编号。这样,每个结点对应一个编号。
图中数字对应当前结点的编号。
对于编号为i
的结点,若有双亲,则其双亲为i/2
,若有左孩子,则左孩子为2i
,若有右孩子,则右孩子为2i+1
。
注:计算机中的除法运算向下取整,因此不管是左右孩子我们都可以通过除于2得到双亲序号。
编号概念可以帮助我们更好的学习与理解完全二叉树与堆。
2.2.2 完全二叉树
完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K,N个结点的二叉树,当且仅当每一个结点都与相同高度K的满二叉树中编号从1至N的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
相同深度的完全二叉树与满二叉树的区别就在于最后一层叶子结点。满二叉树的最后一层,叶子结点数,一定是“满”的。完全二叉树的叶子结点数可以不“满”,但一定是从左至右连续的叶子结点。
例如上图所示,这四种情况是深度为3的完全二叉树的所有可能情况。第一种同时也是满二叉树。
2.3 二叉树的存储结构
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
- 顺序存储:顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为非完全二叉树会有空间的浪费(非完全二叉树除根结点外每一层都可能有空)。而实际使用中只有堆才会使用数组来存储(堆属于完全二叉树利用顺序结构存储的一种应用)。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
- 链式存储:链式存储结构就是使用链表来表示一棵二叉树。通常的方法是链表中每个结点由三个域组成,数据域和左、右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 (即前面提及的孩子表示法)。链式结构又分为二叉链和三叉链,几叉即几条链接。本篇只涉及二叉链,但高阶数据结构如红黑树等会用到三叉链。
typedef int BTDataType;
// 二叉链-left,right两条链接
struct BinaryTreeNode
{
struct BinTreeNode* left; // 指向当前结点左孩子
struct BinTreeNode* right; // 指向当前结点右孩子
BTDataType data; // 当前结点值域
}
// 三叉链-parent,left,right三条链接
struct BinaryTreeNode
{
struct BinTreeNode* parent; // 指向当前结点的双亲
struct BinTreeNode* left; // 指向当前结点左孩子
struct BinTreeNode* right; // 指向当前结点右孩子
BTDataType data; // 当前结点值域
};
三、二叉树的顺序结构与实现
3.1 堆的概念及结构
现实中我们通常把堆(一种特殊完全二叉树)使用顺序结构的数组来存储,需要注意的是数据结构里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
堆定义:如果有一个关键码的集合K = { k0, k1, k2,…, kn-1 },把它的所有元素以完全二叉树的顺序存储方式存储在一个一维数组中,并满足:ki <= k2i+1且ki <= k2i+2 (ki >= k2i+1且ki >= k2i+2), i=0,1,2,…,n-1,则称为小堆(或大堆)。将根结点最大的堆叫做最大堆或大根堆,根结点最小的堆叫做最小堆或小根堆。
也就是说如果一棵完全二叉树各个结点的值大于它自己的左右孩子,则为大堆;如果一棵完全二叉树各个结点的值小于它自己的左右孩子,则为小堆。同时,如果根结点大于所有结点则为大根堆,如果根结点小于所有结点则为小根堆。
下面来解释一下“以完全二叉树的顺序存储方式”:把数据按次序排成完全二叉树,再按从上至下从左至右的顺序依次放入一维数组。
关键码的集合就是一维数组。而在前面满二叉树中我们拓展了编号的概念,相似的,这里的数组下标就类似前面的编号,但数组下标是从0开始的,所以计算公式需要稍稍变化。
编号从1开始,那么对于某个编号为i的结点:
i/2就是它的双亲编号,2i就是它的左孩子编号,2i+1就是它的右孩子编号。
数组下标从0开始,我们仔细观察上图中的两组关键码可以发现:
对于某个下标为i的关键码,它的双亲下标是(i-1)/2,它的左孩子下标是2i+1,它的右孩子下标是2i+2。
编号概念延伸到完全二叉树的顺序结构存储中将帮助我们快速的找到堆中任意结点的双亲或孩子。
3.2 堆的实现
堆的实现,也是实现特殊完全二叉树的顺序结构。
需要实现的操作包括:堆的创建与销毁,堆的向上与向下调整,堆的插入与删除,取堆顶数据,获取堆的数据个数,堆的判空以及拓展内容—堆排序。
先给出总代码。
头文件:
#pragma once
#include<stdio.h>
#include<assert.h>// 提供assert函数
#include<stdlib.h>// 提供realloc,free函数
#include<stdbool.h>// 提供bool数据类型
typedef int HPDataType;// 堆中的数据的类型重命名
typedef struct Heap// 堆的结构体定义
{
HPDataType* a;// 动态数组存储堆中数据
int size;// 记录堆中数据个数
int capacity;// 记录动态数组容量
}Heap;
// 堆的初始化
void HeapInit(Heap* php);
// 堆的销毁
void HeapDestory(Heap* php);
// 交换数组元素
void Swap(HPDataType* const m, HPDataType* const n);
// 向上调整
void HeapAdjustUp(HPDataType* arr, int child);
// 堆的插入
void HeapPush(Heap* php, HPDataType x);
// 向下调整
void HeapAdjustDown(HPDataType* arr, int n, int parent);
// 堆的删除
void HeapPop(Heap* php);
// 取堆顶的数据
HPDataType HeapTop(Heap* php);
// 堆的数据个数
int HeapSize(Heap* php);
// 堆的判空
bool HeapEmpty(Heap* php);
// 对数组进行堆排序
void HeapSort(int* a, int n);
源文件:
//堆的初始化
void HeapInit(Heap* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
// 堆的销毁
void HeapDestory(Heap* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
//交换数组元素
void Swap(HPDataType* const m, HPDataType* const n)
{
assert(m && n);
HPDataType tmp = *m;
*m = *n;
*n = tmp;
}
//向上调整
void HeapAdjustUp(HPDataType* arr, int child)
{
assert(arr);
int parent = (child - 1) / 2;
while (child > 0)
{
if (arr[child] < arr[parent])// <为小堆,>为大堆
{
Swap(&arr[child], &arr[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
// 堆的插入——大堆/小堆
void HeapPush(Heap* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newcapacity);
if (!tmp)
{
perror("HeapPush::realloc fail!");
return;
}
php->a = tmp;
php->capacity = newcapacity;
}
php->a[php->size++] = x;
HeapAdjustUp(php->a, php->size-1);
}
// 向下调整
void HeapAdjustDown(HPDataType* arr, int n, int parent)
{
assert(arr);
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && arr[child + 1] < arr[child])// <为小堆,>为大堆
{
child++;
}
if (arr[parent] > arr[child])// >为小堆,<为大堆
{
Swap(&arr[parent], &arr[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
// 堆的删除:删除根结点
void HeapPop(Heap* php)
{
assert(php);
assert(php->size > 0);
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
HeapAdjustDown(php->a, php->size, 0);
}
// 取堆顶的数据
HPDataType HeapTop(Heap* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
// 堆的数据个数
int HeapSize(Heap* php)
{
assert(php);
return php->size;
}
// 堆的判空
bool HeapEmpty(Heap* php)
{
assert(php);
return php->size == 0;
}
// 对数组进行堆排序
// 降序用小堆/升序用大堆
void HeapSort(int* a, int n)
{
assert(a);
向上调整建堆-时间复杂度O(n*logn)
//for (int i = 1; i < n; i++)
//{
// HeapAdjustUp(a, i);
//}
//向下调整建堆-时间复杂度O(n)
for (int i = (n - 2) / 2; i >= 0; i--)
{
HeapAdjustDown(a, n, i);
}
//排序—时间复杂度O(n*logn)
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
HeapAdjustDown(a, end, 0);
end--;
}
}
3.2.1 堆的创建与销毁
前面已经介绍过,堆实际上就是以顺序结构存储的特殊的完全二叉树,本质是一个数组。所以堆的创建就是申请一个数组并初始化。当然也有直接将现成数组(存储了有效数据的数组)调整成堆数组的情况,这在后面的建堆会提到。
堆的定义如下:
typedef int HPDataType;// 重命名堆中的数据的类型,方便统一修改
typedef struct Heap// 堆的定义
{
HPDataType* a;// 申请一个动态数组存储堆中数据
int size;// 堆中数据个数
int capacity;// 堆/动态数组最大容量
}Heap;
和上一篇栈相关的文章中实现栈时类似,动态数组指针负责指向存储一系列堆中数据的内存空间,在使用时按需申请空间;整型变量size负责记录当前存储的有效数据个数;整型变量capacity负责记录当前动态数组的最大容量,当size等于capacity时,那么数组满容,就需要重新申请空间进行扩容。
以上是堆的定义,我们还需要对堆进行初始化:
// 堆的初始化
void HeapInit(Heap* php)// php是指向堆的指针
{
assert(php);
php->a = NULL;// 堆中动态数组还未申请空间,先将指针置为空
php->size = php->capacity = 0;// 同理将数据个数与数组容量置为0
}
同理,堆的销毁就是释放数组空间并将数据个数与数组容量初始化:
void HeapDestroy(Heap* php)
{
assert(php);
free(php->a);// 释放动态数组申请的空间
php->a = NULL;
php->size = php->capacity = 0;
}
使用:
int main()
{
Heap hp;// 创建代表堆的变量
HeapInit(&hp);// 传递堆变量的地址进行初始化
HeapDestroy(&hp);// 传递堆变量的地址进行销毁
}
3.2.2 堆的插入与向上调整算法
我们知道堆的存储结构是数组,而堆的插入操作其实就是在数组尾部进行插入。但如何保证在插入新数据后堆的结构不被破坏呢?
例如下面的示例,这是一个小根堆,抛开逻辑结构,它的实际存储结构是20,25,55,50,30,60
。现在我们在这个小根堆中进行插入操作,插入了一个5,那么如何恢复被破坏的堆结构呢?这需要用到向上调整算法。
要想保证原结构,我们就需要将5向上调整。
向上调整的具体逻辑是:孩子与双亲比较,若符合条件则与双亲进行交换,不断的比较与交换直到根结点或者恢复堆结构提前结束。
直接来看操作,孩子结点的值5小于双亲结点的值55,交换
5小于20,交换
孩子结点为根结点结束。当然还存在一种结束情况,那就是孩子结点大于双亲结点,即已经恢复堆结构。需要注意,向上调整的条件是:插入数据前数组中的数据为堆序列(即符合堆结构的元素序列)。在插入5之前,数组中的序列是符合小根堆结构的,所以能采用向上调整算法恢复堆结构。
向上调整代码示例如下:
void HeapAdjustUp(HPDataType* arr, int child)// arr就是上面堆结构体中指向动态数组的指针,child是需要向上调整的结点的下标
{
assert(arr);
int parent = (child - 1) / 2;// 通过孩子的下标找双亲的下标,(i-1)/2即可
while (child > 0)// 结束条件一:child=0,也就是孩子是根结点时
{
if (arr[child] < arr[parent])// <为小堆,>为大堆
{
Swap(&arr[child], &arr[parent]);// 交换孩子与双亲,即交换数组元素
child = parent;// 更新孩子下标为原双亲的下标
parent = (child - 1) / 2;// 更新新孩子下标的双亲下标
}
else// 结束条件二:孩子结点与双亲结点的大小关系符合堆中关系
{
break;
}
}
}
不管需要建大堆还是建小堆,我们只需要更改一下判断语句的比较逻辑即可,也就是更改一下if语句中的大小判断。
有了向上调整,我们就可以很轻松的实现堆的插入操作了,代码示例如下:
void HeapPush(Heap* php, HPDataType x)
{
assert(php);
//先判断是否需要扩容
if (php->size == php->capacity)// 数据个数等于数组最大容量时就要扩容
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;// capacity等于0时就置为4,不等于0就乘以2倍,然后赋值给newcapacity
HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newcapacity);// realloc重新申请空间
if (!tmp)
{ //申请失败
perror("HeapPush::realloc fail!");
return;
}
//申请成功
php->a = tmp;// 令堆中指向动态数组空间的指针指向新申请到的空间
php->capacity = newcapacity;// 更新数组最大容量
}
php->a[php->size++] = x;// 数组尾部插入新数据
HeapAdjustUp(php->a, php->size-1);// 将新数据向上调整
}
3.2.3 堆的删除与向下调整算法
什么是堆的删除操作呢?堆的删除操作要实现的是对于根结点的删除。如果我们直接删除根结点数据,那么原来堆的结构就无序了,例如下面的小根堆:
如果我们直接删除10的话,就不再是小根堆结构了。所以直接删除根结点会非常麻烦。在堆的删除中同样需要引入一个算法,也就是向下调整。和向上调整类似,向下调整的条件同样是需要原序列为堆序列,逻辑也完全类似就是比较与交换。
具体为:双亲与孩子比较,符合条件就交换,直至双亲为叶子结点或者恢复堆结构时结束。
例如,我们将上面小根堆的根结点10用50替换,我们可以利用向下调整的算法重新恢复原来的堆结构
50大于20那么50与20进行交换
50大于25且大于30,那么50与最小的25交换
不断重复比较交换直到叶子结点,最终重新得到小根堆。
堆的删除操作就是利用最后一个叶子结点与根结点进行交换,然后size减1即可删除原根结点,再将新的根结点向下调整完成全部操作。
向下调整算法的代码示例如下:
void HeapAdjustDown(HPDataType* arr, int n, int parent)// n是当前堆中数据个数,parent是需要向下调整的结点的下标
{
assert(arr);
int child = parent * 2 + 1;// 由双亲结点的下标得到左孩子结点的下标,直接2i+1即可
while (child < n)// 结束条件一:child等于最大下标n-1
{
// 先比较左孩子与右孩子,得到较大值或较小值的孩子
if (child + 1 < n && arr[child + 1] < arr[child])// <为小堆,>为大堆
{
child++;
}
// 再比较孩子与双亲
if (arr[parent] > arr[child])// >为小堆,<为大堆
{
Swap(&arr[parent], &arr[child]);
parent = child;
child = parent * 2 + 1;
}
else// 结束条件二:孩子结点与双亲结点的大小关系符合堆中关系
{
break;
}
}
}
堆的删除操作代码示例如下:
void HeapPop(Heap* php)
{
assert(php);
assert(php->size > 0);// 确保堆中有数据可删
Swap(&php->a[0], &php->a[php->size - 1]);// 交换堆的根结点与最后一个叶子结点
php->size--;// 堆中数据个数减1
HeapAdjustDown(php->a, php->size, 0);// 将新的根结点向下调整
}
3.2.4 其他操作
还有获取堆中数据个数,获取堆顶的数据以及堆的判空等操作未实现。
代码示例如下:
// 取堆顶的数据
HPDataType HeapTop(Heap* php)
{
assert(php);
assert(php->size > 0);// 确保堆中存在数据
return php->a[0];
}
// 堆的数据个数
int HeapSize(Heap* php)
{
assert(php);
return php->size;
}
// 堆的判空
bool HeapEmpty(Heap* php)
{
assert(php);
return php->size == 0;// 如果size=0则返回true,否则返回false
}
尽管这些操作十分简单,实现代码也十分少,但我们仍然需要将这些操作分别封装起来,而不是用的时候直接写这些代码,因为同样的数据结构,代码的实现细节并不总是相同的,为了方便与统一,我们单独封装这些操作成为一个函数,既方便理解,使用时也可以直接调用函数完成操作。
3.3 堆的应用
3.3.1 建堆
堆的基本操作实现了,但对于数组中的任意一串数据,我们如何将其转化成堆结构呢?
例如,数组int a[] = { 8, 1, 3, 10, 20, 100, 0, 11, 19 }
,很明显这个数组中的元素并不是堆序列。那么我们如何转化为有效堆序列呢?
来看代码:
Heap hp;// 创建堆
int arr[9] = { 8, 1, 3, 10, 20, 100, 0, 11, 19 };// 一系列数据需要存储入堆中
HeapInit(&hp);// 初始化堆
for (int i = 0; i < 9; i++)
{
HeapPush(&hp, arr[i]);// 将数组中的元素依次插入堆中
}
for (int i = 0; i < 9; i++)
{
printf("%d ", hp.a[i]);// 打印堆中数据
}
打印结果(完成转化):
其实在上面的插入操作中我们已经完成了对任意一串数据建堆的主要操作。因为堆初始为空,依次向堆中插入数据,插入操作中会自动对数据进行调整算法,数据全部插入后直接就是堆结构。所以任意一组数据,我们直接插入堆中就是,插入算法会帮助我们调整并保存原堆结构。
知道关键原理后,我们当然也可以直接在数组上建堆。这里有两种建堆方法,就复杂度而言向下调整更优,这里不再赘述,大家可以自行了解向上调整建堆与向下调整建堆的时间复杂度差异,最终就是一个数列求和问题。
int arr[9] = { 8, 1, 3, 10, 20, 100, 0, 11, 19 };
向上调整建堆-时间复杂度O(nlogn)
//for (int i = 1; i < n; i++)
//{
// HeapAdjustUp(a, i);
//}
// 向下调整建堆-时间复杂度O(n)
// i初始值找最后一个非叶子结点,n为数据个数,n-1为最后一个元素的下标,(n-1-1)/2为其双亲结点
for (int i = (n - 2) / 2; i >= 0; i--)
{
HeapAdjustDown(a, n, i);
}
向下调整建堆:从最后一个非叶子结点开始,它与它前面所有结点都执行一次向下调整算法。具体流程如下(调整顺序为10-3-1-8):
向上调整建堆:我们从下标为1的结点,即根结点的左孩子开始,它与它后面每一个结点都执行一次向上调整算法。具体流程如下(调整顺序为1-3-10-20-100-0-11-19)。
3.3.2 堆排序
堆排序即利用堆的思想来进行排序。我们想要将数组中的数据排成有序(升序或降序),分为两个步骤:
- 建堆
- 升序:建大堆
- 降序:建小堆
- 利用堆删除思想来进行排序
建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。
先来解释一下升序建大,降序建小的问题。
对于任意一个堆,如上,一个小根堆,升序建小堆的话会出现一个非常尴尬的情况,那就是根结点是最小的,根结点位置是正确的,位于数组下标为0的位置,我们想要继续得出第二小的数据放在数组下标为1的位置时,数据已经不再是堆序列,重新恢复堆结构会十分麻烦。所以为了方便,我们用降序建小,升序建大,这样我们就可以利用堆删除中的向下调整的思想来排序了。
对于上面这个小堆,我们将根结点0与最后一个叶子结点19交换,这样我们就得到了降序序列的最小元素了且位于数组尾,再将0从堆排序中排除(把0“当作”从堆中删除了),然后将19向下调整就又恢复了堆结构。我们不断交换根元素与剩余未排序序列中的尾元素,再将新根元素向下调整。最后,数组中的序列就是从大到小的降序序列了。
堆排序的代码示例:
// 对数组进行堆排序
// 降序用小堆/升序用大堆
void HeapSort(int* a, int n)// a为指向数组的指针,n为数组中元素个数
{
assert(a);
向上调整建堆
//for (int i = 1; i < n; i++)
//{
// HeapAdjustUp(a, i);
//}
//向下调整建堆-时间复杂度O(n)
for (int i = (n - 2) / 2; i >= 0; i--)
{
HeapAdjustDown(a, n, i);
}
//排序-时间复杂度O(nlogn)
int end = n - 1;// end是数组最后一个有效元素的下标,数组长度-1即是
while (end > 0)// 当交换结点为根结点时堆排结束
{
Swap(&a[0], &a[end]);// 交换根结点与剩余未排序元素中的最后一个元素
HeapAdjustDown(a, end, 0);// 将新的根结点向下调整恢复堆结构
end--;// 未排序元素减1
}
}
序列最终是升序还是降序取决于调整算法的实现细节,也就是比较条件,是小堆调整还是大堆调整。
3.3.3 TOP-K问题
TOP-K问题:求大量数据中的前K个最大的元素或者最小的元素。比如:专业前10名、世界500强、福布斯富豪榜、游戏中战力前100的玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但如果数据量非常大呢?当数据量大到超过内存容量,我们又如何排序呢?有人可能想到依据内存容量来分批解决,全球也不过是百亿人而已,分个十次百次不断划分下去总能解决,但这样不就显得很麻烦和低效了么?
我们其实有更好的办法。那就是用堆来解决,基本思路如下:
- 用数据集合中的第1到K个元素先建第一个堆
- 求前k个最大的元素,则建小堆(建小堆,那根就是堆中最小值,大于根就覆盖根)
- 求前k个最小的元素,则建大堆(建大堆,那根就是堆中最大值,小于根就覆盖根)
- 用剩余的N-K个元素依次与堆顶元素来比较,比堆顶元素更大或者更小则替换堆顶元素。
- N-K个元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
这样的好处是我们可以将数据先放在存储空间更大的硬盘上或者任何存储设备中,而空间较小的内存中只用存储堆中的k个数据即可,我们程序的空间复杂度可能直接就从百亿级降到了个数级。
例如:
我们先来创建一个文件存储一百万个随机数,然后在一百万个随机数中找出最大的10个数。
void CreateData(int n)// n=1000000
{
// 创建一个包含n个随机数的文件
srand((unsigned int)time(NULL));// 将随机数的种子设置为时间戳
const char* file = "data.txt";// 将文件名放入字符变量file中
FILE* fin = fopen(file, "w");// 创建文件指针fin用于向data.txt文件中写入数据
if (!fin)// 如果打开文件失败则报错
{
perror("CreateData::fopen fail!");
return;
}
for (int i = 0; i < n; i++)// 写入n个数据
{
int x = (rand() + i) % 10000;// 生成一万以内的随机自然数
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
需要注意的是获取时间戳的time
函数的头文件是time.h
,而控制随机数种子的srand
函数与生成随机数的rand
函数的头文件是stdlib.h
。同一个种子生成的随机数随机性是有限的,将种子设置为时间可以提高随机数的随机性。而rand() + i
的原因也是为了提高随机性。对随机数rand() + i
取模是为了使随机数的范围已知,例如模上一万,那么随机数的范围就是0~9999,以便于我们代码完成后对百万个随机数中前k个最值的结果的检查(自行向文件中写入k个大于9999的数即可)。
获取到存储了百万个数的文件后,我们就可以从文件中取数据求TOP-K了。
代码示例如下:
void PrintTopK(int k)//找出前k个最大值-建小堆
{
// 申请k个整型空间创建堆
int* arr = (int*)malloc(sizeof(int) * k);
if (!arr)
{
perror("PrintTopK::malloc fail!");
return;
}
// 获取文件中n个随机数中的前k个建立堆
const char* file = "data.txt";
FILE* fout = fopen(file, "r");// 创建fout文件指针用于从数据文件data.txt中读取数据
if (!fout)
{
perror("PrintTopK::fopen fail!");
return;
}
for (int i = 0; i < k; i++)// 先获取前k个建堆
{
fscanf(fout, "%d", &arr[i]);
}
// 找最大的k个值-建小堆
for (int i = (k - 2) / 2; i >= 0; i--)// 向下调整建堆
{
HeapAdjustDown(arr, k, i);
}
int x = 0;// x依次存储文件中剩余的N-K个数据
// 遍历比较所有随机数
while (fscanf(fout, "%d", &x) > 0)// 向文件中读取数据并放入x中,fscanf返回值为读取到的数据个数
{
if (x > arr[0])// 小堆根结点的值是极小值,大于根结点值就覆盖根节点的值
{
arr[0] = x;
HeapAdjustDown(arr, k, 0);// 向下调整恢复堆结构
}
}
//排序完成后,堆中放着前k个最大值或最小值
for (int i = 0; i < k; i++)// 打印前k个数
{
printf("%d\n", arr[i]);
}
fclose(fout);
}
树的第一部分到此结束。本篇我们学习了树,二叉树和堆的相关基础知识,也完成了堆的代码实现以及堆的一些应用。树的内容是很丰富的,下一篇文章将继续介绍树、二叉树与堆,内容包括二叉树的链式结构实现与相关OJ练习推荐,最后还会提供一系列选择题作为总结与查漏补缺。