主要内容:
树是一种非线性的数据结构,它是由n(n>=0)个节点组成的具有层次关系的结构。它的层次关系看起来像一颗倒着的树,因此将它叫做树。一颗树 = 根节点 + n颗子树(n>=0),而子树又可以按上述定义,因此树这种结构是递归定义的。
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法 等。我们这里就简单的了解其中最常用的孩子兄弟表示法。
typedef int DataType;
struct Node
{
struct Node* child; // 第一个孩子结点
struct Node* brother; // 指向其下一个兄弟结点
DataType data; // 结点中的数据域
};
如下图:左边的为真正的逻辑结构,右边的为孩子兄弟表示后的逻辑结构。
如:Linux的目录结构就是一颗树,Windows的目录结构是森林,森林中树的表示为孩子兄弟表示法。
二叉树就是度最大是2的树。二叉树可以看作为:根+左子树+右子树。而左子树也可看作根+左子树+右子树
对于任何一颗二叉树都是由以下几种情况组合而成:
1.满二叉树:
一个二叉树,如果每一层次的节点数都达到最大值,那么该二叉树就是满二叉树。
下面这颗树的高度为3,第一层有20个节点,第二层有21个节点,第三层有2^2个节点
最后一层的节点数大于前面k-1层的节点总数
假设一颗满二叉树的高度为k,则第k层有2^(k-1)个节点
总共的节点数:N = 2^0 + 2^1 + 2^2 + … + 2^(k-1) = 2^k - 1
2.完全二叉树
完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。简言之:前k-1层是满二叉树,第k层节点从左向右依次排列。
对于高度为k的完全二叉树,其节点数量的范围:【2^(k-1), 2^k - 1】
证明:
前k-1层为满二叉树,所以前k-1层的节点总数:N = 2^(k-1)-1,而第k层的节点数至少为1,
所以下限=2^(k-1)-1+1 = 2^(k-1); 当第k层的节点数满时,上限:2^k - 1;
证明:假设满二叉树的高度为h,则2h-1 = n => 2h = n + 1 两边同时取对数: h = log2(n+1)
顺序存储就是使用数组来存储二叉树,一般只有完全二叉树才会使用顺序存储,因为非完全二叉树会存在空间浪费的情况。而现实中只有堆才会使用数组来存储。逻辑结构是树形结构,物理结构是顺序存储
完全二叉树的节点是从上到下,从左向右依次存储的,所以可以依次存放到数组中。
非完全二叉树中节点不是依次存储的,需要给空节点留位置,因此会造成空间的浪费。
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链。
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* left; // 指向当前节点左孩子
struct BinTreeNode* right; // 指向当前节点右孩子
BTDataType data; // 当前节点值域
}
// 三叉链
struct BinaryTreeNode
{
struct BinTreeNode* parent; // 指向当前节点的双亲
struct BinTreeNode* left; // 指向当前节点左孩子
struct BinTreeNode* right; // 指向当前节点右孩子
BTDataType data; // 当前节点值域
};
如果有一个关键码的集合K = { k1,k2 ,k3 ,…,},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: ki<=k2i+1 且 ki<=k2i+2 ( ki>=k2i+1 且ki >=k2i+2 ) ,i = 0,1, 2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
typedef int HPDataType;
typedef struct heap
{
HPDataType* data;
int size;
int capacity;
}heap;
//建立
void HeapCreate(heap* php, HPDataType* nums, int numsLen);
//初始化
void HeapInit(heap* php);
//销毁
void HeapDestroy(heap* php);
//入堆
void HeapPush(heap* php, HPDataType x);
//删除堆顶
void HeapPop(heap* php);
//获得堆顶数据
HPDataType HeapTop(heap* php);
//求堆中数据量
int HeapSize(heap* php);
//判断是否为空
bool HeapEmpty(heap* php);
//向下调整算法
void AdjustDown(HPDataType* data, int n, int parent);
//向上调整算法
void AdjustUp(HPDataType* data, int child);
//交换函数
void Swap(HPDataType* p1, HPDataType* p2);
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。
下面先介绍两种建堆算法:1.向上调整算法 2.向下调整算法
:::tips
向上调整算法:将数据插入一个堆中,该数据与父节点的数据比较,若该数据大于父节点则交换,反之不交换,向上调整直至该数组变为堆。
:::
图示:使用向上调整算法的前提就是插入数据前数组已经是一个堆
代码实现:
void AdjustUp(HPDataType* data, int child)
{
assert(data != NULL);
int parent = (child - 1) / 2;
while (child > 0)
{
if (data[child] > data[parent])
{
Swap(data + child, data + parent);
child = parent;
parent = (child - 2) / 2;
}
else
{
break;
}
}
}
向下调整算法:找出子节点中最大的那个节点,然后与根节点比较,如果大于根节点则进行交换,然后按照上述方法向下调整,直到到达数组尾部。
// n为data数组的长度,parent为要调整的双亲节点
void AdjustDown(HPDataType* data, int n, int parent)
{
assert(data != NULL);
//假设左孩子最大
int child = parent * 2 + 1;
while (child < n)
{
//验证是否左孩子最大,注意没有右孩子的情况
if (child + 1 < n && data[child + 1] > data[child])
{
child++;
}
//如果孩子大于双亲,则交换,继续向下调整
if (data[child] > data[parent])
{
Swap(data + child, data + parent);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
铺垫完成,正式开始建堆
已知下面的数组,由此建堆。
int array[] = {27,15,19,18,28,34,65,49,25,37};
我们可以用向上调整算法建堆,也可以用向下调整算法建堆,就这两种方法来看使用向下调整算法建堆更好,具体为什么可以看两种时间复杂度的推导。
使用向上调整算法建堆:
由于向上调整算法的前提条件是数组已经是堆,因此我们从数组中第一个数开始插入,因为空树也可认为是堆,每次插入后数组还是堆,重复上述步骤,直到数组中的数据全部插入,至此堆建立完成。
//php为指向堆结构体的指针,nums为数组,numsLen为数组长度
void HeapCreate(heap* php, HPDataType* nums, int numsLen)
{
assert(php != NULL);
php->data = (HPDataType*)malloc(sizeof(HPDataType) * numsLen);
if (php->data == NULL)
{
perror("Create");
exit(-1);
}
php->size = numsLen;
php->capacity = numsLen;
memcpy(php->data, nums, numsLen * sizeof(HPDataType));
//1.使用向上调整建堆
int i = 0;
for (; i < numsLen; i++)
{
AdjustUp(php->data, i);
}
}
该方法建堆的时间复杂度推导:
假设所建的堆为一颗高度为h的满二叉树,节点数为:n
那么第1层有20 个节点,需要向上调整0次
第2层有21个节点,调整1次
第3层有22个节点, 调整2次
…
第h层有2h-1个节点, 调整h-1次
则所有节点总共调整次数:F(h) = 211 + 222 +…+2h-1*(h-1), 由此可以看出该式子是等差等比形式,所以我们可以用错位相减法来求和
2F(h) = 2^2 * 1 + 2^3 * 2 + … + 2^(h-1) * (h-2)+ 2^h * (h-1) —(1)
F(h) = 2^1 * 1 + 2^2 * 2 +…+2^(h-1) * (h-1) —(2)
用(1)-(2)式:可得: F(h) = - 2^1 * 1 - (2^2 +2^3+ …+2^(h-1)) + 2^h * (h-1)
= -(2^1+ 2^2 +2^3+. … + 2^(h-1)+ 2^h) + 2^h * h
= 2 - 2^(h+1) + 2^h * h
又因为 h = log2(n+1)
所以: F(h) = 2 - (n+1) * 2+(n+1) * log2(n+1) = (n+1)log2(n+1) - 2 * n
所以:O(n) = nlog2n
当然还有一种简单算法:时间复杂度取影响最大的那一项,那么F(h)= 2^1 * 1 + 2^2 * 2 +…+2^(h-1) * (h-1)中,毫无疑问
,2h-1*(h-1) 比前面h-1项加起来还大:因为第h层的节点数大于前面h-1层的节点总数,所以计算时间复杂度时只看最后一项也是可以的,
将h = log2(n+1)代入2^h-1 * (h-1)中:也可以得到O(n) = n*log2n
使用向下调整算法建堆:
由于向下调整算法的前提条件是左右子树都是堆,那么我们可以从最后一个元素开始调整,因为最后一个元素没有子树,空树可以看作是堆, 但是叶子节点本来就是堆,所以没必要调整了,所以我们改为从最后一个分支节点开始调整,这时也满足左右子树均为堆
void HeapCreate(heap* php, HPDataType* nums, int numsLen)
{
assert(php != NULL);
php->data = (HPDataType*)malloc(sizeof(HPDataType) * numsLen);
if (php->data == NULL)
{
perror("Create");
exit(-1);
}
php->size = numsLen;
php->capacity = numsLen;
memcpy(php->data, nums, numsLen * sizeof(HPDataType));
//2.使用向下调整建堆
int i = 0;
//最后一个节点的下标为:numsLen-1, 则其双亲节点的下标:(numsLen-1-1)/2
for (i = (numsLen - 2) / 2; i >= 0; i--)
{
AdjustDown(php->data, php->size, i);
}
}
该算法的时间复杂度推导:
假设所建的堆为一颗高度为h的满二叉树,节点数为:n
那么第1层有20 个节点,需要向调下整h-1次
第2层有21个节点,调整h-2次
第3层有22个节点, 调整h-3次
…
第h-1层有2h-2个节点, 调整1次
第h层有2h-1个节点, 调整0次
则所有节点总共调整次数:F(h) = 20*(h-1) + 21*(h-2) +…+2h-21+2h-10, 由此可以看出该式子是等差等比形式,所以我们可以用错位相减法来求和
F(h) = 2^0 * (h-1) + 2^1 * (h-2) +…+2^(h-2) * 1 —(1)
2F(h) = 2^1 * (h-1)+2^2 * (h-2) + … + 2^(h-1) —(2)
用(2)-(1) 可得: F(h) = -2^0 * (h-1) + 2^ 1 + 2 ^2 + … + 2^(h-2)+ 2^(h-1)
= 2^0 + 2^1 + 2^2 + … + 2^(h-2) +2^ (h-1) - h
= 2^(h- 1) - h
又 h = log2(n+1)
所以可得F(n) = n+1-1-log2(n+1) = n - log2(n+1)
故O(n) = n
总结:由于向上调整算法建堆的时间复杂度为n*log2n ,而向下调整算法建堆的时间复杂度为n
所以我们选择用向下调整算法建堆, (简单理解,使用向下调整算法的时候节点多调整少,向上则是节点多调整多)
在插入前,数组已经是堆,故可以使用向上调整算法,调整次数为高度次,即:h = log2(n+1)
图示:
代码实现:
void HeapPush(heap* php, HPDataType x)
{
assert(php != NULL);
//检查是否需要扩容
if (php->size == php->capacity)
{
int newCapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
HPDataType* temp = (HPDataType*)realloc(php->data, sizeof(int) * newCapacity);
if (temp == NULL)
{
perror("push::");
exit(-1);
}
php->data = temp;
php->capacity = newCapacity;
}
//先插入
php->data[php->size++] = x;
//然后调整,size指向的是末尾元素的下一个
AdjustUp(php->data, php->size-1);
}
堆的删除
删除堆是删除堆顶的数据,将堆顶的数据根最后一个数据一换,然后删除数组最后一个数据,这时,左右子树均为堆,因此可以使用向下调整算法。
void HeapPop(heap* php)
{
assert(php != NULL);
Swap(php->data, php->data + php->size - 1);
php->size--;
AdjustDown(php->data, php->size, 0);
}
5.求堆的长度以及判断是否为空
int HeapSize(heap* php)
{
assert(php != NULL);
return php->size;
}
bool HeapEmpty(heap* php)
{
assert(php != NULL);
return php->size == 0;
}
堆排序分为两个步骤: 1.建堆(升序建大堆,降序建小堆), 2.利用堆删除来达到有序的目的
求top-k问题,求数据集合中前k个最大或者最小的数据,一般数据量较大.
(1)如果数据量较小,可以全部放入内存中,则将这些数据进行建堆, 然后利用堆删除的思想,选出k个数即可
时间复杂度:n+klog2(n+1) (其中n为建堆的时间,每次选一个数,要调整高度次,选k个调整k高度)
空间复杂度:1
(2)问题: 数据量:10亿个整数,需要4G的内存空间,要求在其中选出最大的k个数
数据量较大,不能全部放入内存中,则将前k个数在内存中建立一个小堆,然后遍历剩下的数据,如果比堆顶的数据大,则将该数放入堆顶,然后进行向下调整,直到文件中的数据全部遍历完成,此时内存中的k个数的小堆,存放的就是文件中的最大的k个数
时间复杂度:k + (n-k)log2(k+1)
空间复杂度:k, 因为要在内存中建立k个数的小堆