树(二)——二叉树
目录
本章主要讲解内容为:
- 树的非递归遍历算法,两种版本
- 树的扩展前缀以及前缀中缀构建方法
源码
基础知识
一、定义
二叉树的递归定义:二叉树是每个结点最多含有两棵子树的树结构。
二、性质
二叉树的递归定义标识着它具有很多递归性质。
二叉树的遍历、查找、构建、删除、复制和计数等全部可以用递归来实现,详见代码。
三、构建
二叉树的构建方法有:硬编码生成、扩展前缀、前缀结合中缀等。我实现了后两种方法。
四、遍历
二叉树的递归遍历非常简单,参见代码。
主要分析二叉树的非递归遍历,除了书上的版本外,我自己另写了一个版本,称为通用方法,参照了指令及状态机所设计。不同之处:书上的代码仅用一个栈,而通用方法使用两个栈——指令栈和数据栈。
非递归遍历
一、普通方法
(I)前序遍历
递归遍历方法如下(伪代码):
- pre_visit(node)
- {
- if(!node) return
- print node.data
- if(node.left) visit(node.left)
- if(node.right) visit(node.right)
- }
结合(根-左-右)遍历方式,思考递归方法下前序遍历的调用栈情况(步骤):
- 从根结点root开始,访问根结点root并打印,接着访问左孩子lchild1,调用栈层数+1,父调用暂停在第5行
- 访问lchild1,接着访问lchild1的左孩子lchild2并打印,调用栈层数+1,父调用暂停在第5行
- …
- 访问到最左结点lchildn,调用栈层数=n,父调用暂停在第5行始,此时调用栈已经相对较深了
此时的调用栈:
n: visit(lchildn),运行
n-1:visit(lchildn-1),暂停在第5行
…
0: visit(root),暂停在第5行 - 访问完lchildn,没有左孩子了,返回,调用栈出栈
此时的调用栈:
n-1:visit(lchildn-1),运行至第6行,访问右孩子rchild1
…
0: visit(root),暂停在第5行 - 继续(从lchildn到rchild1的访问变化是以lchildn-1为中间媒介的)
此时的调用栈:
n: visit(rchild1),运行
n-1:visit(lchildn-1),运行至第6行,访问右孩子rchild1
…
0: visit(root),暂停在第5行 - 把rchild1看作root,类比步骤1~4
- 运行,直到访问完rchild1
- 继续,出栈
此时的调用栈:
n-2:visit(lchildn-2),运行至第6行,访问右孩子rchild2
…
0: visit(root),暂停在第5行 - 把rchild2看作root,类比步骤1~4
- 运行,直到访问完rchild2
- 继续,出栈
- …
- 直到调用栈为空
由此可以看出调用规律:
- 对给定结点node,visit(node)即是先运行步骤1~4,依最左路径找到node的最左结点left0,并打印路径(因为路径上的结点依次已被访问过,故先打印根结点)
- 访问完最左结点left0,找到其父结点,即是栈顶left1,left1已访问过,故访问left1的右孩子right1
- 访问right1的方法参照调用规律1,是递归的
综上可以写出算法:
template <class T, class N>
void BiTree<T, N>::PreOrderNoRecursion2()
{
stack<N*> s;
N *p = root;
while (p != NULL || !s.empty())
{
while (p != NULL)//遍历到最左结点,同时记录路径,输出路径(根-左=(根->最左)路径)
{
cout << p->data;
s.push(p);
p = p->lchild;
}
if (!s.empty())
{
p = s.top();
s.pop();//访问后弹出最左结点,当前为最左父结点
p = p->rchild;//访问最左父结点的右孩子
}
}
}
(II)中序遍历
递归遍历方法如下(伪代码):
- in_visit(node)
- {
- if(!node) return
- if(node.left) visit(node.left)
- print node.data
- if(node.right) visit(node.right)
- }
结合(左-根-右)遍历方式,思考递归方法下中序遍历的调用栈情况(步骤):
- 从根结点root开始,访问左孩子lchild1(不打印),调用栈层数+1,父调用暂停在第4行
- 访问lchild1,接着访问lchild1的左孩子lchild2(不打印),调用栈层数+1,父调用暂停在第4行
- …
- 访问到最左结点lchildn并打印,调用栈层数=n,父调用暂停在第4行始,此时调用栈已经相对较深了
此时的调用栈:
n: visit(lchildn),运行
n-1:visit(lchildn-1),暂停在第4行
…
0: visit(root),暂停在第4行 - 访问完lchildn,没有左孩子了,返回,调用栈出栈
此时的调用栈:
n-1:visit(lchildn-1),运行至第6行,访问右孩子rchild1
…
0: visit(root),暂停在第4行 - 访问lchildn-1(不打印),继续(从lchildn到rchild1的访问变化是以lchildn-1为中间媒介的,即访问lchildn-1的右子树)
此时的调用栈:
n: visit(rchild1),运行
n-1:visit(lchildn-1),运行至第6行,访问右孩子rchild1
…
0: visit(root),暂停在第4行 - 把rchild1看作root,类比步骤1~4
- 运行,直到访问完rchild1
- 继续,出栈
此时的调用栈:
n-2:visit(lchildn-2),运行至第6行,访问右孩子rchild2
…
0: visit(root),暂停在第4行 - 把rchild2看作root,类比步骤1~4
- 运行,直到访问完rchild2
- 继续,出栈
- …
- 直到调用栈为空
由此可以看出调用规律:(其实与前序遍历有相似之处)
- 对给定结点node,visit(node)即是先运行步骤1~4,依最左路径找到node的最左结点left0
- 访问完最左结点left0并打印,找到其父结点,即是栈顶left1,访问left1的右孩子right1(不打印)
- 访问right1的方法参照调用规律1,是递归的
- 结合规律2和3,可知:遍历完当前结点node后,应该遍历node的父结点(即栈顶)的右子树的最左结点,同样,这也是线索树的遍历顺序
综上可以写出算法:(就输出结果的那一行换了位置,总体逻辑是一样的,说明调用栈的变化规律也是一样的)
template <class T, class N>
void BiTree<T, N>::InOrderNoRecursion2()
{
stack<N*> s;
N *p = root;
while (p != NULL || !s.empty())
{
while (p != NULL)//定位到最左结点
{
s.push(p);
p = p->lchild;
}
if (!s.empty())
{
p = s.top();
cout << p->data;//从最左结点开始访问
s.pop();
p = p->rchild;//访问最左结点(依次)的父结点的右孩子
}
}
}
(III)后序遍历
后序遍历较前序、中序复杂,调用栈的变化规律不同于前序、中序。究其原因,是在输出之前有两次递归调用,因此,无法通过取栈顶知晓遍历的上一个结点(遍历的直接前驱),故必须以一变量来记录上一次访问的结点。
递归遍历方法如下(伪代码):
- post_visit(node)
- {
- if(!node) return
- if(node.left) visit(node.left)
- if(node.right) visit(node.right)
- print node.data
- }
结合(左-根-右)遍历方式,思考递归方法下后序遍历的调用栈情况(步骤):
- 从根结点root开始,访问左孩子lchild1(不打印),调用栈层数+1,父调用暂停在第4行
- 访问lchild1,接着访问lchild1的左孩子lchild2(不打印),调用栈层数+1,父调用暂停在第4行
- …
- 访问到最左结点lchildn并打印,调用栈层数=n,父调用暂停在第4行始,此时调用栈已经相对较深了
此时的调用栈:
n: visit(lchildn),运行
n-1:visit(lchildn-1),暂停在第4行
…
0: visit(root),暂停在第4行 - 访问完lchildn,访问lchildn-1的右孩子lchildn-1_rchild(假设有右孩子),lchildn-1_rchild为root最左父结点的右孩子,假如lchildn-1没有右孩子(右子树),那么lchildn-1的子树已访问完,应该打印lchildn-1
此时的调用栈:
n: visit(lchildn-1_rchild),运行
n-1:visit(lchildn-1),暂停在第5行,访问右孩子
…
0: visit(root),暂停在第4行 - 把lchildn-1_rchild看作root,类比步骤1~4
- 运行,直到访问完lchildn-1_rchild
- 打印lchildn-1_rchild(仅在lchildn-1有右孩子lchildn-1_rchild的情况下)此时的调用栈:
n: visit(lchildn-1_rchild),运行至第6行,打印lchildn-1_rchild
n-1:visit(lchildn-1),暂停在第5行,访问右孩子
…
0: visit(root),暂停在第4行 - 继续,出栈
此时的调用栈:
n-1:visit(lchildn-1),暂停在第6行,访问并打印最左父结点(在打印lchildn-1_rchild及其子结点之后才打印该结点lchildn-1)
…
0: visit(root),暂停在第4行 - 继续,出栈,访问lchildn-2的右结点lchildn-2_rchild此时的调用栈:
n-1:visit(lchildn-2_rchild),运行
n-2:visit(lchildn-2),暂停在第5行
…
0: visit(root),暂停在第4行 - 将lchildn-2_rchild看作root,运行步骤1~4(看步骤6,会发现很相似,都是遍历左结点的右子树)
- …
- 直到调用栈为空
设遍历过程中的前驱(上次遍历结点)为pre,由此可以看出调用规律:
- 对给定结点node,visit(node)即是先运行步骤1~4,依最左路径找到node的最左结点left0
- 访问完最左结点left0并打印,找到其右兄弟,访问右兄弟right0,访问完right0就打印right0。
- 访问完right0后,找到并访问right0的父结点left1,访问完后,打印left1,left1访问完后,访问left2。
- 上述访问右兄弟right0的方法参照调用规律1,是递归的
算法的实现需要解决几个问题:
- 访问完当前结点后,如何找到其右兄弟
- 访问完子树后,如何找到父结点
以后序遍历为基础,结合pre这个前驱变量的特征,可以罗列出pre的指向:
- 有从子结点向父结点的过渡(父子过渡),此时pre=child,解决问题2
- 有从左结点向右结点的过渡(兄弟过渡),此时pre=left,无法解决问题1
要解决问题1,只能通过栈来解决。将要访问的孩子结点索性一次性保存到栈中,由于兄弟结点的遍历顺序是先左再右,故而进栈顺序为先右再左。
那么按照这个方法,处理当前结点时,将其孩子压栈,这是父结点向孩子结点的过渡,是通过栈的,没有借助pre。
因此可以写出基本步骤:
- 处理当前结点时,若孩子未访问过(pre<>孩子,兄弟过渡),就将孩子压栈(先右再左),自身不出栈(出栈后就没办法打印了),不使用pre
- 处理当前结点时,若孩子已访问过(pre=孩子),则打印自身,然后自身出栈,父子过渡,使用pre
现在,问题简化成:
- 什么情况下,将孩子压栈
- 什么情况下,访问本结点
以上两种情况之间互斥。
因而有:
- 压栈情况:孩子未访问过(pre<>lchild && pre<>rchild),且当前有孩子(lchild<>null || rchild<>null)
- 访问情况:除压栈情况以外的情况
综上可以写出算法:
template <class T, class N>
void BiTree<T, N>::PostOrderNoRecursion2()
{
stack<N*> s;
N *cur = root; //当前结点
N *pre = NULL; //前一次访问的结点
s.push(root);
while (!s.empty() && cur)
{
cur = s.top();
if ((cur->lchild == NULL&&cur->rchild == NULL) ||
((pre == cur->lchild || pre == cur->rchild)))
{
//当前为叶子结点或上一次访问为孩子结点,即按左-右-根(孩子-根)顺序,孩子全部访问过,接着访问父结点
cout << cur->data;
s.pop();
pre = cur;
}
else
{
//当前为从上到下访问,孩子没访问过,则孩子入栈
if (cur->rchild != NULL) s.push(cur->rchild);
if (cur->lchild != NULL) s.push(cur->lchild);//左-右-根,入栈顺序为(根)-右-左
}
}
}
二、通用方法
按指令拆解visit方法:
假设数据栈为s,指令栈为sip
- visit(bt)
- {
- //---- ins #0
- if(!bt)return //###return=>s.pop+sip.pop
- //---- ins #1 pre
- if(!bt.left)visit(bt.left) //###call visit(bt.left)=>s.push(bt.left)+sip.push(0)
- //---- ins #2 in
- if(!bt.right)visit(bt.right) //###call visit(bt.right)=>s.push(bt.right)+sip.push(0)
- //---- ins #3 post
- }
设一变量为ins,代表指令所在行,按正常的运行顺序,应是0->1->2->3->end。
如果在某处需要返回,则只需将指令出栈即可。
接下来,我们就可以在ins#0 #1 #2 #3这四处地方写上相应的处理程序。若无返回或者调用,则当前ins自增。
- ins#0,处理空值返回操作,判断数据栈顶是否为空,若为空则数据栈和指令栈都出栈一次。
- ins#1,处理前序遍历操作,打印结点数据,递归访问左结点。
- ins#2,处理中序遍历操作,打印结点数据,递归访问右结点。
- ins#3,处理后序遍历操作,打印结点数据,处理返回操作,数据栈和指令栈都出栈一次。
故算法如下:
template <class T, class N>
void BiTree<T, N>::MainOrderNoRecursion(typename BiTree<T, N>::NoRecursionType type)
{
if (root == NULL) return; //非递归树遍历通用版本,结合状态机指令 stack<N*> s;//结点栈
stack<int> sip;//状态机 s.push(root);
sip.push(0); while (!s.empty() || !sip.empty())
{
N* bt = s.top();//取结点栈顶 switch (sip.top())//取指令栈顶
{
case 0: sip.top()++;
if (bt == NULL)//遍历到NULL,出栈
{
s.pop();
sip.pop();
continue;
} case 1: sip.top()++;
if (type == PREORDER) cout << bt->data;
if (bt->lchild != NULL)
{
s.push(bt->lchild);
sip.push(0);
continue;
} case 2: sip.top()++;
if (type == INORDER) cout << bt->data;
if (bt->rchild != NULL)
{
s.push(bt->rchild);
sip.push(0);
continue;
} case 3:
if (type == POSTORDER) cout << bt->data;
s.pop();
sip.pop();
continue;
} throw "非法IP!";
}
}
树的构建
一、扩展前缀
所谓扩展前缀,顾名思义,必须是前缀编码,扩展就是以“#”代替空结点。
如常见的算术表达式:3+4*5,扩展前缀就是+3##*4##5##。其中?##代表叶子结点。
扩展前缀构建也采用递归调用方式。
将前缀看作[Head] [Left] [Right]三个部分,返回一棵树。[Head]只有一个元素,直接取出来,作为父结点。那两个子结点就从[Left] [Right]这两个前缀中生成,这即是递归调用。
template <class T, class N>
N *BiTree<T, N>::CreateByPre(int& ipre)
{
if (ipre >= (int)pre.size())
throw "输入串错误";
T e = pre[ipre++];
if (e == '\0') return NULL;
if (e == '#') return NULL;
N *bt = New();
bt->data = e;
bt->lchild = CreateByPre(ipre); // 建左子树
bt->rchild = CreateByPre(ipre); // 建右子树
return bt;
}
二、前缀与中缀
把扩展前缀的“#”规则拿掉,那普通的前缀字串就无法生成一棵唯一的树了,究其原因,是无法知晓递归调用的出口,而“#”恰恰是递归的出口。
知道一棵树的前缀和中缀,就能够还原这棵树,条件是树的结点值不能有重复,也就是说,前缀和中缀能够完全确定一棵树,如何证明?
假设前缀为pre,中缀为in,pre和in的长度是n。将其作划分:
- pre=[Head] [Head-Left] [Head-Right]
- in=[Head-Left] [Head] [Head-Right]
现在,假设in中Head的下标为k,则Head-Left中缀的范围就是[0,k-1],Head-Right中缀的范围就是[k+1,n-1]。
这样,经过一轮划分,生成一棵不完全中缀树——父结点为Head,孩子为Head-Left和Head-Right,且此树是唯一的。
接下来,按照同样方法,只不过这次划分的对象是Head-Left和Head-Right,重复直到Head-Left或Head-Right长度为1(即叶子结点)。
现在思想为什么树的结点值不能重复,关键在于在in中寻找Head——如果Head有多个,就不能保证找到了正确的Head。
举例:
- pre = *+xyz
- in = x+y*z
第一次划分后:
- pre = * [+xy] [z]
- in = [x+y] * [z]
第二次划分后:
- pre = * [+ [x] [y]] [z]
- in = [[x] + [y]] * [z]
有优先级(括号)的中缀可以确定一棵二叉树,因此,该方法有效,一般的前缀和中缀可以还原二叉树。
template <class T, class N>
N* BiTree<T, N>::CreateByPreMid(int ipre, int imid, int n)
{
if (n == 0) return NULL;
N *p = New();
p->data = pre[ipre];// 前缀为根-左-右
int i;
for (i = 0; i < n; i++) // 在中序序列中定位根结点
{
if (pre[ipre] == mid[imid + i]) break;
}
if (i == n) throw "前缀和中缀字符不匹配!";
p->lchild = CreateByPreMid(ipre + 1, imid, i);// 建左子树
p->rchild = CreateByPreMid(ipre + i + 1, imid + i + 1, n - i - 1);// 建右子树
return p;
}
总结
二叉树是计算机数据结构当中的核心内容,它本身有着优美的递归性质。
树结构在查找方面有平衡二叉树AVL、红黑树RBT等,在数据压缩方面有哈夫曼树等,在图形学领域有四叉树、八叉树等等,因而,掌握好树结构对于学习计算机算法而言是不可或缺的。
树(二)——二叉树的更多相关文章
- JavaScript树(二) 二叉树搜索
TypeScript方式实现源码 // 二叉树与二叉树搜索 class Node { key; left; right; constructor(key) { this.key = key; this ...
- 树(二叉树 & 二叉搜索树 & 哈夫曼树 & 字典树)
树:n(n>=0)个节点的有限集.有且只有一个root,子树的个数没有限制但互不相交.结点拥有的子树个数就是该结点的度(Degree).度为0的是叶结点,除根结点和叶结点,其他的是内部结点.结点 ...
- AVL树(二)之 C++的实现
概要 上一章通过C语言实现了AVL树,本章将介绍AVL树的C++版本,算法与C语言版本的一样. 目录 1. AVL树的介绍2. AVL树的C++实现3. AVL树的C++测试程序 转载请注明出处:ht ...
- 伸展树(二)之 C++的实现
概要 上一章介绍了伸展树的基本概念,并通过C语言实现了伸展树.本章是伸展树的C++实现,后续再给出Java版本.还是那句老话,它们的原理都一样,择其一了解即可. 目录1. 伸展树的介绍2. 伸展树的C ...
- Java数据结构之树和二叉树
从这里开始将要进行Java数据结构的相关讲解,Are you ready?Let's go~~ Java中的数据结构模型可以分为一下几部分: 1.线性结构 2.树形结构 3.图形或者网状结构 接下来的 ...
- 数据结构与算法系列研究五——树、二叉树、三叉树、平衡排序二叉树AVL
树.二叉树.三叉树.平衡排序二叉树AVL 一.树的定义 树是计算机算法最重要的非线性结构.树中每个数据元素至多有一个直接前驱,但可以有多个直接后继.树是一种以分支关系定义的层次结构. a.树是n ...
- python数据结构之树和二叉树(先序遍历、中序遍历和后序遍历)
python数据结构之树和二叉树(先序遍历.中序遍历和后序遍历) 树 树是\(n\)(\(n\ge 0\))个结点的有限集.在任意一棵非空树中,有且只有一个根结点. 二叉树是有限个元素的集合,该集合或 ...
- 数据结构与算法(C/C++版)【树与二叉树】
第六章<树与二叉树> 树结构是一种非线性存储结构,存储的是具有"一对多"关系的数据元素的集合. 结点: A.B.C等,结点不仅包含数据元素,而且包含指向子树的分支.例如 ...
- K:树、二叉树与森林之间的转换及其相关代码实现
相关介绍: 二叉树是树的一种特殊形态,在二叉树中一个节点至多有左.右两个子节点,而在树中一个节点可以包含任意数目的子节点,对于森林,其是多棵树所组成的一个整体,树与树之间彼此相互独立,互不干扰,但其 ...
- K:树与二叉树
相关介绍: 树(英语:tree)是一种抽象数据类型(ADT)或是作这种抽象数据类型的数据结构,用来模拟具有树状结构性质的数据集合.它是由n(n>0)个有限节点组成的一个具有层次关系的集合.把它 ...
随机推荐
- JQuery + XML作为前后台数据交换格式实践
JQuery + xml作为前后台数据交换 JQuery提供良好的异步加载接口AJAX,可以局部更新页面数据, http://api.jquery.com/category/ajax/ xml作为一种 ...
- JQuery 表单校验插件 validate 使用纪录
JS诞生其中一个目的就是将, 服务器端的校验在客户端提前完成, 以避免用户提交数据后, 后台校验报错的糟糕用户体验. 基于JQuery库的有很多优秀的插件, 其中对于浏览器端表单进行验证的基本功能也有 ...
- 移动web开发框架
纯粹的总结一下移动web开发框架,移动web开发框架有jQuery Mobile .Sencha Touch等等,他们都来源于web开发,是成熟的框架,jQuery Mobile出自于jQuery家族 ...
- chrome浏览器不允许记忆登录账户的方法
autocomplete方法 https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_ ...
- EF CodeFirst 如何通过配置自动创建数据库<当模型改变时>
最近悟出来一个道理,在这儿分享给大家:学历代表你的过去,能力代表你的现在,学习代表你的将来. 十年河东十年河西,莫欺少年穷 学无止境,精益求精 本篇为进阶篇,也是弥补自己之前没搞明白的地方,惭愧 ...
- 写在开始编写Java之前(2)——Java的环境
上回说到Java具有跨系统性的特点 但是每个系统还是有其Java虚拟机,叫做JVM 其中Java运行环境(JRE)中就包括了JVM 假如你只要运行已经编辑好的Java语句,只要下载JRE就行了 但是作 ...
- RabbitMq 应用
RabbitMQ是一个在AMQP基础上完整的,可复用的企业消息系统.它可以用于大型软件系统各个模块之间的高效通信,支持高并发,支持可扩展. 前提是你的RabbitMq服务已搭建好,制作一个winfor ...
- W3cshool之JavaScript基础
1. JavaScript 对大小写敏感 名为 "myfunction"的函数和名为 "myFunction" 的函数是两个不同的函数,同样,变量 & ...
- Chart 绘制,自带动画效果
package com.example.canvasdemo; import android.annotation.SuppressLint; import android.content.Conte ...
- android实现通过浏览器点击链接打开本地应用(APP)并拿到浏览器传递的数据
为了实现这个功能可折腾了我好久,先上一份代码,经楼主验证是绝对可以用的而且也比较清晰的代码!(ps:还是先剧透下吧,第三方大部分浏览器无法成功.) 点击浏览器中的URL链接,启动特定的App. 首先做 ...