数据结构:二叉树结构详解

导言#

我们先来看个例子,假设我连续抛一毛、五毛、一块钱的硬币各一个,那么这 3 枚硬币呈现出的状态有多少种可能呢?我们知道抛一枚硬币只有两种可能——证明或反面,也就是说抛硬币这个事件可能会产生两种可能性,所以我们来看:

如果我们把这个过程模拟成一个树,整个树有 8 个叶结点,那么这个事件的 8 种可能性我们就能说明白了。

二叉树的定义#

二叉树 (Binary Tree) 是 n(n ≥ 0) 个结点的有限集合,该集合为空集时称为空二叉树,由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。例如上文作为例子的树结构,由于出现了一个结点有 3 个子树的情况,所以不属于二叉树,而如图所示结构就是二叉树。

对于二叉树来说有以下特点:

  1. 二叉树的每个结点至多有两个子树,也就是说二叉树不允许存在度大于 2 的结点;
  2. 二叉树有左右子树之分,次序不允许颠倒,即使是只有一棵子树也要有左右之分。

因此对于一棵有 3 个结点的二叉树来说,由于需要区分左右,会有以下五种情况。

特殊的二叉树#

斜树#

所有结点都只有左(右)子树的二叉树被称为左(右)斜树,同时这个树结构就是一个线性表,如图所示。

满二叉树#

满二叉树要求所有的分支结点都存在左右子树,并且所有的叶结点都在同一层上,若满二叉树的层数为 n,则结点数量为 2n-1 个结点,子叶只能出现在最后一层,内部结点的度都为 2,如图所示。

完全二叉树#

从定义上来说,完全二叉树是满足若对一棵具有 n 个结点的二叉树按层序编号,如果编号为 i 的结点 (1 ≤ i ≤ n)于同样深度的满二叉树中编号为 i 的结点在二叉树的位置相同的二叉树。这样讲有些繁琐,可以理解为完全二叉树生成结点的顺序必须严格按照从上到下,从左往右的顺序来生成结点,如图所示。

因此我们就不难观察出完全二叉树的特点,完全二叉树的叶结点只能存在于最下两层,其中最下层的叶结点只集中在树结构的左侧,而倒数第二层的叶结点集中于树结构的右侧。当结点的度为 1 时,该结点只能拥有左子树。

二叉树的性质#

性质 内容
性质一 在二叉树的 i 层上至多有 2i-1 个结点(i>=1)
性质二 深度为 k 的二叉树至多有 2k-1 个结点(i>=1)
性质三 对任何一棵二叉树 T,如果其终端结点树为 n0,度为 2 的结点为 n2,则 n0 = n2 + n1
性质四 具有 n 个结点的完全二叉树的深度为 [log2n] + 1 向下取整
性质五 如果有一棵有 n 个结点的完全二叉树(其深度为 [log2n] + 1,向下取整)的结点按层次序编号(从第 1 层到第 [log2n] + 1,向下取整层,每层从左到右),则对任一结点 i(1 <= i <= n)有
1.如果 i = 1,则结点 i 是二叉树的根,无双亲;如果 i > 1,则其双亲是结点 [i / 2],向下取整
2.如果 2i > n 则结点 i 无左孩子,否则其左孩子是结点 2i
3.如果 2i + 1 > n 则结点无右孩子,否则其右孩子是结点 2i + 1

二叉树的存储结构#

顺序存储#

由于二叉树的结点至多为 2,因此这种性质使得二叉树可以使用顺序存储结构来描述,在使用顺序存储结构时我们需要令数组的下标体现结点之间的逻辑关系。我们先来看完全二叉树,如果我们按照从上到下,从左到右的顺序遍历完全二叉树时,顺序是这样的:

那么我们就会发现,设父结点的序号为 k,则子结点的序号会分别为 2k 和 2k + 1,子结点的序号和父结点都是相互对应的,因此我们可以用顺序存储结构来描述,例如如图大顶堆:

用顺序存储结构描述如图所示:

那么对于一般的二叉树呢?我们可以利用完全二叉树的编号来实现,如果在完全二叉树对应的结点是空结点,修改其值为 NULL 即可,例如:

再看个例子,左斜树:

但是我们可以很明显地看到,对于一个斜树,我开辟的空间数远超过实际使用的空间,这样空间就被浪费了,因此顺序存储结构虽然可行,但不合适。

链式存储#

由于二叉树的每个结点最多只能有 2 个子树,因此我们就不需要使用上述的 3 种表示法来做,可以直接设置一个结点具有两个指针域和一个数据域,那么这样建好的链表成为二叉链表。例如:


再看个例子,上述我描述孩子兄弟表示法的树结构,稍加改动就可以把图示改成二叉树:

结构体定义#

