前面学习过的顺序表链表等都是线性表(结构),逻辑结构上数据是诶个存储的,呈现出一条线性。而今天要了解的树就不再是一种线性结构了。
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树(正常的树是根在下,叶在上),也就是说它是根朝上,而叶朝下的。
有一个特殊的结点(最开始的结点),称为根结点,根节点没有前驱结点
除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱(父节点),可以有0个或多个后继(子结点)
树是递归定义的。
在上图结构中对应的父子关系为:结点A为父结点,BCD则为子结点,而子结点也可能同时为父节点,相应的,B为EF的父节点,EF为B的子结点…
任何一个结点都有0~N个子结点。
注意:树形结构中,子树之间不能有交集,否则就不是树形结构
数据结构中树的概念本质是:树的植物本身 + 人类亲缘关系的概念的结合。
注:加粗为重要概念
节点的度:一个节点含有的子树(孩子)的个数称为该节点的度; 如上图: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)棵互不相交的树的集合称为森林;
树结构相对线性表就比较复杂了,要存储表示起来也比较麻烦,既然保存值域,也要保存结点和结点之间的关系。
这里就简单的了解其中最常用的孩子兄弟表示法。
typedef int DataType;
struct Node
{
struct Node* _firstChild1; // 左边第一个孩子结点
struct Node* _pNextBrother; // 指向其下一个亲兄弟结点
DataType _data; // 结点中的数据域
};
父亲结点永远指向左边第一个孩子结点,兄弟之间互相用兄弟指针链接起来。
一棵二叉树是结点的一个有限集合,该集合:
从上图可以看出:
本文只是简单引出概念,后面的文章会着重介绍二叉树。
现实中通常把堆(一种二叉树)使用顺序结构的数组来存储,此堆为数据结构中的堆,而非操作系统中对内存区域划分中的堆。
如果有一个关键码的集合K = { K 0 K_0 K0 , K 1 K_1 K1 , K 2 K_2 K2 ,…, K n − 1 K_{n-1} Kn−1 },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: K i K_i Ki <= K 2 ∗ i + 1 K_{2*i+1} K2∗i+1且 K i K_i Ki <= K 2 ∗ i + 2 K_{2*i+2} K2∗i+2 ( K i K_i Ki >= K 2 ∗ i + 1 K_{2*i+1} K2∗i+1。且 K i K_i Ki = K 2 ∗ i + 2 K_{2*i+2} K2∗i+2) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:
小根堆特点:任何一个(父)结点的值 <= 孩子的值
相反地,大根堆特点:任何一个(父)结点的值 >= 孩子的值
因为堆是完全二叉树,因此用数组把它每一层的结点的值依次存储到数组中,类似于数组实现顺序表。
这里出现了一个问题,由于数组是用下标存储和访问的,那么每个结点的父子关系是怎样的?
不妨探究一下父子结点在数组中对应下标的位置关系:
根据上图的位置关系,有大佬就总结出了这么一条计算左右孩子结点下标位置的公式:
相应地,根据孩子结点下标来算出父节点位置下标:
左孩子下标都是奇数,右孩子都是偶数,根据除法性质,可以不用考虑左右孩子,一律使用 (孩子结点下标 - 1) / 2,即可算出目标位置。
实现堆之前,先要想清楚,想象的逻辑结构是二叉树,而实际操纵的是数组,因此要利用下标,跳跃着寻找它的左右孩子结点,跳出数组大小就意味着到空了。
堆的最直观的特点是可以快速找出最大或者最小的元素,堆允许增删查改,修改后依然要让其保持为大堆/小堆。
下面来实现小堆结构(大堆结构只不过是与小堆相反的)。
typedef int DataType;
//后面更改数据类型只需要该int
typedef struct Heap
{
DataType* heap;
int size;
int capacity;
}Heap;
//赋值为初始状态
void InitHeap(Heap* php)
{
assert(php);
php->heap = NULL;
php->size = php->capacity = 0;
}
插入数据为整个堆的实现中最难的步骤,因为插入数据后还要保持其小堆结构,如果不符合堆的顺序就要使用向上调整算法,这里是向上调整的是它的祖先结点。
如上图,如果插入的数据是10,不符合小堆的顺序(父结点的值 <= 孩子结点的值),因此需要向上与它的祖先结点进行调整交换,直到符合小堆的顺序。
根据上面通过孩子下标计算父亲结点下标的公式,即可算出对应的祖先结点。
void Swap(DataType* ele1, DataType* ele2)
{
DataType tmp = *ele1;
*ele1 = *ele2;
*ele2 = tmp;
}
//向上调整
void AdjustUp(DataType* heap, int childIndx)
{
int fatherIndex = (childIndx - 1) / 2;
//结束条件有二:
//1.孩子结点下标到0了,说明它到了堆顶无需交换了
//2.当孩子比父亲大也停止
while (childIndx > 0 && heap[childIndx] < heap[fatherIndex])
{
//迭代交换
Swap(&heap[childIndx], &heap[fatherIndex]);
childIndx = fatherIndex;
fatherIndex = (childIndx - 1) / 2;
}
}
//插入data到堆中
void PushHeap(Heap* php, DataType data)
{
assert(php);
//扩容
if (php->heap == php->capacity)
{
php->capacity = php->capacity == 0 ? 4 : php->capacity * 2;
DataType* tmp = (DataType*)realloc(php->heap, sizeof(DataType) * php->capacity);
if (!tmp)
{
perror("realloc fail");
exit(-1);
}
php->heap = tmp;
}
php->heap[php->size] = data;
php->size++;
//插入数据后向上调整
AdjustUp(php->heap, php->size - 1);
}
该操作的实际意义是:找次大或者次小的数据。
问题是如何删除?如果采用顺序表的从前向后挪动覆盖删除,会有两个弊端:
这时就有人想出了一种方法:把堆顶的元素和最后一个元素交换后删除,因为堆的底层是数组实现的,数组的尾删效率非常高。交换后一定不满足小堆的顺序了,因此这里需要使用向下调整算法。
具体地,向下寻找孩子结点,因为左右子树都是小堆,因此找出两个孩子中值较小的进行交换,直到满足小堆顺序。
向下调整与向上调整地时间复杂度均为O(logN),因为堆是完全二叉树,如果树的高度是k层,它的结点范围在[2k-1,2k - 1],那么[k = l o g 2 N + 1 log_2N + 1 log2N+1,k = l o g 2 ( N + 1 ) log_2(N + 1) log2(N+1)],把对结果影响不大的项数都去掉后,复杂度即为O(logN)。
//向下调整
void AdjustDown(DataType* heap, int fatherIndex, int heapSize)
{
int minchild = fatherIndex * 2 + 1;
//孩子下标得小于堆的最大下标才有的玩
while (minchild < heapSize)
{
//假设两个孩子中小的一个为左孩子
//再判断右孩子是否大于左孩子
if (minchild + 1 < heapSize && heap[minchild] > heap[minchild + 1])
{
++minchild;
}
//父亲大于孩子就调整
if (heap[minchild] < heap[fatherIndex])
{
Swap(&heap[minchild], &heap[fatherIndex]);
fatherIndex = minchild;
minchild = fatherIndex * 2 + 1;
}
else
{
break;
}
}
}
//删除堆顶元素
void PopHeap(Heap* php)
{
assert(php);
assert(!isHeapEmpty(php));
//交换后删除
Swap(&php->heap[0], &php->heap[php->size - 1]);
php->size--;
AdjustDown(php->heap, 0, php->size);
}
//堆不能为空才有数据可以返回
DataType HeapTop(Heap* php)
{
assert(php);
assert(!isHeapEmpty(php));
return php->heap[0];
}
//size = 0说明堆为空
bool isHeapEmpty(Heap* php)
{
assert(php);
return php->size == 0;
}
//简单的不提
int elementsNumofHeap(Heap* php)
{
assert(php);
return php->size;
}
//没啥好说的
void PirintHeap(Heap* php)
{
assert(php);
for (int i = 0; i < php->size; ++i)
{
printf("%d ", php->heap[i]);
}
printf("\n");
}
//注意释放动态开辟的数组
//并把对应的成员置成初始状态
void DestroyHeap(Heap* php)
{
assert(php);
free(php->heap);
php->heap = NULL;
php->size = php->capacity = 0;
}
以上就是堆的实现代码,在了解了堆的向下调整算法后,下一篇文章会介绍堆的实际应用:堆排序和TopK问题。
本篇完