文章目录
- 二叉树的基本概念
- 二叉树中的基本术语
- 二叉树的特点
- 特殊的二叉树
- 二叉树的存储结构
- 二叉树的遍历
- 二叉树的常见操作
- 二叉树操作的过程理解
- 复杂度分析
- C和Java中手动实现
- Java中二叉树相关结构的分析
- C++中对于树结构容器的分析
- 本章小结
前面我们已经把顺序表、链表、栈和队列这些比较基础的线性结构做了整理。到了这里,数据结构就不再只是"一个接一个往后排"的线性关系了,而是开始进入一种层次更明显、结构更复杂的数据组织方式,这就是树。
而在树这一类结构中,二叉树又是最基础、最经典的一种。后面我们继续学习堆、二叉搜索树、平衡树、哈夫曼树,甚至图的一些遍历思想时,很多地方都会和二叉树有直接关系。所以说,二叉树这一部分其实是整个数据结构里非常重要的一章。
这一篇就按照前面几篇的写法来整理。主要从二叉树的基本概念、常见性质、存储方式、遍历方式、常见操作、复杂度分析,以及 C 和 Java 中手动造轮子的实现这几个方面来展开。
二叉树的基本概念
树结构和线性表最大的不同就在于,线性表中的元素通常是一对一地往后连接,而树结构中的元素则会形成一种分层关系。
二叉树,顾名思义,就是每个结点最多只有两棵子树的一种树结构。通常我们把这两棵子树分别叫做:
- 左子树
- 右子树
也就是说,在二叉树中,每个结点最多只会有两个孩子结点,不可能出现第三个孩子。
最简单的二叉树结构可以理解成下面这样:
text
A
/ \
B C
/ \ /
D E F
这里:
A是根结点B和C是A的孩子结点D、E、F是更下一层的结点
从逻辑上来看,二叉树具有很强的层次感。也正因为这样,它很适合描述很多带有层级关系的问题。
二叉树中的基本术语
在真正开始写代码之前,二叉树里有几个术语最好先分清楚。不然后面看代码和分析性质的时候,很容易混。
根结点
一棵树最上面的那个结点,通常叫做根结点。根结点没有前驱结点,它是整棵树的起点。
双亲结点与孩子结点
如果某个结点下面连着其它结点,那么这个结点就可以看成这些结点的双亲结点,而下面的结点就是它的孩子结点。
例如在上面那棵树中:
A是B和C的双亲B和C是A的孩子
兄弟结点
有同一个双亲的结点,通常互称兄弟结点。
例如:
B和C是兄弟结点D和E也是兄弟结点
叶子结点
没有孩子结点的结点,通常叫做叶子结点。
例如在上面的例子里:
DEF
都属于叶子结点。
子树
某个结点下面整棵部分结构,可以看成这个结点的一棵子树。
例如:
B为根的那一部分就是A的左子树C为根的那一部分就是A的右子树
结点的度
一个结点拥有孩子的个数,通常就叫这个结点的度。
在二叉树中,结点的度只可能是:
012
因为二叉树每个结点最多只有两个孩子。
树的高度
树的高度可以简单理解成树一共有多少层,或者说从根结点到最深叶子结点所经过的层数。
例如只有一个根结点的树,高度通常可以认为是 1。
二叉树的特点
二叉树虽然也是树,但它和一般树相比有几个非常明显的特点。
每个结点最多只有两个孩子
这是二叉树最根本的限制。也正因为这个限制,二叉树的很多分析和实现都会比普通树更规整一些。
左右子树有区别
这一点非常重要。
在二叉树中,左子树和右子树不能随便交换后还认为是同一棵树。也就是说,即使两边的值一样,只要左右位置不同,这两棵树在结构上就可能不是同一棵树。
例如:
text
A A
/ \
B B
这两棵树显然不是同一棵二叉树。
二叉树可以为空
空树也是二叉树的一种特殊情况。也就是说,一棵二叉树可以一个结点都没有。
这一点在代码里非常常见,因为递归处理二叉树时,空树往往就是最基本的终止条件。
特殊的二叉树
在学习二叉树的过程中,有几种特殊形态特别常见,后面做题和写代码时也经常会碰到。
满二叉树
如果一棵二叉树的每一层结点数都达到最大值,那么这棵树就叫满二叉树。
例如:
text
A
/ \
B C
/ \ / \
D E F G
这棵树就是满二叉树。
它的特点是结构非常规整,所有非叶子结点的度都为 2,所有叶子结点都在同一层。
完全二叉树
完全二叉树是比满二叉树稍微放宽一点的结构。
它的特点是:
- 除了最后一层,其它各层都满
- 最后一层的结点必须从左到右连续出现
完全二叉树在实际中非常常见,比如堆这种结构,本质上就经常用完全二叉树来表示。
斜树
如果一棵二叉树中的结点几乎都只往一边长,比如一直只有左孩子或者一直只有右孩子,那么这种树通常也会被称作左斜树或右斜树。
这种结构虽然也是二叉树,但会让很多操作的效率变差。
二叉树的存储结构
和前面学过的其它结构一样,二叉树也有不同的存储方式。比较常见的主要有两种:
- 顺序存储
- 链式存储
顺序存储
顺序存储一般是用数组来表示二叉树。
这种方式最适合的对象其实不是任意二叉树,而是结构比较规整的二叉树,尤其是完全二叉树。
如果根结点下标从 0 开始,那么通常:
- 某个结点下标为
i - 它的左孩子下标就是
2 * i + 1 - 它的右孩子下标就是
2 * i + 2
如果下标从 1 开始,那么常见关系则是:
- 左孩子下标为
2 * i - 右孩子下标为
2 * i + 1
顺序存储的优点是:
- 通过下标找父子关系很方便
- 很适合完全二叉树
缺点也很明显:
- 对于普通二叉树,尤其是不规则二叉树,空间浪费会比较严重
链式存储
链式存储是最常见的二叉树表示方法。
一个二叉树结点通常会包含:
- 数据域
- 左孩子指针
- 右孩子指针
最常见的结构一般是这样:
text
[data | left | right]
这种写法的好处是:
- 对各种形态的二叉树都比较通用
- 结构直观
- 递归处理起来非常自然
所以在手动实现和算法题中,二叉树大多数时候都是按链式结构来写的。
二叉树的遍历
二叉树和顺序表、链表不一样,它不是单纯一条线,所以访问结点时就会涉及不同的遍历顺序。
遍历这一部分是二叉树里非常核心的内容。很多问题本质上都可以看成"按什么顺序访问结点"。
前序遍历
前序遍历的顺序是:
text
根 -> 左 -> 右
也就是说,先访问根结点,再访问左子树,最后访问右子树。
以上面那棵树为例:
text
A
/ \
B C
/ \ /
D E F
前序遍历结果就是:
text
A B D E C F
中序遍历
中序遍历的顺序是:
text
左 -> 根 -> 右
同样以上面这棵树为例,中序遍历结果就是:
text
D B E A F C
后序遍历
后序遍历的顺序是:
text
左 -> 右 -> 根
对应结果就是:
text
D E B F C A
层序遍历
层序遍历和前中后序不一样,它不是递归定义顺序,而是按层从上往下、从左往右访问。
例如这棵树的层序遍历结果就是:
text
A B C D E F
层序遍历一般需要借助队列来完成。
二叉树的常见操作
学二叉树不能只会画图和写遍历,很多基础操作同样非常重要。尤其是在后面写题时,很多题其实都是这些基础操作的变形。
求结点个数
求二叉树结点个数,本质上可以这样理解:
- 当前树的结点数
- 等于左子树结点数
- 加上右子树结点数
- 再加上根结点自己
如果是空树,那么结点数自然就是 0。
所以这类题最典型的递归思路往往就是:
text
size(root) = size(root->left) + size(root->right) + 1
求叶子结点个数
叶子结点的关键特征就是没有左孩子,也没有右孩子。
所以做这类题时,一般可以这样理解:
- 如果当前结点为空,返回
0 - 如果当前结点左右都为空,返回
1 - 否则继续去左右子树里找叶子
求树的高度
树的高度,本质上就是:
- 左子树高度
- 右子树高度
- 取较大值
- 再加上当前这一层
所以递归公式一般就是:
text
height(root) = max(height(root->left), height(root->right)) + 1
查找某个结点
如果二叉树本身没有特殊顺序,比如它不是二叉搜索树,那么查找某个值时一般还是要老老实实遍历。
也就是说:
- 先看当前结点是不是目标
- 如果不是,就去左子树找
- 左边没找到,再去右子树找
判断完全二叉树
判断完全二叉树在做题时也很常见。
它的核心思路通常是层序遍历:
- 先从上到下、从左到右遍历
- 一旦遇到空结点
- 后面如果又出现非空结点
- 那就不是完全二叉树
这一类问题通常会用队列来做。
二叉树操作的过程理解
二叉树这一章和前面顺序表、链表有点不一样。它的"增删查改"不一定像线性结构那样单独讲得很强,因为普通二叉树本身并没有固定的插入规则和删除规则。真正常见的,反而是创建、遍历、统计和查找。
不过从学习角度来看,这一部分还是可以简单理解一下。
创建结点的过程
如果采用链式结构,那么创建一个二叉树结点时,一般会做下面这些事:
- 开辟一个新结点
- 把数据放进去
- 左右孩子都先置空
例如一个新结点 A 创建以后,通常可以看成:
text
[A | NULL | NULL]
然后后面如果要把它和其它结点连起来,再分别修改它的左孩子和右孩子指针。
建立一棵树的过程
建立一棵二叉树,本质上就是不断创建结点,然后把父子关系连接起来。
例如:
text
A
/ \
B C
要建立这棵树,通常就是:
- 先创建
A - 再创建
B - 再创建
C - 然后让
A->left = B - 再让
A->right = C
所以从本质上说,建树并不是"把值填进去"这么简单,而是要把结构也搭出来。
查找的过程
普通二叉树查找时,一般没有办法像数组那样直接定位,也不能像二叉搜索树那样利用大小规律快速缩小范围。
所以它最常见的过程就是:
- 看当前结点
- 不对就去左边
- 左边还不对再去右边
这其实就是一种树上的遍历过程。
修改的过程
如果已经找到了对应结点,那么修改操作往往并不复杂,直接改结点中的数据域就可以。
真正麻烦的往往不是改,而是"先找到它"。
所以二叉树中的修改,从过程上看,也可以理解成:
- 先查
- 再改
复杂度分析
二叉树的复杂度分析往往和树的形态有关,所以这一部分要有一点整体概念。
遍历
无论是前序、中序、后序还是层序,只要把整棵树完整走一遍,那么每个结点通常都会访问一次。
所以遍历的时间复杂度一般都是:
text
O(n)
这里的 n 表示结点总数。
求结点个数、叶子个数、高度
这些操作本质上也都是把整棵树走一遍,所以时间复杂度通常也都是:
text
O(n)
查找
如果只是普通二叉树查找某个值,那么最坏情况下也可能把整棵树都走完,因此时间复杂度一般也是:
text
O(n)
空间复杂度
如果使用递归,那么空间复杂度通常和递归深度有关。
- 对于比较平衡的树,递归深度大致是
O(logn) - 对于退化成链表的斜树,递归深度最坏可能到
O(n)
所以说,二叉树很多操作的时间复杂度表面上看都是 O(n),但它们在运行过程中的栈深度,往往和树的形态有很大关系。
C和Java中手动实现
这一部分还是和前面几篇一样,不只停留在概念层面,而是自己把二叉树的基础结构和常见操作手动写一遍。这样后面无论是做题还是继续学其它树结构,都会顺很多。
C语言手动实现二叉树
如果用 C 语言来表示二叉树,最常见的写法就是链式结构。
结点结构一般可以写成这样:
c
typedef char BTDataType;
typedef struct BinaryTreeNode
{
BTDataType data;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
这里:
data用来存当前结点数据left指向左孩子right指向右孩子
创建结点
c
BTNode* BuyNode(BTDataType x)
{
BTNode* node = (BTNode*)malloc(sizeof(BTNode));
if(node == NULL)
{
perror("malloc fail");
exit(-1);
}
node->data = x;
node->left = NULL;
node->right = NULL;
return node;
}
手动创建一棵树
c
BTNode* CreateTree()
{
BTNode* A = BuyNode('A');
BTNode* B = BuyNode('B');
BTNode* C = BuyNode('C');
BTNode* D = BuyNode('D');
BTNode* E = BuyNode('E');
BTNode* F = BuyNode('F');
A->left = B;
A->right = C;
B->left = D;
B->right = E;
C->left = F;
return A;
}
前序遍历
c
void PrevOrder(BTNode* root)
{
if(root == NULL)
return;
printf("%c ", root->data);
PrevOrder(root->left);
PrevOrder(root->right);
}
中序遍历
c
void InOrder(BTNode* root)
{
if(root == NULL)
return;
InOrder(root->left);
printf("%c ", root->data);
InOrder(root->right);
}
后序遍历
c
void PostOrder(BTNode* root)
{
if(root == NULL)
return;
PostOrder(root->left);
PostOrder(root->right);
printf("%c ", root->data);
}
求结点个数
c
int TreeSize(BTNode* root)
{
if(root == NULL)
return 0;
return TreeSize(root->left) + TreeSize(root->right) + 1;
}
求叶子结点个数
c
int TreeLeafSize(BTNode* root)
{
if(root == NULL)
return 0;
if(root->left == NULL && root->right == NULL)
return 1;
return TreeLeafSize(root->left) + TreeLeafSize(root->right);
}
求树的高度
c
int TreeHeight(BTNode* root)
{
if(root == NULL)
return 0;
int leftH = TreeHeight(root->left);
int rightH = TreeHeight(root->right);
return leftH > rightH ? leftH + 1 : rightH + 1;
}
查找结点
c
BTNode* TreeFind(BTNode* root, BTDataType x)
{
if(root == NULL)
return NULL;
if(root->data == x)
return root;
BTNode* leftRet = TreeFind(root->left, x);
if(leftRet != NULL)
return leftRet;
return TreeFind(root->right, x);
}
从这一套代码里其实就能看出来,二叉树很多基础操作的思路都很统一:
- 空树是递归终止条件
- 左子树递归
- 右子树递归
- 当前结点根据题目要求做处理
这就是二叉树里最经典的一种递归思维。
Java中手动实现二叉树
Java 中手动实现二叉树时,整体思想和 C 语言一样,只不过不需要自己手动管理释放空间。
结点类一般可以写成这样:
java
class TreeNode {
public char val;
public TreeNode left;
public TreeNode right;
public TreeNode(char val) {
this.val = val;
}
}
然后可以自己封装一个二叉树类:
java
public class MyBinaryTree {
public TreeNode root;
public TreeNode createTree() {
TreeNode A = new TreeNode('A');
TreeNode B = new TreeNode('B');
TreeNode C = new TreeNode('C');
TreeNode D = new TreeNode('D');
TreeNode E = new TreeNode('E');
TreeNode F = new TreeNode('F');
A.left = B;
A.right = C;
B.left = D;
B.right = E;
C.left = F;
return A;
}
}
前序遍历
java
public void preOrder(TreeNode root) {
if (root == null) {
return;
}
System.out.print(root.val + " ");
preOrder(root.left);
preOrder(root.right);
}
中序遍历
java
public void inOrder(TreeNode root) {
if (root == null) {
return;
}
inOrder(root.left);
System.out.print(root.val + " ");
inOrder(root.right);
}
后序遍历
java
public void postOrder(TreeNode root) {
if (root == null) {
return;
}
postOrder(root.left);
postOrder(root.right);
System.out.print(root.val + " ");
}
求结点个数
java
public int size(TreeNode root) {
if (root == null) {
return 0;
}
return size(root.left) + size(root.right) + 1;
}
求叶子结点个数
java
public int getLeafNodeCount(TreeNode root) {
if (root == null) {
return 0;
}
if (root.left == null && root.right == null) {
return 1;
}
return getLeafNodeCount(root.left) + getLeafNodeCount(root.right);
}
求高度
java
public int getHeight(TreeNode root) {
if (root == null) {
return 0;
}
int leftHeight = getHeight(root.left);
int rightHeight = getHeight(root.right);
return Math.max(leftHeight, rightHeight) + 1;
}
查找结点
java
public TreeNode find(TreeNode root, char val) {
if (root == null) {
return null;
}
if (root.val == val) {
return root;
}
TreeNode ret = find(root.left, val);
if (ret != null) {
return ret;
}
return find(root.right, val);
}
如果继续往后写,层序遍历、判断完全二叉树、构建二叉树这些操作也都可以接着扩展。但从基础入门来看,上面这一套已经足够把二叉树最核心的结构和递归处理方式讲清楚了。
Java中二叉树相关结构的分析
Java 标准库里并没有像 ArrayList、LinkedList 这种直接给你一个通用二叉树容器类。
但是这并不代表 Java 里没有树结构。实际上很多常见容器的底层思想都和树有关系。
例如:
TreeMapTreeSet
它们底层通常都会涉及平衡搜索树这一类结构。
也就是说,虽然平时我们未必直接手写一个"通用二叉树容器",但树结构的思想其实在很多库里都已经被用上了。
C++中对于树结构容器的分析
在 C++ STL 中,虽然也没有一个直接叫做 BinaryTree 的通用容器类,但像下面这些关联式容器,底层通常也和树结构关系密切:
setmapmultisetmultimap
从学习角度来看,前面自己手动实现普通二叉树,是为了把树的最基础结构和递归思维先打扎实。后面再去理解这些标准库容器时,就会更容易知道它们为什么能支持有序查找、插入和删除。
本章小结
二叉树是树结构中最基础也最重要的一种。它的每个结点最多只有两个孩子,因此在结构和分析上都比较规整。学习二叉树时,最关键的地方并不只是记住几个名词,而是要真正理解:
- 结点之间的层次关系
- 左右子树的区别
- 前中后序遍历的含义
- 递归在二叉树中的应用方式
本篇主要整理了二叉树的基本概念、常见术语、特殊形态、存储结构、遍历方式、常见操作、复杂度分析,以及 C 和 Java 中手动实现的部分。只要把这一部分的基础打扎实,后面继续学堆、搜索树、平衡树和很多树形算法题时,整体都会顺很多。
好的,那么关于二叉树这一部分就先整理到这里。后面如果继续往下写,其实很自然就可以接到堆,或者接二叉树中的一些典型算法问题。因为到了这里,树这一类结构才算是真正开始展开了。