前一篇文章,我们讲到了stake(栈)和queue(队列)两个数据结构里的重要概念(C++初步也只需要学到这个程度即可),现在,我们即将步入数据结构里最最重要的启蒙级概念——树(tree)。
一、什么是树?
这便是一棵简陋的"树"了。
概念上来说,树是一种非线性结构,以有限层数构成有层次关系的集合。
实际操作上来说,由于树是由链表作为底层原理实现的,不访问上一层无法访问到下一层,而这便是“层次关系”之说。
注意:树不能有交集。
上面第一行的三个全都不是树。
树的相关概念
结点的度:一个结点含有的子树数量(实际只需要看分叉几根就可以了,比如A是3)
叶子结点:度为0的结点。
分支结点:度不为0的结点。
父结点:子树的根结点的上一结点。
子结点:某一结点的子树的根节点。
兄弟结点:具有相同父结点。
树的度:整棵树结点的度的最大值。
结点的层次:将树横向分开,可以有第一层,第二层,第三层,这便是层次。
树的表示方法
左孩子右兄弟表示法。
typedef int Datatype;
struct TreeNode{
Datatype data;
struct TreeNode*child;
struct TreeNode*brother;
}
此处的brother指向下一个兄弟结点。child指向其中一个孩子结点。
这种表示法很好地避免了度为多个的情况,如果有多个孩子的情况下,用相同父结点的兄弟结点来访问,有多层时,用父结点来向下访问。有效规避了结构体定义多个变量而造成不必要的重复。
这样便可以表示一个任意度的树了。
二、二叉树
实际上我们研究的肯定不是一条线往下的,因为那就是链表,多做阐述没有意义。
二叉树,是我们研究树的开始。
二叉树,顾名思义,每个结点最多有两个子结点。用术语来讲,就是度为二的树。
(上图演示的是度为三的树)
二叉树的表示方法
在二叉树里,我们并不需要用左孩子右兄弟表示法,因为度为二,所以只需要left和right表示左右子结点即可(NULL表示没有该结点)。
typedef int Datatype;
struct BinaryTreeNode{
Datatype data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}
(C++的话结构体里面不用再额外写struct)
相当于由一个根结点,即可访问到树的任一结点。
特殊的二叉树
满二叉树:每一层结点均为该层最大值(2^(k-1),k为该层的层数),其结点一定为2^h-1个(h为二叉树的层数)。
完全二叉树:设二叉树层数为h,则前h-1层为满二叉树,第h层的结点数量在[0,2^(h-1)]范围内。
满二叉树一定是完全二叉树,而完全二叉树不一定是满二叉树。
(这可能就是程序员眼里的树吧)
二叉树的性质
不难从上述表述得知,如果将根结点的层数设为1,那么第k层的最大结点数为2^(k-1)个,最多结点数是2*h-1(h为层数)
除此之外呢,还有一条极为重要的性质.
N0=N2+1。
其含义是任意二叉树,度为0的结点是度为2的结点数量加一。
证明:
所有结点数N=N0+N1+N2。
从连接结点的边来看,由于结点被访问的唯一性,故N个结点,有N-1条边。
N1产生1条边,N2产生2条边,N0不产生边。
那么就有N-1=N1+2*N2。
两式相减得到1=N0-N2,即N0=N2+1。
二叉树的结构
和栈与队列一样,二叉树同样可以用数组和链表表示。
但是,完全二叉树由于其连续性(层序遍历的方式),可以用数组表示,非完全二叉树则完全不行,不能用数组表示出究竟是左孩子还是右孩子(在数组里储存空格,不方便访问)。
因而用链表的形式便可以都表示出来,图如表示方式所示。
三、堆
堆(heap)在这里是一种数据结构,形式是完全二叉树。
没错,大名鼎鼎的堆排序就出自这里!
(swap函数就是交换两个值)
短短数行代码就实现了对一个数组的排序,且时间复杂度为O(nlogn),效率实在太高。
框框抛出一个重磅炸弹,还得知道炸弹是怎么制造出来的。
什么是堆?
如上述所言,堆的本质是完全二叉树,但与普通的完全二叉树不同的是,堆分为大堆和小堆,大堆的含义是父结点大于等于子结点,小堆的含义则是父结点小于等于子结点。
如图为小堆
实际上的储存结构
堆的实现
那么怎么实现堆呢?
虽然堆实际上是用数组实现的,但我们仍需要从二叉树的想法来思考(因为二叉树的遍历是递归遍历,在后面会讲)
打乱一下刚刚那个堆的顺序。
现在这个堆既不是大堆也不是小堆,实际上称为二叉树更为合理。
我们要排序,所以得是一个大堆\小堆(否则就是毫无逻辑关系的一堆数据,没有意义)
向下调整与向上调整
胡乱的调整不是我们想要的,对于计算机我们应该始终保持一种方法,因而产生了向下调整和向上调整两种办法。
这两种办法本质没有区别,都是通过交换父结点和子结点使得堆变成大堆\小堆,只是调整的方向一个是自上而下,一个是自下而上。
void AdjustDown(HPDataType* a, int n, int parent) {
int child = parent * 2 + 1;
while (child<n-1)
{
if (a[child + 1] < a[child]) {
child++;
}
if (a[child] < a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
break;
}
}
void AdjustUp(HPDataType* a, int child) {
int parent = (child - 1) / 2;
while (child>0) {
if (a[child] > a[parent]) {
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
乍看之下,两个办法没有区别,毕竟都是反复交换得到的。
但按照代码其实可以知道,降序建的小堆,而升序建的大堆(这个实际上取决于你的代码中间的判断语句)
实际上,无论怎么写代码,向下调整都更具有优势。
从最坏情况下来看,设层数为h。
向下调整:第1层需要调整h-1次,第k层需要调整h-k次。
向上调整:第1层需要调整0次,第k层需要调整k次。
那么相乘后加起来,便可知道两种情况分别是排序不等式的两个极端(向下调整为倒序和最小,向上调整为顺序和最大)
故推荐使用向下调整作为建堆的手段。
堆排序
有了向下调整,我们就可以开始堆排了。
void HeapSort(HPDataType* a, int n) {
for (int i = (n-2)/2; i>0; i--) {
AdjustDown(a, n,i);
}//建堆
while (n-1 > 0) {
Swap(&a[0], &a[n-1]);
AdjustDown(a, n-1, 0);
n--;
}
}
整段代码需要讲解的点有两个。
一、adjustdown为什么是从叶子结点上一层开始排的?
原因在于如果始终由根结点开始,不知道要经过多少次才能排到根是符合要求的,所以从子树看起,如果子树都排好了,那么根结点也是易排的。
二、swap的含义(此处的排序为由大到小,如果需要由小到大,建大堆后重复操作即可)
由于之前说过,adjustdown降序建小堆,那么根结点一定为最小值,所以将其和末尾交换,末尾的值是确定正确的,那么再对其他结点重复这一操作,就可以得到一个由大到小的数组。
没错,就是这么简单。
堆的其他功能
和栈与队列一样,堆同样有一些功能。
void HeapInit(Heap* php);
// 堆的销毁
void HeapDestory(Heap* hp);
// 堆的插入
void HeapPush(Heap* hp, HPDataType x);
// 堆的删除
void HeapPop(Heap* hp);
// 取堆顶的数据
HPDataType HeapTop(Heap* hp);
// 堆的数据个数
int HeapSize(Heap* hp);
// 堆的判空
int HeapEmpty(Heap* hp);
(HPDataType为自己设定的类型)
代码实现如下,但在堆排序之下黯然失色。
void HeapInit(Heap* hp) {
assert(hp);
hp->a = NULL;
hp->size = hp->capacity = 0;
}
// 堆的销毁
void HeapDestory(Heap* hp) {
assert(hp);
free(hp->a);
hp->a = NULL;
hp->size = hp->capacity = 0;
free(hp);
hp = NULL;
}
void HeapPush(Heap* hp, HPDataType x) {
assert(hp);
if (hp->size == hp->capacity) {
int newcapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;
HPDataType* tmp = (HPDataType*)realloc(hp->a, newcapacity * sizeof(HPDataType));
if (tmp == NULL) {
perror("realloc failed");
return;
}
hp->a = tmp;
hp->capacity = newcapacity;
}
hp->a[hp->size++] = x;
AdjustUp(hp->a,hp->size-1);
}
// 堆的删除
void HeapPop(Heap* hp) {
assert(hp);
assert(hp->size > 0);
Swap(&hp->a[0], &hp->a[hp->size - 1]);
hp->size--;
AdjustDown(hp->a,hp->size,0);
}
// 取堆顶的数据
HPDataType HeapTop(Heap* hp) {
assert(hp);
assert(hp->size > 0);
return hp->a[0];
}
// 堆的数据个数
int HeapSize(Heap* hp) {
assert(hp);
return hp->size;
}
// 堆的判空
int HeapEmpty(Heap* hp) {
assert(hp);
if (hp->size == 0)
return 0;
else
return -1;
}
Top-K问题
排序算法不可避免的一件事是,算法的复杂度一定是O(nlogn),我们要知道一个最大值还算OK,只需要遍历一遍即可,那如果我们要k个呢?难道我们要去设k个变量吗?k个变量之前还需要进行比较,实在麻烦。
而堆,为我们提供了一个新思路。
我们建一个k个元素的数组,并且将前k个填入堆,构成小堆,自然根结点的部分为最小值了。此时,我们将后续遍历的值不断与第一个元素(因为它一定是整个堆的最小值)比较,并且将放入数组的值向下调整保证时刻为小堆。
这样,我们就用近乎O(n)的思路,解决了一个可能原本需要O(nlogn)的问题。
而且这个方法在面对数据量巨大的时候更有奇效。
我们不可能将所有的数据都存储在我们某个文件里,一定会出现需要从别的文件里得到数据的时候。
那当我们调用的时候,发现内存根本无法容纳海量数据的时候,排序就变成了无稽之谈。
而利用刚刚的思路,我们实际上只需要k+1个元素的内存,优势显而易见。
四、二叉树的实现
让我们重新回到二叉树的主题。
提二叉树的实现前,我们必须先提到二叉树的遍历方式。
(假装已经创建出了一个二叉树)
深度优先遍历(DFS)
二叉树的常规遍历分为前序,中序,后序三种。
其中前中后代表的是根在遍历过程中的优先级。
前序:根,左孩子,右孩子。
中序:左孩子,根,右孩子。
后序:左孩子,右孩子,根。
还是回到这个比较简单的图,我将答案展示在下方,可以对照一下,看看思路正确与否。
前序:7->3->5->1->4->6->2
中序:5->3->1->7->6->4->2
后序:5->1->3->6->2->4->7
三种遍历方式没有高下之分。
而实际上前序应该是:
7 3 5 NULL NULL 1 NULL NULL 4 6 NULL NULL 2 NULL NULL
毕竟计算机不知道什么时候结束,所以在探测左孩子和右孩子的时候,一定要到NULL才会停下。
// 二叉树前序遍历
void BinaryTreePrevOrder(BTNode* root) {
if (root == NULL)
return;
printf("%c", root->data);
BinaryTreePrevOrder(root->left);
BinaryTreePrevOrder(root->right);
}
中序和后序可以自己去试一下~,答案如下:
(留白以让大家好遮住答案)
// 二叉树中序遍历
void BinaryTreeInOrder(BTNode* root) {
if (root == NULL)
return;
BinaryTreeInOrder(root->left);
printf("%c", root->data);
BinaryTreeInOrder(root->right);
}
// 二叉树后序遍历
void BinaryTreePostOrder(BTNode* root) {
if (root == NULL)
return;
BinaryTreePostOrder(root->left);
BinaryTreePostOrder(root->right);
printf("%c", root->data);
}
由于二叉树的分叉,我们难以顺着一个方向一直找,因为那样就等于找不到原先的结点了,不利用递归的话,代码难以书写。
可以说,二叉树频繁地使用到了递归的想法。(二叉树的大部分基础题都是递归实现的)
层序遍历(广度优先遍历BFS)
void BinaryTreeLevelOrder(BTNode* root) {
Queue q;
QueueInit(&q);
if (root)
QueuePush(&q, root);
while (!QueueEmpty(&q)) {
BTNode* front = QueueFront(&q);
QueuePop(&q);
printf("%c", front->data);
if (front->left)
QueuePush(&q, front->left);
if (front->right)
QueuePush(&q, front->right);
}
QueueDestroy(&q);
}
讲到层序遍历,我们就需要扯到一点之前说过的栈与队列了。
DFS深搜是为了找到每一条路径(相当于一条道走到黑才往回走),而BFS就是为了找到最短路径了。
层序遍历的实质是队列,当访问一个结点的时候,我们将其子结点放入队列中,直到所有子结点都完全遍历完(队列清空),整棵树也就一层一层地遍历完了。
二叉树的创建
说了这么多,终于要到二叉树的创建了。
创建二叉树并不如创建数组那样简单,往往我们创建二叉树是为了达成某种目的,如找最短路径、枚举每一条可能路径等等。
// 通过前序遍历的数组"ABD##E#H##CF##G##"构建二叉树
BTNode* BinaryTreeCreate(BTDataType* a, int* pi) {
if (a[*pi] == '#') {
(*pi)++;
return NULL;
}
BTNode* root = (BTNode*)malloc(sizeof(BTNode));
root->data = a[(*pi)++];
root->left = BinaryTreeCreate(a, pi);
root->right = BinaryTreeCreate(a, pi);
return root;
}
以什么方式遍历出结果的二叉树,就以该种遍历方式创建即可,同样是把对每个结点的操作作为根,放在left和right的前、中、后即可。