文章目录
- 二叉树的遍历
- 前序遍历
- 中序遍历
- 后序遍历
- 层次遍历
- 不用栈的二叉树中序遍历算法
- Morris
- 代码分析
- 二叉树的构造
- 概述
- 如何完成二叉树的构造
- **回顾**
- **思考**
- 各种遍历序列提供的信息
- 二叉树遍历性质
- 性质1
- 性质2
- 线索化二叉树
- 引入
- 定义
- 构造
- 堆
- 堆的定义
- 堆的性质
- 堆的建立
- 堆的元素插入(代码)
- 堆的元素删除(代码)
- 霍夫曼树(哈夫曼树)
- 来源
- 基本概念
- 1. 路径和路径长度
- 2. 结点的权及结点的带权路径长度
- 3. 树的带权路径长度
- 引出霍夫曼树(定义)
- 构造霍夫曼树
- 原则
- 如何构建即过程
- 特点
- 霍夫曼编码
- 为什么要使用霍夫曼编码
- 霍夫曼编码的特点
二叉树的遍历
前序遍历
二叉树先序遍历是指先访问根结点,再访问左子树,最后访问右子树。先序遍历的特点是每个结点都在其左右子树之前被访问,因此也称为根左右遍历。
二叉树先序遍历的应用场景有:
- 输出二叉树的结构,方便观察和调试。
- 复制二叉树,因为先序遍历可以保证每个结点在其子树之前被复制。
- 序列化和反序列化二叉树,因为先序遍历可以保证每个结点在其子树之前被存储或读取。
- 求二叉树的深度,因为先序遍历可以保证每个结点在其子树之前被计数。
// 先序遍历(递归)
void preOrder(BTree root) {
if (root == NULL) return; // 空树直接返回
printf("%c ", root->data); // 输出根结点
preOrder(root->lChild); // 递归遍历左子树
preOrder(root->rChild); // 递归遍历右子树
}
// 先序遍历(非递归)
void preOrderWithoutRecursion(BTree root) {
if (root == NULL) return; // 空树直接返回
BTree stack[100]; // 定义一个数组作为辅助栈
int top = -1; // 栈顶指针初始化为-1
BTree cur = root; // 当前结点指针初始化为根结点
while (cur != NULL || top != -1) { // 当前结点不为空或者栈不空时循环
while (cur != NULL) { // 当前结点不为空时循环
printf("%c ", cur->data); // 输出当前结点
stack[++top] = cur; // 将当前结点入栈
cur = cur->lChild; // 将当前结点更新为左孩子(继续向左走)
}
if (top != -1) { // 如果栈不空
cur = stack[top--]; // 将栈顶元素出栈并赋值给当前结点
cur = cur->rChild; // 将当前结点更新为右孩子(继续向右走)
}
}
}
中序遍历
二叉树中序遍历是指先访问左子树,再访问根结点,最后访问右子树。中序遍历的特点是每个结点都在其左右子树之间被访问,因此也称为左根右遍历。
二叉树中序遍历的应用场景有:
- 输出二叉搜索树的有序序列,因为二叉搜索树的中序遍历是升序或降序的。
- 求二叉树的第k小或第k大的元素,因为二叉搜索树的中序遍历可以按顺序找到第k个元素。
- 判断二叉搜索树是否合法,因为二叉搜索树的中序遍历应该是单调递增或递减的。
- 求二叉搜索树中两个结点的最近公共祖先,因为二叉搜索树的中序遍历可以确定两个结点的相对位置。
// 中序遍历(递归)
void inOrder(BTree root) {
if (root == NULL) return; // 空树直接返回
inOrder(root->lChild); // 递归遍历左子树
printf("%c ", root->data); // 输出根结点
inOrder(root->rChild); // 递归遍历右子树
}
// 中序遍历(非递归)
void inOrderWithoutRecursion(BTree root) {
if (root == NULL) return; // 空树直接返回
BTree stack[100]; // 定义一个数组作为辅助栈
int top = -1; // 栈顶指针初始化为-1
BTree cur = root; // 当前结点指针初始化为根结点
while (cur != NULL || top != -1) { // 当前结点不为空或者栈不空时循环
while (cur != NULL) { // 当前结点不为空时循环
stack[++top] = cur; // 将当前结点入栈
cur = cur->lChild; // 将当前结点更新为左孩子(继续向左走)
}
if (top != -1) { // 如果栈不空
cur = stack[top--]; // 将栈顶元素出栈并赋值给当前结点
printf("%c ", cur->data); // 输出当前结点
cur = cur->rChild; // 将当前结点更新为右孩子(继续向右走)
}
}
}
后序遍历
二叉树后序遍历是指先访问左子树,再访问右子树,最后访问根结点。后序遍历的特点是每个结点都在其左右子树之后被访问,因此也称为左右根遍历。
二叉树后序遍历的应用场景有:
- 删除二叉树,因为后序遍历可以保证每个结点在其子树之后被删除,避免内存泄漏。
- 求二叉树的高度,因为后序遍历可以保证每个结点在其子树之后被计算,从下往上递推高度。
- 求二叉树的平衡因子,因为后序遍历可以保证每个结点在其子树之后被计算,从下往上递推平衡因子。
- 求二叉树中两个结点的最近公共祖先,因为后序遍历可以保证每个结点在其子树之后被访问,从下往上找到第一个包含两个结点的祖先。
// 后序遍历(递归)
void postOrder(BTree root) {
if (root == NULL) return; // 空树直接返回
postOrder(root->lChild); // 递归遍历左子树
postOrder(root->rChild); // 递归遍历右子树
printf("%c ", root->data); // 输出根结点
}
// 后序遍历(非递归)
void postOrderWithoutRecursion(BTree root) {
if (root == NULL) return; // 空树直接返回
BTree stack[100]; // 定义一个数组作为辅助栈
int top = -1; // 栈顶指针初始化为-1
BTree cur = root; // 当前结点指针初始化为根结点
BTree pre = NULL; // 上一个访问过的结点指针初始化为NULL
while (cur != NULL || top != -1) { // 当前结点不为空或者栈不空时循环
while (cur != NULL) { // 当前结点不为空时循环
stack[++top] = cur; // 将当前结点入栈
cur = cur->lChild; // 将当前结点更新为左孩子(继续向左走)
}
if (top != -1) { // 如果栈不空
cur = stack[top]; // 将当前结点更新为栈顶元素(但不出栈)
if (cur->rChild == NULL || cur->rChild == pre) { // 如果当前结点没有右孩子或者右孩子已经访问过了(即当前结点的左右子树都已经访问过了)
printf("%c ", cur->data); // 输出当前结点
pre = cur; // 将上一个访问过的结点更新为当前结点
top--; // 将当前结点出栈
cur = NULL; // 将当前结点置空(继续向上回溯)
} else { // 如果当前结点有右孩子且右孩子还没有访问过了(即当前结点的左子树已经访问过了,右子树还没有访问过了)
cur = cur->rChild; // 将当前结点更新为右孩子(继续向右走)
}
}
}
}
思考
层次遍历
二叉树层次遍历是指按照从上到下、从左到右的顺序访问二叉树中的每个结点。层次遍历的特点是每个结点都在其所在层次的顺序被访问,因此也称为广度优先遍历。
二叉树层次遍历的应用场景有:
- 输出二叉树的结构,方便观察和调试。
- 求二叉树的最小深度,因为层次遍历可以保证第一个遇到的叶子结点就是最小深度所在的层。
- 求二叉树的最大宽度,因为层次遍历可以保证每一层的结点都被连续访问,从而统计每一层的宽度。
- 判断二叉树是否为完全二叉树,因为层次遍历可以保证第一个空孩子之后不应该再出现非空孩子。
// 层次遍历
void levelOrder(BTree root) {
if (root == NULL) return; // 空树直接返回
BTree queue[100]; // 定义一个数组作为辅助队列
int front = 0; // 队首指针初始化为0
int rear = 0; // 队尾指针初始化为0
queue[rear++] = root; // 将根结点入队
while (front != rear) { // 队列不空时循环
BTree cur = queue[front++]; // 将队首元素出队并赋值给当前结点
printf("%c ", cur->data); // 输出当前结点
if (cur->lChild != NULL) { // 如果当前结点有左孩子
queue[rear++] = cur->lChild; // 将左孩子入队
}
if (cur->rChild != NULL) { // 如果当前结点有右孩子
queue[rear++] = cur->rChild; // 将右孩子入队
}
}
}
不用栈的二叉树中序遍历算法
不用栈的二叉树中序遍历算法是指利用二叉树的空指针域来存放后继结点的地址,从而实现不用辅助空间的中序遍历。这种算法也称为线索化二叉树或Morris遍历算法。
不用栈的二叉树中序遍历算法的优点是节省了空间复杂度,只需要O(1)的额外空间;缺点是修改了原来的二叉树结构,虽然最后可以恢复,但是可能会影响其他操作。
Morris
// Morris Inorder遍历
void MorrisIn(BTree root){
BTree cur = root; // 定义当前节点
BTree mostRight = NULL; // 定义最右节点
while (cur != NULL){ // 当前节点不为空时循环
mostRight = cur->lChild; // 找到左孩子节点
if (mostRight != NULL){ // 当前节点有左孩子节点
while (mostRight->rChild != NULL && mostRight->rChild != cur){ // 找到左子树中最右下方的节点
mostRight = mostRight->rChild;
}
if (mostRight->rChild == NULL){ // 第一次到达时
mostRight->rChild = cur; // 将最右下方节点的右孩子指向当前节点,建立线索
cur = cur->lChild; // 当前节点更新为其左孩子
continue; // 继续循环
} else{ // 第二次到达时
mostRight->rChild = NULL; // 将线索断开
}
}
printf("%c ", cur->data); // 输出当前节点
cur = cur->rChild; // 当前节点更新为其右孩子
}
}
代码分析
- 定义当前节点和最右节点
BTree cur = root; // 定义当前节点
BTree mostRight = NULL; // 定义最右节点
这里定义了两个变量,分别代表当前节点和最右节点,并将当前节点初始化为根节点,最右节点初始化为NULL。
- 遍历二叉树
while (cur != NULL){ // 当前节点不为空时循环
// ...省略代码...
printf("%c ", cur->data); // 输出当前节点
cur = cur->rChild; // 当前节点更新为其右孩子
}
通过while循环来遍历二叉树,在当前节点不为空的情况下执行循环,输出当前节点的值,然后将当前节点更新为其右孩子(前提是如果当前节点没有右孩子,则继续向上返回,直到找到有右孩子的节点)。
- 找到左子树中的最右下方节点
mostRight = cur->lChild; // 找到左孩子节点
if (mostRight != NULL){ // 当前节点有左孩子节点
while (mostRight->rChild != NULL && mostRight->rChild != cur){ // 找到左子树中最右下方的节点
mostRight = mostRight->rChild;
}
}
在当前节点存在左孩子节点的情况下,通过循环找到左子树中的最右下方节点并将其赋值给mostRight变量,如果当前节点的左子树中没有右孩子,则mostRight为当前节点的左孩子节点;如果当前节点的左子树中有右孩子,则mostRight为左子树中最右下方节点。
- 新建或删除线索
if (mostRight->rChild == NULL){ // 第一次到达时
mostRight->rChild = cur; // 将最右下方节点的右孩子指向当前节点,建立线索
cur = cur->lChild; // 当前节点更新为其左孩子
continue; // 继续循环
} else{ // 第二次到达时
mostRight->rChild = NULL; // 将线索断开
}
在找到左子树中的最右下方节点后,如果该节点的右孩子为NULL,则说明第一次到达该节点,此时将该节点的右孩子指向当前节点,相当于建立线索,然后将当前节点更新为其左孩子,继续遍历;如果该节点的右孩子不为NULL且不是当前节点,则说明第二次到达该节点,此时将该节点的右孩子置为NULL,相当于断开线索。
- 总结
综上,MorrisIn函数实现了对二叉树的中序遍历。在循环中,不断更新当前节点为其右孩子,同时通过mostRight变量找到左子树中的最右下方节点,新建或删除线索,实现了在不借助栈的情况下对二叉树的中序遍历。
区别
不用栈的二叉树中序遍历算法有两种,一种是Morris遍历
,另一种是用线索化遍历
。
这两种算法的思想都是利用线索(或链表)将二叉树的遍历顺序预先存储下来,然后直接根据线索遍历二叉树,避免使用栈,节省空间。
不过,Morris遍历算法是通过修改二叉树的指针来实现线索化的,而线索化遍历算法则是通过在二叉树上添加额外的线索(或链表)来实现线索化的。
优劣
Morris遍历的时间复杂度为O(n),空间复杂度为O(1),与线索化二叉树遍历的时间复杂度和空间复杂度相同。但是Morris遍历的实现更加简单,不需要像线索化二叉树遍历那样需要对二叉树进行线索化的预处理,而是在遍历过程中动态创建线索,因此Morris遍历更加方便。
Morris遍历算法的代码实现相对较简单,而且不需要额外的数据结构,所以比线索化遍历算法更加常用。但是,如果需要对二叉树进行多次遍历,或者需要同时实现前序、中序、后序遍历,而不想每次都要重新遍历树建立线索,则线索化遍历算法可能更加适合。
二叉树的构造
概述
如何完成二叉树的构造
回顾
结论
思考
各种遍历序列提供的信息
二叉树遍历性质
二叉树的遍历有以下性质:
- 任意一种遍历序列都无法唯一确定一棵二叉树,因为可能存在多种结构的二叉树具有相同的遍历序列。
- 如果知道了二叉树的中序遍历序列和任意一种其他遍历序列,就可以唯一确定一棵二叉树,因为可以根据中序遍历序列确定左右子树的范围,然后根据其他遍历序列确定根节点的位置,以此递归地构造二叉树。
性质1
例子
性质2
例子
例题
线索化二叉树
引入
设一棵二叉树有n个结点,则有n-1条边(指针连线),而n个结点共有2n个指针域(Lchild和Rchild),显然有n-1个空闲指针域未用。则可以利用这些空闲的指针域来存放结点的直接前驱
和直接后继
信息。
- 前驱:在某种遍历方式下,一个结点的前一个访问的结点就是其前驱;
- 后继:在某种遍历方式下,一个结点的后一个访问的结点就是其后继。
定义
对结点的指针域做如下规定:
-
若结点有左孩子,则Lchild指向其左孩子,否则,指向其直接前驱;
-
若结点有右孩子,则Rchild指向其右孩子,否则,指向其直接后继;
为避免混淆,对结点结构加以改进,增加两个标志域,如图所示。
以中序为例
构造
线索化二叉树:二叉树的线索化指的是依照某种遍历次序使二叉树成为线索二叉树的过程
。
线索化的过程就是在遍历过程中修改空指针使其指向直接前驱或直接后继的过程。
图例
对于后序遍历的线索树中找结点的直接后继依然困难可分以下三种情况:
- 若结点是二叉树的根结点:其直接后继为空;
- 若结点是其父结点的左孩子或右孩子且其父结点没有右子树:直接后继为其父结点;
- 若结点是其父结点的左孩子且其父结点有右子树:直接后继是对其父结点的右子树按后序遍历的第一个结点。
总结
堆
堆的定义
堆结构是一种数组对象,它可以被视为一棵完全二叉树。树中每个结点与数组中存放该结点中值的那个元素相对应,如下图:
堆是一种基于完全二叉树的高效数据结构,通常分为大根堆
和小根堆
两种类型。
大根堆的每个结点的值都不小于其子结点的值,而小根堆的每个结点的值都不大于其子结点的值。
堆的重要应用包括堆排序、优先队列等。
在堆中,根结点存储着堆中最大(或最小)值,因此堆的建立过程就是不断将元素插入到树的叶子结点中,并使得新插入的结点上浮至合适的位置,以维护堆的性质。堆的插入和删除操作都要保证堆的结构仍然是完全二叉树,并且满足堆的性质。
堆的操作包括建立堆、插入元素、删除根结点、获取堆顶元素等。其中,堆排序是一种在大根堆或小根堆上进行排序的算法,它的核心思想是将待排序的数组构建成一个堆,并不断将堆顶元素取出放到有序数组中,直到堆为空。
堆的性质
设数组A的长度为len,堆的结点个数为size, size≤len,则A[i]存储编号为i的结点值(1≤i≤size),堆的根为A[1],并且利用完全二叉树的性质,我们很容易求第i个结点的父结点的下标为i/2,左孩子结点的下标为2i,右孩子结点的下标为2i+1;
堆具有这样一个性质,对除根以外的每个结点i,A[parent(i)]≥A[i]。即除根结点以外,所有结点的值都不得超过其父结点的值,这种堆又称为“大根堆”,这样就推出,堆中的最大元素存放在根结点中,且每一结点的子树中的结点值都小于等于该结点的值;反之,对除根以外的每个结点i,A[parent(i)]≤A[i]的堆,称为“小根堆”。
堆的建立
例:建立一个小根堆,设n = 9,分别为:3 5 1 7 6 4 2 5 4。
堆的元素插入(代码)
#include <cstdio>
#include <cstdlib>
#define MaxSize 50
int heap[MaxSize],siz = 0;
void swap(int arr[], int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
void insert(int x){
heap[++siz] = x; // 将新元素加入堆的最后一个位置
int pos = siz; // pos指向新加入元素的位置
while (pos != 1){ // 自下而上维护堆的性质
if (heap[pos]<heap[pos/2]) // 若新元素比其父节点小,交换两个元素的位置
swap(heap,pos,pos/2);
else // 否则堆性质已经满足,结束循环
break;
pos = pos/2; // pos指向其父节点,继续向上比较
}
}
堆的元素删除(代码)
void del(){
if (siz == 0) { // 如果堆为空,直接返回
return;
}
printf("删除的堆顶最小元素是:%d\n",heap[1]); // 打印被删除的最小元素
heap[1] = heap[len-1]; // 将堆的最后一个元素替换到根节点
len--; // 堆的大小减1
int now = 1;
while (now*2<=len){ // 从堆顶开始向下调整堆
int next = now*2; // next是now的子节点
if (next+1<=len&&heap[next+1]<heap[next])next = next+1; // 找到两个子节点中更小的那个
if (heap[next]<heap[now]){ // 如果子节点的值比父节点小,就交换两个节点
swap(heap,now,next);
}
else break; // 否则停止调整
now = next; // 继续向下调整
}
}
全部代码
//
// Created by lenovo on 2023/5/14.
//
#include <cstdio>
#include <cstdlib>
#define MaxSize 50
int heap[MaxSize],siz = 0, len = 1;;
void swap(int arr[], int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
void insert(int x){
heap[++siz] = x; // 将新元素加入堆的最后一个位置
int pos = siz; // pos指向新加入元素的位置
while (pos != 1){ // 自下而上维护堆的性质
if (heap[pos]<heap[pos/2]) // 若新元素比其父节点小,交换两个元素的位置
swap(heap,pos,pos/2);
else // 否则堆性质已经满足,结束循环
break;
pos = pos/2; // pos指向其父节点,继续向上比较
}
}
void del(){
if (siz == 0) { // 如果堆为空,直接返回
return;
}
printf("删除的堆顶最小元素是:%d\n",heap[1]); // 打印被删除的最小元素
heap[1] = heap[len-1]; // 将堆的最后一个元素替换到根节点
len--; // 堆的大小减1
int now = 1;
while (now*2<=len){ // 从堆顶开始向下调整堆
int next = now*2; // next是now的子节点
if (next+1<=len&&heap[next+1]<heap[next])next = next+1; // 找到两个子节点中更小的那个
if (heap[next]<heap[now]){ // 如果子节点的值比父节点小,就交换两个节点
swap(heap,now,next);
}
else break; // 否则停止调整
now = next; // 继续向下调整
}
}
void input(){
char x;
while(true){
scanf("%s",&x);
if (x == '#') break; // 输入#时停止添加元素
insert(x-'0'); // 将输入的元素加入堆中
len++;
}
}
void printArray(int arr[], int size) {
for (int i = 1; i < size; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main(){
input();
printArray(heap,len);
del();
printArray(heap,len);
}
霍夫曼树(哈夫曼树)
来源
基本概念
1. 路径和路径长度
- 路径:在一棵树中,从一个结点到另一个结点之间的通路,称为路径;
- 路径长度:路径上的分支数目,称为路径长度;
2. 结点的权及结点的带权路径长度
- 结点的权是指给结点赋予的一个有着某种含义的数值,称为该结点的权。
- 带权路径长度:从根结点到某个结点的路径长度与该结点的权值的乘积,称为带权路径长度;
3. 树的带权路径长度
- 树的带权路径长度:所有叶子结点的带权路径长度之和,记为 WPL。
例题
引出霍夫曼树(定义)
霍夫曼树是一种带权路径长度最短的二叉树,也称为最优二叉树。
它是根据一组给定的权值构造出来的,每个权值对应一个叶子结点。
霍夫曼树的特点是它的带权路径长度最小,也就是说,它能用最少的分支数来表示所有的权值。
这样就可以节省存储空间和传输时间,因此霍夫曼树常用于数据压缩和编码。😊
构造霍夫曼树
原则
如何构建即过程
构造霍夫曼树的基本思路是:
- 首先,将给定的一组权值作为叶子结点,构成一个森林,每棵树只有一个结点;
- 然后,从森林中选出两个权值最小的树,作为一棵新树的左右子树,新树的权值为两个子树的权值之和;
- 接着,从森林中删除选出的两棵树,并将新树加入森林;
- 重复上述步骤,直到森林中只剩一棵树为止,这棵树就是霍夫曼树。
示例
特点
霍夫曼树的特点有以下几点 :
- 霍夫曼树是一种带权路径长度最短的二叉树,也称为最优二叉树;
- 霍夫曼树是一种满二叉树,只有度为 0 或 2 的结点,没有度为 1 的结点;
- 霍夫曼树的权值较大的结点离根结点较近,权值较小的结点离根结点较远;
- 霍夫曼树的任意非叶子结点的左右子树交换后仍然是一棵霍夫曼树;
- 霍夫曼树的带权路径长度与权值的分布有关,与权值的排列顺序无关;
霍夫曼编码
为什么要使用霍夫曼编码
霍夫曼编码的特点
霍夫曼编码的特点有以下几点:
-
霍夫曼编码是一种基于霍夫曼树的编码方式,它可以给不同的字符分配不同长度的二进制编码,使得出现频率高的字符用较短的编码,出现频率低的字符用较长的编码,从而实现数据压缩的目的;
-
霍夫曼编码是一种无损压缩编码,它可以保证原始数据在压缩和解压缩后不会发生任何变化,保证数据的完整性和可靠性;
-
霍夫曼编码是一种前缀编码,它可以保证任何一个字符的编码都不是另一个字符的编码的前缀,这样就可以方便地进行识别和解码,避免歧义和错误;
-
-
这句话的意思是,如果用霍夫曼编码给不同的字符分配二进制编码,那么就不会出现这样的情况:一个字符的编码是另一个字符的编码的一部分,而且在前面。例如,如果有两个字符 A 和 B ,它们的编码分别是 01 和 011 ,那么就不符合这个条件,因为 A 的编码 01 是 B 的编码 011 的前缀。这样就会造成解码时的混乱和错误,因为无法区分 01 是 A 还是 B 的一部分。但是如果用霍夫曼编码,就不会出现这样的问题,因为它可以保证任何一个字符的编码都不是另一个字符的编码的前缀。
-
-
霍夫曼编码是一种最优编码,它可以使得数据压缩后的平均长度达到最小,也就是说,它可以使得数据压缩率达到最大。