目录
数是一个种非线性数据结构,之所以叫做树,因为它画出来像一颗倒挂的树,也就是根朝上,叶朝下。其中,有一个特殊的结点,叫根结点(下图的A结点),就是最上面单独在一行的结点。
注意,树型结构中,子树不能有交叉,否则就不是树形结构。
接下来,我们来了解一下树的相关概念,这对我们学习二叉树很有帮助。
节点的度:一个节点含有的子树的个数称为该节点的度,上图中,A节点的度为6。
叶节点或终端节点:度为0的节点称为叶节点,上图中,B、C、H、I...等均为叶节点。
非终端节点或分支节点:度为0的节点,上图中,D、E、F、G等节点为分支节点。
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点,上图中,A是B的父节点。
孩子节点或子节点:一个节点含有子树的节点称为该节点的子节点,上图中,B是A的孩子节点。
兄弟节点:具有相同父节点的节点称为兄弟节点,上图中,B、C是兄弟节点。
数的度:一棵树中,最大的节点的度称为数的度,上图中,树的度为6。
节点的层次:从根节点开始,根为第一层,根的子节点为第二层,以此类推。
树的高度或深度:树中节点的最大层次,上图中,树的高度为4。
堂兄弟节点:双亲在同一层的节点互为堂兄弟;上图中,H、I互为堂兄弟节点。
节点的祖先:从根到该节点所经分支上的所有节点;上图中,A是所有节点的祖先。
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。上图中,所有节点都是A的子孙。
森林:由m(m>0)棵互不相交的树的集合称为森林。
那么,树的表示方法有哪些呢?
树结构相对线性表就比较复杂,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法。
struct TreeNode
{
struct TreeNode* leftchild;//指向自己的孩子节点
struct TreeNode* rightbrother;//指向右面的兄弟节点
TreeDataType val;//当前节点的值
};
孩子兄弟表示法的示意图如下:
那么,树形结构在实际中也是有广泛应用的,比如文件系统的目录树结构。
一棵二叉树是结点的一个有限集合,该集合:
1.或者为空
2.由一个根节点加上两棵别称为左子树和右子树的二叉树组成
从上图可以看出:
1. 二叉树不存在度大于2的结点
2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
注意,二叉树都是由以下几种情况复合而成:
1.满二叉树
一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是
说,如果一个二叉树的层数为K,且结点总数是 ,则它就是满二叉树。
2.完全二叉树
完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
1.若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2^(i-1)个结点。
2.若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2^h-1。
3.对于任何一棵二叉树。如果度为0其叶节点个数为n0,度为2的分支节点个数为n2,则n0=n2+1。(增加一个度为2的一定会增加一个度为0的)
4.若规定根节点的层数为1,具有n个结点的满二叉树的深度h=log(n+1),(以2为底,n+1的对数)。
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否则无右孩子
下面利用以上性质,做个题:
1.在具有 2n 个结点的完全二叉树中,叶子结点个数为__
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
1.顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
2.链式存储
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链。
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
堆总是一棵完全二叉树。把它的所有元素按完全二叉树的顺序存储方式存储到一个数组中。堆又分为大堆和小堆,简单来说:
大堆:任何一个父亲>=孩子
小堆:任何一个父亲<=孩子
首先把堆内容的的数据类型重定义,以便后续修改:
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
使用Heap这样一个结构体来实现堆,并将结构体重命名为HP,其中成员a是HPDataType*类型,接受realloc动态内存开辟得到的空间。size这个变量指向最后元素的下一个元素的下标,也表示堆元素个数。capacity表示堆的容量。
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = 0;
php->capacity = 0;
}
将堆的a初始化为空指针,size和capacity初始化为0。
void HeapDestory(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->capacity = php->size = 0;
}
释放掉动态内存开辟的空间。
void HeapPush(HP* php, int x)
{
assert(php);
if (php->size == php->capacity)
{
int newcapacity = (php->capacity == 0) ? 4 : (2 * php->capacity);
HPDataType* tmp = (HPDataType*)realloc(php->a,newcapacity * sizeof(HPDataType));
if (tmp == NULL)
{
perror("realloc");
exit(-1);
}
php->a = tmp;
php->capacity = newcapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a,php->size-1);
}
这个接口中,除了最后三行外,上面的几行都是用来判断堆容量是否充足,如果不充足,那么扩充空间。当空间够用时,将待插入元素插入到size处,然后让size自增。在把待插入元素插入到数组最后后,需要把刚插入的元素向上调整到合适的位置,以满足堆的定义。因此,单独设计了AdjustUp函数用以实现这个功能。AdjustUp函数有两个参数,第一个参数为待调整的接受数组指针a,第二个参数为待调整元素下标child。
void AdjustUp(HPDataType* a, int child);
AdjustUp函数的设计思想如下:
?AdjustUp函数实现:
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 = (parent - 1) / 2;
}
else
{
break;
}
}
}
堆的删除特指删除堆顶元素,即根节点。
void HeapPop(HP* php)
{
assert(php);
assert(php->size > 0);
Swap(&(php->a[0]), &(php->a[php->size - 1]));
php->size--;
AdjustDown(php->a, php->size, 0);
}
删除堆顶元素的思想是,先将堆的首尾元素交换,然后将size自减,此时堆的结构被破坏,需要将堆顶元素进行调整,因此,需要设计AdjustDown函数进行调整。
AdjustDown函数的设计思想如下:
?AdjustDown函数实现:
void AdjustDown(int* a,int size,int parent)
{
int child = parent * 2 + 1;
while (child < size)
{
if ((child+1) < size && a[child] > a[child + 1])
{
child++;
}
if (a[parent] > a[child])
{
Swap(&(a[parent]), &(a[child]));
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
HPDataType HeapTop(HP* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
size_t HeapSize(HP* php)
{
assert(php);
return php->size;
}
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
堆排序,就是利用堆的思想来进行排序,总共分为两个步骤:
1)建堆
如果我们想排成升序,那建成大堆还是小堆呢?
假设建成小堆,那根节点必定是最小的,为了得到次小的,需要将剩下的数看成堆,关系全乱了,只能重新建堆,因此,如果排成升序,要建大堆;如果排成降序,要建小堆。下面我们以升序为例,在建大堆时,我们可以采用向上调整和向下调整两种方法:
向上调整:
1.从第二个元素开始,先让第二个元素和第一个元素构成大堆,需要调用AdjustUp函数;
2.再让第三个元素和根节点构成大堆,
3.依次类推,直到得到最终的大堆。
向下调整:
1.从倒数第一个非叶子,也就是最后一个节点的父亲开始,如果不是大堆,则调整,需要调用AdjustDown函数。
2.再找到1中所述父亲节点的前一个节点(即图中的49),由于49大于23和46,是大堆,则无需调整。
3.继续找到2中父亲节点的前一个节点(即图中的19),继续调整,以此类推,直到得到最终的大堆。
那么问题来了,既然向上调整和向下调整都可以构成大堆,那么这两种方法有无有劣之分呢?有的!
接下来,从时间复杂度来看一下这两种方法:
向下调整的时间复杂度:
?可以看出,向下调整的时间复杂度比O(N)还小,认为是O(N)。
向上调整的时间复杂度:
?可以看出,把不重要的去掉,时间复杂度是N*logN。
那么,向下调整和向上调整的时间复杂度不一样的本质在哪里呢?
向下调整:节点多的层下移次数少,节点少的层下移次数多。
向上调整:节点多的层下移次数多,节点少的层下移次数少。
2)利用堆删除项进行排序
在N个数里面找到最大的前K个(N远大于K),这里有两个思路:
思路一
N个数插入到大堆里面,Pop K次,时间复杂度为N*logN+K*logN -> O(N*logN)。
N很大很大,假设N是100亿,K是10,100亿个整数需要的空间为400亿字节≈40G左右,这未免有些夸张~因此,思路一的问题是内存不够。
思路二
1.读取前K个值,建立K个数的小堆
2.依次再读取后面的值,跟堆顶比较,如果比堆顶大,则替换堆顶进堆(替换堆顶值后,再向下调整)
时间复杂度:O(N*logK)
那么,实现一下思路二:
首先,我们先需要新建一个含有10000000个数据的文件,每个数据不大于100000000:
void CreateNDate()
{
int n = 10000000;
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (int i = 0; i < n; i++)
{
int x = (rand() + i) % 100000000;
fprintf(fin,"%d\n",x);
}
fclose(fin);
}
然后,更改里面的5个数,让这五个数大于10000000,需要选出这个5个数:
void PrintTopK(const char* file, int k)
{
FILE* fin = fopen(file, "r");
if (fin == NULL)
{
perror("fopen error");
return;
}
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
perror("malloc fail");
return;
}
int i = 0;
for (i = 0; i < k; i++)
{
fscanf(fin, "%d", &minheap[i]);
AdjustUp(minheap, i);
}
int x = 0;
while (fscanf(fin, "%d", &x) != EOF)
{
if (x > minheap[0])
{
minheap[0] = x;
AdjustDown(minheap, k, 0);
}
}
for (i = 0; i < k; i++)
{
printf("%d ",minheap[i]);
}
free(minheap);
fclose(fin);
}
int main()
{
//CreateNDate();
PrintTopK("data.txt", 5);
return 0;
}
选出了top5,任务完成!
现在,我们换一个角度来看待二叉树:任何一棵二叉树可以拆解成三个部分:根、左子树、右子树。
?在学习二叉树的基本操作之前,需要先创建一棵二叉树,但是现在还没有深入学习二叉树,因此,暂时手动创建一棵二叉树。
实现代码如下:
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}TreeNode;
TreeNode* BuyTreeNode(BTDataType x)
{
TreeNode* p = (TreeNode*)malloc(sizeof(TreeNode));
if (p == NULL)
{
perror("malloc fail");
return;
}
p->data = x;
p->left = NULL;
p->right = NULL;
return p;
}
TreeNode* CreateNode()
{
TreeNode* node1 = BuyTreeNode(1);
TreeNode* node2 = BuyTreeNode(2);
TreeNode* node3 = BuyTreeNode(3);
TreeNode* node4 = BuyTreeNode(4);
TreeNode* node5 = BuyTreeNode(5);
TreeNode* node6 = BuyTreeNode(6);
TreeNode* node7 = BuyTreeNode(7);
node1->left = node2;
node2->left = node3;
node1->right = node4;
node4->left = node5;
node4->right = node6;
node5->left = node7;
return node1;
}
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:
1.前序遍历:访问根结点的操作发生在遍历其左右子树之前(根-左子树-右子树)。
2.中序遍历:访问根结点的操作发生在遍历其左右子树之中(间)(左子树-根-右子树)。
3.后序遍历:访问根结点的操作发生在遍历其左右子树之后(左子树-右子树-根)。
现在,我们以一个例子为例,来具体看一下二叉树这三种遍历的结果。
以这棵二叉树为例,
前序遍历:
前序遍历实现代码:?
void PrevOrder(TreeNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
printf("%d ", root->data);
PrevOrder(root->left);
PrevOrder(root->right);
}
中序遍历:
?中序遍历实现代码:
void InOrder(TreeNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
InOrder(root->left);
printf("%d ", root->data);
InOrder(root->right);
}
后序遍历:
?后序遍历实现代码:
void PostOrder(TreeNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->data);
}
设二叉树的根节点所在层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第二层上的节点,接着是第三层的节点,以此类推,自上而下,从左向右,逐层访问数的节点的过程就是层序遍历。
我们要借助栈来实现层序遍历,栈中每个元素是:
typedef struct BinaryTreeNode* QDataType;
层序遍历的实现代码:
void LevelOrder(TreeNode* root)
{
Quene q;
QueneInit(&q);
if (root)
QuenePush(&q,root);
int levelsize = 1;
while (!QueneEmpty(&q))
{
while (levelsize--)
{
TreeNode* front = QueneFront(&q);
QuenePop(&q);
printf("%d ", front->data);
if (front->left)
QuenePush(&q, front->left);
if (front->right)
QuenePush(&q, front->right);
}
levelsize = QueneSize(&q);
printf("\n");
}
printf("\n");
QueneDestroy(&q);
}
层序遍历的思想是,先把二叉树第一层结点的指针压入栈中,用front记录栈顶元素,然后pop出栈顶元素,再用之前记录的front打印出第一层结点元素,接下来把第一层结点的左右结点压栈,再次用front记录栈顶元素,然后pop出栈顶元素,依次类推。其中,levelsize用来记录某层结点个数,用于控制换行。
学习完层序遍历后,我们可以利用层序遍历来判断是否为完全二叉树,实现代码如下:
bool BinaryTreeComplete(TreeNode* root)
{
Quene q;
QueneInit(&q);
if (root)
QuenePush(&q, root);
while (!QueneEmpty(&q))
{
TreeNode* front = QueneFront(&q);
QuenePop(&q);
if (front == NULL)
break;
QuenePush(&q, front->left);
QuenePush(&q, front->right);
}
//遇到空以后,如果后面还有非空就不是完全二叉树
while (!QueneEmpty(&q))
{
TreeNode* front = QueneFront(&q);
QuenePop(&q);
if (front)
return false;
}
QueneDestroy(&q);
return true;
}
?这段代码的意思是,如果遇到空以后,后面还有非空,就不是完全二叉树。
在计算二叉树结点个数时,往往利用递归实现,递归需要找到:1.子问题分治2.返回条件。
使用递归计算二叉树节点个数,子问题分治:左子树节点个数+右子树节点个数+1,返回条件:节点为空,返回0。
int TreeSize(TreeNode* root)
{
return root == NULL ? 0 : TreeSize(root->left) + TreeSize(root->right) + 1;
}
使用递归计算二叉树叶子节点个数,子问题分治:左子树叶子节点个数+右子树叶子节点个数,返回条件:节点为空,返回0;叶子,返回1。
int TreeLeafSize(TreeNode* root)
{
if (root == NULL)
{
return 0;
}
if (!root->left && !root->right)
{
return 1;
}
return TreeLeafSize(root->left) + TreeLeafSize(root->right);
}
使用递归计算二叉树高度,子问题分治:左子树高度和右子树高度中大的那一个+1,返回条件:节点为空,返回0。
int TreeHeight(TreeNode* root)
{
if (root == NULL)
{
return 0;
}
int left_height = TreeHeight(root->left);
int right_height = TreeHeight(root->right);
return left_height > right_height ?
left_height + 1 : right_height + 1;
}
但是,在计算二叉树高度时,有同学可能会这样写:
int TreeHeight(TreeNode* root)
{
if (root == NULL)
{
return 0;
}
return TreeHeight(root->left) > TreeHeight(root->right) ?
TreeHeight(root->left) + 1 : TreeHeight(root->right) + 1;
}
这样写其实问题很大,原因在于当比较出TreeHeight(root->left)和TreeHeight(root->right)大小时,为了得出TreeHeight(root->left) + 1或者TreeHeight(root->right) + 1需要再次计算TreeHeight(root->left)或TreeHeight(root->right),这就造成极大的计算浪费,效率低下!
使用递归计算二叉树第k层节点个数,子问题分治:
1.空,返回0;
2.不为空且k==1,返回1;
3.不为空且k>1,返回左子树的k-1层+右子树的k-1层。
int TreeLevelK(TreeNode* root, int k)
{
assert(k >= 1);
if (root == NULL)
{
return 0;
}
if (k == 1)
{
return 1;
}
return TreeLevelK(root->left, k - 1) + TreeLevelK(root->right, k - 1);
}
?使用递归查找二叉树值为x的节点,子问题分治:
1.空,返回NULL
2.当前结点值等于x,返回x的地址
3.不为空且当前结点值不等于x,先找其左子树,再找其右子树
TreeNode* TreeFind(TreeNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
//判断当前结点
if (root->data == x)
return root;
//左子树
TreeNode* ret1 = TreeFind(root->left, x);
if (ret1)
return ret1;
//右子树
TreeNode* ret2 = TreeFind(root->right, x);
if (ret2)
return ret2;
return NULL;
}
在二叉树链式结构这部分刚开始的这一节,我们当时还不太熟悉二叉树,但是为了之后二叉树的一系列计算,我们手搓得到了一棵二叉树。那么现在,我们要正式创建一棵二叉树。
通过前序遍历的数组构建二叉树
我们通过前序遍历的数组"ABD##E#H##CF##G##"构建二叉树:
TreeNode* TreeCreate(char* a,int* pi)
{
if (a[*pi] == "#")
{
(*pi)++;
return NULL;
}
TreeNode* root = (TreeNode*)malloc(sizeof(TreeNode));
if (root == NULL)
{
perror("malloc fail");
exit(-1);
}
root->data = a[(*pi)++];
root->left = TreeCreate(a, pi);
root->right = TreeCreate(a, pi);
return root;
}
在这个创建二叉树接口中,参数pi表示数组下标的地址,这是由于我们要通过递归创建二叉树,通过使用数组下标的地址最好。
二叉树销毁
在销毁二叉树时,应该采用后序遍历,即先把左子树和右子树先销毁,再把根节点销毁。如果先把根节点销毁了,就找不到其左子树和右子树了。
void DestoryTree(TreeNode* root)
{
if (root == NULL)
return;
DestoryTree(root->left);
DestoryTree(root->right);
free(root);
}