树是一种非线性的数据结构,它是由 n ( n > = 0 ) n(n>=0) n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
它具有以下的特点:

注意:
成环的树是图,是另一种数据结构

树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既要保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法。
typedef int DataType;
struct Node
{
struct Node* _firstChild1; // 第一个孩子结点
struct Node* _pNextBrother; // 指向其下一个兄弟结点
DataType _data; // 结点中的数据域
};

文件系统的目录等就是树的一种应用:

二叉树(Binary tree)是树形结构的一个重要类型。许多实际问题抽象出来的数据结构往往是二叉树形式,即使是一般的树也能简单地转换为二叉树,而且二叉树的存储结构及其算法都较为简单,因此二叉树显得特别重要。二叉树特点是每个节点最多只能有两棵子树,且有左右之分 。
二叉树是递归定义的,其节点有左右子树之分,逻辑上二叉树有五种基本形态:

完全二叉树的节点数量:
高度为h的完全二叉树的节点个数在 [ 2 h − 1 , 2 h − 1 ] [2^{h - 1}, 2^h - 1] [2h−1,2h−1]
完全二叉树的特点是叶子节点只可能出现在层序最大的两层上,并且某个节点的左分支下子孙的最大层序与右分支下子孙的最大层序相等或大1 。

性质1: 二叉树的第 i i i 层上至多有 2 i − 1 ( i ≥ 1 ) 2^i-1(i≥1) 2i−1(i≥1)个节点 。
性质2: 深度为 h h h 的二叉树中至多含有 2 h − 1 2^h-1 2h−1 个节点 。
性质3: 若在任意一棵二叉树中,有 n 0 n_0 n0个叶子节点,有 n 2 n_2 n2 个度为 2 2 2的节点,则必有 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1 。
性质4: 具有 n n n 个节点的满二叉树深为 l o g 2 ( n + 1 ) log_2^{(n+1)} log2(n+1) 。
性质5: 若对一棵有 n n n 个节点的完全二叉树进行顺序编号 ( 1 ≤ i ≤ n ) (1≤i≤n) (1≤i≤n) ,那么,对于编号为 i ( i ≥ 1 ) i(i≥1) i(i≥1)的节点:
- 若 i > 0 i>0 i>0 , i i i 位置节点的双亲序号: ( i − 1 ) / 2 ; i = 0 , i (i-1)/2;i=0,i (i−1)/2;i=0,i 为根节点编号,无双亲节点
- 若 2 i + 1 < n 2i+1
2i+1<n ,左孩子序号: 2 i + 1 , 2 i + 1 > = n 2i+1,2i+1>=n 2i+1,2i+1>=n 否则无左孩子- 若 2 i + 2 < n 2i+2
2i+2<n ,右孩子序号: 2 i + 2 , 2 i + 2 > = n 2i+2,2i+2>=n 2i+2,2i+2>=n 否则无右孩子

二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
顺序结构存储就是利用性质五使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。后面会用到的堆就是使用这种形式存储的。


二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。
二叉链: 链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该节点左孩子和右孩子所在的链节点的存储地址

// 二叉链
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
}
三叉链: 在二叉链的基础上增加父节点的存储地址

// 三叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pParent; // 指向当前节点的双亲
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
};
堆(heap)是计算机科学中一类特殊的数据结构的统称。堆通常是一个可以被看做一棵树的数组对象。堆总是满足下列性质:
- 堆中某个结点的值总是不大于或不小于其父结点的值;
- 堆总是一棵完全二叉树。
通常我们使用顺序结构的数组来存储。
将根结点最大的堆叫做最大堆或大根堆:

根结点最小的堆叫做最小堆或小根堆:

向下调整算法是在堆的前提下维护堆的特性
算法思想:
选取子节点中较大或者较小的一个(大跟堆选较大,小跟堆选较小),如果大于或者小于当前节点即交换父子节点的值(大跟堆取大于,小跟堆取小于),依次迭代,直到子节点超过了堆的大小。

