文章目录
树
截止目前,我们已经知道了线性数据结构(数组、链表、栈、队列)和表数据结构(哈希表),但是这些数据结构不适合具有层次结构的数据。
定义-1一棵树t是一个非空的有限元素的集合,其中一个元素为根,其余的元素组成t的子树。
另外,还需要了解的专有名词有:孩子节点、双亲节点、兄弟节点、祖先节点、子孙节点、内部节点、外部节点。
在这本书中,对深度、高度、级的定义是一致的,也都是从1开始。详细定义参考这里面的定义。
一个元素的度指的是其孩子的个数,比如叶节点的度就是0.一棵树的度是其元素的度的最大值。
二叉树
定义-2一棵二叉树t是有限个元素的集合(可以为空)。当二叉树为非空时,其中有一个元素为根,余下元素被划分成两个二叉树,分别称为t的左子树和右子树。
二叉树和树的根本区别是:
- 二叉树的每个元素都恰好有两棵子树,其中一个或两个可能为空。而树的每个元素可有任意数量的子树;
- 在二叉树中,每个元素的子树都是有序的,也就是说,有左子树和右子树之分。而树的子树是无序的。
二叉树的特性
这里的特性是基于根的深度为1,叶节点的高度为1。
特性-1 一棵二叉树有n个元素, n > 0 n\gt0 n>0,它有n-1条边。
特性-2 一棵二叉树的高度为h, h ≥ 0 h\ge0 h≥0,它最少有h个元素,最多有 2 h − 1 2^h-1 2h−1个元素(满二叉树)。
特性-3 一棵二叉树有n个元素, n > 0 n\gt0 n>0,它的高度最大为n,最小高度为 ⌈ log 2 ( n + 1 ) ⌉ \lceil\log_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 1\le i\le n 1≤i≤n。有以下关系成立:
- 如果 i = 1 i=1 i=1,则该元素为二叉树的根。若 i > 1 i\gt1 i>1,则其父节点的编号为 ⌊ i / 2 ⌋ \lfloor i/2\rfloor ⌊i/2⌋;
- 如果 2 i > n 2i\gt n 2i>n,则该元素无做孩子。否则其左孩子的编号为2i;
- 如果 2 i + 1 > n 2i+1\gt n 2i+1>n,则该元素无右孩子。否则其右孩子的编号为2i+1。
二叉树的描述
数组描述
二叉树的数组表示利用了特性-4,把二叉树看做是缺少了部分元素的完全二叉树。
可以看出这种方法十分的浪费空间,对于有n个元素的二叉树,最多需要 2 n 2^n 2n个空间存储(考虑到数组的0位置,因为4的编号从1开始)。
链表描述
二叉树最常用的表示方法是用指针。每个元素用一个节点表示,节点有两个指针域,分别称为leftChild和rightChild,此外还有一个element域。
实现方式如下所示:
template <class T>
struct binaryTreeNode
{
T element;
binaryTreeNode<T>* leftChild;
binaryTreeNode<T>* rightChild;
// 默认构造函数,元素随着T决定,指针需要避免迷途
binaryTreeNode() { leftChild = rightChild = NULL; }
binaryTreeNode(const T& theElement) { element = theElement; leftChild = rightChild = NULL; }
binaryTreeNode(const T& theElement,
binaryTreeNode<T>* theLeftChild,
binaryTreeNode<T>* theRightChild) :
element(theElement),
leftChild(theLeftChild),
rightChild(theRightChild)
{};
};
二叉树的遍历
分为前序遍历、中序遍历、后序遍历和层次遍历。
// 从根开始,先左后右
template<class T>
void preOrder(binaryTreeNode<T> *t) {
visit(t); // 访问树根
preOrder(t->leftChild); // 访问左子树
preOrder(t->rightChild); // 访问右子树
}
// 从左子树开始,后中再右
template<class T>
void inOrder(binaryTreeNode<T>* t) {
inOrder(t->leftChild);
visit(t);
inOrder(t->rightChild);
}
// 从左子树开始,后右再中
template<class T>
void postOrder(binaryTreeNode<T>* t) {
postOrder(t->leftChild);
postOrder(t->rightChild);
visit(t);
}
// 层次遍历、广度优先
template<class T>
void levelOrder(binaryTreeNode<T>* t) {
std::queue<binaryTreeNode*, deque<binaryTreeNode*>> 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)等共计四种遍历函数。
template <class T>
class 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*
。
linkedBinaryTree
template<class E>
class linkedBinaryTree : public binaryTree<binaryTreeNode<E>> {
public:
linkedBinaryTree() { root = NULL; treeSize = 0; }
~linkedBinaryTree() { erase(); };
bool empty() const { return treeSize == 0; }
int size() const { return treeSize; }
void preOrder(void(*theVisit)(binaryTreeNode<E>*)) { visit = theVisit; preOrder(root); }
void inOrder(void(*theVisit)(binaryTreeNode<E>*)) { visit = theVisit; inOrder(root); }
void postOrder(void(*theVisit)(binaryTreeNode<E>*)) { visit = theVisit; postOrder(root); }
void levelOrder(void(*theVisit)(binaryTreeNode<E>*))) { visit = theVisit; postOrder(root);
void erase() {
postOrder(dispose);
root = NULL;
treeSize = 0;
}
private:
binaryTreeNode<E>* root;
int treeSize;
static void (*visit)(binaryTreeNode<E>*);
static void preOrder(binaryTreeNode<E>* t);
static void inOrder(binaryTreeNode<E>* t);
static void postOrder(binaryTreeNode<E>* t);
static void levelOrder(binaryTreeNode<E>* t);
static void dispose(binaryTreeNode<E>* 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 \lfloor\log_2p\rfloor+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指针都被改为指向各自的祖父。
路径压缩的过程不会改变树的重量,但是会改变树的高度,于是乎,路径压缩和高度规则一起使用是很麻烦的。