目录
一.树
1.1概念
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因 为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
特点:1.树有一个特殊的结点,称为根结点,由它来进行树的分支,每棵树有且只有一个根节点(它是树的开始)
2.除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继。
3.子树(根节点的孩子或者它的孩子的孩子)之间不能交集
1.2关于树的相关命名
节点的度:一个节点含有的子树的个数称为该节点的度
叶节点:度为0的节点称为叶节点
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点
层次(高度):根为第1层(高度为1),下一层为第二层(高度为2),以此类推......
节点的祖先:从根到该节点所经分支上的所有节点
堂兄弟节点:双亲在同一层的节点互为堂兄弟
1.3树在实际的应用(表示文件系统的目录树结构)
二.二叉树
2.1概念
一棵二叉树是结点的一个有限集合,由一个根节点加上两棵别称为左子树和右子树的二叉树组成
2.2特点
1. 二叉树不存在度大于2的结点
2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
它有以下这几种情况
2.3特殊的二叉树
满二叉树:每一个层的结点数都达到最大值(两个).
完全二叉树:对于深度为K 的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对 应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
2.4二叉树的性质
若规定根节点的层数为1,则一棵满二叉树的第i层上有2^(i-1)个结点,总共节点个数为:2^i-1
假设有N个节点,则i=log2(N+1)
而完全二叉树的总节点的范围:[2^(i-1),2^i-1]
假设有N个节点,层数的范围:[log2(N)+1,log2(N+1)]
对任何一棵二叉树, 如果度为0其叶结点个数为 N0, 度为2的分支结点个数为N2 ,则有 N0= N2+1
如果二叉树存储在数组里,通过子节点n(n>0)找到双亲节点,则双亲节点为:(n-1)/2
通过双亲节点n(n>0)找到左(右)节点,则左(右)节点为:n*2+1(n*2+2)
2.5二叉树的存储
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
1. 顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空 间的浪费。而现实中使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
2. 链式存储
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是 链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。
三.二叉树的顺序存储
3.1.堆
如果有一个关键码的集合K = { , , ,…, },把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足: = 且 >= ) i = 0,1, 2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。(不一定是降序(大堆)或者升序(小堆)!!)
堆的性质: 堆中某个节点的值总是不大于或不小于其父节点的值; 堆总是一棵完全二叉树。
3.2堆的实现(以小堆为例子)
3.2.1数组调整为堆
先随机给出一个数组
int a[] = {27,15,19,18,28,34,65,49,25,37};
如何把它调整为堆呢?
先在内存里开辟一段数组内存,在将a里的数组一个一个的插到开辟的数组中,在插入的过程,我们需要学习一个向上调整算法
在插入的位置(孩子节点)i找它的双亲节点(i-1)/2,与它进行比较,如果比它小,就进行两者的交换,更新孩子节点与它的双亲节点,再依次往上进行比较,值到跟根节点比较完后结束
//交换两个数
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向上调整
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;
}
}
}
3.2.2向上调整的时间复杂度
O(N)=N*logN
3.2.3堆的删除
思路:
将数组建成堆后,我们要堆里的数据进行删除,那么怎么删除呢?
有人会说,一个一个删除数据size--不就可以实现了吗?
那如果我限定在第一个删除呢?
删除第一个数也就是根节点,删除后保持数组还是堆。size--可就行不通了。
3.3.4向下调整算法
这就得将第一个与最后一个交换,用向下调整算法
向下调整:从根节点出发,找到它的左孩子有右孩子中最小的那个(15),与它进行比较,比它大就进行两者的交换,在往下找,直到没找到比它(28)下的数就停止
//向下调整
void AdjustDown(HPDataType*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;
}
}
}
时间复杂度计算:
O(N)=N
以下是完整的堆的实现完整代码
//堆的初始化
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->capacity = 0;
php->size = 0;
}
//堆的销毁
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->capacity = 0;
php->size = 0;
}
//交换两个数
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向上调整
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;
}
}
}
//向下调整
void AdjustDown(HPDataType*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;
}
}
}
//建小堆
void HeapPush(HP* php, HPDataType x)
{
assert(php);
//有值没内存越界判断
if (php->capacity == php->size)
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
//是realloc 不是malloc
HPDataType* p = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));
if (p == NULL)
{
perror("realloc");
exit(-1);
}
php->a = p;
php->capacity = newcapacity;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a,php->size-1);
}
//删除堆的一个数
void HeapPop(HP* php)
{
assert(php);
//有值才pop
assert(php->size > 0);
int end = php->size - 1;
Swap(&php->a[0], &php->a[end]);
php->size--;
AdjustDown(php->a, php->size,0);
}
HPDataType HeapTop(HP* php)
{
assert(php);
//不为空
assert(php->size>0);
return php->a[0];
}
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
size_t HeapSize(HP* php)
{
assert(php);
return php->size;
}
//测试数据
int main()
{
int a[] = {4,6,2,1,5,8,2,9};
HP heap;
HeapInit(&heap);
//排好的小堆
for (int i = 0; i < 7; i++)
{
HeapPush(&heap, a[i]);
}
while (!HeapEmpty(&heap))
{
printf("%d ", HeapTop(&heap));
HeapPop(&heap);
}
HeapDestroy(&heap);
return 0;
}
3.3堆的应用
3.3.1堆排序
在上面的堆的实现中,我们是在通过向内存申请空间,将数组的值一个一个插入实现的。那么,可不可以不开空间,直接在数组里排序呢,答案是可以的。
从数组的第二个开始,往上进行调整为堆
删除数据与堆的删除相同
void HeapSort(int* a, int n)
{
//建小堆
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
如果用这种来实现的话,要实现它要写两个函数来解决,有没有什么更好的方式呢?
3.3.1.1优化
在数组里找到它的最后的双亲节点,与最小(建小堆)的的子节点交换,然后向下调整,达到目的
void HeapSort(int* a, int n)
{
//小堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a,n,i);
}
int end = n - 1;
while (end>0)
{
Swap(&a[0], &a[end]);
AdjustDown(a,end,0);
end--;
}
}
所以在以后的堆排序中,用一个向下调整就能达到目的
补充:建堆是的向下与向上调整虽然O(N)不同,但整个堆排序的的O(N)=N*logN是相同的,因为while循环本质上是向上调整(最后一层的叶节点是最多的,与根节点交换后再调整,调整的次数是最多的,与向上调整算时间复杂度的时候,最后一层的叶节点最多,调整的次数最多!!!)
3.3.2TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能 数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决。
基本思路如下:o(N)=N*logK
1.求前k个最大(小)的元素,用数据的k个元素建小堆(大堆)
2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素,将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。-->O(N)=(N-K)logK)
举例:在文件里找前k个元素
先在文件里随机造出10000个数据(可能有重叠)
void CreateNDate()
{
// 造数据
int n = 10000;
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) % 10000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
在里面找出最大的前K个元素
//求最大的前k个
void PrintTopK(int k)
{
FILE* fout = fopen("data.txt", "r");
if (fout == NULL)
{
perror("fopen");
return;
}
int* topk =(int*)malloc(sizeof(int)*k);
if (topk == NULL)
{
perror("malloc");
return;
}
//建小堆
for (int i = 0; i < k; i++)
{
fscanf(fout,"%d",&topk[i]);
AdjustUp(topk, i);
}
//插比topk[0]大的数并替换它
int x = 0;
while (fscanf(fout,"%d", &x) != EOF)
{
if (x > topk[0])
{
topk[0] = x;
AdjustDown(topk, k, 0);
}
}
for (int i = 0; i < k; i++)
{
printf("%d ", topk[i]);
}
free(topk);
fclose(fout);
}
//测试代码,可在文件里更改数据成为最大值验证
int main()
{
//CreateNDate();
PrintTopK(5);
return 0;
}
总结:堆排序算法在后面的排序里与其它排序(希尔排序,快速排序)里进行比较,有优点有缺点,值得我们去理解学习它掌握堆排序
四.二叉树的链式存储
1.概念
在学习之前,先来回顾二叉树的组成
1. 空树
2. 非空:根节点,根节点的左子树、根节点的右子树组成的。
从中可看出,二叉树是递归的
2.二叉树的遍历
学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉 树中的节点进行相应的操作,并且每个节点只操作一次。
按照规则,二叉树可分为三种遍历方式
1. 前序遍历(Preorder Traversal )——访问根结点的操作发生在遍历其左右子树之前。
2. 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
3. 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。
三种遍历的写法
2.1构建二叉树
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}TreeNode;
TreeNode* BuyTreeNode(int x)
{
TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
assert(node);
node->data = x;
node->left = NULL;
node->right = NULL;
return node;
}
TreeNode* CreateTree()
{
TreeNode* node1 = BuyTreeNode(1);
TreeNode* node2 = BuyTreeNode(2);
TreeNode* node3 = BuyTreeNode(3);
TreeNode* node4 = BuyTreeNode(4);
TreeNode* node5 = BuyTreeNode(5);
TreeNode* node7 = BuyTreeNode(7);
//连接节点
node1->left = node2;
node1->right = node4;
node2->left = node3;
node4->left = node5;
node4->right = node7;
return node1;
}
2.2前序遍历
在用代码将前序遍历的结果打印出来需要用到递归(分治思想)打印的顺序是根 左子树 右子树
void PrevOrder(TreeNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
printf("%d ", root->data);
PrevOrder(root->left);
PrevOrder(root->right);
}
下面结合图来理解前序遍历的代码
2.3中序遍历
中序遍历与前序类似,但它的顺序是;左子树 根 右子树,意味着在写代码的时候打印位置要考虑到位
void InOrder(TreeNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
InOrder(root->left);
printf("%c ", root->data);
InOrder(root->right);
}
2.4后序遍历
后序遍历打印的顺序:左子树 右子树 根
void AfterOrder(TreeNode* root)
{
if (root == NULL)
{
printf("N ");
return;
}
AfterOrder(root->left);
AfterOrder(root->right);
printf("%d ", root->data);
}
2.5层序遍历
除了前序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。
设二叉树的根节点所在 层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第2层 上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。
那么,怎么来实现层序遍历呢?
我们可以借助队列(先入先出)的特点来实现,先进A,A出来后就把A的左节点(B)和右节点(C)入到队列里面,B出来又左节点(D)和右节点(NULL)入到队列里....直到队列为空,则层序遍历就完成了
用C语言写的时候得自己写出队列实现的各种函数再进行层序遍历
如果是以前有写过队列的实现,那么直接cv到现有项中,再进行头文件引用
void TreeLevelOrder(TreeNode* root)
{
qu q;
quinit(&q);
if (root!=NULL)
{
//存树的节点
qupushbuck(&q,root);
}
while (!quempty(&q))
{
TreeNode* tmp = quFront(&q);
qupopfron(&q);
printf("%d", tmp->data);
if (tmp->left!=NULL)
{
qupushbuck(&q, tmp->left);
}
if (tmp->right!=NULL)
{
qupushbuck(&q, tmp->right);
}
}
//队列用完destory
qudestory(&q);
}
2.5.1优化
如果我们想让打印出来的值更二叉树一样,一层有多少个值就打印多少个。
void TreeLevelOrder(TreeNode* root)
{
qu q;
quinit(&q);
if (root!=NULL)
{
//存树的节点
qupushbuck(&q,root);
}
//每层个数
int levesize = 1;
while (!quempty(&q))
{
while (levesize--)
{
TreeNode* tmp = quFront(&q);
qupopfron(&q);
printf("%c", tmp->data);
if (tmp->left!=NULL)
{
qupushbuck(&q, tmp->left);
}
if (tmp->right!=NULL)
{
qupushbuck(&q, tmp->right);
}
}
//每层打印完就换行
printf("\n");
//更新每层节点的个数
levesize = qusize(&q);
}
//队列用完destory
qudestory(&q);
}
加个局部变量levesize来控制每层打印的个数是真的妙,完美解决C存在的缺陷(队列一个保存节点,一个保存int,那么就得创建两个队列)
3.二叉树的其它计算
3.1节点总个数
方法:分治问题,把二叉树分为 左子树 右子树,左子树再分左子树和右子树......
节点总个数=左子树的总节点+右子树的总节点+根节点
int TreeSize(TreeNode* root)
{
if (root == NULL)
{
return 0;
}
return TreeSize(root->left) + TreeSize(root->right) + 1;
}
3.2叶子节点总个数
要计算叶子节点的总个数,就要考虑3中情况:
1.root为空 2.root为空但它是叶子节点 3.都不是,分治=左右叶子节点之和
int TreeLeafSize(TreeNode* root)
{
if (root == NULL)
{
return 0;
}
if (root->left == NULL && root->right == NULL)
{
return 1;
}
return TreeLeafSize(root->left) + TreeLeafSize(root->right);
}
3.3计算二叉树的高度
思路:还是用分治问题:分为左子树与右子树,找出左子树与右子树中最大的那个高度,最后再加上1(第一层根节点)
int TreeHeight(TreeNode* root)
{
if (root == NULL)
{
return 0;
}
int left = TreeHeight(root->left);
int right = TreeHeight(root->right);
if (left > right)
{
return left + 1;
}
return right + 1;
}
如果我想简洁写成下面的形式,可不可以?
int TreeHeight(TreeNode* root)
{
return root == NULL ? 0 :
TreeHeight(root->left) > TreeHeight(root->right) ?
TreeHeight(root->left) + 1 : TreeHeight(root->right) + 1;
问题:写成双目的嵌套会出现return返回时高度是多少没保存导致多次递归,有可能栈溢出
3.4第k层节点的个数
思路:分治问题,分为左子树的k-1层+右子树的k-1层。共有三种情况
1.root为空,返回0
2.root不为空且k==1 ,返回1
3.root不为空且k>1,返回左子树的k-1层+右子树的k-1层
int TreeLevelK(TreeNode* root, int k)
{
assert(k>0);
if (root == NULL)
{
return 0;
}
if (root!=NULL&&k == 1)
{
return 1;
}
return TreeLevelK(root->left, k - 1) + TreeLevelK(root->right, k - 1);
}
思考:在学这个的时候,感觉就在判断root为不为空或者是递归的层次是不是到了第k层了来记录第kc层的节点(跳过阅读)
3.5查找值为x的节点
用前序遍历,将每个节点的值与x进行判断,是就返回这个节点,不是就往下进行再进行查找
TreeNode* TreeFind(TreeNode* root, BTDataType x)
{
if (root == NULL)
return NULL;
if (root->data == x)
return root;
TreeFind(root->left,x);
TreeFind(root->right,x);
return NULL;
}
读到这里,如果把上面的代码当成确定的答案cv完就走的话,就大错特错了!!
上面的代码是很多人(包括我)在写的时候的经典错误,为什么??
让我来画图分析分析
聪明的你已经看出问题再哪了,找到3之后返回出去的是上一层调用它的函数,不是最终的函数。如果要让它返回到最终的函数,就得用一个变量来保存x,让x安全的返回。
TreeNode* TreeFind(TreeNode* root, BTDataType x)
{
if (root == NULL)
return NULL;//return;会有野指针
if (root->data == x)
return root;
//保存数据
TreeNode*l= TreeFind(root->left, x);
if (l!=NULL)
return l;
TreeNode*r= TreeFind(root->right, x);
if (r!=NULL)
return r;
return NULL;//在外层就是找不到了
}
补充:我们找到的x返回出去的是它的节点,不是x值。我们就可以修改x节点的值。
3.6判断完全二叉树
层序遍历一遍二叉树,直到遇到空节点就跳出循环,现在来判断现在的队列里有没有非空节点.画图来看清晰一点
int TreeComplete(TreeNode* root)
{
qu q;
quinit(&q);
if (root != NULL)
qupushbuck(&q, root);
while (!quempty(&q))
{
TreeNode* fron = quFront(&q);
qupopfron(&q);
//最后一层已经出去的节点已经全部出去了
if (fron == NULL)
{
break;
}
qupushbuck(&q, fron->left);
qupushbuck(&q, fron->right);
}
while (!quempty(&q))
{
TreeNode* fron = quFront(&q);
qupopfron(&q);
if (fron != NULL)
{
return false;
}
}
return true;
//队列用完destory!!
qudestory(&q);
}
五.相关oj题练习
1. 单值二叉树。965. 单值二叉树 - 力扣(LeetCode)
3. 对称二叉树。OJ链接
4.二叉树的前序遍历。 OJ链接
5.二叉树中序遍历 。OJ链接
6. 二叉树的后序遍历 。OJ链接
7.判断一颗二叉树是否是平衡二叉树。OJ链接
8.翻转二叉树。Oj链接
9.另一颗树的子树。OJ链接
10.二叉树的构建及遍历。OJ链接
总结;关于二叉树的学习一定要画图,画图,再画图!!!
1万多字的分享,求三连!