// 向下调整算法
void AdjustDown(HeapDataType* data, size_t parent, size_t size)
{
assert(data);
size_t child = parent * 2 + 1;
while (child < size)
{
//if (child + 1 < size && data[child + 1] < data[child]) // 小堆
if (child + 1 < size && data[child + 1] > data[child]) // 大堆
child++;
//if (data[child] < data[parent]) // 小堆
if (data[child] > data[parent]) // 大堆
{
Swap(&data[child], &data[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
在堆的基础上维护堆,向堆尾插入节点时,需要向上维护堆。
算法思想:
如果当前节点是否大于或小于父节点(大跟堆取大于,小跟堆取小于),交换父子节点,依次迭代,直到子节点成为根节点

// 向上调整算法
void AdjustUp(HeapDataType* data, size_t child)
{
assert(data);
size_t parent = (child - 1) / 2;
while (child > 0)
{
if (data[child] > data[parent]) // 大堆
//if (data[child] < data[parent]) // 小堆
{
Swap(&data[child], &data[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
在堆的基础上进行插入,插入后使用向上调整算法维护堆。
// 堆的插入
void HeapPush(Heap* heap, HeapDataType x)
{
assert(heap);
// 扩容检查
CheckCapacity(heap);
heap->data[heap->size] = x;
AdjustUp(heap->data, heap->size);
heap->size++;
}

将堆顶堆底元素进行交换,删除最后一个元素,并对堆顶进行向下调整,维护堆的性质:

// 堆的删除
void HeapPop(Heap* heap)
{
assert(heap);
assert(heap->size);
heap->size--;
Swap(&heap->data[heap->size], &heap->data[0]);
AdjustDown(heap->data, 0, heap->size);
}
当然可以从零开始构建一个堆,这样就是正常的HeapPush 和 HeapPop,没什么好说的。要说的是在原数组的基础上进行构建。
当给出一个数组,,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。根节点左右子树不是堆,我们怎么调整呢?这里我们从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,就可以调整成堆。

// 构建堆
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
AdjustDown(data, i, size - 1);
建堆的时间复杂度需要计算每个细节,看似是 O ( n l o g n ) O(nlog^n) O(nlogn) 但其实是 O ( N ) O(N) O(N)
需要操作多少个节点,每个节点需要进行多少次操作:

堆排序就是堆的基础上实现的,分为一下几个步骤:
堆排序使用的是 HeapPop 操作,选取一个最大或者最小的数与尾部,操作
n
n
n 次。
// 堆排序
void HeapSort(HeapDataType* data, int size)
{
assert(data);
// 建堆
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
AdjustDown(data, i, size - 1);
// 排序
while (size > 0)
{
Swap(&data[0], &data[size - 1]);
size--;
AdjustDown(data, 0, size);
}
}
需要注意的是:升序建大堆,降序建小堆
TOP-K问题: 即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
一般想到的是排序,排序的时间复杂度是: O ( n l o g n ) O(nlog^n) O(nlogn)
如果数据量是100亿个整数:
1GB = 1024MB
1024MB = 1024 * 1024KB
1024 * 1024KB = 1024 * 1024 * 1024byte ≈ 10亿
100亿个整数 = 400亿字节 = 40GB内存
很显然不可能有这么大的内存,那么最佳的方式就是用堆来解决,堆的优势就展现出来了,我们只需要在内存中存放前 K K K 个数即可:
时间复杂度: O ( K + ( N − k ) l o g K ) O(K + (N - k)log^K) O(K+(N−k)logK)
// TopK问题
void TopK(HeapDataType* data, int n, int k)
{
assert(data);
HeapDataType* a = (HeapDataType*)malloc(sizeof(HeapDataType) * k);
assert(a);
// 建堆
for (int i = 0; i < k; i++) a[i] = data[i];
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
AdjustDown(a, i, k);
// TopK
for (int i = k; i < n; i++)
{
if (data[i] >= a[0])
{
a[0] = data[i];
AdjustDown(a, 0, k);
}
}
for (int i = 0; i < k; i++) printf("%d ", a[i]);
}