树二叉树二叉树的特性二叉树的描述
数组描述链表描述 二叉树的遍历
表达式树 抽象数据类型BinaryTreelinkedBinaryTree基于树结构的并查集
集合的树形描述求解策略C++实现性能分析合并函数的性能改进查找函数的性能改进
树截止目前,我们已经知道了线性数据结构(数组、链表、栈、队列)和表数据结构(哈希表),但是这些数据结构不适合具有层次结构的数据。
定义-1一棵树t是一个非空的有限元素的集合,其中一个元素为根,其余的元素组成t的子树。
另外,还需要了解的专有名词有:孩子节点、双亲节点、兄弟节点、祖先节点、子孙节点、内部节点、外部节点。
在这本书中,对深度、高度、级的定义是一致的,也都是从1开始。详细定义参考这里面的定义。
一个元素的度指的是其孩子的个数,比如叶节点的度就是0.一棵树的度是其元素的度的最大值。
二叉树定义-2一棵二叉树t是有限个元素的集合(可以为空)。当二叉树为非空时,其中有一个元素为根,余下元素被划分成两个二叉树,分别称为t的左子树和右子树。
二叉树和树的根本区别是:
- 二叉树的每个元素都恰好有两棵子树,其中一个或两个可能为空。而树的每个元素可有任意数量的子树;在二叉树中,每个元素的子树都是有序的,也就是说,有左子树和右子树之分。而树的子树是无序的。
这里的特性是基于根的深度为1,叶节点的高度为1。
特性-1 一棵二叉树有n个元素, n > 0 ngt0 n>0,它有n-1条边。
特性-2 一棵二叉树的高度为h, h ≥ 0 hge0 h≥0,它最少有h个元素,最多有 2 h − 1 2^h-1 2h−1个元素(满二叉树)。
特性-3 一棵二叉树有n个元素, n > 0 ngt0 n>0,它的高度最大为n,最小高度为 ⌈ log 2 ( n + 1 ) ⌉ lceillog_2(n+1)rceil ⌈log2(n+1)⌉(满二叉树)。
当高度为h的二叉树恰好有 2 h − 1 2^h-1 2h−1个元素时,称其为满二叉树(1+2+4+…)。
关于完全二叉树的定义:对一棵满二叉树,从上到下,从左到右(广度优先的方式)进行编号,假设从满二叉树中删除k个其编号为 2 h − i 2^h-i 2h−i元素,称所得到的树为完全二叉树。
特性-4 设完全二叉树的一元素其编号为i, 1 ≤ i ≤ n 1le ile n 1≤i≤n。有以下关系成立:
- 如果
i
=
1
i=1
i=1,则该元素为二叉树的根。若
i
>
1
igt1
i>1,则其父节点的编号为
⌊
i
/
2
⌋
lfloor i/2rfloor
⌊i/2⌋;如果
2
i
>
n
2igt n
2i>n,则该元素无做孩子。否则其左孩子的编号为2i;如果
2
i
+
1
>
n
2i+1gt n
2i+1>n,则该元素无右孩子。否则其右孩子的编号为2i+1。
二叉树的数组表示利用了特性-4,把二叉树看做是缺少了部分元素的完全二叉树。
可以看出这种方法十分的浪费空间,对于有n个元素的二叉树,最多需要 2 n 2^n 2n个空间存储(考虑到数组的0位置,因为4的编号从1开始)。
链表描述二叉树最常用的表示方法是用指针。每个元素用一个节点表示,节点有两个指针域,分别称为leftChild和rightChild,此外还有一个element域。
实现方式如下所示:
template二叉树的遍历struct binaryTreeNode { T element; binaryTreeNode * leftChild; binaryTreeNode * rightChild; // 默认构造函数,元素随着T决定,指针需要避免迷途 binaryTreeNode() { leftChild = rightChild = NULL; } binaryTreeNode(const T& theElement) { element = theElement; leftChild = rightChild = NULL; } binaryTreeNode(const T& theElement, binaryTreeNode * theLeftChild, binaryTreeNode * theRightChild) : element(theElement), leftChild(theLeftChild), rightChild(theRightChild) {}; };
分为前序遍历、中序遍历、后序遍历和层次遍历。
// 从根开始,先左后右 template表达式树void preOrder(binaryTreeNode *t) { visit(t); // 访问树根 preOrder(t->leftChild); // 访问左子树 preOrder(t->rightChild); // 访问右子树 } // 从左子树开始,后中再右 template void inOrder(binaryTreeNode * t) { inOrder(t->leftChild); visit(t); inOrder(t->rightChild); } // 从左子树开始,后右再中 template void postOrder(binaryTreeNode * t) { postOrder(t->leftChild); postOrder(t->rightChild); visit(t); } // 层次遍历、广度优先 template void levelOrder(binaryTreeNode * t) { std::queue > q; while (t != NULL) { visit(t); if (t->element != NULL) q.push(t->leftChild); if (t->rightChild != NULL) q.push(t->rightChild); try { t = q.front(); } catch (...) { return; } q.pop(); } }
用算术符号和变量构成的二叉树。
对一棵表达式树分别进行中序、前序和 后序遍历,结果便是表达式的中缀、前缀和后缀形式。
前缀:+ab;中缀:a+b;后缀:ab+。
中缀表达式需要加上优先级规则才能避免歧义。
在后缀和前缀表达式中,不必采用优先级规则,遇到一个操作符,则将其与栈顶的操作数相匹配。把这些操作数弹出栈,由操作符执行响应的操作,然后将计算结果压入操作数栈。
抽象数据类型BinaryTree二叉树的操作有:
- empty():若树为空,则返回true,否则返回false;size():返回二叉树的节点/元素个数;preOrder(visit)等共计四种遍历函数。
templateclass binaryTree{ public: virtual ~binaryTree() {} virtual bool empty() const = 0; virtual int size() const = 0; virtual void proOrder(void (*) (T*)) = 0; virtual void inOrder(void (*) (T*)) = 0; virtual void postOrder(void (*) (T*)) = 0; virtual void levelOrder(void (*) (T*)) = 0; }
其中,二叉树遍历方法的参数类型为void (*) (T*)。
这是一种函数类型,这种函数的返回值类型是void,它的参数类型是T*。
linkedBinaryTreetemplateclass linkedBinaryTree : public binaryTree > { public: linkedBinaryTree() { root = NULL; treeSize = 0; } ~linkedBinaryTree() { erase(); }; bool empty() const { return treeSize == 0; } int size() const { return treeSize; } void preOrder(void(*theVisit)(binaryTreeNode *)) { visit = theVisit; preOrder(root); } void inOrder(void(*theVisit)(binaryTreeNode *)) { visit = theVisit; inOrder(root); } void postOrder(void(*theVisit)(binaryTreeNode *)) { visit = theVisit; postOrder(root); } void levelOrder(void(*theVisit)(binaryTreeNode *))) { visit = theVisit; postOrder(root); void erase() { postOrder(dispose); root = NULL; treeSize = 0; } private: binaryTreeNode * root; int treeSize; static void (*visit)(binaryTreeNode *); static void preOrder(binaryTreeNode * t); static void inOrder(binaryTreeNode * t); static void postOrder(binaryTreeNode * t); static void levelOrder(binaryTreeNode * t); static void dispose(binaryTreeNode * t) { delete t; } };
这其中最令人眼前一亮的应该是函数指针作为形参的操作。
我们首先在private部分定义了一个函数指针,之后将preOrder等遍历函数进行重载,以接收访问函数的指针。
详细内容可参照这里。
基于树结构的并查集在之前,我们已经介绍过了数组和链表结构的并查集。
在此处,我们要介绍树结构的并查集,其中每个集合被表示为一棵树。
集合的树形描述任何一个集合S都可以描述为一棵具有 ∣ S ∣ |S| ∣S∣个节点的树,一个节点代表一个元素。任何一个元素都可以作为根元素;剩余元素的任何子集可以作为根元素的孩子;再剩余元素的任何子集可以作为根元素的孙子。
每个非根节点都有一个指针指向其父节点。指向父节点的指针之所以需要,是因为查找操作需要向上搜索一棵树。查找与合并操作都需要向下移动。
求解策略把每一个集合表示为一棵树。查找时,我们把根元素作为集合标示符。
对于find函数来说,为了确定元素theElement属于哪一个集合,我们从元素theElement节点开始,沿着节点到其父节点向上移动,直到根节点为止。
在合并时,我们假设在调用语句unite(classA,classB)中,classA和classB分别是两个不同树的集合的根。为了把两个集合合并,让一棵树成为另一棵树的子树。
C++实现int* parent;
void initialize(int numberOfElements) {
// 初始化numberOfElements棵树,每棵树一个元素
parent = new int[numberOfElements + 1];
for (int e = 0; e < numberOfElements + 1; e++)
parent[e] = 0;
}
int find(int theElement) {
// 返回元素theElement所在树的根
while (parent[theElement] != 0)
theElement = parent[theElement];
}
int unite(int rootA, int rootB) {
// 合并两棵其根节点不同的树
parent[rootB] = rootA;
}
我们将树结构的并查集和数组结构的并查集对比一下:
- 在初始化上,思想都是各自为类,所以树并查集的每一个节点都指向了空节点0,而数组并查集每一个节点的代表元素都是本身;在find上,树并查集需要层层递进,找到根节点,数组并查集只需要返回数组索引位置处的元素即可;在unite上,树并查集只需要更改根节点的parent即可,而数组并查集需要把每个classB的成员改成classA,比较麻烦。
关于链表结构的并查集,看着好烦,这里就不比较了。
性能分析| 结构 | 构造 | find | unite |
|---|---|---|---|
| 数组 | Θ ( n ) Theta(n) Θ(n) | Θ ( 1 ) Theta(1) Θ(1) | Θ ( n ) Theta(n) Θ(n) |
| 链表 | Θ ( n ) Theta(n) Θ(n) | Θ ( 1 ) Theta(1) Θ(1) | O ( n ) O(n) O(n) |
| 树 | Θ ( n ) Theta(n) Θ(n) | O ( h ) O(h) O(h) | Θ ( 1 ) Theta(1) Θ(1) |
比如说,一个树结构的并查集进行了f次查找和u次合并,那么一个系列的操作的时间为 O ( f u ) O(fu) O(fu)。
这是因为,每一个合并,都给某些树增加了高度,潜在地增加了查找的时间。
合并函数的性能改进在对根i和根j的树进行合并操作时,利用重量规则和高度规则,可以提高并查集算法的性能。
重量规则:若根为i的树的节点数少于根为j的树的节点数,则将j作为i的父节点。否则,将i作为j的父节点。
高度规则:若根为i的树高度小于根为j的树的高度,则将j作为i的父节点。否则,将i作为j的父节点。
为了把重量规则应用到合并算法中,在每个节点增加一个布尔域root。当且仅当每一个节点是当前根节点时,它的root域为true。
每个根节点的parent域用来记录该树的节点总数。
struct unionFindNode{
int parent;
bool root;
unionFindNode()
{parent = 1; root = true;}
};
初始化、查找和合并函数仍然采用先前的程序。
struct unionFindNode {
int parent;
bool root;
unionFindNode()
{
parent = 1; root = true;
}
};
unionFindNode* node;
void initialize(int n) {
node = new unionFindNode[n + 1];
}
int find(int theElement) {
while (!node[theElement].parent)
theElement = node[theElement].parent;
return theElement;
}
void unite(int rootA, int rootB) {
if (node[rootA].parent > node[rootB].parent) {
node[rootA].parent += node[rootB].parent;
node[rootB].parent = rootA;
node[rootB].root = false;
}
else {
node[rootB].parent += node[rootA].parent;
node[rootA].parent = rootB;
node[rootA].root = false;
}
}
之所以采用重量规则是基于如下引理:
引理-1【重力规则引理】:假设从单元素集合出发,用重量规则进行合并操作。若以此方式构建一棵具有p个节点的树t,则树的高度最多为 ⌊ log 2 p ⌋ + 1 lfloorlog_2prfloor+1 ⌊log2p⌋+1。
如果采用高度规则,此结论依然成立。
查找函数的性能改进修改方法是缩短从元素e到根的查找路径。这个方法利用了路径压缩。这个过程的实现至少有三种不同的途径:路径紧缩、路径分割和路径对折。
在路径紧缩中,从待查节点到根节点的路径上,所有节点的parent指针都被改为指向根节点。
int find(int theElement){
// 返回元素theElement所在树的根
// 紧缩从元素theElement到根的路径
// theRoot最终是根
int theRoot = theElement;
while (!node[theRoot].root)
theRoot = theElement;
// 紧缩从theElement到theRoot的路径
int currentNode = theElement;
while(currentNode != theRoot){
int parentNode = node[currentNode].parent;
node[currentNode].parent = theRoot;
currentNode = parentNode;
}
return theRoot;
}
在路径分割中,从e节点到根节点的路径上,除根节点和其子节点之外,每个节点的parent指针都被改为指向各自的祖父。
在路径对折中,从e节点到根节点的路径上,除根节点和其子节点之外,每隔一个节点,其parent指针都被改为指向各自的祖父。
路径压缩的过程不会改变树的重量,但是会改变树的高度,于是乎,路径压缩和高度规则一起使用是很麻烦的。



