目录
一、树概念及结构
1.1树的概念
1.2 树的相关概念
1.3树的表示
1.4 树在实际中的运用(表示文件系统的目录树结构)
二、二叉树概念及结构
2.1二叉树的概念
2.2现实中的二叉树:编辑
2.3特殊的二叉树
2.4二叉树的性质
2.5 二叉树的存储结构
2.6二叉树例题
3.二叉树的顺序结构及实现
3.1 二叉树的顺序结构
3.2 堆的概念及结构
3.3选择题
3.4堆的实现算法
(1)向下调整算法
(2)向上调整算法(堆的创建)
3.5堆的实现(小堆)
3.6堆的应用
TOP-K问题
实现
堆排序
3.7建堆时间复杂度
四、二叉树链式结构的实现
4.1前置说明
4.2二叉树的遍历
4.2.1 前序、中序以及后序遍历
4.2.2二叉树节点个数:
4.2.3二叉树叶子节点的个数
4.2.4二叉树第K层节点的个数
4.2.5二叉树深度/高度
4.2.6查找值为x的节点
4.2.7层次/序遍历
4.2.8判断二叉树是否是完全二叉树
4.2.9二叉树的销毁
4.3二叉树基础oj练习与C++进阶中的二叉树遍历
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因 为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
- 有一个特殊的结点,称为根结点(树的起始点,也是顶点),根节点没有前驱结点
- 除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继
- 因此,树是递归定义的。
注:
- 任何树都会被分成根和子树
- 既可能是多个子树,也可能是空树
注意:树形结构中,子树之间不能有交集,否则就不是树,而是另外一种数据结构 – 图。
- 节点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A的为6
- 叶节点或终端节点:度为0的节点称为叶节点; 如上图:B、C、H、I...等节点为叶节点
- 非终端节点或分支节点:度不为0的节点; 如上图:D、E、F、G...等节点为分支节点
- 双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点
- 孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点
- 兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点
- 树的度:一棵树中,含有最多度的节点对应的度称为树的度; 如上图:根节点的度最多,为6,故树的度为6
- 节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;
- 注:有点树的层次是从0开始的。但尽量选从1开始的,因为这样空树的高度就是0。否则高度就是-1.因为要区分第0层和高度0
- 树的高度或深度:树中节点的最大层次; 如上图:树的高度为4
- 堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点
- 节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先
- 子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙
- 森林:由m(m>0)棵互不相交的树的集合称为森林; 1.3 树的表示 树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结
如何用代码来定义树呢?
方式一:假设说明了树的度为N
缺点:
方式二:顺序表里存储树节点的指针
缺点:结构相对复杂,会出现二级指针的使用(SLDataType* a <=> struct treeNode** a)
注:上面的方式各有优缺点,表示树结构的最优方法,左孩子右兄弟表示法
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间 的关系(比如该节点的子节点有几个?每个子节点的相对位置关系?),实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法 等。我们这里就简单的了解其中最常用的孩子兄弟表示法。
typedef int DataType;
struct Node
{
struct Node* firstChild1; // 第一个孩子结点,永远指向第一个孩子
struct Node* pNextBrother; // 指向其下一个兄弟结点,指向孩子右边的兄弟
DataType data; // 结点中的数据域
};
一棵二叉树是结点的一个有限集合,该集合或者为空,或者是由一个根节点加上两棵分别称为左子树和右子树的二叉树组成。
特点:
1、每个节点最多有两棵子树,即不存在超过度为2的节点。
2、二叉树的子树有左右之分,且左右不能颠倒。
注意:对于任意的二叉树都是由以下几种情况复合而成的:
1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是 说,如果一个二叉树的层数为K,且结点总数是
,则它就是满二叉树。
总结:
- 所有的叶子节点都在最后一层
- 所有的分支节点都有两个孩子
2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K 的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对 应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
总结:
- 前n-1层都是满的
- 最后一层不满,但是最后一层从左到右都是连续的(中间没有间隔)
1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有
个结点.
2. 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是
.
3. 对任何一棵二叉树, 如果度为0其叶结点个数为
, 度为2的分支结点个数为
,则有
=
+1(二叉树中度为0的节点比度为2的节点多一个)
4. 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h=
![]()
5. 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对 于序号为i的结点有:
- 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点
- 若2i+1=n否则无左孩子
- 若2i+2=n否则无右孩子
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
顺序结构:
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储,关于堆我们后面的章节会专门讲解。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
为什么一定要用存储完全二叉树的规律来存储非完全二叉树呢?
答:因为存储完全二叉树的数组方式可以表示完全二叉树的结构
图解:
假如parent是父节点在数组中的下标,则:
- leftChild = parent*2+1
- rightChild = parent*2+2
于是我们可以得出推论:
- 求任意节点对应的子节点,我们以它为父节点parent计算出其对应的的子节点Child,假如数组中不存在该下标,则可以说该子节点不存在。如果左右Child都不存在,则说明该父节点没有字节的,即叶节点
- 求任意节点对应的父节点,我们不管它是左孩子还是右孩子,可以用公式
用数组表示二叉树来存储的缺陷:
假如不是完全二叉树,则必会造成空间的浪费
链式存储:
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是 链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所 在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,后面课程 学到高阶数据结构如红黑树等会用到三叉链。
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; // 当前节点值域 };
1. 某二叉树共有 399 个结点,其中有 199 个度为 2 的结点,则该二叉树中的叶子结点数为( )
A 不存在这样的二叉树
B 200
C 198
D 199
2.下列数据结构中,不适合采用顺序存储结构的是( )
A 非完全二叉树
B 堆
C 队列
D 栈
3.在具有 2n 个结点的完全二叉树中,叶子结点个数为( )
A n
B n+1
C n-1
D n/2
4.一棵完全二叉树的节点数位为531个,那么这棵树的高度为( )
A 11
B 10
C 8
D 12
解答:高度为h的树的节点范围是:[
,
]
h=10对应范围是[512,1023]符合题意
5.一个具有767个节点的完全二叉树,其叶子节点个数为()
A 383
B 384
C 385
D 386
解答:
方法一:
方法二:
假设度为0的节点有n0个
度为1的节点有n1个
度为2的节点有n2个
=> n0+n1+n2=767
因为按n0=n2+1,n1为0或1(这里n1取0)
=> n0+0+n0-1=767
=> n0 =384
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆(以及上一章的栈)和操作系统虚拟进程地址空间中的堆(和栈)是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
如果有一个关键码的集合K = { , , ,…, },把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足: = 且 >= ) i = 0,1, 2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
总结:
- 大堆:树中一个树及子树中,任何一个父亲都大于等于孩子
- 小堆:树中一个数及子树中,任何一个父亲都小于等于孩子
注意:所以的数组都可以表示成完全二叉树,但是它不一定是堆
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树。
1.下列关键字序列为堆的是:()
A 100,60,70,50,32,65
B 60,70,65,50,32,100
C 65,100,70,32,50,60
D 70,65,100,32,50,60
E 32,50,100,70,65,60
F 50,100,70,65,60,32
解题方法:画图
A. B.
其余同理,答案是:A、
2.已知小根堆为8,15,10,21,34,16,12,删除关键字 8 之后需重建堆,在此过程中,关键字之间的比较次 数是()。
A 1
B 2
C 3
D 4
3.一组记录排序码为(5 11 7 2 3 17),则利用堆排序方法建立的初始堆为
A(11 5 7 2 3 17)
B(11 5 7 2 17 3)
C(17 11 7 2 3 5)
D(17 11 7 5 3 2)
E(17 7 11 3 5 2)
F(17 7 11 3 2 5)
4.最小堆[0,3,2,5,7,4,6,8],在删除堆顶元素0之后,其结果是()
A[3,2,5,7,4,6,8]
B[2,3,5,7,4,6,8]
C[2,3,4,5,7,8,6]
D[2,3,4,5,6,7,8]
堆的向下调整:
(以小堆为例)
- 先设定根节点为当前节点(通过下标获取,标记为cur),比较左右子树的值,找出更小的值,用child来标记。(找小儿子)
- 比较child和cur的值,如果child比cur小,则不满足小堆的规则,需要进行交换。
- 如果child比cur大,满足小堆的规则,不需要交换,调整结束。(结束条件①:父亲比小儿子还小 ②调整到叶节点了,因为叶子没有孩子了)
- 处理完一个节点之后,从当前的child出发,循环之前的过程。
向下调整(小堆)示例:
void AdjustDownSmall(int* a, int n, int parent) { assert(a); int child = parent * 2 + 1; //若算出孩子>=n,不存在。没有孩子循环结束 while (child
注:
- 没必要定义leftChild和rightChild。因为若有孩子,左孩子一定存在,右孩子则不一定存在。故只定义一个child就好了,child+1就是rightChild。
- 右孩子存在的条件是:child+1< 有效长度;
向下调整(大堆)示例:
void AdjustDownBig(int* a, int n, int parent) { assert(a); int child = parent * 2 + 1; //若算出孩子>=n,不存在。没有孩子循环结束 while (child
a[child]) { ++child; }//默认左孩子最大,如果右孩子大于左孩子,则选择右孩子与父节点发生交换 if (a[child] > a[parent]) { Swap(&a[child], &a[parent]); parent = child; child = parent * 2 + 1; } //子节点大于父节点,循环结束 else { break; } } }
下面我们给出两个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。
根节点左右子树不是堆,我们怎么调整呢?
这里我们从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,就可以调整成堆。
堆的向上调整:
(以小堆为例)
- 先设定倒数的第一个叶子节点为当前节点(通过下标获取,标记为child),找出他的父亲节点,用parent来标记。
- 比较parent和child的值,如果child比parent小,则不满足小堆的规则,需要进行交换。
- 如果child比parent大,满足小堆的规则,不需要交换,调整结束。
- 处理完一个节点之后,从当前的parent出发,循环之前的过程。
int a[] = {9,7,8,10,3,6}
向上调整(小堆)示例:
代码实现:
//向上调整法(小堆): void AdjustUpSmall(int* a, int child) { assert(a); //已知孩子下标找父亲下标 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; } } }
int a[] = {1,5,3,8,7,6}
向上调整(大堆)示例:
代码实现:
//向上调整法(大堆): void AdjustUpBig(int* a, int child) { assert(a); //已知孩子下标找父亲下标 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; } } }
堆的定义及创建:
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
堆的初始化:
void HeapInit(HP* hp)
{
assert(hp);
hp->a = NULL;
hp->size = hp->capacity = 0;
}
栈的扩容:
void checkCapacity(HP* hp)
{
assert(hp);
if (hp->size == hp->capacity)
{
int newCapacity = hp->capacity == 0? 4 : hp->capacity * 2;
HPDataType* newA = realloc(hp->a, sizeof(HPDataType) * newCapacity);
if (newA == NULL)
{
printf("realloc fail");
exit(-1);
}
hp->capacity = newCapacity;
hp->a = newA;
}
}
节点数据交换:
void Swap(HPDataType* px,HPDataType* py)
{
HPDataType tmp = *px;
*px = *py;
*py = tmp;
}
栈的插入:
堆的插入要求原二叉树为一个堆,再插入新数据后依然为一个堆。这就要求对新数据的位置进行调整,对此我们要学习向上调整算法。
注意:插入时只能在堆的末尾进行操作,否则会破坏堆的整个结构。
步骤:将数据插入到数组最后,再进行向上调整。
向上调整算法:把新节点顺着其双亲调整到合适的位置,就能形成一个新的堆。
int a[]={5,10,15,20} int a[]={5,10,15,20,4}
示例:
void HeapPush(HP* hp, HPDataType x)
{
assert(hp);
checkCapacity(hp);
hp->a[hp->size] = x;
hp->size++;
AdjustUp(hp->a,hp->size,hp->size-1);//这里不能写成hp->size--,必须写成hp->size-1。前者改变了size本身,使得函数先增后减,陷入死循环
}
栈的判空:
bool HeapEmpty(HP* hp)
{
assert(hp);
return hp->size == 0;
}
栈的长度:
int HeapSize(HP* hp)
{
assert(hp);
return hp->size;
}
栈的打印:
void HeapPrint(HP* hp)
{
assert(hp);
for (int i = 0; i < hp->size; i++)
{
printf("%d ", hp->a[i]);
}
printf("\n");
}
堆的销毁:
void HeapDestory(HP* hp)
{
assert(hp);
free(hp->a);
hp->size = hp->capacity = 0;
}
堆的删除:
删除堆是删除堆顶的数据,将堆顶的数据根最后一个数据一换,然后删除数组最后一个数据,再进行向下调 整算法。
void HeapPop(HP* hp) { assert(hp); assert(!HeapEmpty(hp)); //交换首尾元素 Swap(&hp->a[0], &hp->a[hp->size - 1]); //删除尾元素 hp->size--; //向下调整法: AdjustDown(hp->a, hp->size, 0);//从下标为0处开始向下调整 }
注:
步骤:
完整代码:
Heap.h
#pragma once
#include
#include
#include
#include
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
void HeapInit(HP* hp);
void HeapDestory(HP* hp);
void HeapPush(HP* hp, HPDataType x);
void HeapPop(HP* hp);
void AdjustUp(int* a, int n, int child);
void AdjustDown(int* a, int n, int parent);
void checkCapacity(HP* hp);
void HeapPrint(HP* hp);
bool HeapEmpty(HP* hp);
int HeapSize(HP* hp);
void Swap(HPDataType* px, HPDataType* py);
Heap.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"
//初始化
void HeapInit(HP* hp)
{
assert(hp);
hp->a = NULL;
hp->size = hp->capacity = 0;
}
//扩容
void checkCapacity(HP* hp)
{
assert(hp);
if (hp->size == hp->capacity)
{
int newCapacity = hp->capacity == 0? 4 : hp->capacity * 2;
HPDataType* newA = realloc(hp->a, sizeof(HPDataType) * newCapacity);
if (newA == NULL)
{
printf("realloc fail");
exit(-1);
}
hp->capacity = newCapacity;
hp->a = newA;
}
}
//向上调整法(小堆):
void AdjustUp(int* a, int n, int child)
{
assert(a);
//已知孩子下标找父亲下标
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;
}
}
}
//向下调整法(小堆):
void AdjustDown(int* a, int n, int parent)
{
assert(a);
int child = parent * 2 + 1;
//若算出孩子>=n,不存在。没有孩子循环结束
while (childa[hp->size] = x;
hp->size++;
AdjustUp(hp->a, hp->size, hp->size - 1);
}
//打印
void HeapPrint(HP* hp)
{
assert(hp);
for (int i = 0; i < hp->size; i++)
{
printf("%d ", hp->a[i]);
}
printf("\n");
}
//判空
bool HeapEmpty(HP* hp)
{
assert(hp);
return hp->size == 0;
}
//长度
int HeapSize(HP* hp)
{
assert(hp);
return hp->size;
}
//删除
void HeapPop(HP* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
//交换首尾元素
Swap(&hp->a[0], &hp->a[hp->size - 1]);
//删除尾元素
hp->size--;
//向下调整法:
AdjustDown(hp->a, hp->size, 0);//从下标为0处开始向下调整
}
//销毁
void HeapDestory(HP* hp)
{
assert(hp);
free(hp->a);
hp->size = hp->capacity = 0;
}
Test.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"
int main()
{
int a[] = { 70,56,30,25,15,10,75 };
HP hp;
HeapInit(&hp);
for (int i = 0; i < sizeof(a) / sizeof(a[0]); ++i)
{
HeapPush(&hp,a[i]);
}
HeapPrint(&hp);
HeapPop(&hp);
HeapPrint(&hp);
HeapDestory(&hp);
return 0;
}
就是找到一堆数中前K个最大的数或最小的数。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
假如现在要从1000个数中找到最大的前10个数,怎么搞?
方式一:先排降序,前十个就是最大的。时间复杂度O()
方式二:N个数依次插入大堆,Pop K次,每次取堆顶的数据就是前K个。时间复杂度O()
方式三:假设N非常大,N为10亿,内存中存不下这些数,它们存在文件中。k是100。方式一、二都不能用了。
注:
10亿个整数,大概占用多大的空间--4G
- 1G=1024MB
- 1G=1024*1024KB
- 1G=1024*1024*1024Byte
- 1G约为10亿字节
注:我们平时所用的排序是内排序,也就是数据存储于内存中。如果数据太大了,内存存不下,那内排序就用不上了。也就只能用外排序了,一种经典的外排序方法归并。但也有缺陷,毕竟现在数据存储在磁盘当中,排起来比在内存当中慢很多。故只能用k个数的小堆
方式三思路:
- 用前K个数建立一个K个数的小堆(找前k个最大元素则建小堆;找前k个最小元素则建大堆)
- 剩下的N-K个数,依次跟堆顶的数据进行比较
- 如果堆顶的数据大,就替换堆顶的数据,再向下调整
- 最后堆里的K个数就是最大的K个数
时间复杂度:O()
设置前K个最大的数据:
void TestTopK()
{
int n = 10000;
int* a = (int*)malloc(sizeof(int) * n);
srand(time(0));
for (size_t i = 0; i < n; ++i)
{
a[i] = rand() % 1000000;
}
//再去设置10个比100w大的数
a[5] = 1000000 + 1;
a[1231] = 1000000 + 2;
a[531] = 1000000 + 3;
a[5121] = 1000000 + 4;
a[115] = 100000 + 5;
a[2335] = 100000 + 6;
a[9999] = 100000 + 7;
a[76] = 100000 + 8;
a[423] = 100000 + 9;
a[3144] = 100000 + 10;
PrintTopK(a, n, 10);
}
获取栈顶数据:
HPDataType HeapTop(HP* hp)
{
assert(hp);
assert(!HeapEmpty(&hp));
return hp->a[0];
}
打印前K个数:
方法一:
void PrintTopK(int* a, int n, int k)
{
HP hp;
HeapInit(&hp);
//创建一个K个数的小堆
for (int i = 0; i < k; ++i)
{
HeapPush(&hp, a[i]);
}
//剩下的N-K个数跟堆顶的数据比较,比堆顶数据大,就替换它进堆
for (int i = k; i < n; ++i)
{
if (a[i] > HeapTop(&hp))
{
HeapPop(&hp);
HeapPush(&hp, a[i]);
}
}
HeapPrint(&hp);
HeapDestory(&hp);
}
方法二:
void PrintTopK(int* a, int n, int k)
{
HP hp;
HeapInit(&hp);
//创建一个K个数的小堆
for (int i = 0; i < k; ++i)
{
HeapPush(&hp, a[i]);
}
//剩下的N-K个数跟堆顶的数据比较,比堆顶数据大,就替换它进堆
for (int i = k; i < n; ++i)
{
if (a[i] > HeapTop(&hp))
{
hp.a[0] = a[i];
AdjustDown(hp.a, hp.size, 0);
}
}
HeapPrint(&hp);
HeapDestory(&hp);
}
完整代码:
//获取堆顶数据
HPDataType HeapTop(HP* hp)
{
assert(hp);
assert(!HeapEmpty(&hp));
return hp->a[0];
}
//打印前K个数
void PrintTopK(int* a, int n, int k)
{
HP hp;
HeapInit(&hp);
//创建一个K个数的小堆
for (int i = 0; i < k; ++i)
{
HeapPush(&hp, a[i]);
}
//剩下的N-K个数跟堆顶的数据比较,比堆顶数据大,就替换它进堆
for (int i = k; i < n; ++i)
{
if (a[i] > HeapTop(&hp))
{
hp.a[0] = a[i];
AdjustDown(hp.a, hp.size, 0);
}
}
HeapPrint(&hp);
HeapDestory(&hp);
}
void TestTopK()
{
int n = 10000;
int* a = (int*)malloc(sizeof(int) * n);
srand(time(0));
for (size_t i = 0; i < n; ++i)
{
a[i] = rand() % 1000000;
}
//再去设置10个比100w大的数
a[5] = 1000000 + 1;
a[1231] = 1000000 + 2;
a[531] = 1000000 + 3;
a[5121] = 1000000 + 4;
a[115] = 100000 + 5;
a[2335] = 100000 + 6;
a[9999] = 100000 + 7;
a[76] = 100000 + 8;
a[423] = 100000 + 9;
a[3144] = 100000 + 10;
PrintTopK(a, n, 10);
}
int main()
{
TestTopK();
return 0;
}
按照TopK问题的思路写一个堆的升序:空间复杂度O(N)
void HeapSort(int* a, int n)
{
HP hp;
HeapInit(&hp);
//建立一个N个小堆
for (int i = 0; i < n; i++)
{
HeapPush(&hp, a[i]);
}
//Pop N次
for (int i = 0; i < n; i++)
{
a[i]=HeapTop(&hp);
HeapTop(&hp);
}
HeapDestory(&hp);
}
int main()
{
int a[] = { 70,56,30,25,15,10,70 };
HeapSort(a, sizeof(a) / sizeof(a[0]));
for (int i = 0; i < sizeof(a)/sizeof(a[0]); i++)
{
printf("%d ", a[i]);
}
return 0;
}
要求优化到O(1) <=> 不能开辟新空间<=>不能用Heap <=> 把a构建成堆
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
(1)建堆
升序建大堆,降序建小堆。
(2) 利用堆删除思想来进行排序
以排升序为例,先建好大堆,然后把最大的数也就是第一个数和最后一个数进行交换。把最后一个数看作从堆中取中,再把第一个数进行向下调整建立新堆,即能找到次大的数。反复重复上述步骤,便可以实现排序。
方法一:向上调整法
void HeapSort(int* a, int n)
{
for (int i = 0; i < n; i++)
{
AdjustUpSmall(a,i);
}
}
方法二:向下调整法(小堆)
注:
void HeapSort(int* a, int n)
{
//建堆
//时间复杂度O(n)
for (int i = (n-1-1)/2; i >= 0; --i)
{
AdjustDownBig(a,n,i);
}
//依次选数,调堆=》排序
//时间复杂度O(nlogn)
for(int end=n-1;end>0;--end)
{
Swap(&a[end],&a[0]);
//再调堆,选出次小的数
AdjustDownBig(a,end,0);
}
}
向上调整建堆的时间复杂度为O(N*log N),向下调整的时间复杂度为O(N),所以我们常用向下调整建堆。
下面我将重点演示向下调整建堆的时间复杂度计算,感兴趣的小伙伴也可以自己验证一下向上调整的时间复杂度,方法相同。
从图中我们也可以看出,每一层节点越多,需要移动的次数就越少,故导致最后的时间复杂度为 O(N)。
注:普通二叉树增删查改没有什么价值,因为用来存储数据呢,太复杂了。不如链表顺序表
故我们只关注它的递归遍历结构
- 为后面学习更有用的树打基础
- 很多OJ题结构就是普通二叉树
普通二叉树
价值体现:在它的基础之上,增加一些性质,才有意义:
1.搜索二叉树
2.huffman tree
在学习二叉树的基本操作前,需先要创建一棵二叉树,然后才能学习其相关的基本操作。由于现在大家对二 叉树结构掌握还不够深入,为了降低大家学习成本,此处手动快速创建一棵简单的二叉树,快速进入二叉树 操作学习,等二叉树结构了解的差不多时,我们反过头再来研究二叉树真正的创建方式。
typedef int BTDataType; //定义树 typedef struct BinaryTreeNode { struct BinaryTreeNode* leftChild; struct BinaryTreeNode* rightChild; BTDataType data; }BTNode; //创建新节点 BTNode* BuyNode(BTDataType x) { BTNode* node = (BTNode*)malloc(sizeof(BTNode)); if (node == NULL) { printf("malloc fail\n"); exit(-1); } node->leftChild = node->rightChild = NULL; node->data = x; } //插入整数 BTNode* CreatBinaryTree() { BTNode* node1 = BuyNode(1); BTNode* node2 = BuyNode(2); BTNode* node3 = BuyNode(3); BTNode* node4 = BuyNode(4); BTNode* node5 = BuyNode(5); BTNode* node6 = BuyNode(6); node1->leftChild = node2; node1->rightChild = node4; node2->leftChild = node3; node4->leftChild = node5; node4->rightChild = node6; return node1; } //插入字符 BTNode* CreatBinaryTree() { BTNode* nodeA = BuyNode('A'); BTNode* nodeB = BuyNode('B'); BTNode* nodeC = BuyNode('C'); BTNode* nodeD = BuyNode('D'); BTNode* nodeE = BuyNode('E'); BTNode* nodeF = BuyNode('F'); nodeA->leftChild = nodeB; nodeA->rightChild = nodeC; nodeB->leftChild = nodeD; nodeC->leftChild = nodeE; nodeC->rightChild = nodeF; //BuyNode把未处理部分已经赋为NULL了 return nodeA; }
注意:上述代码并不是创建二叉树的方式,真正创建二叉树方式后序详解重点讲解。
我们以后看待二叉树,要把它看成根、左子树、右子树三部分
。。。。重复以上操作,直到根无子树即为结束
学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉 树中的节点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历 是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:
前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。
操作顺序:根、左子树、右子树
- 先遍历根A, A遍历完后遍历A的左子树B,左子树B遍历完了才轮到右子树C
- 遍历A的左子树B,B遍历完后遍历B的左子树D,左子树D遍历完了才轮到右子树
- 遍历B的左子树D,D遍历完后遍历D的左子树,左子树遍历完了才轮到右子树
- 因为D的两个子树为NULL,故B的左子树遍历完了
- 接下来遍历B的右子树
- 因为B的左右子树都遍历完了,故A的左子树遍历完了
- 接下来遍历A的右子树C,C遍历完后遍历C的左子树E,左子树E遍历完了才轮到右子树F
- 遍历C的左子树E,E遍历完后遍历E的左子树,左子树遍历完了才轮到右子树
- 因为E的两个子树为NULL,故C的左子树遍历完了
接下来遍历C的右子树F
F遍历完后,遍历它的左子树 , 左子树遍历完了才轮到右子树
因为F的两个子树为NULL,故C的右子树遍历完了
总结一下顺序:A B D NULL NULL NULL C E NULL NULL F NULL NULL
先序遍历可以想象为,一个小人从一棵二叉树根节点为起点,沿着二叉树外沿,逆时针走一圈回到根节点,路上遇到的元素顺序,就是先序遍历的结果
代码实现:
// 二叉树前序遍历 void PreOrder(BTNode* root) //这里的root接收的是根节点A的地址 { if (root == NULL) //递归中遇到NULL,返回上一层节点 { printf("NULL "); return; } printf("%c ", root->data); PreOrder(root->leftChild); //递归遍历左子树 PreOrder(root->rightChild); //递归遍历右子树 } int main() { BTNode* root = CreatBinaryTree(); // 二叉树前序遍历 PreOrder(root); }
中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
操作顺序:左子树、根、右子树
总结一下顺序:NULL D NULL B NULL A NULL E NULL C NULL F NULL
记住,中序遍历就是从最左边开始,把每个节点垂直投影到同一直线上,然后从左往右读值就可以了,多看几遍动图就理解了
代码实现:
void InOrder(BTNode* root) { if (root == NULL) { printf("NULL "); return; } InOrder(root->leftChild); printf("%c ", root->data); InOrder(root->rightChild); } int main() { BTNode* root = CreatBinaryTree(); //二叉树中序遍历 InOrder(root); }
后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。
操作顺序:左子树、右子树、根
总结一下顺序:NULL NULL D NULL B NULL NULL E NULL NULL F C A
就是围着树的外围绕一圈,如果发现一剪刀就能剪下的葡萄(必须是一颗葡萄)(也就是葡萄要一个一个掉下来,不能一口气掉超过1个这样),就把它剪下来,组成的就是后序遍历了。代码实现:
void PostOrder(BTNode* root) { if (root == NULL) { printf("NULL"); return; } PostOrder(root->leftChild); PostOrder(root->rightChild); printf("%c ", root->data); } int main() { BTNode* root = CreatBinaryTree(); //二叉树后序遍历 PostOrder(root); }
错误的写法:
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
{
return;
}
int count = 0;
++count;
BinaryTreeSize(root->leftChild);
BinaryTreeSize(root->rightChild);
return count;
}
int main()
{
//二叉树的节点个数
printf("节点个数是:%d", BinaryTreeSize(root));
}
运行结果:
注:虽然每进一个节点我加一次,看似可以。但是我里面定义了int count=0,每进一个节点前先清空再加1,结果永远为1
改进:用static修饰count
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
{
return 0;//不给个数字会报错,因为返回类型是int
}
static int count = 0;
++count;
BinaryTreeSize(root->leftChild);
BinaryTreeSize(root->rightChild);
return count;
}
int main()
{
//二叉树的节点个数
printf("节点个数是:%d", BinaryTreeSize(root));
printf("节点个数是:%d", BinaryTreeSize(root));
}
运行结果:
注:这种写法让int count=0;使用一次,后续不会影响count的加1,确实会得到正确结果。但是这种写法也是错的!因为一旦你之后调用这个函数,它会把之前的节点树也一并加上。即使改成全局变量,也会有相似的问题
改进:
void BinaryTreeSize(BTNode* root,int* pn)
{
if (root == NULL)
{
return;
}
++*pn;
BinaryTreeSize(root->leftChild,pn);
BinaryTreeSize(root->rightChild,pn);
}
int main()
{
//二叉树的节点个数
int n1 = 0;
BinaryTreeSize(root,&n1);
printf("节点个数是:%d\n", n1);
int n2 = 0;
BinaryTreeSize(root, &n2);
printf("节点个数是:%d\n", n2);
}
运行结果:
简洁牛逼的写法:
int BinaryTreeSize(BTNode* root)
{
return root == NULL ? 0 : BinaryTreeSize(root->leftChild) + BinaryTreeSize(root->rightChild) + 1;
}
int main()
{
//二叉树的节点个数
printf("节点个数是:%d", BinaryTreeSize(root));
}
原理:打个比方,校长要调查全校学生的人数。他不可能挨个寝室记录。它会叫来每个学院的院长,让他们把该院的师生人数统计好,交上去,以此类推。得到每个学院的师生人数后,再把自己加上去就是全校的师生人数了。当然了,如果为空,就表示包括校长在内的全校师生压根就不存在,鬼故事嘛你懂的
有缺陷的写法:
//二叉树叶子节点个数
int BinaryTreeLeafSize(BTNode* root)
{
return root == NULL ? 1 : BinaryTreeLeafSize(root->leftChild) + BinaryTreeLeafSize(root->rightChild);
}
int main()
{
BTNode* root = CreatBinaryTree();
//二叉树叶子节点个数
printf("叶子节点个数是:%d\n",BinaryTreeLeafSize(root));
}
运行结果:
注:该写法没有考虑到当二叉树为空时的情况,如果二叉树为空,那就不存在叶子节点
正确的写法:
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL) //二叉树不存在,叶节点为0
{
return 0;
}
if (root->leftChild == NULL && root->rightChild == NULL) //二叉树存在,节点的两个子树为空,则该节点为叶子节点,+1
{
return 1;
}
return BinaryTreeLeafSize(root->leftChild) + BinaryTreeLeafSize(root->rightChild); //二叉树存在,节点的两个子树不是叶子,那么叶子节点等于左子树的叶子节点+右子树的叶子节点
}
int main()
{
BTNode* root = CreatBinaryTree();
//二叉树叶子节点个数
printf("叶子节点个数是:%d\n",BinaryTreeLeafSize(root));
}
运行结果:
int BinaryTreeKSize(BTNode* root,int k)
{
assert(k > 0);
if (root == NULL)
{
return 0;
}
if (k == 1)
{
return 1;
}
//root不为空,k也不等于1,说明root这棵树的第k节点在子树里面
//转换成求左右子树的第k-1层的子节点数量
return BinaryTreeKSize(root->leftChild, k - 1) + BinaryTreeKSize(root->rightChild, k - 1);
}
int main()
{
BTNode* root = CreatBinaryTree();
int k = 0;
printf("请输入要求哪一层的节点:");
scanf("%d", &k);
printf("第k层节点的个数是:%d\n", BinaryTreeKSize(root, k));
}
运行结果:
有缺点的代码:
//二叉树的深度/高度
int BinaryTreeDepth(BTNode* root)
{
if (root == NULL)
{
return 0;
}
return BinaryTreeDepth(root->leftChild) > BinaryTreeDepth(root->rightChild) ? BinaryTreeDepth(root->leftChild) + 1 : BinaryTreeDepth(root->rightChild) + 1;
}
int main()
{
BTNode* root = CreatBinaryTree();
//二叉树的深度/高度
printf("二叉树的深度/高度是:%d\n", BinaryTreeDepth(root));
}
当前树的深度/高度=左子树的深度或右子树的深度大的+1
缺点:调用次数过多,且因没保存要重复计算
改进写法:
int BinaryTreeDepth(BTNode* root)
{
if (root == NULL)
{
return 0;
}
int leftDepth = BinaryTreeDepth(root->leftChild);
int rightDepth = BinaryTreeDepth(root->rightChild);
return leftDepth > rightDepth ? leftDepth + 1 : rightDepth + 1;
}
int main()
{
BTNode* root = CreatBinaryTree();
//二叉树的深度/高度
printf("二叉树的深度/高度是:%d\n", BinaryTreeDepth(root));
}
思路类似校长调查全校师生人数,不过现在是调查身高最高的那个人是谁。命令被一层一层下达后,最底层的室长把所收取的数据做个比较,把最大的舍友身高数据报了上去。老师收到后把全班的室长传来的数据做个比较,把最大的那一份身高数据传了上去。依次类推,直到最终的校长得到最大的数据
看似正确,实则会报错的写法:
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->data == x)
{
return root;
}
BinaryTreeFind(root->leftChild, x);
BinaryTreeFind(root->rightChild, x);
}
报错的原因:虽然这种写法符合执行逻辑,看起来能够正确运行,但是VS有一个特点,它是根据语法来判断是否运行的。在他看来,如果if (root == NULL)和if (root->data == x)没有返回,那么后续就没有返回了,这是不符合它认为的语法的。
改进:
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->data == x)
{
return root;
}
if (BinaryTreeFind(root->leftChild, x))
{
return BinaryTreeFind(root->leftChild, x);
}
if (BinaryTreeFind(root->rightChild, x))
{
return BinaryTreeFind(root->rightChild, x);
}
}
这种写法可以运行,但是效率太低。低在和找高度问题一个道理:我好不容易通过递归千辛万苦找到了对应节点地址,但是呢我没有保存下来它的地址,而是报告说找到了,它存在。然后让return从头到尾再重复找一次
改进:
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->data == x)
{
return root;
}
BTNode* leftRet=BinaryTreeFind(root->leftChild, x);
if (leftRet)
{
return leftRet;
}
BTNode* rightRet = BinaryTreeFind(root->rightChild, x);
if(rightRet)
{
return rightRet;
}
return NULL;//找完了,没有对应x的节点
}
层次遍历很好理解,就是从根节点开始,一层一层,从上到下,每层从左到右,依次写值就可以了
层次遍历结果:A B C D E F G H I J K
解释外圈跑的意思:
绕着外围跑一整圈的真正含义是:遍历所有结点时,都先往左孩子走,再往右孩子走。
实现思路:
代码实现:
void BinaryTreeLevelOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
Queue q;
QueueInit(&q);
QueuePush(&q, root);
While(!QueueEmpty(&q))
{
NTNode** front = QueueFront(&q);
QueuePop(&q);
printf("%d ", front->data);
//左孩子放进队列
if (front->leftChild)
{
QueuePush(&q, front->leftChild);
}
//右孩子放进队列
if (front->rightChild)
{
QueuePush(&q, front->rightChild);
}
}
printf("\n");
QueueDestory(&q);
}
int main()
{
BinaryTreeLevelOrder(root);
}
运行结果:
bool BinaryTreeComplete(BTNode* root)
{
Queue q;
QueueInit(&q);
QueuePush(&q, root);
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front == NULL)
{
break;
}
else
{
QueuePush(&q, front->leftChild);
QueuePush(&q, front->rightChild);
}
}
//遇到空了以后,检查队列中剩下的节点
//1.剩下的全是空,则是完全二叉树
//2.剩下存在非空,则不是完全二叉树
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front)//非空
{
return false;
}
}
QueueDestory(&q);
return true;
}
void BinaryTreeDestory(BTNode* root)
{
if (root == NULL)
{
return;
}
BinaryTreeDestory(root->leftChild);
BinaryTreeDestory(root->rightChild);
free(root);
}