C++竞赛学习-树及二叉树

先介绍树(tree)的概念。

现实中的树是由树根、茎干、树枝、树叶组成的,树的营养是由树根出发、通过茎干与树枝来不断传递,最终到达树叶的。

在数据结构中,树则是用来概括这种传递关系的一种数据结构。

为了简化,数据结构中把树枝分叉处、树叶、树根抽象为结点(node),其中树根抽象为根结点(root),且对一棵树来说最多存在一个根结点;把树叶概括为叶子结点(leaf),且叶子结点不再延伸出新的结点;把茎干和树枝统一抽象为边(edge),且一条边只用来连接两个结点(一个端点一个)。

这样,树就被定义为由若干个结点和若干条边组成的数据结构,且在树中的结点不能被边连接成环。

在数据结构中,一般把根结点置于最上方(与现实中的树恰好相反),然后向下延伸出若干条边到达子结点(child)(从而向下形成子树(subtree)),而子结点又向下延伸出边并连接一些结点…..直至到达叶子结点,看起来就像是把现实中的树颠倒过来的样子。下图展示了三种不同形态的树。

我们并不需要对树的许多理论知识都了如指掌,下面只给出几个比较实用的概念和性质,希望大家能把它们记住,其中性质 ①⑤ 经常被用来出边界数据:

① 树可以没有结点,这种情况下把树称为空树(empty tree)。

② 树的层次(layer)从根结点开始算起,即根结点为第一层,根结点子树的根结点为第二层,以此类推。

③ 把结点的子树棵数称为结点的度(degree),而树中结点的最大的度称为树的度(也称为树的宽度),例如下图中的三棵树的度分别为2、3、5。

④ 由于一条边连接两个结点,且树中不存在环,因此对有n个结点的树,边数一定是n -1。且满足连通、边数等于顶点数减1的结构一定是一棵树。

⑤ 叶子结点被定义为度为 0 的结点,因此当树中只有一个结点(即只有根结点)时,根结点也算作叶子结点。

⑥ 结点的深度(depth)是指从根结点(深度为 1 )开始自顶向下逐层累加至该结点时的深度值;结点的高度(height)是指从最底层叶子结点(高度为 1 )开始自底向上逐层累加至该结点时的高度值。

树的深度是指树中结点的最大深度,树的高度是指树中结点的最大高度。对树而言,深度和高度是相等的,例如上图中的三棵树的深度和高度分别为 4、4、2,但是具体到某个结点来说深度和高度就不一定相等了。

⑦ 多棵树组合在一起称为森林(forest),即森林是若干棵树的集合。

我们对树只要有这些理解就可以了,更需要关心的是下面要介绍的二叉树,这是重点。

首先直接给出二叉树的递归定义:

① 要么二叉树没有根结点,是一棵空树。

② 要么二叉树由根结点、左子树、右子树组成,且左子树和右子树都是二叉树。

那么,什么是递归定义呢?其实递归定义就是用自身来定义自身。

一是递归边界,二是递归式。二叉树中任何个结点的左子树既可以是一棵空树,也可以是一棵有左子树和右子树的二叉树;结点的右子树也既可以是一棵空树,又可以使一棵有左子树和右子树的二叉树,这样直到到递归边界,递归定义结束。

下图是几种形态不同的二叉树∶

我们需要注意区分二叉树与度为 2 的树的区别。

对树来说,结点的子树是不区分左右顺序的,因此度为 2 的树只能说明树中每个结点的子结点个数不超过 2。

而二叉树虽然也满足每个结点的子结点个数不超过 2,但它的左右子树是严格区分的,不能随意交换左子树和右子树的位置,这就是二叉树与度为 2 的树最主要的区别。

下面介绍两种特殊的二叉树。

① 满二叉树:每一层的结点个数都达到了当层能达到的最大结点数。上图中的树 E 即为一棵满二叉树。

② 完全二叉树:除了最下面一层之外,其余层的结点个数都达到了当层能达到的最大结点数,且最下面一层只从左至右连续存在若干结点,而这些连续结点右边的结点全部不存在。上图中的树 DE 均为一棵完全二叉树。

为什么花费这么多篇幅来介绍二叉树的递归定义呢?这是因为应在二叉树的很多算法中都需要直接用到这种递归的定义来实现算法。

因此,大家应能仔细体会一下二叉树的这个递归定义。