Copy Highlighter-hljs
typedef struct BiTNode { ElemType data; //数据域 ChildPtr *lchild,*rchild; //左右孩子的指针域 //可以开个指针域指向双亲,变为三叉链表 }BiTNode, *BiTree;

二叉树的遍历#

递归遍历法#

斐波那契数列#

我们先不急着开始谈二叉树的遍历,而是先回忆一下我们是怎么利用斐波那契数列实现递归的:

代码实现:

Copy Highlighter-hljs
int f(int n) { if (n == 0) return 0; else if (n == 1) return 1else return f(n - 2) + f(n - 1); }

代码很好读,已经不是什么难题了,但是我们并不是一开始就懂得递归是个什么玩意,我们也是通过模拟来深刻理解的。因此下面我们用图示法进行模拟,假设我需要获取第 4 个斐波那契数:

仔细看,我们模拟递归函数调用的过程,和二叉树长得是一模一样啊,那么对于二叉树的操作,我们能否用递归来作些文章?

遍历算法#

由于二叉树的结点使用了递归定义,也就是结点的拥有自己本身作为成员的成员,这就使得遍历算法可以使用递归实现,而且思路很清晰。

Copy Highlighter-hljs
void PreOrderTraverse (BiTree T) { if(T == NULL) return; //cout << T->data << " " ; //前序遍历 PreOrderTraverse (T->lchild); //cout << T->data << " " ; //中序遍历 PreOrderTraverse (T->rchild); //cout << T->data << " " ; //后序遍历 }

可以看到,根据输出语句的位置不同,输出的数据顺序是不一样的,例如如图所示二叉树,3 种顺序的输出顺序为:
前序:先访问根结点,然后先进入左子树前序遍历,再进入右子树前序遍历。

中序:从根结点出发,先进入根结点的左子树中序遍历,然后访问根结点,最后进入右子树中序遍历。

后序:从左到右先叶子后结点的方式进入左、右子树遍历,最后访问根结点。

· 需要注意的是,无论是什么样的遍历顺序,访问结点都是从根结点开始访问,按照从上到下,从左到右的顺序向下挖掘,分为 3 中顺序主要因为我们需要有一些方式来描述递归遍历的结果,让我们可以抽象二叉树的结构,因此我们就按照输出语句放的位置不同而决定是什么序遍历,所以我这边就将 3 中遍历顺序放在一起谈。

层序遍历法#

方法介绍#

顾名思义,层序遍历法就是从第一层(根结点)开始,按照从上到下,从左到右的顺序进行遍历,如图所示。

层序遍历法不仅直观,而且好理解,但是我们要思考,处于同一层的结点存在于不同子树,按照刚才的递归遍历法我们无法和其他子树产生沟通,那该怎么实现?仔细观察,层序遍历就好像从根结点开始,一层一层向下扩散搜索,这就跟我们队列实现迷宫算法非常类似,因为迷宫算法的不同路径也是无关联的,但是我们是用广度优先搜索的思想可以找到最短路径。

算法实现#

Copy Highlighter-hljs
void levelOrder(BiTree t) { BiNnode ptr; queue<BiTree> que_level; //层序结点队列 if (t == NULL) //空树处理 { cout << "NULL"; } que_level.push(t); //根结点入队列 while (!que_level.empty() && que_level.front()) //直至空队列,结束循环 { cout << que_level.front()->data << ' '; if (que_level.front()->left != NULL) //队列头结点是否有左结点 { que_level.push(que_level.front()->left); //左结点入队列 } if (que_level.front()->right != NULL) //队列头结点是否有左结点 { que_level.push(que_level.front()->right); //右结点入队列 } que_level.pop(); //队列头出队列 } }

补充一道关于层序遍历的选择题:

某二叉树的前序和后序遍历序列正好相反,则该二叉树一定是(B)
A、空或只有一个结点
B、高度等于其结点数
C、任一结点无左孩子
D、任一结点无右孩子

建立二叉树#

拓展二叉树#

例如要确定一个二叉树,我们肯定不能只是把结点说明白,还需要把每个结点是否有左右孩子说明白。例如如图所示树结构,我们可以向其中填充结点,使其的所有结点填充完后均具有左右结点,为了表示该结点其实是不存在的,我们需要设置一个标志来表示,例如是“#”,那么这种描述就是拓展二叉树如图所示。

按照前序遍历,输出的结果为“ABD#GE##C#F##”。

建树算法#

对于树来说,遍历是各种操作的基础,我们刚刚是通过递归的方式实现了二叉树的遍历读取,现在我们可以再次搬出递归,使其按照前序遍历的顺序建立二叉树。假设树结构的每一个结点的数据域都是一个字符,先序遍历的顺序已知,算法要求将一个字符序列的元素依次读入建立二叉树。
由于对一个树结构来说,每个结点的左右分支都可以被理解为是一个树结构,例如根结点就拥有左右子树,叶结点可以理解为左右子树都是空树的根结点。因此我们可以通过分治思想,每一次只构建一棵子树的根结点,然后递归建立左右子树,直至读取到“#”终止递归。

Copy Highlighter-hljs
void CreatBiTree(BiTree &T) { char ch; cin >> ch; if(ch == '#') //读取到 NULL 结点 T = NULL; //建立空树,结束递归 else { T = new BiTNode; //生成树的根结点 T->data = ch; CreatBiTree(T->lchild); //创建根结点的左子树 CreatBiTree(T->rchild); //创建根结点的右子树 } }

已知前序、中序遍历建树法#

样例模拟#

假设我有如下遍历序列:

Copy Highlighter-hljs
ABDFGHIEC //前序遍历 FDHGIBEAC //中序遍历

我们来尝试一下用这两个遍历结果反向建立一棵二叉树。首先根据前序遍历的特点,对于一棵树来说,在前序遍历时根结点会被先输出,在中序遍历时根结点会在左子树结点输出完毕之后输出,因此我们可以知道这棵二叉树的根结点的值为 “A”,而在中序遍历中“A”结点又把序列分为了左右子树,分别是“FDHGIBE”和“C”,如图所示。

我的根结点安排明白了,这个时候在我眼里,前序遍历只剩下了“BDFGHIEC”,而对于左子树的中序遍历是“FDHGIBE”,右子树的中序遍历是“C”。
对于二叉树来说,可以看做由两颗子树构成的森林重新组合的树结构,因此在我眼里根据前序遍历的结构,左子树的根结点是“B”,该结点把二叉树分成了左右子树分别是“FDHGI”和“E”,如图所示。

重复上述切片操作,就能够建立一棵二叉树。


我们发现了,反向建树的方式还是渗透了分治法的思想,通过分治把一个序列不断分支成左右子树,知道分治到叶结点。因此我们可以总结出建树的算法思路:在递归过程中,如果当前先序序列的区间为 [idx_f1,idx_f2],中序序列的区间为 [idx_m1,idx_m2],设前序序列的第一个元素在中序序列中的下标为 k,那么左子树的结点个数为 num = (k − idx_m1) 。这样左子树的先序序列区间就是 [idx_f1 + 1, idx_f1 + num],左子树的中序序列区间是 [idx_m1,k − 1];右子树的先序序列区间是 [idx_f1 + num + 1,idx_f1],右子树的中序序列区间是 [k + 1,idx_m2],由于我按照先序序列的顺序安排结点,因此当先序序列的 idx_f1 > idx_f2 时,就是递归的结束条件。

代码实现#

Copy Highlighter-hljs
void createTree(tree& t, int idx_f1, int idx_f2, int idx_m1,int idx_m2) { //front 和 middle 是存储输入的前序、中序序列的数组,为全局变量 int i; t = new treenode; if (idx_f1 > idx_f2) //前序序列已经安排完毕,结束 { t = NULL; return; } t->data = front[idx_f1]; //构建子树的根结点 for (i = idx_m1; i <= idx_m2; i++) { if (middle[i] == front[idx_f1]) //查找到在中序序列中的对应位置 { break; } } //递归分治,将子树对应的前序、中序序列传入递归函数 createTree(t->left, idx_f1 + 1, idx_f1 + i - idx_m1, idx_m1, i - 1); createTree(t->right, idx_f1 + i - idx_m1 + 1, idx_f2, i + 1, idx_m2); }

已知后序、中序遍历建树法#

样例模拟#

假设我有如下遍历序列:

Copy Highlighter-hljs
2 3 1 5 7 6 4 //后序遍历 1 2 3 4 5 6 7 //中序遍历

后续遍历的最后一个元素是根结点,因此通过根结点“4”在中序序列分成了左、右子树。对于后续遍历也被分为两个序列“2 3 1”和“5 7 6”。

对于左子树来说,根据后序遍历“2 3 1”,他的根结点是“1”,这个结点将中序序列分成了左右子树,分别为右子树“2 3”和一个空左树。

重复上述操作即可还原出二叉树。


过程和前、中序序列建树是很相似的,虽然后序遍历理解起来比前序要复杂一些,因为前序序列你只需要一个一个向下读取。不过我们发现当我们找到根结点在中序序列中的位置之后,后序遍历中左子树与中序遍历左子树位置是对应的,后序遍历中右子树与中序遍历中右子树位置相差一个元素,也就是中序遍历根节点的位置。因此我们可以利用这个特性使用指针来描述数组,就不需要传递那么多描述下标的参数了。

代码实现#

Copy Highlighter-hljs
void createBiTree(BiTree& t, int* back, int* middle, int n) { //back 和 middle 分别是指向后续、中序序列的数组的指针 int num; int* ptr; if (n <= 0) //序列长度小于 0 时,递归结束 { t = NULL; return; } t = new BiNode; ptr = middle; //ptr 指向 middle 的第一个元素 while (*ptr != back[n - 1]) { ptr++; //查找中序序列的根结点 } t->data = *ptr; num = ptr - middle; //左子树的结点数,可以通过这个变量退出右子树结点数 createBiTree(t->left, back, middle, num); //通过指针运算限制传入的数组 createBiTree(t->right, back + num, middle + num + 1, n - num - 1); }

非递归遍历和线索化#

二叉树遍历非递归遍历和线索化

二叉树的其他操作#

复制二叉树#

还是用递归,与创建二叉树类似,先申请一个新结点用于拷贝根结点,然后通过递归依次复制每一个子树的根结点即可实现。

Copy Highlighter-hljs
void CopyBiTree(BiTree &T,BiTree &NewT) { if(T == NULL) //根结点是空树,结束复制 NewT = NULL; return; else { NewT = new BiTNode; NewT->data = T->data; //拷贝根结点 CopyBiTree(T->lchild,NewT->lchild); //拷贝左子树根结点 CopyBiTree(T->rchild,NewT->rchild); //拷贝右子树根结点 } }

获取二叉树的深度#

还是用递归,与创建二叉树类似,利用分治的思想,对于倒数第二层的子树来说深度为 1 或 2,即左右子树是否存在的问题,那么当我从最底层分治回根结点时,二叉树的深度即为左右子树深度较大的数值加 1,最后函数需要返回树的深度。

Copy Highlighter-hljs
int DepthBiTree(BiTree T) { int l_depth,r_depth; if(T == NULL) //若树为空树则表示子树的深度为 0 return 0; else { l_depth = DepthBiTree(T->lchild); //向左子树挖掘深度 r_depth = DepthBiTree(T->rchild); //向右子树挖掘深度 if(l_depth > r_depth) //返回左右子树中的较大层数 return l_depth + 1; else return r_depth + 1; } }

统计二叉树的结点数#

还是用递归,每一个子树的结点数为其左子树和右子树的结点树之和再加上它本身,也就是加 1。

Copy Highlighter-hljs
int NodeCount(BiTree T) { if(T == Tree) return 0; //若为空树,则结点数为 0 else return NodeCount(T->lchild) + NodeCount(T->rchild) + 1; //挖掘左右结点的节点个数 }

哈夫曼树#

左转我的另一篇博客——哈夫曼树与哈夫曼编码

例题:表达式树#

建树算法#

伪代码#

代码实现#

Copy Highlighter-hljs
void InitExpTree(BTree& T, string str) { stack<BTree> forest; //森林栈 stack<char> oper; BTree a_node; BTree num1, num2; int idx = 0; while (str[idx])//搞不定有 ‘#’ 的做法,害 { if (In(str[idx])) { if (oper.empty() || Precede(oper.top(), str[idx]) == '<') //加一个空栈情况 { oper.push(str[idx++]); } else if (Precede(oper.top(), str[idx]) == '>') //这个分支 idx 不用变 { num2 = forest.top(); forest.pop(); num1 = forest.top(); forest.pop(); CreateExpTree(a_node, num1, num2, oper.top()); oper.pop(); forest.push(a_node); } else { oper.pop(); idx++; //别忘了加了 } } else //是数字 { CreateExpTree(a_node, NULL, NULL, str[idx++]); forest.push(a_node); } } while (!oper.empty()) //剩余的符号要记得处理 { num2 = forest.top(); forest.pop(); num1 = forest.top(); forest.pop(); CreateExpTree(a_node, num1, num2, oper.top()); oper.pop(); forest.push(a_node); } T = forest.top(); }

计算表达式树#

伪代码#

代码实现#

Copy Highlighter-hljs
double EvaluateExTree(BTree T) { double num1, num2; if (!In(T->data)) { return T->data - '0'; //别忘了减掉 } num1 = EvaluateExTree(T->lchild); num2 = EvaluateExTree(T->rchild); switch (T->data) { case '+': return num1 + num2; case '-': return num1 - num2; case '*': return num1 * num2; case '/': if (num2 == 0) //别忘了加了 { cout << "divide 0 error!"; exit(0); //别忘了出错之后就啥都不干了 } return num1 / num2; } }

参考资料#

《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构教程》—— 李春葆 主编,清华大学出版社
《数据结构(C语言版|第二版)》—— 严蔚敏 李冬梅 吴伟民 编著,人民邮电出版社

posted @   乌漆WhiteMoon  阅读(8054)  评论(0编辑  收藏  举报
编辑推荐:
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示
CONTENTS