本文参考网课为 数据结构与算法
1 第五章二叉树,主讲人 张铭 、王腾蛟 、赵海燕 、宋国杰 、邹磊 、黄群。
本文使用IDE为 Clion
,开发环境 C++14
。
更新:2023 / 12 / 17
先回顾一下线性结构与非线性结构的主要区别:
包含 n (n>=0) 个结点的有穷集合E,E上定义了一个满足以下条件关系r:
- 有且仅有一个称为树根(root)的结点er ∈ E,在关系r下没有前驱;
- 除结点 er 外,E中所有结点对于关系r来说都有且仅有一个前驱;
- 除结点 er 外,对任何结点 e ∈ E,都存在一个结点序列 er,e1,…,es,使得er 就是树根,且 es = e,有序对<ei-1,ei> ∈ r (1 <= i <= s);该结点序列称为从根到结点e的一条路径(path)
Node
)
root
)parent
)children
)Sibling
)branch
/ internal node
)、叶结点( leaf
/ external node
)edge
)path
)、路径长度
ancestor
)、后代( desendant
)
degree
)、层数( levels
)
depth
)、高度( height
)
以下面的树结构示例来进一步理解这些术语的含义:
A 为根结点( root node
)
B 是 D和E的父结点( parent node
),D和E是B的子结点( children node
)
D 是E的兄弟结点( sibling node
)
D、E、F、G 和 I 是叶结点( external node
或者 leaf node
)
A、B、C、H 是分支结点( internal node
)
C结点的度数( degree
)是3
E结点的层数是2,I结点的层数是3
树的深度是3,高度是4
树是包含 n
( n>=0
)个结点的有限集合 T
。
非空 T
满足:
例如,
A是根节点,BD、CEFGHI、JK分别为一颗子树。
只包含1个结点的树必然仅由根结点组成。
包含 n>1 个结点的树借助于少于n个结点的树来定义。
不包含任何结点的树称为空树。
一颗 二叉树
是由结点的有限集合来构成:
二叉树
left subtree
)和右子树( right subtree
)的 二叉树
组成例如,
对于根结点A,BD是A的左子树,CEFGHI是A的右子树;
对于根结点C,EG是C的左子树,FHI是C的右子树;
二叉树
拥有以下几种基本形态,
( c ) 右空 和 ( d ) 左空 是两颗不同的 二叉树
,但作为 树
则是相同的。二叉树
的概念与术语与 树
的基本相同。
一颗 二叉树
中的结点,要么为 叶结点
,或恰有两颗非空子树,那么我们称这样的一颗 二叉树
为 满二叉树
( Full Binary Tree
)。
例如,
B、F、G、H、I均为叶结点
A、C、D、E都恰好有两个子结点,即满二叉树
一颗 二叉树
最多只有最下面两层的结点度数可以小于2,并且,最下面一层的结点都集中在该层最左边的若干位置上,那么我们称这样的一颗 二叉树
为 完全二叉树
( Complete Binary Tree
)。
例如,
ABCDEF是一颗 完全二叉树
再例如,
ABCDEFGHIJL不是一颗 完全二叉树
,因为L没有集中到最左边的位置上。
完全二叉树
的特点如下:
给定一颗 二叉树
,我们可以通过替换该 二叉树
中的 空子树
为 空叶结点
来扩充成一颗 扩充二叉树
:
空叶结点
空叶结点
例如,
将1、4、7这3个原本度数为1的不满的分支结点在它相应的位置上扩充1个 空叶结点
,将2、5、8、10这4个叶结点给扩充2个相应的 空叶结点
,那么就得到了如上图所示的一个 扩充二叉树
。
性质1:非空二叉树的第 i
( i>=0
)层至多有 2i 个结点
证明:
基于该性质,我们可以计算一个给定高度的 二叉树
中结点数目的范围,例如:
对于如上图所示的一个深度为3、高度为4的 二叉树
,它的结点个数的取值范围在 4 ~ 15(20+21+22+23)之间。
性质2:深度为k的二叉树至多有 2k+1-1 个结点( k>=0 )
证明:假设第 i 层上的最大结点个数为 mi,由性质1可知,深度为k的 二叉树
中最大的结点个数。
性质3:一颗 二叉树
中度为 0
的结点数目 n0 比度为2的结点数目 n2多1,即 n0 = n2 + 1。
证明:n0,n1,n2 分别表示 n 个结点中度为0、1、2的结点数,则有 n = n0 + n1 + n2 (1)
若用e表示树的边数,则有 n=e+1(除了根结点以外,其他结点都有一条与父结点之间的边存在,所以我们有树中结点数目等于树的边数+1)
边均由度为1和2的结点射出,故有e=n1+2n2,即,
n = e + 1 = n1 + 2n2 + 1 (2)
由(1)和(2)得,
n0 + n1 + n2 = n1 + 2 * n2 + 1
即,n0 = n2 + 1
性质4:满二叉树定理:非空满二叉树的叶结点数目为其分支结点数+1。
证明:设 二叉树
结点数为n,其中叶结点数为m、分支结点数为b,则有
n(总结点数)= m(叶)+ b(分支) (1)
因为每个分支恰有2个子结点(满),故有2*b条边;n个结点,故
n - 1 = 2b (2)
所以,由(1)和(2)可以得到 n-1 = m+b-1 = 2b,即
m(叶)= b(分支)+1
性质5:满二叉树定理推论:一个非空二叉树的空子树数目等于其结点数+1。
证明:设二叉树为T,将其所有空子树替换为叶结点,所得的扩充满二叉树为T’,亦即,T的原有结点变为T’ 的分支结点。
根据满二叉树定理,T’ 的叶结点数目等于T的结点个数加1;每个新添加的叶结点对应T德一个空子树
所以,T中空子树数目等于T中结点数加1。
性质6:具有n个结点的完全二叉树的高度和深度分别为 log2(n+1) 和 log2(n+1) - 1
证明:设其深度为k,则有
n = 20 + 21 + 2^2 + … + 2k-1 + mk = 2k - 1 + mk (1)
故 2k - 1 < n <= 2k+1 -1 (2)
2k < n+1 <= 2k+1
k < log2(n+1) <= k+1
所以 k = log2(n+1) - 1
在明确了 二叉树
的逻辑结构和 二叉树
的性质后,我们可以来考虑 二叉树
上可以实施的运算的集合,以适用于不同应用的需求。
一般来说,二叉树
的运算有:
抽象数据类型定义了二叉树基本操作的集合,在具体应用中可根据实际情况以此为基础进行适当的扩充/删减。
抽象数据类型定义的操作的具体实现与二叉树的存储相关。
二叉树
结点的抽象数据类型,包括这个结点的数据域,以及可以有适应于不同初始条件的结点的构造函数。
template <class T>
class BinaryTreeNode {
friend class BinaryTreeNode<T>; // 声明二叉树类为友元类
private:
T info; // 二叉树结点数据域
public:
BinaryTreeNode(); // 缺省构造函数
BinaryTreeNode(const T &ele); // 给定数据的构造
BinaryTreeNode(const T &ele, BinaryTreeNode<T> *l, BinaryTreeNode<T> *r); // 子树构造结点
T value() const; // 返回当前结点数据
BinaryTreeNode<T>* leftchild() const; // 返回左子树
BinaryTreeNode<T>* rightchild() const; // 返回右子树
BinaryTreeNode<T>* Parent(); // 返回父结点
BinaryTreeNode<T>* LeftSibling(); // 返回左兄结点
BinaryTreeNode<T>* RightSibling(); // 返回右兄结点
BinaryTreeNode<T>& operator = (const BinaryTreeNode<T>& Node); // 重载赋值操作符
bool isLeaf() const; // 判断是否为叶结点
void setLeftchild(BinaryTreeNode<T>*); // 设置左子树
void setRightchild(BinaryTreeNode<T>*); // 设置右子树
void setValue(const T& val); // 设置数据域
};
template <class T>
class BinaryTree{
private:
BinaryTreeNode<T>* root; // 二叉树根节点
public:
BinaryTree(){root=NULL;}; // 构造函数
~BinaryTree(){DeleteBinaryTree(root);}; // 析构函数
bool isEmpty() const; // 判定二叉树是否为空树
BinaryTreeNode<T>* Root(){return root;} // 返回根结点
void CreateTree(const T& info, BinaryTree<T>& leftTree, BinaryTree<T>& rightTree); // 构造新树
void PreOrder(BinaryTreeNode<T> *root); // 前序遍历二叉树或其子树
void InOrder(BinaryTreeNode<T> *root); // 中序遍历二叉树或其子树
void PostOrder(BinaryTreeNode<T> *root); // 后序遍历二叉树或其子树
void LevelOrder(BinaryTreeNode<T> *root); // 按层次遍历二叉树或其子树
void DeleteBinaryTree(BinaryTreeNode<T> *root); // 删除二叉树或其子树
};
二叉树
的遍历也就是将非线性结构的 二叉树
线性化的过程,又成周游( traversal
)。这代表着系统访问数据结构中的每个结点,使得每个结点恰好被访问且仅被访问到1次。
访问是指对结点进行期望的操作,具体地操作取决于应用的需求,譬如对二叉树结点数据成员进行读、写、改等操作。
二叉树
的遍历分为:
depth-first search / traversal
,DFS
/ DFT
)breadth-first search / traversal
,BFT
/ BFS
)
一颗 二叉树
由3部分组成:
t
L
( left child
)R
( right child
)通过变换根结点的遍历顺序,可以有以下3种方案:
前序遍历
( preorder traversal
)中序遍历
( inorder traversal
)后序遍历
( postorder traversal
)以下面的 二叉树
为例,
前序遍历
得到的序列为 A、B、D、E、G、C、F、H、I中序遍历
得到的序列为 D、B、G、E、A、C、H、F、I后序遍历
得到的序列为 D、G、E、B、H、I、F、C、A
以上面的 二叉树
为例,可以以3种不同的遍历方式得到表达同一表达式的不同序列:
前序遍历
得到的序列为 ÷-ab+cd
,即波兰表示法中序遍历
得到的序列为 (a-b)/(c+d)
,即中缀表示后序遍历
得到的序列为 ab-cd+÷
,即逆波兰表示法实现 二叉树
深度优先遍历的方式有:
如果是 前序遍历
,按照以下的递归算法进行遍历:
template <class T>
void BinaryTree<T>::DepthOrder(BinaryTreeNode<T>* root){
if (root != NULL){ // 前序
Visit(root);
DepthOrder(root->leftchild()); // 递归访问左子树
DepthOrder(root->rightchild()); // 递归访问右子树
}
}
如果是 中序遍历
,按照以下的递归算法进行遍历:
template <class T>
void BinaryTree<T>::DepthOrder(BinaryTreeNode<T>* root){
if (root != NULL){ // 中序
DepthOrder(root->leftchild()); // 递归访问左子树
Visit(root);
DepthOrder(root->rightchild()); // 递归访问右子树
}
}
如果是 后序遍历
,按照以下的递归算法进行遍历:
template <class T>
void BinaryTree<T>::DepthOrder(BinaryTreeNode<T>* root){
if (root != NULL){ // 后序
DepthOrder(root->leftchild()); // 递归访问左子树
DepthOrder(root->rightchild()); // 递归访问右子树
Visit(root);
}
}
以上面的二叉树为例,
以前序遍历的方式进行周游时,
此时栈为空,遍历结束。
用代码来实现以上描述,如下所示:
template <class T> void BinaryTree<T>::PreOrderWithoutRecursion(BinaryTreeNode<T> *root){
using std::stack; // 使用STL中的栈
stack<BinaryTreeNode<T>* > aStack;
BinaryTreeNode<T> *pointer = root; // 从根结点开始访问
aStack.pull(NULL); // 放入栈底监视哨
while (pointer){ // 当pointer有值,或者!aStack.empty()
Visit(pointer -> value()); // 访问当前结点
if (pointer -> rightchild()!=NULL) // 如果当前结点的右子树非空
aStack.push(pointer->rightchild()); // 将右子树结点压栈
if (pointer -> leftchild()!=NULL) // 如果左子树非空
pointer = pointer -> leftchild(); // 左路下降,访问左子树结点
else{ // 如果左子树为空
pointer = aStack.top();
aStack.pop(); // 栈顶元素弹出、退栈
}
}
}
以上面的二叉树为例,
以中序遍历的方式进行周游时,
以上面的二叉树为例,
以后序遍历的方式进行周游时,
此时栈为空,遍历结束。
时间代价
遍历具有n个结点的 二叉树
需要 O(n)
时间
前序遍历
和 中序遍历
中,某些结点入栈 / 出栈一次,不超过 O(n)
后序遍历
中,每个结点分别从左、右边各入栈 / 出栈一次 O(n)
空间代价
遍历过程中栈的最大容量与树的高度成正比
O(n)
O(logn)
广度优先遍历
是从 二叉树
的第0层(根结点)开始,自上至下逐层遍历;在同一层中,按照从左到右的顺序对结点逐一访问。
时间代价
每个结点都被访问且只被访问一次,时间代价为 O(n)
空间代价
与树的最大宽度相关
O(n)
O(1)
二叉树
的存储一般分为:
顺序存储
数组
实现 完全二叉树
链式存储
指针
实现 二叉树
根据应用的需要和 二叉树
的结构特点,我们可以采用不同的存储方式:
顺序存储
二叉树
按紧凑结构存储,且在应用过程中二叉树的大小和形状很少发生剧烈变化时,可采用 顺序存储
方法
按照广度优先遍历的方式,即按层次顺序将一颗具有n个结点的 完全二叉树
的结点从 0 到 n-1 编号,得到结点的一个线性序列。如下所示:
从结点编号i可推知其父结点、子结点、兄弟结点的编号:
完全二叉树
的层次结构足以反映其结构,按层次序列顺序存储,根据结点的存储位置可计算其左、右子结点、父节点、兄弟结点的存储地址。
完全二叉树
的 顺序存储
,在存储结构上是线性的,但依然完整的反应了 二叉树
的逻辑结构。
树结构最自然的存储方式是 链式存储
,不同结点随机存储在内存空间中,结点间关系用指针表示。
结点的形式如下所示:
每个结点除存储结点本身的数据外,设置两个指针字段 left
和 right
分别存储其左子结点和右子结点的地址。若结点的某个子节点为空,则相应的指针应为空指针。
以下面的 二叉树
为例,
将上面的 二叉树
转换为如下所示的 二叉链表
:
n
个结点的 二叉链表
有 n+1
个空指针。
链式存储
的优缺点如下:
n+1
个,存储密度低、结构性开销大向 BinbaryTreeNode 类中增加两个私有数据成员,如下所示:
private: // 增加2个私有数据成员
BinaryTreeNode<T> *left; // 指向左子树的指针
BinaryTreeNode<T> *right; // 指向右子树的指针
template <class T> class BinaryTreeNode{
friend class BinaryTreeNode<T>; // 声明二叉树类为友元类
private:
T info; // 二叉树结点数据域
public:
BinaryTreeNode(); // 缺省构造函数
BinaryTreeNode(const T & ele); // 给定数据的构造
BinaryTreeNode(const T & ele, BinaryTreeNode<T> *l, BinaryTreeNode<T> *r);
};
template<class T>
BinaryTreeNode<T>* BinaryTree<T>::
Parent(BinaryTreeNode<T> *rt, BinaryTreeNode<T> *current){
BinaryTreeNode<T> *tmp;
if (rt == NULL) return (NULL); // 如果根结点是NULL,返回NULL
if (current == rt -> leftchild() || current == rt -> rightchild()) // 如果当前结点为根结点的左/右子树,返回rt
return rt;
if ((tmp = Parent(rt -> leftchild, current) != NULL)
return tmp;
if ((tmp = Parent(rt -> rightchild, current) != NULL)
return tmp;
return NULL;
}
BinaryTreeNode<T>* BinaryTree<T>::Parent(BinaryTreeNode<T> *current){
using std::stack; // 使用STL中的栈
stack<BinaryTreeNode<T>* > aStack;
BinaryTreeNode<T> *pointer = root;
aStack.push(NULL); // 栈底监视哨
while (pointer){ // 当pointer存在,或者,!aStack.empty()时
if (current == pointer -> leftchild() || current == pointer -> rightchild()) // 若current为pointer的子结点,则返回pointer
return pointer;
if (pointer -> rightchild() != NULL) // 若右子结点非空,则右子结点入栈
aStack.push(pointer -> rightchild());
if (pointer -> leftchild() != NULL) // 若左子结点非空,则左路下降
pointer = pointer -> leftchild();
else // 若左子结点为空,弹出栈顶元素(退栈),poniter取栈顶
pointer = aStack.top();aStack.pop();
}
}
对于经常要回溯父节点的应用,可以增加一个指向其父节点的指针域,成为如下所示的 三叉链表
,以提供向上访问的能力:
虽然增加一个指针域,付出了空间的代价,但是可以使得经常要去找父节点的运算变得高效。
α + γ = 1
α
γ
以 二叉链表
为例进行空间代价分析:
每个结点占用两个指针、一个数据域,假定数据域大小为d、指针域大小为p,整个结构由n个结点组成,那么总体空间大小是 (2p+d)n
,总体结构性开销是 2pn
。
γ =
2
p
n
(
2
p
+
d
)
n
\frac{2pn}{(2p+d)n}
(2p+d)n2pn?,如果 p = d,γ =
2
3
\frac{2}{3}
32?。
为降低结构性开销,若只有叶结点存储数据、分支结点为内部结构,则开销取决于二叉树满的程度,越满则存储效率越高。
叶结点和分支结点差别较大,可区分叶结点存储和分支结点的存储。例如在叶结点存储操作数、在分支结点存储操作符:
采用不同的存储实现,不仅空间开销有差异,对于运算和操作的实现也不同。
具体应用中采取何种存储结构,除了二叉树的形态外,还应该考虑运算:
二叉树
的一个主要用途是提供对数据(包括索引)的快速检索,而一般二叉树对此并不具有性能优势。
和上图右侧的树类似的树,我们称其为 二叉搜索树
( Binary Search Tree
,简称 BST
)。它在中文里还有其他常用名称,例如 二叉查找树
、二叉检索树
、二叉排序树
。
二叉搜索树
具有以下性质:
K
的结点满足:
二叉树
,可以得到一个有序序列 15 17 18 22 35 51 60 88 93
对于 二叉搜索树
,常用的操作有 检索
、插入
( 生成
)、删除
。
所有的操作都围绕 二叉搜索树
的性质,并保持相应的性质。操作的效率取决于树的高度。
假定待检索的检索码为 K
,
K
,则检索成功并结束;否则,则继续:
K
小于根结点的值,则只检索左子树K
大于根结点的值,则只检索右子树如此,一直持续到 K
被找到,或者,遇上叶结点仍没发现 K
,则检索失败,说明不存在满足条件的结点。
35
的结点35
开始,根结点检索码为 35
,检索成功并结束。19
的结点35
开始,根结点 35
和 检索码 19
不一致且检索码 19
比根结点 35
小,则继续检索左子树,即以 16
为根结点的左子树19
比根结点 16
大,则继续检索右子树,即以 19
为根结点的右子树。根结点 19
同检索码 19
一致,检索成功并结束。40
的结点35
开始,根结点 35
和 检索码 40
不一致且检索码 40
比根结点 35
大,则继续检索右子树,即以 60
为根结点的右子树40
比根结点 60
小,则继续检索左子树,即以 51
为根结点的左子树51
为根结点的左子树为空,检索失败检索的基本操作是比较。每次比较,只需要检索2个子树之一。每比较1次,树的高度降低1。因此,检索代价是 O(h)
,h
是树高。
插入
操作应保证结点插入后仍保持 BST
性质,即,BST
的不变量和中序有序性。
假定待插入的检索码为 K
,
K
结点作为根结点插入,操作结束
K
小于根结点的值,将其插入左子树K
大于根结点的值,将其插入右子树17
的结点35
开始,检索码 17
小于根结点 35
,只能将检索码 17
插入根结点为 16
的左子树中17
比根结点 16
大,只能将检索码 17
插入根结点为 19
的右子树中17
比根结点 19
小,且根结点 19
的左子树为空,因此插入 19
的左子树,如下所示:插入操作的前提是一次失败的检索,然后将新结点作为叶结点插入,改动相关特定结点的空指针即可。
其时间复杂度与根到插入位置的路径长度相关,而这个路径长度是插入结点落在先前那次失败检索时外部结点所在的层次。在最坏的情况下,这个路径长度与树的高度成正比。
删除
操作应保证结点删除后仍保持 BST
性质,且树高变化较小。
首先检索到待删除结点 H
,再根据其在树中所处位置分情况处理:
H
,并将其父结点 J
的相应指针位置置空H
,并以 H
的子结点代替 H
原有的位置
H
,并以 H
的左子树中最大的结点或者右子树中最小的结点来替换 H
原有的位置
假设要删除 G
,G
的左右子树均不为空,即符合第3种情况。
G
的右子树中最小的结点为 H
,H
符合第2种情况,将 H
替换到 G
的位置,H
的右子树结点 I
替换到 H
的位置。
删除操作需要:
以上两步的检索次数加起来是不会超过整个二叉搜索树的高度的。所以删除的时间代价是 O(h)
,同树高 h
成正比。
其时间复杂度与根到插入位置的路径长度相关,而这个路径长度是插入结点落在先前那次失败检索时外部结点所在的层次。在最坏的情况下,这个路径长度与树的高度成正比。
满足以下几种特性的二叉树:
完全二叉树
我们将它称为 最小值堆
( min-heap
)。如上图所示。
堆
是什么?——
堆
是一种树型结构。
完全二叉树
的层次序列,可采用顺序数组表示BST
那样实现关键码的完全排序,而是局部有序,只有父结点的大小关系可以确定堆是基于树的满足一定约束的重要数据结构,存在许多变体:二项式堆、斐波那契堆。
二叉树所表示的二叉堆是常用的一种堆,由于完全二叉树良好的性质,常采用数组来存储堆。
堆的基本操作均依赖于两个重要的函数 ShiftUp
和 ShiftDown
。
ShiftUp
或 ShiftDown
ShiftUp
进行调整构建具有N个结点的堆的时间复杂度为 O(N)
。
堆的深度为 logN
,插入、删除元素的平均时间代价和最差时间代价都是 O(logN)
适合需要经常查找最小值、又经常增删数据的场景。但是,查找任意值的效率不高,因为往往需要遍历整棵树后才知道它在哪儿。
template <class T> // 最小堆 ADT 定义
class MinHeap{
private:
T* heapArray; // 存放堆数据的数组
int CurrentSize; // 当前堆中元素数目
int MaxSize; // 堆所能容纳的最大元素数目
void BuildHeap(); // 建堆函数
public:
MinHeap(const int n); // 构造函数,n为最大元素数目
virtual ~MinHeap(){delete []heapArray;};// 析构函数
bool isLeaf(int pos) const; // 若为叶结点,返回TRUE
int leftchild(int pos) const; // 返回左子结点位置
int rightchild(int pos) const; // 返回右子结点位置
int parent(int pos) const; // 返回父结点位置
void ShiftDown(int left); // 筛选法函数,参数left表示开始处理的数组下标
void ShiftUp(int pos); // 从pos开始向上调整序列为堆
bool Remove(int pos, T&node); // 删除给定下标的元素node
bool Insert(const T& newNode); // 插入新元素newNode
T& RemoveMin(); // 删除堆顶最小值
};
如何建 堆
?——
n
个关键码组织到一维数组中,shiftdown
从右向左、自底向上将以各个分支结点为根的子树调整成堆,直到树根为止以下面的二叉树为例,
该二叉树存在8个结点,编号从0到7。
堆
的性质。堆
的性质
16>5,将71和5进行交换,得到下面的结构:
以新编号2的结点5为根结点的一个二叉树结构已经被调整成 堆
73>23,将73和23进行交换,得到下面的结构:
新编号3的结点73,同其子结点68进行比较,73>68,将73和68进行交换,得到下面的结构:
以新编号1的结点23为根结点的一个二叉树结构已经被调整成 堆
23>5,将72和5进行交换,得到下面的结构:
新编号2的结点72,同其子结点16、71进行比较,72>16,72>71,16>71,将72和16进行交换,得到下面的结构:
以新编号2的结点16为根结点的一个二叉树结构已经被调整成 堆
至此,原始的二叉树被调整为 最小堆
。
如果使用代码来实现上述的 shiftdown
操作,
template <class T>
void MinHeap<T>::ShiftDown(int position){
int i = position; // 指向父结点的指针
int j = 2*i + 1; // 指向关键值较小的子结点的指针
T temp = heapArray[i]; // 使用temp来保存父结点的关键值
while (j<CurrentSize){
if ((j<CurrentSize-1) && (heapArray[j] > heapArray[j+1])) // 如果编号j的结点的关键值小于编号j+1的结点的关键值
j++; // j下移,j指向j+1
if (temp > heapArray[j]){ // 如果父结点的关键值大于编号j的结点的关键值
heapArray[i] = heapArray[j]; // 交换父结点和编号j的结点
i = j;
j = 2*j+1; // 向下继续
}
else break; // 调整结束,退出while循环
}
heapArray[i] = temp;
}
使用 shiftdown
的时间代价分析如下:
log(N+1)
。每循环一次就把目标结点下移一层,故循环最多为 log(N+1)
次。所以,最差的情况下,shiftdown
的时间代价为 O(logN)
。
构建具有N个结点的堆的时间代价分析如下:
以下面的完全二叉树为例,
一个N个结点的完全二叉树,若同时为满二叉树,则筛选的层数达到最大,此时有 N = 2d - 1 => d = log(N+1)
第 k 层有至多 2k 个结点,且第k层离叶结点的距离为 d-k-1层。
构建具有N个结点的堆需要的比较次数为:
最差的情况下,2次比较需要1次交换,所以最大交换次数为 n-logn
因此,构建具有N个结点的堆的时间复杂度为 O(N)
。
以向下面的二叉树插入元素12为例说明如何进行 插入
操作:
堆
的性质。此时,需要同其父结点进行比较,36>12,故交换36和12、将12 shiftup
放至根结点的位置上,形成下面的结构:shiftup
,最终形成下面的结构:
如果使用代码来实现上述的 shifup
和插入操作,
template <class T>
void MinHeap<T>::ShiftUp(int position){
int temppos = position; // 从position向上开始调整,使序列成为堆
T temp = heapArray[temppos]; // 使用temp保存temppos结点
while ((temppos > 0) && (heapArray[parent(temppos)] > temp)){
heapArray[temppos] = heapArray[parent(temppos)]; // 交换temppos和其父结点
temppos = parent(temppos); // 交换temppos和其父结点的指针
}
heapArray[temppos] = temp; // 最终temppos结点的关键值仍为temp
}
template <class T>
bool MinHeap<T>::Insert(const T & newNode){ // 向堆中插入新元素newNode
if (CurrentSize == MaxSize) // 堆空间已满
return false;
heapArray[CurrentSize] = newNode; // 将新元素添加到最后
ShiftUp(CurrentSize); // 向上调整
CurrentSize ++;
}
构建具有N个结点的堆的时间复杂度为 O(N)
。
堆的深度为 logN
插入、删除元素的平均时间代价和最差时间代价都是 O(logN)
删除根结点后须维护和保持 堆
的特性。
以下面的二叉树为例,对如何删除根结点元素进行说明:
将最后1个元素45放到根结点上作为替代,如下所示:
但是,这时候整个二叉树结构未必再满足 堆
的特性。
以此类推,45>36,45>40,36<40,将45和36交换,形成下面的结构:
整个过程可能需要从树根一直筛到树叶,因此效率同树的高度成正比,即耗时 O(logN)
。
再以下面的二叉树为例,对如何删除树上的元素进行说明:
ShiftUp
,63成为新结点,形成下面的结构:
此时,树上的各个节点满足 最小堆
的特性。至此,调整完毕。
如果使用代码来实现删除根结点的操作,
template <class T> T MinHeap<T>::RemoveRoot(){
if (CurrentSize == 0) exit(1); // 如果MinHeap的size是0,退出;
Item tmpItem = heapArray[0]; // 使用tmpItem保存heapArray的第0个结点;
heapArray[0] = heapArray[CurrentSize - 1]; // 将最后一个结点和根结点进行交换
heapArray[CurrentSize - 1] = tmpItem;
CurrentSize --; // 删除新最后1个结点,即,旧根结点
ShiftDown(0); // 将根结点shift down
return tmpItem;
}
如果使用代码来实现删除树上某结点的操作,
template <class T>
bool MinHeap<T>::Remove(int pos, T& node){
if ((pos<0) || (pos>=CurrentSize)) // 如果pos<0或大于树中结点的个数,非法
return false;
T temp = heapArray[pos]; // 使用temp保存树上的第pos个结点
heapArrayp[pos] = heapArray[--CurrentSize]; // 将树上的第pos个结点和最后一个结点进行交换
if (heapArray[parent(pos)]) > heapArray[pos] // 如果新第pos个结点的父结点比其大
ShiftUp(pos); // 将新第pos个结点ShiftUp
else ShiftDown(pos); // 否则,将新第pos个结点ShiftDown
node = temp;
return true;
}
构建具有N个结点的堆的时间复杂度为 O(N)
。
堆的深度为 logN
插入、删除元素的平均时间代价和最差时间代价都是 O(logN)
Priority Queue
)HBLT
、WBLT
、maxWBLT
)在程序设计以及数据通信领域,常常需要给某些给定字符集进行编码。编码是建立字符集与计算机或通信的数字系统之间的对应关系。也就是说,采用一组无歧义的规则将字符集中每个字符编码为唯一可标识的代码串。
一般编码有:
fixed-length coding scheme
)
variable-length coding scheme
)
可以用二叉树来设计和表示前缀编码。约定叶结点代表字符,一个结点的左分支标记 ‘0’,右分支标记 ‘1’,这样的话,根结点到叶结点的路径上所有分支标记所组成的代码串作为该叶结点所代表字符的编码。
这样的编码方式得到的一定是前缀编码。为什么呢?因为从根到一个叶结点的路径肯定不可能是根到另外一个叶结点路径的前缀,这是由二叉树的结构特性所保证的。同时,进行不等长编码的初衷是要提高空间的利用率。
如何保证这样的编码树所得到的编码总长度最小?通过 Huffman算法。
通过下面的例子来了解编码总长的概念,
上面有三颗具有4个外部结点的二叉树,相应的权值分别为6,2,3,4。
(a)、(b)、(c)三种形态的编码总长为其带权外部路径长度。
Huffman编码其实就是对一个待编码的字符集D={d0,…,dn-1},D中各种字符的出现频率为W={w0,…,wn-1},对字符集D进行二进制编码,使得通信编码总长最短,且di 的编码不是dj编码的前缀。反之亦然。
Huffman编码的基本思想是,将di作为外部结点,wi为外部结点的权,构造具有最小带权外部路径长度的扩充二叉树。也就是说,Huffman树是一个具有n个外部结点的扩充二叉树:
构建一个Huffman树的步骤为:
以下面的示例为例对如何构建Huffman树进行说明,
以上是13个待编码的字符按照权值的有序排列,可构成下面的Huffman树,在这样的一棵树上,把每个结点到它的左子结点的边标记为0、到它的右子结点的边标记为1。那么将从根到叶结点的路径上的0、1标记组成该叶结点所代表字符的编码,如下所示:
出现频率越大的字符,其编码越短。
template <class T>
HuffmanTree <T>::HuffmanTree(T weight[], int n){
MinHeap<HuffmanTreeNode<T> heap(n)>; // 最小值堆
HuffmanTreeNode<T> *parent, firstchild, secondchild;
HuffmanTreeNode<T> *NodeList = new HuffmanTreeNode<T>[n];
for (int i=0; i<n; i++){ // 初始化
NodeList[i].info = weight[i]
NodeList[i].parent = NodeList[i].left = NodeList[i].right = NULL;
heap.Insert(NodeList[i]); // 向堆中添加元素
}
for (i=0; i<n-1; i++){ // 通过n-1次合并 建立Huffman树
parent = new HuffmanTreeNode<T>; // 申请一个分支结点
firstchild = heap.RemoveMin(); // 选择权值最小的结点
secondchild = heap.RemoveMin(); // 选择权值次小的结点
MergeTree(firstchild, secondchild, parent) // 将权值最小的两棵树合并到parent树
heap.Insert(*parent); // 把parent插入到堆中去
root = parent; // Huffman树的根结点赋值
}
delete [] NodeList;
};
采用Huffman算法构造的扩充二叉树即可编码字符集,也用来解码 / 译码二进制代码串。
译码与编码过程相逆:
以下面的示例为例对如何译码Huffman树进行说明,
“111 101110” 即 “d12d2”。
Huffman编码适合于:
引理:
一颗含有两个以上结点的Huffman树中,使用频率最小的两个字符是兄弟结点,而且其深度不比树中其他任何叶结点浅。
证明:
记使用频率最低的两个字符为 y1 和 y2,y1和y2的父结点为y
假设x1和x2是最深的结点,x1和x2的父结点为x,y一定会有比x更大的 “权”,否则会选择y而不是x作为结点v的子结点。
但是,由于y1和y2是频率最小的字符,所以不可能出现y的权值比x权值还小的情况。
定理:
对于给定的一组字符,函数Huffman Tree实现了最小外部路径权重。
证明:
对于字符个数n,可归纳如下:
因此,根据归纳原理,定理成立。
Huffman编码的空间效率评判指标是字符的平均编码长度。
各字符的编码长度 ci 乘以其出现概率 pi,即:
c0p0 + c1p1 + … + cn-1pn-1
或者,
fi 表示第i个字符的出现频率,fT表示所有字符出现的总次数
(c0f0 + c1f1 + … + cn-1fn-1)/ fT
以下面的Huffman树为例说明Huffman的编码效率:
上面的Huffman树的编码的平均长度是,
(3*(19+23+29+31+31+47)) + 4*(11+13+17)+ 4*(11+13+17) + 57 + 65 + 7*(2+3)) / 238 = 3.38
如果将这13个字符进行等长编码,则每个字符的编码平均长度为 log13 = 4位。
然而将这13个字符进行Huffman编码,每个字符的编码平均长度为3.38位。
比起等长编码,Huffman编码只需要等长编码 3.38 / 4 = 84% 的空间。
再以下面的示例为例体会Huffman的编码效率:
如果存在一个100,000个字符组成的文件,这100,000个字符有6种字符,且这6种字符的出现频率差异较大,如下所示:
224,000 / 300,000 = 74 %
使用不等长编码可以节省 26% 的空间。