最后从二叉树的角度来理解一下几个树的概念:

① 层次:如果把二叉树看成家谱,那么层次就是辈分。如下图所示,如果 E 是自己的位置,那么根结点 A 是爷爷,他的两个儿子就是父亲 B 和伯父 C ,它们是一个层次(辈分)的。由于自己在 E 的位置,因此 D 是兄弟,而伯父 C 的两个儿子 F 和 G 就是堂兄弟,这样 DEFG 就是同一层次(辈分)。

② 孩子结点、父亲结点、兄弟结点、祖先结点、子孙结点:一个结点的子树的根结点称为它的孩子结点,而它称为孩子结点的父亲结点。

与该结点同父亲的结点称为该结点的兄弟结点(同一层次非同父亲的结点称为堂兄弟结点)。

如果存在一条从结点 X 到结点 Y 的从上至下的路径,那么称结点 X 是结点 Y 的祖先结点,结点 Y 是结点 X 的子孙结点。

注意:自己既是自己的祖先结点,也是自己的子孙结点。

例如上图中,B 是 E 的父亲结点, E 是 B 的孩子结点, B 与 C 互为兄弟节点,ABD 都是 D 的祖先结点,D 同时也是 ABD 的子孙结点。

 

一般来说,二叉树使用链表来定义。

和普通链表的区别是,由于二叉树每个结点有两条出边,因此指针域变成了两个—分别指向左子树的根结点地址和右子树的根结点地址。

如果某个子树不存在,则指向 NULL,其他地方和普通链表完全相同,因此又把这种链表叫作二叉链表,其定义方式如下:

