前言
数据结构,一门数据处理的艺术,精巧的结构在一个又一个算法下发挥着他们无与伦比的高效和精密之美,在为信息技术打下坚实地基的同时,也令无数开发者和探索者为之着迷。
也因如此,它作为博主大二上学期最重要的必修课出现了。由于大家对于上学期C++系列博文的支持,我打算将这门课的笔记也写作系列博文,既用于整理、消化,也用于同各位交流、展示数据结构的美。
此系列文章,将会分成两条主线,一条“数据结构基础”,一条“数据结构拓展”。“数据结构基础”主要以记录课上内容为主,“拓展”则是以课上内容为基础的更加高深的数据结构或相关应用知识。
欢迎关注博主,一起交流、学习、进步,往期的文章将会放在文末。
这一篇,我们来探讨下二叉树的一个非常有趣的问题:二叉树的遍历序列。
遍历,就是逐个访问二叉树的结点。遍历序列,就是访问节点的次序。
本文我们要讨论的是三种遍历方案:先序遍历,中序遍历,后续遍历。每一种遍历方案都会生成对应的遍历序列,有趣的事情在于我们不仅可以根据二叉树生成遍历序列,还可以根据遍历序列还原整颗二叉树,这是后半部分讨论的重点。
二叉树结构体及功能函数定义
在开始讨论问题之前,我想先来给定一个二叉树及其操作函数的定义和声明,为后文的算法实现做下铺垫。
对于一个二叉树结点:
- 结点值为一个大写字母
- 使用指针保存左右子节点
- 空指针表示无左右子树
结构体和类定义
因此,不难给出二叉树结点的结构体和类定义:
//C
typedef struct _BiTreeNode{
char value;
struct _BiTreeNode * left;
struct _BiTreeNode * right;
}BiTreeNode,*BiTree;
//java
public class BiTree {
static class Node{
char value;
Node left;
Node right;
}
}
功能函数的实现
实际上,对二叉树操作的功能函数有很多。那么下面给定实现的,就只是本文需要的功能:初始化二叉树,插入结点。
初始化二叉树
为了方便表示空二叉树,不妨在创建二叉树时引入一个空的头结点,二叉树真正的根节点为头结点的右子节点。当二叉树为空时,头结点的右子节点为空。
//C
BiTree createBiTree(){
BiTree head = (BiTreeNode*)malloc(sizeof(BiTreeNode));
head->right = NULL;
head->left = NULL;
head->value = '*';
return head;
}
//java
private Node head;
public BiTree() {
head = new Node();
head.right = null;
head.left = null;
head.value = '*';
}
public Node root() {//获得二叉树根
return head.right;
}
public Node head() {//获得二叉树头结点
return head;
}
插入结点
要插入一个节点,我们需要操作者提供父节点值
,新节点值
以及插入位置
(即左节点还是右节点),在函数执行结束后返回一个值代表插入成功与否。
//C
int insert(BiTree node,char faVal,char val,int isLeft){
if(node == NULL){//空节点匹配失败
return 0;
}
if(node->value == faVal){//匹配到父节点
//初始化子节点
BiTreeNode * son = (BiTreeNode*)malloc(sizeof(BiTreeNode));
son->value = val;
son->left = NULL;
son->right = NULL;
//将子节点插入父节点
if(isLeft){
node->left = son;
}else{
node->right = son;
}
}else{
//匹配父节点失败,继续向子树中匹配。左子树插入返回子树的插入结果
return insert(node->left,faVal,val,isLeft) ? 1 : insert(node->right,faVal,val,isLeft);
}
}
//java
public boolean insert(char faVal,char val,boolean isLeft,Node node) {
if(node == null) {//空节点插入失败
return false;
}
if(node.value == faVal) {//匹配到父节点
//创建子节点
Node son = new Node();
son.value = val;
son.left = null;
son.right = null;
//插入父节点左节点或右节点
if(isLeft) {
node.left = son;
}else {
node.right = son;
}
return true;//结点插入成功
}else {//未匹配到父节点,继续向子树中匹配
//查看左子树插入是否成功,否则尝试插入右子树
return insert(faVal,val,isLeft,node.left) ? true : insert(faVal,val,isLeft,node.right);
}
}
二叉树的三种遍历
ok下面让我们进入正题,来实现二叉树的三种遍历。
他们分别是:先序遍历,中序遍历,后续遍历。
不论是三种中的哪种遍历,都有一个相同的约定,即对于一个节点来说,左子树一定先于右子树进入遍历序列。在这种约定之后,三种遍历的区别就在于父节点出现的位置。
具体些,假设当前结点为 k k k,就是:
- 先序遍历先遍历 k k k,再遍历 l e f t ( k ) left(k) left(k),然后遍历 r i g h t ( k ) right(k) right(k)
- 中序遍历先遍历 l e f t ( k ) left(k) left(k),再遍历 k k k,最后遍历 r i g h t ( k ) right(k) right(k)
- 后序遍历先遍历 l e f t ( k ) left(k) left(k),再遍历 r i g h t ( k ) right(k) right(k),最后遍历 k k k
光看概念也许会有些眼花缭乱,用一个图像形象的表示一下就是:
以按照遍历顺序打印结点值为例,我们会惊奇的发现三种遍历操作的实现是如此的简单和对称。
//C
void preOrder(BiTree node){//先序遍历
if(node == NULL){
return;
}
printf("%c ",node->value);//当前结点的值
preOrder(node->left); //遍历左子树
preOrder(node->right); //遍历右子树
}
void inOrder(BiTree node){//中序遍历
if(node == NULL){
return;
}
inOrder(node->left); //遍历左子树
printf("%c ",node->value);//当前结点的值
inOrder(node->right); //遍历右子树
}
void postOrder(BiTree node){//后续遍历
if(node == NULL){
return;
}
postOrder(node->left); //遍历左子树
postOrder(node->right); //遍历右子树
printf("%c ",node->value);//当前结点的值
}
//java
public void preOrder(Node node){//先序遍历
if(node == null){
return;
}
System.out.print(node.value + " ");//当前结点的值
preOrder(node.left); //遍历左子树
preOrder(node.right); //遍历右子树
}
public void inOrder(Node node){//中序遍历
if(node == null){
return;
}
inOrder(node.left); //遍历左子树
System.out.print(node.value + " ");//当前结点的值
inOrder(node.right); //遍历右子树
}
public void postOrder(Node node){//后续遍历
if(node == null){
return;
}
postOrder(node.left); //遍历左子树
postOrder(node.right); //遍历右子树
System.out.print(node.value + " ");//当前结点的值
}
下面我们来试试如下的二叉树:
运行结果如下:
二叉树遍历序列的还原
好的下面我们来讨论一个有趣的问题:如何使用二叉树的遍历序列恢复一颗二叉树。
首先需要讨论一个问题:最少需要二叉树的什么序列可以恢复一颗二叉树,一个序列可以吗?
回答是:至少需要其中的两个序列,一个序列是不足以确定一个二叉树的
举个例子,假如有中序序列: B A C BAC BAC
则它至少可以是以下的两种:
其他两种的序列也面临着同样的问题。
事实上,计算一种遍历序列对应的不同二叉树种类也是个有趣的题目,这个问题有空我们再进一步探讨。
OK,既然确定一颗二叉树至少需要三种遍历序列中的两种,那么下面我们就来讨论这 C 3 2 = 3 C^2_3 = 3 C32=3种情况。
利用中遍历序列还原二叉树
使用遍历序列还原二叉树,明确三种遍历方案生成的序列的性质是关键
根据上文的叙述,对于三种遍历方案形成的序列,我们可以用如下的图示:
由于前中后的次序是递归定义的,所以k的子树也有同样的性质,所以进一步,有:
(
l
k
lk
lk和
r
k
rk
rk分别为
k
k
k的左子树和右子树的根节点)
从上图我们至少可以得到如下信息:
- 先序遍历中,左子树根结点在该节点的右侧,但不知道左右子树规模及右子树根
- 后序遍历中,右子树根节点在该节点的左侧,但不知道左右子树规模及左子树根
- 中序遍历中,知道左右子树规模,但不知道左右子树根
- 在所有遍历序列中,我们知道该子树的规模
充分了解上面的信息是我们解决问题的关键!!!
解决问题的关键思想就是“取长补短”
下面的算法实现,我们都用上文的例子,只不过这次是从遍历序列下手生成二叉树:
前
序
遍
历
:
A
B
D
G
C
E
F
H
中
序
遍
历
:
D
G
B
A
E
C
H
F
后
序
遍
历
:
G
D
B
E
H
F
C
A
前序遍历: A B D G C E F H\\ 中序遍历: D G B A E C H F\\ 后序遍历: G D B E H F C A
前序遍历:ABDGCEFH中序遍历:DGBAECHF后序遍历:GDBEHFCA
先序序列&中序序列
有了上面的铺垫,下面的解决思路就变得清晰了。我们可以给出使用先序序列和中序序列来构建二叉树的算法。
- 整棵树的根为先序遍历序列中的第一个元素,区间为整个序列
- 对于根为 k k k的子树:
-
- 在先序序列中找到区间的第一个元素为当前子树的
根
- 在先序序列中找到区间的第一个元素为当前子树的
-
- 在中序序列中找到
根
所在位置,确定左右子树的规模
- 在中序序列中找到
-
- 在先序序列中找到
左子树根
,根据左子树规模找到右子树的根
- 在先序序列中找到
-
- 前序序列中,
左子树序列
为左子树根到右子树根的左开右闭区间,右子树序列
为右子树根到区间右端点
- 前序序列中,
-
- 中序序列中,
左子树序列
为区间左端点到根节点左闭右开区间,右子树序列
为根节点到区间右端点的左开右闭区间
- 中序序列中,
- 递归如上过程,直到区间仅剩下一个元素,其为叶子节点。
以上过程,墙裂建议读者用一组数据亲手操作一下。过程会有些绕,但是结论是很漂亮的。
根据上面的过程,我们尝试着给出实现的算法,该算法要求给出先序序列和中序序列
//C
int inoMap[256];//中序序列中元素的下标映射,为了便于找出根在中序序列中的位置
/*根据前序序列和中序序列生成树*/
/*树,前序序列,中序序列,前序序列左右端点,中序序列左右端点*/
void preAndIno(BiTree tree,char * pre,char * ino,int preL,int preR,int inoL,int inoR){
char root = pre[preL]; //找到根节点
int rootIdx = inoMap[root]; //根节点在中序序列中位置
int sizeL = rootIdx - inoL; //左子树规模
int sizeR = inoR - rootIdx; //右子树规模
if(sizeL){//该节点有左子树
char lRoot = pre[preL + 1];//左子树根在根节点右边
insert(tree,root,lRoot,1); //插入该节点
preAndIno(tree,pre,ino,preL + 1,preL + sizeL,inoL,rootIdx - 1);//构建左子树
}
if(sizeR){//该节点有右子树
char rRoot = pre[preL + sizeL + 1];//右子树根和根节点之间隔着一个左子树序列
insert(tree,root,rRoot,0); //插入该节点
preAndIno(tree,pre,ino,preL + 1 + sizeL,preR,rootIdx + 1,inoR);//构建右子树
}
}
BiTree getBiTreeFromPreorderAndInorder(char * pre,char * ino,int n){
BiTree tree = createBiTree();
insert(tree,'*',pre[0],0);//建立根节点
for(int i = 0;i < n;i++){//构建中序序列中元下标的映射
inoMap[ino[i]] = i;
}
preAndIno(tree,pre,ino,0,n - 1,0,n - 1);//构建树
return tree;
}
//java
static int[] inoMap = new int[256];
public static BiTree getBiTreeFromPreOrderAndInOrder(char[] pre,char[] ino,int n) {
BiTree tree = new BiTree();
for(int i = 0;i < n;i++) {
inoMap[ino[i]] = i;
}
tree.insert('*', pre[0], false, tree.head());
preAndIno(tree,pre,ino,0,n - 1,0,n - 1);
return tree;
}
private static void preAndIno(BiTree tree,char[] pre,char[] ino,int preL,int preR,int inoL,int inoR){
char root = pre[preL]; //找到根节点
int rootIdx = inoMap[root]; //根节点在中序序列中位置
int sizeL = rootIdx - inoL; //左子树规模
int sizeR = inoR - rootIdx; //右子树规模
if(sizeL != 0){//该节点有左子树
char lRoot = pre[preL + 1];//左子树根在根节点右边
tree.insert(root,lRoot,true,tree.head); //插入该节点
preAndIno(tree,pre,ino,preL + 1,preL + sizeL,inoL,rootIdx - 1);//构建左子树
}
if(sizeR != 0){//该节点有右子树
char rRoot = pre[preL + sizeL + 1];//右子树根和根节点之间隔着一个左子树序列
tree.insert(root,rRoot,false,tree.head); //插入该节点
preAndIno(tree,pre,ino,preL + 1 + sizeL,preR,rootIdx + 1,inoR);//构建右子树
}
}
为了检验结果,我们输出其后序遍历:
后序序列&中序序列
其实有了先序序列和中序序列的经验,将先序序列换成后序序列并没有什么区别。
后续序列能够提供每个根节点的右子树根,这是使用后续序列的关键。
具体的过程和前序序列差不多,但是前后会反过来:
- 整棵树的根为后序遍历序列中的最后一个元素,区间为整个序列
- 对于根为 k k k的子树:
-
- 在后序列中找到区间的最后一个元素为当前子树的
根
- 在后序列中找到区间的最后一个元素为当前子树的
-
- 在中序序列中找到
根
所在位置,确定左右子树的规模
- 在中序序列中找到
-
- 在后序序列中找到
右子树根
,根据右子树规模找到左子树的根
- 在后序序列中找到
-
- 后序序列中,
右子树序列
为右子树根到左子树根的左开右闭区间,左子树序列
为左子树根到区间左端点
- 后序序列中,
-
- 中序序列中,
左子树序列
为区间左端点到根节点左闭右开区间,右子树序列
为根节点到区间右端点的左开右闭区间
- 中序序列中,
- 递归如上过程,直到区间仅剩下一个元素,其为叶子节点。
//C
void postAndIno(BiTree tree,char * post,char * ino,int postL,int postR,int inoL,int inoR){
char root = post[postR]; //ÕÒµ½¸ù½Úµã
int rootIdx = inoMap[root]; //¸ù½ÚµãÔÚÖÐÐòÐòÁÐÖÐλÖÃ
int sizeL = rootIdx - inoL; //×ó×ÓÊ÷¹æÄ£
int sizeR = inoR - rootIdx; //ÓÒ×ÓÊ÷¹æÄ£
if(sizeL){//¸Ã½ÚµãÓÐ×ó×ÓÊ÷
char lRoot = post[postR - 1 - sizeR];//×ó×ÓÊ÷¸ùºÍ¸ù½ÚµãÖ®¸ô×ÅÒ»¸öÓÒ×ÓÊ÷ÐòÁÐ
insert(tree,root,lRoot,1); //²åÈë¸Ã½Úµã
postAndIno(tree,post,ino,postL,postR - 1 - sizeR,inoL,rootIdx - 1);//¹¹½¨×ó×ÓÊ÷
}
if(sizeR){//¸Ã½ÚµãÓÐÓÒ×ÓÊ÷
char rRoot = post[postR - 1];//ÓÒ×ÓÊ÷¸ùÔÚ¸ù½ÚµãÇ°Ãæ
insert(tree,root,rRoot,0); //²åÈë¸Ã½Úµã
postAndIno(tree,post,ino,postR - sizeR,postR - 1,rootIdx + 1,inoR);//¹¹½¨ÓÒ×ÓÊ÷
}
}
BiTree getBiTreeFromPostorderAndInorder(char * post,char * ino,int n){
BiTree tree = createBiTree();
insert(tree,'*',post[n - 1],0);//½¨Á¢¸ù½Úµã
for(int i = 0;i < n;i++){//¹¹½¨ÖÐÐòÐòÁÐÖÐԪϱêµÄÓ³Éä
inoMap[ino[i]] = i;
}
postAndIno(tree,post,ino,0,n - 1,0,n - 1);//¹¹½¨Ê÷
return tree;
}
//java
private static void postAndIno(BiTree tree,char[] post,char[] ino,int postL,int postR,int inoL,int inoR){
char root = post[postR]; //ÕÒµ½¸ù½Úµã
int rootIdx = inoMap[root]; //¸ù½ÚµãÔÚÖÐÐòÐòÁÐÖÐλÖÃ
int sizeL = rootIdx - inoL; //×ó×ÓÊ÷¹æÄ£
int sizeR = inoR - rootIdx; //ÓÒ×ÓÊ÷¹æÄ£
if(sizeL != 0){//¸Ã½ÚµãÓÐ×ó×ÓÊ÷
char lRoot = post[postR - 1 - sizeR];//×ó×ÓÊ÷¸ùºÍ¸ù½ÚµãÖ®¸ô×ÅÒ»¸öÓÒ×ÓÊ÷ÐòÁÐ
tree.insert(root,lRoot,true,tree.head()); //²åÈë¸Ã½Úµã
postAndIno(tree,post,ino,postL,postR - 1 - sizeR,inoL,rootIdx - 1);//¹¹½¨×ó×ÓÊ÷
}
if(sizeR != 0){//¸Ã½ÚµãÓÐÓÒ×ÓÊ÷
char rRoot = post[postR - 1];//ÓÒ×ÓÊ÷¸ùÔÚ¸ù½ÚµãÇ°Ãæ
tree.insert(root,rRoot,false,tree.head()); //²åÈë¸Ã½Úµã
postAndIno(tree,post,ino,postR - sizeR,postR - 1,rootIdx + 1,inoR);//¹¹½¨ÓÒ×ÓÊ÷
}
}
public static BiTree getBiTreeFromPostorderAndInorder(char[] post,char[] ino,int n){
BiTree tree = new BiTree();
tree.insert('*',post[n - 1],false,tree.head());//½¨Á¢¸ù½Úµã
for(int i = 0;i < n;i++){//¹¹½¨ÖÐÐòÐòÁÐÖÐԪϱêµÄÓ³Éä
inoMap[ino[i]] = i;
}
postAndIno(tree,post,ino,0,n - 1,0,n - 1);//¹¹½¨Ê÷
return tree;
}
使用后续序列和中序序列还原二叉树有相关练习题:戳我
题解在这里:戳我
先序序列&后序序列
看见前面两种组合都可以完美的复原出原二叉树。于是很容易顺势认为使用先序序列和后序序列也可以复原出一个二叉树。
然而不幸的是,这个想法是错误的!!根据先序序列和后序序列不能唯一确定一颗二叉树!!!!
其实只要我们举出一个两棵树生成了相同的先序和后续的序列的反例即可:
比如对于如下的树:
其先序序列和后序序列都是:
先
序
序
列
:
A
B
C
后
序
序
列
:
C
B
A
先序序列:ABC\\ 后序序列:CBA
先序序列:ABC后序序列:CBA
所以仅根据这两个序列是不能唯一确定并还原一颗二叉树的。
至少,我们不知道一棵树的左右子树的规模情况,于是并不知道其是否存在,所以当左右子树中有其中一个不存在时序列就会存在分歧。
最后,再强调一遍:唯一确定并还原一棵二叉树至少需要包括中序序列在内的两个序列,仅有前序序列和后序序列不能唯一确定一棵二叉树!!!!!
往期博客
- 【数据结构基础】数据结构基础概念
- 【数据结构基础】线性数据结构——线性表概念 及 数组的封装
- 【数据结构基础】线性数据结构——三种链表的总结及封装
- 【数据结构基础】线性数据结构——栈和队列的总结及封装(C和java)
- 【算法与数据结构基础】模式匹配问题与KMP算法
参考资料:
- 《数据结构》(刘大有,杨博等编著)
- 《算法导论》(托马斯·科尔曼等编著)
- 《图解数据结构——使用Java》(胡昭民著)
- OI WiKi