一、树概念及结构
- 树的概念
树是一种非线性的数据结构,它是由n(n >= 0)个有限节点组成的一个具有层次关系的集合。把它叫做树是因为他看起来像是一颗倒挂起来的树,也就是说它是根朝上,而叶子朝下的。
-> 有一个特殊的节点,称为根节点,该节点没有前驱节点。
-> 除根节点外,其余节点被分成M(M > 0)个互不相交的集合T1, T2, . . . . . TM。其中每一个集合Ti(1 <= i <= M)又是一颗结构与树类似的子树。每颗子树的根节点有且只有一个前驱,可以有0个或多个后继。
-> 因此,树是用递归定义的。
PS: 树形结构中,子树之间不能有交集,否则就不是树形结构。
->子树是 不相交的;
-> 除了根节点外,每个节点 有且仅有一个父节点;
-> 一个 N个节点的树有 N - 1条边;
2. 树的相关概念
->节点的度:一个节点含有的子树的个数称为该节点的度;如上图:A的度为6。
->叶节点或终端节点:度为0的节点称为叶节点;如上图:B,C,H,I,K,L,M,N,P,Q都是叶节点。
->非终端节点或分支节点:度不为0的节点;如上图:D,E,F,G,J都是分支节点。
->双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点。
->孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点。
->兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点。
->树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6。
->节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推。
->树的高度或深度:树中节点的最大层次; 如上图:树的高度为4。
->堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点。
->节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先。
->子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙。
->森林:由m(m>0)棵互不相交的树的集合称为森林。
二、二叉树概念及结构
1.二叉树的概念
一颗二叉树是节点的一个有限集合,该集合:
->由一个根节点加上两颗别称为左子树和右子树的二叉树组成。
->或者为空。
从上图可以看出:
二叉树不存在度大于2的节点。
二叉树的子树有左右之分,次序不能颠倒,所以二叉树是一个有序树。
注意:对于任意的二叉树都是由以下几种情况复合而成的。
2.特殊的二叉树
(1) 满二叉树:
一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是2^k - 1 ,则它就是满二叉树。
(2) 完全二叉树:
完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。(通俗来讲,完全二叉树其实就是一颗高度为N的树,它的前N-1层都是满的,而第N层从左到右是连续的)。
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= 。
5. 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
(1). 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点。
(2). 若2i+1<n,左孩子序号:2i+1,2i+1>=n否则无左孩子。
(3). 若2i+2<n,右孩子序号:2i+2,2i+2>=n否则无右孩子。
三、二叉树的存储结构
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
顺序结构:
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
链式结构:
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。
四、二叉树的顺序结构及实现(Heap)
- 二叉树的顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
2. 堆的概念及结构
如果有一个关键码的集合K = { K0, K1, K2, …… , K(n-1) },把它的所有元素按照完全二叉树的顺序存储方式存储在一个一维数组中,并满足:K[i] <= K[2*i + 1] 且 K[i] <= K[2*i + 2] ( K[i] >= K[2*i + 1] 且 K[i] >= K[2*i + 2]) i = 0, 1, 2……,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或者大根堆,根节点最小的堆叫做最小堆或者小根堆。
堆的性质:
->堆中的某个节点的值总是不大于或不小于其父节点的值。
-> 堆总是一颗完全二叉树。
3. 堆(Heap)的实现
3.1 堆向下调整算法
现在我们给出一个数组,在逻辑上我们可以将它看成是一颗完全二叉树。我们通过从根节点开始的向下调整算法将它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆才能进行调整。
向下调整算法过程(以下为小堆):
用parent指向需要调整的节点,child指向其左孩子,如果child+1也在需要调整的范围内并且child+1指向的节点的值小于child指向节点的值,那么就让child++。目的是为了让child指向左右孩子中小的那一个节点。(如果是大堆,就让child指向左右孩子中大的那一个节点)
判断parent与child分别指向节点的值,如果parent所在节点的值小于child所在节点的值。那么我们就让parent与child两个节点的值进行交换。
交换后,让parent=child,child=2*parent+1。然后重复以上操作,直到将整棵树调整完。
//交换函数
void swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
//a为即将调整的数组(完全二叉树),n为最后一个数据的下标+1
//parent是第一个需要调整的节点的下标
void AdjustDown(HDataType* a, int n,int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
//如果右孩子小于左孩子
if (child + 1 < n && a[child] > a[child + 1])
{
child++;
}
//如果parent所在节点的值大于child所在节点的值,就将二者的值进行交换
if (a[parent] > a[child])
{
swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
//如果parent节点的值小于child所在节点的值,那么就结束循环,
//从parent节点开始所在子树调整完毕
else
{
break;
}
}
}
该算法时间复杂度:O() ->N为节点个数,即数组元素个数,向下调整次数最多为完全二叉树的高度。
3.2 堆的创建
前面我们讲到堆向下调整算法,但是这个算法使用的时候有一个前提,就是我们的左右子树必须都是一个堆。那么,对于正常的数组而言,要满足这个条件是非常困难的。所以我们就要创建堆,现在给出一个数组,我们想要将它调整成一个小堆。此时数组在逻辑结构上可以看成一棵完全二叉树,但并不是一个堆,接下来我们就对它进行调整。
代码实现:
//给出一个数组,将它建立成小堆
//n为数组中的元素个数
void CreatHeap(int* a, int n)
{
for(int i = (n - 1 - 1) / 2; i >= 0; --i)
{
//从倒数第一个非叶子节点开始,向前对每一个节点进行向下调整,
AdjustDown(a, n, i);
}
}
通过向下调整算法,我们就可以实现将一个数组建立成堆。
3.3 建堆时间复杂度分析
因为数组可以看作是一颗完全二叉树,而满二叉树是一颗特殊的完全二叉树,所以下面就用满二叉树来进行证明:
因此:建堆的时间复杂度为O(N)。
3.4 堆的插入
堆的插入是指在数组的尾部插入一个数,然后进行向上调整,将尾部数据调整到合适的位置使整个数组符合堆的性质:
向上调整算法:
//child为插入点的下标
void AdjustUp(HDataType* a, int child)
{
assert(child >= 0);
int parent = (child - 1) / 2;
//while(parent >= 0) 这种写法不好,是非正常结束循环
while (child > 0)
{
if (a[child] > a[parent])
{
swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
有了向上调整算法后,现在我们又多了一种建堆方法,向上调整算法,我们可以把给定数组当作一个从0个元素开始逐渐向堆中插入数据的堆,也就是我们可以从根节点的第一个孩子节点开始,然后向上调整,直至将整个数组调整成一个堆。
void HeapSort(HDataType* a, int n)
{
//向上调整建堆
//从第一个节点的左孩子开始,对每个节点都向上调整建堆
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}
}
向上调整算法时间复杂度分析:
所以:向上调整建堆算法时间复杂度为O(N*logN)
我们可以看出,向上调整算法在时间复杂度上相比于向下调整算法是比较差的。所以,一般在给定数组的情况下我们通常选择的是向下调整算法来进行建堆。
3.5 堆的删除
堆的删除一般是指将堆顶的元素删除,然后继续调整,使它仍然是一个堆。我们一般是将堆顶元素与最后一个元素进行交换,然后进行向下调整。(这里我们只提一下关于堆的删除的思想,下面我们将会用到这个思想)
4. 堆的应用
4.1 堆排序
给定一个数组,对它进行排序,我们应该怎样实现它的排序呢?这里就要用到我们的堆排序。堆排序的前提是我们的数组首先得是一个堆,然后我们才能进行排序。所以,在实现堆排序之前我们还要将数组中的元素调整成为一个堆。
堆排序分以下两个步骤:
建堆->升序排序建大堆,降序排序建小堆
利用堆删除思想来进行排序
这里利用降序排序来进行演示(由于前面我们的向下调整算法是实现的小堆,我们就演示降序)
重复以上操作,就可以对数据进行降序排序了,这里只演示了两趟,剩下的操作类似就不再演示了。
代码实现:
//交换函数
void swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
//a为即将调整的数组(完全二叉树),n为最后一个数据的下标+1
//parent是第一个需要调整的节点的下标
void AdjustDown(HDataType* a, int n,int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
//如果右孩子小于左孩子
if (child + 1 < n && a[child] > a[child + 1])
{
child++;
}
//如果parent所在节点的值大于child所在节点的值,就将二者的值进行交换
if (a[parent] > a[child])
{
swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
//如果parent节点的值小于child所在节点的值,那么就结束循环,
//从parent节点开始所在子树调整完毕
else
{
break;
}
}
}
void HeapSort(HDataType* a, int n)
{
//向下调整建大堆
//时间复杂度为O(N)
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
//将堆顶跟最后一个数据互换,再向下调整
int end = n - 1;
while (end)
{
swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
4.2 TopK问题
对于大量的数据而言,我们应该怎么通过O(N)的时间复杂度来将其中最大的几位数据找到呢?由于数据量太大,我们不能通过排序来进行求解。这里就可以用我们的TopK算法。通过O(N)的时间复杂度来进行求解:
利用数据中前k个元素进行建堆(求最大的k个就建立小堆,最小的k个就建立大堆)
将剩下的N-k个元素与堆顶元素进行比较,不满足就替换堆顶元素
所有数据比较完之后,堆中就剩下最大(最小)的k个数据了。
示例:
int main()
{
srand(time(NULL));
//开辟一块空间,存入数据
int* data = (int*)malloc(sizeof(int) * 100000);
if (data == NULL)
{
perror("malloc fail");
exit(-1);
}
for (int i = 0; i < 100000; i++)
{
data[i] = rand() % 100000;
}
//构造出最大的k个数据
data[2] = 100000 + 10;
data[100] = 100000 + 33;
data[10000] = 100000 + 88;
data[1923] = 100000 + 77;
data[13445] = 100000 + 10099;
//为了选出数据中最大(最小)的k个数据,先开辟一块空间用来建堆
//同时将前k个数据用来建堆
int k = 5;
int* topk = (int*)malloc(sizeof(int) * k);
if (topk == NULL)
{
perror("malloc fail");
exit(-1);
}
//先将数据中前k个建小堆
memcpy(topk, data, sizeof(int) * k);
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(topk, k, i);
}
//从数据的第k+1个数据开始遍历,如果遇到比堆顶数据大的数就将它赋值给堆顶的数据
//然后从堆顶开始向下调整,使他继续保持是一个堆
for (int i = k; i < 100000; i++)
{
if (topk[0] < data[i])
{
topk[0] = data[i];
AdjustDown(topk, k, 0);
}
}
//打印出堆中的数据,观察是否是我们在之前造出来的最大的那几个数据
for (int i = 0; i < k; ++i) printf("%d ", topk[i]);
printf("\n");
return 0;
}
运行结果:
与我们构造的最大的k个数据相同。这里只是简单的通过少量数据进行试验,当然我们在实际情况中遇到的数据量会更大。但是,利用堆的思想来做可以让我们快速找出topK。