二叉树与堆排序(概念|遍历|实现)
前言
在计算机科学中,二叉树和堆排序是两个重要的概念,它们在数据结构与算法领域占据着核心地位。
二叉树作为一种基础的数据结构,具有层次分明的特性,能够高效地支持查找、插入和删除等操作。
而堆排序则是一种基于二叉树的高效排序算法,利用完全二叉树的特性,通过构建大顶堆或小顶堆来实现数据的快速排序。
当然它们的作用远不止这些,想要了解更多就往下看吧。
二叉树属于树的一种,这里我们得先了解一下树的相关概念:
一,树
1.1树的概念
树是一种数据结构,由于它里面数据的连接关系,它是非线性的。因它的示意图看起来像一颗倒挂的树而得名。
拓展一下线性与非线性的区分以及用途:
- 线性: 数据元素之间存在一对一的顺序关系,通常以线性或顺序的方式排列。
- 非线性:数据元素之间不按顺序排列,而是通过层次或复杂的关系连接。
- 用途: 线性结构适合简单数据存储和操作,而非线性结构则更适合表示复杂关系和层次结构。
1.2树的相关概念
部分概念示意图如下:
前驱节点:某结点可以由另一个结点找到,则该另一个节点称为前驱结点,对于单个结点它如果它有前驱结点那么有且仅有一个前驱结点;
根结点:树的起始结点,一颗树仅有一个根结点,根节点无前驱节点;
结点的度:一个结点含有子树的个数,即子结点的个数;
叶结点(或终端结点):度为 0 的结点,即没有子结点;
父结点:如果一个结点有子结点,则该结点就是这个子结点的父结点,仅上下层互称;
子结点:上下两层且有链接关系,下层的结点称为子结点,仅上下层互称;
兄弟结点:具有相同的父节点的结点的互称;
树的度:在树中,结点的最大的度即树的度;
结点的层次:从根开始定义起,根为第 1 层,根的子结点为第 2 层,以此类推;
树的高度或深度:树中结点的最大层次;
堂兄弟结点:父节点在同一层的结点的互称;
结点的祖先:从根到该结点所经分支上的所有结点;
子孙:低层的结点都是高层的子孙(相对而言),以某结点为根的子树中任⼀结点都称为该结点的子孙;
森林:由 m ( m>0 ) 棵互不相交的树的集合称为森林。
注意:
- 二叉树可以分为多颗子树(也是二叉树),即可以有多个根节点。
- 当节点数为0时,树为空树;当节点数大于等于1时,树由一个根节点和若干个互不相交的子集组成,每个子集本身又是一棵树,因此树是递归定义的。
那么如何分辨树呢?
- 在树形结构中,同等级子树之间不能相交(相交称为图),否则就不是树形结构。
- 除根节点之外,单个结点仅有一个前驱结点。
- 在树中,n 个结点有 n-1 条边。
1.3树的表示
对于树的代码表示,我们既要保存值域,也要保存结点和结点之间的关系,实际中树有很多种表方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。
其中,孩子兄弟表示法最常用:
关键:由父向子,由左向右找对象。
示意图:
代码:
typedef int DataType;
struct TreeNode
{struct TreeNode*Child;//左边第一个孩子struct TreeNode*Brother;//指向下一个兄弟结点DataType data;//结点中的数据域(存储的数据)
};
1.4树的实际运用
文件系统是计算机存储和管理文件的⼀种方式,它利用树形结构来组织和管理文件和文件夹。在文件系统中,树结构被广泛应用,它通过父结点和子结点之间的关系来表示不同层级的文件和文件夹之间的关联。
了解完树,我们进入二叉树
二,二叉树
2.1二叉树的概念
在二叉树中,对于一个节点,它最多有两个子结点即“二叉”。
一颗二叉树是结点的有限集合,在其中要么为空,要么由一个根节点加上两颗分别称为左子树和右子树的二叉树组成。
注意:对于一颗二叉树:
- 其中结点的度必定小于等于2(0,1,2);
- 它的子树有左右顺序之分,不可颠倒,二叉树属于有序树。
2.2特殊的二叉树
- 满二叉树,其中每个节点的度都为 2(每层结点数达到最大值),即对于满二叉树有 k 层,则它的结点总数为 2^k-1 ,第 k 层节点数为 2^(k-1) ,满二叉树是一种特殊的完全二叉树。
- 完全二叉树,对于深度为 K 的,有 n 个 结点的二叉树,当且仅当其每⼀个结点都与深度为 K 的满二叉树中编号从 1 至 n 的结点一一对应不间断时称之为完全二叉树。简而言之,除了最后一层,其他层都是满的,而且最后一层的结点都必须靠左排列。(三种情况:有左无右,都无,有左有右)
示意图:
各种树之间的关系:
2.3二叉树的性质
根据满⼆叉树的特点可知:
1)若规定根结点的层数为 1 ,则一棵非空二叉树的第 i 层上最多有 个结点;
2)若规定根结点的层数为 1 ,则深度为 h 的二叉树的最大结点数是 ;
3)若规定根结点的层数为 1 ,具有 n 个结点的满二叉树的深度 。
2.4二叉树的存储结构
二叉树有两种存储结构,一个是用数组存储称为顺序结构,另一个用链表存储称为链式结构。
下面依次讲解;
2.4.1顺序结构
使用顺序结构存储的对象一般是完全二叉树,因为非完全二叉树用数组存储时,会造成空间的浪费,二叉树的顺序顺序存储在物理上是一个数组,而在逻辑上是一颗二叉树。
下面,我们用堆来实现二叉树的顺序结构。
注意,这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
在了解结构的实现之前我们得先了解堆的相关概念:
什么是堆?
堆是一种完全二叉树,如果将一颗完全二叉树按照存储的数据父节点永远比子结点大(或等于),且子节点永远比它的子节点大(或等于)的逻辑结构顺序排列,那么这课二叉树就叫做大堆(或最大堆、大根堆),反之叫做小堆(或者最小堆、小根堆)。
在一个堆中其中任一结点的满足 "每个结点的值 >= ( or <= ) 其子树中每个的值",任意一个堆都满足上文的局部有序性,如果不满足则不称为堆。
我们还需了解一些编号相关规律:
对于具有 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,则无右孩子。
2.4.2堆的实现
1,创建
创建堆
//堆的结构(假设为小堆)
typedef int HPDataType;
typedef struct HP
{HPDataType* arr;//(为啥是*,动态数组)int size;//有效数据个数int capasity;//容量
}HP;
2,初始化
对堆内相关对象初始化,目的是确保堆处于 干净、可控的状态,避免未定义行为,并为后续操作做好准备。
void HPInit(HP*php)
{assert(php);//判断传来地址是否为空php->arr = NULL;php->capasity = php->size = 0;
}
3,销毁
归还空间,数据归零
void HPDestory(HP* php)
{if (php->arr){free(php->arr);}php->arr = NULL;php->capasity = php->size = 0;
}
4,调整算法
调整数据,让其符合堆的局部有序性
依据上面👆讲到的编号相关规律进行孩子与父母的位置调整
向上:
//交换
void Swap(int* x, int* y)
{int tmp = *x;*x = *y;*y = tmp;
}
//向上调整
void Adjustup(HPDataType* arr, int child)
{int parent = (child - 1) / 2;while (child > 0){if (arr[child] < arr[parent])//孩子小就交换说明是小堆{Swap(&arr[child], &arr[parent]);child = parent;parent = (child - 1) / 2;}else{break;}}
}
向下(找父亲,找到儿子并比较):
//交换
void Swap(int* x, int* y)
{int tmp = *x;*x = *y;*y = tmp;
}
//向下调整
void Adjustdown(HPDataType* arr, int parent, int n)
{int child = parent * 2 + 1;while (child < n)//结束循环条件:child越界/父亲比孩子小{//找最小孩子if (child + 1 < n && arr[child] > arr[child + 1])//注意child越界{child++;}if (arr[parent] > arr[child]){Swap(&arr[child], &arr[parent]);parent = child;child = parent * 2 + 1;}else{break;}}
}
5,插入
判断容量,扩容,插入尾部
//插入
void HPPush(HP* php, HPDataType x)
{assert(php);//判断是否为空,为空增容if (php->size == php->capasity){int newcapacity = php->capasity == 0 ? 4 : 2 * php->capasity;HPDataType* tmp = (HPDataType*)realloc(php->arr, sizeof(HPDataType) * newcapacity);if (tmp == NULL){perror("realloc fail!\n");exit(1);}php->arr = tmp;php->capasity = newcapacity;}php->arr[php->size] = x;//向上调整Adjustup(php->arr, php->size);php->size++;
}
6,删除
交换顶与底,size--后默认已经不见原来堆顶数据,删除堆顶数据
//判空
bool HPEmpty(HP* php)
{assert(php);return php->size==0;//返回的是真假,如果size为 0 返回 true。
}
//删除默认删除堆顶
void HPPop(HP*php)
{assert(!HPEmpty(php));Swap(&php->arr[0], &php->arr[php->size - 1]);//交换堆顶与堆底php->size--;//向下调整Adjustdown(php->arr, 0, php->size);
}
7,取堆顶
搭配删除,打印操作可实现有序打印。
//取堆顶(大堆输出由大到小,小堆反之)
HPDataType HPTop(HP* php)
{assert(!HPEmpty(php));return php->arr[0];
}
8,完整代码
头文件:
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>//堆的结构(假设为小堆)
typedef int HPDataType;
typedef struct HP
{HPDataType* arr;//(为啥是*,动态数组吗)int size;//有效数据个数int capasity;//容量
}HP;//初始化
void HPInit(HP*php);
//销毁
void HPDestory(HP*php);
//插入
void HPPush(HP* php,HPDataType x);
//删除
void HPPop(HP* php);
源文件:
#include"heap.h"
//初始化
void HPInit(HP*php)
{assert(php);//判断传来地址是否为空php->arr = NULL;php->capasity = php->size = 0;
}
//销毁
void HPDestory(HP* php)
{if (php->arr){free(php->arr);}php->arr = NULL;php->capasity = php->size = 0;
}
//交换
void Swap(int* x, int* y)
{int tmp = *x;*x = *y;*y = tmp;
}
//向上调整
void Adjustup(HPDataType* arr, int child)
{int parent = (child - 1) / 2;while (child > 0){if (arr[child] < arr[parent])//孩子小就交换说明是小堆{Swap(&arr[child], &arr[parent]);child = parent;parent = (child - 1) / 2;}else{break;}}
}
//插入
void HPPush(HP* php, HPDataType x)
{assert(php);//判断是否为空,为空增容if (php->size == php->capasity){int newcapacity = php->capasity == 0 ? 4 : 2 * php->capasity;HPDataType* tmp = (HPDataType*)realloc(php->arr, sizeof(HPDataType) * newcapacity);if (tmp == NULL){perror("realloc fail!\n");exit(1);}php->arr = tmp;php->capasity = newcapacity;}php->arr[php->size] = x;//向上调整Adjustup(php->arr, php->size);php->size++;
}
//判空
bool HPEmpty(HP* php)
{assert(php);return php->size==0;
}int HPSize(HP* php)
{assert(php);return php->size;
}
//向下调整
void Adjustdown(HPDataType* arr, int parent, int n)
{int child = parent * 2 + 1;while (child<n){//找最小孩子if (child + 1 < n && arr[child] > arr[child + 1])//注意越界{child++;}if (arr[parent] > arr[child]){Swap(&arr[child], &arr[parent]);parent = child;child = parent * 2 + 1;}else{break;}}
}
//删除默认堆顶
void HPPop(HP*php)
{assert(!HPEmpty(php));Swap(&php->arr[0], &php->arr[php->size - 1]);php->size--;//向下调整Adjustdown(php->arr, 0, php->size);
}
2.4.3堆的应用
1,堆排序
所谓堆排序其实并不是用堆这个数据结构来排序,而是利用堆的特性与调整算法思想来实现排序效果。
使用 向下 调整算法:
建堆:不使用堆这个数据结构,而是使用堆的向下调整算法;得到一个数组,先对数组进行调整,直至符合堆的局部有序性。
输出:堆顶与底部换位,向下调整,循环往复,直至排到最后一个数据,此时数组有序(调整为小堆则数组由大到小即降序,调整为大堆则数组为升序)
//向下调整算法
void Adjustdown(HPDataType* arr, int parent, int n)
{int child = parent * 2 + 1;while (child<n){//找最小孩子if (child + 1 < n && arr[child] > arr[child + 1])//注意越界{child++;}if (arr[parent] > arr[child]){Swap(&arr[child], &arr[parent]);parent = child;child = parent * 2 + 1;}else{break;}}
}
//堆排序
void HPSort(HPDataType* arr, int n)
{//数组化堆(满足堆的特性)for (int i = (n - 1 - 1) / 2;i>=0;i--){Adjustdown(arr, i, n);}int end = n - 1;//输出排序while (end > 0){Swap(&arr[0], &arr[end]);//首尾交换Adjustdown(arr, 0, end);end--;}
}
向下调整算法的时间复杂度:最差情况为 ,所以该堆排序的时间复杂度为
。
使用 向上 调整算法:
注意,向上调整算法的一些特别之处:参数只有数组和子结点,所以它不能一口气调整整个数组,需要依次输入数据调整;向下调整则是对整个数组进行整体调整(对于复杂度计算有关)。
void HPSort1(HPDataType* arr, int n)
{for (int i = 0; i<n; i++){Adjustup(arr, i);//child依次由孩子变为父亲再变为爷爷,依此类推}int end = n - 1;//输出排序while (end > 0){Swap(&arr[0], &arr[end]);//首尾交换Adjustdown(arr, 0, end);//依旧不变,但调整目标要一致end--;}
}
向上调整复杂度为 ,总共为
。
既然调衡算法复杂度相同,两者建堆复杂度如何呢?
对于向下调整算法建堆:
第1层, 2^0 个结点,交换到根结点后,需要向下 移动0层;
第2层, 2^1 个结点,交换到根结点后,需要向下 移动1层;
第3层, 2^2 个结点,交换到根结点后,需要向下 移动2层;
第4层, 2^3 个结点,交换到根结点后,需要向下 移动3层;
第h层, 2^(h-1) 个结点,交换到根结点后,需要向下 移动h-1层。
对于向上调整算法建堆:
第1层, 2^0 个结点,交换到根结点后,需要向上 移动0层;
第2层, 2^1 个结点,交换到根结点后,需要向上 移动1层;
第3层, 2^2 个结点,交换到根结点后,需要向上 移动2层;
第4层, 2^3 个结点,交换到根结点后,需要向上 移动3层;
第h层, 2^(h-1) 个结点,交换到根结点后,需要向上 移动h-1层。
向下调整建堆,调整次数越多的数据个数越少,向上调整恰恰相反。
由相关数学计算(比差数列)得出:
向下调整建堆复杂度:;向上调整建堆复杂度:
。
2,TOPK问题
TOP-K问题:即求数据组合中前K个最大的元素或者最小的元素,⼀般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了 (可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
首先,用数据集合中前K个元素来建堆前k个最大的元素,则建小堆前k个最小的元素,则建大堆;
然后,用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
代码如下:
void CreateNDate()//这里是演示
{// 造数据int n = 100000;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) % 1000000;fprintf(fin, "%d\n", x);}fclose(fin);
}
void topk()
{printf("请输⼊k:> ");int k = 0;scanf("%d", &k);const char* file = "data.txt";FILE* fout = fopen(file, "r");if (fout == NULL){perror("fopen error");return;}int val = 0;int* minheap = (int*)malloc(sizeof(int) * k);if (minheap == NULL){perror("malloc error");return;}for (int i = 0; i < k; i++){fscanf(fout, "%d", &minheap[i]);}// 建k个数据的⼩堆 for (int i = (k - 1 - 1) / 2; i >= 0; i--){AdjustDown(minheap, k, i);}int x = 0;while (fscanf(fout, "%d", &x) != EOF){// 读取剩余数据,⽐堆顶的值⼤,就替换他进堆 if (x > minheap[0]){minheap[0] = x;AdjustDown(minheap, k, 0);}}for (int i = 0; i < k; i++){printf("%d ", minheap[i]);}fclose(fout);
}
2.4.4链式结构的实现
链式结构,即用链表为基础构建二叉树,在这里二叉树不局限于完全二叉树或满二叉树,下面我们构建该二叉树:
在逻辑上,二叉树是根节点链接其左右节点,所以用两个指针分别表示其指向的左右节点,再构建一个变量用于存储数据。
typedef int BTDataType;
typedef struct BinaryTerrNode
{BTDataType data;struct BinaryTreeNode* left;struct BinaryTreeNode* right;
}BTNode;
构建完成之后这里我们不讲插入删除。后续高阶会涉及(平衡树等等)。这里我们学习二叉树的遍历方式。
根结点的左子树和右子树分别又是由子树结点,子树结点的左子树、子树结点的右子树组成的,因此二叉树定义是递归式的,后序链式二叉树的操作中基本都是按照该概念实现的。
有三种遍历方式(按照根节点的访问操作前后顺序):
- 前序遍历 口诀:根--左--右
- 后序遍历 口诀:左--根--右
- 后序遍历 口诀:左--右--根
口诀非实际规律,仅仅便于记忆,直到最底层。
代码如下:
//前序遍历--根左右
void PreOrder(BTNode* root)
{if (root==NULL)//结束条件{printf("NULL ");return;}printf("%c ", root->data);//打印的是当前根结点PreOrder(root->left);PreOrder(root->right);
}
//中序遍历--左根右
void InOrder(BTNode* root)
{if (root == NULL){printf("NULL ");return;}InOrder(root->left);printf("%c ", root->data);//打印的是当前根结点InOrder(root->right);
}
//后序遍历--左右根
void PostOrder(BTNode* root)
{if (root == NULL){printf("NULL ");return;}PostOrder(root->left);PostOrder(root->right);printf("%c ", root->data);//打印的是当前根结点
}
下面用几个例子来熟悉一下:
求二叉树节点个数
考虑情况:
1,计数器,全局变量不可取(多次调用会混乱)size被累加,
int size = 0;
int BinaryTreeSize(BTNode* root)
{if (root == NULL){return 0;}size++;BinaryTreeSize(root->left);BinaryTreeSize(root->right);return size;
}
2,考虑加入函数参数中,函数每次运行结束会销毁,尝试传地址,同样size会累加。
void BinaryTreeSize(BTNode* root,int*psize)
{if (root == NULL){return 0;}++(*(psize));BinaryTreeSize(root->left,psize);BinaryTreeSize(root->right,psize);
}
3,不用计数器,用 return 返回中相加,即 1 + 左子树中结点个数 + 右子树中结点的个数。
int BinaryTreeSize(BTNode* root)
{if (root == NULL){return 0;}return 1 + BinaryTreeSize(root->left) + BinaryTreeSize(root->right);
}
求叶子节点个数
思路同上:
int BinaryTreeLeafSize(BTNode* root)
{if (root == NULL){return 0;}if (root->left == NULL && root->right == NULL){return 1;}return BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right);
}
求第K层结点个数
思路同上
int BinaryTreeLevelKSize(BTNode* root, int k)
{if (root == NULL){return 0;}if (k == 1){return 1;}return BinaryTreeLevelKSize(root->left, k - 1) + BinaryTreeLevelKSize(root->right, k - 1);
}
求二叉树的层次
依旧遍历,取最大值。
int BinaryTreeDepth(BTNode* root)
{if (root == NULL){return 0;}int leftDep = BinaryTreeDepth(root->left);int rightDep = BinaryTreeDepth(root->right);return 1 + (leftDep > rightDep ? leftDep : rightDep);
}