struct node { typename data; //数据域 node* lchild; //指向左子树根结点的指针 node* rchild; //指向右子树根结点的指针 };
由于在二叉树建树前根结点不存在,因此其地址一般设为NULL:

node* root = NUL;
而如果需要新建结点(例如往二叉树中插入结点的时候),就可以使用下面的函数:

//生成一个新结点,v 为结点权值 node* newNode(int v) { node* Node=new node; //申请一个 node 型变量的地址空间 Node->data=v; //结点权值为v Node->lchild=Node->rchild=NUL; //初始状态下没有左右孩子 return Node; //返回新建结点的地址 }
二叉树的常用操作有以下几个:二叉树的建立,二叉树结点的查找、修改、插入与删除,其中删除操作对不同性质的二叉树区别比较大,因此不做具体介绍。

在这里我们主要介绍查找、修改、插入、建树的通用思想。

查找操作是指在给定数据域的条件下,在二叉树中找到所有数据域为给定数据域的结点,并将它们的数据域修改为给定的数据域。

需要使用递归来完成查找修改操作。

还记得二叉树的递归定义吗?

其中就包含了二叉树递归的两个重要元素:递归式和递归边界。

在这里,递归式是指对当前结点的左子树和右子树分别递归,递归边界是当前结点为空时到达死胡同。

例如查找修改操作就可以用这样的思路,即先判断当前结点是否是需要香找的结点;如果是,则对其进行修改操作;如果不是,则分别往该结点的左孩子和右孩子递归,直到当前结点为 NULL 为止。

于是就有下面的代码(数据域以 int 型为例,下同):

void search (node* root,int x,int newdata) { if(root==NULL) { return; // 空树,死胡同(递归边界) } if(root->data==x) // 找到数据域为x的结点,把它修改成newdata { root->data = newdata; } search(root->lchild,x,newdata); // 往左子树搜索 x(递归式) search(root->rchild,x,newdata); // 往右子树搜索 x(递归式) }

由于二叉树的形态很多,因此在题目不说明二叉树特点时是很难给出结点插入的具体方法的。

但是又必须认识到,结点的插入位置一般取决于数据域需要在二叉树中存放的位置(这与二叉树本身的性质有关),且对给定的结点来说,它在二叉树中的插入位置只会有一个(如果结点有好几个插入位置,那么题目本身就有不确定性了)。

因此可以得到这样一个结论,即二叉树结点的插入位置就是数据域在二叉树中寻找失败的位置。

而由于这个位置是确定的,因此在递归查找的过程中一定是只根据二叉树的性质来选择左子树或右子树中的一棵子树进行递归,且最后到达空树(死胡同)的地方就是查找失败的地方,也就是结点需要插入的地方。由此可以得到二叉树结点插入的代码:

// insert 函数将在二叉树中插入一个数据域为 x 的新结点 //注意根结点指针 root 要使用引用,否则插入不会成功 void insert(node* &root,int x) { if(root==NUL) //空树,说明查找失败,也即插入位置(递归边界) { root=newNode(x); return; } if((由二叉树的性质,x 应该插在左子树) { insert(root->lchild,x); //往左子树搜索(递归式) } else { insert(root->rchild,x); //往右子树搜索(递归式) } }
在上述代码中,很关键的一点是根结点指针 root 使用了引用 &。

引用的作用在前面已经介绍过,即在函数中修改 root 会直接修改原变量。

这么做的原因是,在 insert 

函数中新建了结点,并把新结点的地址赋给了当层的 root。

如果不使用引用,root = new node 这个语句对 root 的修改就无法作用到原变量(即上一层的 root → lchild 与 root → rchild )上去,也就不能把新结点接到二叉树上面,因此 insert 函数必须加引用。

那么为什么前面的 search 函数不需要加引用呢?这是因为 search 函数中修改的是指针 root 指向的内容,而不是 root 本身,而对指针实现的结点内容的修改是不需要加引用的。

那么,如何判断是否要加引用呢?

一般来说,如果函数中需要新建结点,即对二叉树的结构做出修改,就需要加引用;如果只是修改当前已有结点的内容,或仅仅是遍历树,就不用加引用。至于判断不出来的情况,不妨直接试一下加引用和不加引用的区别再来选择。最后再特别提醒一句,在新建结点之后,务必令新结点的左右指针域为NULL,表示这个新结点暂时没有左右子树。

二叉树的创建其实就是二叉树结点的插入过程,而插入所需要的结点数据域一般都会由题目给出,因此比较常用的写法是把需要插入的数据存储在数组中,然后再将它们使用 insert 函数一个个插入二叉树中,并最终返回根结点的指针 root。

而等大家熟悉之后,可能更方便的写法是直接在建立二叉树的过程中边输入数据边插入结点。代码如下:

//二叉树的建立 node* Create(int data[],int n) { node* root = NULL; //新建空根结点root for(int i=0;i<n;i++) { insert(root,data[i]); //将data[0]~data[n-1]插入二叉树中 } return root; //返回根结点 }

很多同学不理解递归边界中 root=NULL 这样的写法,并且把它当作未经消化的东西去死记,也搞不清到底 *root==NULL 跟 root==NULL 有什么区别。

这些都是由于不清楚二叉树到底是个什么样的存储方式导致的,下面通过下图来说明这一点。

如上图所示,左边概念意义的二叉树在使用二叉链表存储之后形成了箭头右边的图。对每个结点,第一个部分是数据域,数据域后面紧跟两个指针域,用以存放左子树根结点的地址和右子树根结点的地址。

如果某棵子树是空树,那么显然也就不存在根结点,其地址就会是 NULL,表示不存在这个结点。

因此图中 C 的左子树、DEF 的左子树和右子树都为空树,故 C 的左指针域、 DEF 的左指针域与右指针域都为 NULL。

在递归时,总是往左子树根结点和右子树根结点递归。

此时如果子树是空树,那么 root 一定是 NULL,表示这个结点不存在。

而所谓的 *root = NULL 的错误就很显然了,因为 *root 的含义是获取地址 root 指向的空间的内容,但这无法说明地址 root 是否为空,也即无法确定是否存在这个结点,因此*root = NULL 的写法是错误的。

通过上面的讲解,大家需要明白 root =NULL 与 *root=NULL 的区别,也即结点地址为 NULL 与结点内容为 NULL 的区别(也相当于结点不存在与结点存在但没有内容的区别),这在写程序时是非常重要的,因为在二叉链表中一般都是判定结点是否存在,所以一般都是 root==NULL。

对完全二叉树来说,除了采用二叉链表的存储结构外,还可以有更方便的存储方法。对一棵完全二叉树,如果给它的所有结点按从上到下、从左到右的顺序进行编号(从1开始),就会得到类似于下图所示的编号顺序。

 

该结点(记下标为 root )的左子结点的编号 root*2 大于结点总个数 n (想一想为什么不需要判断右子结点?);

判断某个结点是否为空结点的标志为:该结点下标 root 大于结点总个数 n 。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值