表与树的不同在于,表的每一个节点都是一对一的关系,每个节点最多就有一个前向和一个后向节点。而树的话每个节点都最多有一个前向节点,能有多个后向节点。
树的定义方法有多种,数据结构表示方法也很多,但是因为所有的树都能用二叉树来表示,所以这里就只说二叉树的问题。树的一些基本概念,如深度,高等等就忽略了。
本文先讲述一下搜索树的建立,因为搜索树比较简单,建立之后会用递归和非递归的方式叙述一下前中后序遍历,
以下图片如无说明来源的话均来自这里
搜索树
以下c++代码将用于建立二叉树
#include<iostream>
#include<algorithm>
#include "dsexceptions.h"
using namespace std;
template<typename Comparable>//Comparable为节点当前值
class BinarySearchTree{
/*此类描述二叉搜索树的建立,私有成员有:
节点,节点包含当前值以及左右孩子的节点指针,以及默认构造函数
根节点
与公有成员基本一致,但是只通过公有成员调用
公有成员有:
默认构造函数,拷贝构造函数,移动构造函数,析构函数,等于运算符
插入以及删除元素,打印树,查找最大最小值,查找某值是否存在
判断树是否空,以及将树设为空
打印树
*/
private:
struct BinaryNode{
Comparable element;
BinaryNode *left;
BinaryNode *right;
BinaryNode(const Comparable &ele,BinaryNode *lt , BinaryNode *rt )
:element(ele),left(lt),right(rt) {}
BinaryNode(Comparable &&ele,BinaryNode *lt, BinaryNode *rt)
:element(std::move(ele)),left(lt),right(rt) {}
};
BinaryNode *root;
void insert(const Comparable &x,BinaryNode *&N){
if(N==nullptr) N = new BinaryNode(x,NULL,NULL);
else if(x<N->element) insert(x,N->left);
else if(x>N->element) insert(x,N->right);
else return;
}
void remove(const Comparable &x,BinaryNode *&N){
if(N==nullptr) return ;
else if(x<N->element) remove(x,N->left);
else if(x>N->element) remove(x,N->right);
else if(N->left!=NULL&&N->right!=NULL){
N->element = findMin(N->right)->element;
remove(N->element,N->right);
}
else{
BinaryNode *t = N;
N = (N->left==nullptr) ? N->right : N->left;
delete t;
}
}
void printTree(const BinaryNode *N,ostream& out = cout) const{
if(N==nullptr) return;
else{
printTree(N->left,out);
out<<"Element: "<<N->element<<endl;
printTree(N->right,out);
}
}
BinaryNode* findMin(BinaryNode *N) const{
if(N==nullptr) return NULL;
else if(N->left!=NULL) return findMin(N->left);//此处跟标准代码不一致
else return N;
}
BinaryNode* findMax(BinaryNode *N) const{
if(N==NULL) return NULL;
while(N->right!=NULL) N=N->right;
return N;
}
bool contains(const Comparable &x,BinaryNode *N) const {
if(N==NULL) return false;
else if(x<N->element) return contains(x,N->left);
else if(x>N->element) return contains(x,N->right);
else return true;
/* 非递归调用
while(N!=NULL){
if(x<N->element) N = N->left;
else if(x>N->element) N = N->right;
else return true;
}
return false;
*/
}
}
void makeEmpty(BinaryNode *&N){
if(N==nullptr) return;
else if(N->left!=nullptr) makeEmpty(N->left);
else if(N->right!=nullptr) makeEmpty(N->right);
delete N;
N=nullptr;
}
BinaryNode *clone(BinaryNode *N) const{
//cout<<"clone called"<<endl;
if(N==nullptr) return nullptr;
else
return new BinaryNode(N->element,clone(N->left),clone(N->right));
}
public:
BinarySearchTree():root(nullptr){}//构造函数
BinarySearchTree(const BinarySearchTree &N) :root(nullptr){
cout<<"copy called"<<endl;
root = clone(N.root);
}
BinarySearchTree(BinarySearchTree &&N):root(N.root){N.root=nullptr;}
~BinarySearchTree(){
makeEmpty();
}
BinarySearchTree & operator=(const BinarySearchTree &N){
cout<<"operator called"<<endl;
BinarySearchTree copy = N;
std::swap(*this,copy);
return *this;
}
BinarySearchTree & operator=(BinarySearchTree &&N){
//std::swap(*this,N);
std::swap(root,N.root);
//此处不能用swap(*this,N);
//原因应该是这样会重复调用构建函数
//可以重点查一下谷歌
return *this;
}
const Comparable & findMin(){
return findMin(root)->element;
}
const Comparable & findMax(){
return findMax(root)->element;
}
void insert(const Comparable &x){
insert(x,root);
}
void insert(Comparable &&x){
insert(std::move(x),root);
}
void remove(const Comparable &x){
remove(x,root);
}
void printTree(ostream &out=cout){
printTree(root,out);
}
bool contains(const Comparable &x){
return contains(x,root);
}
bool isEmpty() const{return root==nullptr;};
void makeEmpty() {
makeEmpty(root);
}
算法首先定义了树中的节点元素,每个节点包含元素值,以及左右孩子的指针,以及两个默认构造函数。树的节点定义为私有成员,只能通过类的接口调用。
私有成员还包含了插入、删除、查找最值、查找某值。这几个成员在公有部分也有接口,但是两者形参不一致。因为这些操作需要递归调用来操作节点值,所以通过私有成员来声明。并且在声明时插入删除因为需要改变节点值,参数的传入是通过指针引用完成,这样可以修改指针的值。
contains
contains函数是检查树中是否存在某个值,如果存在则返回true,不存在则返回false。搜索树的结构令这种操作十分容易,如果该节点的值与待比较值相等,则直接返回true,如果小于,则向左子树递归调用,如果大于,则向右子树调用。在执行检查前,要判断节点指针是否为空,否则对空指针执行以上操作将发生错误。
调用时执行的递归操作是尾递归,因此可以将其消除,消除尾递归后的写法我也写在代码中了。
findMin 和 findMax
在搜索树中,最小值一定出现在树的最左边,最大值一定出现在树的最右边,所以查找最小值时,可以递归对节点求左子树,直到其左子树为空,查找最大值也类似。由于其是尾递归,所以也可以将其消除,在findMin和findMax函数中,一个用了尾递归,一个用非递归。
要注意一点的是,在非递归调用的时候,函数中的代码改变了指针的值(并不是指针指向的值),由于函数使用时用的是指针的拷贝,所以对原指针并没有影响,如果需要在函数中改变指针的值,比如在remove函数中需要删除节点,就需要通过指针引用来传输。
insert
实现插入的过程并不困难,与contains类似,先逐个比较插入元素与树的节点元素,插入元素比节点元素小,则进入左子树,否则进入右子树。当子树的节点元素为空时,则新建节点,该位置即为元素应该插入的位置。
注意如果插入元素在树中已经存在,则结束插入函数,什么也不做。
remove
对树来说,相对最复杂的就是删除元素,寻找要删除的元素的步骤与contains和insert类似,当寻找到需要删除的元素后,需要分情况处理。
当节点为叶子时,也就是左右节点都是空节点时,可以直接删除该节点。
当节点有一个子树时,可以将该节点删除,然后将子树节点放在原来节点的位置。
当节点有两个子树时是相对比较复杂的,通常的做法是查找右子树中最小的元素,将其放在需要删除的节点位置,然后将其从子树中的位置删除。
析构函数和拷贝构造函数
树中调用析构函数需要将每一个节点的指针删除,为此设计了makeEmpty函数,删除当前节点之后,当左右子树不为空时,递归调用来删除左右子树直至所有节点都被删除。
拷贝构造函数需要新建一棵与待拷贝对象一模一样的子树,为此设计了clone函数,如果当前待拷贝的节点不为空,则拷贝节点元素的同时,对树的左右孩子调用clone。因此函数能够一直递归下去,直到叶子节点为止。
最后有一个需要注意的地方是,在代码的140行,右值=运算符的定义中,不能用类似平常的=运算符那样用std::swap(*this,copy),这样定义的话会一直递归调用自身函数,直至崩溃,应该是因为std::swap(*this,copy) 中也包含了右值=运算符的调用。
平衡树
下边开始讲一下平衡树,在搜索树插入的时候,按照插入的元素顺序不同,有可能会让搜索树倾斜到一边,比如可能会出现以下这些情况:
(以上图片来源于这里)
当树失去平衡时,也就失去了其查找速度的优势,一棵好的平衡树每个元素的平均查找次数不超过log(N),如果树完全偏向一边的话,就退化成了链表,所以提出了平衡树的概念
AVL树是比较简单的一种平衡树,它要求每一个节点中的左右子树深度差不超过1,如下图所示,左边的是AVL树,右边的根节点的左右子树深度差超过1,所以不是AVL树
对于AVL树,插入和删除都会有可能会使树失去平衡,因此每次插入删除后都要对经过的节点进行平衡检查,当某个节点的左右子树深度差超过1时,需要一些操作来恢复平衡。
当插入元素后,某个节点A的左右深度差超过1时,只能有以下4个情况
- 1 在A的左子树的左子树插入
- 2 在A的左子树的右子树插入
- 3 在A的右子树的左子树插入
- 4 在A的右子树的右子树插入
其中1和4是镜像问题,2和3是镜像问题,也就是他们的解决方式是一致的,所以用来恢复平衡的操作只有两种。
单旋转
对于问题1,如上图所示,在k1的左子树中插入元素后,k2的左子树深度比右子树大2(图中z和y深度相等,x的深度在插入后比y大1)。要重新恢复平衡,需要把X上移,Z下移,Y不动(Y在此处可以为空,或只有节点,无子树)。为此可以将k1作为根,k1右子树为k2,k1原来的右子树变为k2的左子树即可。
双旋转
对于问题2,不能通过单旋转解决。如图所示,在插入前,B和C不能同时为空,而且不能拥有子树,只能为节点。当在非空那端插入元素后,对于k3节点,其左子树深度比右子树大2,并且插入是在其左子树的右子树中发生的。这种情况不能利用单选择,因为旋转后,D的深度+1,但是A的深度-1,树同样不平衡。
因此只能用k2为根节点,k1和k3分别为其左右子树的节点,B和C挂在k1右端和k3左端。
此操作可以先对k1和k2做一次单旋转,再对k2和k3做一次单旋转完成,因此叫做双旋转。
因为对于AVL树,与搜索树不同在于其插入和删除时需要判断树是否需要旋转而保持平衡,同时需要知道每个节点的深度。因此两者只有insert和remove不同,并且添加了判断平衡和旋转的函数,每个节点也添加了其高度元素。下图是AVL树的一些操作代码
struct AvlNode{
Comparable element;
AvlNode *left;
AvlNode *right;
int height;
AvlNode(const Comparable &ele,AvlNode *lt , AvlNode *rt )
:element(ele),left(lt),right(rt) {}
AvlNode(Comparable &&ele,AvlNode *lt, AvlNode *rt)
:element(std::move(ele)),left(lt),right(rt) {}
};
void insert(const Comparable &x,AvlNode *&N){
if(N==nullptr) N = new AvlNode(x,NULL,NULL);
else if(x<N->element) insert(x,N->left);
else if(x>N->element) insert(x,N->right);
else return;
balance(N);
}
void remove(const Comparable &x,AvlNode *&N){
if(N==nullptr) return ;
else if(x<N->element) remove(x,N->left);
else if(x>N->element) remove(x,N->right);
else if(N->left!=NULL&&N->right!=NULL){
N->element = findMin(N->right)->element;
remove(N->element,N->right);
}
else{
AvlNode *t = N;
N = (N->left==nullptr) ? N->right : N->left;
delete t;
}
balance(N);
}
static const int ALLOWED_IMBALANCE = 1;
void balance( AvlNode * & t )
{
if( t == nullptr )
return;
if( height( t->left ) - height( t->right ) > ALLOWED_IMBALANCE )
if( height( t->left->left ) >= height( t->left->right ) )
rotateWithLeftChild( t );
else
doubleWithLeftChild( t );
else
if( height( t->right ) - height( t->left ) > ALLOWED_IMBALANCE )
if( height( t->right->right ) >= height( t->right->left ) )
rotateWithRightChild( t );
else
doubleWithRightChild( t );
t->height = max( height( t->left ), height( t->right ) ) + 1;
}
int height(AvlNode *t) const{
return t==nullptr ? -1 : t->height;
}
void rotateWithLeftChild(AvlNode *&k2){
AvlNode *k1 = k2->left;
k2->left = k1->right;
k1->right = k2;
k2->height = max(height(k2->left),height(k2->right)) + 1;
k1->height = max(height(k1->left),k2->height) + 1;
k2 = k1;
}
void rotateWithRightChild(AvlNode *&k1){
AvlNode *k2 = k1->right;
k1->right = k2->left;
k2->left = k1;
k1->height = max(height(k1->left),height(k1->right)) + 1;
k2->height = max(height(k2->left),height(k2->right)) + 1;
k1 = k2;
}
void doubleWithLeftChild(AvlNode *&k3){
rotateWithRightChild(k3->left);
rotateWithLeftChild(k3);
}
void doubleWithRightChild(AvlNode *&k3){
rotateWithLeftChild(k3->right);
rotateWithRightChild(k3);
}
代码开始处AvlNode的结构声明中除了节点元素和和左右孩子指针外,还添加了一个高度元素,高度在插入和删除的时候,对所有被访问过的节点都有可能改变,因此设计了balance函数,在插入和删除时对所有节点都进行高度检查。代码59行用于初始化节点高度,对于空节点,高度设为-1。
balance函数用于检查每个节点的左右高度差是否在1以内,如果大于1,则检查高度大的节点,再判断其左右节点的高度来决定用单旋转或双旋转来保持平衡,并且更新节点高度。
此处还有最后一个问题,删除元素时,如果树失衡,失衡时的状态跟插入时是基本类似的,但是有一个例外(可能还有其他例外但是暂时只想到一个),如下方左图所示
如果X和Y此时同深度且深度为2,那删除Z后树失衡,此时用单旋转就可以完成平衡操作,因此在balance中,在比较失衡节点的左右子树深度(42-51行)时,用的是>=,不是>。
树的遍历
树的遍历方式有多种,按照纵向的话有前序,中序,后序遍历,这三种遍历方式都是先访问右子树,然后再访问左子树。这三种遍历用递归方式写起来都十分简单,非递归方式的话需要使用栈。
按照横向的话有层序遍历,这种遍历方式是按照树的每一层,从左到右打印相同层数的节点出来,需要用到队列来处理。
层序遍历
层序遍历用队列来实现的话并不困难,从根节点开始,打印根节点的数值并将其左右儿子放进队列尾部,然后弹出队列首元素,打印数值并将其左右儿子放进队列尾部,一直进行直到队列为空,比如要用层序遍历以下的树,访问顺序如下
- 打印A,将B、C放入队列,访问:A 队列:BC
- 打印B,将D、F放入队列,访问:AB 队列:CDF
- 打印C,将G、I放入队列,访问:ABC 队列: DFGI
- 打印D,访问:ABCD 队列: FGI
- 打印F,将E放入队列,访问:ABCDF,队列:GIE
- 打印G,将H放入队列,访问:ABCDFG,队列:IEH
- 打印I,访问:ABCDFGI,队列:EH
- 打印E,访问ABCDFGIE,队列:H
- 打印H,访问ABCDFGIEH,队列空,结束
层次遍历的代码如下
void LevelOrderTraversal(BinaryNode *N){
queue<BinaryNode*> Q;
Q.push(N);
while(!Q.empty()){
BinaryNode *Node = Q.front();
cout<<Node->element<<endl;
if(Node->left!=NULL) Q.push(Node->left);
if(Node->right!=NULL) Q.push(Node->right);
Q.pop();
}
}
递归方式
二叉树前序遍历指的是先访问节点,再访问左子树,再访问右子树。
中序遍历指的是先访问左子树,再访问节点,再访问右子树。
后序遍历指的是先访问左子树,再访问右子树,再访问节点。
三种遍历的递归方式代码如下:
void PreOrderTraversal(BinaryNode *N){
if(N==nullptr) return;
cout<<N->element<<endl;
PreOrderTraversal(N->left);
PreOrderTraversal(N->right);
}
void InOrderTraversal(BinaryNode *N){
if(N==nullptr) return;
InOrderTraversal(N->left);
cout<<N->element<<endl;
InOrderTraversal(N->right);
}
void PostOrderTraversal(BinaryNode *N){
if(N==nullptr) return;
PostOrderTraversal(N->left);
PostOrderTraversal(N->right);
cout<<N->element<<endl;
}
非递归方式
树的遍历的非递归方式比递归方式复杂,需要利用栈实现。其中前序和中序遍历又比后序遍历要简单,下边代码是前序和中序遍历的实现:
void PreOrderTraversal(BinaryNode *N){
if(N==nullptr) return;
stack<BinaryNode*> s;
while(N!=nullptr||!s.empty()){
while(N!=nullptr){
cout<<N->element<<endl;
s.push(N);
N = N->left;
}
if(!s.empty()){
N = s.top();
s.pop();
N = N->right;
}
}
}
void InOrderTraversal(BinaryNode *N){
if(N==nullptr) return;
stack<BinaryNode*> s;
while(N!=nullptr||!s.empty()){
while(N!=nullptr){
s.push(N);
N = N->left;
}
if(!s.empty()){
N = s.top();
cout<<N->element<<endl;
s.pop();
N = N->right;
}
}
}
前序遍历时,首先声明了一个循环,当节点非空时,不断把左子树节点放入栈里,并访问当前节点。之后弹出栈顶元素并访问右子树,再对右子树作同样操作。
中序遍历时,与前序遍历的方法类似,不同之处在于,访问节点是在弹出栈顶元素后进行的。
后序遍历的非递归模式比前序和中序的要困难。在前序遍历中,访问节点元素是在第一次碰到该节点,并在放入堆栈的同时进行的,中序遍历中是在访问完左子树并且弹出栈顶元素的时候进行的。而后续遍历要求访问完节点的左子树和右子树后再访问节点元素。因此在弹出栈顶元素时,还需要知道是否已经访问过左右子树。而前序和中序遍历的代码并没有提供这个功能,因此需要对代码作一些改动。
以下是后序遍历非递归的代码
void PostOrderTraversal(BinaryNode *N){
if(N==nullptr) return;
stack<BinaryNode*> s;
BinaryNode *pre = NULL;
while(N){
s.push(N);
N = N->left;
}
while(!s.empty()){
N = s.top();
s.pop();
if(N->right==NULL||pre==N->right){
cout<<N->element<<endl;
pre=N;
}
else if(pre==N->left){
s.push(N);
N = N->right;
while(N){
s.push(N);
N = N->left;
}
}
}
}
代码中在最开始的时候声明了一个pre指针,这个指针用于记录访问过的节点,当某个节点的右节点被访问过时,才会访问该节点。
首先从根节点开始,当左子树不为空,递归把左子树放入堆栈中。
当堆栈不为空,弹出栈顶元素,判断栈顶节点右子树是否被访问空或是否为空,如果是则打印元素,并且将pre设置为当前节点(注意如果节点为叶节点,也就是左右子树都为空,也会被访问)。
否则上一个被访问的节点肯定是左子树(else if可以改成else),此时再次把节点放入堆栈中,进入右子树,对右子树的左子树执行进栈操作,然后重复进行。
总结
本文主要讲解了:
1.利用c++建立二叉搜索树的过程。
2.平衡二叉树的插入、删除过程。
3.二叉树的3种遍历的递归和非递归方式。
关于二叉树还有很多内容,比如堆、红黑树等。限于篇幅以及时间,以后的文章再提及。